From ec6661cbb6cd05de6e4f429db82c2d383f6d9411 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 21:27:46 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.144:=20Table=2011=20=E2=80=94=20p?= =?UTF-8?q?er-Table-4a-code=20secondary=20fraction=20dispatch=20for=20elec?= =?UTF-8?q?tric=20storage=20heaters=20+=20remove=20code=20408=20from=20?= =?UTF-8?q?=C2=A7A.2.2=20forced-secondary=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/test_heating_systems_corpus.py | 6 +- .../sap10_calculator/rdsap/cert_to_inputs.py | 56 +++++++- .../rdsap/tests/test_cert_to_inputs.py | 128 ++++++++++++++++++ 3 files changed, 186 insertions(+), 4 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 46c80a79..4a0cd8cd 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -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), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 654d3e45..797eb23e 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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) diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 1221e4f1..9fd0dc0b 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -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):