diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index a55ff29e..10d51a4b 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -2578,6 +2578,69 @@ def test_summary_000565_ext3_absent_gable_h_zero_lodgement_deducts_per_rdsap_10_ ) +def test_summary_000565_hp_plus_boiler_pump_gain_3w_per_sap_10_2_table_5a_note_a() -> None: + # Arrange — SAP 10.2 Table 5a (PDF p.177) verbatim: + # + # "Central heating pump in heated space, 2013 or later: 3 W" + # + # Note a): "Where there are two main heating systems serving + # different parts of the dwelling, assume each has its own + # circulation pump and therefore include two figures from this + # table. ... Set to zero in summer months. Not applicable for + # electric heat pumps from database. Where two main systems serve + # the same space a single pump is assumed." + # + # Pre-slice the cascade zeroed the central-heating pump GAIN + # (worksheet line 70) whenever `main_heating_details[0]. + # main_heating_category == 4` (heat pump). This is the right rule + # for ELECTRICITY (Table 4f: HP pump electricity is in the COP) but + # wrong for GAINS — Table 5a's "not applicable for electric heat + # pumps" only zeros the contribution from the HP itself. Any other + # non-HP main heating system in the cert still has its own pump, + # and that pump's gain still applies to internal gains. + # + # Cert 000565 lodges two main systems: + # [0] HP (category 4) pump_age "2013 or later" + # [1] Gas boiler (category 2) pump_age None + # + # Per spec, system [1]'s pump contributes 3 W (post-2013 default + # date from [0]'s lodgement). Worksheet line (70) confirms: + # + # "Pumps, fans 3.0000 3.0000 3.0000 3.0000 3.0000 0.0000 + # 0.0000 0.0000 0.0000 3.0000 3.0000 3.0000 (70)" + # + # → 3 W in 8 winter months, 0 in summer (per Table 5a Note a). + # + # Pre-slice cascade: 0 W every month, leaving 24 W·months of + # internal gains uncounted. The missing gains → ~10 kWh/yr extra + # space heating → +£0.70 cost, +0.90 kg CO2, −0.008 continuous + # SAP. The full §5 (70)..(73) line refs except (70) match the + # worksheet at 1e-3 already — this is the last cascade gap on + # cert 000565. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + from domain.sap10_calculator.rdsap.cert_to_inputs import internal_gains_section_from_cert + + # Act + ig = internal_gains_section_from_cert(epc) + + # Assert — (70)m winter values match the worksheet's 3 W; summer + # values stay 0 per Table 5a Note a) seasonal mask. + assert ig is not None + expected = (3.0, 3.0, 3.0, 3.0, 3.0, 0.0, 0.0, 0.0, 0.0, 3.0, 3.0, 3.0) + pumps_fans = ig.pumps_fans_monthly_w + assert all( + abs(pumps_fans[m] - expected[m]) <= 1e-4 + for m in range(12) + ), ( + f"cascade (70)m pumps_fans gain={tuple(round(x, 4) for x in pumps_fans)}; " + f"ws (70)m={expected}; deltas=" + f"{tuple(round(pumps_fans[m] - expected[m], 4) for m in range(12))} " + f"(expected 3.0 W in winter, 0.0 W summer — Table 5a row 1 + Note a)" + ) + + def test_summary_mapper_raises_on_unmapped_wall_type_code() -> None: # Arrange — strict-coverage gate per [[reference-unmapped-api- # code]] mirror: an Elmhurst wall_type lodgement that isn't in diff --git a/domain/sap10_calculator/worksheet/internal_gains.py b/domain/sap10_calculator/worksheet/internal_gains.py index 0ff6129f..aa735418 100644 --- a/domain/sap10_calculator/worksheet/internal_gains.py +++ b/domain/sap10_calculator/worksheet/internal_gains.py @@ -27,7 +27,7 @@ from dataclasses import dataclass from decimal import Decimal, ROUND_HALF_UP from enum import Enum from math import cos, exp, pi -from typing import Final, Optional +from typing import Final from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow @@ -645,20 +645,31 @@ def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory: return PumpDateCategory.UNKNOWN -# SAP 10.2 Table 4f categories that do NOT apply a central-heating pump -# gain in §5: the pump/fan electricity is already accounted for in the -# system COP / efficiency. Cert 0380's worksheet line (70) is 0.0 for -# every month, confirming category 4 (heat pumps). -_CATEGORIES_WITHOUT_CENTRAL_HEATING_PUMP: Final[frozenset[int]] = frozenset({4}) +# SAP 10.2 Table 5a Note a) (PDF p.177): "Not applicable for electric +# heat pumps from database." The pump GAIN (worksheet line 70) is +# omitted only for HP-category systems. Where the cert lodges a +# non-HP main system alongside an HP (e.g. cert 000565 with HP main 1 +# + gas boiler main 2), the non-HP system's pump still applies — so +# the gain is zero ONLY when EVERY lodged main system is an HP. +# +# (Distinct from Table 4f, which governs pump ELECTRICITY accounting: +# HP pump electricity is hidden in the COP regardless of whether +# secondary boilers are present.) +_HEAT_PUMP_MAIN_HEATING_CATEGORY: Final[int] = 4 -def _main_heating_category_from_cert(epc: EpcPropertyData) -> Optional[int]: - """First main-heating detail's category, or None when the cert - lodges no main heating.""" +def _all_main_systems_are_heat_pumps(epc: EpcPropertyData) -> bool: + """True iff every lodged main heating system is a heat pump + (category 4). When True, SAP 10.2 Table 5a Note a) zeros the + central-heating-pump GAIN. When False (mixed HP + boiler, or + boiler-only), the non-HP system's pump gain still applies.""" details = epc.sap_heating.main_heating_details if not details: - return None - return details[0].main_heating_category + return False + return all( + d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY + for d in details + ) def internal_gains_from_cert( @@ -714,13 +725,16 @@ def internal_gains_from_cert( daylight_factor=c_daylight, ) - # SAP 10.2 Table 4f: heat-pump packages (category 4) account for the - # circulation pump's electricity inside the system COP, so worksheet - # line (70) "Pumps, fans" is 0 for HP certs (cert 0380's worksheet - # confirms 0 every month). Bypass the pump-W computation rather than - # carrying it through `pumps_fans_monthly_w`'s seasonal mask. - main_category = _main_heating_category_from_cert(epc) - if main_category in _CATEGORIES_WITHOUT_CENTRAL_HEATING_PUMP: + # SAP 10.2 Table 5a Note a) (PDF p.177): the central-heating-pump + # GAIN is "Not applicable for electric heat pumps from database". + # Zero only when EVERY lodged main heating system is an HP — when + # any non-HP system (gas boiler, oil boiler, etc.) is present, its + # circulation pump still contributes 3/7/10 W per the pump's + # installation date (Table 5a row 1). Cert 000565 lodges HP main 1 + # + gas boiler main 2 → 3 W gain (worksheet line 70 confirms + # 3.0000 W in 8 winter months, 0 in summer). Cert 0380 (HP-only) + # → 0 W gain (worksheet line 70 confirms 0 every month). + if _all_main_systems_are_heat_pumps(epc): pump_w = 0.0 else: pump_w = central_heating_pump_w(