diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 58695b84..e758e02e 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -786,7 +786,11 @@ class EpcPropertyDataMapper: flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, roof_construction=bp.roof_construction, roof_insulation_location=bp.roof_insulation_location, - roof_insulation_thickness=bp.roof_insulation_thickness, + roof_insulation_thickness=_api_resolve_sloping_ceiling_thickness( + bp.roof_construction, + bp.roof_insulation_thickness, + bp.construction_age_band, + ), sap_room_in_roof=( SapRoomInRoof( floor_area=bp.sap_room_in_roof.floor_area.value, @@ -934,7 +938,11 @@ class EpcPropertyDataMapper: flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, roof_construction=bp.roof_construction, roof_insulation_location=bp.roof_insulation_location, - roof_insulation_thickness=bp.roof_insulation_thickness, + roof_insulation_thickness=_api_resolve_sloping_ceiling_thickness( + bp.roof_construction, + bp.roof_insulation_thickness, + bp.construction_age_band, + ), sap_room_in_roof=( SapRoomInRoof( # floor_area is a Measurement in 19.0 @@ -1098,8 +1106,25 @@ class EpcPropertyDataMapper: floor_insulation_thickness=bp.floor_insulation_thickness, flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, roof_construction=bp.roof_construction, + # Surface human-readable strings derived from the + # API integer codes. The cascade reads these via + # Slice 88 (`floor_construction_type` → u_floor's + # "Suspended"/"Solid" branch selection) and Slice + # 89 (`roof_construction_type` containing "sloping + # ceiling" → cos(30°) inclined-surface area). + floor_construction_type=_api_floor_construction_str( + bp.sap_floor_dimensions[0].floor_construction + if bp.sap_floor_dimensions else None + ), + roof_construction_type=_api_roof_construction_str( + bp.roof_construction + ), roof_insulation_location=bp.roof_insulation_location, - roof_insulation_thickness=bp.roof_insulation_thickness, + roof_insulation_thickness=_api_resolve_sloping_ceiling_thickness( + bp.roof_construction, + bp.roof_insulation_thickness, + bp.construction_age_band, + ), sap_room_in_roof=( SapRoomInRoof( floor_area=_measurement_value(bp.sap_room_in_roof.floor_area), @@ -1290,8 +1315,25 @@ class EpcPropertyDataMapper: floor_insulation_thickness=bp.floor_insulation_thickness, flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, roof_construction=bp.roof_construction, + # Surface human-readable strings derived from the + # API integer codes. The cascade reads these via + # Slice 88 (`floor_construction_type` → u_floor's + # "Suspended"/"Solid" branch selection) and Slice + # 89 (`roof_construction_type` containing "sloping + # ceiling" → cos(30°) inclined-surface area). + floor_construction_type=_api_floor_construction_str( + bp.sap_floor_dimensions[0].floor_construction + if bp.sap_floor_dimensions else None + ), + roof_construction_type=_api_roof_construction_str( + bp.roof_construction + ), roof_insulation_location=bp.roof_insulation_location, - roof_insulation_thickness=bp.roof_insulation_thickness, + roof_insulation_thickness=_api_resolve_sloping_ceiling_thickness( + bp.roof_construction, + bp.roof_insulation_thickness, + bp.construction_age_band, + ), sap_room_in_roof=( SapRoomInRoof( floor_area=_measurement_value(bp.sap_room_in_roof.floor_area), @@ -1551,8 +1593,25 @@ class EpcPropertyDataMapper: floor_insulation_thickness=bp.floor_insulation_thickness, flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, roof_construction=bp.roof_construction, + # Surface human-readable strings derived from the + # API integer codes. The cascade reads these via + # Slice 88 (`floor_construction_type` → u_floor's + # "Suspended"/"Solid" branch selection) and Slice + # 89 (`roof_construction_type` containing "sloping + # ceiling" → cos(30°) inclined-surface area). + floor_construction_type=_api_floor_construction_str( + bp.sap_floor_dimensions[0].floor_construction + if bp.sap_floor_dimensions else None + ), + roof_construction_type=_api_roof_construction_str( + bp.roof_construction + ), roof_insulation_location=bp.roof_insulation_location, - roof_insulation_thickness=bp.roof_insulation_thickness, + roof_insulation_thickness=_api_resolve_sloping_ceiling_thickness( + bp.roof_construction, + bp.roof_insulation_thickness, + bp.construction_age_band, + ), sap_room_in_roof=( SapRoomInRoof( floor_area=_measurement_value(bp.sap_room_in_roof.floor_area), @@ -1947,6 +2006,75 @@ def _api_party_wall_construction_int(value: Union[int, str, None]) -> Optional[i return _API_PARTY_WALL_CONSTRUCTION_TO_SAP10.get(value) +# GOV.UK API `floor_construction` integer → human-readable string the +# cascade's `u_floor` looks for via the "Suspended"/"Solid" prefix +# (see Slice 88 — `heat_transmission.py` consumes `bp.floor_ +# construction_type` to choose the suspended-branch BS EN ISO 13370 +# formula). Only the values observed across the 10 golden fixtures +# (1, 2) are mapped; unrecognised codes fall through to None. +_API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, str] = { + 1: "Solid", + 2: "Suspended timber", +} + + +# GOV.UK API `roof_construction` integer → human-readable string the +# cascade's roof-area logic looks for via the "sloping ceiling" +# substring (see Slice 89 — `heat_transmission.py` applies the +# cos(30°) inclined-surface factor when the bp's +# `roof_construction_type` contains "sloping ceiling"). Codes 4 + 8 +# are observed on cert 001479; the wider RdSAP10 roof-construction +# enum (1=Flat, 3=Pitched no-access, 5=Vaulted, etc.) is mapped as +# best-effort against SAP10 nomenclature. +_API_ROOF_CONSTRUCTION_TO_STR: Dict[int, str] = { + 1: "Flat", + 3: "Pitched (slates/tiles), no access to loft", + 4: "Pitched (slates/tiles), access to loft", + 5: "Pitched (vaulted ceiling)", + 8: "Pitched, sloping ceiling", +} + + +def _api_floor_construction_str(value: Optional[int]) -> Optional[str]: + """Translate the API integer floor_construction code to the + human-readable string the cascade reads via Slice 88's + `effective_floor_description` in `heat_transmission.py`.""" + return _API_FLOOR_CONSTRUCTION_TO_STR.get(value) if value 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 + `roof_construction_type`-based cos(30°) factor in + `heat_transmission.py`.""" + return _API_ROOF_CONSTRUCTION_TO_STR.get(value) if value is not None else None + + +def _api_resolve_sloping_ceiling_thickness( + roof_construction: Optional[int], + roof_insulation_thickness: Union[str, int, None], + age_band: Optional[str], +) -> Union[str, int, None]: + """Apply Slice 57's pre-1950 sloping-ceiling-roof rule to the API + path: when a "Pitched, sloping ceiling" roof carries no insulation + thickness lodgement on a pre-1950 dwelling (age bands A-D), set + the thickness to 0 mm so the cascade's `u_roof` returns the + uninsulated Table 16 row (U=2.30) rather than the age-band default + (e.g. U=0.40 for age C pitched-with-loft). Mirrors the Elmhurst + `_resolve_sloping_ceiling_thickness` for the API code-based path. + + Observed on cert 001479 Ext2: age C, roof_construction=8 (PS), + roof_insulation_thickness=None — worksheet U=2.30 (uninsulated PS + sloping ceiling); without this rule the cascade returns U=0.40.""" + if roof_insulation_thickness is not None: + return roof_insulation_thickness + if roof_construction != 8: # 8 = Pitched, sloping ceiling + return roof_insulation_thickness + if age_band is None or age_band.upper() not in _PRE_1950_AGE_CODES: + return roof_insulation_thickness + return 0 + + def _elmhurst_wall_insulation_int(coded: str) -> Optional[int]: """Map an Elmhurst wall-insulation-type string ('A As Built') to the SAP10 integer enum (4 = as-built). Returns None on unknown 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 0fbd4bb5..272ab6a9 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=-10, - expected_pe_resid_kwh_per_m2=-2.0499, - expected_co2_resid_tonnes_per_yr=-0.0447, + expected_sap_resid=-13, + expected_pe_resid_kwh_per_m2=+10.4527, + expected_co2_resid_tonnes_per_yr=+0.5916, 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 " @@ -118,9 +118,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="6035-7729-2309-0879-2296", actual_sap=70, - expected_sap_resid=-4, - expected_pe_resid_kwh_per_m2=+34.0247, - expected_co2_resid_tonnes_per_yr=+0.7631, + expected_sap_resid=-5, + expected_pe_resid_kwh_per_m2=+34.4963, + expected_co2_resid_tonnes_per_yr=+0.7742, 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=+3, - expected_pe_resid_kwh_per_m2=-22.5292, - expected_co2_resid_tonnes_per_yr=-0.5993, + expected_sap_resid=+2, + expected_pe_resid_kwh_per_m2=-15.8298, + expected_co2_resid_tonnes_per_yr=-0.4207, notes=( "Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, " "Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and " @@ -147,8 +147,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="8135-1728-8500-0511-3296", actual_sap=72, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-16.5112, - expected_co2_resid_tonnes_per_yr=-0.2863, + expected_pe_resid_kwh_per_m2=-16.3714, + expected_co2_resid_tonnes_per_yr=-0.2836, notes=( "Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges " "blocked_chimneys_count=1. Slice 59 per-bp window apportionment " @@ -160,8 +160,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="2130-1033-4050-5007-8395", actual_sap=82, expected_sap_resid=+3, - expected_pe_resid_kwh_per_m2=-51.9024, - expected_co2_resid_tonnes_per_yr=+0.1422, + expected_pe_resid_kwh_per_m2=-51.0953, + expected_co2_resid_tonnes_per_yr=+0.1517, notes=( "End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, " "postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays " diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index 01d0be61..0ec63012 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -484,7 +484,19 @@ def heat_transmission_from_cert( description=wall_description, wall_insulation_type=wall_ins_type, ) - ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=roof_description) + # When the per-bp `roof_insulation_thickness` is explicitly lodged + # as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling + # age C from Slice 91's `_api_resolve_sloping_ceiling_thickness`) + # the global `epc.roofs[].description` ("Pitched, insulated" from + # another bp) must NOT override the per-bp truth via u_roof's + # Table 18 footnote (2) assumed-insulation path. Drop the + # description in that case so the cascade returns the spec + # uninsulated U-value (Table 18 row 0). Cohort Summary mappers + # leave `epc.roofs` empty so description is None there anyway. + effective_roof_description = ( + None if roof_thickness == 0 else roof_description + ) + ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description) # Floor U-value routing (in priority order): # 1. Basement floor — Table 23 F-column override (whole floor=0). # 2. Exposed/semi-exposed upper floor — Table 20 lookup; no