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:
Khalim Conn-Kowlessar 2026-05-31 13:37:14 +00:00 committed by Jun-te Kim
parent 29d0523765
commit 9427354d88
4 changed files with 105 additions and 22 deletions

View file

@ -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),

View file

@ -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.

View file

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

View file

@ -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: