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
parent 8843df1b46
commit fba45d1111
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
# 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
# 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
@ -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 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 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 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),

View file

@ -1306,7 +1306,10 @@ def _control_type(main: Optional[MainHeatingDetail]) -> int:
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:
"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
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`
at datatypes/epc/domain/mapper.py:3646):
1 = Radiators R = 1.0
@ -1348,8 +1361,16 @@ def _responsiveness(main: Optional[MainHeatingDetail]) -> float:
return 1.0
# Table 4a — per-heating-system R (overrides emitter lookup).
sap_code = main.sap_main_heating_code
if sap_code is not None and sap_code in _RESPONSIVENESS_BY_SAP_CODE:
return _RESPONSIVENESS_BY_SAP_CODE[sap_code]
if sap_code is not None:
# 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.
emitter = main.heat_emitter_type
if not emitter:
@ -1359,6 +1380,30 @@ def _responsiveness(main: Optional[MainHeatingDetail]) -> float:
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.
# These rows override the emitter-based Table 4d lookup because the spec
# 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)
climate = _climate_source(postcode_climate)
tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type)
return mean_internal_temperature_monthly(
monthly_external_temp_c=tuple(
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,
total_floor_area_m2=dim.total_floor_area_m2,
control_type=_control_type(main),
responsiveness=_responsiveness(main),
responsiveness=_responsiveness(main, tariff=tariff),
living_area_fraction=_living_area_fraction(
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
# for the Elmhurst corpus (cert-side mapping is a future slice).
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(
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:
"""SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans"
+ footnote e) verbatim: