fix(fuel): price secondary dual-fuel/anthracite at their own rate, not the colliding LPG code (RdSAP 10 Table 32)

The gov-API lodges secondary fuel as an enum whose value can COLLIDE with a
different same-valued RdSAP 10 Table 32 / SAP 10.2 Table 12 fuel code:
  - enum 9  = "dual fuel (mineral and wood)"  vs Table code 9  = LPG SC11F
  - enum 5  = "anthracite"                    vs Table code 5  = LPG (bulk)
The main-fuel boundary already canonicalises these (`_GOV_API_COLLISION_
FUELS`), but the SECONDARY-heating cost + CO2/PE paths never did — they took
the bare same-value lookup, so a dual-fuel room heater was priced as LPG
(3.48 vs dual-fuel 3.99 p/kWh) and emitted as LPG (CO2 0.241 vs 0.087),
and an anthracite secondary as bulk LPG (12.19 vs 3.64 p/kWh). The price
under-count over-rates SAP; the CO2 over-count inflates emissions.

Fix: add enum 9 to `_GOV_API_COLLISION_FUELS` (5 and 33 were already there)
and canonicalise the secondary fuel code on both the cost
(`_secondary_fuel_cost_gbp_per_kwh`) and factor (`_secondary_fuel_code`)
paths, mirroring the main-fuel boundary. canonical_fuel_code only touches
{5,9,33}, so genuinely Table-coded secondaries (House coal 11, wood logs 20,
community fuels 30-32) are left unchanged — confirmed by a full-map audit.

Corpus: within-0.5 69.7% -> 70.2% (MAE 0.854 -> 0.845; dual-fuel-secondary
cohort 42.9% -> 49.0%, signed +0.55 -> +0.41) and CO2 MAE 0.12 -> 0.08 t/yr
(bias +0.04 -> 0.00). Ratcheted the corpus floors (within 0.70, MAE 0.85,
CO2 0.09, PE 4.0). A prior session deferred enum 9 ("direction not
understood") while the EPC PE/CO2 lens was confounded by the climate-cascade
bug (fc7c4d2d); on the corrected lens the over-rate direction is clear.

pyright not installed in this codespace (strict gate not run locally).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-18 14:52:58 +00:00
parent 6950deae06
commit 8942d45772
4 changed files with 79 additions and 11 deletions

View file

@ -2772,7 +2772,11 @@ def _secondary_fuel_cost_gbp_per_kwh(
meter_type, fuel_is_electric=True
):
return _secondary_off_peak_rate_gbp_per_kwh(meter_type)
return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP
# Normalise colliding gov-API enum codes (e.g. 9 dual fuel, whose
# value collides with Table-32 9 = LPG SC11F) before the price lookup,
# exactly as the main-fuel boundary does — otherwise the same-value
# Table lookup mis-prices the secondary at the colliding fuel's rate.
return prices.unit_price_p_per_kwh(canonical_fuel_code(sec_fuel)) * _PENCE_TO_GBP
def _pv_array_generation_kwh_per_yr(
@ -3927,6 +3931,10 @@ def _secondary_fuel_code(epc: EpcPropertyData) -> int:
code = _int_or_none(epc.sap_heating.secondary_fuel_type)
if code is None:
return _STANDARD_ELECTRICITY_FUEL_CODE
# Normalise colliding gov-API enum codes (e.g. 9 dual fuel, whose value
# collides with the LPG Table code) so the CO2/PE factor lookups resolve
# to the lodged fuel — mirrors the main-fuel boundary + the cost side.
code = canonical_fuel_code(code) or code
if code in CO2_KG_PER_KWH:
return code
return _table_12_factor_fuel_code(code)

View file

@ -121,11 +121,17 @@ API_FUEL_TO_TABLE_32: Final[dict[int, int]] = {
# 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.
# 9 = dual fuel (mineral + wood) — Table-32 code 9 is LPG SC11F
# 3.48 p vs dual fuel 3.99 p. The gov-API lodges API enum 9 for a
# dual-fuel appliance (description "Room heaters, dual fuel
# (mineral and wood)"), but the same-value Table-32 lookup returns
# LPG 3.48 p, under-pricing the (mostly secondary) dual-fuel heat.
# A prior session deferred this as "direction not understood"
# while the EPC PE/CO2 lens was confounded by the climate-cascade
# bug (fixed in fc7c4d2d); on the corrected lens the dual-fuel
# secondary cohort over-rates (SAP too high = cost too low) by
# +0.55 signed, and pricing UP to the dual-fuel 3.99 p row reduces
# that over-rate — the correct direction.
#
# COMMUNITY FUELS (handled elsewhere, NOT here): API 30 (waste
# combustion), 31 (biomass) and 32 (biogas) — all "(community)" in the
@ -140,7 +146,7 @@ API_FUEL_TO_TABLE_32: Final[dict[int, int]] = {
# cert_to_inputs), where the community meaning is unambiguous. Community
# fuels 20/25 do not collide with an electricity code, so they resolve
# correctly through the heat-network path without any special handling.
_GOV_API_COLLISION_FUELS: Final[frozenset[int]] = frozenset({5, 33})
_GOV_API_COLLISION_FUELS: Final[frozenset[int]] = frozenset({5, 9, 33})
def canonical_fuel_code(fuel_code: Optional[int]) -> Optional[int]:

View file

@ -80,6 +80,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_pv_overshading_factor, # pyright: ignore[reportPrivateUsage]
_pv_pitch_deg, # pyright: ignore[reportPrivateUsage]
_responsiveness, # pyright: ignore[reportPrivateUsage]
_secondary_fuel_code, # pyright: ignore[reportPrivateUsage]
_secondary_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage]
_section_12_4_4_summer_immersion_applies, # pyright: ignore[reportPrivateUsage]
@ -2162,6 +2163,47 @@ def test_is_electric_main_dual_fuel_table_32_code_10_is_not_electric() -> None:
assert _is_electric_main(community_electric_main) is False
def test_dual_fuel_secondary_api_enum_9_prices_as_dual_fuel_not_lpg() -> None:
# Arrange — the gov-API lodges secondary fuel enum 9 = "dual fuel (mineral
# and wood)", but enum value 9 COLLIDES with the same-valued RdSAP 10
# Table 32 / SAP 10.2 Table 12 code 9 = "LPG (bulk, SC11F)". The secondary
# cost + CO2/PE paths previously took the same-value lookup (LPG 3.48
# p/kWh, CO2 0.241 kg/kWh) instead of translating the enum to the dual-
# fuel row (3.99 p/kWh, CO2 0.087) — under-costing the secondary (SAP
# over-rate) AND over-counting its CO2 (LPG is fossil; dual fuel is part
# wood). Enum 9 is now in `_GOV_API_COLLISION_FUELS`, and both secondary
# paths canonicalise (mirroring the main-fuel boundary). SAP 10.2 Table
# 12 (p.189) / RdSAP 10 Table 32 (p.95).
gas_boiler_main = MainHeatingDetail(
has_fghrs=False, main_fuel_type=26, heat_emitter_type=1,
emitter_temperature=1, main_heating_control=2106,
main_heating_category=2, sap_main_heating_code=102,
)
dual_fuel_secondary_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_heating=make_sap_heating(
main_heating_details=[gas_boiler_main],
secondary_fuel_type=9, # gov-API enum: dual fuel (mineral + wood)
secondary_heating_type=631,
),
)
# Act — the rating-cascade secondary price + the CO2/PE fuel code.
secondary_rate_gbp_per_kwh = _secondary_fuel_cost_gbp_per_kwh(
dual_fuel_secondary_epc.sap_heating,
gas_boiler_main,
2, # standard (single-rate) meter
SAP_10_2_SPEC_PRICES,
)
secondary_factor_code = _secondary_fuel_code(dual_fuel_secondary_epc)
# Assert — dual fuel 3.99 p/kWh (NOT LPG 3.48) + Table code 10 (NOT 9).
assert abs(secondary_rate_gbp_per_kwh - 0.0399) <= 1e-6
assert secondary_factor_code == 10
def test_is_electric_water_dual_fuel_table_32_code_10_is_not_electric() -> None:
# Arrange — same API/Table 32 collision as `_is_electric_main` per
# S0380.136 docstring.

View file

@ -138,10 +138,22 @@ _CORPUS = Path(
# (rating cascade). Worksheet-validated to 1e-4 on simulated case 45 (rating
# CO2 692.13; demand CO2 626.78, PE 6581.59). The residual PE/CO2 spread is
# now the genuine per-cert mapper-fidelity tail.
_MIN_WITHIN_HALF_SAP = 0.695
_MAX_SAP_MAE = 0.86
_MAX_CO2_MAE_TONNES = 0.13 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 4.2 # kWh / m2 / yr vs energy_consumption_current
# DUAL-FUEL SECONDARY COLLISION (RdSAP 10 Table 32 / SAP 10.2 Table 12): the
# gov-API lodges fuel enum 9 ("dual fuel, mineral and wood") for a dual-fuel
# room heater, but enum 9 collides with the same-valued Table-32/12 code 9
# (LPG SC11F), so the price (3.48 vs dual-fuel 3.99 p/kWh) AND the CO2/PE
# factors (LPG 0.241 / 1.163 vs dual fuel 0.087 / 1.049) resolved to LPG —
# the secondary was under-costed (→ SAP over-rate) and over-counted on CO2.
# Canonicalising enum 9 (now in `_GOV_API_COLLISION_FUELS`) on the secondary
# cost + factor paths took within-0.5 69.7% -> 70.2% (MAE 0.854 -> 0.845;
# dual-fuel-secondary cohort 42.9% -> 49.0%, signed +0.55 -> +0.41) and CO2
# MAE 0.12 -> 0.08 t/yr (bias +0.04 -> 0.00). A prior session deferred enum 9
# ("direction not understood") while the PE/CO2 lens was confounded by the
# climate-cascade bug (fc7c4d2d); the corrected lens shows the over-rate.
_MIN_WITHIN_HALF_SAP = 0.70
_MAX_SAP_MAE = 0.85
_MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 4.0 # kWh / m2 / yr vs energy_consumption_current
def _load_corpus() -> list[dict[str, Any]]: