mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
fix(fuel): map gov-API community fuels 30/31/32 (waste/biomass/biogas) to Table-12 community rows, gated on heat-network context
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 <noreply@anthropic.com>
This commit is contained in:
parent
87485bbe3d
commit
a7761ea83f
4 changed files with 156 additions and 6 deletions
|
|
@ -1617,9 +1617,17 @@ 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:
|
||||||
|
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
|
# Normalise colliding gov-API solid-fuel enum codes (see
|
||||||
# `_main_fuel_code`) before the shared price/CO2/PE lookups.
|
# `_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))
|
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]:
|
def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]:
|
||||||
"""Resolve `MainHeatingDetail.main_fuel_type` to a SAP fuel code.
|
"""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
|
return None
|
||||||
fuel = main.main_fuel_type
|
fuel = main.main_fuel_type
|
||||||
if isinstance(fuel, int):
|
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
|
# Normalise the colliding gov-API solid-fuel enum codes (5
|
||||||
# anthracite / 9 dual fuel / 33 coal) to their canonical Table
|
# anthracite / 9 dual fuel / 33 coal) to their canonical Table
|
||||||
# 32/12 codes here — at the fuel-TYPE boundary — so the shared
|
# 32/12 codes here — at the fuel-TYPE boundary — so the shared
|
||||||
|
|
|
||||||
|
|
@ -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, 33: 11,
|
26: 1, 27: 2, 28: 4, 29: 30, 30: 42, 31: 43, 32: 44, 33: 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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, 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
|
# 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
|
# 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)
|
# 0.45 p delta nets neutral-to-negative on the (outlier-dominated)
|
||||||
# dual-fuel certs and shifts them in a direction not yet understood —
|
# dual-fuel certs and shifts them in a direction not yet understood —
|
||||||
# investigate separately. Community heat-network fuels (20/25/31) are
|
# investigate separately.
|
||||||
# also out of scope — their standing-charge / CO2 / PE routing is handled
|
#
|
||||||
# by the dedicated heat-network path.
|
# 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})
|
_GOV_API_COLLISION_FUELS: Final[frozenset[int]] = frozenset({5, 33})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,12 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||||
SAP_10_2_SPEC_PRICES,
|
SAP_10_2_SPEC_PRICES,
|
||||||
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
|
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
|
||||||
_heat_network_code_302_effective_factor, # 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_distribution_electricity, # pyright: ignore[reportPrivateUsage]
|
||||||
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
|
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
|
||||||
_heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
|
_heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
|
||||||
_heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage]
|
_heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage]
|
||||||
|
_main_fuel_code, # pyright: ignore[reportPrivateUsage]
|
||||||
_is_electric_main, # pyright: ignore[reportPrivateUsage]
|
_is_electric_main, # pyright: ignore[reportPrivateUsage]
|
||||||
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
|
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
|
||||||
_is_electric_water, # 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
|
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:
|
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