diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 39c429255..3ccd88fe4 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -5939,7 +5939,9 @@ def _map_main_building_part( wall_construction=_pashub_wall_construction_int( main.walls_construction_type ), - wall_insulation_type=main.walls_insulation_type, + wall_insulation_type=_pashub_wall_insulation_type_int( + main.walls_insulation_type + ), wall_thickness_measured=main.wall_thickness_mm is not None, party_wall_construction=_pashub_party_wall_construction_int( main.party_wall_construction_type @@ -5967,7 +5969,9 @@ def _map_extension_building_part( wall_construction=_pashub_wall_construction_int( ext_c.walls_construction_type ), - wall_insulation_type=ext_c.walls_insulation_type, + wall_insulation_type=_pashub_wall_insulation_type_int( + ext_c.walls_insulation_type + ), wall_thickness_measured=ext_c.wall_thickness_mm is not None, party_wall_construction=_pashub_party_wall_construction_int( ext_c.party_wall_construction_type @@ -7160,6 +7164,33 @@ def _pashub_wall_construction_int(label: Optional[str]) -> Union[int, str]: return _PASHUB_WALL_CONSTRUCTION_TO_SAP10[key] +# PasHub surveyed `walls_insulation_type` label → SAP10 wall-insulation code that +# `u_wall` consumes. Same target code-space as the Elmhurst sibling +# (`_ELMHURST_WALL_INSULATION_TO_SAP10`) — PasHub just lodges the full human +# label. "As built" is the assumed/default cascade code (Elmhurst "A"=4). +_PASHUB_WALL_INSULATION_TYPE_TO_SAP10: Dict[str, int] = { + "As built": 4, # as built / assumed (default cascade) + "Filled Cavity": 2, # WALL_INSULATION_FILLED_CAVITY + "External": 1, # external wall insulation +} + + +def _pashub_wall_insulation_type_int(label: Optional[str]) -> Union[int, str]: + """Resolve a PasHub surveyed `walls_insulation_type` label to its SAP10 + wall-insulation code at the mapper boundary. A blank label is "no lodging" and + passes through as the empty string (the field is `Union[int, str]`; the + calculator's `_int_or_none` resolves a non-numeric string to None); a non-empty + label the lookup does not cover strict-raises `UnmappedPasHubLabel` so the + coverage gap is fixed here rather than silently dropping the filled-cavity + U-value credit downstream (#1561).""" + if label is None or not label.strip(): + return "" + key = label.strip() + if key not in _PASHUB_WALL_INSULATION_TYPE_TO_SAP10: + raise UnmappedPasHubLabel("wall insulation type", label) + return _PASHUB_WALL_INSULATION_TYPE_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 81bd72e29..e579079a2 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -113,6 +113,44 @@ class TestWallConstructionCoding: EpcPropertyDataMapper.from_site_notes(survey) +class TestWallInsulationTypeCoding: + """`from_site_notes` must int-code the PAS Hub `walls_insulation_type` label + to the SAP10 wall-insulation code `u_wall` consumes (matching the Elmhurst + `_ELMHURST_WALL_INSULATION_TO_SAP10` sibling), not copy the raw label. A raw + label reads as `None`, so a filled cavity loses its lower-U credit and the + wall is over-counted (Guinness cohort, issue #1561). + """ + + @pytest.mark.parametrize( + "label, expected_code", + [ + ("As built", 4), # as built / assumed (default cascade) + ("Filled Cavity", 2), # WALL_INSULATION_FILLED_CAVITY + ("External", 1), # external wall insulation + ("", ""), # 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_insulation_type" + ] = label + survey = from_dict(PasHubRdSapSiteNotes, data) + result = EpcPropertyDataMapper.from_site_notes(survey) + assert result.sap_building_parts[0].wall_insulation_type == expected_code + + def test_unknown_label_strict_raises(self) -> None: + data = load("pashub_rdsap_site_notes_example1.json") + data["building_construction"]["main_building"][ + "walls_insulation_type" + ] = "Aerogel Blanket" + survey = from_dict(PasHubRdSapSiteNotes, data) + with pytest.raises(UnmappedPasHubLabel): + EpcPropertyDataMapper.from_site_notes(survey) + + class TestFromSiteNotesExample1: """ Fixture: pashub_rdsap_site_notes_example1.json @@ -302,8 +340,8 @@ class TestFromSiteNotesExample1: 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" - assert result.sap_building_parts[0].wall_insulation_type == "As built" + # main_building.walls_insulation_type: "As built" → SAP10 code 4 (as built/assumed) + assert result.sap_building_parts[0].wall_insulation_type == 4 def test_wall_thickness_measured(self, result: EpcPropertyData) -> None: # main_building.wall_thickness_mm: 280 → thickness was measured @@ -549,7 +587,7 @@ class TestFromSiteNotesExample1: identifier=BuildingPartIdentifier.MAIN, construction_age_band="I", wall_construction=4, # "Cavity" → SAP10 code 4 (WALL_CAVITY) - wall_insulation_type="As built", + wall_insulation_type=4, # "As built" → SAP10 code 4 (as built/assumed) wall_thickness_measured=True, party_wall_construction=4, # "Cavity Masonry, Unfilled" → SAP10 code 4 (U=0.5) sap_floor_dimensions=[