From 4d0e2ed6cfaebae033be1bdff7fbd490cebddb12 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 1 Jun 2026 23:59:29 +0000 Subject: [PATCH] Slice S0380.162: SAP 10.2 Appendix N3.1 default pump gain for electric HPs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix N3.1 (PDF p.105) "Circulation pump and fan": "For electric heat pumps: The electricity used by the water circulation pump or fan is included within the calculated annual space and hot water heating efficiency and is not included in worksheet (230c). **The default heat gain from Table 5a is included via worksheet (70).**" This rule applies the Table 5a row "Central heating pump in heated space" GAIN (3 / 10 / 7 W per pump-age bucket) to electric heat pumps even though the pump ELECTRICITY is hidden in the COP and excluded from (230c). The "Not applicable for electric heat pumps from database" clause in Table 5a footnote a) scopes only to the PCDB-Table-362 cascade case (Appendix N1.2.1: "For heat pumps held in the PCDB ... a single water circulation pump serving the heat emitters is sufficient" — pump kWh AND gain embedded in COP). S0380.160 over-stripped the gain by zeroing pump_w for every HP category-4 main, conflating the PCDB-Table-362 case with the Table-4a default cascade. This slice refines the HP gate in `_any_main_system_has_central_heating_pump`: - Cat 4 HP WITH `main_heating_index_number` lodged (PCDB Table 362) → continue (skip; pump in COP per N1.2.1); - Cat 4 HP with SAP code in `_TABLE_4A_WARM_AIR_SAP_CODES` (Cat 5 warm-air HPs distribute via ducted air, no water circulation pump; warm-air fan handled separately by Table 5a "Warm air heating system fans" row, S0380.161) → continue; - Otherwise (Cat 4 HP, Table 4a default cascade, water-emitter) → apply Table 5a default per Appendix N3.1. Per-line walk on ashp (SAP code 214 air-to-water HP, Cat 4, no PCDB, "Post 2013" pump age): worksheet (70)[Jan] = 3.0000 W cascade pre-slice = 0.0000 W delta = -3.000 W The -3 W winter gain shortfall over-stated cascade (84) Total gains by -3 W in heating months → cascade SH demand +12.27 kWh/yr (cascade 9302 vs worksheet 9290), pushing continuous SAP down 0.024 because the cost residual was driven by the +1.5 kWh × 12 month shortfall flowing through the £0.0741 low-rate cost. Closures: ashp: ΔSAP -0.0240 → +0.0000 EXACT, Δcost +£0.55 → +£0.00 EXACT gshp: ΔSAP -0.0178 → -0.0000 EXACT, Δcost +£0.41 → -£0.00 EXACT ΔPE +36 → +25.51 (and ΔCO2 +7.33 → +6.31) — residuals narrow to the Elmhurst-vs-spec HW PE annual-vs-monthly Table 12e/12d quirk only (same pattern as the 16-variant lighting-PE deferred cohort, scaled by HW kWh = 1138 vs 2384 → 25.51 vs 48.66). Cohort Σ |ΔSAP_c| 0.07 → 0.03; all 25 cascade-OK variants now SAP+cost EXACT. Cohort-1 (cert 0380 et al.) golden fixtures unaffected — those certs lodge `main_heating_index_number` (PCDB Table 362) → HP gate skips correctly → (70) = 0 preserved. Cert 000565 (HP main 1 + gas boiler main 2) unaffected — wet-boiler branch fires for main 2. Verbatim spec quote (SAP 10.2 Appendix N3.1, PDF p.105): "For electric heat pumps: The electricity used by the water circulation pump or fan is included within the calculated annual space and hot water heating efficiency and is not included in worksheet (230c). The default heat gain from Table 5a is included via worksheet (70)." Tests: 906 pass (+1), 0 fail. Pyright net-zero (35 → 35). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 20 +++++- .../worksheet/internal_gains.py | 24 ++++++- .../worksheet/tests/test_internal_gains.py | 67 +++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index e40666b2..36d8ff3c 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -334,8 +334,24 @@ class _CorpusExpectation: # Electric 2 SAP -0.1087 → -0.0000 EXACT; joins the lighting-PE # deferred cohort (CO2 +11.95 / PE +48.66). Cohort Σ|ΔSAP_c| # 0.18 → 0.07 in one slice. +# +# Slice S0380.162 closed ashp + gshp by restoring the SAP 10.2 +# Appendix N3.1 (PDF p.105) "default heat gain from Table 5a is +# included via worksheet (70)" rule for electric heat pumps that DON'T +# have a PCDB Table 362 record lodged. S0380.160 had over-stripped +# the gain (zeroed for all HPs); ashp/gshp use Table 4a Cat 4 default +# cascade and worksheet (70) = 3.0000 W in heating months. Refined +# `_any_main_system_has_central_heating_pump` HP gate: PCDB-lodged +# HPs (e.g. cert 0380 cohort with Table 362 record) keep 0 W (pump +# embedded in COP per N1.2.1); Cat 5 warm-air HPs keep 0 W (no water +# circulation pump; warm-air fan handled by .161); Cat 4 HPs without +# PCDB and not warm-air → apply pump gain per N3.1. ashp/gshp ΔSAP +# -0.024/-0.018 → -0.0000 EXACT; ΔPE +36/+34 → +25.51 (residual +# narrowed to the Elmhurst-vs-spec HW PE annual-vs-monthly quirk +# only). Cohort Σ|ΔSAP_c| 0.07 → 0.03 in one slice. All 25 cascade-OK +# variants now SAP+cost EXACT. _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='ashp', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+6.3106, expected_pe_resid_kwh=+25.5090), _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.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), _CorpusExpectation(variant='electric 3', 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), @@ -344,7 +360,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6605), _CorpusExpectation(variant='electric 8', 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='electric 9', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6605), - _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=-0.0178, expected_cost_resid_gbp=+0.4092, expected_co2_resid_kg=+7.0616, expected_pe_resid_kwh=+33.5171), + _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+6.3106, expected_pe_resid_kwh=+25.5090), _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), diff --git a/domain/sap10_calculator/worksheet/internal_gains.py b/domain/sap10_calculator/worksheet/internal_gains.py index c2a6d65a..8e6784e1 100644 --- a/domain/sap10_calculator/worksheet/internal_gains.py +++ b/domain/sap10_calculator/worksheet/internal_gains.py @@ -714,13 +714,35 @@ def _any_main_system_has_central_heating_pump(epc: EpcPropertyData) -> bool: Mirrors `cert_to_inputs._is_wet_boiler_main` — see docstring there for the kWh-side parallel in Table 4f. + + Electric heat pump exception per SAP 10.2 Appendix N3.1 (PDF p.105): + "For electric heat pumps: The electricity used by the water + circulation pump or fan is included within the calculated annual + space and hot water heating efficiency and is not included in + worksheet (230c). **The default heat gain from Table 5a is included + via worksheet (70).**" → Cat 4 HPs WITHOUT a PCDB record (Table 4a + default cascade) get the Table 5a default pump gain. Cat 4 HPs + WITH a PCDB record (Table 362) embed the pump gain in the COP → + no separate Table 5a gain. Cat 5 warm-air HPs (codes 521/523-527) + distribute via fans, not a water pump — handled by the warm-air + fan row of Table 5a (see `_any_main_system_has_warm_air_distribution`). """ details = epc.sap_heating.main_heating_details if not details: return False for d in details: if d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY: - continue + # PCDB Table 362 record → pump electricity AND gain are + # embedded in COP (Appendix N1.2.1); no separate gain row. + if d.main_heating_index_number is not None: + continue + # Cat 5 warm-air HP (codes 521/523-527) → no water pump. + code = d.sap_main_heating_code + if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES: + continue + # Cat 4 HP, Table 4a default cascade → apply Table 5a + # pump gain per Appendix N3.1. + return True code = d.sap_main_heating_code if code is not None and any( code in r for r in _WET_BOILER_SAP_CODE_RANGES diff --git a/domain/sap10_calculator/worksheet/tests/test_internal_gains.py b/domain/sap10_calculator/worksheet/tests/test_internal_gains.py index bd6695d7..c63eea1d 100644 --- a/domain/sap10_calculator/worksheet/tests/test_internal_gains.py +++ b/domain/sap10_calculator/worksheet/tests/test_internal_gains.py @@ -575,6 +575,73 @@ def test_internal_gains_from_cert_reproduces_000490_worksheet_end_to_end() -> No assert result.total_internal_gains_monthly_w[m] == pytest.approx(expected_73[m], abs=1e-1), f"(73) month {m+1}" +def test_internal_gains_pumps_fans_applies_3w_pump_gain_for_cat4_hp_without_pcdb() -> None: + """SAP 10.2 Appendix N3.1 (PDF p.105) — "For electric heat pumps: + The electricity used by the water circulation pump or fan is + included within the calculated annual space and hot water heating + efficiency and is not included in worksheet (230c). The default + heat gain from Table 5a is included via worksheet (70)." + + The pump GAIN is included via Table 5a's "Central heating pump in + heated space" row even though the pump ELECTRICITY is hidden in + the COP. Worksheet evidence (controlled-variable corpus): + - ashp (Cat 4 HP, code 214 air-to-water, "Post 2013" pump, + no PCDB record): (70) = 3.0000 W heating-mask + - gshp (Cat 4 HP, code 211 ground-source, "Post 2013" pump, + no PCDB record): (70) = 3.0000 W heating-mask + + PCDB Table 362 HP records embed the pump in the COP including the + gain — for those certs (70) = 0 (e.g. cert 0380 cohort). Cat 5 + warm-air HPs (codes 521/523-527) have no water circulation pump; + their fan gain is separately handled via the Table 5a "Warm air + heating system fans" row (S0380.161). + + Pre-slice S0380.160 zeroed pump gain for all HPs; this test forces + a finer-grained gate: Cat 4 HP + no PCDB record + non-warm-air + code → apply the pump gain. + """ + # Arrange — Cat 4 ASHP (code 214) without PCDB index, "Post 2013" + # pump age → 3 W per Table 5a. + sap_heating = SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, + heat_emitter_type=1, # radiators + emitter_temperature=1, + sap_main_heating_code=214, + main_heating_category=4, # HP + main_heating_control=2106, + central_heating_pump_age_str="Post 2013", + ), + ], + has_fixed_air_conditioning=False, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=90.0, + low_energy_fixed_lighting_bulbs_count=6, + sap_windows=[], + sap_heating=sap_heating, + ) + + # Act + result = internal_gains_from_cert( + epc=epc, + dwelling_volume_m3=227.25, + heat_gains_from_water_heating_monthly_kwh=(0.0,) * 12, + overshading=OvershadingCategory.AVERAGE, + ) + + # Assert — 3 W in heating months (Jan-May, Oct-Dec), 0 in summer. + 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) + for m in range(12): + assert abs(result.pumps_fans_monthly_w[m] - expected[m]) <= 1e-9, ( + f"(70) month {m+1} = {result.pumps_fans_monthly_w[m]:.4f}, " + f"expected {expected[m]:.4f}" + ) + + def test_internal_gains_pumps_fans_adds_warm_air_fan_gain_for_cat5_hp_main() -> None: """SAP 10.2 Table 5a (PDF p.177) row "Warm air heating system fans a) c)" — gain = SFP × 0.04 × V (W). Default SFP = 1.5 W/(l/s) per footnote c