mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
fix(mapper): map dropped §3.9.2 Simplified Type-2 room-in-roof (API)
The gov API lodges a §3.9.2 Simplified Type-2 RR (a room-in-roof bounded by continuous common walls) under `room_in_roof_type_2` — gable + common-wall lengths AND heights. The block was undeclared → `from_dict` dropped it → neither the Type-1 nor Detailed path fired → the cascade's Simplified branch billed the WHOLE A_RR shell (12.5√(floor/1.5)) at the Table-18-col-4 default with no gable/common-wall deduction (over-count → under-rate; 7 corpus certs at signed −5.02). Fix: declare `RoomInRoofType2` on rdsap_schema_21_0_0/_21_0_1 + SapRoomInRoof, and build `detailed_surfaces` by MIRRORING the worksheet-validated Summary path (`_map_elmhurst_rir_surface`, is_simplified) rather than back-solving: common wall → L × (0.25 + H) (billed at the main-wall U) gable → L × (0.25 + H) − Σ (H − H_cw)²/2 (RdSAP 10 §3.9.2 + Table 4) The gable correction sums all common walls (exposed/party/sheltered, incl. the H=0 absent-gable negative-area case that deducts from the A_RR residual); a Connected gable sums only the common walls it overtops. The `gable_wall_type_*` code routes the kind (0/1/2/3 = Party/Exposed/Sheltered/ Connected). A raw-L×H prototype scattered; the §3.9.2 quadratic is the missing piece. Validation is cross-mapper parity, NOT a corpus back-solve: `_api_type_2_ surfaces` produces surfaces IDENTICAL to the Summary path on cohort cert 000565 (connected_wall 3.68, gable_wall_external 16.08/27.68, common walls, and the −0.17 absent-gable quadratic), and 000565 is pinned to 1e-4 in the harness — so the API RR fabric is now correct by construction. The remaining type-2 cohort SAP scatter is unrelated per-cert causes (stone walls, secondary fuel), not the RR. Gauges: corpus within-0.5 67.6% → 67.9% (MAE 0.979 → 0.959); /tmp 71.7% → 71.8% (MAE 0.838 → 0.822). Harness 47/47 (000565 unchanged); regression = the 3 pre-existing fails; pyright net-zero (65=65). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
419e340477
commit
6385a0be85
4 changed files with 164 additions and 3 deletions
|
|
@ -3931,12 +3931,88 @@ def _api_build_room_in_roof(
|
|||
# §3.9.1 default RR storey height (2.45 m); the type code routes
|
||||
# the U-value (Exposed → main-wall U, Party → 0.25).
|
||||
rir.detailed_surfaces = _api_type_1_gable_surfaces(type_1)
|
||||
type_2 = getattr(bp_rir, "room_in_roof_type_2", None)
|
||||
if type_2 is not None:
|
||||
rir.detailed_surfaces = _api_type_2_surfaces(type_2)
|
||||
details = getattr(bp_rir, "room_in_roof_details", None)
|
||||
if details is not None:
|
||||
rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat)
|
||||
return rir
|
||||
|
||||
|
||||
def _api_type_2_surfaces(
|
||||
type_2: Any,
|
||||
) -> Optional[List[SapRoomInRoofSurface]]:
|
||||
"""Translate the §3.9.2 Simplified Type 2 block into the per-surface
|
||||
list the cascade's Detailed-RR branch consumes — MIRRORING the
|
||||
worksheet-validated Summary path (`_map_elmhurst_rir_surface`,
|
||||
is_simplified, validated to 1e-4 by cohort cert 000565). Unlike the
|
||||
Type 1 block (gable lengths only, billed raw L × 2.45), Type 2 lodges
|
||||
gable + common-wall lengths AND heights, so the spec's §3.9.2 areas
|
||||
apply:
|
||||
common wall → `L × (0.25 + H)` (billed at uw)
|
||||
gable → `L × (0.25 + H_gable)
|
||||
− Σ_each_common (H_gable − H_common,n)² / 2`
|
||||
The gable correction is taken over ALL common walls for an exposed/
|
||||
party/sheltered gable (the worksheet evaluates it literally, incl. the
|
||||
H_gable=0 absent-gable case → a negative area that deducts from the
|
||||
A_RR residual without billing a physical wall); a Connected gable
|
||||
(Table 4 row 4, U=0) sums only the common walls it overtops, matching
|
||||
the Summary's connected-gable branch. The `gable_wall_type_*` code
|
||||
routes the kind (0 Party / 1 Exposed / 2 Sheltered / 3 Connected) via
|
||||
`_api_type_1_gable_kind`; U-values are left to the cascade (no per-
|
||||
gable U is lodged on the API path)."""
|
||||
cw_heights = [
|
||||
float(h)
|
||||
for length, h in (
|
||||
(type_2.common_wall_length_1, type_2.common_wall_height_1),
|
||||
(type_2.common_wall_length_2, type_2.common_wall_height_2),
|
||||
)
|
||||
if length is not None and h is not None and length > 0 and h > 0
|
||||
]
|
||||
surfaces: List[SapRoomInRoofSurface] = []
|
||||
gable_specs = (
|
||||
(type_2.gable_wall_type_1, type_2.gable_wall_length_1,
|
||||
type_2.gable_wall_height_1),
|
||||
(type_2.gable_wall_type_2, type_2.gable_wall_length_2,
|
||||
type_2.gable_wall_height_2),
|
||||
)
|
||||
for gable_type, length, height in gable_specs:
|
||||
# Length is mandatory; H may be 0 for the §3.9.2 absent-gable
|
||||
# quadratic (only when common walls drive the correction).
|
||||
if length is None or length <= 0 or height is None:
|
||||
continue
|
||||
if height <= 0 and not cw_heights:
|
||||
continue
|
||||
kind = _api_type_1_gable_kind(gable_type)
|
||||
length_m, height_m = float(length), float(height)
|
||||
if cw_heights:
|
||||
if kind == "connected_wall":
|
||||
correction = sum(
|
||||
((height_m - h) ** 2) / 2.0 for h in cw_heights if height_m > h
|
||||
)
|
||||
else:
|
||||
correction = sum(((height_m - h) ** 2) / 2.0 for h in cw_heights)
|
||||
area = _round_half_up_2dp(1.0, length_m * (0.25 + height_m) - correction)
|
||||
else:
|
||||
area = _round_half_up_2dp(length_m, height_m)
|
||||
surfaces.append(SapRoomInRoofSurface(kind=kind, area_m2=area))
|
||||
common_specs = (
|
||||
(type_2.common_wall_length_1, type_2.common_wall_height_1),
|
||||
(type_2.common_wall_length_2, type_2.common_wall_height_2),
|
||||
)
|
||||
for length, height in common_specs:
|
||||
if length is None or height is None or length <= 0 or height <= 0:
|
||||
continue
|
||||
surfaces.append(
|
||||
SapRoomInRoofSurface(
|
||||
kind="common_wall",
|
||||
area_m2=_round_half_up_2dp(float(length), 0.25 + float(height)),
|
||||
)
|
||||
)
|
||||
return surfaces or None
|
||||
|
||||
|
||||
def _api_rir_detailed_surfaces(
|
||||
details: Any,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -2228,3 +2228,47 @@ class TestRoomInRoofDetailedSlopeAndStudWall:
|
|||
assert studs[0].insulation_thickness_mm == 75
|
||||
assert abs(commons[0].area_m2 - 10.32) <= 1e-9
|
||||
assert commons[0].insulation_thickness_mm is None
|
||||
|
||||
|
||||
class TestRoomInRoofType2SimplifiedQuadratic:
|
||||
"""RdSAP 10 §3.9.2 Simplified Type 2 RR — the gov API lodges gable +
|
||||
common-wall lengths AND heights under `room_in_roof_type_2`. The block
|
||||
was undeclared → dropped → the cascade billed the whole A_RR shell at
|
||||
the Table-18-col-4 default (over-count → under-rate, 7 corpus certs at
|
||||
signed −5.02). The mapper now MIRRORS the worksheet-validated Summary
|
||||
§3.9.2 areas (cross-mapper parity, proven identical on cohort cert
|
||||
000565): common walls L×(0.25+H), gables L×(0.25+H) − Σ(H−H_cw)²/2."""
|
||||
|
||||
def test_from_api_response_applies_3_9_2_gable_quadratic(self) -> None:
|
||||
# Arrange — two common walls (L=8, H=1 → cw_heights [1,1]); an
|
||||
# exposed gable (L=10, H=2) and a party gable (L=6, H=2).
|
||||
# common wall = round(8 × (0.25+1)) = 10.00
|
||||
# exposed gable= round(10 × (0.25+2) − 2×(2−1)²/2) = round(22.5−1) = 21.50
|
||||
# party gable = round(6 × (0.25+2) − 1.0) = round(13.5−1) = 12.50
|
||||
cert = load("21_0_1.json")
|
||||
rir = cert["sap_building_parts"][0]["sap_room_in_roof"]
|
||||
rir.pop("room_in_roof_type_1", None)
|
||||
rir["room_in_roof_type_2"] = {
|
||||
"gable_wall_type_1": 1, "gable_wall_length_1": 10.0,
|
||||
"gable_wall_height_1": 2.0,
|
||||
"gable_wall_type_2": 0, "gable_wall_length_2": 6.0,
|
||||
"gable_wall_height_2": 2.0,
|
||||
"common_wall_length_1": 8.0, "common_wall_height_1": 1.0,
|
||||
"common_wall_length_2": 8.0, "common_wall_height_2": 1.0,
|
||||
}
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
rir_part = result.sap_building_parts[0].sap_room_in_roof
|
||||
assert rir_part is not None
|
||||
surfaces = rir_part.detailed_surfaces
|
||||
assert surfaces is not None
|
||||
ext = [s for s in surfaces if s.kind == "gable_wall_external"]
|
||||
party = [s for s in surfaces if s.kind == "gable_wall"]
|
||||
commons = [s for s in surfaces if s.kind == "common_wall"]
|
||||
assert len(ext) == 1 and abs(ext[0].area_m2 - 21.50) <= 1e-9
|
||||
assert len(party) == 1 and abs(party[0].area_m2 - 12.50) <= 1e-9
|
||||
assert len(commons) == 2
|
||||
assert abs(commons[0].area_m2 - 10.00) <= 1e-9
|
||||
|
|
|
|||
|
|
@ -236,12 +236,29 @@ class RoomInRoofDetails:
|
|||
common_wall_height_2: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoomInRoofType2:
|
||||
"""RdSAP §3.9.2 Simplified Type 2 RR — gable + common-wall geometry.
|
||||
See `rdsap_schema_21_0_1.RoomInRoofType2`. Previously dropped."""
|
||||
gable_wall_type_1: Optional[int] = None
|
||||
gable_wall_type_2: Optional[int] = None
|
||||
gable_wall_length_1: Optional[float] = None
|
||||
gable_wall_length_2: Optional[float] = None
|
||||
gable_wall_height_1: Optional[float] = None
|
||||
gable_wall_height_2: Optional[float] = None
|
||||
common_wall_length_1: Optional[float] = None
|
||||
common_wall_length_2: Optional[float] = None
|
||||
common_wall_height_1: Optional[float] = None
|
||||
common_wall_height_2: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapRoomInRoof:
|
||||
"""Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0."""
|
||||
floor_area: Union[int, float]
|
||||
construction_age_band: str
|
||||
room_in_roof_type_1: Optional[RoomInRoofType1] = None
|
||||
room_in_roof_type_2: Optional[RoomInRoofType2] = None
|
||||
room_in_roof_details: Optional[RoomInRoofDetails] = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -278,14 +278,38 @@ class RoomInRoofDetails:
|
|||
common_wall_height_2: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoomInRoofType2:
|
||||
"""RdSAP §3.9.2 Simplified Type 2 RR — a room-in-roof bounded by
|
||||
continuous common walls (accessible common-wall height < 1.8 m, so the
|
||||
space counts as RR not a separate storey). Lodges gable + common-wall
|
||||
lengths AND heights (unlike Type 1, gable lengths only). `gable_wall_
|
||||
type_*` is the Table 4 variant (0 Party / 1 Exposed / 2 Sheltered /
|
||||
3 Connected). Previously undeclared → dropped by `from_dict`, so the
|
||||
cascade billed the whole A_RR shell at the Table-18-col-4 default
|
||||
(over-count → under-rate)."""
|
||||
gable_wall_type_1: Optional[int] = None
|
||||
gable_wall_type_2: Optional[int] = None
|
||||
gable_wall_length_1: Optional[float] = None
|
||||
gable_wall_length_2: Optional[float] = None
|
||||
gable_wall_height_1: Optional[float] = None
|
||||
gable_wall_height_2: Optional[float] = None
|
||||
common_wall_length_1: Optional[float] = None
|
||||
common_wall_length_2: Optional[float] = None
|
||||
common_wall_height_1: Optional[float] = None
|
||||
common_wall_height_2: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapRoomInRoof:
|
||||
floor_area: Union[int, float]
|
||||
construction_age_band: str
|
||||
# Two real-API shapes coexist: older certs (cohort 6035, 0240, test
|
||||
# fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; newer
|
||||
# certs (9501) lodge the Detailed-RR block. Accept both.
|
||||
# Three real-API shapes coexist: older certs (cohort 6035, 0240, test
|
||||
# fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; some lodge
|
||||
# the §3.9.2 Simplified Type 2 wrapper (gable + common-wall geometry);
|
||||
# newer certs (9501) lodge the Detailed-RR block. Accept all three.
|
||||
room_in_roof_type_1: Optional[RoomInRoofType1] = None
|
||||
room_in_roof_type_2: Optional[RoomInRoofType2] = None
|
||||
room_in_roof_details: Optional[RoomInRoofDetails] = None
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue