mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.136: route _is_electric_main / _is_electric_water via the canonical T32-first normaliser (dual-fuel closure)
`_is_electric_main` and `_is_electric_water` hand-rolled a literal set
check `code in {10, 25, 29}` ∪ `{30..40}` to classify a fuel code as
electricity. The set conflated two enums:
- {10, 25, 29} — API enum codes (epc_codes.csv row main_fuel):
10 = electricity (backwards compat)
25 = electricity (community)
29 = electricity (not community)
- {30, 31, ..., 40} — Table 32 codes (RdSAP 10 spec p.95):
30 = standard tariff
31/32 = 7-hour low/high
33/34 = 10-hour low/high
35 = 24-hour heating
38/40 = 18-hour high/low
API enum codes 1-29 collide with Table 32 codes 1-29 for unrelated
fuels — API 10 = "electricity" vs Table 32 10 = "dual fuel (mineral +
wood)". S0380.135's EES dispatch sets `main_fuel_type` to Table 32
codes (BDI → 10 for dual fuel), so a dual-fuel main was silently
mis-classified as electric. The `_space_heating_fuel_cost_gbp_per_kwh`
tariff branch then re-routed solid fuel 6's space heating cost through
the 18-hour-low electric rate (5.50 p/kWh) instead of dual-fuel 3.99
p/kWh — solid fuel 6 SAP residual −7.38 → −11.37 in S0380.135.
The fix promotes the existing `table_32._is_electric_code` to public
`is_electric_fuel_code` and routes both `_is_electric_main` and
`_is_electric_water` through it. The canonical helper normalises a
fuel code via T32-first then API-translate fallback (same convention
as `unit_price_p_per_kwh`), so a Table-32-code-10 dual-fuel main
classifies as non-electric correctly.
Subtle behaviour change: API enum code 25 ("electricity (community)")
maps via API_FUEL_TO_TABLE_32 to Table 32 code 41 ("heat from electric
heat pump (community)") which is a heat network billed at the heat-
network rate (4.24 p/kWh single rate), not at the off-peak electric
tariff. Pre-S0380.136 the literal-set check would have treated this
as direct electric and applied the Table 12a high/low-rate split —
that was wrong; community heat networks don't have an off-peak split.
The new canonical helper correctly excludes code 41 from
_ELECTRIC_FUEL_CODES.
Heating-systems corpus impact:
solid fuel 6 (Dual Fuel Anthracite Wood, SAP 160):
ΔSAP −11.3731 → +1.9493 (now in cluster with other solid-fuel)
Δcost +£268.44 → −£44.91
ΔPE unchanged (PE wasn't affected by the cost mis-routing)
No other corpus variants moved — none have `main_fuel_type` in the
ambiguous API/T32 collision range that was previously mis-classified.
Extended handover suite: 879 pass / 0 fail (+2 from new AAA tests
covering both `_is_electric_main` and `_is_electric_water` dual-fuel
non-electric classification + API code 29 → electric / API code 25 →
heat-network non-electric semantics).
Pyright net-zero on touched files (43 → 43).
No golden fixture impact — no golden cert lodges `main_fuel_type=10`
(dual fuel) on the cascade path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
29d0523765
commit
9427354d88
4 changed files with 105 additions and 22 deletions
|
|
@ -170,7 +170,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
|||
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983),
|
||||
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+1.5867, expected_cost_resid_gbp=-36.5606, expected_co2_resid_kg=-78.9461, expected_pe_resid_kwh=+151.1685),
|
||||
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+1.7045, expected_cost_resid_gbp=-39.2732, expected_co2_resid_kg=-52.5294, expected_pe_resid_kwh=+160.0328),
|
||||
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=-11.3731, expected_cost_resid_gbp=+268.4432, expected_co2_resid_kg=+4.8671, expected_pe_resid_kwh=+87.0778),
|
||||
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+1.9493, expected_cost_resid_gbp=-44.9072, expected_co2_resid_kg=+4.8671, expected_pe_resid_kwh=+87.0778),
|
||||
_CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+2.0439, expected_cost_resid_gbp=-47.0520, expected_co2_resid_kg=-91.3569, expected_pe_resid_kwh=+44.3084),
|
||||
_CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=+1.8115, expected_cost_resid_gbp=-41.7407, expected_co2_resid_kg=+26.9399, expected_pe_resid_kwh=+87.6830),
|
||||
_CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=+1.7052, expected_cost_resid_gbp=-39.2906, expected_co2_resid_kg=+28.0233, expected_pe_resid_kwh=+154.9673),
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ from domain.sap10_calculator.tables.table_12a import (
|
|||
)
|
||||
from domain.sap10_calculator.tables.table_32 import (
|
||||
additional_standing_charges_gbp,
|
||||
is_electric_fuel_code,
|
||||
unit_price_p_per_kwh as table_32_unit_price_p_per_kwh,
|
||||
)
|
||||
from domain.sap10_calculator.worksheet.fuel_cost import FuelCostResult, fuel_cost
|
||||
|
|
@ -1142,28 +1143,24 @@ def _is_off_peak_meter(meter_type: object, *, fuel_is_electric: bool) -> bool:
|
|||
|
||||
|
||||
def _is_electric_main(main: Optional[MainHeatingDetail]) -> bool:
|
||||
"""Main heating fuel is electricity (codes 29 or 10 in API enum;
|
||||
Table 32 codes 30-40)."""
|
||||
code = _main_fuel_code(main)
|
||||
if code is None:
|
||||
return False
|
||||
# API codes that route to electricity
|
||||
if code in {10, 25, 29}:
|
||||
return True
|
||||
# Table 32 electricity codes directly
|
||||
if code in {30, 31, 32, 33, 34, 35, 36, 38, 39, 40}:
|
||||
return True
|
||||
return False
|
||||
"""Main heating fuel is electricity. Delegates to the canonical
|
||||
Table-32-first normalisation in `table_32.is_electric_fuel_code`.
|
||||
|
||||
Pre-S0380.136 this hand-rolled a literal set check
|
||||
`code in {10, 25, 29}` (API codes) ∪ `{30..40}` (Table 32 codes).
|
||||
That silently mis-classified dual-fuel mains (Table 32 code 10 =
|
||||
"dual fuel mineral+wood", S0380.135 EES dict BDI → 10) as electric,
|
||||
re-routing space-heating cost to the 7-hour low electric rate
|
||||
(5.50 p/kWh) instead of dual-fuel 3.99 p/kWh — solid fuel 6 SAP
|
||||
residual −7.38 → −11.37.
|
||||
"""
|
||||
return is_electric_fuel_code(_main_fuel_code(main))
|
||||
|
||||
|
||||
def _is_electric_water(water_heating_fuel: Optional[int]) -> bool:
|
||||
if water_heating_fuel is None:
|
||||
return False
|
||||
if water_heating_fuel in {10, 25, 29}:
|
||||
return True
|
||||
if water_heating_fuel in {30, 31, 32, 33, 34, 35, 36, 38, 39, 40}:
|
||||
return True
|
||||
return False
|
||||
"""Same as `_is_electric_main` for the water-heating fuel code.
|
||||
See its docstring for the API/Table 32 collision rationale."""
|
||||
return is_electric_fuel_code(water_heating_fuel)
|
||||
|
||||
|
||||
# RdSAP 10 Table 32 (page 95) — (high_rate_p, low_rate_p) per tariff.
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ from domain.sap10_calculator.exceptions import (
|
|||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
|
||||
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
|
||||
_is_electric_main, # pyright: ignore[reportPrivateUsage]
|
||||
_is_electric_water, # pyright: ignore[reportPrivateUsage]
|
||||
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
|
||||
_pv_overshading_factor, # pyright: ignore[reportPrivateUsage]
|
||||
_pv_pitch_deg, # pyright: ignore[reportPrivateUsage]
|
||||
|
|
@ -1058,6 +1060,78 @@ def test_heat_emitter_code_2_underfloor_in_screed_routes_to_responsiveness_0p75_
|
|||
assert abs(result - 0.75) <= 1e-9
|
||||
|
||||
|
||||
def test_is_electric_main_dual_fuel_table_32_code_10_is_not_electric() -> None:
|
||||
# Arrange — API enum code 10 = "electricity (backwards compat)"; Table
|
||||
# 32 code 10 = "dual fuel (mineral + wood)". Same integer, different
|
||||
# meaning depending on the enum. Pre-S0380.136 `_is_electric_main` used
|
||||
# a literal set check `code in {10, 25, 29}` that assumed the value was
|
||||
# an API code — it silently mis-classified a Table 32 dual-fuel main
|
||||
# (the S0380.135 EES dispatch BDI → 10) as electric, re-routing space-
|
||||
# heating cost through the 7-hour low electric rate (5.50 p/kWh) instead
|
||||
# of dual-fuel 3.99 p/kWh. solid fuel 6 SAP residual −7.38 → −11.37.
|
||||
#
|
||||
# The fix delegates to `table_32.is_electric_fuel_code` which normalises
|
||||
# via T32-first then API-translate fallback (mirrors the pattern in
|
||||
# `unit_price_p_per_kwh`).
|
||||
dual_fuel_main = MainHeatingDetail(
|
||||
has_fghrs=False, main_fuel_type=10, heat_emitter_type=1,
|
||||
emitter_temperature=1, main_heating_control=2105,
|
||||
main_heating_category=4, sap_main_heating_code=160,
|
||||
)
|
||||
|
||||
# Act / Assert — dual fuel (Table 32 10) must NOT be electric
|
||||
assert _is_electric_main(dual_fuel_main) is False
|
||||
|
||||
# Sanity — Table 32 electric codes 30-40 still classify as electric
|
||||
for t32_electric in (30, 31, 32, 33, 34, 35, 38, 40, 60):
|
||||
electric_main = MainHeatingDetail(
|
||||
has_fghrs=False, main_fuel_type=t32_electric, heat_emitter_type=1,
|
||||
emitter_temperature=1, main_heating_control=2105,
|
||||
main_heating_category=2, sap_main_heating_code=191,
|
||||
)
|
||||
assert _is_electric_main(electric_main) is True, (
|
||||
f"Table 32 code {t32_electric} should be electric"
|
||||
)
|
||||
|
||||
# API enum code 29 = "electricity (not community)" routes to Table 32
|
||||
# code 30 (standard electric tariff) — classifies as electric.
|
||||
api_electric_main = MainHeatingDetail(
|
||||
has_fghrs=False, main_fuel_type=29, heat_emitter_type=1,
|
||||
emitter_temperature=1, main_heating_control=2105,
|
||||
main_heating_category=2, sap_main_heating_code=191,
|
||||
)
|
||||
assert _is_electric_main(api_electric_main) is True
|
||||
|
||||
# API enum code 25 = "electricity (community)" routes to Table 32 code
|
||||
# 41 = "heat from electric heat pump (community)", which is a heat
|
||||
# network billed at the heat-network rate (4.24 p/kWh single rate)
|
||||
# rather than an off-peak electric tariff. `is_electric_fuel_code`
|
||||
# correctly classifies it as NOT electric for tariff-handling purposes
|
||||
# (the Table 12a high/low-rate split doesn't apply to heat networks).
|
||||
# Pre-S0380.136 the literal-set check `code in {10, 25, 29}` mis-
|
||||
# treated it as direct electric and applied the off-peak split.
|
||||
community_electric_main = MainHeatingDetail(
|
||||
has_fghrs=False, main_fuel_type=25, heat_emitter_type=1,
|
||||
emitter_temperature=1, main_heating_control=2105,
|
||||
main_heating_category=2, sap_main_heating_code=301,
|
||||
)
|
||||
assert _is_electric_main(community_electric_main) is False
|
||||
|
||||
|
||||
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.
|
||||
|
||||
# Act / Assert — dual fuel WH fuel must NOT be electric
|
||||
assert _is_electric_water(10) is False
|
||||
|
||||
# Sanity — Table 32 electric codes still classify as electric
|
||||
for t32_electric in (30, 31, 32, 33, 34, 35, 38, 40, 60):
|
||||
assert _is_electric_water(t32_electric) is True, (
|
||||
f"Table 32 code {t32_electric} should be electric"
|
||||
)
|
||||
|
||||
|
||||
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"):
|
||||
#
|
||||
|
|
|
|||
|
|
@ -194,7 +194,19 @@ def _is_gas_code(fuel_code: Optional[int]) -> bool:
|
|||
return code is not None and code in _GAS_FUEL_CODES
|
||||
|
||||
|
||||
def _is_electric_code(fuel_code: Optional[int]) -> bool:
|
||||
def is_electric_fuel_code(fuel_code: Optional[int]) -> bool:
|
||||
"""Whether the fuel code maps to a Table 32 electricity row, after
|
||||
normalising via T32-first then API-translate fallback.
|
||||
|
||||
Use this in preference to ad-hoc literal-set checks like
|
||||
`code in {10, 25, 29}`: those mix API enum codes (where 10 is
|
||||
"electricity backwards-compat") and Table 32 codes (where 10 is
|
||||
"dual fuel mineral+wood"), so a Table-32-code-10 dual-fuel main
|
||||
silently mis-classifies as electric. The S0380.135 EES-code →
|
||||
Table 32 mapper lookups set `main_fuel_type` to Table 32 codes
|
||||
(BDI → 10 = dual fuel), so the literal-set checks fail loudly here
|
||||
unless normalised through `_to_table_32_code` first.
|
||||
"""
|
||||
code = _to_table_32_code(fuel_code)
|
||||
return code is not None and code in _ELECTRIC_FUEL_CODES
|
||||
|
||||
|
|
@ -221,7 +233,7 @@ def additional_standing_charges_gbp(
|
|||
gas_code = main_fuel_code if _is_gas_code(main_fuel_code) else water_heating_fuel_code
|
||||
total += standing_charge_gbp(gas_code)
|
||||
if tariff is not Tariff.STANDARD and (
|
||||
_is_electric_code(main_fuel_code) or _is_electric_code(water_heating_fuel_code)
|
||||
is_electric_fuel_code(main_fuel_code) or is_electric_fuel_code(water_heating_fuel_code)
|
||||
):
|
||||
off_peak_code = _OFF_PEAK_STANDING_CODE.get(tariff)
|
||||
if off_peak_code is not None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue