mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
6950deae06
commit
8942d45772
4 changed files with 79 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue