From 8861dac694b3afc4b65943cc5192f36f0d4a993d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 10:37:26 +0000 Subject: [PATCH] =?UTF-8?q?S0380.196:=20API=20Simplified=20Type=201=20RR?= =?UTF-8?q?=20gables=20deduct=20from=20A=5FRR=20shell=20(RdSAP=2010=20?= =?UTF-8?q?=C2=A73.9.1(e)=20p.21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golden cert 6035's residual (SAP -2 / PE +19.16 / CO2 +0.42t) was a real API-mapper bug, NOT lodged divergence (prior claim retracted). The API `room_in_roof_type_1` block lodges gable walls by length only (no height). The mapper carried just the scalar `gable_*_length_m`, and the cascade's `_part_geometry` gable formula silently drops height-less gables (needs a height) -> the whole A_RR shell `12.5√(A_RR_floor/1.5)` billed as roof at U_RR=2.30 instead of the §3.9.1(e) residual `A_RR − Σ gables`. On 6035 that over-counted roof by 22.78 m² × 2.30 = +52.4 W/K (roof 130.73 -> 78.33, matching the site-notes case-4 replica at 1e-4 — cross-mapper parity). RdSAP 10 §3.9.1(e) (PDF p.21): "the area of the room-in-roof gable walls ... is deducted from A_RR to give the residual roof area." Fix: route the Type 1 gables through `detailed_surfaces` (gable area = L × the §3.9.1 default RR storey height 2.45 m; gable_wall_type 0=Party->gable_wall U=0.25, 1=Exposed->gable_wall_external "as common wall" per Table 4 p.22) so the cascade's Detailed-RR residual fires — the same path the site-notes mapper already uses. Re-pinned golden residuals: - 6035: SAP -2 -> +0 (exact), PE +19.16 -> +1.84, CO2 +0.42 -> +0.01 - 0240: same fix applies (2 Party gables L=6.4); PE +5.80 -> +3.91, CO2 +0.32 -> +0.22, SAP integer unchanged Also corrected the stale "gable_wall_type 0 = external" schema comment (6035's Summary proves 0=Party, 1=Exposed) and added a strict UnmappedApiCode raise for unknown gable_wall_type codes. Suite: 2342 passed, 1 skipped. New code: 0 pyright errors. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 90 +++++++++++++++++-- datatypes/epc/schema/rdsap_schema_21_0_0.py | 9 +- datatypes/epc/schema/rdsap_schema_21_0_1.py | 9 +- .../rdsap/test_golden_fixtures.py | 70 +++++++++++++-- 4 files changed, 155 insertions(+), 23 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 88a0e188..1f205786 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2710,20 +2710,84 @@ def _api_build_sap_floor_dimensions( return out +# RdSAP 10 §3.9.1 (PDF p.21): a Simplified Type 1 RR gable has no measured +# height — the worksheet applies the default RR storey height of 2.45 m, so +# the gable area is L × 2.45 (cert 6035 Summary lodges H=2.45 explicitly, +# matching this default; gable area 4.65 × 2.45 = 11.39 m²). +_RIR_TYPE_1_GABLE_HEIGHT_M: Final[float] = 2.45 + +# GOV.UK API `room_in_roof_type_1.gable_wall_type_*` integer → the +# `SapRoomInRoofSurface.kind` the cascade's Detailed-RR branch routes by +# U-value. Established from cert 6035's Summary (gable_wall_type_1=1 ↔ +# "Exposed" U=0.29; gable_wall_type_2=0 ↔ "Party" U=0.25): +# 0 = Party → `gable_wall` (Table 4 p.22 row 2, U=0.25) +# 1 = Exposed → `gable_wall_external` (Table 4 p.22 row 1, "as common wall") +_API_TYPE_1_GABLE_TYPE_TO_KIND: Dict[int, str] = { + 0: "gable_wall", + 1: "gable_wall_external", +} + + +def _api_type_1_gable_kind(gable_type: Optional[int]) -> str: + """Map a `gable_wall_type_*` code to the cascade's RR surface kind. + + `None` (type unlodged) defaults to `gable_wall` (party) — the modal + RR gable and the conservative choice (party billing at U=0.25 vs the + main-wall U). A lodged integer outside the known set raises + `UnmappedApiCode` so a new gable variant forces an explicit mapping + rather than silently mis-routing its U-value (mirror of the strict- + raise pattern on the other API helpers).""" + if gable_type is None: + return "gable_wall" + if gable_type not in _API_TYPE_1_GABLE_TYPE_TO_KIND: + raise UnmappedApiCode("gable_wall_type", gable_type) + return _API_TYPE_1_GABLE_TYPE_TO_KIND[gable_type] + + +def _api_type_1_gable_surfaces( + type_1: Any, +) -> Optional[List[SapRoomInRoofSurface]]: + """Translate the Simplified Type 1 scalar gable fields into the + per-surface list the cascade's Detailed-RR branch consumes. + + Gable area = L × the §3.9.1 default RR storey height (2.45 m); the + `gable_wall_type_*` code routes the kind (Exposed vs Party). U-values + are left to the cascade (Exposed falls through to the main-wall U; + Party uses the fixed 0.25). Returns None when no gable length is + lodged so the cascade keeps its existing residual-only behaviour.""" + surfaces: List[SapRoomInRoofSurface] = [] + for gable_type, length in ( + (type_1.gable_wall_type_1, type_1.gable_wall_length_1), + (type_1.gable_wall_type_2, type_1.gable_wall_length_2), + ): + if length is None or length <= 0: + continue + surfaces.append( + SapRoomInRoofSurface( + kind=_api_type_1_gable_kind(gable_type), + area_m2=_round_half_up_2dp( + float(length), _RIR_TYPE_1_GABLE_HEIGHT_M + ), + ) + ) + return surfaces or None + + def _api_build_room_in_roof( bp_rir: Any, *, is_flat: bool = False, ) -> Optional[SapRoomInRoof]: """Build `SapRoomInRoof` from the API schema's per-bp RR block. Two real-API shapes coexist: - `room_in_roof_type_1` (cohort certs 6035, 0240): RdSAP §3.9.1 - Simplified Type 1 — gable lengths only, cascade applies the - 2.45 m default storey height. + Simplified Type 1 — gable lengths only (no heights). Built into + `detailed_surfaces` using the 2.45 m default RR storey height so + the cascade's residual deducts the gables from the A_RR shell. - `room_in_roof_details` (cert 9501): RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-ceiling detail. - When the Detailed block is present, build `detailed_surfaces` so the - cascade's per-surface RR branch (heat_transmission.py:629) picks - up exact gable + flat-ceiling areas instead of falling through to - the Table 18 col(4) "all elements" default U. + For BOTH shapes we build `detailed_surfaces` so the cascade's + per-surface RR branch picks up exact gable + flat-ceiling areas and + the §3.9.1(e) residual roof (A_RR shell − Σ gables), instead of + billing the whole shell at the Table 18 col(4) "all elements" U. """ if bp_rir is None: return None @@ -2733,10 +2797,20 @@ def _api_build_room_in_roof( ) type_1 = getattr(bp_rir, "room_in_roof_type_1", None) if type_1 is not None: - # RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights — - # the cascade applies the 2.45 m default storey height). + # RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights). rir.gable_1_length_m = type_1.gable_wall_length_1 rir.gable_2_length_m = type_1.gable_wall_length_2 + # Route the gables through `detailed_surfaces` so the cascade's + # Detailed-RR residual deducts each gable from the A_RR shell + # (RdSAP 10 §3.9.1(e) p.21: A_RR_final = 12.5√(A_RR_floor/1.5) − + # Σ gables) — the same path the site-notes mapper builds. The + # scalar `gable_*_length_m` fields alone can't trigger this: the + # cascade's `_part_geometry` gable formula needs a height and + # silently drops height-less gables, billing the WHOLE shell as + # roof (a ~52 W/K over-count on cert 6035). Gable area = L × the + # §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) 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) diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 383a4a6e..c8cc6e23 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -175,10 +175,11 @@ class SapFloorDimension: class RoomInRoofType1: """RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only. - `gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.; - full enum not yet mapped). `gable_wall_length_*` is the run of the - external gable in metres. Heights are NOT lodged here — the cascade - applies the §3.9.1 default storey height (2.45 m).""" + `gable_wall_type_*` is the Table 4 gable variant (0 = Party, + 1 = Exposed — established from cert 6035's Summary; other variants + not yet seen). `gable_wall_length_*` is the run of the gable in + metres. Heights are NOT lodged here — the mapper applies the §3.9.1 + default RR storey height (2.45 m).""" gable_wall_type_1: Optional[int] = None gable_wall_type_2: Optional[int] = None gable_wall_length_1: Optional[float] = None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index e8925863..242d30b2 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -207,10 +207,11 @@ class SapFloorDimension: class RoomInRoofType1: """RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only. - `gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.; - full enum not yet mapped). `gable_wall_length_*` is the run of the - external gable in metres. Heights are NOT lodged here — the cascade - applies the §3.9.1 default storey height (2.45 m).""" + `gable_wall_type_*` is the Table 4 gable variant (0 = Party, + 1 = Exposed — established from cert 6035's Summary; other variants + not yet seen). `gable_wall_length_*` is the run of the gable in + metres. Heights are NOT lodged here — the mapper applies the §3.9.1 + default RR storey height (2.45 m).""" gable_wall_type_1: Optional[int] = None gable_wall_type_2: Optional[int] = None gable_wall_length_1: Optional[float] = None diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index dc000956..375fe930 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -41,6 +41,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, cert_to_demand_inputs, cert_to_inputs, + heat_transmission_section_from_cert, ) _FIXTURES_DIR = Path(__file__).parent / "fixtures" / "golden" @@ -82,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+5.8007, - expected_co2_resid_tonnes_per_yr=+0.3173, + expected_pe_resid_kwh_per_m2=+3.9138, + expected_co2_resid_tonnes_per_yr=+0.2213, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -120,7 +121,13 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "extract-fans default (age J, 4 hab rooms → 2 fans). " "Cascade ventilation HLC rises ~0.07 ACH × volume → SH " "demand rises proportionally; PE +2.5225 → +5.8007, CO2 " - "+0.1395 → +0.3173. SAP integer unchanged at 72." + "+0.1395 → +0.3173. SAP integer unchanged at 72. " + "Slice S0380.196 (the 6035 RR fix) also applies here: this " + "cert's `room_in_roof_type_1` lodges two gables (L=6.4, both " + "Party) with no height, previously dropped → roof over-count. " + "Routing them through `detailed_surfaces` deducts 2×(6.4×2.45) " + "from the A_RR shell → roof drops, tightening PE +5.8007 → " + "+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72." ), ), _GoldenExpectation( @@ -193,9 +200,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="6035-7729-2309-0879-2296", actual_sap=70, - expected_sap_resid=-2, - expected_pe_resid_kwh_per_m2=+19.1566, - expected_co2_resid_tonnes_per_yr=+0.4211, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=+1.8379, + expected_co2_resid_tonnes_per_yr=+0.0103, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "S0380.189 fixed the dominant driver: walls are solid brick " @@ -223,7 +230,24 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "WHC=901 + main code 104). Eq D1 monthly blend (mean ~80%) " "produces ~150 kWh/yr more HW fuel than the pre-slice flat-" "winter calc → PE residual +46.0952 → +47.2928, CO2 +1.0495 " - "→ +1.0779." + "→ +1.0779. " + "Slice S0380.196 CLOSED the residual (the prior 'lodged " + "divergence' claim is RETRACTED — it was a real API-mapper " + "bug). The API `room_in_roof_type_1` block lodges two gable " + "walls (L=4.65 each) but no heights; the mapper carried only " + "the scalar lengths, and the cascade's `_part_geometry` gable " + "formula silently drops height-less gables → the whole " + "55.67 m² A_RR shell billed as roof at U_RR=2.30 instead of " + "the §3.9.1(e) residual `12.5√(29.75/1.5) − 2×11.39 = 32.89`. " + "That over-counted roof by 22.78 m² × 2.30 = +52.4 W/K (roof " + "130.73 → 78.33, matching the site-notes case-4 replica at " + "1e-4). Fix: route the Type 1 gables through `detailed_" + "surfaces` (gable area = L × the §3.9.1 default RR storey " + "height 2.45 m; Exposed → gable_wall_external, Party → " + "gable_wall) so the cascade's Detailed-RR residual fires. " + "SAP resid -2 → +0 (exact), PE +19.16 → +1.84, CO2 " + "+0.42 → +0.01. Remaining +1.84 PE is unrelated gains/HW " + "(no worksheet for 6035 itself to pin further)." ), ), _GoldenExpectation( @@ -731,3 +755,35 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number( assert main.main_heating_index_number == expected_pcdb_id if expected_winter_eff is not None: assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3 + + +def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None: + """Cert 6035 lodges a Simplified Type 1 room-in-roof (`room_in_roof_ + type_1`) with two gable walls (L=4.65 each). Per RdSAP 10 §3.9.1(e) + (PDF p.21) the gable areas deduct from the A_RR shell — the residual + roof area is `12.5√(A_RR_floor/1.5) − Σ gables`, NOT the full shell. + + The API mapper must route these scalar gables through + `detailed_surfaces` (gable area = L × the §3.9.1 default RR storey + height 2.45 m) so the cascade's Detailed-RR residual fires, exactly + as the site-notes path does. Before the fix the gables (no lodged + height) were silently dropped → the whole 55.67 m² shell billed at + U_RR=2.30, a +52 W/K roof over-count and the entire 6035 residual. + + Cross-mapper parity: this is the value the site-notes case-4 replica + (`worksheet/_elmhurst_worksheet_001431_6035.py`) pins to its + worksheet at 1e-4: + loft (41.73−29.75=11.98) × U_roof(300 mm) = 1.6772 + ext (7.21) × U_roof(300 mm) = 1.0094 + RR residual (55.67 − 2×11.39 = 32.89) × U_RR(age A=2.30) = 75.647 + → 78.3336 W/K + """ + # Arrange + doc = _load_cert("6035-7729-2309-0879-2296") + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Act + roof_w_per_k = heat_transmission_section_from_cert(epc).roof_w_per_k + + # Assert + assert abs(roof_w_per_k - 78.3336) <= 1e-4