From a2a4b6824ad2c2735efabeab3711a56e9cccc022 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 1 Jun 2026 20:56:52 +0000 Subject: [PATCH] Slice S0380.157: SAP 10.2 Table 2b note b) WHC=903 electric-immersion guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 2b note b) (PDF p.159) — verbatim: Multiply Temperature Factor by 0.9 if there is separate time control of domestic hot water (boiler systems, warm air systems and heat pump systems). The parenthetical list restricts the rule to systems where the heat generator (boiler / warm-air / HP) is the device heating the cylinder. Electric immersion is NOT in that list because the immersion isn't a heat-generator system feeding DHW — it sits inside the cylinder. The ×0.9 multiplier reflects shorter cylinder-heating periods when a boiler / HP / warm-air operates on a separate timer for DHW vs SH; if the heat generator doesn't feed the cylinder at all (because the immersion does), there's no such timing effect. Pre-slice `_separately_timed_dhw` returned True for any Cat 4 HP main BEFORE consulting WHC (line 3872 `if main.main_heating_category == 4: return True`). For electric 2 (sap_main_heating_code=524 Cat 5 warm-air ASHP, main_heating_category=4 per Elmhurst mapper, WHC=903 electric immersion + cylinder + cylinder thermostat lodged), the cat-4 branch fired before the existing `_is_electric_water` check could route the cert to False. The cascade applied ×0.9 to the Temperature Factor (53), pulling (55) from 1.2294 → 1.1064 → cascade annual (56) = 403.87 vs worksheet (56) annual = 448.73. Same WHC=903 principle as the prior slice S0380.156 (Table 3 zero- loss list for electric immersion): when HW is independent of the main heating, main-heating-specific DHW rules don't apply — even when the main happens to be a HP / boiler / warm-air system. Fix: new top-of-function `if epc.sap_heating.water_heating_code == _WHC_ELECTRIC_IMMERSION: return False` guard in `_separately_timed_dhw`. Reuses the constant introduced in S0380.156. Closures electric 2: Cylinder (56) storage loss annual 403.87 → 448.73 (matches worksheet 1.2294 × 365 = 448.73 EXACT within rounding) HW kWh demand 2339.24 → 2384.12 (matches worksheet (62)/(64) = 2384.116 EXACT) ΔSAP +0.8118 → +0.7002 Δcost −£18.71 → −£16.14 ΔCO2 −7.21 → −2.37 kg ΔPE −161.68 → −108.58 kWh The remaining +0.70 SAP residual is a separate upstream gap (likely warm-air-HP SH cascade or Table 4a SH efficiency for code 524) — follow-up slice. No regressions on the other 24 cohort variants. Cohort-1 ASHP certs (Cat 4 HP + WHC=901 = HW from HP + cylinder) keep ×0.9 as before because their WHC=901 doesn't trigger the new guard. Extended handover suite: 901 pass / 0 fail (was 900 — +1 from the new AAA test). Pyright net-zero (43 → 43). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 21 +++- .../sap10_calculator/rdsap/cert_to_inputs.py | 15 +++ .../rdsap/tests/test_cert_to_inputs.py | 112 ++++++++++++++++++ 3 files changed, 147 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 01bd07f0..231577ed 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -236,10 +236,29 @@ class _CorpusExpectation: # 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. +# +# Slice S0380.157 added the companion SAP 10.2 Table 2b note b) +# WHC=903 guard at the top of `_separately_timed_dhw`. Pre-slice the +# Cat 4 HP branch (line 3872 `if main.main_heating_category == 4: +# return True`) returned True before consulting WHC, so for electric +# 2 (Cat 4 HP + WHC=903 immersion + cylinder) the cascade applied +# the Table 2b note b ×0.9 Temperature Factor multiplier to a +# cylinder fed by an electric immersion (not by the HP). Per the +# spec's verbatim system-type list "boiler systems, warm air systems +# and heat pump systems", electric immersion is not in scope. +# Worksheet electric 2 lodges (53) = 0.6000 / (55) = 1.2294 (= +# 0.0181 × 1.0294 × 0.6 × 110 — no ×0.9). Cascade cylinder storage +# loss annual 403.87 → 448.73 (matches worksheet). HW kWh demand +# 2339.24 → 2384.12 (EXACT match to worksheet (62)/(64)). SAP +# +0.8118 → +0.7002; cost −£18.71 → −£16.14; CO2 −7.21 → −2.37 kg; +# PE −161.68 → −108.58 kWh. Same WHC=903 principle as .156 (HW +# independent of main heating → main-heating-specific DHW rules do +# not apply). No regressions on other variants — only electric 2 has +# the (Cat 4 HP + WHC=903 + cylinder) 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.8118, expected_cost_resid_gbp=-18.7061, expected_co2_resid_kg=-7.2129, expected_pe_resid_kwh=-161.6840), + _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+0.7002, expected_cost_resid_gbp=-16.1353, expected_co2_resid_kg=-2.3729, expected_pe_resid_kwh=-108.5828), _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 afbea483..1cae5c86 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -3869,6 +3869,21 @@ def _separately_timed_dhw( """ if main is None: return False + # SAP 10.2 Table 2b note b) verbatim system-type list — "boiler + # systems, warm air systems and heat pump systems". Electric + # immersion is not in that list because the immersion isn't a + # heat-generator system feeding DHW: it sits inside the cylinder. + # The ×0.9 multiplier reflects shorter cylinder-heating periods + # when a boiler / HP / warm-air operates on a separate timer for + # DHW vs SH — when the heat generator doesn't feed the cylinder at + # all (because the immersion does), there's no such timing effect. + # The Elmhurst WHC=903 lodging signals "HW from a separate electric + # immersion heater" — the cylinder is independent of the main + # heating, regardless of what the main heating is (HP / boiler / + # warm-air). Same principle as the [[S0380.156]] Table 3 primary- + # loss WHC=903 guard. + if epc.sap_heating.water_heating_code == _WHC_ELECTRIC_IMMERSION: + return False if main.main_heating_category == 4: return True if _is_electric_water(epc.sap_heating.water_heating_fuel): 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 253b0e93..14ece0b7 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,118 @@ def test_sap_table_3_primary_loss_applies_to_solid_fuel_back_boiler_with_cylinde ) +def test_sap_table_2b_temperature_factor_no_0p9_for_whc_903_electric_immersion_with_heat_pump_main() -> None: + """SAP 10.2 Table 2b note b) (PDF p.159) — verbatim: + + Multiply Temperature Factor by 0.9 if there is separate time + control of domestic hot water (boiler systems, warm air systems + and heat pump systems). + + The verbatim parenthetical list restricts the rule to systems where + the heat generator (boiler / warm-air / HP) is the device heating + the cylinder. Electric immersion is NOT in that list — it's a + separate device on the cylinder, not a heat-generator system feeding + DHW. The principle: the ×0.9 reflects shorter cylinder-heating + periods when the boiler/HP operates on a separate timer for DHW vs + SH; if the heat generator doesn't feed the cylinder at all (because + an immersion does), the rule doesn't apply. + + For electric 2 (sap_main_heating_code=524 Cat 5 warm-air ASHP, + main_heating_category=4 per Elmhurst mapper, WHC=903 electric + immersion + cylinder + cylinder thermostat lodged), the worksheet + block 11a §4 lodges: + + Temperature factor from Table 2b 0.6000 (53) + Enter (49) or (54) in (55) 1.2294 (55) + + (55) = 0.0181 × 1.0294 × 0.6 × 110 = 1.2294 — no ×0.9. Pre-slice + `_separately_timed_dhw` returns True for any Cat 4 HP main BEFORE + consulting WHC, so the cascade applied ×0.9 → (55) cascade = 1.1064 + → cascade (56) annual = 403.87 vs worksheet (56) annual = 448.74 + (cascade UNDER by ~45 kWh storage loss). + + Same principle as the Slice S0380.156 Table 3 primary-loss WHC=903 + guard: when HW is from electric immersion, main-heating-specific + DHW rules don't apply, regardless of the main heating type. + """ + # Arrange — electric 2 corpus variant: same EPC shape as the .156 + # test, but here we drive the §4 cascade and read (56) storage loss + # instead of (59) primary loss. + 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 epc.sap_heating.water_heating_code == 903 + assert epc.sap_heating.cylinder_thermostat == "Y" + + # Act — drive §4 (45..65) via the cascade helper. Pre-slice the + # cat-4 branch in `_separately_timed_dhw` returns True before any + # WHC check, so the cascade's `cylinder_storage_loss_monthly_kwh` + # applies the ×0.9 Temperature Factor multiplier to a system whose + # cylinder is fed by an electric immersion (not by the HP). + 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 — (56) annual must match the worksheet's 448.7370 kWh/yr + # (= 1.2294 kWh/day × 365). Cascade pre-slice: 403.87 kWh/yr (= ×0.9 + # of the worksheet value, off by 44.87 kWh/yr). + expected_storage_annual = 1.2294 * 365.0 # = 448.731 kWh/yr + got_storage_annual = sum(wh_result.solar_storage_monthly_kwh) + assert abs(got_storage_annual - expected_storage_annual) <= 0.5, ( + f"electric 2 (Cat 4 HP + WHC=903 immersion + cylinder) " + f"§4 (56) storage loss annual = {got_storage_annual:.4f} kWh/yr; " + f"want ≈ {expected_storage_annual:.4f} kWh/yr (1.2294 × 365). " + f"Pre-slice `_separately_timed_dhw` returned True for the Cat-4 " + f"HP main before consulting WHC, so the cascade applied the " + f"Table 2b note b ×0.9 multiplier to a cylinder fed by an " + f"electric immersion (not by the HP). Per the spec's verbatim " + f"system-type list 'boiler systems, warm air systems and heat " + f"pump systems', electric immersion is not in scope." + ) + + 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: