From 8d45fcb544bc17e81ead6fa1febe4b0f959f6728 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Jul 2026 15:16:08 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Normalize=20PasHub=20site-notes=20emitter?= =?UTF-8?q?=20label=20to=20its=20SAP10=20code=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/tests/test_from_site_notes.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index e23d71f57..53917b864 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -165,10 +165,9 @@ class TestFromSiteNotesExample1: assert result.sap_heating.main_heating_details[0].main_fuel_type == 26 def test_main_heating_emitter(self, result: EpcPropertyData) -> None: - # heating_and_hot_water.main_heating.emitter: "Radiators" - assert ( - result.sap_heating.main_heating_details[0].heat_emitter_type == "Radiators" - ) + # heating_and_hot_water.main_heating.emitter: "Radiators" is normalized + # at the mapper boundary to SAP10 emitter code 1 (matching Elmhurst). + assert result.sap_heating.main_heating_details[0].heat_emitter_type == 1 def test_main_heating_no_fghrs(self, result: EpcPropertyData) -> None: # heating_and_hot_water.main_heating.flue_gas_heat_recovery_system: false @@ -380,7 +379,7 @@ class TestFromSiteNotesExample1: MainHeatingDetail( has_fghrs=False, main_fuel_type=26, - heat_emitter_type="Radiators", + heat_emitter_type=1, emitter_temperature="Unknown", fan_flue_present=True, main_heating_control="Programmer, room thermostat and TRVs", From 5b0f25847ceb3fc7ff3f8320271b717f06b5d38c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Jul 2026 15:17:06 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Normalize=20PasHub=20site-notes=20emitter?= =?UTF-8?q?=20label=20to=20its=20SAP10=20code=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _map_sap_heating resolves the raw emitter label (e.g. Radiators) to its SAP10 emitter code via _pashub_heat_emitter_code, reusing the Elmhurst emitter map. Blank passes through; an unrecognised label strict-raises UnmappedPasHubLabel at the mapper boundary (ADR-0015) instead of resurfacing as the calculator's UnmappedSapCode: heat_emitter_type. Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b3bfdb370..266fa6e71 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6028,7 +6028,7 @@ def _map_sap_heating( MainHeatingDetail( has_fghrs=main.flue_gas_heat_recovery_system, main_fuel_type=_pashub_main_fuel_code(fuel_type), - heat_emitter_type=main.emitter, + heat_emitter_type=_pashub_heat_emitter_code(main.emitter), emitter_temperature=main.emitter_temperature, fan_flue_present=main.fan_assist, main_heating_control=main.controls, @@ -7081,6 +7081,20 @@ def _pashub_main_fuel_code(fuel_label: str) -> Union[int, str]: return code +def _pashub_heat_emitter_code(emitter_label: str) -> Union[int, str]: + """Resolve a PasHub surveyed main-heating emitter label to its SAP10 emitter + code at the mapper boundary (ADR-0015), reusing the Elmhurst emitter map + (`Radiators -> 1`). A blank label passes through unchanged; a non-empty label + the map does not cover strict-raises `UnmappedPasHubLabel` here rather than + resurfacing downstream as the calculator's `UnmappedSapCode: heat_emitter_type`.""" + if not emitter_label: + return emitter_label + code = _ELMHURST_HEAT_EMITTER_TO_SAP10.get(emitter_label) + if code is None: + raise UnmappedPasHubLabel("heat emitter", emitter_label) + return code + + def _resolve_elmhurst_underfloor_subtype( main_floor: ElmhurstFloorDetails, main_age_band: str, From 64e31742b337f1cd2cfa4ead912148f84ea98426 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Jul 2026 15:17:56 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Strict-raise=20on=20an=20unrecognised=20Pas?= =?UTF-8?q?Hub=20main-heating=20emitter=20label=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/domain/tests/test_from_site_notes.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 53917b864..862f301af 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -858,3 +858,25 @@ class TestPasHubUnmappedMainFuel: # Assert assert result.sap_heating.main_heating_details[0].main_fuel_type == 30 + + +class TestPasHubUnmappedHeatEmitter: + """A PasHub survey whose surveyed main-heating emitter label the mapper does + not yet cover must strict-raise `UnmappedPasHubLabel` at the boundary, + naming the label — so a coverage gap is fixed here (a one-line lookup + addition) rather than passed downstream as a raw string to resurface as the + calculator's `UnmappedSapCode: heat_emitter_type`. + """ + + def test_unrecognised_emitter_label_raises_naming_the_label(self) -> None: + # Arrange — an example fixture whose emitter cell carries a label the + # Elmhurst emitter map does not cover. + from datatypes.epc.domain.mapper import UnmappedPasHubLabel + + raw = load("pashub_rdsap_site_notes_example1.json") + raw["heating_and_hot_water"]["main_heating"]["emitter"] = "Skirting heaters" + survey = from_dict(PasHubRdSapSiteNotes, raw) + + # Act / Assert + with pytest.raises(UnmappedPasHubLabel, match="Skirting heaters"): + EpcPropertyDataMapper.from_site_notes(survey)