diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 5c785343f..39c429255 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -5936,7 +5936,9 @@ def _map_main_building_part( return SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, construction_age_band=_extract_age_band(main.age_range), - wall_construction=main.walls_construction_type, + wall_construction=_pashub_wall_construction_int( + main.walls_construction_type + ), wall_insulation_type=main.walls_insulation_type, wall_thickness_measured=main.wall_thickness_mm is not None, party_wall_construction=_pashub_party_wall_construction_int( @@ -5962,7 +5964,9 @@ def _map_extension_building_part( return SapBuildingPart( identifier=BuildingPartIdentifier.extension(ext_c.id), construction_age_band=_extract_age_band(ext_c.age_range), - wall_construction=ext_c.walls_construction_type, + wall_construction=_pashub_wall_construction_int( + ext_c.walls_construction_type + ), wall_insulation_type=ext_c.walls_insulation_type, wall_thickness_measured=ext_c.wall_thickness_mm is not None, party_wall_construction=_pashub_party_wall_construction_int( @@ -7126,6 +7130,36 @@ def _pashub_party_wall_construction_int(label: Optional[str]) -> Optional[int]: return _PASHUB_PARTY_WALL_CONSTRUCTION_TO_SAP10[key] +# PasHub surveyed `walls_construction_type` label → SAP10 `wall_construction` +# code (the WALL_* constants in domain.sap10_ml.rdsap_uvalues) that `u_wall` +# consumes. Same target code-space as the GOV.UK-API (`_api_wall_construction_ +# code`) and Elmhurst (`_ELMHURST_WALL_CODE_TO_SAP10`) siblings — PasHub just +# lodges the full human label. +_PASHUB_WALL_CONSTRUCTION_TO_SAP10: Dict[str, int] = { + "Solid brick": 3, # WALL_SOLID_BRICK + "Cavity": 4, # WALL_CAVITY + "Timber frame": 5, # WALL_TIMBER_FRAME + "System Build (i.e Any Other)": 6, # WALL_SYSTEM_BUILT +} + + +def _pashub_wall_construction_int(label: Optional[str]) -> Union[int, str]: + """Resolve a PasHub surveyed `walls_construction_type` label to its SAP10 + `wall_construction` code at the mapper boundary. A blank label is "no lodging" + and passes through as the empty string (the `SapBuildingPart.wall_construction` + field is `Union[int, str]`, and the calculator's `_int_or_none` resolves a + non-numeric string to None → the age-band/thickness U-value fallback); a + non-empty label the lookup does not cover strict-raises `UnmappedPasHubLabel` + so the coverage gap is fixed here rather than silently dropping the + construction distinction downstream (#1560).""" + if label is None or not label.strip(): + return "" + key = label.strip() + if key not in _PASHUB_WALL_CONSTRUCTION_TO_SAP10: + raise UnmappedPasHubLabel("wall construction", label) + return _PASHUB_WALL_CONSTRUCTION_TO_SAP10[key] + + def _resolve_elmhurst_underfloor_subtype( main_floor: ElmhurstFloorDetails, main_age_band: str, diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 843c2a96f..81bd72e29 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -74,6 +74,45 @@ class TestPartyWallConstructionCoding: EpcPropertyDataMapper.from_site_notes(survey) +class TestWallConstructionCoding: + """`from_site_notes` must int-code the PAS Hub `walls_construction_type` label + to the SAP10 `wall_construction` code `u_wall` consumes (WALL_* constants), + not copy the raw label. A raw label reads as `None`, so the wall U-value falls + back to age band + thickness only and loses the solid/cavity/timber/system + distinction (Guinness cohort, issue #1560). + """ + + @pytest.mark.parametrize( + "label, expected_code", + [ + ("Solid brick", 3), # WALL_SOLID_BRICK + ("Cavity", 4), # WALL_CAVITY + ("Timber frame", 5), # WALL_TIMBER_FRAME + ("System Build (i.e Any Other)", 6), # WALL_SYSTEM_BUILT + ("", ""), # no lodging — passes through (calc nulls it via _int_or_none) + ], + ) + def test_label_maps_to_sap_code( + self, label: str, expected_code: "int | str" + ) -> None: + data = load("pashub_rdsap_site_notes_example1.json") + data["building_construction"]["main_building"][ + "walls_construction_type" + ] = label + survey = from_dict(PasHubRdSapSiteNotes, data) + result = EpcPropertyDataMapper.from_site_notes(survey) + assert result.sap_building_parts[0].wall_construction == expected_code + + def test_unknown_label_strict_raises(self) -> None: + data = load("pashub_rdsap_site_notes_example1.json") + data["building_construction"]["main_building"][ + "walls_construction_type" + ] = "Woven Basket" + survey = from_dict(PasHubRdSapSiteNotes, data) + with pytest.raises(UnmappedPasHubLabel): + EpcPropertyDataMapper.from_site_notes(survey) + + class TestFromSiteNotesExample1: """ Fixture: pashub_rdsap_site_notes_example1.json @@ -259,8 +298,8 @@ class TestFromSiteNotesExample1: assert result.sap_building_parts[0].construction_age_band == "I" def test_wall_construction(self, result: EpcPropertyData) -> None: - # main_building.walls_construction_type: "Cavity" - assert result.sap_building_parts[0].wall_construction == "Cavity" + # main_building.walls_construction_type: "Cavity" → SAP10 code 4 (WALL_CAVITY) + assert result.sap_building_parts[0].wall_construction == 4 def test_wall_insulation_type(self, result: EpcPropertyData) -> None: # main_building.walls_insulation_type: "As built" @@ -509,7 +548,7 @@ class TestFromSiteNotesExample1: SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, construction_age_band="I", - wall_construction="Cavity", + wall_construction=4, # "Cavity" → SAP10 code 4 (WALL_CAVITY) wall_insulation_type="As built", wall_thickness_measured=True, party_wall_construction=4, # "Cavity Masonry, Unfilled" → SAP10 code 4 (U=0.5)