fix(elmhurst): raise on unmapped fuel-fired secondary room-heater code

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-14 10:35:38 +00:00
parent ac77624d67
commit 9830ea2110
2 changed files with 71 additions and 1 deletions

View file

@ -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

View file

@ -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)