mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
8843df1b46
commit
fba45d1111
3 changed files with 184 additions and 6 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = 21−1.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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue