Slice S0380.155: SAP 10.2 Table 4a — heat-pump water-efficiency column dispatch

SAP 10.2 Table 4a (PDF p.163-164) heat-pump rows split efficiency into
two columns — "space" and "water":

    Code  System                                            space  water
    211   Ground source HP with flow temp <= 35°C            230    170
    213   Water source HP with flow temp <= 35°C             230    170
    215   Gas-fired GSHP with flow temp <= 35°C              120     84
    216   Gas-fired WSHP with flow temp <= 35°C              120     84
    217   Gas-fired ASHP with flow temp <= 35°C              110     77
    521   Warm-air electric GSHP                             230    170
    523   Warm-air electric WSHP                             230    170
    525   Warm-air gas-fired GSHP                            120     84
    526   Warm-air gas-fired WSHP                            120     84
    527   Warm-air gas-fired ASHP                            110     77

The split reflects real physics: heat pumps lose efficiency raising
water to ~55°C DHW temperatures vs ~35°C space-heating flow. ASHP
"in other cases" (codes 214, 221, 223, 224) and the "other cases"
gas-fired rows (225-227) have space == water = 170 / 84 / 77 — no
distinct DHW column.

Pre-slice the cascade routed WHC ∈ {901, 902, 914} ("HW from main
heating") through `seasonal_efficiency(main_code)`, which only consults
the Space column. For SAP code 211 the cascade returned 2.30 (= space)
when the spec requires 1.70 (= water). HW fuel kWh undercounted by
26% on the heating-systems corpus gshp variant: cascade 841.47 kWh vs
worksheet 1138.46 kWh.

New `_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY` dict (10 codes where Space
≠ Water) consulted in `_water_efficiency_with_category_inherit` before
falling through to the existing `seasonal_efficiency` path. Codes
where Space == Water keep the legacy inheritance — no behaviour
change. Non-HP main heating (boilers, storage heaters) likewise
unchanged.

Closures (gshp variant — SAP code 211 + WHC=901 + cylinder):
  HW fuel kWh:  841.47 → 1138.45 (matches worksheet 1138.46)
  ΔSAP_c:       +0.9373 → -0.0178
  Δcost:        -£21.60 → +£0.41
  ΔCO2:         -34.98  → +7.06 kg/yr
  ΔPE:          -418.92 → +33.52 kWh/yr

No regressions on 40 other corpus variants — gshp is the only fixture
that lodges a heat-pump code with diverging Space/Water columns.

Cohort-1 ASHP closure (S0380.28 reciprocal interpolation) is unaffected
because that path runs through `heat_pump_record` PCDB Appendix N3
when a PCDB Table 362 record is lodged; this fix is the Table 4a
fallback for cases without a PCDB record.

Extended handover suite: 899 pass / 0 fail. Pyright net-zero (43 → 43).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-01 15:13:21 +00:00 committed by Jun-te Kim
parent 981aaadf73
commit ca6a0efd70
3 changed files with 136 additions and 1 deletions

View file

@ -228,7 +228,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_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='gshp', block='11a', expected_sap_resid=+0.9373, expected_cost_resid_gbp=-21.5977, expected_co2_resid_kg=-34.9751, expected_pe_resid_kwh=-418.9168),
_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),
_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),

View file

