Merge pull request #1359 from Hestia-Homes/fix/heat-pump-flow-temp-efficiency

fix(sap): heat-pump PSR-extension + water-heating 100% floor (Appendix N)
This commit is contained in:
Daniel Roth 2026-06-29 16:59:47 +01:00 committed by GitHub
commit 085bd29af4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 219 additions and 15 deletions

View file

@ -6234,6 +6234,10 @@ _SAP_DESIGN_HEAT_LOSS_DELTA_T_K: Final[float] = 24.2
_HP_SPACE_HEATING_IN_USE_FACTOR_N3_6: Final[float] = 0.95
_HP_IN_USE_FACTOR_CRITERIA_MET: Final[float] = 0.95
_HP_IN_USE_FACTOR_CRITERIA_FAIL: Final[float] = 0.60
# SAP 10.2 Appendix N3.7 (PDF p.109): the heat-pump water-heating efficiency
# (in-use factor × η_water) is "subject to a minimum efficiency of 100%" —
# below that the direct-electric backup governs.
_HP_WATER_HEATING_MIN_EFFICIENCY: Final[float] = 1.0
def _heat_pump_cylinder_meets_pcdb_criteria(
@ -6325,7 +6329,12 @@ def _heat_pump_apm_efficiencies(
main_heating_efficiency = (
_HP_SPACE_HEATING_IN_USE_FACTOR_N3_6 * eta_space_1_pct / 100.0
)
water_efficiency_pct = in_use_water * eta_water_3_pct / 100.0
# N3.7: in-use factor × η_water, subject to a minimum efficiency of 100%
# (the direct-electric backup floors the heat pump's water heating).
water_efficiency_pct = max(
in_use_water * eta_water_3_pct / 100.0,
_HP_WATER_HEATING_MIN_EFFICIENCY,
)
return (main_heating_efficiency, water_efficiency_pct)

View file

@ -263,6 +263,14 @@ _HP_PSR_GROUP_OFFSET_PSR: Final[int] = 0
_HP_PSR_GROUP_OFFSET_ETA_SPACE_1: Final[int] = 2
_HP_PSR_GROUP_OFFSET_ETA_WATER_3: Final[int] = 6
# SAP 10.2 Appendix N2 (PDF p.101, footnotes 44/45): out-of-range PSR
# extension for air/ground/water source heat pumps. Above the record's
# largest PSR the efficiency is reciprocal-interpolated toward 100% at
# `_EXTENSION_PSR_MULTIPLE` × the largest PSR; below the smallest PSR, and
# beyond that multiple, the efficiency is the terminal 100%.
_EXTENSION_TERMINAL_EFFICIENCY_PCT: Final[float] = 100.0
_EXTENSION_PSR_MULTIPLE: Final[float] = 2.0
def _parse_psr_groups(raw: tuple[str, ...]) -> tuple[PsrEfficiencyGroup, ...]:
"""Decode the variable-length PSR-dependent block of a format-465
@ -317,28 +325,60 @@ def interpolate_heat_pump_efficiency_at_psr(
(not their reciprocals taken from PCDB), so the η_*_pct values must
be strictly positive every PCDB row in the cohort satisfies this.
Per spec PDF p.100 lines 7039-7072: clamp to the smallest PSR in
the record when `target_psr` is below it, and to the largest when
above ("if the PSR is greater than the largest PSR in the database
record then the heat pump space and water heating fractions for the
largest PSR should be used, and if the PSR is less than the
smallest PSR in the database record then the heat pump space and
water heating fractions for the smallest PSR should be used").
Out-of-range PSR (spec PDF p.101, footnotes 44/45 air/ground/water
source heat pumps):
- Below the smallest PSR in the record: "an efficiency of 100%
should be used if the PSR is less than the smallest value in the
database record."
- Above the largest PSR in the record: "an efficiency may be
obtained from linear interpolation between that at the largest
PSR in the data record and efficiency 100% at PSR two times the
largest PSR in the data record. If the PSR is greater than two
times the largest PSR in the data record an efficiency of 100%
should be used." The interpolation is reciprocal-linear too
(footnote 43), with 100% as the upper anchor.
Both space- and water-heating PSR-dependent efficiencies extend the
same way. (Exhaust-air heat pumps and combined heat-pump-and-boiler
packages instead use 100% directly above the largest PSR, and combined
packages clamp to the edge rows; neither is distinguished by the
current PCDB parse, so the air/ground/water rule is applied uniformly
a documented limitation. The dominant RdSAP cohort is air source.)
Cohort fixture: cert 3336-2825-9400-0512-8292 (Mitsubishi PUZ-WM50VHA,
PCDB 104568) PSR 1.40151 brackets PCDB rows PSR 1.2 (η_space_1
= 253.9) and PSR 1.5 (η_space_1 = 229.2). Linear (pre-slice):
237.31; reciprocal (spec-faithful): 236.74 matches worksheet
(206)/(210) at 1e-4 once the 0.95 in-use factor is applied.
Out-of-range anchor: PCDB 100061 (golden fixture case 56), largest PSR
2.0 (η_space_1=352.0). At dwelling PSR 3.10665 the extension to 100%
at PSR 4.0 gives η_space_1 = 147.011 (206) = 139.660, matching the
accredited Elmhurst worksheet (vs the old clamp's 352.0 → 334.4%).
"""
if not psr_groups:
raise ValueError("PSR groups required for interpolation")
if target_psr <= psr_groups[0].psr:
first = psr_groups[0]
return (first.eta_space_1_pct, first.eta_water_3_pct)
if target_psr >= psr_groups[-1].psr:
if target_psr < psr_groups[0].psr:
return (_EXTENSION_TERMINAL_EFFICIENCY_PCT, _EXTENSION_TERMINAL_EFFICIENCY_PCT)
if target_psr > psr_groups[-1].psr:
last = psr_groups[-1]
return (last.eta_space_1_pct, last.eta_water_3_pct)
upper_psr = _EXTENSION_PSR_MULTIPLE * last.psr
if target_psr >= upper_psr:
return (
_EXTENSION_TERMINAL_EFFICIENCY_PCT,
_EXTENSION_TERMINAL_EFFICIENCY_PCT,
)
t = (target_psr - last.psr) / (upper_psr - last.psr)
eta_space_1 = 1.0 / (
(1.0 - t) / last.eta_space_1_pct
+ t / _EXTENSION_TERMINAL_EFFICIENCY_PCT
)
eta_water_3 = 1.0 / (
(1.0 - t) / last.eta_water_3_pct
+ t / _EXTENSION_TERMINAL_EFFICIENCY_PCT
)
return (eta_space_1, eta_water_3)
for low_group, high_group in zip(psr_groups, psr_groups[1:]):
if low_group.psr <= target_psr <= high_group.psr:
span = high_group.psr - low_group.psr

View file

@ -54,6 +54,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_apply_heat_network_hiu_default_store, # pyright: ignore[reportPrivateUsage]
_cylinder_thermostat_present, # pyright: ignore[reportPrivateUsage]
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
_heat_pump_apm_efficiencies, # pyright: ignore[reportPrivateUsage]
_heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage]
_heat_network_community_fuel_code, # pyright: ignore[reportPrivateUsage]
_heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage]
@ -4841,6 +4842,57 @@ def test_hot_water_from_pcdb_heat_pump_bills_at_app_n_wh_high_rate() -> None:
assert abs(rate_immersion - 0.0750) <= 1e-6
def test_heat_pump_water_efficiency_is_floored_at_100pct_per_app_n3_7() -> None:
# Arrange — SAP 10.2 Appendix N3.7 ("Thermal efficiency for water
# heating heat pumps", PDF p.109): "multiply the thermal efficiency
# (ηwater) for water heating by the in-use factor in Table N8; subject
# to a MINIMUM EFFICIENCY OF 100%." Our `_heat_pump_apm_efficiencies`
# applied the in-use factor but omitted the floor, so an oversized heat
# pump whose PSR-extended ηwater × 0.60 in-use fell below 100% billed
# water heating at that sub-100% efficiency (over-counting HW fuel).
#
# Accredited anchor: golden fixture case 56 (PCDB 100061, the config of
# cert 100110101713). At HLC 107.82 W/K the PSR is 3.107, above the
# record's largest PSR 2.0, so the Appendix N2 extension takes ηwater,3
# from 198.9% toward 100% at 2 x 2.0 = 4.0 → 128.55%; × the 0.60 in-use
# factor (Open-EPC certs never lodge cylinder HX area → criteria fail)
# = 77.13% < 100% → the worksheet (216) reads 100.0000. In-range PSR
# (case 54, HLC large) keeps 0.60 × 198.9 = 119.34% (worksheet case 54
# (216) = 112.5% for its 187.5% record — both above the floor, unchanged).
from domain.sap10_calculator.tables.pcdb import heat_pump_record
record = heat_pump_record(100061)
assert record is not None
hp_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity (heat pump)
heat_emitter_type=1, # radiators
emitter_temperature=0,
main_heating_control=2210,
main_heating_category=4,
sap_main_heating_code=None,
main_heating_index_number=100061,
)
epc = _typical_semi_detached_epc() # no specified cylinder → in-use 0.60
# Act — oversized PSR (extension region) vs an in-range PSR.
_space_ext, water_ext = _heat_pump_apm_efficiencies(
main=hp_main, hp_record=record,
hlc_annual_avg_w_per_k=107.82, # PSR 3.107 > largest 2.0
epc=epc,
) or (None, None)
_space_in, water_in = _heat_pump_apm_efficiencies(
main=hp_main, hp_record=record,
hlc_annual_avg_w_per_k=400.0, # PSR 0.837, in range
epc=epc,
) or (None, None)
# Assert — extended HP water efficiency is floored at 100% (1.0); the
# in-range PSR keeps the un-floored 0.60 × 198.9% = 119.34%.
assert water_ext is not None and abs(water_ext - 1.0) < 1e-9
assert water_in is not None and abs(water_in - 0.60 * 198.9 / 100.0) < 1e-9
def test_hot_water_immersion_off_peak_bills_at_table_13_blend() -> None:
# Arrange — SAP 10.2 Table 12a (PDF p.191) "Immersion water heater"
# row routes the WH column to Table 13 (PDF p.197). For an electric

View file

@ -180,3 +180,88 @@ def test_interpolate_heat_pump_efficiency_at_cert_0380_psr_per_sap_app_n() -> No
# ≈ 0.0035077 → eta_water_3 ≈ 285.0861
assert abs(eta_space_1 - 234.5235) < 1e-3
assert abs(eta_water_3 - 285.0861) < 1e-3
def test_interpolate_extends_above_largest_psr_toward_100pct_per_app_n() -> None:
"""SAP 10.2 Appendix N2 (PDF p.101, footnote 44/45) — PSR above the
record's largest value extends the efficiency toward 100%, it is NOT
clamped to the top-of-table value.
"in the case of a heat pump (ground, water or air source), where
the PSR is greater than the largest value in the data record, an
efficiency may be obtained from linear interpolation between that
at the largest PSR in the data record and efficiency 100% at PSR
two times the largest PSR in the data record. If the PSR is
greater than two times the largest PSR in the data record an
efficiency of 100% should be used."
Interpolation is reciprocal-linear (footnote 43). Accredited anchor:
Elmhurst worksheet for cert 100110101713 / "golden fixture debugging"
case 56 (PCDB 100061, ECODAN 8.5 kW, largest PSR row η_space,1=352.0).
The dwelling HLC (39) = 107.8199 W/K and max output 8.106 kW give
PSR = 8.106 × 1000 / (107.8199 × 24.2) = 3.106650
which exceeds the record's largest PSR (2.0). The spec extension to
100% at 2 × 2.0 = 4.0 yields, at t = (3.106650 2.0)/(4.0 2.0):
1/η = (1 t)/352.0 + t/100.0 η_space,1 = 147.011
so that (206) = 0.95 × 147.011 = 139.660 matching the accredited
worksheet exactly. The previous top-of-table clamp returned 352.0
( 334.4%), over-rating the dwelling by +18 SAP.
"""
from domain.sap10_calculator.tables.pcdb.parser import (
interpolate_heat_pump_efficiency_at_psr,
)
record = heat_pump_record(100061)
assert record is not None
assert record.psr_groups[-1].psr == 2.0
assert record.psr_groups[-1].eta_space_1_pct == 352.0
eta_space_1, _eta_water_3 = interpolate_heat_pump_efficiency_at_psr(
record.psr_groups, target_psr=3.106649864134083,
)
assert abs(eta_space_1 - 147.011) < 1e-2
assert abs(0.95 * eta_space_1 - 139.6604) < 1e-2
def test_interpolate_above_twice_largest_psr_is_100pct_per_app_n() -> None:
"""SAP 10.2 Appendix N2 — beyond twice the largest PSR the efficiency
is exactly 100% (the upper terminus of the extension), for both space
and water heating PSR-dependent results."""
from domain.sap10_calculator.tables.pcdb.parser import (
interpolate_heat_pump_efficiency_at_psr,
)
record = heat_pump_record(100061)
assert record is not None
eta_space_1, eta_water_3 = interpolate_heat_pump_efficiency_at_psr(
record.psr_groups, target_psr=9.0, # > 2 × 2.0
)
assert eta_space_1 == 100.0
assert eta_water_3 == 100.0
def test_interpolate_below_smallest_psr_is_100pct_per_app_n() -> None:
"""SAP 10.2 Appendix N2 (PDF p.101) — "For all heat pumps, an
efficiency of 100% should be used if the PSR is less than the smallest
value in the database record." (Not clamped to the smallest row.)"""
from domain.sap10_calculator.tables.pcdb.parser import (
interpolate_heat_pump_efficiency_at_psr,
)
record = heat_pump_record(100061)
assert record is not None
assert record.psr_groups[0].psr == 0.2
eta_space_1, eta_water_3 = interpolate_heat_pump_efficiency_at_psr(
record.psr_groups, target_psr=0.1, # < 0.2
)
assert eta_space_1 == 100.0
assert eta_water_3 == 100.0

View file

@ -193,7 +193,10 @@ _CORPUS = Path(
# within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815. Surfaced by Khalim's Elmhurst
# stress worksheet (simulated case 46): closed its last ventilation residual
# (our Jan ACH 9.14 -> 9.0748 exact; SAP 29 -> 30 = accredited Elmhurst).
_MIN_WITHIN_HALF_SAP = 0.74
# 0.74 -> 0.742 via the heat-pump water-heating 100% floor (App N3.7): cert
# 100110101713 moves inside +-0.5 (|err| 4.97 -> 0.49). See the _MAX_SAP_MAE
# log below for the paired space-heating PSR-extension + water-floor slices.
_MIN_WITHIN_HALF_SAP = 0.742
# 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak
# trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion)
# -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57,
@ -248,7 +251,22 @@ _MIN_WITHIN_HALF_SAP = 0.74
# an identical dwelling rates SAP 87 with "Connected to Dwelling = Yes" (credit
# -£167) vs SAP 74 with "No" (credit £0). Enum decoded empirically: 0 = no PV,
# 1 = not connected, 2 = connected (the gov-API does not expose it elsewhere).
_MAX_SAP_MAE = 0.740
# Then 0.740 -> 0.726 via the heat-pump PSR-extension fix (SAP 10.2 Appendix N2,
# PDF p.101 footnotes 44/45): an air/ground/water source heat pump whose plant
# size ratio exceeds the PCDB record's largest PSR is no longer clamped to the
# top-of-table COP — its efficiency is reciprocal-interpolated toward 100% at
# twice the largest PSR (and 100% below the smallest PSR). Accredited Elmhurst
# worksheet for cert 100110101713 (golden fixture case 56, PCDB 100061, PSR
# 3.107 over largest 2.0): (206) 334.4% -> 139.66% = Elmhurst exact. Only two
# certs move (both oversized-PSR heat pumps): 100110101713 +18.32 -> -4.97 and
# 4510053280 -0.61; within-0.5 holds at 74.1%.
# Then 0.726 -> 0.722 (within-0.5 74.1% -> 74.2%) via the heat-pump water-
# heating 100% floor (SAP 10.2 Appendix N3.7, PDF p.109: in-use x eta_water
# subject to a minimum efficiency of 100%). Only 100110101713 moves: its
# oversized-PSR water eff 0.60 x 128.55% = 77.13% is floored to 100% (=
# accredited Elmhurst (216)), taking the cert 68.03 -> 72.51 (|err| 4.97 ->
# 0.49, now inside +-0.5). In-range heat pumps keep their > 100% water COP.
_MAX_SAP_MAE = 0.722
_MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 3.5 # kWh / m2 / yr vs energy_consumption_current