diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 3e56e8cae..681480feb 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6042,12 +6042,7 @@ def _map_sap_heating( main = heating.main_heating secondary = heating.secondary_heating - # secondary_fuel_type is an int code in the domain model; we can't map a - # site-notes string directly, so leave it None unless there is secondary heating. - # The string fuel type is preserved via sap_heating when needed. - secondary_fuel_type = ( - None if secondary.secondary_fuel == "No Secondary Heating" else None - ) + secondary_fuel_type = _pashub_secondary_fuel_code(secondary.secondary_fuel) shower_outlets = ( ShowerOutlets( @@ -7115,6 +7110,28 @@ _PASHUB_MAIN_FUEL_TO_SAP10: Dict[str, int] = { "Electricity": 30, # the standard-electricity fuel code } +# PasHub surveyed secondary-heating `secondary_fuel` label → SAP 10.2 Table 32 +# fuel code that `secondary_fuel_type` (an int in the domain model) consumes. +# "No Secondary Heating" is the no-secondary-heater shape and maps to None, +# not a fuel code. +_PASHUB_SECONDARY_FUEL_TO_SAP10: Dict[str, int] = { + "Electricity": 30, # standard electricity, Table 32 code 30 +} + + +def _pashub_secondary_fuel_code(fuel_label: str) -> Optional[int]: + """Resolve a PasHub surveyed secondary-heating Fuel label to a SAP10 + Table 32 fuel code at the mapper boundary (ADR-0015). "No Secondary + Heating" is the no-secondary-heater shape and maps to None; a non-empty + label the lookup does not cover strict-raises `UnmappedPasHubLabel` so + the gap is fixed here, never silently mis-billed downstream.""" + if not fuel_label or fuel_label == "No Secondary Heating": + return None + code = _PASHUB_SECONDARY_FUEL_TO_SAP10.get(fuel_label) + if code is None: + raise UnmappedPasHubLabel("secondary fuel", fuel_label) + return code + def _pashub_main_fuel_code(fuel_label: str) -> Union[int, str]: """Resolve a PasHub surveyed main-heating Fuel label to a SAP10 fuel code diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index be334e293..52e6d22ba 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -113,6 +113,41 @@ class TestWallConstructionCoding: EpcPropertyDataMapper.from_site_notes(survey) +class TestSecondaryFuelTypeCoding: + """`from_site_notes` must int-code the PAS Hub secondary-heating + `secondary_fuel` label to the SAP 10.2 Table 32 `secondary_fuel_type` + code, not leave it hardcoded `None`. ~67/205 Guinness cohort properties + lodge a "Panel, convector or radiant heaters" secondary heater whose + fuel was silently dropped (issue #1564). + """ + + @pytest.mark.parametrize( + "label, expected_code", + [ + ("Electricity", 30), # standard electricity, Table 32 code 30 + ("No Secondary Heating", None), # no secondary heater lodged + ("", 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["heating_and_hot_water"]["secondary_heating"]["secondary_fuel"] = label + survey = from_dict(PasHubRdSapSiteNotes, data) + result = EpcPropertyDataMapper.from_site_notes(survey) + assert result.sap_heating.secondary_fuel_type == expected_code + + def test_unknown_label_strict_raises(self) -> None: + data = load("pashub_rdsap_site_notes_example1.json") + data["heating_and_hot_water"]["secondary_heating"][ + "secondary_fuel" + ] = "Bulk LPG" + survey = from_dict(PasHubRdSapSiteNotes, data) + with pytest.raises(UnmappedPasHubLabel): + 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