From 6385a0be85fac32c14d6de238c39573200a7a58b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 15:01:11 +0000 Subject: [PATCH] =?UTF-8?q?fix(mapper):=20map=20dropped=20=C2=A73.9.2=20Si?= =?UTF-8?q?mplified=20Type-2=20room-in-roof=20(API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- datatypes/epc/domain/mapper.py | 76 +++++++++++++++++++ .../domain/tests/test_from_rdsap_schema.py | 44 +++++++++++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 17 +++++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 30 +++++++- 4 files changed, 164 insertions(+), 3 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b3fc944f..b79b51d1 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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, *, diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 054e9864..84795363 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -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 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 4f7e7e40..c9565d9b 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -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 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 37714034..0c6379d9 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -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