From 597ca51dd9f1b92f67204f6ed0740194bddea65c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 14 Jul 2026 17:26:36 +0000 Subject: [PATCH 1/2] Map PasHub water-heating fields for cylinder dwellings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_map_sap_heating` set cylinder geometry/insulation but left `water_heating_code` / `water_heating_fuel` / `immersion_heating_type` None (and immersion as a raw label). For cylinder dwellings that loses the HW-system typing the calculator's water cascade keys off. Resolve them at the mapper boundary, gated on cylinder presence (combi dwellings keep the None default so HW inherits the main system, unchanged): - `water_heating_code` from the `system` label — "From main heating 1" -> 901, "Electric immersion" -> 903, community -> 950; strict-raises an unknown label. - `water_heating_fuel` = standard electricity (30) for the electric-immersion WHC 903; None otherwise (901 inherits the main fuel). - `immersion_heating_type` via the Elmhurst map (Single -> 2), cylinder-gated. Combi-dominated cohort so the accuracy impact is negligible by design (Tier 3): within-0.5 and MAE unchanged (12.9% / 2.56), harness still 206-xfail with no new hard failures. Closes #1565 Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 64 ++++++++++++++++++- .../epc/domain/tests/test_from_site_notes.py | 58 +++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 3e56e8cae..166e40686 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6071,6 +6071,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=[ @@ -6092,12 +6106,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 + ), ) @@ -7259,6 +7277,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 be334e293..324ddff45 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -188,6 +188,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 @@ -546,6 +603,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" From 56b551674efe57f7dee63f2e35f38009c7160ba2 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 14 Jul 2026 17:30:00 +0000 Subject: [PATCH 2/2] Map PasHub secondary-heating fuel to a SAP10 fuel code secondary_fuel_type was hardcoded None in the from_site_notes mapper, dropping the fuel for the ~67 Guinness GMCA 205 properties that lodge a "Panel, convector or radiant heaters" secondary heater. Adds _pashub_secondary_fuel_code (ADR-0015 pattern, mirrors _pashub_main_fuel_code) mapping "Electricity" -> SAP10 code 30, with "No Secondary Heating"/blank -> None and unknown labels strict-raising UnmappedPasHubLabel. Fixes #1564 Co-Authored-By: Claude Sonnet 5 --- datatypes/epc/domain/mapper.py | 29 +++++++++++---- .../epc/domain/tests/test_from_site_notes.py | 35 +++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) 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