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). "