fix(elmhurst): map secondary room-heater SAP codes to Table 4a fuel category

Completes `_elmhurst_secondary_fuel_from_sap_code` per SAP 10.2 §12
(PDF p.34: "Secondary heating systems and applicable fuel types are taken
from the room heaters section of Table 4a") + RdSAP 10 §10.4.1. Each
Table 4a room-heater code now resolves to its fuel CATEGORY's modal fuel:

  - gas room heaters    601-613 → mains gas   (26 → Table 32 1, 3.48 p/kWh)
  - liquid room heaters 621-625 → heating oil (28 → Table 32 4, 5.44 p/kWh)
  - solid room heaters  631-636 → house coal  (11 → Table 32 11, 3.67 p/kWh)
  - electric room htrs   691-694/699/701 → None (cascade electricity default)

Previously only the gas (601-613→26) and solid (631-634→11) blocks were
mapped; liquid heaters (621-625) and 635-636 fell through to None →
silently billed as electricity (13.19 p/kWh), a large mis-price for an
oil/solid heater. The prior slice raised on those; this maps them to the
correct category fuel instead, and keeps the raise ONLY for codes inside
the room-heater range (601-701) that are not a recognised Table 4a row.

The specific sub-fuel within a category (mains gas vs LPG vs biogas) is a
SEPARATE lodgement per §10.4.1 and is NOT exported in the Summary, so the
gas block stays the modal mains gas — worksheet "simulated case 37" lodged
its 605 live-effect fire on biogas (7.60 p/kWh), unrecoverable from the
Summary code alone (this is the entire +7 SAP case-37 gap: secondary
energy £131 + a separate biogas standing charge £70; every other line
matches the worksheet exactly, incl. (206) main efficiency 61%).

5 AAA tests, harness 47/47 (0 raised), pyright net-zero, regression clean,
corpus gauge unchanged (Elmhurst-path only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-15 06:27:37 +00:00
parent 9830ea2110
commit 0fae84d2b6
2 changed files with 90 additions and 41 deletions

View file

@ -5332,6 +5332,35 @@ def _elmhurst_pump_age_int(age_str: Optional[str]) -> Optional[int]:
return 2
# SAP 10.2 Table 4a "Room heaters" section — the secondary-heating SAP
# code → FUEL CATEGORY map. Per RdSAP 10 §10.4.1 + SAP 10.2 §12 (PDF
# p.34, "Secondary heating systems and applicable fuel types are taken
# from the room heaters section of Table 4a") each code carries an
# appliance type whose APPLICABLE FUELS are a category (gas / liquid /
# solid / electric); the SPECIFIC sub-fuel within that category (mains
# gas vs LPG vs biogas) is lodged SEPARATELY and is NOT recoverable from
# the Summary, which exports only the code. We therefore resolve each
# code to its category's MODAL fuel. Codes mirror the Table 4a efficiency
# rows in `domain.sap10_ml.sap_efficiencies` (601-613 gas, 621-625
# liquid, 631-636 solid, 691-694/699/701 electric).
_ELMHURST_SECONDARY_GAS_CODES: Final[frozenset[int]] = frozenset(
{601, 602, 603, 604, 605, 606, 607, 609, 610, 611, 612, 613}
)
_ELMHURST_SECONDARY_LIQUID_CODES: Final[frozenset[int]] = frozenset(
{621, 622, 623, 624, 625}
)
_ELMHURST_SECONDARY_SOLID_CODES: Final[frozenset[int]] = frozenset(
{631, 632, 633, 634, 635, 636}
)
_ELMHURST_SECONDARY_ELECTRIC_CODES: Final[frozenset[int]] = frozenset(
{691, 692, 693, 694, 699, 701}
)
# Modal Table 32 / `_ELMHURST_MAIN_FUEL_TO_SAP10` fuel code per category.
_SECONDARY_FUEL_MAINS_GAS: Final[int] = 26 # Table 32 code 1, 3.48 p/kWh
_SECONDARY_FUEL_HEATING_OIL: Final[int] = 28 # → Table 32 code 4, 5.44 p/kWh
_SECONDARY_FUEL_HOUSE_COAL: Final[int] = 11 # Table 32 code 11, 3.67 p/kWh
def _elmhurst_secondary_fuel_from_sap_code(
sap_code: Optional[int],
) -> Optional[int]:
@ -5342,49 +5371,43 @@ def _elmhurst_secondary_fuel_from_sap_code(
electricity when `secondary_fuel_type` is None correct for the
portable-electric default but wrong for fuel-fired room heaters.
SAP 10.2 Table 4a Category 10 ("Room heaters") code blocks:
601-613: Gas (mains gas / LPG / biogas) column A is mains gas;
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 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).
691-699: Electric room heaters. Cascade default (None) routes
to standard electricity (13.19 p/kWh).
Each code resolves to its SAP 10.2 Table 4a fuel CATEGORY's modal
fuel (per RdSAP 10 §10.4.1 the specific sub-fuel is a separate
lodgement the Summary omits):
- gas room heaters (601-613) mains gas (26)
- liquid room heaters (621-625) heating oil (28)
- solid room heaters (631-636) house coal (11)
- electric room heaters (691-699, None (cascade electricity
701) default; electricity IS the fuel)
A code in the room-heater range (601-701) that is NOT a recognised
Table 4a row RAISES `UnmappedElmhurstLabel` rather than silently
mis-fuelling per the strict-raise pattern. Codes outside the range
(e.g. None / no secondary) return None.
Cohort cert 2102-3018-0205-7886-5204 surfaces the 631 ("Open fire
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.
in grate") path — pre-fix 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.
SUB-FUEL CAVEAT: the gas block 601-613 resolves to the modal mains
gas; an LPG or biogas live-effect fire (worksheet "simulated case 37"
lodged biogas at 7.60 p/kWh vs mains gas 3.48 p/kWh) is
indistinguishable here the sub-fuel is not in the Summary export.
"""
if sap_code is None:
return None
if 601 <= sap_code <= 613:
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:
if sap_code in _ELMHURST_SECONDARY_GAS_CODES:
return _SECONDARY_FUEL_MAINS_GAS
if sap_code in _ELMHURST_SECONDARY_LIQUID_CODES:
return _SECONDARY_FUEL_HEATING_OIL
if sap_code in _ELMHURST_SECONDARY_SOLID_CODES:
return _SECONDARY_FUEL_HOUSE_COAL
if sap_code in _ELMHURST_SECONDARY_ELECTRIC_CODES:
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.
if 601 <= sap_code <= 701:
# A room-heater-range code we don't recognise — raise rather than
# let the cascade silently bill it as electricity.
raise UnmappedElmhurstLabel("secondary_heating.sap_code", str(sap_code))
return None

View file

@ -750,10 +750,36 @@ class TestElmhurstSecondaryFuelFromSapCode:
# 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.
def test_liquid_fuel_room_heater_resolves_to_heating_oil(self) -> None:
# Arrange — SAP code 621 (liquid-fuel room heater) → its Table 4a
# category's modal fuel, heating oil (28 → Table 32 code 4), NOT a
# silent electricity fallback.
from datatypes.epc.domain.mapper import (
_elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage]
)
# Act
fuel = _elmhurst_secondary_fuel_from_sap_code(621)
# Assert
assert fuel == 28
def test_solid_fuel_room_heater_resolves_to_house_coal(self) -> None:
# Arrange — SAP code 631 (open fire in grate) → house coal (11).
from datatypes.epc.domain.mapper import (
_elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage]
)
# Act
fuel = _elmhurst_secondary_fuel_from_sap_code(631)
# Assert
assert fuel == 11
def test_unrecognised_room_heater_range_code_raises(self) -> None:
# Arrange — SAP code 620 sits in the room-heater range (601-701) but
# is not a recognised Table 4a row; rather than silently mis-fuel it
# must raise.
from datatypes.epc.domain.mapper import (
UnmappedElmhurstLabel,
_elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage]
@ -761,4 +787,4 @@ class TestElmhurstSecondaryFuelFromSapCode:
# Act / Assert
with pytest.raises(UnmappedElmhurstLabel):
_elmhurst_secondary_fuel_from_sap_code(621)
_elmhurst_secondary_fuel_from_sap_code(620)