From af34ad9846f1c2f7bb22110147927c21f589e620 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 1 Jun 2026 23:02:42 +0000 Subject: [PATCH] Slice S0380.160: SAP 10.2 Table 5a wet-pump gate for central heating gain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 5a (PDF p.177) row "Central heating pump in heated space" only applies to mains with a water-loop circulation pump. Footnote a) names two exclusions verbatim ("Does not apply if a heating system used solely for domestic hot water. ... Not applicable for electric heat pumps from database."), and the row's name carries the implicit third: dry mains with no central heating pump (electric storage heaters, electric direct-acting, solid-fuel room heaters without back-boilers) — the row simply doesn't list them. Pre-slice `internal_gains_from_cert` gated only on Note a) (HP exclusion), applying `central_heating_pump_w(date_category=...)` to every non-HP main. The default UNKNOWN-date branch added 7 W of pump gain to (70)m for every dry-system fixture in the controlled-variable corpus, even though the worksheet (70)m = 0 every month. Per-line walk on electric 3 (SAP code 401 "Manual charge control"): cascade (73)[Jan] = 640.21 W worksheet (73)[Jan] = 633.21 W delta = +7.00 W cascade (70)[Jan] = 7.00 W worksheet (70)[Jan] = 0.00 W Table 5a inapplicable The +7 W winter-month gain lowered cascade SH demand by ~38 kWh/yr (cascade 11050 vs worksheet 11088). At Table 32 18-hour low-rate ~7.4 p/kWh that's £2.50/yr under-charging — matching the cluster's uniform Δcost = -£1.96..-£2.80 pattern. Continuous SAP rose ~+0.10 because cost dominates the ECF. Fix: new `_any_main_system_has_central_heating_pump(epc)` predicate in `internal_gains.py`, mirroring `cert_to_inputs._is_wet_boiler_main` (S0380.149 — Table 4f kWh side). Wet if any non-HP main lodges: - sap_main_heating_code in {101-141, 151-161, 191-196} (gas/oil/ solid-fuel/electric boilers per Table 4a/4b), - main_heating_index_number (PCDB Table 322 record), - main_heating_category in {1, 2} (RdSAP central heating), OR - heat_emitter_type in {1, 3} (radiators / fan-coil per Table 4d). Dead `_all_main_systems_are_heat_pumps` helper removed (the new predicate subsumes its role). Cluster closures (10 variants): electric 3: SAP +0.1215 → -0.0000, cost -£2.80 → -£0.00 electric 5: SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00 electric 6: SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00 electric 7: SAP +0.1017 → -0.0000, cost -£2.34 → -£0.00 electric 8: SAP +0.0941 → -0.0000, cost -£2.17 → -£0.00 electric 9: SAP +0.1199 → -0.0000, cost -£2.76 → -£0.00 solid fuel 4: SAP +0.0850 → -0.0000, cost -£1.96 → -£0.00 solid fuel 9: SAP +0.1072 → -0.0000, cost -£2.47 → -£0.00 solid fuel 10: SAP +0.1134 → +0.0000, cost -£2.61 → -£0.00 solid fuel 11: SAP +0.0912 → +0.0000, cost -£2.10 → +£0.00 Σ |ΔSAP_c| across 25-variant cohort: 1.24 → 0.18. All 10 cluster variants now join the lighting-PE +48.66 / CO2 +11.95 deferred cohort (Elmhurst-vs-spec monthly factor quirk, same shape as electric 1 + solid fuel 5/6/7/8 from prior closures). Verbatim spec quote (SAP 10.2 Table 5a row 1, PDF p.177): "Central heating pump in heated space, 2013 or later 3 a)" "Central heating pump in heated space, 2012 or earlier 10 a)" "Central heating pump in heated space, unknown date 7 a)" The row name ("Central heating pump") gates by construction: dry systems have no central heating pump and the row's three sub-rows don't apply. No regressions on the other 31 variants or any golden fixture; the 6 Elmhurst U985 fixtures lodge PCDB index → the new predicate returns True → pump_w unchanged. Tests: 904 pass (+1), 0 fail. Pyright net-zero (35 → 35). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 35 ++++-- .../worksheet/internal_gains.py | 105 ++++++++++++++---- .../worksheet/tests/test_internal_gains.py | 56 ++++++++++ 3 files changed, 165 insertions(+), 31 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 2bdb155e..82b22f6c 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -304,16 +304,31 @@ class _CorpusExpectation: # (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. +# +# Slice S0380.160 closed the 10-variant cluster (electric 3/5/6/7/8/9 +# + solid fuel 4/9/10/11) by gating SAP 10.2 Table 5a row "Central +# heating pump in heated space" (PDF p.177) on whether the cert lodges +# a wet, non-HP main. Pre-slice the cascade added 7 W of pump gain +# (UNKNOWN-date default) to (70)m for every non-HP main; per the per- +# line walk on electric 3, worksheet (70)m = 0 across all 12 months +# because storage heaters / dry room heaters have no primary water +# loop. The +7 W winter gain was lowering cascade SH demand by ~38 +# kWh/yr (cascade 11050 vs worksheet 11088 for electric 3), in turn +# under-charging cost by ~£2.50 and pushing continuous SAP up ~+0.10. +# Cluster SAP / cost / CO2 (rating block) now EXACT for all 10 +# variants; only the lighting-PE +48.66 / +11.95 CO2 deferred quirk +# remains (same offset as electric 1 + solid fuel 5/6/7/8). Cluster +# Σ|ΔSAP_c| 1.06 → 0.00 in one slice. _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.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=+0.1081, expected_cost_resid_gbp=-2.4918, expected_co2_resid_kg=+7.2978, expected_pe_resid_kwh=+0.0658), - _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), - _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+0.1017, expected_cost_resid_gbp=-2.3444, expected_co2_resid_kg=+7.6424, expected_pe_resid_kwh=+3.0976), - _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+0.0941, expected_cost_resid_gbp=-2.1679, expected_co2_resid_kg=+7.9230, expected_pe_resid_kwh=+6.5824), - _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=+0.1199, expected_cost_resid_gbp=-2.7611, expected_co2_resid_kg=+6.8225, expected_pe_resid_kwh=-4.5085), + _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), + _CorpusExpectation(variant='electric 5', 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 6', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604), + _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='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), @@ -329,14 +344,14 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # control-type gaps — separate slices. _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-93.0988, expected_pe_resid_kwh=-1027.5099), _CorpusExpectation(variant='solid fuel 3', 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='solid fuel 4', block='11a', expected_sap_resid=+0.0850, expected_cost_resid_gbp=-1.9582, expected_co2_resid_kg=-9.3050, expected_pe_resid_kwh=-5.7762), + _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604), _CorpusExpectation(variant='solid fuel 5', 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='solid fuel 6', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604), _CorpusExpectation(variant='solid fuel 7', 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='solid fuel 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='solid fuel 9', block='11a', expected_sap_resid=+0.1072, expected_cost_resid_gbp=-2.4702, expected_co2_resid_kg=+9.6917, expected_pe_resid_kwh=-5.0715), - _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+0.1134, expected_cost_resid_gbp=-2.6121, expected_co2_resid_kg=+9.3131, expected_pe_resid_kwh=-13.9149), - _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+0.0912, expected_cost_resid_gbp=-2.1006, expected_co2_resid_kg=+10.5547, expected_pe_resid_kwh=-0.7387), + _CorpusExpectation(variant='solid fuel 9', 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='solid fuel 10', 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='solid fuel 11', 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), ) diff --git a/domain/sap10_calculator/worksheet/internal_gains.py b/domain/sap10_calculator/worksheet/internal_gains.py index aa735418..989a0c43 100644 --- a/domain/sap10_calculator/worksheet/internal_gains.py +++ b/domain/sap10_calculator/worksheet/internal_gains.py @@ -658,18 +658,76 @@ def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory: _HEAT_PUMP_MAIN_HEATING_CATEGORY: Final[int] = 4 -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.""" +# SAP 10.2 Table 5a row "Central heating pump in heated space" (PDF +# p.177) only applies to mains with a water-loop circulation pump. +# Dry mains — electric storage heaters (Table 4a Cat 7 codes 401-409, +# 421), warm-air heaters without HPs (Cat 9), solid-fuel room heaters +# without back-boilers (codes 631-636 minus the boiler combos at +# 151-161), electric direct-acting heaters — have no primary water +# loop, so the row simply doesn't apply and worksheet (70)m = 0. +# +# Mirrors `cert_to_inputs._WET_BOILER_CODE_RANGES` (Table 4f kWh +# accounting). Kept as a sibling constant here so the worksheet layer +# does not depend on rdsap. Same code-range coverage: +# 101-141 Gas/oil boilers (Table 4b) +# 151-161 Solid-fuel boilers + back-boiler combos (Table 4a) +# 191-196 Electric boilers + CPSU (Table 4a) +_WET_BOILER_SAP_CODE_RANGES: Final[tuple[range, ...]] = ( + range(101, 142), + range(151, 162), + range(191, 197), +) + +# Heat-emitter types (Table 4d) that imply a wet primary loop — +# radiators (1) and fan-coil units (3) require water-side delivery. +# UFH (2) excluded because it can be wet OR electric (in-screed cable); +# the SAP code or category disambiguates. Warm-air (4) and electric +# storage / direct-acting emitters are dry. Used only as a fallback +# when no SAP code / PCDB index / category is lodged (e.g. the 000490 +# hand-built unit-test fixture). +_WET_HEAT_EMITTER_TYPES: Final[frozenset[int]] = frozenset({1, 3}) + + +def _any_main_system_has_central_heating_pump(epc: EpcPropertyData) -> bool: + """SAP 10.2 Table 5a row "Central heating pump in heated space" + (PDF p.177) — predicate for whether the pump-gain row applies. + + Identifies wet, non-HP mains by (any of): + - sap_main_heating_code in Table 4a/4b wet-boiler ranges + (gas/oil/solid-fuel/electric boilers) + - main_heating_index_number lodged + category not HP (PCDB + Table 322 gas/oil boiler record) + - main_heating_category in {1, 2} (RdSAP "central heating" with + or without separate HW — both wet) + - heat_emitter_type in {1 radiators, 3 fan-coil} (Table 4d wet + emitter types; UFH/2 excluded as it can be electric) + + HP mains (category 4) are skipped per Table 5a Note a) "Not + applicable for electric heat pumps from database." Where any + non-HP main qualifies as wet, the pump gain applies (per the + same note's clause about two mains in the same space). + + Mirrors `cert_to_inputs._is_wet_boiler_main` — see docstring there + for the kWh-side parallel in Table 4f. + """ details = epc.sap_heating.main_heating_details if not details: return False - return all( - d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY - for d in details - ) + for d in details: + if d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY: + continue + code = d.sap_main_heating_code + if code is not None and any( + code in r for r in _WET_BOILER_SAP_CODE_RANGES + ): + return True + if d.main_heating_index_number is not None: + return True + if d.main_heating_category in {1, 2}: + return True + if d.heat_emitter_type in _WET_HEAT_EMITTER_TYPES: + return True + return False def internal_gains_from_cert( @@ -725,21 +783,26 @@ def internal_gains_from_cert( daylight_factor=c_daylight, ) - # 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: + # SAP 10.2 Table 5a row "Central heating pump in heated space" + # (PDF p.177) — the gain applies only to mains with a water-loop + # circulation pump. Excludes: + # (i) HP mains per Table 5a Note a) "Not applicable for electric + # heat pumps from database" (cert 0380 HP-only → 0 W), + # (ii) Dry mains with no primary water loop — electric storage + # heaters (Cat 7), warm-air heaters (Cat 9), solid-fuel room + # heaters without back-boilers, electric direct-acting. + # Worksheet (70)m = 0 across the 41-variant controlled- + # variable corpus for every dry main; see + # `_any_main_system_has_central_heating_pump`. + # Mixed HP + wet-boiler mains (cert 000565: HP main 1 + gas boiler + # main 2) DO carry the gain via the non-HP main's pump (worksheet + # line 70 confirms 3.0000 W in 8 winter months, 0 in summer). + if _any_main_system_has_central_heating_pump(epc): pump_w = central_heating_pump_w( date_category=_pump_date_category_from_cert(epc) ) + else: + pump_w = 0.0 # Liquid-fuel + warm-air + PIV + MV + HIU branches default to zero for # the combi-gas-natural-vent population; future slices will detect them # from epc.main_heating_details + epc.mechanical_ventilation. diff --git a/domain/sap10_calculator/worksheet/tests/test_internal_gains.py b/domain/sap10_calculator/worksheet/tests/test_internal_gains.py index 55bdb058..93f0bb19 100644 --- a/domain/sap10_calculator/worksheet/tests/test_internal_gains.py +++ b/domain/sap10_calculator/worksheet/tests/test_internal_gains.py @@ -575,6 +575,62 @@ 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_is_zero_for_electric_storage_heater_main() -> None: + """SAP 10.2 Table 5a (PDF p.177) row "Central heating pump in heated + space" — the gain applies only to mains with a water-loop circulation + pump. Electric storage heaters (Table 4a Cat 7 codes 401-409 + 421) + have no primary water loop and no circulation pump, so the row does + not apply and worksheet (70)m = 0 every month. + + Mirrors the kWh-side gate in `cert_to_inputs._table_4f_circulation_pump_kwh` + (S0380.149). Worksheet evidence: the controlled-variable corpus at + `sap worksheets/heating systems examples/` lodges 001431 under 41 + heating-system variants — every dry electric storage / room-heater + variant lodges (70)m = 0.0 across all 12 months (e.g. electric 3, + solid fuel 4/9/10/11). + """ + # Arrange — minimal cert lodging an Elmhurst-style electric storage + # heater main (Table 4a code 401 "Manual charge control") with no + # PCDB index, no category, no heat-emitter (storage heaters distribute + # heat directly to the room — no emitter loop). + sap_heating = SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # mains electricity + heat_emitter_type="", # storage heaters have no emitter loop + emitter_temperature="", + sap_main_heating_code=401, + main_heating_control=2401, + central_heating_pump_age_str="Unknown", + ), + ], + 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 — (70)m is zero in every month, including heating months + # (Jan-May, Oct-Dec) where wet-system pumps would contribute 3/7/10 W. + for m in range(12): + assert abs(result.pumps_fans_monthly_w[m] - 0.0) <= 1e-9, ( + f"(70) month {m+1} = {result.pumps_fans_monthly_w[m]:.4f}, expected 0.0" + ) + + def _build_section_5_epc(fixture: ModuleType) -> EpcPropertyData: """Wrap a fixture's base `build_epc()` with the §5-relevant fields it doesn't yet carry: sap_windows (DG air-filled / PVC), low-energy bulb