From 19235d1144bf6bb4e9bb805d432d94ba130f2539 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 8 Jun 2026 20:31:43 +0000 Subject: [PATCH] fix(fuel): canonicalise colliding gov-API solid-fuel codes (anthracite/coal) at the fuel-type boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 13 ++++- domain/sap10_calculator/tables/table_12.py | 2 +- domain/sap10_calculator/tables/table_32.py | 34 ++++++++++- .../rdsap/test_cert_to_inputs.py | 58 ++++++++++++++++++- 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 7b77c669..aef8d53f 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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) diff --git a/domain/sap10_calculator/tables/table_12.py b/domain/sap10_calculator/tables/table_12.py index 2c884128..dc64e57f 100644 --- a/domain/sap10_calculator/tables/table_12.py +++ b/domain/sap10_calculator/tables/table_12.py @@ -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, } diff --git a/domain/sap10_calculator/tables/table_32.py b/domain/sap10_calculator/tables/table_32.py index 955bad9c..33accf46 100644 --- a/domain/sap10_calculator/tables/table_32.py +++ b/domain/sap10_calculator/tables/table_32.py @@ -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; diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 83a65467..d48f947c 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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"): #