diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 5eeb0748..7b3ca948 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -241,8 +241,8 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # cost / CO2 / PE all route via the correct Table 32 fuel code. # Remaining residuals are likely heating-system efficiency or # control-type gaps — separate slices. - _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.7654, expected_cost_resid_gbp=-63.7195, expected_co2_resid_kg=+120.3433, expected_pe_resid_kwh=-1241.7357), - _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3086, expected_cost_resid_gbp=-30.1525, expected_co2_resid_kg=-327.2043, expected_pe_resid_kwh=-918.6312), + _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.0649, expected_cost_resid_gbp=-47.5795, expected_co2_resid_kg=+295.4889, expected_pe_resid_kwh=-754.0879), + _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+0.2968, expected_cost_resid_gbp=-6.8392, expected_co2_resid_kg=-74.2162, expected_pe_resid_kwh=-214.2510), _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+0.0850, expected_cost_resid_gbp=-1.9582, expected_co2_resid_kg=-9.3050, expected_pe_resid_kwh=-5.7762), _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 97434aa3..9a377fad 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4006,6 +4006,7 @@ def _primary_loss_applies( main: Optional[MainHeatingDetail], cylinder_present: bool, hp_record: Optional[HeatPumpRecord], + water_heating_code: Optional[int] = None, ) -> bool: """SAP 10.2 Table 3 (PDF p.160) zero-loss configurations — primary loss only fires when a cylinder is present AND the lodgement falls @@ -4074,6 +4075,25 @@ def _primary_loss_applies( and code not in _TABLE_4B_COMBI_OR_CPSU_CODES ): return True + # Table 4a solid-fuel + electric boilers (codes 151-161 / 191-196): + # the spec rule applies to ANY heat generator connected to a cylinder + # via primary pipework — not just Table 4b gas/oil boilers. The + # discriminator is the cert's `water_heating_code`: 901 / 902 / 914 + # (HW from main heating) means the back-boiler / electric boiler + # feeds the cylinder through a primary loop and the loss applies. + # WHC=903 (HW from a separate electric immersion) means the cylinder + # isn't on the boiler's primary loop and no loss applies. Cohort + # evidence (1431 corpus, age G, cylinder thermostat lodged): + # - solid fuel 2 (code 158, WHC=901): ws (59) ≈ 505 kWh/yr → apply + # - solid fuel 3 (code 160, WHC=901): ws (59) ≈ 643 kWh/yr → apply + # - solid fuel 5 (code 153, WHC=903): ws (59) = 0 → skip + # - solid fuel 4..11 (codes 633/636 non-boiler, WHC=903): skip + if ( + code is not None + and _is_wet_boiler_main(main) + and water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES + ): + return True return False @@ -4400,7 +4420,12 @@ def _primary_loss_override( hp_record: Optional[HeatPumpRecord] = None if main is not None and main.main_heating_index_number is not None: hp_record = heat_pump_record(main.main_heating_index_number) - if not _primary_loss_applies(main, cylinder_present, hp_record): + if not _primary_loss_applies( + main, + cylinder_present, + hp_record, + water_heating_code=epc.sap_heating.water_heating_code, + ): return None return primary_loss_monthly_kwh( pipework_insulation_fraction=_pipework_insulation_fraction_table_3( 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 3000726f..892fe543 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -3771,6 +3771,110 @@ def test_sap_table_3_primary_loss_applies_to_non_pcdb_table_4b_regular_boiler_wi ) +def test_sap_table_3_primary_loss_applies_to_solid_fuel_back_boiler_with_cylinder_and_whc_901() -> None: + """SAP 10.2 Table 3 (PDF p.160) — primary circuit loss applies when + hot water is heated by a heat generator (e.g. boiler) connected to + a hot water storage vessel via primary pipework. The spec doesn't + restrict the rule to Table 4b gas/oil boilers — Table 4a solid-fuel + boilers (codes 151-161: manual/auto-feed boilers, range cookers, + closed room heaters with back-boiler, open fires with back-boiler) + also feed cylinders via primary pipework when WHC=901 (HW from main + heating). + + The discriminator is the lodged `water_heating_code`: + - WHC=901/902/914 (HW from main heating) + wet boiler + cylinder + → primary loss applies (the back-boiler's primary loop incurs + the standing loss). + - WHC=903 (HW from a separate immersion or secondary system) → + no primary loss, even if the main is a wet boiler — the + cylinder isn't connected to the boiler's primary loop. + + Worksheet evidence across the 001431 corpus (all age G, same + cylinder + cylinder thermostat lodged): + - solid fuel 2 (code 158, WHC=901): (59) ≈ 505 kWh/yr (winter only) + - solid fuel 3 (code 160, WHC=901): (59) ≈ 643 kWh/yr (year-round) + - solid fuel 5 (code 153, WHC=903): (59) = 0 (separate immersion) + + Pre-slice `_primary_loss_applies` only covered Table 4b codes + 101-141 (gas/oil) — Table 4a solid-fuel boiler codes 151-161 fell + through and primary loss silently went to zero, leaving the §5 (72) + water-heating internal gain ~74 W lower than the worksheet for + every WHC=901 solid-fuel back-boiler variant. Knock-on: SH demand + ~+330 kWh/yr (less internal gain → more SH needed) → ~+2.3% SAP + over-shoot pattern documented in the Cluster B audit (electric 5 + has the same +2.3% pattern but a separate cause). + """ + # Arrange — solid fuel 2 corpus variant: Table 4a code 158 + # (anthracite closed room heater with back-boiler) + 110 L cylinder + + # cylinder thermostat Yes + WHC=901. No PCDB index 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_sf2 = ( + Path(__file__).parents[4] + / "sap worksheets/heating systems examples/solid fuel 2" + ) + summary_pdf = next(corpus_sf2.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 == 158 + assert epc.sap_heating.water_heating_code == 901 + + # Act — drive §4 (45..65) via the cascade helper. Cascade post-slice + # should now apply primary loss (Table 4a solid-fuel boiler + WHC=901 + # + cylinder → loss applies). + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=0.65, + is_instantaneous=False, + primary_age="G", + pcdb_record=None, + ) + assert wh_result is not None + + # Assert — primary loss must be non-zero (the worksheet's (59) sums + # to ~505 kWh/yr for SF2). The cascade uses (h=3, h=3) per + # `_separately_timed_dhw=True` so the cascade output is the + # year-round Table 3 formula = 31×14×(0.0245×3 + 0.0263) ≈ 43.3 kWh + # per 31-day month ≈ 510 kWh/yr — within ~5 kWh of the worksheet. + annual_primary = sum(wh_result.primary_loss_monthly_kwh) + assert annual_primary > 400.0, ( + f"solid fuel 2 (Table 4a code 158, WHC=901) primary loss " + f"annual = {annual_primary:.2f} kWh/yr; pre-slice the cascade " + f"returned 0 because `_primary_loss_applies` only covered " + f"Table 4b codes. Per SAP 10.2 Table 3 the spec rule applies " + f"to any boiler connected to a cylinder via primary pipework." + ) + + 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: