From bd193e06fce4ab7d7f913907b00bf332d03bfb9d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 22:20:50 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.146:=20Table=203=20primary=20loss?= =?UTF-8?q?=20=E2=80=94=20Table=204b=20non-PCDB=20regular=20boilers=20with?= =?UTF-8?q?=20cylinder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss": "Primary circuit loss applies when hot water is heated by a heat generator (e.g. boiler) connected to a hot water storage vessel via insulated or uninsulated pipes (the primary pipework). Primary loss is set to zero for the following: Electric immersion heater Combi boiler ... CPSU ..." A Table 4b regular (non-combi, non-CPSU) gas or liquid-fuel boiler feeding a cylinder is in neither zero-loss list, so primary loss must apply. Pre-slice the Elmhurst-path fallback in `_primary_loss_applies` only covered PCDB Table 322 records (S0380.142) — when the cert lodges a Table 4b code (e.g. oil 1 sap_main_heating_code 127 "Condensing oil boiler") with no PCDB index and no `main_heating_category` lodgement, primary loss silently fell through to zero. This slice extends the Elmhurst-path fallback in `_primary_loss_applies` to fire when `sap_main_heating_code` is in the Table 4b code range (101-141) and NOT in the combi/CPSU sub-row exclusion set per Table 3: Combi codes: 103, 104, 107, 108, 112, 113, 118, 128, 129, 130 CPSU codes: 120, 121, 122, 123 Oil 1 worksheet (59)m daily rate = 1.3972 kWh/day uniform = 14 × [0.0245 × 3 + 0.0263] (uninsulated pipework, has cylinder thermostat + separately timed DHW → h=3 winter & summer per Table 3 split). Annual sum = 365 × 1.3972 ≈ 510 kWh/yr — matches the worksheet's (59) annual. Cascade impact on heating-systems corpus: - oil 1 SAP residual +2.66 → +1.76 (Δ -0.90) cost -£61.24 → -£40.60 (Δ +£20.64) CO2 -242.27 → -129.22 (Δ +113.05 kg/yr) PE -1050.49 → -590.02 (Δ +460.47 kWh/yr) Only the oil 1 variant moves — every other cascade-OK variant either already routes primary loss via the PCDB Table 322 branch (oil pcdb 1/ 2/3, pcdb 1) or via the boiler-category {1,2} branch. The other oil codes 124/125/126/131/132 + range-cooker codes 133-141 are gated for free by the same dispatch when their certs surface in future cohorts. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 2 +- .../sap10_calculator/rdsap/cert_to_inputs.py | 45 +++++++- .../rdsap/tests/test_cert_to_inputs.py | 104 ++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index a0313cb5..eca1793d 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -229,7 +229,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _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), - _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+2.6578, expected_cost_resid_gbp=-61.2402, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=-1050.4919), + _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+1.7621, expected_cost_resid_gbp=-40.6035, expected_co2_resid_kg=-129.2211, expected_pe_resid_kwh=-590.0236), _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), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 714be843..0fec19d7 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -3767,6 +3767,28 @@ def _heat_pump_extended_heating_days_per_month( return None +# SAP 10.2 Table 4b (PDF p.168) sub-rows that are explicitly combi or +# CPSU boilers — i.e. on the Table 3 zero-loss list ("Combi boiler ... +# CPSU ..."). Every other Table 4b code (101-141) is a regular or +# back-boiler / range-cooker boiler that incurs primary circuit loss +# when feeding a hot-water cylinder. +# +# Combi codes: +# 103, 104 — combi gas 1998+ (non-condensing / condensing) +# 107, 108 — combi gas 1998+ permanent pilot +# 112, 113 — combi gas pre-1998 fan-assisted flue +# 118 — combi gas pre-1998 balanced/open flue +# 128, 129, 130 — combi oil (pre-1998 / 1998+ / condensing) +# CPSU codes: +# 120, 121, 122, 123 — CPSU gas (auto/permanent × non/condensing) +_TABLE_4B_COMBI_OR_CPSU_CODES: Final[frozenset[int]] = frozenset({ + 103, 104, 107, 108, 112, 113, 118, + 120, 121, 122, 123, + 128, 129, 130, +}) +_TABLE_4B_CODE_RANGE: Final[range] = range(101, 142) + + def _primary_loss_applies( main: Optional[MainHeatingDetail], cylinder_present: bool, @@ -3780,7 +3802,7 @@ def _primary_loss_applies( Combi boilers, CPSUs, thermal stores within 1.5 m insulated pipe, direct-acting electric boilers, electric immersion heaters, and HPs with `hw_vessel_mode = 1` (integral) all skip the loss. For - cohort coverage we model three paths: + cohort coverage we model four paths: - HP with PCDB record: gate on `hp_record.hw_vessel_mode != 1` - Boiler (cat 1, 2) with cylinder: primary loss applies (the cascade's pre-slice-102d behaviour was zero, masking ~516 @@ -3793,6 +3815,15 @@ def _primary_loss_applies( leaves `main_heating_category=None`, so the cascade dispatch falls through to this branch instead of the boiler-category branch above). + - Table 4b non-PCDB boiler (sap_main_heating_code 101-141) + with cylinder, when main_heating_category is not lodged: + primary loss applies UNLESS the code is on the Table 3 zero + list (combi sub-rows + CPSU sub-rows per + `_TABLE_4B_COMBI_OR_CPSU_CODES`). Mirror of the PCDB Table + 322 branch — Elmhurst's heating-systems corpus leaves + `main_heating_category=None` for Table 4b oil 1 (code 127 + "Condensing oil boiler" + 110 L cylinder), so the boiler- + category branch above misses it; this branch picks it up. """ if not cylinder_present: return False @@ -3818,6 +3849,18 @@ def _primary_loss_applies( if main.main_heating_index_number is not None: if gas_oil_boiler_record(main.main_heating_index_number) is not None: return True + # Elmhurst-path fallback for Table 4b non-PCDB boilers: a lodged + # `sap_main_heating_code` in the 101-141 gas/liquid-fuel-boiler + # range that is NOT a combi or CPSU sub-row is a regular / back- + # boiler / range-cooker boiler — primary loss applies per Table 3 + # row 1 (boiler + cylinder via primary pipework). + code = main.sap_main_heating_code + if ( + code is not None + and code in _TABLE_4B_CODE_RANGE + and code not in _TABLE_4B_COMBI_OR_CPSU_CODES + ): + return True return False 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 db60e6d9..dcb01a7f 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -3536,3 +3536,107 @@ def test_rdsap_10_table_32_prices_charge_mains_gas_hot_water_at_3p48_per_kwh() - f"{inputs.hot_water_fuel_cost_gbp_per_kwh!r}, expected 0.0348 per " f"RdSAP 10 Table 32 mains gas (§19.1 amendment, ADR-0010)" ) + + +def test_sap_table_3_primary_loss_applies_to_non_pcdb_table_4b_regular_boiler_with_cylinder() -> None: + """SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss": + + Primary circuit loss applies when hot water is heated by a + heat generator (e.g. boiler) connected to a hot water storage + vessel via insulated or uninsulated pipes (the primary + pipework). 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 + + A Table 4b regular (non-combi, non-CPSU) gas or liquid-fuel boiler + feeding a hot-water cylinder is in neither zero-loss list, so + primary loss must apply. Pre-slice the Elmhurst-path fallback in + `_primary_loss_applies` only covered PCDB Table 322 records — when + the cert lodges a Table 4b code (e.g. oil 1 sap_main_heating_code + = 127 "Condensing oil boiler") with no PCDB index and no + `main_heating_category` lodgement, primary loss silently fell back + to zero. + + Oil 1 worksheet (59)m daily rate = 1.3972 kWh/day uniform = + 14 × [0.0245 × 3 + 0.0263] (uninsulated pipework, has cylinder + thermostat + separately timed DHW → h=3 winter & summer per Table + 3 split). Annual sum = 365 × 1.3972 ≈ 510 kWh/yr. + """ + # Arrange — oil 1 corpus variant: Table 4b code 127 (condensing + # oil boiler) + 110 L cylinder + cylinder thermostat Yes. No + # PCDB index lodged; main_heating_category None per Elmhurst + # mapper. Same property shape as pcdb 1 fixture (cert 001431). + import re + import subprocess + from pathlib import Path + + from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor + from datatypes.epc.domain.mapper import EpcPropertyDataMapper + + corpus_oil_1 = ( + Path(__file__).parents[4] + / "sap worksheets/heating systems examples/oil 1" + ) + summary_pdf = next(corpus_oil_1.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) + + # Pre-conditions reaffirm the diagnostic shape: cylinder lodged, + # Table 4b regular oil boiler code 127, no PCDB index, no category. + main = epc.sap_heating.main_heating_details[0] + assert epc.has_hot_water_cylinder is True + assert main.sap_main_heating_code == 127 + assert main.main_heating_index_number is None + assert main.main_heating_category is None + + # Act — drive §4 (45..65) via the cascade helper. Primary loss is + # NOT applied today (pre-slice) because the Table 4b code path is + # missing in `_primary_loss_applies`. + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=0.84, + is_instantaneous=False, + primary_age="G", + pcdb_record=None, + ) + assert wh_result is not None + + # Assert — (59)m annual ≈ 510 kWh per Table 3 + the daily-rate + # back-solve above. Worksheet oil 1 line (59) sum = 7 × 43.3132 + # + 4 × 41.9160 + 1 × 39.1216 ≈ 509.98 kWh/yr. + expected_primary_annual = 509.98 + got_primary_annual = sum(wh_result.primary_loss_monthly_kwh) + assert abs(got_primary_annual - expected_primary_annual) < 1.0, ( + f"oil 1 primary loss annual: got {got_primary_annual!r}, " + f"want {expected_primary_annual!r} per SAP 10.2 Table 3 " + f"(Table 4b regular oil boiler + cylinder + uninsulated " + f"primary pipework + cylinder thermostat + separately timed " + f"DHW)" + )