From 52c5826afc2261f5694ef6c5b40b6df857d9fe66 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Jul 2026 14:27:34 +0000 Subject: [PATCH 1/5] =?UTF-8?q?Normalize=20PasHub=20main-heating=20Mains?= =?UTF-8?q?=20gas=20to=20SAP=20fuel=20code=2026=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 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index bde715237..ad18a969e 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -160,8 +160,9 @@ class TestFromSiteNotesExample1: # --- main heating --- def test_main_heating_fuel(self, result: EpcPropertyData) -> None: - # heating_and_hot_water.main_heating.fuel: "Mains gas" - assert result.sap_heating.main_heating_details[0].main_fuel_type == "Mains gas" + # heating_and_hot_water.main_heating.fuel: "Mains gas" is normalized + # at the mapper boundary to SAP fuel code 26 (matching Elmhurst). + 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" From 02ad8701d38275daa0dd9dac087452fa7bcd8cef Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Jul 2026 14:29:26 +0000 Subject: [PATCH 2/5] =?UTF-8?q?Normalize=20PasHub=20main-heating=20Mains?= =?UTF-8?q?=20gas=20to=20SAP=20fuel=20code=2026=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) --- datatypes/epc/domain/mapper.py | 24 ++++++++++++++++++- .../epc/domain/tests/test_from_site_notes.py | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index ad9883bc3..e9e72ab6c 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6027,7 +6027,7 @@ def _map_sap_heating( main_heating_details=[ MainHeatingDetail( has_fghrs=main.flue_gas_heat_recovery_system, - main_fuel_type=fuel_type, + main_fuel_type=_pashub_main_fuel_code(fuel_type), heat_emitter_type=main.emitter, emitter_temperature=main.emitter_temperature, fan_flue_present=main.fan_assist, @@ -7030,6 +7030,28 @@ def _elmhurst_main_fuel_int(fuel_type: str) -> Optional[int]: return _ELMHURST_MAIN_FUEL_TO_SAP10.get(fuel_type) +# PasHub Site-Notes surveyed main-heating "Fuel" labels mapped to SAP10 fuel +# codes (the epc-codes `main_fuel` enum the calculator's resolver routes via +# its API→Table-12/32 translation). Seeded only with labels confirmed from +# live PasHub fixtures; a dedicated lookup rather than reuse of the Elmhurst +# map, which carries legacy label oddities PasHub must not inherit. New labels +# are a one-line addition — until then they strict-raise at the boundary +# (ADR-0015) rather than being silently mis-billed downstream. +_PASHUB_MAIN_FUEL_TO_SAP10: Dict[str, int] = { + "Mains gas": 26, # matches the Elmhurst mapper's mains-gas code + "Electricity": 30, # the standard-electricity fuel 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 + at the mapper boundary (ADR-0015). A genuinely blank label is the "no main + heating" shape and passes through unchanged.""" + if not fuel_label: + return fuel_label + return _PASHUB_MAIN_FUEL_TO_SAP10.get(fuel_label, fuel_label) + + 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 ad18a969e..81670e239 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -379,7 +379,7 @@ class TestFromSiteNotesExample1: main_heating_details=[ MainHeatingDetail( has_fghrs=False, - main_fuel_type="Mains gas", + main_fuel_type=26, heat_emitter_type="Radiators", emitter_temperature="Unknown", fan_flue_present=True, From 1fead6e4c7890235e4bc3c73703450509b54d159 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Jul 2026 14:30:49 +0000 Subject: [PATCH 3/5] =?UTF-8?q?Strict-raise=20on=20an=20unrecognised=20Pas?= =?UTF-8?q?Hub=20main-heating=20Fuel=20label=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/mapper.py | 24 +++++++++++++++++++ .../epc/domain/tests/test_from_site_notes.py | 22 +++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index e9e72ab6c..a9f9e5cae 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -7030,6 +7030,30 @@ def _elmhurst_main_fuel_int(fuel_type: str) -> Optional[int]: return _ELMHURST_MAIN_FUEL_TO_SAP10.get(fuel_type) +class UnmappedPasHubLabel(ValueError): + """A PasHub Site-Notes survey lodged a finite-enum label that the mapper + does not yet know how to translate to the SAP10 cascade enum. + + Raised by the strict PasHub label-to-code helpers to surface mapper- + coverage gaps at the `from_site_notes` boundary (ADR-0015) instead of + passing the raw string downstream, where it resurfaces as the + calculator's deep `MissingMainFuelType` raise — or worse, is silently + mis-billed. Mirrors `UnmappedApiCode` / `UnmappedElmhurstLabel`. + + Distinguish "lodging absent" (a genuinely blank cell — the helper returns + it unchanged, correct) from "lodging present but unrecognised" (raise — + a Fuel label the lookup doesn't yet cover, needs a dict entry added). + """ + + def __init__(self, field: str, value: str) -> None: + super().__init__( + f"unmapped PasHub {field} label: {value!r}; " + f"add an entry to the corresponding mapper lookup dict" + ) + self.field = field + self.value = value + + # PasHub Site-Notes surveyed main-heating "Fuel" labels mapped to SAP10 fuel # codes (the epc-codes `main_fuel` enum the calculator's resolver routes via # its API→Table-12/32 translation). Seeded only with labels confirmed from diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 81670e239..52598b720 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -820,3 +820,25 @@ class TestElmhurstSecondaryFuelFromSapCode: # Act / Assert with pytest.raises(UnmappedElmhurstLabel): _elmhurst_secondary_fuel_from_sap_code(620) + + +class TestPasHubUnmappedMainFuel: + """A PasHub survey whose surveyed main-heating Fuel 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 `MissingMainFuelType`, or silently mis-billed. + """ + + def test_unrecognised_fuel_label_raises_naming_the_label(self) -> None: + # Arrange — an example fixture whose Fuel cell carries a label the + # PasHub lookup does not yet know. + from datatypes.epc.domain.mapper import UnmappedPasHubLabel + + raw = load("pashub_rdsap_site_notes_example1.json") + raw["heating_and_hot_water"]["main_heating"]["fuel"] = "Anthracite" + survey = from_dict(PasHubRdSapSiteNotes, raw) + + # Act / Assert + with pytest.raises(UnmappedPasHubLabel, match="Anthracite"): + EpcPropertyDataMapper.from_site_notes(survey) From a8afcd1b3daeb2eea37e0928f86b26225befd8da Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Jul 2026 14:31:32 +0000 Subject: [PATCH 4/5] =?UTF-8?q?Strict-raise=20on=20an=20unrecognised=20Pas?= =?UTF-8?q?Hub=20main-heating=20Fuel=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) --- datatypes/epc/domain/mapper.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index a9f9e5cae..b3bfdb370 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -7070,10 +7070,15 @@ _PASHUB_MAIN_FUEL_TO_SAP10: Dict[str, int] = { def _pashub_main_fuel_code(fuel_label: str) -> Union[int, str]: """Resolve a PasHub surveyed main-heating Fuel label to a SAP10 fuel code at the mapper boundary (ADR-0015). A genuinely blank label is the "no main - heating" shape and passes through unchanged.""" + heating" shape and passes through unchanged; 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: return fuel_label - return _PASHUB_MAIN_FUEL_TO_SAP10.get(fuel_label, fuel_label) + code = _PASHUB_MAIN_FUEL_TO_SAP10.get(fuel_label) + if code is None: + raise UnmappedPasHubLabel("main fuel", fuel_label) + return code def _resolve_elmhurst_underfloor_subtype( From b91763fc1dfd4534250af930566d03efe2b89dcb Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Jul 2026 14:34:28 +0000 Subject: [PATCH 5/5] =?UTF-8?q?Preserve=20PasHub=20blank-Fuel=20electric-s?= =?UTF-8?q?ystem=20inference=20through=20fuel-code=20normalization=20?= =?UTF-8?q?=F0=9F=9F=AA?= 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 | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 52598b720..e23d71f57 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -842,3 +842,20 @@ class TestPasHubUnmappedMainFuel: # Act / Assert with pytest.raises(UnmappedPasHubLabel, match="Anthracite"): EpcPropertyDataMapper.from_site_notes(survey) + + def test_blank_fuel_electric_system_resolves_to_electricity_code(self) -> None: + # Arrange — an all-electric system whose surveyed Fuel cell is blank; + # the existing electric-system inference must survive normalization and + # emit the standard-electricity SAP fuel code (30), not the label. + raw = load("pashub_rdsap_site_notes_example1.json") + raw["heating_and_hot_water"]["main_heating"]["fuel"] = "" + raw["heating_and_hot_water"]["main_heating"][ + "system_type" + ] = "Electric storage heaters" + survey = from_dict(PasHubRdSapSiteNotes, raw) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert + assert result.sap_heating.main_heating_details[0].main_fuel_type == 30