@ -2081,6 +2081,35 @@ _WATER_INHERIT_FROM_MAIN_CODES: Final[frozenset[int]] = frozenset({901, 902, 914
_WHC_FROM_MAIN_HEATING: Final[int] = 901
# SAP 10.2 Table 4a (PDF p.163-164) — heat-pump rows have TWO efficiency
# columns ("space" and "water"). For low-temperature ground/water-source
# HPs (codes 211, 213) and all gas-fired HPs (215, 216, 217) the water
# column is lower than the space column because the HP loses efficiency
# raising water to ~55°C DHW temperatures vs ~35°C space-heating flow.
# Mirror in Category 5 warm-air HPs (codes 521, 523, 525, 526, 527).
#
# When WHC ∈ {901, 902, 914} ("HW from main heating") the cascade
# inherits the main system's efficiency for HW. For Table 4a HP codes
# the inherit must consult this Water column, NOT the Space column.
# `seasonal_efficiency` returns the Space column verbatim; this dict
# overrides for the codes where the two columns diverge.
_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY: Final[dict[int, float]] = {
# Electric heat pumps with flow temperature <= 35°C
211: 1.70, # Ground source HP (space 230)
213: 1.70, # Water source HP (space 230)
# Gas-fired heat pumps with flow temperature <= 35°C
215: 0.84, # Ground source HP (space 120)
216: 0.84, # Water source HP (space 120)
217: 0.77, # Air source HP (space 110)
# Category 5 warm-air heat pumps — same shape as Category 4
521: 1.70, # Electric GSHP warm-air (space 230)
523: 1.70, # Electric WSHP warm-air (space 230)
525: 0.84, # Gas-fired GSHP warm-air (space 120)
526: 0.84, # Gas-fired WSHP warm-air (space 120)
527: 0.77, # Gas-fired ASHP warm-air (space 110)
}
def _water_efficiency_with_category_inherit(
*,
water_heating_code: Optional[int],
@ -2094,10 +2123,22 @@ def _water_efficiency_with_category_inherit(
when `sap_main_heating_code` is None. The legacy water_heating_efficiency
only passes main_code through and so collapses heat pumps (cat 4) +
no-code lodgements into the 0.80 gas-boiler default.
SAP 10.2 Table 4a (PDF p.163-164) heat-pump rows split efficiency
into Space and Water columns. For Table 4a HP codes with diverging
columns (`_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY`) we return the
Water value directly; `seasonal_efficiency` returns the Space value
so unconditionally inheriting through it gives the wrong number for
DHW (HP loses efficiency at higher DHW temperatures).
"""
if water_heating_code is None:
return _legacy_water_heating_efficiency(None, main_code)
if water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES:
if (
main_code is not None
and main_code in _TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY
):
return _TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY[main_code]
return seasonal_efficiency(main_code, main_category, main_fuel)
return _legacy_water_heating_efficiency(water_heating_code, main_code)

View file

@ -56,6 +56,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_separately_timed_dhw, # pyright: ignore[reportPrivateUsage]
_space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage]
_water_efficiency_with_category_inherit, # pyright: ignore[reportPrivateUsage]
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
cert_to_demand_inputs,
cert_to_inputs,
@ -1629,6 +1630,99 @@ def test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b()
assert sep_immersion is False
def test_water_efficiency_uses_table_4a_water_column_for_heat_pumps_per_sap_10_2() -> None:
# Arrange — SAP 10.2 Table 4a (PDF p.163-164) gives heat pumps two
# efficiency columns: "space" and "water". For low-temperature
# ground-source / water-source heat pumps and gas-fired heat pumps
# the columns differ:
#
# Code System space water
# 211 Ground source HP with flow temp <= 35°C 230 170
# 213 Water source HP with flow temp <= 35°C 230 170
# 215 Gas-fired GSHP with flow temp <= 35°C 120 84
# 216 Gas-fired WSHP with flow temp <= 35°C 120 84
# 217 Gas-fired ASHP with flow temp <= 35°C 110 77
# 521 Warm-air electric GSHP 230 170
# 523 Warm-air electric WSHP 230 170
# 525 Warm-air gas-fired GSHP 120 84
# 526 Warm-air gas-fired WSHP 120 84
# 527 Warm-air gas-fired ASHP 110 77
#
# Heat pumps lose efficiency when raising water to DHW temperatures
# (typically 55-60°C) vs space-heating flow temperatures (~35°C for
# the low-temp codes). The split columns reflect this real physics.
#
# Pre-slice `_water_efficiency_with_category_inherit` routed WHC=901
# ("HW from main heating") through `seasonal_efficiency(main_code)`
# — which only consults the SPACE column. For SAP code 211 the
# cascade returned 2.30 (= space) when the spec requires 1.70
# (= water). HW fuel kWh undercounted by 26%: cascade HW for the
# heating-systems corpus gshp variant was 841.47 kWh vs worksheet
# 1138.46 kWh.
#
# ASHP "in other cases" (codes 214, 224) and 525-526 unchanged in
# space-vs-water terms keep returning the same value via the
# legacy `seasonal_efficiency` path — no regression risk.
# Act / Assert — codes where Table 4a space ≠ water route through
# the water column when WHC inherits from main heating.
expected_water = {
211: 1.70, 213: 1.70, 215: 0.84, 216: 0.84, 217: 0.77,
521: 1.70, 523: 1.70, 525: 0.84, 526: 0.84, 527: 0.77,
}
for code, expected in expected_water.items():
result = _water_efficiency_with_category_inherit(
water_heating_code=901,
main_code=code,
main_category=4,
main_fuel=30,
)
assert abs(result - expected) <= 1e-9, (
f"Table 4a code {code}: WHC=901 should return water "
f"efficiency {expected} (space-vs-water split per spec); "
f"got {result}"
)
# Codes with space == water (214, 221, 223, 224, 524) are unchanged.
# Verifies the fix preserves the legacy path for these.
for code, expected in ((214, 1.70), (221, 1.70), (224, 1.70),
(524, 1.70)):
result = _water_efficiency_with_category_inherit(
water_heating_code=901,
main_code=code,
main_category=4,
main_fuel=30,
)
assert abs(result - expected) <= 1e-9, (
f"Table 4a code {code}: water efficiency stays at {expected} "
f"(space = water column); got {result}"
)
# Non-HP codes (e.g. solid-fuel boiler 158 / gas-combi 102) keep
# inheriting via `seasonal_efficiency` since their Table 4a/4b row
# has a single efficiency column.
sf_result = _water_efficiency_with_category_inherit(
water_heating_code=901, main_code=158, main_category=None,
main_fuel=15,
)
assert abs(sf_result - 0.65) <= 1e-9, (
f"Solid-fuel back-boiler (158) water eff should be Table 4a "
f"column (0.65); got {sf_result}"
)
# WHC outside the inherit-from-main set (e.g. 903 = HW from separate
# immersion) ignores the heat-pump fix and uses the WHC's own
# efficiency from Table 4a's HW section.
immersion_result = _water_efficiency_with_category_inherit(
water_heating_code=903, main_code=211, main_category=4,
main_fuel=30,
)
assert abs(immersion_result - 1.00) <= 1e-9, (
f"WHC=903 (separate electric immersion) bypasses HP main "
f"efficiency lookup → 100%; got {immersion_result}"
)
def test_section_12_4_4_summer_immersion_applies_to_back_boiler_combos() -> None:
# Arrange — SAP 10.2 §12.4.4 (PDF p.36-37) names two Table 4a back-
# boiler combos that route DHW through an electric immersion Jun-Sep