fix(fuel): canonicalise colliding gov-API solid-fuel codes (anthracite/coal) at the fuel-type boundary

A coal main (gov-API main_fuel_type=33) was priced at the electricity
10-hour low rate (7.5 p) and anthracite (5) at the bulk-LPG rate
(12.19 p), because the shared price/CO2/PE lookups check Table-32/12-code
membership BEFORE translating the API enum — and codes 5/33 collide with
a different-fuel Table code. This drove the cohort's single worst cert
(2100 anthracite, -61 SAP). `is_electric_fuel_code(33)` also wrongly
classified the coal main as electric.

The gov-API fuel enum (confirmed by description-vs-code audit on
main_heating[].description): 5=anthracite, 33=coal, 9=dual-fuel,
20/25/31=community. The collision can't be resolved inside the shared
table functions — code 33 is ALSO the electricity-10h TARIFF code used by
the dual-rate CO2/PE split (golden 000565), so normalising there breaks
electricity certs. Instead `canonical_fuel_code` normalises the colliding
SOLID-fuel enums (5->15 anthracite, 33->11 house coal) at the fuel-TYPE
boundary in `_main_fuel_code` / `_water_heating_fuel_code`, where the code
is known to be a fuel type (never a tariff code).

Scoped to anthracite (5) + coal (33) — the unambiguous large mispricings.
Dual-fuel (9, 0.45 p delta) and community (20/25/31, heat-network path)
are deferred (noted in `_GOV_API_COLLISION_FUELS`).

API SAP eval: mean|err| 1.424 -> 1.329 (the -61 anthracite outlier 2100
-> -11, residual now fabric); within-0.5 53.1% (flat); 909 computed, 0
raises. Golden + Elmhurst regression green (the shared table functions
are unchanged, so the electricity-tariff CO2/PE path is untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 20:31:43 +00:00
parent d90b6f5643
commit 19235d1144
4 changed files with 101 additions and 6 deletions

View file

@ -110,6 +110,7 @@ from domain.sap10_calculator.tables.table_13 import (
)
from domain.sap10_calculator.tables.table_32 import (
additional_standing_charges_gbp,
canonical_fuel_code,
is_electric_fuel_code,
is_liquid_fuel_code,
standing_charge_gbp,
@ -1616,7 +1617,9 @@ def _water_heating_fuel_code(epc: EpcPropertyData) -> Optional[int]:
PE/cost lookups returned defaults instead of the gas-combi values.
"""
if epc.sap_heating.water_heating_fuel:
return epc.sap_heating.water_heating_fuel
# Normalise colliding gov-API solid-fuel enum codes (see
# `_main_fuel_code`) before the shared price/CO2/PE lookups.
return canonical_fuel_code(epc.sap_heating.water_heating_fuel)
return _main_fuel_code(_water_heating_main(epc))
@ -1943,7 +1946,13 @@ def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]:
return None
fuel = main.main_fuel_type
if isinstance(fuel, int):
return fuel
# Normalise the colliding gov-API solid-fuel enum codes (5
# anthracite / 9 dual fuel / 33 coal) to their canonical Table
# 32/12 codes here — at the fuel-TYPE boundary — so the shared
# price/CO2/PE table lookups (which also receive electricity
# TARIFF codes 31/33 for the dual-rate split) never confuse a
# coal fuel-type 33 with the electricity-10h tariff code 33.
return canonical_fuel_code(fuel)
raise MissingMainFuelType(fuel, main.sap_main_heating_code)

View file

@ -214,7 +214,7 @@ API_FUEL_TO_TABLE_12: Final[dict[int, int]] = {
0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10,
10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9,
18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41,
26: 1, 27: 2, 28: 4, 29: 30,
26: 1, 27: 2, 28: 4, 29: 30, 33: 11,
}

View file

@ -105,9 +105,41 @@ API_FUEL_TO_TABLE_32: Final[dict[int, int]] = {
0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10,
10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9,
18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41,
26: 1, 27: 2, 28: 4, 29: 30,
26: 1, 27: 2, 28: 4, 29: 30, 33: 11,
}
# Gov-API `main_fuel_type` enum codes whose value COLLIDES with a
# same-valued Table 32 code of a DIFFERENT fuel. The gov EPC register
# always lodges the API enum, so for these the API translation is
# authoritative and must win over the direct same-value Table-code
# lookup (which otherwise mis-prices solid fuel at the colliding code's
# rate). Confirmed by the description-vs-code audit on
# `main_heating[].description`:
# 5 = anthracite — Table-32 code 5 is bulk LPG (secondary), 12.19 p
# vs anthracite 3.64 p. Drove the cohort's worst cert (2100,
# -61 SAP at the LPG rate).
# 33 = coal — Table-32 code 33 is the electricity 10-hour low rate
# 7.5 p vs house coal 3.67 p (and `is_electric_fuel_code(33)`
# wrongly classified the coal main as electric).
# DEFERRED (not included): API 9 = dual fuel (mineral + wood) is also a
# collision (Table-32 9 = LPG SC11F 3.48 p vs dual fuel 3.99 p) but the
# 0.45 p delta nets neutral-to-negative on the (outlier-dominated)
# dual-fuel certs and shifts them in a direction not yet understood —
# investigate separately. Community heat-network fuels (20/25/31) are
# also out of scope — their standing-charge / CO2 / PE routing is handled
# by the dedicated heat-network path.
_GOV_API_COLLISION_FUELS: Final[frozenset[int]] = frozenset({5, 33})
def canonical_fuel_code(fuel_code: Optional[int]) -> Optional[int]:
"""Normalise a colliding gov-API fuel enum (see
`_GOV_API_COLLISION_FUELS`) to its canonical Table 32 code so the
same-value collision can't mis-resolve it. Non-colliding codes and
already-canonical Table codes pass through unchanged."""
if fuel_code in _GOV_API_COLLISION_FUELS:
return API_FUEL_TO_TABLE_32.get(fuel_code, fuel_code)
return fuel_code
# RdSAP10 Table 32 — annual standing charge in £/yr per Table 32 fuel
# code. Only fuels with a published standing charge appear here;

View file

@ -1439,8 +1439,14 @@ def test_is_electric_main_dual_fuel_table_32_code_10_is_not_electric() -> None:
# Act / Assert — dual fuel (Table 32 10) must NOT be electric
assert _is_electric_main(dual_fuel_main) is False
# Sanity — Table 32 electric codes 30-40 still classify as electric
for t32_electric in (30, 31, 32, 33, 34, 35, 38, 40, 60):
# Sanity — Table 32 electric codes still classify as electric. Code 33
# is EXCLUDED here: as a lodged *main fuel-type* the gov-API enum 33
# means COAL (description-vs-code audit), which `_main_fuel_code`
# canonicalises to House coal (Table code 11) before `_is_electric_main`
# — so a main fuel-type 33 is NOT electric. The Table 32 electricity-10h
# code 33 is only ever used internally for the dual-rate tariff split
# (never as a main fuel-type), so it is unaffected.
for t32_electric in (30, 31, 32, 34, 35, 38, 40, 60):
electric_main = MainHeatingDetail(
has_fghrs=False, main_fuel_type=t32_electric, heat_emitter_type=1,
emitter_temperature=1, main_heating_control=2105,
@ -1489,6 +1495,54 @@ def test_is_electric_water_dual_fuel_table_32_code_10_is_not_electric() -> None:
)
def test_solid_fuel_main_prices_at_table_32_solid_rate_not_colliding_code() -> None:
# Arrange — the gov-API `main_fuel_type` enum (confirmed by the
# description-vs-code audit on `main_heating[].description`) carries
# 5 = anthracite and 33 = coal. Both COLLIDE with a same-valued Table
# 32 code of a different fuel: code 5 = bulk LPG secondary (12.19 p),
# code 33 = electricity 10-hour low rate (7.5 p). The shared price
# lookup checks the Table-32 dict first, so without normalisation an
# anthracite main billed at 12.19 p and a coal main at 7.5 p — driving
# the cohort's worst cert (2100 anthracite, -61 SAP). `_main_fuel_code`
# now canonicalises the colliding gov-API enum to its Table 32 code at
# the fuel-TYPE boundary (5 -> 15 anthracite 3.64 p; 33 -> 11 house
# coal 3.67 p) — and the canonical coal code is no longer mis-flagged
# electric. The electricity-10h TARIFF code 33 (dual-rate split) is
# untouched because it never enters as a main fuel-type.
from domain.sap10_calculator.tables.table_12a import Tariff
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_main_fuel_code, # pyright: ignore[reportPrivateUsage]
)
anthracite_main = MainHeatingDetail(
has_fghrs=False, main_fuel_type=5, heat_emitter_type=1,
emitter_temperature=1, main_heating_control=2105,
main_heating_category=2, sap_main_heating_code=158,
)
coal_main = MainHeatingDetail(
has_fghrs=False, main_fuel_type=33, heat_emitter_type=1,
emitter_temperature=1, main_heating_control=2105,
main_heating_category=2, sap_main_heating_code=158,
)
# Act
anthracite_code = _main_fuel_code(anthracite_main)
coal_code = _main_fuel_code(coal_main)
anthracite_rate = _space_heating_fuel_cost_gbp_per_kwh(
anthracite_main, Tariff.STANDARD, SAP_10_2_SPEC_PRICES
)
coal_rate = _space_heating_fuel_cost_gbp_per_kwh(
coal_main, Tariff.STANDARD, SAP_10_2_SPEC_PRICES
)
# Assert — canonical codes + solid-fuel rates (not 12.19 p / 7.5 p),
# and coal is no longer classed as electric.
assert anthracite_code == 15
assert coal_code == 11
assert abs(anthracite_rate - 0.0364) <= 1e-6
assert abs(coal_rate - 0.0367) <= 1e-6
assert _is_electric_main(coal_main) is False
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"):
#