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