feat(baseline): sap_code_to_fuel normalizes via the calculator's own helper

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 18:24:39 +00:00
parent 3c0ac98122
commit d559298de2
3 changed files with 42 additions and 13 deletions

View file

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

View file

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

View file

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