Slice S0380.160: SAP 10.2 Table 5a wet-pump gate for central heating gain

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-01 23:02:42 +00:00 committed by Jun-te Kim
parent cdc5307ebc
commit d793ae8851
3 changed files with 165 additions and 31 deletions

View file

@ -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),
)

View file

@ -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.

View file

@ -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