From 8942d457723a29ebf0ca62d170182b95c03ebf33 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 18 Jun 2026 14:52:58 +0000 Subject: [PATCH] fix(fuel): price secondary dual-fuel/anthracite at their own rate, not the colliding LPG code (RdSAP 10 Table 32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 10 ++++- domain/sap10_calculator/tables/table_32.py | 18 +++++--- .../rdsap/test_cert_to_inputs.py | 42 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 20 +++++++-- 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index a271f8f5..49576723 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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) diff --git a/domain/sap10_calculator/tables/table_32.py b/domain/sap10_calculator/tables/table_32.py index 8377fe86..14544aea 100644 --- a/domain/sap10_calculator/tables/table_32.py +++ b/domain/sap10_calculator/tables/table_32.py @@ -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]: 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 e3e99e9e..2ead1bef 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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. diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index ca4681e4..ef933adb 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -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]]: