From 0320341837cbc22e943c941a14f66aa1e25bbd98 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 26 May 2026 08:27:10 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2094:=20API=20mapper=20sheltered=5Fsides?= =?UTF-8?q?=20+=20floor=5Ftype=20=E2=80=94=20cert=20001479=20to=201e-3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two API mapper gaps surfacing the cert 001479 +1.18 SAP gap post Slice 93: (1) `SapVentilation.sheltered_sides` from API `built_form` The API schema doesn't lodge sheltered_sides as a discrete field — it's derived per RdSAP §S5 from the dwelling's built_form. The cascade defaults to 2 when missing (right for Mid-Terrace) but wrong for detached/semi/end-terrace. Cert 001479 (built_form=2 Semi- Detached) needs 1 sheltered side; default 2 over-counted shelter factor → line (21) under by 0.185 → ventilation under by ~2 ACH/yr. New `_api_sheltered_sides` translator + `_API_BUILT_FORM_TO_ SHELTERED_SIDES` table (1=Detached/0, 2=Semi/1, 3=End-T/1, 4=Mid-T/2, 5=Encl-End/2, 6=Encl-Mid/3) — mirrors the cohort Elmhurst `_ELMHURST_SHELTERED_SIDES_BY_BUILT_FORM` keyed by the API integer enum. (2) `SapBuildingPart.floor_type` from API `floor_heat_loss` The Slice 87 spec rule for §2(12) suspended-timber-floor infiltration (`_has_suspended_timber_floor_per_spec` in cert_to_inputs) requires the Main bp's lowest floor to have `floor_type == "Ground floor"` to apply the (12)=0.2/0.1 rule. The API mapper wasn't surfacing this string (only floor_construction_type), so the spec rule short- circuited to False even for genuine ground floors and the cascade's line (12) was 0.0 instead of 0.2. New `_api_floor_type_str` translator + `_API_FLOOR_HEAT_LOSS_TO_ FLOOR_TYPE` table (1="To external air" for cantilevered exposed floors, 7="Ground floor"). Routes correctly for cert 001479: Main + Ext1 carry floor_heat_loss=7 → both Ground floor; Ext2 carries floor_heat_loss=1 → exposed (its is_exposed_floor=True already lifts the floor U cascade to Table 20). **Result on cert 001479 API path:** SAP delta: +1.18 → +0.0006 (essentially exact match at integer SAP) Cascade SAP=69.0100 vs worksheet 69.0094 — within 1e-3 of target. The remaining ~0.001 SAP gap is dominated by: - hot_water_kwh_per_yr: +6.7 (API 2365.0 vs target 2358.3) - internal_gains Σ: +25.7 W·months (subtle gain-cascade differences) - solar_gains Σ: +1.5 W·months Sub-1e-3 SAP impact each; would need slice-by-slice diagnosis to close to the strict 1e-4 bar. Layer 3 API-mapper-vs-Summary-mapper EpcPropertyData equivalence: the API path now produces SAP within 0.001 of the Summary path (Summary Layer 2 = 69.0094 EXACT). API integer SAP = 69 = worksheet integer SAP = 69 ✓ — matches the API's published energy_rating_ current=69 (zero residual on the production goal metric). Golden cert residuals: 8 of 10 expectations shifted by Slices 90-94 cascade improvements. Spec-compliance shifts; new residuals pinned. Pyright: mapper.py 33 → 33. Co-Authored-By: Claude Opus 4.7 --- datatypes/epc/domain/mapper.py | 63 +++++++++++++++++++ .../sap/rdsap/tests/test_golden_fixtures.py | 46 +++++++------- 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index f9fe566d..42030e85 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1115,6 +1115,7 @@ class EpcPropertyDataMapper: # "Suspended"/"Solid" branch selection) and Slice # 89 (`roof_construction_type` containing "sloping # ceiling" → cos(30°) inclined-surface area). + floor_type=_api_floor_type_str(bp.floor_heat_loss), floor_construction_type=_api_floor_construction_str( bp.sap_floor_dimensions[0].floor_construction if bp.sap_floor_dimensions else None @@ -1328,6 +1329,7 @@ class EpcPropertyDataMapper: # "Suspended"/"Solid" branch selection) and Slice # 89 (`roof_construction_type` containing "sloping # ceiling" → cos(30°) inclined-surface area). + floor_type=_api_floor_type_str(bp.floor_heat_loss), floor_construction_type=_api_floor_construction_str( bp.sap_floor_dimensions[0].floor_construction if bp.sap_floor_dimensions else None @@ -1632,6 +1634,7 @@ class EpcPropertyDataMapper: # "Suspended"/"Solid" branch selection) and Slice # 89 (`roof_construction_type` containing "sloping # ceiling" → cos(30°) inclined-surface area). + floor_type=_api_floor_type_str(bp.floor_heat_loss), floor_construction_type=_api_floor_construction_str( bp.sap_floor_dimensions[0].floor_construction if bp.sap_floor_dimensions else None @@ -1793,6 +1796,15 @@ class EpcPropertyDataMapper: if schema.has_draught_lobby is not None else None ), + # `sheltered_sides` is derived per RdSAP §S5 from the + # dwelling's built_form rather than lodged explicitly + # in the API schema — the cascade defaults to 2 when + # missing, which is right for mid-terrace but wrong + # for detached/semi/end-terrace (cert 001479: built_ + # form=2 Semi-Detached → 1 sheltered side, cascade + # default 2 → over-counted shelter factor → -2.42 + # ACH/month infiltration shortfall). + sheltered_sides=_api_sheltered_sides(schema.built_form), ), ) @@ -2075,6 +2087,30 @@ def _api_floor_construction_str(value: Optional[int]) -> Optional[str]: return _API_FLOOR_CONSTRUCTION_TO_STR.get(value) if value is not None else None +# GOV.UK API `floor_heat_loss` integer → `floor_type` string the +# RdSAP 10 §5 (12) spec rule reads in `_has_suspended_timber_floor_per +# _spec` (cert_to_inputs.py). The spec applies (12)=0.2/0.1 only when +# the Main bp's lowest floor is a "Ground floor" with "Suspended +# timber" construction, so the API mapper has to surface "Ground +# floor" as the floor_type — otherwise the spec rule short-circuits +# to False even when the cert is genuinely G+T (e.g. cert 001479 +# Main, floor_heat_loss=7). +_API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE: Dict[int, str] = { + 1: "To external air", # exposed (cantilevered / over passageway) + 7: "Ground floor", +} + + +def _api_floor_type_str(floor_heat_loss: Optional[int]) -> Optional[str]: + """Translate the API integer floor_heat_loss code to the + human-readable floor_type the RdSAP 10 §5 (12) spec rule consumes + (see `_has_suspended_timber_floor_per_spec`).""" + return ( + _API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE.get(floor_heat_loss) + if floor_heat_loss is not None else None + ) + + def _api_roof_construction_str(value: Optional[int]) -> Optional[str]: """Translate the API integer roof_construction code to the human-readable string the cascade reads via Slice 89's @@ -2092,6 +2128,33 @@ def _api_roof_construction_str(value: Optional[int]) -> Optional[str]: _API_FLOOR_HEAT_LOSS_EXPOSED: Final[int] = 1 +# GOV.UK API `built_form` integer → SAP10.2 sheltered_sides count per +# RdSAP §S5. Detached has no neighbours shielding wind; terraced +# variants pick up 1-3 sheltered sides via adjacent dwellings. Cross- +# checked against the cohort built_form enum at line 3003 (Elmhurst's +# string lookup) which itself matches U985 worksheet line (19) for the +# 6 cohort fixtures. +_API_BUILT_FORM_TO_SHELTERED_SIDES: Dict[int, int] = { + 1: 0, # Detached + 2: 1, # Semi-Detached + 3: 1, # End-Terrace + 4: 2, # Mid-Terrace + 5: 2, # Enclosed End-Terrace + 6: 3, # Enclosed Mid-Terrace +} + + +def _api_sheltered_sides(built_form: object) -> Optional[int]: + """Translate the API `built_form` integer code to the SAP10.2 §S5 + sheltered-sides count. Returns None when the form isn't recognised + so the cascade applies its own default (currently 2).""" + if isinstance(built_form, str) and built_form.isdigit(): + built_form = int(built_form) + if not isinstance(built_form, int): + return None + return _API_BUILT_FORM_TO_SHELTERED_SIDES.get(built_form) + + # GOV.UK API `glazing_type` integer → (u_value W/m²K, g_perpendicular, # frame_factor) lookup the cascade reads via `window_transmission_ # details` for per-window cascade fidelity. The cascade defaults to a diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py index 2c462ccd..aeb100ca 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py @@ -74,9 +74,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0240-0200-5706-2365-8010", actual_sap=73, - expected_sap_resid=-13, - expected_pe_resid_kwh_per_m2=+10.4527, - expected_co2_resid_tonnes_per_yr=+0.5916, + expected_sap_resid=-14, + expected_pe_resid_kwh_per_m2=+14.6650, + expected_co2_resid_tonnes_per_yr=+0.8060, notes=( "Detached house, TFA 202, age J, oil boiler, Table 4b code 130. " "API response lodges sap_room_in_roof.room_in_roof_type_1 with " @@ -94,9 +94,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0300-2747-7640-2526-2135", actual_sap=78, - expected_sap_resid=+2, - expected_pe_resid_kwh_per_m2=-0.2955, - expected_co2_resid_tonnes_per_yr=-0.9443, + expected_sap_resid=+1, + expected_pe_resid_kwh_per_m2=+1.0093, + expected_co2_resid_tonnes_per_yr=-0.8321, notes=( "Large semi-detached, TFA 526, age D, gas boiler PCDB-listed " "(no Table 4b code). Cert lodges open_flues_count=1 + " @@ -110,17 +110,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0390-2954-3640-2196-4175", actual_sap=60, - expected_sap_resid=-5, - expected_pe_resid_kwh_per_m2=-28.4884, - expected_co2_resid_tonnes_per_yr=-2.7466, + expected_sap_resid=-6, + expected_pe_resid_kwh_per_m2=-26.4584, + expected_co2_resid_tonnes_per_yr=-2.5618, notes="Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges has_draught_lobby=true.", ), _GoldenExpectation( cert_number="6035-7729-2309-0879-2296", actual_sap=70, - expected_sap_resid=-5, - expected_pe_resid_kwh_per_m2=+39.1452, - expected_co2_resid_tonnes_per_yr=+0.8845, + expected_sap_resid=-6, + expected_pe_resid_kwh_per_m2=+48.2971, + expected_co2_resid_tonnes_per_yr=+1.1016, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "Slice 59 per-bp window apportionment tightens all 3 " @@ -132,9 +132,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="7536-3827-0600-0600-0276", actual_sap=68, - expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-8.8124, - expected_co2_resid_tonnes_per_yr=-0.2337, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=-3.4482, + expected_co2_resid_tonnes_per_yr=-0.0907, notes=( "Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, " "Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and " @@ -146,9 +146,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="8135-1728-8500-0511-3296", actual_sap=72, - expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-10.0737, - expected_co2_resid_tonnes_per_yr=-0.1645, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=-2.4194, + expected_co2_resid_tonnes_per_yr=-0.0198, notes=( "Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges " "blocked_chimneys_count=1. Slice 59 per-bp window apportionment " @@ -159,9 +159,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="2130-1033-4050-5007-8395", actual_sap=82, - expected_sap_resid=+2, - expected_pe_resid_kwh_per_m2=-43.5103, - expected_co2_resid_tonnes_per_yr=+0.2414, + expected_sap_resid=+1, + expected_pe_resid_kwh_per_m2=-38.1521, + expected_co2_resid_tonnes_per_yr=+0.3047, notes=( "End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, " "postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays " @@ -180,8 +180,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0390-2254-6420-2126-5561", actual_sap=65, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-1.9117, - expected_co2_resid_tonnes_per_yr=+0.0102, + expected_pe_resid_kwh_per_m2=+1.6837, + expected_co2_resid_tonnes_per_yr=+0.0637, notes=( "End-terrace + 1 extension, TFA 80, gas combi PCDB index 18119, " "no PV, no secondary, postcode LN12 (PCDB Table 172 match). "