From a7761ea83fa73d2ee48f050a57193a0249cb0bc8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 8 Jun 2026 21:55:48 +0000 Subject: [PATCH] fix(fuel): map gov-API community fuels 30/31/32 (waste/biomass/biogas) to Table-12 community rows, gated on heat-network context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gov-API `main_fuel_type`/`water_heating_fuel` enum (epc_codes.csv) codes 30="waste combustion (community)", 31="biomass (community)", 32="biogas (community)" collide in VALUE with the Table-32 electricity codes 30 (standard rate), 31 (7-hour low) and 32 (7-hour high). All three sit in `_ELECTRIC_FUEL_CODES`, so `is_electric_fuel_code` flagged a community-scheme main as electric and `_is_electric_main` routed its cost through the off-peak electricity branch — BYPASSING the heat-network rate in `_heat_network_factor_fuel_code`. Cert 8536 (biomass community, SAP code 301) was billing at 5.5 p/kWh grid electricity instead of the 4.24 p/kWh heat-network rate → -17.2 SAP. Per RdSAP 10 §C / SAP 10.2 Table 12 (PDF p.191) the community waste/biomass/biogas rows are codes 42/43/44 (the same rows the backwards-compat enum codes 11/12/13 already map to). Add 30->42, 31->43, 32->44 to both API fuel-translation tables. The remap CANNOT be global (`canonical_fuel_code`): the cascade uses the bare Table-32 code 30 internally as `_STANDARD_ELECTRICITY_FUEL_CODE` (the RdSAP no-water-heating immersion default writes `water_heating_fuel=30`), so a blanket remap mis-prices genuine grid electricity as community waste (cert 2211 regressed +16 SAP in a prototype). Instead `_heat_network_community_fuel_code` translates only when `_is_heat_network_main` is true, at the `_main_fuel_code` / `_water_heating_fuel_code` fuel-TYPE boundary, where the community meaning is unambiguous. Per the strict-raise principle ([[reference-unmapped-sap-code]]), a heat-network main lodging a colliding community fuel the table doesn't cover raises `UnmappedSapCode` rather than silently falling through to the same-numbered electricity code. Eval (API SAP vs lodged): cert 8536 -17.25 -> -6.51, cert 5036 -6.29 -> +1.36; mean|err| 1.329 -> 1.312, within-1.0 67.88% -> 67.99%, within-2.0 81.74% -> 81.85%, within-0.5 held at 53.14%, 909 computed / 0 raises. No golden / calculator regressions. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 63 ++++++++++++++- domain/sap10_calculator/tables/table_12.py | 2 +- domain/sap10_calculator/tables/table_32.py | 20 ++++- .../rdsap/test_cert_to_inputs.py | 77 +++++++++++++++++++ 4 files changed, 156 insertions(+), 6 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index aef8d53f..31709822 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1617,9 +1617,17 @@ 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: + fuel = epc.sap_heating.water_heating_fuel + # When DHW is on a heat network, the colliding community fuels + # 30/31/32 take their Table 12 community row rather than the + # same-numbered electricity code — see + # `_heat_network_community_fuel_code`. + community = _heat_network_community_fuel_code(fuel, _water_heating_main(epc)) + if community is not None: + return community # 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 canonical_fuel_code(fuel) return _main_fuel_code(_water_heating_main(epc)) @@ -1929,6 +1937,53 @@ _RESPONSIVENESS_BY_EMITTER_CODE: Final[dict[int, float]] = { } +# Gov-API community fuel enum codes (waste 30 / biomass 31 / biogas 32) +# whose VALUE collides with a Table-32 electricity tariff code of the same +# number (30 standard / 31 7-hour-low / 32 7-hour-high). Per +# `epc_codes.csv` these are unambiguously "(community)" fuels, but the +# bare Table-32 codes 30/31/32 are ALSO used internally as grid +# electricity (e.g. `_STANDARD_ELECTRICITY_FUEL_CODE = 30` written by the +# no-water-heating immersion default), so the community meaning is only +# authoritative when the main is a heat network — see +# `_heat_network_community_fuel_code`. +_API_COMMUNITY_COLLISION_FUELS: Final[frozenset[int]] = frozenset({30, 31, 32}) + + +def _heat_network_community_fuel_code( + fuel: int, main: Optional[MainHeatingDetail] +) -> Optional[int]: + """Translate a gov-API community fuel enum to its SAP Table 12 + community fuel code WHEN the main is a heat network; else return None + so the caller keeps `fuel` unchanged. + + Community fuels 30 (waste) / 31 (biomass) / 32 (biogas) collide in + value with the Table-32 electricity codes 30/31/32. Without this + translation `is_electric_fuel_code` flags a community-scheme main as + electric and `_is_electric_main` routes its cost through the off-peak + electricity branch — bypassing the heat-network rate + (`_heat_network_factor_fuel_code`) entirely. Per RdSAP 10 §C / SAP + 10.2 Table 12 the community waste/biomass/biogas rows are codes + 42/43/44 (the same rows the backwards-compat enum codes 11/12/13 map + to). Cert 8536 (biomass community, SAP code 301) closed -17.2 → -6.5. + + Gating on `_is_heat_network_main` keeps the bare Table-32 code 30 the + cascade uses internally as grid electricity untouched on + non-community certs (e.g. cert 2211 whose whc=999 default writes + `water_heating_fuel=30`). + + Raises `UnmappedSapCode` when a heat-network main lodges a colliding + community fuel the translation table doesn't cover — surfacing the + gap loudly instead of silently mis-pricing it as grid electricity, + per the strict-raise principle ([[reference-unmapped-sap-code]]). + """ + if fuel not in _API_COMMUNITY_COLLISION_FUELS or not _is_heat_network_main(main): + return None + translated = API_FUEL_TO_TABLE_12.get(fuel) + if translated is None: + raise UnmappedSapCode("heat_network_community_fuel", fuel) + return translated + + def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]: """Resolve `MainHeatingDetail.main_fuel_type` to a SAP fuel code. @@ -1946,6 +2001,12 @@ def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]: return None fuel = main.main_fuel_type if isinstance(fuel, int): + # Heat-network community fuels 30/31/32 collide with electricity + # Table-32 codes — translate to the community row before anything + # else so the main isn't mis-classified as electric. + community = _heat_network_community_fuel_code(fuel, main) + if community is not None: + return community # 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 diff --git a/domain/sap10_calculator/tables/table_12.py b/domain/sap10_calculator/tables/table_12.py index dc64e57f..7ea27585 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, 33: 11, + 26: 1, 27: 2, 28: 4, 29: 30, 30: 42, 31: 43, 32: 44, 33: 11, } diff --git a/domain/sap10_calculator/tables/table_32.py b/domain/sap10_calculator/tables/table_32.py index 33accf46..8377fe86 100644 --- a/domain/sap10_calculator/tables/table_32.py +++ b/domain/sap10_calculator/tables/table_32.py @@ -105,7 +105,7 @@ 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, 33: 11, + 26: 1, 27: 2, 28: 4, 29: 30, 30: 42, 31: 43, 32: 44, 33: 11, } # Gov-API `main_fuel_type` enum codes whose value COLLIDES with a @@ -125,9 +125,21 @@ API_FUEL_TO_TABLE_32: Final[dict[int, int]] = { # 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. +# investigate separately. +# +# COMMUNITY FUELS (handled elsewhere, NOT here): API 30 (waste +# combustion), 31 (biomass) and 32 (biogas) — all "(community)" in the +# enum — collide in VALUE with the Table-32 electricity codes 30 (standard +# rate), 31 (7-hour low) and 32 (7-hour high). They must NOT be +# canonicalised globally: the cascade uses the bare Table-32 code 30 +# internally as `_STANDARD_ELECTRICITY_FUEL_CODE` (e.g. the RdSAP +# no-water-heating immersion default writes `water_heating_fuel=30`), so a +# blanket remap would mis-price genuine grid electricity as community +# waste. The translation is therefore done at the fuel-TYPE boundary +# GATED on heat-network context (`_heat_network_community_fuel_code` in +# 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}) 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 d48f947c..3c961919 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -50,10 +50,12 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] _heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage] + _heat_network_community_fuel_code, # pyright: ignore[reportPrivateUsage] _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] _heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage] _heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage] + _main_fuel_code, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] _is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] @@ -1543,6 +1545,81 @@ def test_solid_fuel_main_prices_at_table_32_solid_rate_not_colliding_code() -> N assert _is_electric_main(coal_main) is False +def test_heat_network_biomass_community_fuel_resolves_to_table12_community_code() -> None: + # Arrange — a heat-network main (SAP Table 4a code 301 = community + # heating; main_heating_category=6) lodging gov-API `main_fuel_type` + # 31 = "biomass (community)" per epc_codes.csv. The enum value 31 + # COLLIDES with the Table-32 7-hour-low-rate electricity code 31, so + # without the gated translation `is_electric_fuel_code(31)` flags this + # community-scheme main as electric and `_is_electric_main` routes its + # cost through the off-peak electricity branch — bypassing the + # heat-network rate (cert 8536 biomass-community, -17.2 SAP). Per + # RdSAP 10 §C / SAP 10.2 Table 12 the community biomass row is code 43 + # (the same row the backwards-compat enum 12 maps to). + biomass_community_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=31, heat_emitter_type=0, + emitter_temperature="NA", main_heating_control=2306, + main_heating_category=6, sap_main_heating_code=301, + ) + + # Act + fuel_code = _main_fuel_code(biomass_community_main) + factor_code = _heat_network_factor_fuel_code(biomass_community_main) + + # Assert — community biomass Table-12 row, NOT electricity, and the + # main is no longer mis-classified as electric. + assert fuel_code == 43 + assert factor_code == 43 + assert _is_electric_main(biomass_community_main) is False + + +def test_non_heat_network_electricity_code_30_is_not_remapped_to_community() -> None: + # Arrange — the colliding community translation must be GATED on + # heat-network context: the cascade writes the bare Table-32 code 30 + # (`_STANDARD_ELECTRICITY_FUEL_CODE`) as genuine grid electricity on + # non-community certs (e.g. the RdSAP no-water-heating immersion + # default — cert 2211). A non-heat-network main carrying code 30 must + # stay electric, NOT be remapped to community waste (Table-12 42). + electric_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=30, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2106, + main_heating_category=2, sap_main_heating_code=191, + ) + + # Act + fuel_code = _main_fuel_code(electric_main) + + # Assert — unchanged grid-electricity code, still electric. + assert fuel_code == 30 + assert _is_electric_main(electric_main) is True + + +def test_heat_network_unmapped_community_collision_fuel_raises() -> None: + # Arrange — a heat-network main lodging a colliding community fuel the + # translation table does NOT cover must raise rather than silently + # mis-price it as the same-numbered grid-electricity code (the + # strict-raise principle — a community fuel that "falls through" is a + # mapping gap to surface, not a default to swallow). Simulate the gap + # by removing biomass (31) from the translation table. + from unittest.mock import patch + + import domain.sap10_calculator.rdsap.cert_to_inputs as cti + + main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=31, heat_emitter_type=0, + emitter_temperature="NA", main_heating_control=2306, + main_heating_category=6, sap_main_heating_code=301, + ) + patched = dict(cti.API_FUEL_TO_TABLE_12) + del patched[31] + + # Act / Assert — the gap surfaces loudly instead of resolving to the + # colliding electricity code 31. + with patch.object(cti, "API_FUEL_TO_TABLE_12", patched): + with pytest.raises(UnmappedSapCode): + _heat_network_community_fuel_code(31, main) + + 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"): #