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"): #