fix(fuel): strict-raise on unmapped Table-12 factor fuel codes

Tier-1 finding of the silent-fallback audit. The fuel-type helpers fed the
SAP 10.2 Table 12/32 cost/CO2/PE lookups via a silent
`API_FUEL_TO_TABLE_12.get(fuel, fuel)` passthrough at 5 sites
(_heat_network_factor_fuel_code, HW CO2/PE, _secondary_fuel_code, PV). A fuel
code in NEITHER the API enum map NOR the Table-12 numbering passed straight
through to the mains-gas default baked into unit_price_p_per_kwh /
co2_factor_kg_per_kwh / primary_energy_factor (table_12.py:233/274/287,
table_32.py:190) — silently mis-pricing a novel/colliding fuel as grid gas.
This is the class that mis-priced cert 8536's community biomass as
electricity (-17 SAP) before a7761ea8.

New _table_12_factor_fuel_code mirrors .get(fuel, fuel) EXACTLY for every
recognised input (union of the CO2/PE/price/monthly table keys +
API_FUEL_TO_TABLE_12 values) and raises UnmappedSapCode only when the
resolved code is recognised by no table — surfacing the gap loudly per the
strict-raise principle (reference_unmapped_sap_code). Verified behaviour-
preserving: 0/909 corpus certs hit the new raise; eval unchanged at 54.9%
within-0.5 / 909 computed / 0 raises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-09 10:05:57 +00:00
parent ddb9fdbec5
commit 7878a96900
2 changed files with 82 additions and 9 deletions

View file

@ -87,7 +87,10 @@ from domain.sap10_calculator.tables.pcdb.postcode_weather import (
from domain.sap10_calculator.tables.table_12 import (
API_FUEL_TO_TABLE_12,
CO2_KG_PER_KWH,
CO2_KG_PER_KWH_MONTHLY,
PE_FACTOR_MONTHLY,
PRIMARY_ENERGY_FACTOR,
UNIT_PRICE_P_PER_KWH,
_DEFAULT_CO2_KG_PER_KWH, # pyright: ignore[reportPrivateUsage]
_DEFAULT_PEF, # pyright: ignore[reportPrivateUsage]
co2_monthly_factors_kg_per_kwh,
@ -2040,6 +2043,42 @@ def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]:
raise MissingMainFuelType(fuel, main.sap_main_heating_code)
# Fuel codes the Table 12 / Table 32 factor & price lookups recognise as a
# DIRECT key (vs falling through to their mains-gas default). The union of
# every per-fuel column the cost/CO2/PE cascade consumes, plus the values
# `API_FUEL_TO_TABLE_12` translates to (all valid Table-12 codes). A code in
# this set — or translatable into it via the API enum map — is priced/
# factored correctly; a code in NEITHER would silently default to mains gas.
_RECOGNISED_TABLE_12_FUEL_CODES: Final[frozenset[int]] = frozenset(
set(CO2_KG_PER_KWH)
| set(PRIMARY_ENERGY_FACTOR)
| set(UNIT_PRICE_P_PER_KWH)
| set(CO2_KG_PER_KWH_MONTHLY)
| set(PE_FACTOR_MONTHLY)
| set(API_FUEL_TO_TABLE_12.values())
)
def _table_12_factor_fuel_code(fuel: int) -> int:
"""`API_FUEL_TO_TABLE_12.get(fuel, fuel)` with a STRICT tail.
Returns the Table-12 factor code for the cost / CO2 / PE lookups, with
behaviour identical to the prior silent passthrough for every recognised
input. The one difference: when the resolved code is neither translatable
via the API enum map NOR already a recognised Table-12/32 fuel code, it
raises `UnmappedSapCode` instead of passing the unknown code through to
the mains-gas default baked into `unit_price_p_per_kwh` /
`co2_factor_kg_per_kwh` / `primary_energy_factor` (the silent fuel-
collision class cert 8536's community biomass mis-priced as grid
electricity was this pattern). Mirror of the strict-raise principle
([[reference-unmapped-sap-code]]).
"""
code = API_FUEL_TO_TABLE_12.get(fuel, fuel)
if code in _RECOGNISED_TABLE_12_FUEL_CODES:
return code
raise UnmappedSapCode("table_12_factor_fuel", fuel)
def _heat_network_factor_fuel_code(
main: Optional[MainHeatingDetail],
) -> Optional[int]:
@ -2068,7 +2107,7 @@ def _heat_network_factor_fuel_code(
fuel = _main_fuel_code(main)
if fuel is None or not _is_heat_network_main(main):
return fuel
return API_FUEL_TO_TABLE_12.get(fuel, fuel)
return _table_12_factor_fuel_code(fuel)
def _fuel_cost_gbp_per_kwh(
@ -3627,7 +3666,7 @@ def _hot_water_co2_factor_kg_per_kwh(
return _DEFAULT_CO2_KG_PER_KWH
table_12_code = (
fuel if fuel in CO2_KG_PER_KWH
else API_FUEL_TO_TABLE_12.get(fuel, fuel)
else _table_12_factor_fuel_code(fuel)
)
if tariff is not Tariff.STANDARD:
return co2_factor_kg_per_kwh(table_12_code)
@ -3691,7 +3730,7 @@ def _hot_water_primary_factor(
return _DEFAULT_PEF
table_12_code = (
fuel if fuel in PRIMARY_ENERGY_FACTOR
else API_FUEL_TO_TABLE_12.get(fuel, fuel)
else _table_12_factor_fuel_code(fuel)
)
if tariff is not Tariff.STANDARD:
return primary_energy_factor(table_12_code)
@ -3725,7 +3764,7 @@ def _secondary_fuel_code(epc: EpcPropertyData) -> int:
return _STANDARD_ELECTRICITY_FUEL_CODE
if code in CO2_KG_PER_KWH:
return code
return API_FUEL_TO_TABLE_12.get(code, code)
return _table_12_factor_fuel_code(code)
def _secondary_heating_co2_factor_kg_per_kwh(
@ -7098,15 +7137,12 @@ def cert_to_inputs(
secondary_fuel_monthly_kwh=energy_requirements_result.secondary_fuel_monthly_kwh,
hot_water_monthly_kwh=hot_water_monthly_kwh_for_pv,
main_fuel_code_table_12=(
API_FUEL_TO_TABLE_12.get(main_fuel, main_fuel)
_table_12_factor_fuel_code(main_fuel)
if main_fuel is not None else None
),
secondary_fuel_code_table_12=_secondary_fuel_code(epc),
water_heating_fuel_code_table_12=(
API_FUEL_TO_TABLE_12.get(
epc.sap_heating.water_heating_fuel,
epc.sap_heating.water_heating_fuel,
)
_table_12_factor_fuel_code(epc.sap_heating.water_heating_fuel)
if epc.sap_heating.water_heating_fuel is not None else None
),
# SAP 10.2 Appendix M1 §3a — exclude the low-rate portion of an

View file

@ -56,6 +56,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
_heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage]
_main_fuel_code, # pyright: ignore[reportPrivateUsage]
_table_12_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
_is_electric_main, # pyright: ignore[reportPrivateUsage]
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
_is_electric_water, # pyright: ignore[reportPrivateUsage]
@ -1663,6 +1664,42 @@ def test_heat_network_unmapped_community_collision_fuel_raises() -> None:
_heat_network_community_fuel_code(31, main)
def test_table_12_factor_fuel_unmapped_code_raises() -> None:
# Arrange — a fuel code that is neither translatable via
# API_FUEL_TO_TABLE_12 nor already a recognised Table-12/32 fuel code.
# 998 is a deliberately out-of-range sentinel for "novel/unknown fuel".
unmapped_fuel: Final[int] = 998
# Act / Assert — the gap surfaces loudly instead of passing the code
# through to the silent mains-gas default in unit_price_p_per_kwh /
# co2_factor_kg_per_kwh / primary_energy_factor (the cert-8536
# community-collision class). Strict-raise per [[reference-unmapped-sap-code]].
with pytest.raises(UnmappedSapCode) as excinfo:
_table_12_factor_fuel_code(unmapped_fuel)
# Assert — the raised error names the field and the offending value.
assert excinfo.value.field == "table_12_factor_fuel"
assert excinfo.value.value == unmapped_fuel
def test_table_12_factor_fuel_recognised_codes_preserve_get_semantics() -> None:
# Arrange — recognised inputs must behave EXACTLY like the prior
# `API_FUEL_TO_TABLE_12.get(fuel, fuel)` passthrough: a gov-API enum is
# translated (26 mains-gas-not-community -> Table-12 code 1); a code
# already in the Table-12 numbering passes through unchanged (51 = heat-
# network mains gas).
api_enum_mains_gas: Final[int] = 26
table_12_heat_network_gas: Final[int] = 51
# Act
translated: int = _table_12_factor_fuel_code(api_enum_mains_gas)
passthrough: int = _table_12_factor_fuel_code(table_12_heat_network_gas)
# Assert — identical to the old .get(fuel, fuel) results, no behaviour drift.
assert translated == 1
assert passthrough == 51
def test_responsiveness_solid_fuel_sap_code_160_returns_0p50_per_table_4a() -> None:
# Arrange — SAP 10.2 Table 4a (PDF p.169, "Heating systems"):
#