Slice S0380.159: SAP 10.2 Table 4a R tariff-aware dispatch for electric storage

SAP 10.2 Table 4a (PDF p.166) Cat 7 "Electric storage heaters"
splits the responsiveness R between two sub-tables:

  Off-peak tariff:
    Slimline storage heaters       ... R = 0.2  402
    Convector storage heaters      ... R = 0.2  403
    Slimline + Celect-type control ... R = 0.4  405
    Convector + Celect-type ctrl   ... R = 0.4  406
  24-hour heating tariff:
    Slimline storage heaters       ... R = 0.4  402
    Convector storage heaters      ... R = 0.4  403
    Slimline + Celect-type control ... R = 0.6  405
    Convector + Celect-type ctrl   ... R = 0.6  406

Per SAP 10.2 §12.4.3 (PDF p.36) the 18-hour tariff has electricity
at low rate for 18 hours per day with at most 6h of interruption /
2h max each — operationally equivalent to 24-hour for storage-heater
charging. The cascade therefore routes EIGHTEEN_HOUR + TWENTY_FOUR_
HOUR through the 24-hour Table 4a sub-row.

Pre-slice `_responsiveness` keyed on `sap_main_heating_code` only
and returned R=0.2 for code 402 regardless of tariff. The existing
docstring already flagged the gap:

    402: 0.20,  # Slimline storage heaters (24-hr tariff: 0.40)
    ... "promote to (sap_code, tariff) lookup when 24-hour fixture
    surfaces; until then the off-peak default applies (under-shoots
    R for the 24-hour case)."

Per-line walk on electric 5 (sap_main_heating_code=402 +
meter_type="18 Hour"): cascade T_living (87)[Jan] = 20.1213 vs
worksheet 19.6519, (92)[Jan] = 18.6996 vs worksheet 18.2063, (93)
[Jan] = 19.0996 vs worksheet 18.6063 (cascade +0.4933 K throughout
the cascade). Back-solve from worksheet T_living=19.6519 via the
Table 9b Tsc formula:

  Tsc(R=0.4) = 0.6 × (21-2) + 0.4 × (4.3 + 0.9933 × 705.4/210.23)
             = 11.4 + 0.4 × 7.6325 = 14.4528

  ΔT = 21 - 14.4528 = 6.5472
  u_sum = 0.5 × 6.5472 × (7² + 8²) / (24 × 11.43) = 1.3481
  T_living = 21 - 1.3481 = 19.6519 EXACT match.

Adds:
  - `_CONTINUOUS_CHARGING_TARIFFS: frozenset[Tariff]` = {EIGHTEEN_
    HOUR, TWENTY_FOUR_HOUR} — the tariffs treated as "24-hour
    heating" for Table 4a R selection.
  - `_RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE: dict[int, float]`
    — the override table for codes 402/403/405/406 (404, 407, 409
    keep the same R in both sub-tables).
  - `tariff: Optional[Tariff]` parameter to `_responsiveness`, with
    the override consulted before the off-peak default.
  - Tariff threaded through both call sites of MIT cascade (rating
    + demand paths) via `tariff_from_meter_type`.

Closures electric 5:
  ΔSAP −1.1759 → +0.1081 (91% reduction)
  Δcost +£27.09 → −£2.49
  ΔCO2 +62.72 → +7.30 kg
  ΔPE +438.03 → +0.07 kWh (essentially EXACT)

Electric 5 now joins the same residual cluster as electric 3/6/7/8/
9 (+0.09..+0.12 SAP, −£2..−£3 cost, +£7 CO2) — the cluster that
the prior handovers suspected was a shared shave-the-residual gap.

No regressions on the other 24 cohort variants. Extended handover
suite: 903 pass / 0 fail (was 902 — +1 from the new AAA test).
Pyright net-zero (43 → 43).

Σ |ΔSAP_c| across the 25-variant cohort: 2.30 → 1.24 (~46%
reduction from this slice).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-01 22:25:51 +00:00 committed by Jun-te Kim
parent 4291193ba8
commit 5d086f3ed1
3 changed files with 184 additions and 6 deletions

View file

@ -256,6 +256,32 @@ class _CorpusExpectation:
# not apply). No regressions on other variants — only electric 2 has # not apply). No regressions on other variants — only electric 2 has
# the (Cat 4 HP + WHC=903 + cylinder) combination in the corpus. # the (Cat 4 HP + WHC=903 + cylinder) combination in the corpus.
# #
# Slice S0380.159 promoted the Table 4a Cat 7 (Electric storage
# heaters) responsiveness dispatch from sap_code-only to
# (sap_code, tariff)-aware. Spec text: Table 4a p.166 lists code 402
# "Slimline storage heaters" with R=0.2 under the Off-peak section
# AND R=0.4 under the 24-hour heating tariff section. Per SAP 10.2
# §12.4.3 (PDF p.36) the 18-hour tariff has electricity at low rate
# for 18h/day with ≤6h interruption (max 2h windows) — operationally
# equivalent to 24-hour for storage-heater charging. Pre-slice the
# cascade used R=0.20 unconditionally for code 402, producing T_living
# (87)[Jan]=20.12 and (93)[Jan]=19.10 (cascade +0.49 K vs worksheet
# (93)[Jan]=18.6063). Per-line walk + back-solve from worksheet
# T_living=19.6519 confirmed R=0.4 (Tsc = 0.6×19 + 0.4×(4.3+0.9933×
# 705.4/210.23) = 14.4528 → u_sum = 0.5×6.547×113/274.32 = 1.3481 →
# T_living = 21 1.3481 = 19.6519 EXACT). New
# `_CONTINUOUS_CHARGING_TARIFFS = {EIGHTEEN_HOUR, TWENTY_FOUR_HOUR}` +
# `_RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE` (codes 402/403/405/
# 406) consulted at the top of `_responsiveness` before the off-peak
# default lookup. Tariff threaded through both call sites of MIT
# cascade (rating + demand paths). Closures electric 5: ΔSAP 1.1759
# → +0.1081 (91% reduction), Δcost +£27.09 → £2.49, ΔCO2 +62.72 →
# +7.30 kg, ΔPE +438.03 → +0.07 kWh (PE essentially EXACT). Electric
# 5 now joins the same residual-shape cluster as electric 3/6/7/8/9
# (+0.09..+0.12 SAP, £2..£3 cost, +£7 CO2). No regressions on the
# other 24 variants — only code 402 (electric 5) has a tariff
# override that applies in the corpus.
#
# Slice S0380.158 wired the SAP 10.2 Table 4f (PDF p.174) row "Warm # Slice S0380.158 wired the SAP 10.2 Table 4f (PDF p.174) row "Warm
# air heating system fans" = SFP × 0.4 × V (footnote e default SFP = # air heating system fans" = SFP × 0.4 × V (footnote e default SFP =
# 1.5 W/(l/s) when no PCDB warm-air-unit record). Pre-slice the # 1.5 W/(l/s) when no PCDB warm-air-unit record). Pre-slice the
@ -283,7 +309,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6605), _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6605),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.1087, expected_cost_resid_gbp=+2.5037, expected_co2_resid_kg=+16.5405, expected_pe_resid_kwh=+97.6875), _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.1087, expected_cost_resid_gbp=+2.5037, expected_co2_resid_kg=+16.5405, expected_pe_resid_kwh=+97.6875),
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+0.1215, expected_cost_resid_gbp=-2.8003, expected_co2_resid_kg=+6.7227, expected_pe_resid_kwh=-5.9859), _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+0.1215, expected_cost_resid_gbp=-2.8003, expected_co2_resid_kg=+6.7227, expected_pe_resid_kwh=-5.9859),
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-1.1759, expected_cost_resid_gbp=+27.0929, expected_co2_resid_kg=+62.7232, expected_pe_resid_kwh=+438.0333), _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+0.1081, expected_cost_resid_gbp=-2.4918, expected_co2_resid_kg=+7.2978, expected_pe_resid_kwh=+0.0658),
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+0.1081, expected_cost_resid_gbp=-2.4918, expected_co2_resid_kg=+7.3225, expected_pe_resid_kwh=+0.1603), _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+0.1081, expected_cost_resid_gbp=-2.4918, expected_co2_resid_kg=+7.3225, expected_pe_resid_kwh=+0.1603),
_CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+0.1017, expected_cost_resid_gbp=-2.3444, expected_co2_resid_kg=+7.6424, expected_pe_resid_kwh=+3.0976), _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+0.1017, expected_cost_resid_gbp=-2.3444, expected_co2_resid_kg=+7.6424, expected_pe_resid_kwh=+3.0976),
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+0.0941, expected_cost_resid_gbp=-2.1679, expected_co2_resid_kg=+7.9230, expected_pe_resid_kwh=+6.5824), _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+0.0941, expected_cost_resid_gbp=-2.1679, expected_co2_resid_kg=+7.9230, expected_pe_resid_kwh=+6.5824),

View file

@ -1306,7 +1306,10 @@ def _control_type(main: Optional[MainHeatingDetail]) -> int:
raise UnmappedSapCode("main_heating_control", code) raise UnmappedSapCode("main_heating_control", code)
def _responsiveness(main: Optional[MainHeatingDetail]) -> float: def _responsiveness(
main: Optional[MainHeatingDetail],
tariff: Optional[Tariff] = None,
) -> float:
"""SAP 10.2 responsiveness R ∈ [0, 1] per spec line 15271: """SAP 10.2 responsiveness R ∈ [0, 1] per spec line 15271:
"R = responsiveness of main heating system (Table 4a or "R = responsiveness of main heating system (Table 4a or
@ -1327,6 +1330,16 @@ def _responsiveness(main: Optional[MainHeatingDetail]) -> float:
`heat_emitter_type`. Used as the fallback when the SAP code `heat_emitter_type`. Used as the fallback when the SAP code
isn't in the Table 4a dispatch dict. isn't in the Table 4a dispatch dict.
For electric storage SAP codes (402, 403, 405, 406) Table 4a
Cat 7 splits R between the off-peak tariff (7-hour / 10-hour)
section and the 24-hour heating tariff section. Per SAP 10.2
§12.4.3 (PDF p.36) the 18-hour tariff has "electricity at the
low-rate price ... available for 18 hours per day" with at most
6h of interruption / 2h max each operationally equivalent to
24-hour for storage-heater charging. The cascade therefore routes
EIGHTEEN_HOUR + TWENTY_FOUR_HOUR through the 24-hour Table 4a
sub-rows when an override is registered for the lodged SAP code.
Cert-side heat_emitter_type enum (per `_ELMHURST_HEAT_EMITTER_TO_SAP10` Cert-side heat_emitter_type enum (per `_ELMHURST_HEAT_EMITTER_TO_SAP10`
at datatypes/epc/domain/mapper.py:3646): at datatypes/epc/domain/mapper.py:3646):
1 = Radiators R = 1.0 1 = Radiators R = 1.0
@ -1348,8 +1361,16 @@ def _responsiveness(main: Optional[MainHeatingDetail]) -> float:
return 1.0 return 1.0
# Table 4a — per-heating-system R (overrides emitter lookup). # Table 4a — per-heating-system R (overrides emitter lookup).
sap_code = main.sap_main_heating_code sap_code = main.sap_main_heating_code
if sap_code is not None and sap_code in _RESPONSIVENESS_BY_SAP_CODE: if sap_code is not None:
return _RESPONSIVENESS_BY_SAP_CODE[sap_code] # 24-hour / 18-hour tariff override for electric storage heater
# rows that split between the off-peak and 24-hour sub-tables.
if (
tariff in _CONTINUOUS_CHARGING_TARIFFS
and sap_code in _RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE
):
return _RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE[sap_code]
if sap_code in _RESPONSIVENESS_BY_SAP_CODE:
return _RESPONSIVENESS_BY_SAP_CODE[sap_code]
# Table 4d — fallback per emitter type. # Table 4d — fallback per emitter type.
emitter = main.heat_emitter_type emitter = main.heat_emitter_type
if not emitter: if not emitter:
@ -1359,6 +1380,30 @@ def _responsiveness(main: Optional[MainHeatingDetail]) -> float:
raise UnmappedSapCode("heat_emitter_type", emitter) raise UnmappedSapCode("heat_emitter_type", emitter)
# SAP 10.2 §12.4.3 (PDF p.36) — tariffs with near-continuous low-rate
# availability for storage heaters. The 18-hour tariff allows at most
# 6h of interruption split into ≤2h windows, so the storage heaters
# charge essentially continuously — functionally the same as the
# explicit 24-hour heating tariff for the purposes of selecting the
# Table 4a R sub-row.
_CONTINUOUS_CHARGING_TARIFFS: Final[frozenset[Tariff]] = frozenset({
Tariff.EIGHTEEN_HOUR,
Tariff.TWENTY_FOUR_HOUR,
})
# SAP 10.2 Table 4a (PDF p.166) Cat 7 "Electric storage heaters" —
# 24-hour heating tariff sub-table overrides for the codes whose R
# differs from the off-peak default (only the differing rows; 404,
# 407, 409 keep the same R in both sub-tables).
_RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE: Final[dict[int, float]] = {
402: 0.40, # Slimline storage (off-peak 0.20 → 24-hr 0.40)
403: 0.40, # Convector storage (off-peak 0.20 → 24-hr 0.40)
405: 0.60, # Slimline + Celect (off-peak 0.40 → 24-hr 0.60)
406: 0.60, # Convector + Celect (off-peak 0.40 → 24-hr 0.60)
}
# SAP 10.2 Table 4a (PDF p.163-170) — per-heating-system responsiveness R. # SAP 10.2 Table 4a (PDF p.163-170) — per-heating-system responsiveness R.
# These rows override the emitter-based Table 4d lookup because the spec # These rows override the emitter-based Table 4d lookup because the spec
# explicitly lists R against the heating system (the system's intrinsic # explicitly lists R against the heating system (the system's intrinsic
@ -2997,6 +3042,7 @@ def mean_internal_temperature_section_from_cert(
) )
main = _first_main_heating(epc) main = _first_main_heating(epc)
climate = _climate_source(postcode_climate) climate = _climate_source(postcode_climate)
tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type)
return mean_internal_temperature_monthly( return mean_internal_temperature_monthly(
monthly_external_temp_c=tuple( monthly_external_temp_c=tuple(
external_temperature_c(climate, m) for m in range(1, 13) external_temperature_c(climate, m) for m in range(1, 13)
@ -3006,7 +3052,7 @@ def mean_internal_temperature_section_from_cert(
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
total_floor_area_m2=dim.total_floor_area_m2, total_floor_area_m2=dim.total_floor_area_m2,
control_type=_control_type(main), control_type=_control_type(main),
responsiveness=_responsiveness(main), responsiveness=_responsiveness(main, tariff=tariff),
living_area_fraction=_living_area_fraction( living_area_fraction=_living_area_fraction(
epc.habitable_rooms_count, dim.total_floor_area_m2 epc.habitable_rooms_count, dim.total_floor_area_m2
), ),
@ -5382,7 +5428,9 @@ def cert_to_inputs(
# = transmission HLC + 0.33·V·(25)m. Table 4e control adjustment is 0 # = transmission HLC + 0.33·V·(25)m. Table 4e control adjustment is 0
# for the Elmhurst corpus (cert-side mapping is a future slice). # for the Elmhurst corpus (cert-side mapping is a future slice).
control_type_value = _control_type(main) control_type_value = _control_type(main)
responsiveness_value = _responsiveness(main) responsiveness_value = _responsiveness(
main, tariff=tariff_from_meter_type(epc.sap_energy_source.meter_type),
)
living_area_fraction_value = _living_area_fraction( living_area_fraction_value = _living_area_fraction(
epc.habitable_rooms_count, dim.total_floor_area_m2 epc.habitable_rooms_count, dim.total_floor_area_m2
) )

View file

@ -4408,6 +4408,110 @@ def test_sap_table_3_primary_loss_skipped_for_whc_903_electric_immersion_with_he
) )
def test_sap_table_4a_responsiveness_for_slimline_storage_18_hour_tariff() -> None:
"""SAP 10.2 Table 4a (PDF p.166) — code 402 "Slimline storage heaters"
R value splits by tariff:
Off-peak tariff:
Slimline storage heaters ... R = 0.2 402
24-hour heating tariff:
Slimline storage heaters ... R = 0.4 402
Per SAP 10.2 §12.4.3 (PDF p.36) the 18-hour tariff "is only for use
with electric CPSUs ... electricity at the low-rate price is
available for 18 hours per day, with interruptions totalling 6
hours per day, with the proviso that no interruption will exceed
2 hours". With 18h of low-rate availability the storage heaters
are charged near-continuously operationally equivalent to the
24-hour tariff for responsiveness purposes. Elmhurst's lodging
behaviour for property 001431 electric 5 (sap_main_heating_code=
402 + Tariff="18 Hour" + cylinder + WHC=903) computes the §7 MIT
cascade with R=0.4 (back-solved from worksheet (87)[Jan]=19.6519
via Table 9b: T_sc = 0.6×19 + 0.4×(4.3 + 0.9933×705.4/210.23) =
14.4528 u_sum=1.3481 T_living = 211.3481 = 19.6519 EXACT).
Pre-slice `_responsiveness` ignored the tariff and returned R=0.2
for code 402 unconditionally yielding T_living=20.1213, T_other=
18.0903, (92)=18.6996, (93)=19.0996 (cascade +0.49 K vs worksheet
18.6063) SH demand +366 kWh/yr over the worksheet, ΔSAP 1.18.
The Table 4a 24-hour-tariff override applies for any tariff with
near-continuous low-rate availability: EIGHTEEN_HOUR + TWENTY_FOUR_
HOUR. 7-hour / 10-hour off-peak keep the off-peak defaults.
"""
# Arrange — electric 5 corpus variant: code 402 + 18-hour tariff +
# 110 L cylinder + WHC=903 electric immersion + cylinder thermostat.
import re
import subprocess
from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
corpus_electric_5 = (
Path(__file__).parents[4]
/ "sap worksheets/heating systems examples/electric 5"
)
summary_pdf = next(corpus_electric_5.glob("Summary_*.pdf"))
info = subprocess.run(
["pdfinfo", str(summary_pdf)], capture_output=True, text=True, check=True,
).stdout
pc_match = re.search(r"Pages:\s+(\d+)", info)
assert pc_match is not None
pc = int(pc_match.group(1))
pages: list[str] = []
for i in range(1, pc + 1):
layout = subprocess.run(
["pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(summary_pdf), "-"],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(notes)
main = epc.sap_heating.main_heating_details[0]
assert main.sap_main_heating_code == 402
assert epc.sap_energy_source.meter_type == "18 Hour"
# Act — drive cert_to_inputs and read the responsiveness threaded
# into the MIT cascade via `inputs.adjusted_mean_internal_temp_monthly`.
# The R value isn't exposed directly on `CalculatorInputs`; instead
# we check the downstream effect: the Jan adjusted MIT must match
# the worksheet's (93)[Jan] = 18.6063 (which only happens when the
# Tsc formula uses R=0.4, not R=0.2).
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
# Assert — adjusted MIT (93) Jan must match the worksheet at 1e-3.
# Pre-slice cascade uses R=0.2 → (93)[Jan] = 19.0996 (off by
# +0.4933 K).
# Tolerance: ±0.01 K absorbs the small upstream gains divergence
# between the cascade's (84) and the worksheet's (~7 W diff on
# internal-gains calc precision); the R-flip itself shifts the
# cascade by +0.49 K — closing the residual from 0.49 → ~0.003.
expected_adjusted_mit_jan = 18.6063 # worksheet (93) Jan, R=0.4
got = inputs.mean_internal_temp_monthly_c[0]
assert abs(got - expected_adjusted_mit_jan) <= 1e-2, (
f"electric 5 (Table 4a code 402 Slimline storage + 18-hour "
f"tariff) cascade adjusted MIT (93)[Jan] = {got:.4f}; want "
f"{expected_adjusted_mit_jan:.4f} per worksheet. Pre-slice the "
f"`_responsiveness` dispatch keyed on sap_code only and "
f"returned R=0.2 for code 402 regardless of tariff; per SAP "
f"10.2 Table 4a (PDF p.166) the 24-hour-heating-tariff section "
f"lists code 402 with R=0.4, and per §12.4.3 the 18-hour "
f"tariff is operationally equivalent (18h low-rate availability "
f"with ≤6h interruption / 2h max each = near-continuous "
f"charging like 24-hour)."
)
def test_sap_table_4f_warm_air_heating_system_fans_kwh_for_cat5_heat_pump() -> None: def test_sap_table_4f_warm_air_heating_system_fans_kwh_for_cat5_heat_pump() -> None:
"""SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans" """SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans"
+ footnote e) verbatim: + footnote e) verbatim: