diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 266fa6e71..5c785343f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -5939,7 +5939,9 @@ def _map_main_building_part( wall_construction=main.walls_construction_type, wall_insulation_type=main.walls_insulation_type, wall_thickness_measured=main.wall_thickness_mm is not None, - party_wall_construction=main.party_wall_construction_type, + party_wall_construction=_pashub_party_wall_construction_int( + main.party_wall_construction_type + ), sap_floor_dimensions=_map_floor_dimensions(measurements.main_building.floors), wall_thickness_mm=main.wall_thickness_mm, roof_insulation_location=roof_location, @@ -5963,7 +5965,9 @@ def _map_extension_building_part( wall_construction=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=ext_c.party_wall_construction_type, + party_wall_construction=_pashub_party_wall_construction_int( + ext_c.party_wall_construction_type + ), sap_floor_dimensions=_map_floor_dimensions(ext_m.floors), wall_thickness_mm=ext_c.wall_thickness_mm, roof_insulation_location=roof_location, @@ -7093,6 +7097,33 @@ def _pashub_heat_emitter_code(emitter_label: str) -> Union[int, str]: if code is None: raise UnmappedPasHubLabel("heat emitter", emitter_label) return code +# PasHub surveyed `party_wall_construction_type` label → SAP10 party-wall code +# (RdSAP 10 §5.10 Table 15) that `u_party_wall` consumes. Same target codes as +# the GOV.UK-API (`_API_PARTY_WALL_CONSTRUCTION_TO_SAP10`) and Elmhurst +# (`_ELMHURST_PARTY_WALL_CODE_TO_SAP10`) siblings — PasHub just lodges the full +# human label. `0` is the explicit "unknown" sentinel (u_party_wall → 0.25 house +# / 0.0 flat); `None` is "no party wall / no lodging". +_PASHUB_PARTY_WALL_CONSTRUCTION_TO_SAP10: Dict[str, Optional[int]] = { + "Solid Masonry, Timber Frame, or System Built": 3, # U=0.0 + "Cavity Masonry, Unfilled": 4, # U=0.5 + "Cavity Masonry, Filled": 11, # U=0.2 (WALL_CAVITY_FILLED_PARTY) + "Unable to determine": 0, # unknown sentinel → u_party_wall default + "Not applicable": None, # no party wall +} + + +def _pashub_party_wall_construction_int(label: Optional[str]) -> Optional[int]: + """Resolve a PasHub surveyed `party_wall_construction_type` label to its SAP10 + party-wall code at the mapper boundary. A blank label is "no lodging" and + returns None; a non-empty label the lookup does not cover strict-raises + `UnmappedPasHubLabel` so the coverage gap is fixed here rather than silently + defaulting every party wall to the U=0.25 house value downstream (#1559).""" + if label is None or not label.strip(): + return None + key = label.strip() + if key not in _PASHUB_PARTY_WALL_CONSTRUCTION_TO_SAP10: + raise UnmappedPasHubLabel("party wall construction", label) + return _PASHUB_PARTY_WALL_CONSTRUCTION_TO_SAP10[key] def _resolve_elmhurst_underfloor_subtype( diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 862f301af..843c2a96f 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -1,7 +1,7 @@ import json import os from datetime import date -from typing import Any, Dict +from typing import Any, Dict, Optional import pytest @@ -19,7 +19,7 @@ from datatypes.epc.domain.epc_property_data import ( ShowerOutlet, ShowerOutlets, ) -from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from datatypes.epc.domain.mapper import EpcPropertyDataMapper, UnmappedPasHubLabel from datatypes.epc.schema.tests.helpers import from_dict from datatypes.epc.surveys.pashub_rdsap_site_notes import PasHubRdSapSiteNotes @@ -34,6 +34,46 @@ def load(filename: str) -> Dict[str, Any]: return json.load(f) # type: ignore[no-any-return] +class TestPartyWallConstructionCoding: + """`from_site_notes` must int-code the PAS Hub `party_wall_construction_type` + label to the SAP10 code `u_party_wall` consumes (RdSAP 10 Table 15), not copy + the raw label. A raw label reads as `None` and every party wall silently takes + the U=0.25 house default — phantom heat loss on the ~165/205 "solid" party + walls that should be U=0.0 (Guinness cohort, issue #1559). + """ + + @pytest.mark.parametrize( + "label, expected_code", + [ + ("Solid Masonry, Timber Frame, or System Built", 3), # U=0.0 + ("Cavity Masonry, Unfilled", 4), # U=0.5 + ("Cavity Masonry, Filled", 11), # U=0.2 (party-wall-only code) + ("Unable to determine", 0), # unknown sentinel → u_party_wall 0.25 (house) + ("Not applicable", None), # no party wall + ("", None), # no lodging + ], + ) + def test_label_maps_to_sap_code( + self, label: str, expected_code: Optional[int] + ) -> None: + data = load("pashub_rdsap_site_notes_example1.json") + data["building_construction"]["main_building"][ + "party_wall_construction_type" + ] = label + survey = from_dict(PasHubRdSapSiteNotes, data) + result = EpcPropertyDataMapper.from_site_notes(survey) + assert result.sap_building_parts[0].party_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"][ + "party_wall_construction_type" + ] = "Reinforced Adamantium" + survey = from_dict(PasHubRdSapSiteNotes, data) + with pytest.raises(UnmappedPasHubLabel): + EpcPropertyDataMapper.from_site_notes(survey) + + class TestFromSiteNotesExample1: """ Fixture: pashub_rdsap_site_notes_example1.json @@ -472,7 +512,7 @@ class TestFromSiteNotesExample1: wall_construction="Cavity", wall_insulation_type="As built", wall_thickness_measured=True, - party_wall_construction="Cavity Masonry, Unfilled", + party_wall_construction=4, # "Cavity Masonry, Unfilled" → SAP10 code 4 (U=0.5) sap_floor_dimensions=[ SapFloorDimension( room_height_m=2.37,