diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 681480feb..27ed37fb0 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6066,6 +6066,20 @@ def _map_sap_heating( ) ) + # Water heating is only typed for cylinder dwellings; a combi leaves the WHC + # None so HW inherits the main system (issue #1565). + cylinder_present = heating.water_heating.cylinder_size != "No Cylinder" + water_heating_code = ( + _pashub_water_heating_code(heating.water_heating.system) + if cylinder_present + else None + ) + water_heating_fuel = ( + _PASHUB_STANDARD_ELECTRICITY_FUEL + if water_heating_code == _PASHUB_WHC_ELECTRIC_IMMERSION + else None + ) + return SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), main_heating_details=[ @@ -6087,12 +6101,16 @@ def _map_sap_heating( shower_outlets=shower_outlets, cylinder_size=( heating.water_heating.cylinder_size - if heating.water_heating.cylinder_size != "No Cylinder" + if cylinder_present else None ), cylinder_insulation_type=heating.water_heating.insulation_type, cylinder_insulation_thickness_mm=heating.water_heating.insulation_thickness_mm, - immersion_heating_type=heating.water_heating.immersion_type, + water_heating_code=water_heating_code, + water_heating_fuel=water_heating_fuel, + immersion_heating_type=_pashub_immersion_type_code( + heating.water_heating.immersion_type, cylinder_present + ), ) @@ -7276,6 +7294,48 @@ def _pashub_glazing_type_int(label: Optional[str]) -> Union[int, str]: return _PASHUB_GLAZING_TYPE_TO_SAP10[key] +# PasHub water-heating `system` label → SAP10 water-heating code (WHC) that the +# calculator's water cascade keys off (cert_to_inputs: `_WHC_FROM_MAIN_HEATING` +# 901 inherits the main fuel; `_WHC_ELECTRIC_IMMERSION` 903; heat-network water +# 950). Only lodged for cylinder dwellings — combi dwellings leave it None and HW +# inherits from the main system (issue #1565). +_PASHUB_WATER_HEATING_SYSTEM_TO_SAP10: Dict[str, int] = { + "From main heating 1": 901, + "Electric immersion": 903, + "Hot water only community scheme - boilers": 950, +} +_PASHUB_WHC_ELECTRIC_IMMERSION: Final[int] = 903 +_PASHUB_STANDARD_ELECTRICITY_FUEL: Final[int] = 30 # Table 32 standard electricity + + +def _pashub_water_heating_code(system_label: Optional[str]) -> Optional[int]: + """Resolve a PasHub water-heating `system` label to its SAP10 WHC code. Blank + is "no lodging" (None); an unmapped label strict-raises `UnmappedPasHubLabel`. + Call only for cylinder dwellings (#1565).""" + if system_label is None or not system_label.strip(): + return None + key = system_label.strip() + if key not in _PASHUB_WATER_HEATING_SYSTEM_TO_SAP10: + raise UnmappedPasHubLabel("water heating system", system_label) + return _PASHUB_WATER_HEATING_SYSTEM_TO_SAP10[key] + + +def _pashub_immersion_type_code( + immersion_label: Optional[str], cylinder_present: bool +) -> Optional[int]: + """Resolve a PasHub `immersion_type` label to the SAP10 `immersion_heating_type` + code, reusing the Elmhurst map (1 = dual, 2 = single, RdSAP 10 §10.5). None + when no cylinder or no immersion lodged; unmapped label strict-raises.""" + if not cylinder_present or immersion_label is None: + return None + key = immersion_label.strip() + if key in ("", "None"): + return None + if key not in _ELMHURST_IMMERSION_TYPE_LABEL_TO_SAP10: + raise UnmappedPasHubLabel("immersion type", immersion_label) + return _ELMHURST_IMMERSION_TYPE_LABEL_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 52e6d22ba..04e13eeec 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -223,6 +223,63 @@ class TestGlazingTypeCoding: EpcPropertyDataMapper.from_site_notes(survey) +class TestWaterHeatingCoding: + """`from_site_notes` must int-code the PAS Hub water-heating fields for + **cylinder** dwellings — `water_heating_code` (WHC), `water_heating_fuel`, + `immersion_heating_type` — not leave them None/raw. Combi dwellings (no + cylinder) keep the None default (HW inherits from the main system), so they + are untouched (issue #1565). example1 is a cylinder dwelling. + """ + + def _survey(self, **water: object) -> PasHubRdSapSiteNotes: + data = load("pashub_rdsap_site_notes_example1.json") + data["heating_and_hot_water"]["water_heating"].update(water) + return from_dict(PasHubRdSapSiteNotes, data) + + def test_cylinder_from_main_maps_whc_901(self) -> None: + result = EpcPropertyDataMapper.from_site_notes( + self._survey(system="From main heating 1", cylinder_size="Normal (90-130 litres)") + ) + assert result.sap_heating.water_heating_code == 901 + assert result.sap_heating.water_heating_fuel is None + + def test_electric_immersion_maps_903_electric_fuel_and_single_immersion(self) -> None: + result = EpcPropertyDataMapper.from_site_notes( + self._survey( + system="Electric immersion", + cylinder_size="Normal (90-130 litres)", + immersion_type="Single", + ) + ) + assert result.sap_heating.water_heating_code == 903 + assert result.sap_heating.water_heating_fuel == 30 # standard electricity + assert result.sap_heating.immersion_heating_type == 2 # single + + def test_community_scheme_maps_950(self) -> None: + result = EpcPropertyDataMapper.from_site_notes( + self._survey( + system="Hot water only community scheme - boilers", + cylinder_size="Normal (90-130 litres)", + ) + ) + assert result.sap_heating.water_heating_code == 950 + + def test_combi_leaves_whc_none(self) -> None: + result = EpcPropertyDataMapper.from_site_notes( + self._survey(system="From main heating 1", cylinder_size="No Cylinder") + ) + assert result.sap_heating.water_heating_code is None + + def test_unknown_cylinder_system_strict_raises(self) -> None: + with pytest.raises(UnmappedPasHubLabel): + EpcPropertyDataMapper.from_site_notes( + self._survey( + system="Perpetual motion boiler", + cylinder_size="Normal (90-130 litres)", + ) + ) + + class TestFromSiteNotesExample1: """ Fixture: pashub_rdsap_site_notes_example1.json @@ -581,6 +638,7 @@ class TestFromSiteNotesExample1: cylinder_size="Normal (90-130 litres)", cylinder_insulation_type="Factory fitted", cylinder_insulation_thickness_mm=12, + water_heating_code=901, # cylinder + "From main heating 1" → WHC 901 shower_outlets=ShowerOutlets( shower_outlet=ShowerOutlet( shower_outlet_type="Non-Electric Shower"