Fix HHRSH storage heaters priced at peak rate (engine 48 vs Elmhurst 71)

The high-heat-retention storage-heater (HHRSH) recommendation was computed
as LOWERING SAP and wrongly rejected by the Optimiser, leaving electric-
room-heater flats/maisonettes stuck at band E with no sensible plan.

Root cause: `_HHR_STORAGE_OVERLAY` sets `sap_main_heating_code=409` (HHR
storage) + `meter_type="Dual"` but left `main_heating_category` at the
pre-existing value (10 = electric room heaters). The SAP 10.2 Table 12a
space-heating high-rate-fraction resolver classifies by category, so it
misread the off-peak storage heating as direct-acting electric (1.00
high-rate) and priced 100% at the 7-hour peak rate (15.29p) instead of the
storage off-peak split (0.00, ~5.5p).

Verified against accredited Elmhurst RdSAP-10 on UPRN 100020942571
(8 Booth Close, SE28 8BW): engine HHRSH SAP 48 (peak-priced, cost £1087)
vs Elmhurst 71 (cost ~£611); with the fix the engine gives 73 / £576.

Two-part fix (defence in depth, so no future overlay/mapper can
reintroduce it):
- `_HHR_STORAGE_OVERLAY` now sets `main_heating_category=7`, keeping the
  overlaid EPC internally consistent.
- `_table_12a_system_for_main` now classifies electric storage heaters by
  their Table 4a SAP code (401-409) as well as `category==7`, checked
  before the room-heater branch, so a specific storage code is
  authoritative over a stale/inconsistent category.

Tests: new regression `test_storage_heater_classified_by_sap_code_over_stale_category`;
updated the HHRSH overlay pin. Full accuracy corpus still passes (no regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-02 11:24:03 +00:00
parent c1169b2d39
commit f5fbeb8e7e
4 changed files with 97 additions and 13 deletions

View file

@ -59,6 +59,14 @@ _HEAT_PUMP_CATEGORY = 4
_HHR_STORAGE_OVERLAY = HeatingOverlay(
main_fuel_type=_ELECTRICITY_FUEL,
sap_main_heating_code=_HHR_STORAGE_SAP_CODE,
# RdSAP electric-storage-heater category (7). Set so the overlaid EPC stays
# internally consistent: without it the pre-existing system's category (e.g.
# 10 = electric room heaters) survives alongside the new storage code 409,
# and the SAP 10.2 Table 12a resolver — which classifies by category —
# misreads the off-peak storage heating as direct-acting electric and prices
# 100% at the peak rate (verified: UPRN 100020942571 HHRSH SAP 48 vs
# accredited Elmhurst 71; category 7 -> 73). See ADR-0024.
main_heating_category=7,
main_heating_control=2404,
water_heating_code=903,
water_heating_fuel=_ELECTRICITY_FUEL,

View file

@ -2574,6 +2574,17 @@ _ELECTRIC_ROOM_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset(
{691, 692, 693, 694, 699}
)
# SAP 10.2 Table 4a electric STORAGE-heater codes (PDF p.169, codes 401-409:
# old-large-volume / slimline / fan / integrated-storage-direct 408 / high-heat-
# retention 409, etc.). Mirrors the storage subset of `table_12a._RULE_2_STORAGE_
# CODES` (heaters only — excludes underfloor 421/422 and storage boilers 193/195,
# which have their own Table 12a rows). Used to classify storage heaters by SAP
# code as well as RdSAP `main_heating_category == 7`, so a storage-coded system
# is priced off-peak even when its category is stale or inconsistent.
_ELECTRIC_STORAGE_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset(
range(401, 410)
)
# SAP 10.2 Table 4a electric boilers (PDF p.170, codes 191-196) → their
# Table 12a Grid 1 SH rows (PDF p.191). NOTE the boiler families do NOT all
# share a row — read the spec exactly:
@ -2623,6 +2634,26 @@ def _table_12a_system_for_main(
main.main_heating_index_number is not None
and heat_pump_record(main.main_heating_index_number) is not None
)
# Electric STORAGE heaters — SAP 10.2 Table 12a Grid 1 (PDF p.191).
# Detected by RdSAP main_heating_category 7 OR, authoritatively, by a Table
# 4a electric-storage-heater SAP code (401-409). Keyed on the CODE as well as
# the category, and checked BEFORE the room-heater branch below, so a storage
# system whose category is stale or inconsistent — e.g. a recommendation
# `HeatingOverlay` that sets `sap_main_heating_code=409` (HHR storage) but
# leaves the pre-existing electric-room-heater `main_heating_category=10` — is
# still priced off-peak here instead of being misclassified as direct-acting
# (1.00 high-rate → 100% peak) by the category-10 gate. A specific Table 4a
# code is more authoritative than the coarser category. Code 408 is an
# "Integrated (storage + direct-acting) system" → 0.20 SH high-rate fraction
# at 7-hour; every other storage code → "Other storage heaters" → 0.00
# (charged wholly off-peak). Gated on `_is_electric_main` belt-and-braces.
if _is_electric_main(main) and (
main.main_heating_category == 7
or (code is not None and code in _ELECTRIC_STORAGE_HEATER_SAP_CODES)
):
if code == 408:
return Table12aSystem.INTEGRATED_STORAGE_DIRECT
return Table12aSystem.OTHER_STORAGE_HEATERS
# Electric room heaters are direct-acting electric → SAP 10.2 Table
# 12a Grid 1 (PDF p.191) "Other systems including direct-acting
# electric" row (7-hour high-rate fraction 1.00, 10-hour 0.50).
@ -2634,9 +2665,9 @@ def _table_12a_system_for_main(
# main_heating_category 1, NOT 10, so the category-only gate missed it
# and it fell through to None → 100% off-peak LOW rate, billing
# direct-acting heaters as if they charged overnight like storage
# (cert 2958 +32.2 SAP, the worst over-rate in the sample). Distinct
# from electric STORAGE heaters (category 7), which DO charge off-peak
# and correctly fall through to None here (→ 100% low rate). Gated on
# (cert 2958 +32.2 SAP, the worst over-rate in the sample). Electric
# STORAGE heaters (category 7 / codes 401-409) are handled by the
# storage-heater branch above, so they never reach here. Gated on
# `_is_electric_main` so a non-electric room heater (gas / solid-fuel
# cat 10) is excluded; all callers already pre-gate on electric.
if _is_electric_main(main) and (
@ -2665,16 +2696,6 @@ 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
# Electric boilers (Table 4a codes 191-196) — resolve the misleading TODO
# that lumped them as one "direct-acting" family. They split across THREE
# distinct Table 12a Grid 1 rows (see `_DIRECT_ACTING_ELECTRIC_BOILER_CODES`

View file

@ -76,6 +76,12 @@ def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None:
].overlay.heating == HeatingOverlay(
main_fuel_type=30,
sap_main_heating_code=409,
# Storage category (7) pins the fix for the off-peak-pricing bug: the
# overlay must set it so the Table 12a resolver classifies the off-peak
# storage heating as storage (0.00 high-rate) and not direct-acting
# electric (1.00) via a stale category. See test_cert_to_inputs
# `test_storage_heater_classified_by_sap_code_over_stale_category`.
main_heating_category=7,
main_heating_control=2404,
water_heating_code=903,
water_heating_fuel=30,

View file

@ -1054,6 +1054,55 @@ def test_electric_boilers_191_195_map_to_distinct_table_12a_grid1_rows() -> None
assert space_heating_high_rate_fraction(storage, Tariff.SEVEN_HOUR) == 0.00
def test_storage_heater_classified_by_sap_code_over_stale_category() -> None:
# Regression: electric STORAGE heaters must resolve to a storage Table 12a
# Grid 1 row from their Table 4a SAP code (401-409) even when the RdSAP
# `main_heating_category` is stale/inconsistent (== 10, electric room
# heaters). This is exactly the state the HHRSH recommendation overlay
# produces — it sets `sap_main_heating_code=409` on a dwelling that was
# electric room heaters (category 10) — and the category-10 room-heater
# branch used to win, mispricing the off-peak storage heating as
# direct-acting electric (1.00 high-rate → 100% peak). Verified real-cert
# impact: UPRN 100020942571 HHRSH SAP 48 (peak-priced) vs accredited
# Elmhurst 71; code-authoritative classification restores it to 73.
from domain.sap10_calculator.tables.table_12a import (
Table12aSystem,
Tariff,
space_heating_high_rate_fraction,
)
def _storage_main(code: int, category: int) -> MainHeatingDetail:
return MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity
heat_emitter_type=0,
emitter_temperature=1,
main_heating_control=2404, # HHRSH controls
main_heating_category=category,
sap_main_heating_code=code,
)
# HHR storage (409) with a stale room-heater category (10) — the overlay case.
hhr_stale = _table_12a_system_for_main(_storage_main(409, category=10))
assert hhr_stale is Table12aSystem.OTHER_STORAGE_HEATERS
assert space_heating_high_rate_fraction(hhr_stale, Tariff.SEVEN_HOUR) == 0.00
# Integrated storage/direct-acting (408) is still its own row, code-driven.
integrated = _table_12a_system_for_main(_storage_main(408, category=10))
assert integrated is Table12aSystem.INTEGRATED_STORAGE_DIRECT
assert space_heating_high_rate_fraction(integrated, Tariff.SEVEN_HOUR) == 0.20
# The canonical consistent case (category 7) is unchanged.
hhr_consistent = _table_12a_system_for_main(_storage_main(409, category=7))
assert hhr_consistent is Table12aSystem.OTHER_STORAGE_HEATERS
# A genuine electric room heater (code 691, category 10) is NOT swept into
# the storage branch — it stays direct-acting (1.00 high-rate at 7-hour).
room_heater = _table_12a_system_for_main(_storage_main(691, category=10))
assert room_heater is Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC
assert space_heating_high_rate_fraction(room_heater, Tariff.SEVEN_HOUR) == 1.00
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