diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 231577ed..71b5ca0e 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -255,10 +255,33 @@ class _CorpusExpectation: # 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. +# +# Slice S0380.158 wired the SAP 10.2 Table 4f (PDF p.174) row "Warm +# air heating system fans" = SFP × 0.4 × V (footnote e default SFP = +# 1.5 W/(l/s) when no PCDB warm-air-unit record). Pre-slice the +# cascade's `_table_4f_additive_components` docstring listed warm-air +# fans as "Not yet wired" — every Cat 5 / Cat 9 warm-air main +# resolved `pumps_fans_kwh_per_yr` to 0 even though the spec rule has +# been in place since SAP 2012. For electric 2 (code 524 Cat 5 +# air-source warm-air HP, no MV, V = 227.25 m³): 1.5 × 0.4 × 227.25 = +# 136.35 kWh — matches worksheet block 11a (249) "Pumps, fans and +# electric keep-hot" line exactly. Footnote-e balanced-MV omission +# applies when `mechanical_ventilation_kind` is MVHR or MV (electric +# 2 lodges no MV → fans included). New `_TABLE_4A_WARM_AIR_SAP_CODES` +# frozenset (22 codes: 501-515, 520-521, 523-527). Cascade closures +# electric 2: SAP +0.7002 → −0.1087, cost −£16.14 → +£2.50, CO2 +# −2.37 → +16.54 kg, PE −108.58 → +97.69 kWh. The cascade now +# overshoots cost / CO2 / PE because the +136 kWh of warm-air fan +# electricity is being charged at the full 18-hour high rate; SAP +# under-shoots by 0.11 because the cost residual is still slightly +# off. Remaining gap likely a small upstream SH-demand divergence +# (cascade SH demand +57 kWh vs worksheet — Cat 5 specific). No +# regressions on the other 24 variants — gate keyed on the new +# warm-air-code frozenset and only electric 2 has a code in that set. _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.7002, expected_cost_resid_gbp=-16.1353, expected_co2_resid_kg=-2.3729, expected_pe_resid_kwh=-108.5828), + _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.1087, expected_cost_resid_gbp=+2.5037, expected_co2_resid_kg=+16.5405, expected_pe_resid_kwh=+97.6875), _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 1cae5c86..bd3e0202 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -307,6 +307,37 @@ _TABLE_4F_LIQUID_FUEL_BOILER_AUX_KWH: Final[float] = 100.0 _TABLE_4F_SOLAR_HW_PUMP_DEFAULT_H1_M2: Final[float] = 3.0 +# SAP 10.2 Table 4a (PDF p.165-166) warm-air heating SAP codes. Two +# spec categories distribute heat as ducted air: +# - Category 5: heat pumps with warm-air distribution (codes 521, +# 523, 524 electric SH; 525, 526, 527 gas-fired). +# - Category 9: warm-air systems NOT heat pump (501-511, 520 gas- +# fired; 512-514 liquid-fired; 515 Electricaire electric). +# These systems share the Table 4f "Warm air heating system fans" row +# (the fan electricity is air-side, distinct from the wet-system +# circulation pump and the gas-boiler flue fan). +_TABLE_4A_WARM_AIR_SAP_CODES: Final[frozenset[int]] = frozenset({ + 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 520, + 512, 513, 514, 515, + 521, 523, 524, 525, 526, 527, +}) + +# SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans" = +# SFP × 0.4 × V (kWh/yr). Footnote e): +# "SFP is the specific fan power from the database record for the +# warm air unit if applicable; otherwise 1.5 W/(l/s). These values +# of SFP include the in-use factor." +_TABLE_4F_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S: Final[float] = 1.5 +_TABLE_4F_WARM_AIR_FAN_VOLUME_FACTOR: Final[float] = 0.4 + +# Footnote e) — the warm-air fan electricity is omitted when the +# dwelling also has balanced whole-house mechanical ventilation, +# because the MV system's fans displace the warm-air circulation +# fans. Balanced kinds = MVHR + MV. Extract-only / PIV-from-outside +# / natural ventilation kinds do NOT trigger the omission. +_BALANCED_MV_KIND_NAMES: Final[frozenset[str]] = frozenset({"MVHR", "MV"}) + + def _table_4f_circulation_pump_kwh(main: Optional[MainHeatingDetail]) -> float: """SAP 10.2 Table 4f (PDF p.174) — Main 1 circulation pump kWh based on `central_heating_pump_age` lodging. @@ -357,6 +388,52 @@ def _table_4f_main_1_gas_boiler_flue_fan_kwh( return 0.0 +def _has_balanced_mechanical_ventilation(epc: EpcPropertyData) -> bool: + """SAP 10.2 Table 4f footnote e) balanced-MV gate: True when the + cert lodges either MVHR (balanced with heat recovery) or MV + (balanced without heat recovery). False for MEV / PIV-from-outside + / natural — footnote e) explicitly INCLUDES the warm-air fan kWh + for "a warm air system and MEV or PIV from outside". + """ + sv = epc.sap_ventilation + if sv is None: + return False + name = sv.mechanical_ventilation_kind + return name in _BALANCED_MV_KIND_NAMES + + +def _table_4f_warm_air_heating_fans_kwh( + main: Optional[MainHeatingDetail], + dwelling_volume_m3: float, + has_balanced_mv: bool, +) -> float: + """SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system + fans" = SFP × 0.4 × V per footnote e). Default SFP = 1.5 W/(l/s) + when the cert has no PCDB warm-air-unit record. Suppressed when + the dwelling lodges balanced whole-house MV per the footnote-e + omission rule. + + Fires for the Table 4a Cat 5 (heat pumps with warm-air + distribution) and Cat 9 (warm air NOT heat pump) sub-rows — see + `_TABLE_4A_WARM_AIR_SAP_CODES`. Cohort entry point is the + heating-systems corpus 001431 electric 2 variant (code 524 + air-source warm-air HP, no MV, V = 227.25 m³ → 1.5 × 0.4 × 227.25 + = 136.35 kWh, matching the P960 worksheet (249) line exactly). + """ + if main is None: + return 0.0 + code = main.sap_main_heating_code + if code is None or code not in _TABLE_4A_WARM_AIR_SAP_CODES: + return 0.0 + if has_balanced_mv: + return 0.0 + return ( + _TABLE_4F_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S + * _TABLE_4F_WARM_AIR_FAN_VOLUME_FACTOR + * dwelling_volume_m3 + ) + + def _table_4f_additive_components(epc: EpcPropertyData) -> float: """Sum the SAP 10.2 Table 4f line items that the base `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` lookup doesn't already cover — @@ -378,10 +455,18 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float: schema doesn't carry the lodged value. TODO: parse the Elmhurst §16 aperture area into the schema. + Warm-air heating fans (Table 4f row "Warm air heating system fans" + = SFP × 0.4 × V) live in a sibling helper + `_table_4f_warm_air_heating_fans_kwh` because they require the + dwelling volume from `dimensions_from_cert(epc)`, not just the + EPC payload — see the orchestrator pumps_fans summation. + Not yet wired: - (230f) Combi keep-hot — 600 / 900 kWh per Table 4f when the cert lodges keep-hot on the gas combi. - - (230b) Warm-air heating fans + (230c) for warm-air pump. + - (230c) Warm-air system pump (Cat 9 sub-row for systems with a + separate warm-air circulation pump — cohort doesn't exercise + it yet). - (230h) WWHRS pump. """ total = 0.0 @@ -5033,12 +5118,20 @@ def cert_to_inputs( main_fuel = _main_fuel_code(main) # SAP 10.2 Table 4f (p.174) — Main 1 circulation pump (per # `central_heating_pump_age`) + Main 1 gas-boiler flue fan (45 - # kWh when fan_flue_present + gas fuel). HP mains (cat 4) return - # 0 for both. Additive components add MEV, Main 2 flue fan, - # solar HW pump, and Main 1/2 liquid fuel boiler aux (100 kWh). + # kWh when fan_flue_present + gas fuel) + Main 1 warm-air heating + # fans (SFP × 0.4 × V for Cat 5 / Cat 9 warm-air mains, suppressed + # under balanced MV per footnote e). HP wet mains (cat 4) return 0 + # for the circulation-pump branch. Additive components add MEV, + # Main 2 flue fan, solar HW pump, and Main 1/2 liquid fuel boiler + # aux (100 kWh). pumps_fans_kwh = ( _table_4f_circulation_pump_kwh(main) + _table_4f_main_1_gas_boiler_flue_fan_kwh(main) + + _table_4f_warm_air_heating_fans_kwh( + main=main, + dwelling_volume_m3=dim.volume_m3, + has_balanced_mv=_has_balanced_mechanical_ventilation(epc), + ) ) pumps_fans_kwh += _table_4f_additive_components(epc) # Track the MEV/MVHR-fan portion separately so the cost cascade can 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 14ece0b7..656044d3 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -4408,6 +4408,105 @@ def test_sap_table_3_primary_loss_skipped_for_whc_903_electric_immersion_with_he ) +def test_sap_table_4f_warm_air_heating_system_fans_kwh_for_cat5_heat_pump() -> None: + """SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans" + + footnote e) — verbatim: + + Warm air heating system fans e) SFP × 0.4 × V + + e) SFP is the specific fan power from the database record for + the warm air unit if applicable; otherwise 1.5 W/(l/s). + These values of SFP include the in-use factor. + If the heating system is a warm air unit and there is + balanced whole house mechanical ventilation, the electricity + for warm air circulation should not be included in addition + to the electricity for mechanical ventilation. However it is + included for a warm air system and MEV or PIV from outside. + V is the volume of the dwelling in m³. + + Per Table 4a, warm-air systems are Category 5 (heat pumps with + warm air distribution, codes 521/523/524/525/526/527) and Category + 9 (warm air systems NOT heat pump, codes 501-515 + 520). + + For electric 2 (sap_main_heating_code=524 Cat 5 air-source warm-air + HP, no PCDB record, no MVHR / MV, 110 L cylinder, V=227.25 m³): + worksheet block 11a (249) "Pumps, fans and electric keep-hot" + lodges 136.35 kWh × 13.67 p/kWh = £18.64 → exactly 1.5 × 0.4 × + 227.25 = 136.35 kWh per the default SFP formula. + + Pre-slice the cascade's `_table_4f_additive_components` docstring + explicitly listed "(230b) Warm-air heating fans + (230c) for + warm-air pump" as "Not yet wired" — the cert's pumps_fans_kwh_per_yr + resolved to 0 for every warm-air corpus variant. + """ + # Arrange — electric 2 corpus variant: code 524 + no MVHR + 90 m² + # × 2.525 m = 227.25 m³ dwelling volume per worksheet line (5). + 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 main.sap_main_heating_code == 524 + sv = epc.sap_ventilation + assert sv is not None + # MV kind defaults to NATURAL when the cert lodges no MV system — + # confirms the worksheet (249) value comes from the warm-air fan + # line alone (not blocked by the footnote-e balanced-MV gate). + assert sv.mechanical_ventilation_kind in (None, "NATURAL") + + # Act — drive the full cert→CalculatorInputs cascade. Pre-slice + # `pumps_fans_kwh_per_yr` resolves to 0 because the Cat 5 warm-air + # HP main has no Table 4f circulation pump (HP exemption) and no + # gas-flue fan, and `_table_4f_additive_components` doesn't yet + # cover the warm-air fan row. + inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + + # Assert — pumps_fans_kwh_per_yr must equal the worksheet (249) + # value 136.35 kWh/yr = 1.5 × 0.4 × 227.25 per Table 4f. + expected_kwh = 1.5 * 0.4 * 227.25 # = 136.35 + assert abs(inputs.pumps_fans_kwh_per_yr - expected_kwh) <= 0.01, ( + f"electric 2 (Cat 5 warm-air ASHP code 524, no MVHR, V=227.25 " + f"m³) pumps_fans_kwh_per_yr = {inputs.pumps_fans_kwh_per_yr:.4f}; " + f"want {expected_kwh:.4f} per SAP 10.2 Table 4f 'Warm air " + f"heating system fans' = SFP × 0.4 × V with default SFP = 1.5 " + f"W/(l/s) per footnote e). Pre-slice the cascade's Table 4f " + f"additive-components helper listed warm-air fans as 'Not yet " + f"wired' so every warm-air corpus variant fell back to 0 kWh." + ) + + 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: