From 9830ea21100edd51d218f0ab8d64789ba20ab266 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 14 Jun 2026 10:35:38 +0000 Subject: [PATCH] fix(elmhurst): raise on unmapped fuel-fired secondary room-heater code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Elmhurst Summary lodges only the secondary heating SAP code (Table 4a Category 10), never its fuel. `_elmhurst_secondary_fuel_from_sap_code` mapped the gas block (601-613 → mains gas) and solid block (631-634 → house coal) to their modal defaults, but returned None for any OTHER Category-10 code — and None makes the cascade SILENTLY bill the secondary as electricity (13.19 p/kWh). For a fuel-fired heater (e.g. 621-625 liquid-fuel oil/bioethanol) that is a large, invisible mis-price. Per the UnmappedElmhurstLabel strict-raise pattern (mirrors the wall_type / glazing label raises), a fuel-fired Category-10 code (601-699) outside the mapped gas/solid blocks now RAISES instead of guessing. Electric room heaters (691-699) keep returning None — electricity IS their fuel. The gas block 601-613 still resolves to the modal default mains gas: the Summary cannot distinguish mains gas from LPG/biogas, so an LPG or biogas live-effect fire (worksheet "simulated case 37" used biogas at 7.60 p/kWh vs our 3.48 p/kWh mains-gas default, a +7 SAP gap) is not recoverable from the Summary export — that is a data-availability limit, not a guess we can fix here. This commit closes the genuinely-silent-wrong path; the gas sub-fuel remains the documented modal default. Worksheet harness 47/47, 0 raised. 3 AAA tests, pyright net-zero, regression clean, corpus gauge unchanged (Elmhurst-path only; the API path lodges the secondary fuel explicitly). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 23 ++++++++- .../epc/domain/tests/test_from_site_notes.py | 49 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index e744e9fb..00dace05 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -5347,7 +5347,7 @@ def _elmhurst_secondary_fuel_from_sap_code( column B for LPG. Cohort default is mains gas (`_ELMHURST_MAIN_FUEL_TO_SAP10["Mains gas"] = 26`). 621-625: Liquid fuel room heaters (oil / bioethanol). Cohort - not yet exercised; deferred until a fixture surfaces. + not yet exercised — NOT mapped, so they RAISE (see below). 631-634: Solid fuel room heaters (open fire, closed room heater with/without boiler). House coal is the modal default per Table 12 secondary rate (3.67 p/kWh). @@ -5358,6 +5358,21 @@ def _elmhurst_secondary_fuel_from_sap_code( in grate") path — pre-slice the cascade defaulted to electricity at 13.19 p/kWh, over-charging secondary by ~£340/yr and pushing SAP -15.81 below the worksheet's 63.87. + + RAISE-don't-guess: a Category-10 room-heater code (601-699) that is + fuel-fired but NOT in the mapped gas/solid blocks (e.g. 621-625 liquid + fuel) used to return None → the cascade silently billed it as + electricity (13.19 p/kWh), a large mis-price for an oil/LPG heater. + Per the `UnmappedElmhurstLabel` strict-raise pattern these now raise + so the gap surfaces instead of producing a wrong SAP. Electric room + heaters (691-699) keep returning None — electricity IS their fuel. + + NOTE the gas block 601-613 still resolves to the MODAL default mains + gas: the Summary lodges only the SAP code, never the gas sub-fuel, so + an LPG or biogas live-effect fire (worksheet "simulated case 37" used + biogas at 7.60 p/kWh) is indistinguishable here from mains gas — the + cohort default is correct for the common case and the rarer sub-fuels + are not recoverable from the Summary export. """ if sap_code is None: return None @@ -5365,6 +5380,12 @@ def _elmhurst_secondary_fuel_from_sap_code( return 26 # Mains gas, matching `_ELMHURST_MAIN_FUEL_TO_SAP10` if 631 <= sap_code <= 634: return 11 # House coal (Coal in `_ELMHURST_MAIN_FUEL_TO_SAP10`) + if 691 <= sap_code <= 699: + return None # Electric room heaters → cascade electricity default + if 601 <= sap_code <= 699: + # Fuel-fired Category-10 room heater we do not yet map — raise + # rather than let the cascade silently bill it as electricity. + raise UnmappedElmhurstLabel("secondary_heating.sap_code", str(sap_code)) return None diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index e1d0e2cf..c10bec19 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -713,3 +713,52 @@ class TestUnmeasurableWallThickness: def test_wall_thickness_mm_is_none(self, result: EpcPropertyData) -> None: assert result.sap_building_parts[0].wall_thickness_mm is None + + +class TestElmhurstSecondaryFuelFromSapCode: + """`_elmhurst_secondary_fuel_from_sap_code` must RAISE on an unmapped + fuel-fired Table 4a Category-10 room-heater code rather than return + None and let the cascade silently bill it as electricity (13.19 + p/kWh). The Summary lodges only the secondary SAP code, not its fuel. + """ + + def test_gas_room_heater_resolves_to_mains_gas_modal_default(self) -> None: + # Arrange — SAP code 605 (flush-fitting live-effect gas fire), in + # the 601-613 gas block. The Summary cannot distinguish mains gas + # from LPG/biogas, so the modal default is mains gas (26). + from datatypes.epc.domain.mapper import ( + _elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage] + ) + + # Act + fuel = _elmhurst_secondary_fuel_from_sap_code(605) + + # Assert + assert fuel == 26 + + def test_electric_room_heater_returns_none_for_electricity_default(self) -> None: + # Arrange — SAP code 693 (electric room heater); electricity IS its + # fuel, so None (→ cascade electricity default) is correct, NOT a + # silent mis-fuel. + from datatypes.epc.domain.mapper import ( + _elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage] + ) + + # Act + fuel = _elmhurst_secondary_fuel_from_sap_code(693) + + # Assert + assert fuel is None + + def test_unmapped_liquid_fuel_room_heater_raises(self) -> None: + # Arrange — SAP code 621 (liquid-fuel room heater) is fuel-fired but + # not in the mapped gas/solid blocks; returning None would silently + # bill it as electricity. It must raise instead. + from datatypes.epc.domain.mapper import ( + UnmappedElmhurstLabel, + _elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert + with pytest.raises(UnmappedElmhurstLabel): + _elmhurst_secondary_fuel_from_sap_code(621)