mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +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 (
|
from domain.sap10_calculator.tables.table_32 import (
|
||||||
additional_standing_charges_gbp,
|
additional_standing_charges_gbp,
|
||||||
|
canonical_fuel_code,
|
||||||
is_electric_fuel_code,
|
is_electric_fuel_code,
|
||||||
is_liquid_fuel_code,
|
is_liquid_fuel_code,
|
||||||
standing_charge_gbp,
|
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.
|
PE/cost lookups returned defaults instead of the gas-combi values.
|
||||||
"""
|
"""
|
||||||
if epc.sap_heating.water_heating_fuel:
|
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))
|
return _main_fuel_code(_water_heating_main(epc))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1943,7 +1946,13 @@ def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]:
|
||||||
return None
|
return None
|
||||||
fuel = main.main_fuel_type
|
fuel = main.main_fuel_type
|
||||||
if isinstance(fuel, int):
|
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)
|
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,
|
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,
|
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,
|
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,
|
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,
|
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,
|
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
|
# RdSAP10 Table 32 — annual standing charge in £/yr per Table 32 fuel
|
||||||
# code. Only fuels with a published standing charge appear here;
|
# 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
|
# Act / Assert — dual fuel (Table 32 10) must NOT be electric
|
||||||
assert _is_electric_main(dual_fuel_main) is False
|
assert _is_electric_main(dual_fuel_main) is False
|
||||||
|
|
||||||
# Sanity — Table 32 electric codes 30-40 still classify as electric
|
# Sanity — Table 32 electric codes still classify as electric. Code 33
|
||||||
for t32_electric in (30, 31, 32, 33, 34, 35, 38, 40, 60):
|
# 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(
|
electric_main = MainHeatingDetail(
|
||||||
has_fghrs=False, main_fuel_type=t32_electric, heat_emitter_type=1,
|
has_fghrs=False, main_fuel_type=t32_electric, heat_emitter_type=1,
|
||||||
emitter_temperature=1, main_heating_control=2105,
|
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:
|
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"):
|
# Arrange — SAP 10.2 Table 4a (PDF p.169, "Heating systems"):
|
||||||
#
|
#
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue