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: