mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
d90b6f5643
commit
19235d1144
4 changed files with 101 additions and 6 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
#
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue