diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 00dace05..7e3add21 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index c10bec19..289d415a 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -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)