diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 9df4d266..cfde8c83 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=+3.3965, expected_cost_resid_gbp=-75.6799, expected_co2_resid_kg=-397.0228, expected_pe_resid_kwh=-1601.7416), + _CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+2.8556, expected_cost_resid_gbp=-63.2154, expected_co2_resid_kg=-328.7435, expected_pe_resid_kwh=-1257.9712), # 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 908f73ab..654d3e45 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -3649,7 +3649,7 @@ def _primary_loss_applies( cylinder_present: bool, hp_record: Optional[HeatPumpRecord], ) -> bool: - """SAP 10.2 Table 3 (PDF p.159) zero-loss configurations — primary + """SAP 10.2 Table 3 (PDF p.160) zero-loss configurations — primary loss only fires when a cylinder is present AND the lodgement falls outside the zero list. The cohort path: heat-pump main heating with a separate (not integral) vessel per the PCDB Table 362 record. @@ -3657,11 +3657,19 @@ 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 two paths: + cohort coverage we model three 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 kWh/yr on certs with cylinders). + - PCDB Table 322 (gas/oil boiler) record with cylinder, when + main_heating_category is not lodged: primary loss applies + (cylinder presence + PCDB boiler = "boiler connected to hot- + water storage vessel" per Table 3 row 1 — the spec category + for this fixture is 1, but the Elmhurst mapper currently + leaves `main_heating_category=None`, so the cascade dispatch + falls through to this branch instead of the boiler-category + branch above). """ if not cylinder_present: return False @@ -3676,7 +3684,18 @@ def _primary_loss_applies( # Spec p.159: zero for "Heat pump from PCDB with hot water vessel # integral to package". Vessel mode 1 = integral. return hp_record.hw_vessel_mode != 1 - return main.main_heating_category in {1, 2} + if main.main_heating_category in {1, 2}: + return True + # Elmhurst-path fallback: when the cert lodges a PCDB Table 322 + # record (gas/oil boiler) but `main_heating_category` is None, the + # presence of the PCDB boiler record is sufficient evidence that + # the main is a boiler — Table 3 row 1 applies ("hot water is + # heated by a heat generator (e.g. boiler) connected to a hot + # water storage vessel via insulated or uninsulated pipes"). + if main.main_heating_index_number is not None: + if gas_oil_boiler_record(main.main_heating_index_number) is not None: + return True + return False # RdSAP 10 §10.11 Table 29 "Heating and hot water parameters" row @@ -3901,13 +3920,27 @@ def _water_heating_worksheet_and_gains( daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly, ) main = _first_main_heating(epc) - # SAP 10.2 §4 line 7702: non-combi main heating → (61)m = 0. Without - # this gate the cascade falls through to `combi_loss_monthly_kwh_table_ - # 3a_keep_hot_time_clock()` (600 kWh/yr) on every cert lacking a PCDB - # Table 105 boiler record — including all heat pump certs. - if combi_loss_override is None and not _table_3a_combi_loss_default_applies( + # SAP 10.2 §4 line 7702 (PDF p.137): "Combi loss for each month + # from Table 3a, 3b or 3c (enter '0' if not a combi boiler)". The + # SAP 10.2 Table 3 zero-loss list (PDF p.160) defines a combi boiler + # by its instantaneous-DHW operation: combis don't feed a cylinder + # because their heat exchanger heats DHW on demand. A lodged hot- + # water cylinder therefore means the heat generator is NOT a combi + # — even when the cert lodges a PCDB Table 105 record that would + # otherwise route through `pcdb_combi_loss_override` to a Table 3a/ + # 3b/3c row. Cert pcdb 1 (Potterton KOA PCDB 716 + 110 L cylinder) + # exposes this: pre-slice the cascade applied Table 3a row 1 + # 600 kWh/yr "keep-hot" loss to a PCDB regular oil boiler. + if epc.has_hot_water_cylinder: + combi_loss_override = zero_monthly + elif combi_loss_override is None and not _table_3a_combi_loss_default_applies( main ): + # SAP 10.2 §4 line 7702 fallback: non-combi main heating → (61)m + # = 0. Without this gate the cascade falls through to `combi_ + # loss_monthly_kwh_table_3a_keep_hot_time_clock()` (600 kWh/yr) + # on every cert lacking a PCDB Table 105 boiler record — + # including all heat pump certs. combi_loss_override = zero_monthly # SAP 10.2 §4 lines 7670-7693 + Tables 2/2a/2b — cylinder storage loss # (56)m. Spec p.135 instructs entering 0 in (47) for instantaneous / 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 e7b81a1e..1221e4f1 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -2844,6 +2844,129 @@ def test_sap_9_4_11_no_boiler_interlock_applies_minus_5_pcdb_space_heating_when_ # PCDB Eq D1 step. +def test_sap_4_lines_7700_7702_pcdb_regular_boiler_with_cylinder_zeroes_combi_loss_and_applies_primary_loss() -> None: + """SAP 10.2 §4 line 7702 (PDF p.137): + + Combi loss for each month from Table 3a, 3b or 3c + (enter "0" if not a combi boiler) + + 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 PCDB regular gas/oil boiler (Table 322) feeding a hot-water + cylinder is in neither of the (61)m / (59)m zero-loss lists: + - cylinder presence → not a combi → (61)m = 0 + - boiler + cylinder + indirect pipework → (59)m applies + + Pre-slice the cascade routed PCDB 716 (Potterton KOA, a regular + oil boiler) through `pcdb_combi_loss_override` and got 600 kWh/yr + "Table 3a row 1 keep-hot" (the spec's *combi* fall-through), + while `_primary_loss_applies` returned False because the Elmhurst + mapper leaves `main_heating_category=None` (cascade gates primary + on `main_heating_category in {1, 2}`). Both gaps masked one + another: −1177 kWh missing primary + +600 kWh excess combi netted + to ~−577 kWh on (62). + + This slice introduces the cylinder-presence gate for combi loss + (combi boilers are by definition instantaneous per Table 3 + zero-loss list — a lodged cylinder means the heat generator is + not a combi) and extends primary-loss eligibility to detect PCDB + Table 322 (gas/oil boiler) records when the cascade can't read + main_heating_category from the cert (Elmhurst path). + """ + # Arrange — pcdb 1 corpus variant: PCDB 716 Potterton KOA + 110 L + # cylinder + Cylinder Stat: No (worksheet shows (61)m all zero + # and (59)m monthly = 128.38 / 115.95 / 128.38 / ...; annual sum + # ≈ 1176.79 kWh/yr). + 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.tables.pcdb import gas_oil_boiler_record + + 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 — drive (45..65) directly via the §4 worksheet helper so the + # combi-loss and primary-loss assertions read the per-month tuples + # the cascade hands to `total_water_heating_demand_monthly_kwh`. + main = epc.sap_heating.main_heating_details[0] + pcdb_record = ( + gas_oil_boiler_record(main.main_heating_index_number) + if main.main_heating_index_number is not None + else None + ) + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=0.53, + is_instantaneous=False, + primary_age="G", + pcdb_record=pcdb_record, + ) + assert wh_result is not None + + # Assert 1 — (61)m all zero per §4 line 7702 ("enter '0' if not a + # combi boiler"). A lodged cylinder means the heat generator is + # not a combi (combi boilers are instantaneous per Table 3 + # zero-loss list). + assert sum(wh_result.combi_loss_monthly_kwh) == 0.0, ( + f"pcdb 1 combi loss annual: got {sum(wh_result.combi_loss_monthly_kwh)!r}, " + f"want 0.0 per SAP 10.2 §4 line 7702 (cylinder lodged → main is " + f"not a combi boiler → (61)m = 0)" + ) + + # Assert 2 — (59)m annual ≈ 1176.79 kWh per Table 3 + RdSAP §S10.11 + # Table 29 defaults (uninsulated pipework p=0 for age G; no cylinder + # thermostat → winter h=11, summer h=3). Worksheet pcdb 1 sum = + # Jan 128.38 + Feb 115.95 + ... + Dec 128.38 ≈ 1176.79. + expected_primary_annual = 1176.79 + got_primary_annual = sum(wh_result.primary_loss_monthly_kwh) + assert abs(got_primary_annual - expected_primary_annual) < 1.0, ( + f"pcdb 1 primary loss annual: got {got_primary_annual!r}, " + f"want {expected_primary_annual!r} per SAP 10.2 Table 3 " + f"(PCDB gas/oil boiler + cylinder + uninsulated primary pipework + " + f"no cylinder thermostat)" + ) + + def test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693() -> None: """SAP 10.2 §4 line 7693 (PDF p.137): diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 8a6e490a..9d5f6168 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -127,8 +127,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0390-2954-3640-2196-4175", actual_sap=60, expected_sap_resid=+7, - expected_pe_resid_kwh_per_m2=-26.3749, - expected_co2_resid_tonnes_per_yr=-2.5544, + expected_pe_resid_kwh_per_m2=-28.5027, + expected_co2_resid_tonnes_per_yr=-2.7481, notes=( "Detached, TFA 360, age F, Firebird oil combi PCDF 9005 " "(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + " @@ -141,11 +141,16 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "53 → 54 (resid -7 → -6). Slice S0380.131 flipped heating-oil " "tariff 7.64 → 5.44 (cert 0240 closed exactly), exposing this " "cert's previously-masked +13 SAP of cascade gaps: residual " - "swung -6 → +7. The oil-price bug was netting against an " - "opposite-direction gap (cert lodges age F + 360 m² detached " - "+ Firebird PCDF — likely fabric or hot-water cascade). PE / " - "CO2 residuals unchanged by the unit-price flip; remaining " - "SAP residual is a follow-up slice candidate." + "swung -6 → +7. Slice S0380.142 re-routed this cert via the " + "SAP 10.2 §4 line 7702 cylinder-presence gate ((61)m = 0 since " + "the combi feeds a cylinder → not a combi for DHW per Table 3) " + "+ Table 3 row 1 primary loss (PCDB Table 322 boiler + cylinder " + "→ primary loss applies). The combi-loss removal (-600 kWh/yr) " + "exceeded the primary-loss gain → cascade HW fuel dropped " + "~650 kWh; PE residual shifted -26.37 → -28.50, CO2 -2.55 → " + "-2.75. SAP integer unchanged because the cascade was already " + "well above SAP 60 (actual). Remaining residual is a fabric or " + "different §4 driver — follow-up slice candidate." ), ), _GoldenExpectation(