Slice S0380.144: Table 11 — per-Table-4a-code secondary fraction dispatch for electric storage heaters + remove code 408 from §A.2.2 forced-secondary set

SAP 10.2 Table 11 (PDF p.188) "Fraction of heat supplied by
secondary heating systems" — the "Electric storage heaters (not
integrated)" row splits by Table 4a sub-type:

  - not fan-assisted:                                       0.15
  - fan-assisted:                                           0.10
  - high heat retention (as defined in 9.2.8):              0.10

Plus separate rows:
  Integrated storage/direct-acting electric systems:        0.10
  Electric room heaters:                                    0.20
  Other electric systems (e.g. underfloor):                 0.10

Cross-referenced with SAP 10.2 Table 4a (PDF p.166) Electric
storage codes:

  401: Old (large volume) storage heaters     — not fan-assisted
  402: Slimline storage heaters                — not fan-assisted
  403: Convector storage heaters               — not fan-assisted
  404: Fan storage heaters                     — fan-assisted
  405: Slimline + Celect                       — not fan-assisted
  406: Convector + Celect                      — not fan-assisted
  407: Fan + Celect                            — fan-assisted
  408: Integrated storage + direct-acting      — "Integrated"
  409: High heat retention                     — HHR
  421: Underfloor heating                      — "Other electric"

Pre-slice the cascade defaulted `_secondary_fraction` to 0.10 for
every forced electric-storage code (Elmhurst mapper leaves
`main_heating_category=None`, dispatch falls through to the
`_SECONDARY_HEATING_FRACTION_DEFAULT` 0.10), missing the 0.15
not-fan-assisted sub-row on codes 401/402/403/405/406.

Two compounding spec-citable fixes:

(a) New `_SECONDARY_FRACTION_BY_ELECTRIC_STORAGE_CODE` dispatch dict
    consulted before the category-based lookup in
    `_secondary_fraction`. Routes each Table 4a 4xx code to its
    Table 11 sub-row fraction.

(b) Code 408 removed from `_FORCE_SECONDARY_FOR_MAIN_CODES`.
    SAP 10.2 §A.2.2 (PDF p.~189) verbatim: "This applies to main
    heating codes 401 to 407, 409 and 421" — 408 is explicitly
    NOT in the spec's forced list. The integrated storage+direct-
    acting heater's direct-acting element acts as the secondary
    already, so the calculation doesn't add another.

Corpus impact (electric variants — Elmhurst mapper path):

- electric 3 (SAP 401): sec_frac 0.10 → 0.15; CO2 -117.84 →
  -108.88; PE -1121.97 → -1093.18. SAP / cost residual unchanged
  because the off-peak meter routes the cost calc through the
  `_ZERO_FUEL_COST_FOR_OFF_PEAK` sentinel + legacy scalar-field
  math which bills main and secondary at the same off-peak low
  rate (7.41 p/kWh) — main-vs-secondary split is cost-neutral.
- electric 5 (SAP 402): sec_frac 0.10 → 0.15; CO2 -11.08 → -2.48;
  PE -161.03 → -133.36. Same cost-invariance.
- electric 7 (SAP 408): forced-secondary removed → cascade secondary
  fuel kWh 891 → 0 (matches worksheet); CO2 -37.86 → -53.57;
  PE -498.47 → -549.37. SAP residual unchanged (same off-peak
  cost-invariance).
- electric 4/6/8/9: no change (categories 404/409/421 keep their
  existing 0.10 dispatch).

The remaining +2.55 SAP residual on electric 3 (+1.29 on electric 7)
is now confirmed to be driven by space-heating DEMAND undercount
(cascade SH demand 10083 kWh vs worksheet 11088 kWh for electric 3;
8914 vs 9529 for electric 7), not by sec_frac dispatch. That's a
separate slice — likely §9 MIT calc or §8 gains/HLC for storage-
heater R values, follow-up after this slice.

Extended handover suite: 887 pass, 0 fail (was 886 + 1 new AAA test).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 21:27:46 +00:00
parent 53ceb63624
commit ec6661cbb6
3 changed files with 186 additions and 4 deletions

View file

@ -222,10 +222,10 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.2418, expected_cost_resid_gbp=-5.5706, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017),
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0573, expected_cost_resid_gbp=+1.3188, expected_co2_resid_kg=+8.0120, expected_pe_resid_kwh=+94.4789),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+0.4737, expected_cost_resid_gbp=-10.9153, expected_co2_resid_kg=+10.9544, expected_pe_resid_kwh=+100.9401),
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+2.5452, expected_cost_resid_gbp=-58.6455, expected_co2_resid_kg=-117.8401, expected_pe_resid_kwh=-1121.9666),
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+0.0747, expected_cost_resid_gbp=-1.7232, expected_co2_resid_kg=-11.0752, expected_pe_resid_kwh=-161.0345),
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+2.5452, expected_cost_resid_gbp=-58.6455, expected_co2_resid_kg=-108.8821, expected_pe_resid_kwh=-1093.1815),
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+0.0747, expected_cost_resid_gbp=-1.7232, expected_co2_resid_kg=-2.4846, expected_pe_resid_kwh=-133.3636),
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+1.3278, expected_cost_resid_gbp=-30.5954, expected_co2_resid_kg=-56.1047, expected_pe_resid_kwh=-562.5298),
_CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+1.2903, expected_cost_resid_gbp=-29.7300, expected_co2_resid_kg=-37.8591, expected_pe_resid_kwh=-498.4709),
_CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+1.2903, expected_cost_resid_gbp=-29.7300, expected_co2_resid_kg=-53.5730, expected_pe_resid_kwh=-549.3654),
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.2568, expected_cost_resid_gbp=+5.9163, expected_co2_resid_kg=+11.6231, expected_pe_resid_kwh=+126.0896),
_CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.1181, expected_cost_resid_gbp=+2.7217, expected_co2_resid_kg=+5.6819, expected_pe_resid_kwh=+91.4145),
_CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023),

View file

@ -512,14 +512,54 @@ _SECONDARY_HEATING_FRACTION_DEFAULT: Final[float] = 0.10
# underfloor heating. This applies to main heating codes 401 to 407, 409
# and 421. Portable electric heaters (693) are used in the calculation
# if no secondary system has been identified."
# Code 408 (Integrated storage+direct-acting heater) is explicitly NOT
# in the spec's forced list — the integrated direct-acting element acts
# as the secondary already, so the calculation doesn't add another.
# For gas/oil/solid boiler main systems, the cert calculator only includes
# secondary when one has actually been lodged on the cert.
_DEFAULT_SECONDARY_HEATING_CODE: Final[int] = 693
_FORCE_SECONDARY_FOR_MAIN_CODES: Final[frozenset[int]] = frozenset(
list(range(401, 410)) + [421]
list(range(401, 408)) + [409, 421]
)
# SAP 10.2 Table 11 (PDF p.188) — per-SAP-code secondary heating
# fraction for the "Electric storage heaters (not integrated)" row,
# which splits by Table 4a sub-type:
# not fan-assisted: 0.15
# fan-assisted: 0.10
# HHR: 0.10
# Cross-referenced against SAP 10.2 Table 4a (PDF p.166) code
# definitions (line refs 9120-9128 of the spec PDF):
# 401: Old (large volume) storage heaters — not fan-assisted
# 402: Slimline storage heaters — not fan-assisted
# 403: Convector storage heaters — not fan-assisted
# 404: Fan storage heaters — fan-assisted
# 405: Slimline + Celect — not fan-assisted
# 406: Convector + Celect — not fan-assisted
# 407: Fan + Celect — fan-assisted
# 408: Integrated storage + direct-acting — "Integrated"
# 409: High heat retention — HHR
# 421: Underfloor heating — "Other electric"
# Pre-S0380.144 the cascade defaulted to 0.10 for every forced electric
# storage code (mapper leaves `main_heating_category=None`); this dict
# distinguishes the not-fan-assisted 0.15 sub-row from the fan-
# assisted / HHR / integrated / other-electric 0.10 sub-rows.
_SECONDARY_FRACTION_BY_ELECTRIC_STORAGE_CODE: Final[dict[int, float]] = {
401: 0.15,
402: 0.15,
403: 0.15,
404: 0.10,
405: 0.15,
406: 0.15,
407: 0.10,
408: 0.10, # Not in `_FORCE_SECONDARY_FOR_MAIN_CODES` — only used
# when the cert lodges a secondary explicitly.
409: 0.10,
421: 0.10,
}
# SAP 10.2 Table 12 code 60 — PV export tariff. The calculator uses this
# rate as the per-kWh PV cost credit applied against total annual fuel
# cost in the ECF numerator.
@ -1369,6 +1409,15 @@ def _secondary_fraction(
spec is silent on overriding (only the §A.2.2 forced-secondary rule
is explicit), and an S-B30 attempt to override yielded SAP MAE
+0.16 the wrong direction.
Per-SAP-code dispatch via
`_SECONDARY_FRACTION_BY_ELECTRIC_STORAGE_CODE` (added S0380.144)
splits the Table 11 "Electric storage heaters (not integrated)"
row into its three Table 4a sub-types (not-fan-assisted 0.15,
fan-assisted 0.10, HHR 0.10). Pre-S0380.144 the Elmhurst mapper
left `main_heating_category=None` on every electric variant, and
the cascade fell through to the 0.10 default missing the 0.15
not-fan-assisted sub-row on codes 401/402/403/405/406.
"""
if main is None:
return 0.0
@ -1377,6 +1426,11 @@ def _secondary_fraction(
force = code is not None and code in _FORCE_SECONDARY_FOR_MAIN_CODES
if not has_lodged_secondary and not force:
return 0.0
if (
code is not None
and code in _SECONDARY_FRACTION_BY_ELECTRIC_STORAGE_CODE
):
return _SECONDARY_FRACTION_BY_ELECTRIC_STORAGE_CODE[code]
return _secondary_heating_fraction_for_category(main.main_heating_category)

View file

@ -2967,6 +2967,134 @@ def test_sap_4_lines_7700_7702_pcdb_regular_boiler_with_cylinder_zeroes_combi_lo
)
def test_sap_10_2_table_11_electric_storage_secondary_fraction_dispatches_per_table_4a_code() -> None:
"""SAP 10.2 Table 11 (PDF p.188) "Fraction of heat supplied by
secondary heating systems" — the "Electric storage heaters (not
integrated)" row splits by Table 4a sub-type:
- not fan-assisted: 0.15
- fan-assisted: 0.10
- high heat retention (as defined in 9.2.8): 0.10
Plus separate rows:
Integrated storage/direct-acting electric systems: 0.10
Electric room heaters: 0.20
Other electric systems (e.g. underfloor): 0.10
SAP 10.2 Table 4a (PDF p.166) electric-storage codes:
401: Old (large volume) storage heaters not fan-assisted
402: Slimline storage heaters not fan-assisted
403: Convector storage heaters not fan-assisted
404: Fan storage heaters fan-assisted
405: Slimline + Celect not fan-assisted
406: Convector + Celect not fan-assisted
407: Fan + Celect fan-assisted
408: Integrated storage + direct-acting integrated
409: High heat retention storage heaters HHR
421: Underfloor heating other electric
SAP 10.2 §A.2.2 (PDF p.~189) forces a secondary system in the
calculation when the main is "electric storage heaters or off-peak
electric underfloor heating" — verbatim: "This applies to main
heating codes 401 to 407, 409 and 421" (404 fan-assisted, 408
integrated storage+direct-acting are NOT in the forced set per
spec; 408 in particular bundles its own direct-acting element so
the calculation doesn't add a separate secondary).
Pre-slice the cascade defaulted `_secondary_fraction` to 0.10 for
every forced electric-storage code (mapper leaves
`main_heating_category=None`, dispatch falls through to the
DEFAULT_SECONDARY_HEATING_FRACTION = 0.10), missing the 0.15 row
for not-fan-assisted codes 401-403/405-406. Cert pcdb 1's
corpus electric-storage variants surface the gap:
electric 3 (SAP 401): worksheet (201) = 0.15, cascade = 0.10
electric 5 (SAP 402): worksheet (201) = 0.15, cascade = 0.10
electric 7 (SAP 408): worksheet (201) = 0.00, cascade = 0.10
(cascade wrongly forces secondary)
"""
# Arrange — route corpus electric variants 3 (401), 5 (402), 7 (408)
# through the Elmhurst extractor → mapper → cascade chain. Each
# variant lodges no secondary heating system; the cascade's
# `_secondary_fraction` dispatch is therefore exercised by either
# the §A.2.2 forced-secondary rule (401, 402) or the spec exclusion
# of code 408.
import re
import subprocess
from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_secondary_fraction, # pyright: ignore[reportPrivateUsage]
)
def _epc_for(variant: str):
corpus = (
Path(__file__).parents[4]
/ "sap worksheets/heating systems examples"
/ variant
)
summary_pdf = next(corpus.glob("Summary_*.pdf"))
info = subprocess.run(
["pdfinfo", str(summary_pdf)], capture_output=True, text=True, check=True,
).stdout
pc = int(re.search(r"Pages:\s+(\d+)", info).group(1)) # type: ignore[union-attr]
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()
return EpcPropertyDataMapper.from_elmhurst_site_notes(notes)
epc_401 = _epc_for("electric 3")
epc_402 = _epc_for("electric 5")
epc_408 = _epc_for("electric 7")
# Act
frac_401 = _secondary_fraction(
epc_401.sap_heating.main_heating_details[0],
epc_401.sap_heating.secondary_heating_type,
)
frac_402 = _secondary_fraction(
epc_402.sap_heating.main_heating_details[0],
epc_402.sap_heating.secondary_heating_type,
)
frac_408 = _secondary_fraction(
epc_408.sap_heating.main_heating_details[0],
epc_408.sap_heating.secondary_heating_type,
)
# Assert
assert abs(frac_401 - 0.15) < 1e-9, (
f"SAP code 401 (Old large-volume storage heaters, not fan-"
f"assisted): got {frac_401!r}, want 0.15 per SAP 10.2 Table 11 "
f"'Electric storage heaters (not integrated) - not fan-assisted'"
)
assert abs(frac_402 - 0.15) < 1e-9, (
f"SAP code 402 (Slimline storage heaters, not fan-assisted): "
f"got {frac_402!r}, want 0.15 per SAP 10.2 Table 11"
)
assert frac_408 == 0.0, (
f"SAP code 408 (Integrated storage+direct-acting heater): "
f"got {frac_408!r}, want 0 per SAP 10.2 §A.2.2 forced-"
f"secondary rule which lists codes '401 to 407, 409 and 421' "
f"(408 excluded — integrated systems include their own direct-"
f"acting element). No secondary lodged on cert → frac = 0."
)
def test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693() -> None:
"""SAP 10.2 §4 line 7693 (PDF p.137):