From d559298de2229d598d483a497f2f648bc8a4617f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:24:39 +0000 Subject: [PATCH] feat(baseline): sap_code_to_fuel normalizes via the calculator's own helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fuel codes the calculator now puts on SapResult are its own codes — raw gov-API enums or already-Table-32, depending on the source mapper (ADR-0015). sap_code_to_fuel now runs the code through table_32.to_table_32_code (promoted from private _to_table_32_code) — T32-first, then API-translate, the SAME normalization the calculator's pricing/CO2 helpers use — before the Table-32 -> Fuel dispatch, so the bill's carrier matches what the calculator billed (incl. the API/T32 collision codes, e.g. 20 = wood-logs not heat-net). Falls back to the raw code for billing fuels the price table omits (the 41-58 heat-network range), which resolve to HEAT_NETWORK -> UnpricedFuel — stricter than, and intentionally divergent from, the calculator's lossy default-to-mains-gas for an unpriced code (ADR-0014 §5). Co-Authored-By: Claude Opus 4.8 --- domain/property_baseline/sap_fuel.py | 29 ++++++++++++++----- domain/sap10_calculator/tables/table_32.py | 10 +++---- .../domain/property_baseline/test_sap_fuel.py | 16 ++++++++++ 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/domain/property_baseline/sap_fuel.py b/domain/property_baseline/sap_fuel.py index cd7c6efc..b0523a2f 100644 --- a/domain/property_baseline/sap_fuel.py +++ b/domain/property_baseline/sap_fuel.py @@ -4,13 +4,13 @@ from typing import Final from domain.fuel_rates.fuel import Fuel from domain.sap10_calculator.exceptions import UnmappedSapCode +from domain.sap10_calculator.tables.table_32 import to_table_32_code -# SAP 10.2 / Table 32 fuel code -> canonical billing Fuel (ADR-0014). Bounded to -# the ~47 Table 32 fuel codes (the keys of `table_12.UNIT_PRICE_P_PER_KWH`) — the -# carrier, NOT the PCDB product, so a thousand PCDB heat pumps all share one code. -# Input is a normalised Table 32 fuel code (the calculator sets `main_fuel_type` -# to Table 32 codes); an unmapped code raises `UnmappedSapCode` rather than -# guessing — a bounded, self-surfacing backlog [[reference-unmapped-sap-code]]. +# Table 32 fuel code -> canonical billing Fuel (ADR-0014). Bounded to the ~47 +# Table 32 fuel codes (the keys of `UNIT_PRICE_P_PER_KWH`) — the carrier, NOT the +# PCDB product, so a thousand PCDB heat pumps all share one code. An unmapped code +# raises `UnmappedSapCode` rather than guessing — a bounded, self-surfacing +# backlog [[reference-unmapped-sap-code]]. _CODE_TO_FUEL: Final[dict[int, Fuel]] = { **dict.fromkeys([1, 7], Fuel.MAINS_GAS), # mains gas, grid biogas **dict.fromkeys([2, 3, 5, 9], Fuel.LPG), @@ -29,13 +29,26 @@ _CODE_TO_FUEL: Final[dict[int, Fuel]] = { def sap_code_to_fuel(code: int) -> Fuel: - """Map a SAP 10.2 / Table 32 fuel code to its canonical billing Fuel. + """Map one of the calculator's per-end-use fuel codes to its billing Fuel. + + The code may be a raw gov-API `main_fuel_type` enum or an already-Table-32 + code depending on the source mapper (until [[adr-0015]] normalizes the cert), + so it is first run through the calculator's own ``to_table_32_code`` — + T32-first, then API-translate — the **same** normalization the calculator's + pricing/CO2 helpers use, so the bill's carrier matches what the calculator + billed. The normalized Table-32 code is then dispatched to a billing Fuel. Raises ``UnmappedSapCode`` on a code with no single billing carrier — e.g. dual fuel (10) or the grid-export codes (36/60), which are not an end use's input fuel. """ - fuel = _CODE_TO_FUEL.get(code) + # Normalize to a Table-32 code; fall back to the raw code for billing fuels + # the price table does not carry (the 41-58 heat-network range — `to_table_32_ + # code` returns None there, but they still resolve to HEAT_NETWORK and so to + # UnpricedFuel, which is stricter — and correct — than the calculator's + # lossy default-to-mains-gas for an unpriced code). + normalized = to_table_32_code(code) + fuel = _CODE_TO_FUEL.get(normalized if normalized is not None else code) if fuel is None: raise UnmappedSapCode("fuel_code", code) return fuel diff --git a/domain/sap10_calculator/tables/table_32.py b/domain/sap10_calculator/tables/table_32.py index 398603f7..955bad9c 100644 --- a/domain/sap10_calculator/tables/table_32.py +++ b/domain/sap10_calculator/tables/table_32.py @@ -194,7 +194,7 @@ _OFF_PEAK_STANDING_CODE: Final[dict[Tariff, int]] = { } -def _to_table_32_code(fuel_code: Optional[int]) -> Optional[int]: +def to_table_32_code(fuel_code: Optional[int]) -> Optional[int]: """Normalise a fuel code (Table 32 or API enum) to its Table 32 form.""" if fuel_code is None: return None @@ -204,7 +204,7 @@ def _to_table_32_code(fuel_code: Optional[int]) -> Optional[int]: def _is_gas_code(fuel_code: Optional[int]) -> bool: - code = _to_table_32_code(fuel_code) + code = to_table_32_code(fuel_code) return code is not None and code in _GAS_FUEL_CODES @@ -219,9 +219,9 @@ def is_electric_fuel_code(fuel_code: Optional[int]) -> bool: silently mis-classifies as electric. The S0380.135 EES-code → Table 32 mapper lookups set `main_fuel_type` to Table 32 codes (BDI → 10 = dual fuel), so the literal-set checks fail loudly here - unless normalised through `_to_table_32_code` first. + unless normalised through `to_table_32_code` first. """ - code = _to_table_32_code(fuel_code) + code = to_table_32_code(fuel_code) return code is not None and code in _ELECTRIC_FUEL_CODES @@ -235,7 +235,7 @@ def is_liquid_fuel_code(fuel_code: Optional[int]) -> bool: LPG is treated as GAS by Table 4f (separate "Gas boiler" row, 45 kWh/yr) — `is_liquid_fuel_code` returns False for LPG codes. """ - code = _to_table_32_code(fuel_code) + code = to_table_32_code(fuel_code) return code is not None and code in _LIQUID_FUEL_CODES diff --git a/tests/domain/property_baseline/test_sap_fuel.py b/tests/domain/property_baseline/test_sap_fuel.py index 24dcf193..dacdb075 100644 --- a/tests/domain/property_baseline/test_sap_fuel.py +++ b/tests/domain/property_baseline/test_sap_fuel.py @@ -35,6 +35,22 @@ def test_table_32_codes_map_to_their_billing_fuel(code: int, fuel: Fuel) -> None assert sap_code_to_fuel(code) == fuel +@pytest.mark.parametrize( + ("api_code", "fuel"), + [ + (26, Fuel.MAINS_GAS), # gov-API mains-gas enum -> Table 32 code 1 + (0, Fuel.ELECTRICITY), # API "electricity" -> Table 32 code 30 + (25, Fuel.HEAT_NETWORK), # API community heat -> Table 32 code 41 + (14, Fuel.COAL), # API house coal -> Table 32 code 11 + ], +) +def test_raw_api_fuel_codes_normalize_before_mapping(api_code: int, fuel: Fuel) -> None: + # Arrange — the calculator may carry a raw gov-API fuel code (not yet a Table + # 32 code); sap_code_to_fuel normalizes via the calculator's own helper first. + # Act / Assert + assert sap_code_to_fuel(api_code) == fuel + + def test_an_unmapped_code_raises_rather_than_guessing() -> None: # Arrange — code 10 (dual fuel) has no single billing fuel. # Act / Assert