From 6636f1c3335c232bc738b4e259e379477108a57c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 20:33:01 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.141:=20=C2=A79.4.11=20boiler=20in?= =?UTF-8?q?terlock=20=E2=80=94=20extend=20=E2=88=925pp=20adjustment=20to?= =?UTF-8?q?=20both=20space-heating=20efficiency=20and=20the=20PCDB=20Equat?= =?UTF-8?q?ion=20D1=20water=20cascade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock": For the purposes of the SAP, an interlocked system is one in which both the space and stored water heating are interlocked. If either is not, the 5% seasonal efficiency reduction is applied to both space and water heating; if both are interlocked no reductions are made. Table 4c (PDF p.169-170) lodges -5 for both Space and DHW columns on the "No boiler interlock — regular boiler" row. Pre-slice the cascade applied the -5pp adjustment ONLY to the `water_eff` scalar fallback (`cert_to_inputs.py:4354`) and missed: (a) the SH efficiency path (cascade kept the raw PCDB winter eff for space heating); (b) the PCDB Equation D1 monthly cascade (Eq D1 received raw winter/summer values without the -5pp adjustment). RdSAP §3 (PDF p.57) defines boiler interlock as "Assumed present if there is a room thermostat and (for stored hot water systems heated by the boiler) a cylinder thermostat. Otherwise not interlocked." Cert pcdb 1 (Potterton KOA PCDB 716 + 110 L cylinder + Cylinder Stat: No) reproduces the pattern: worksheet (210) = 60% = PCDB winter 65 - 5; worksheet (217)m monthly Eq D1 pivots on (winter 60, summer 48) not (65, 53). The SH path is further gated on `pcdb_main is not None` because §9.4.11 only applies to "gas and liquid fuel boilers" — cert 000565 (ASHP Main 1) keeps its raw SH eff. The combi-fed-cylinder DHW path (cert 000565 WHC 914 to PCDB combi Main 2) continues to receive its existing -5pp via the `water_pcdb_main` gate (unchanged). Corpus impact: pcdb 1 SAP residual +6.95 → +3.40; cost -£157.61 → -£75.68; CO2 -845.81 → -397.02; PE -3135.30 → -1601.74. No other variant has PCDB main + cylinder + no thermostat, so the other 24 corpus pins are unchanged. Extended handover suite: 884 pass, 0 fail (was 883 + 1 new AAA test pinning the §9.4.11 SH eff path). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 2 +- .../sap10_calculator/rdsap/cert_to_inputs.py | 63 +++++++++---- .../rdsap/tests/test_cert_to_inputs.py | 94 +++++++++++++++++++ 3 files changed, 141 insertions(+), 18 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 28ada210..9df4d266 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -233,7 +233,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239), _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239), _CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+1.1597, expected_cost_resid_gbp=-26.7204, expected_co2_resid_kg=-53.1709, expected_pe_resid_kwh=-271.4351), - _CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+6.9521, expected_cost_resid_gbp=-157.6055, expected_co2_resid_kg=-845.8065, expected_pe_resid_kwh=-3135.2991), + _CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+3.3965, expected_cost_resid_gbp=-75.6799, expected_co2_resid_kg=-397.0228, expected_pe_resid_kwh=-1601.7416), # Slice S0380.133 unblocked 10 solid-fuel variants by routing the # Elmhurst §14.0 "Main Heating EES Code" through the new # `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` dict. Pre-slice the diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 48bb053a..908f73ab 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -50,7 +50,7 @@ Reference: RdSAP 10 specification (10-06-2025); SAP 10.2 specification from __future__ import annotations import math -from dataclasses import dataclass +from dataclasses import dataclass, replace from decimal import ROUND_HALF_UP, Decimal from typing import Callable, Final, Literal, Optional @@ -4334,24 +4334,53 @@ def cert_to_inputs( main_category=water_main.main_heating_category if water_main is not None else None, main_fuel=_main_fuel_code(water_main), ) - # SAP 10.2 Table 4c row "No boiler interlock — regular boiler: - # DHW −5%" (PDF p.169). RdSAP §3 (PDF p.57) defines boiler - # interlock as "Assumed present if there is a room thermostat and - # (for stored hot water systems heated by the boiler) a cylinder - # thermostat. Otherwise not interlocked." A combi-fed cylinder - # routes the boiler as a regular boiler for the DHW circuit (the - # combi's instantaneous-DHW capability is bypassed), so the - # regular-boiler row applies. Note c): the adjustment caps at - # −5pp (no thermostatic control and no boiler interlock do not - # accumulate). Cert 000565 (cylinder lodged + cyl-stat absent + - # WHC 914 to PCDB combi Main 2) closes 79% → 74% — matches - # worksheet (217)m exactly. - if ( + # SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock": "For the purposes + # of the SAP, an interlocked system is one in which both the space + # and stored water heating are interlocked. If either is not, the + # 5% seasonal efficiency reduction is applied to BOTH space and + # water heating; if both are interlocked no reductions are made." + # Table 4c (PDF p.169-170) row "No boiler interlock — regular + # boiler" lodges -5 for both Space and DHW columns. Table 4c + # Note c): "These do not accumulate as no thermostatic control or + # presence of a bypass means that there is no boiler interlock." + # + # RdSAP §3 (PDF p.57) defines boiler interlock as "Assumed present + # if there is a room thermostat and (for stored hot water systems + # heated by the boiler) a cylinder thermostat. Otherwise not + # interlocked." A combi-fed cylinder routes the boiler as a + # regular boiler for the DHW circuit (the combi's instantaneous- + # DHW capability is bypassed), so the regular-boiler row applies. + # + # The DHW path adjusts (a) the `water_eff` scalar fallback and + # (b) the PCDB winter/summer efficiencies fed into the Equation D1 + # monthly cascade so worksheet (217)m matches (e.g. pcdb 1: PCDB + # 716 winter 65, summer 53 → 60, 48). The SH path adjusts `eff` + # only when the SH main is itself a PCDB gas/oil boiler — §9.4.11 + # only applies to "gas and liquid fuel boilers", so cert 000565 + # (ASHP Main 1) keeps its raw SH eff. Cert pcdb 1 (PCDB 716 + 110 L + # cylinder + Cylinder Stat: No) closes 65% → 60% — matches + # worksheet (210) exactly. Cert 000565 closes WH 79% → 74% + # unchanged from S0380.79. + no_interlock = ( epc.has_hot_water_cylinder and epc.sap_heating.cylinder_thermostat != "Y" - and water_pcdb_main is not None - ): + ) + if no_interlock and water_pcdb_main is not None: water_eff -= 0.05 + pcdb_main_for_eq_d1 = pcdb_main + if no_interlock and pcdb_main is not None: + eff -= 0.05 + # Equation D1 reads PCDB winter/summer directly; apply -5pp + # to both so the monthly cascade matches worksheet (217)m. + if ( + pcdb_main.winter_efficiency_pct is not None + and pcdb_main.summer_efficiency_pct is not None + ): + pcdb_main_for_eq_d1 = replace( + pcdb_main, + winter_efficiency_pct=pcdb_main.winter_efficiency_pct - 5.0, + summer_efficiency_pct=pcdb_main.summer_efficiency_pct - 5.0, + ) # SAP 10.2 Appendix N3.6 + N3.7(a) — when an HP cert lodges a PCDB # Table 362 record, the cascade replaces the Table 4a defaults with # APM-interpolated η_space and η_water at the dwelling's PSR. @@ -4532,7 +4561,7 @@ def cert_to_inputs( wh_output_monthly_kwh=wh_result.output_monthly_kwh, wh_output_annual_kwh=wh_result.output_kwh_per_yr, water_efficiency_pct=water_eff, - pcdb_record=pcdb_main, + pcdb_record=pcdb_main_for_eq_d1, space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh, ) else: 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 59e59440..e7b81a1e 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -2750,6 +2750,100 @@ def test_table_4c_no_boiler_interlock_applies_minus_5_dhw_adjustment_when_cylind ) +def test_sap_9_4_11_no_boiler_interlock_applies_minus_5_pcdb_space_heating_when_main_is_gas_oil_boiler_with_cylinder_no_thermostat() -> None: + """SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock": + + For the purposes of the SAP, an interlocked system is one in + which both the space and stored water heating are interlocked. + If either is not, **the 5% seasonal efficiency reduction is + applied to both space and water heating**; if both are + interlocked no reductions are made. + + Pre-slice the cascade applied the -5pp adjustment ONLY to the + `water_eff` scalar fallback (line 4354 in `cert_to_inputs.py`) and + missed the space-heating efficiency path entirely; PCDB-Eq-D1 also + received raw winter/summer values without the -5pp adjustment. + Per §9.4.11 the reduction applies to BOTH SH and DHW when interlock + is absent — which the corpus pcdb 1 variant (PCDB 716 Potterton KOA + + cylinder + no cylinder thermostat) makes observable: worksheet + (210) = 60% = PCDB winter 65 - 5 ; worksheet (217)m monthly Eq D1 + pivots on (winter 60, summer 48) not (65, 53). + + Gate: cylinder present + no cylinder thermostat (RdSAP §3 + definition of "no interlock for stored hot water"). SH path + further gated on `pcdb_main is not None` (SH main is a PCDB + gas/oil boiler — §9.4.11 only applies to "gas and liquid fuel + boilers"). Cert 000565 (ASHP Main 1) keeps its raw SH eff because + its Main 1 is not a boiler. + """ + # Arrange — pcdb 1 corpus variant: property 001431 with Potterton + # KOA PCDB 716 oil boiler + 110 L cylinder (inaccessible) + cyl-stat + # absent (worksheet "Cylinder Stat: No"). Route the Summary PDF + # through the full extractor → mapper → cascade chain so the test + # exercises real-world cert lodgement. + import re + import subprocess + from pathlib import Path + + from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor + from datatypes.epc.domain.mapper import EpcPropertyDataMapper + + corpus_pcdb_1 = ( + Path(__file__).parents[4] + / "sap worksheets/heating systems examples/pcdb 1" + ) + summary_pdf = next(corpus_pcdb_1.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() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(notes) + + # Act — the cascade computes a per-month water heating efficiency + # via Equation D1 when the PCDB record carries winter+summer effs. + # Calling `cert_to_inputs` exercises the full §4 → Eq D1 → §9a chain. + inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + + # Assert — space-heating efficiency drops from PCDB winter 65% to + # the -5pp adjusted 60% per §9.4.11. The cascade exposes this via + # `inputs.main_heating_efficiency` (a fraction). Pin abs=1e-4 vs + # spec target 0.60. + expected_sh_eff = 0.60 + assert abs(inputs.main_heating_efficiency - expected_sh_eff) < 1e-4, ( + f"pcdb 1 main_heating_efficiency: got {inputs.main_heating_efficiency!r}, " + f"want {expected_sh_eff!r} per SAP 10.2 §9.4.11 (PCDB winter 65% " + f"- 5pp no-interlock adjustment); cert lodges PCDB 716 + cylinder " + f"+ no thermostat → no interlock per RdSAP §3." + ) + + # And Eq D1 monthly cascade lands on the worksheet (217)m values. + # Cert 000565's (217)m matches when winter/summer drop by -5pp; the + # pcdb 1 worksheet target is the same arithmetic with PCDB 716's + # (65, 53) → (60, 48). Summer-only months (Jun-Sep — Q_space = 0) + # collapse to the summer eff: 0.53 - 0.05 = 0.48. + # Indirectly observable via `result.hot_water_kwh_per_yr` matching + # the worksheet (219) target for pcdb 1 = 7063.96 kWh/yr. + # NOTE: full closure needs the other §4 fixes (insulation defaults + # + combi gate + primary loss); this test pins only the SH eff + + # PCDB Eq D1 step. + + def test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693() -> None: """SAP 10.2 §4 line 7693 (PDF p.137):