From c727b3cb5abafd5f8dbb1319d3cf4d8bd1eddcb6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 1 Jun 2026 19:28:25 +0000 Subject: [PATCH] Slice S0380.156: SAP 10.2 Table 3 WHC=903 electric-immersion zero-loss guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 3 (PDF p.160) verbatim: Primary loss is set to zero for the following: Electric immersion heater Combi boiler ... CPSU ... Boiler and thermal store within a single casing Separate boiler and thermal store connected by no more than 1.5 m of insulated pipework Direct-acting electric boiler Heat pump (...) with hot water vessel integral to package The Elmhurst WHC=903 lodging signals exactly the first row: "HW from a separate electric immersion heater" — the cylinder is heated by an immersion element inside the tank, no primary pipework between any heat generator and the cylinder. The rule is universal: regardless of what main heating exists for space heating, electric immersion means no primary circuit means no primary loss. Pre-slice `_primary_loss_applies` only consulted `water_heating_code` in the Table 4a wet-boiler branch (codes 151-161 / 191-196). The Cat 4 HP branch returned True unconditionally when no PCDB record was lodged; the Cat 1/2 boiler branch returned True unconditionally; the PCDB Table 322 + Table 4b non-PCDB branches likewise. For the electric 2 corpus variant (sap_main_heating_code=524 Cat 5 warm-air ASHP, main_heating_category=4 per Elmhurst mapper, no PCDB record, WHC=903 + cylinder), the Cat-4 branch falsely returned True and the cascade added ~510 kWh/yr primary loss to a system with no primary circuit at all. Per-line walk discipline applied: cascade `water_heating_from_cert` output dump showed `primary_loss_monthly_kwh_annual = 509.98` while worksheet (59)m = 0 every month → spec lookup found Table 3 verbatim "Electric immersion heater" zero-loss line. Adds `_WHC_ELECTRIC_IMMERSION: Final[int] = 903` constant + a top-of-function `if water_heating_code == _WHC_ELECTRIC_IMMERSION: return False` guard that fires before any of the system-type-keyed branches. Closures electric 2: HW kWh 2849.22 → 2339.24 (matches worksheet (62)/(64) = 2384.12 within the residual ~45 kWh storage-loss gap) ΔSAP −0.4584 → +0.8118 (cascade swung past the worksheet by +1.27 — the pre-slice 'near-correct' value was offsetting cascade bugs per [[feedback-software-no-special-handling]]; the +0.81 residual exposes a separate upstream gap to chase in a follow-up slice) Δcost +£10.56 → −£18.71 ΔCO2 +47.89 → −7.21 kg ΔPE +443.13 → −161.68 kWh No regressions on the other 24 cohort variants — only electric 2 has the (Cat 4 HP, no PCDB, WHC=903) combination in the corpus. Extended handover suite: 900 pass / 0 fail (was 899 — +1 from the new AAA test). Pyright net-zero (43 → 43). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 20 +++- .../sap10_calculator/rdsap/cert_to_inputs.py | 20 ++++ .../rdsap/tests/test_cert_to_inputs.py | 104 ++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index c9c5f87f..01bd07f0 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -218,10 +218,28 @@ class _CorpusExpectation: # per affected variant, SAP residuals shift ±0.15 across 16 variants; # the SH+Sec demand mismatch for electric 3/6/7 (Table 11 fraction # for codes 401/402) remains the open driver of those SAP residuals. +# +# Slice S0380.156 added the universal SAP 10.2 Table 3 (PDF p.160) +# zero-loss guard for WHC=903 (electric immersion HW) at the top of +# `_primary_loss_applies`. Pre-slice the Cat 4 HP branch returned +# True unconditionally when no PCDB record was lodged — so for +# electric 2 (sap_main_heating_code=524 Cat 5 warm-air ASHP, mapped +# to main_heating_category=4, WHC=903 + cylinder), the cascade +# falsely added ~510 kWh/yr primary loss to a system whose cylinder +# is heated directly by an immersion element with no primary +# pipework. Per Table 3 verbatim: "Primary loss is set to zero for +# the following: Electric immersion heater ...". Electric 2 SAP +# residual −0.4584 → +0.8118 (cascade swung past the worksheet — the +# pre-slice 'near-correct' value was masking an offsetting upstream +# gap that the spec-correct fix has exposed); cost +£10.56 → +# −£18.71; CO2 +47.89 → −7.21 kg; PE +443.13 → −161.68. No +# regressions on the other 24 variants — the new guard is gated on +# WHC=903 and only electric 2 has the (Cat 4 HP, no PCDB, WHC=903) +# combination in the corpus. _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=-0.0240, expected_cost_resid_gbp=+0.5536, expected_co2_resid_kg=+7.3267, expected_pe_resid_kwh=+36.3435), _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.4584, expected_cost_resid_gbp=+10.5613, expected_co2_resid_kg=+47.8864, expected_pe_resid_kwh=+443.1346), + _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+0.8118, expected_cost_resid_gbp=-18.7061, expected_co2_resid_kg=-7.2129, expected_pe_resid_kwh=-161.6840), _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 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), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index e70a4add..afbea483 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -533,6 +533,13 @@ _INTERNAL_GAINS_DEFAULT_OVERSHADING: Final[OvershadingCategory] = ( # because there's no cylinder and no primary circuit. _INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909}) +# Elmhurst WHC code for "HW from a separate electric immersion heater": +# cylinder lodged but heated by an immersion element inside the tank, no +# primary pipework between any heat generator and the cylinder. SAP 10.2 +# Table 3 (PDF p.160) puts "Electric immersion heater" first in its +# zero-loss list, so primary loss is zero whenever this code is lodged. +_WHC_ELECTRIC_IMMERSION: Final[int] = 903 + # SAP 10.2 Appendix M equation (M1): EPV = 0.8 × kWp × S × ZPV, summed # per array. The module efficiency constant (0.8), orientation-dependent @@ -4114,6 +4121,19 @@ def _primary_loss_applies( return False if main is None: return False + # SAP 10.2 Table 3 (PDF p.160) zero-loss list — verbatim: + # "Primary loss is set to zero for the following: Electric immersion + # heater ...". Elmhurst WHC=903 lodges "HW from a separate electric + # immersion heater": the cylinder is heated by an immersion element + # inside the tank, no primary pipework between any heat generator + # and the cylinder. Applies universally — regardless of which main + # heating system exists for space heating (Cat 4 HP, Cat 1/2 boiler, + # Table 4b non-PCDB, PCDB Table 322). Pre-slice the WHC check only + # gated the Table 4a wet-boiler branch; the other branches falsely + # returned True for HP / boiler mains with WHC=903, adding ~510 + # kWh/yr primary loss to a system with no primary circuit at all. + if water_heating_code == _WHC_ELECTRIC_IMMERSION: + return False if main.main_heating_category == 4: if hp_record is None: # No PCDB record → assume separate-vessel (conservative; the 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 87019c0c..253b0e93 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -4192,6 +4192,110 @@ def test_sap_table_3_primary_loss_applies_to_solid_fuel_back_boiler_with_cylinde ) +def test_sap_table_3_primary_loss_skipped_for_whc_903_electric_immersion_with_heat_pump_main() -> None: + """SAP 10.2 Table 3 (PDF p.160) zero-loss list — verbatim: + + Primary loss is set to zero for the following: + Electric immersion heater + Combi boiler ... + CPSU ... + ... + + The rule is universal: when HW is heated by an electric immersion + inside the cylinder (no primary pipework between any heat generator + and the cylinder), primary loss is zero — regardless of the main + heating system. The Elmhurst WHC=903 lodging signals exactly this + arrangement: "HW from a separate electric immersion heater". + + Pre-slice `_primary_loss_applies` only checked the WHC for the + Table 4a wet-boiler branch (codes 151-161 / 191-196). The Cat 4 + heat-pump branch returned True unconditionally when no PCDB record + was lodged, the Cat 1/2 boiler branch returned True unconditionally, + and the PCDB Table 322 + Table 4b non-PCDB branches likewise. For + the warm-air HP cert with code 524 + main_heating_category=4 + + WHC=903 + cylinder, the cat-4 branch falsely returned True and the + cascade added ~510 kWh/yr primary loss to a system with no primary + circuit at all. + + Worksheet evidence — electric 2 (sap_main_heating_code=524 Cat 5 + warm-air ASHP, main_heating_category=4 per mapper, WHC=903 electric + immersion, 110 L cylinder + cylinder thermostat lodged): the P960 + block-11a (59)m row reads 0.0000 every month, annual sum = 0. + """ + # Arrange — electric 2 corpus variant: Table 4a code 524 (warm-air + # ASHP) + main_heating_category=4 (Cat 4 HP per Elmhurst mapper) + + # WHC=903 (electric immersion HW) + 110 L cylinder + cylinder + # thermostat lodged. No PCDB heat-pump record lodged. + 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_2 = ( + Path(__file__).parents[4] + / "sap worksheets/heating systems examples/electric 2" + ) + summary_pdf = next(corpus_electric_2.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 epc.has_hot_water_cylinder is True + assert main.sap_main_heating_code == 524 + assert main.main_heating_category == 4 + assert main.main_heating_index_number is None + assert epc.sap_heating.water_heating_code == 903 + + # Act — drive §4 (45..65) via the cascade helper. Pre-slice the Cat + # 4 branch falsely returned True for `_primary_loss_applies` because + # WHC=903 was only consulted in the Table 4a wet-boiler branch. + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.0, + is_instantaneous=False, + primary_age="G", + pcdb_record=None, + ) + assert wh_result is not None + + # Assert — (59)m annual must be 0 because the cylinder is heated by + # an electric immersion element (no primary pipework between any + # heat generator and the cylinder), matching the worksheet's all-zero + # (59)m row. + annual_primary = sum(wh_result.primary_loss_monthly_kwh) + assert abs(annual_primary - 0.0) <= 1e-4, ( + f"electric 2 (Table 4a code 524 Cat 4 HP, WHC=903 immersion) " + f"primary loss annual = {annual_primary:.4f} kWh/yr; pre-slice " + f"the cascade Cat-4 branch returned True even though WHC=903 " + f"means electric immersion heats the cylinder directly. Per " + f"SAP 10.2 Table 3 zero-loss list ('Electric immersion heater') " + f"primary loss must be 0." + ) + + def test_sap_table_4f_circulation_pump_dispatches_per_central_heating_pump_age() -> None: """SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other auxiliary uses" — Heating system circulation pump rows: