fix(storage-heaters): Table 12a code-408 integrated-storage high-rate fraction

SAP 10.2 Table 12a Grid 1 (PDF p.191): electric storage heater SAP code 408
is an "Integrated (storage + direct-acting) system" with a 0.20 space-heating
high-rate fraction on a 7-hour tariff — NOT the 0.00 of "other storage
heaters". `_table_12a_system_for_main` returned None for all storage codes (an
explicit TODO), so code 408 fell back to the 100%-low-rate path and billed
space heating at the bare 7-hour low rate (5.50 p/kWh) — under-costing →
over-rating. Mapped cat-7 storage: 408 -> INTEGRATED_STORAGE_DIRECT (0.20),
others -> OTHER_STORAGE_HEATERS (0.00, unchanged behaviour). The enum +
fraction rows already existed; this only wires the dispatch, so the split
flows self-consistently to both the §10a cost and the Appendix-M1 D_PV
high-rate fraction.

Corpus: sap408 over-raters +14.6/+12.9/+12.7 -> +7.1/+5.1/+3.4 (two crossed
into within-0.5). Gauge 65.9% -> 66.1%, MAE 1.160 -> 1.128. Floor 0.63 -> 0.64
/ MAE ceiling 1.22 -> 1.18. Worksheet harness 47/47 0 diverge. The residual
+3..+7 is the "all other uses" 0.90 high-rate fraction (lighting/pumps/HW
still billed 100%-low on the off-peak legacy path) — the next slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-14 02:12:39 +00:00
parent dfcd7af57c
commit bec62b9167
3 changed files with 91 additions and 6 deletions

View file

@ -2408,7 +2408,8 @@ def _table_12a_system_for_main(
Coverage as fixtures land:
- ASHP / GSHP (codes 211-224, 521-524, PCDB index) wired
- Storage heaters (401-409) TODO
- Storage heaters (cat 7): 408 INTEGRATED_STORAGE_DIRECT (0.20),
all others OTHER_STORAGE_HEATERS (0.00) wired
- Underfloor heating (421-422) TODO
- Direct-acting electric (191) / CPSU (192) / electric storage
boiler (193, 195) TODO
@ -2451,6 +2452,16 @@ def _table_12a_system_for_main(
211 <= code <= 217 or 221 <= code <= 227 or 521 <= code <= 524
):
return Table12aSystem.ASHP_OTHER
# Electric STORAGE heaters (RdSAP main_heating_category 7) — SAP 10.2
# Table 12a Grid 1 (PDF p.191). Code 408 is an "Integrated (storage +
# direct-acting) system" → 0.20 SH high-rate fraction at 7-hour; every
# other storage code is "Other storage heaters" → 0.00 (charged wholly
# off-peak, the same 100%-low-rate the None fallback already gave).
# Gated on `_is_electric_main` belt-and-braces (all callers pre-gate).
if main.main_heating_category == 7 and _is_electric_main(main):
if code == 408:
return Table12aSystem.INTEGRATED_STORAGE_DIRECT
return Table12aSystem.OTHER_STORAGE_HEATERS
return None

View file

@ -624,6 +624,78 @@ def test_cylinder_size_inaccessible_code_5_off_peak_dual_immersion_uses_210l() -
assert volume_l is not None and abs(volume_l - 210.0) <= 1e-9
def test_integrated_storage_heater_408_bills_table_12a_grid1_high_rate_fraction() -> None:
# Arrange — electric storage heaters (cat 7), SAP code 408, on an
# off-peak 7-hour (dual / Economy-7) meter. SAP 10.2 Table 12a Grid 1
# (PDF p.191): code 408 is an "Integrated (storage + direct-acting)
# system" with a 0.20 space-heating high-rate fraction at 7-hour (NOT
# the 0.00 of "other storage heaters"). The scalar SH rate is therefore
# the blend 0.20 × high (15.29) + 0.80 × low (5.50) = 7.458 p/kWh.
from dataclasses import replace
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2401,
main_heating_category=7, # electric storage heaters
sap_main_heating_code=408,
)
base = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[make_building_part(construction_age_band="E")],
sap_heating=make_sap_heating(main_heating_details=[main]),
)
epc = replace(
base,
sap_energy_source=replace(base.sap_energy_source, meter_type="1"),
)
# Act
inputs = cert_to_inputs(epc)
# Assert — blended scalar rate 0.20×15.29 + 0.80×5.50 = 7.458 p/kWh.
assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.07458) <= 1e-9
def test_non_integrated_storage_heater_bills_100_percent_low_rate() -> None:
# Arrange — same off-peak storage cert but SAP code 401 ("other storage
# heaters"): Table 12a Grid 1 gives a 0.00 high-rate fraction → the heat
# is charged wholly at the 7-hour low rate (5.50 p/kWh). Guards that the
# 408 integrated-system 0.20 fraction does NOT leak to other codes.
from dataclasses import replace
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2401,
main_heating_category=7,
sap_main_heating_code=401,
)
base = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[make_building_part(construction_age_band="E")],
sap_heating=make_sap_heating(main_heating_details=[main]),
)
epc = replace(
base,
sap_energy_source=replace(base.sap_energy_source, meter_type="1"),
)
# Act
inputs = cert_to_inputs(epc)
# Assert — 100% low rate, 5.50 p/kWh.
assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.0550) <= 1e-9
def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None:
# Arrange — heat-network main (Table 4a code 301 = community heating,
# category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution

View file

@ -41,10 +41,12 @@ _CORPUS = Path(
)
# Measured floors/ceilings over the fixed corpus at HEAD (1000 certs, 0 skips).
# Current: SAP within-0.5 = 65.9%, SAP MAE = 1.160 (heat-network Table 4c(3)
# flat-rate charging factor, this slice: community cluster 38% -> 62% within-0.5).
# Current: SAP within-0.5 = 66.1%, SAP MAE = 1.128 (Table 12a Grid 1
# integrated-storage code-408 0.20 high-rate fraction, this slice: sap408
# over-rate +14.6/+12.9/+12.7 -> +7.1/+5.1/+3.4; prior slice was the
# heat-network Table 4c(3) flat-rate charging factor).
# CO2 MAE = 0.28 t/yr (signed +0.17 — a systematic over-estimate, see below).
# PE MAE = 14.7 kWh/m2/yr (signed +9.2).
# PE MAE = 14.7 kWh/m2/yr (signed +9.1).
#
# The SAP (cost) gauge is the optimised target — its floor/ceiling are TIGHT.
# CO2 and PE are reported + LOOSELY guarded: both run ~+5% high vs the lodged
@ -63,8 +65,8 @@ _CORPUS = Path(
# energy were 5% high; actual SAP bias is +0.145).
# So closing demand over-estimates lifts BOTH the SAP gauge and PE/CO2; there is
# no one-slice factor fix. RATCHET any ceiling up when a slice tightens it.
_MIN_WITHIN_HALF_SAP = 0.63
_MAX_SAP_MAE = 1.22
_MIN_WITHIN_HALF_SAP = 0.64
_MAX_SAP_MAE = 1.18
_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current