Slice 102e: heat-pump APM efficiencies via SAP 10.2 Appendix N3.6 / N3.7(a)

For any cert lodging a Table 362 heat-pump PCDB record, the cascade now
replaces the Table 4a category defaults with PSR-interpolated
efficiencies per SAP 10.2 Appendix N (PDF p.108):

  (206) = 0.95 × η_space,1_interp                  (N3.6 in-use factor)
  (217) = in_use_factor × η_water,3_interp         (N3.7(a) + footnote 49)

where η_space,1 and η_water,3 are PSR-dependent values from the PCDB
record's PSR-group table (decoded in slice 102c.2), and the dwelling's
PSR is computed per PDF p.100 line 5946-5950:

  PSR = max_nominal_output_kw / (HLC_annual_avg_W_per_K × 24.2 K / 1000)

The N3.7 in-use factor (PDF p.6097) tests three cylinder criteria:
  1. cert volume ≥ PCDB volume
  2. cert heat-exchanger area ≥ PCDB area (unless PCDB area = 0 per fn53)
  3. cert heat loss [(47)×(51)×(52)] ≤ PCDB heat loss

All three pass → 0.95; any criterion fails or is unknown → 0.60. The
Open EPC API never lodges cylinder heat-exchanger area, so for the
cohort this criterion is always "unknown" → in_use_factor = 0.60.

Cert 0380 (Mitsubishi ASHP PCDB 104568, ASHP main, 160 L cylinder):
  cascade PSR              = 4.39 / (127.158 × 24.2 / 1000) ≈ 1.4266
  cascade η_space,1_interp ≈ 235.24  (PSR-1.2 row 253.9, PSR-1.5 229.2)
  cascade η_water,3_interp ≈ 285.13  (PSR-1.2 row 287.7, PSR-1.5 284.3)
  cascade main_heating_eff ≈ 2.2348  (vs worksheet 2.2305, 1.9e-3 diff)
  cascade HW kWh/yr         ≈ 878.05 (vs worksheet 877.97, 0.08 kWh/yr)
  cascade SAP rating        ≈ 89.11  (vs worksheet 88.5104, +0.60)

The remaining +0.60 SAP residual is bounded by the ~0.4% PSR-formula
drift (the cascade computes PSR=1.4266 from (39)_annual_avg × 24.2 K
whereas the worksheet back-solves to ≈ 1.4321). Slice 102f decides
whether further PSR refinement is needed to reach a 1e-4 SAP pin.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 12:34:00 +00:00 committed by Jun-te Kim
parent 9950226267
commit 2e5c519861
2 changed files with 194 additions and 0 deletions

View file

@ -148,6 +148,9 @@ from domain.sap10_calculator.worksheet.ventilation import (
VentilationResult,
ventilation_from_inputs,
)
from domain.sap10_calculator.tables.pcdb.parser import (
interpolate_heat_pump_efficiency_at_psr,
)
from domain.sap10_calculator.worksheet.water_heating import (
PIPEWORK_INSULATED_FULLY,
PIPEWORK_INSULATED_UNINSULATED,
@ -155,7 +158,9 @@ from domain.sap10_calculator.worksheet.water_heating import (
WaterHeatingResult,
combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
combi_loss_monthly_kwh_table_3c_two_profile_instantaneous,
cylinder_storage_loss_factor_table_2,
cylinder_storage_loss_monthly_kwh,
cylinder_volume_factor_table_2a,
primary_loss_monthly_kwh,
water_efficiency_monthly_via_equation_d1,
water_heating_from_cert,
@ -1912,6 +1917,115 @@ def _pipework_insulation_fraction_table_3(primary_age: Optional[str]) -> float:
return PIPEWORK_INSULATED_UNINSULATED
# SAP 10.2 PDF p.100 line 5950: design heat loss = (39) × ΔT, where ΔT
# = 24.2 K. The HLC × ΔT product feeds the PSR denominator per line 5946.
_SAP_DESIGN_HEAT_LOSS_DELTA_T_K: Final[float] = 24.2
# Cohort-derived in-use factors per SAP 10.2 Appendix N3.6 / N3.7 (PDF
# p.108 + the cylinder criteria table at p.6097). 0.95 applies only when
# the cert's cylinder matches the PCDB-lodged volume / heat exchanger
# area / heat loss; 0.60 otherwise (or when any criterion is unknown).
_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
def _heat_pump_cylinder_meets_pcdb_criteria(
epc: EpcPropertyData,
hp_record: "HeatPumpRecord",
) -> bool:
"""Spec PDF p.6097 — "in-use factor 0.95 applies when the actual
cylinder has performance parameters at least equal to those in the
PCDB record, namely:
- cylinder volume not less than that in the PCDB record
- heat transfer area not less than that in the PCDB record
(unless the PCDB heat exchanger area is zero see footnote 53)
- heat loss (kWh/day) [either (48) or (47) × (51) × (52)] not
greater than that in the PCDB record.
If any of these conditions are not fulfilled, or are unknown, the
in-use factor is 0.60."
The Open EPC API does not lodge cylinder heat exchanger area, so
for the cohort this criterion is always "unknown" returns False.
"""
sh = epc.sap_heating
size_code = _int_or_none(sh.cylinder_size)
if size_code is None:
return False
cert_volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
if cert_volume_l is None:
return False
# Volume criterion.
if hp_record.vessel_volume_l is None or cert_volume_l < hp_record.vessel_volume_l:
return False
# Heat exchanger area criterion. The footnote 53 carve-out (PCDB
# area = 0 → test does not apply) doesn't fire here because cohort
# records lodge non-zero areas (3.0 m² for 104568 / 0.415 for
# 102421). Open EPC certs don't lodge HX area → always fail.
if (
hp_record.vessel_heat_exchanger_area_m2 is not None
and hp_record.vessel_heat_exchanger_area_m2 > 0.0
):
return False # cert HX area is unknown per API schema → criterion fails
# Heat loss criterion.
if sh.cylinder_insulation_type != _CYLINDER_INSULATION_TYPE_FACTORY:
return False
thickness_mm = sh.cylinder_insulation_thickness_mm
if thickness_mm is None:
return False
cert_heat_loss_kwh_per_day = (
cert_volume_l
* cylinder_storage_loss_factor_table_2(
insulation_type="factory_insulated",
thickness_mm=float(thickness_mm),
)
* cylinder_volume_factor_table_2a(cert_volume_l)
)
pcdb_heat_loss = hp_record.vessel_heat_loss_kwh_per_day
if pcdb_heat_loss is None or cert_heat_loss_kwh_per_day > pcdb_heat_loss:
return False
return True
def _heat_pump_apm_efficiencies(
*,
main: Optional[MainHeatingDetail],
hp_record: Optional["HeatPumpRecord"],
hlc_annual_avg_w_per_k: float,
epc: EpcPropertyData,
) -> Optional[tuple[float, float]]:
"""Compute `(main_heating_efficiency, water_efficiency_pct)` per
SAP 10.2 Appendix N3.6 (space) + N3.7(a) (water, footnote 49).
Returns None when APM is not applicable (no HP, no PCDB record, no
PSR groups, no max output) so the caller keeps the Table 4a default.
"""
if main is None or main.main_heating_category != 4:
return None
if hp_record is None or not hp_record.psr_groups:
return None
if hp_record.max_output_kw is None or hp_record.max_output_kw <= 0:
return None
if hlc_annual_avg_w_per_k <= 0:
return None
psr = (hp_record.max_output_kw * 1000.0) / (
hlc_annual_avg_w_per_k * _SAP_DESIGN_HEAT_LOSS_DELTA_T_K
)
eta_space_1_pct, eta_water_3_pct = interpolate_heat_pump_efficiency_at_psr(
hp_record.psr_groups, target_psr=psr,
)
in_use_water = (
_HP_IN_USE_FACTOR_CRITERIA_MET
if _heat_pump_cylinder_meets_pcdb_criteria(epc, hp_record)
else _HP_IN_USE_FACTOR_CRITERIA_FAIL
)
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
return (main_heating_efficiency, water_efficiency_pct)
def _primary_loss_applies(
main: Optional[MainHeatingDetail],
cylinder_present: bool,
@ -2320,6 +2434,11 @@ def cert_to_inputs(
if main is not None and main.main_heating_index_number is not None
else None
)
pcdb_hp_record = (
heat_pump_record(main.main_heating_index_number)
if main is not None and main.main_heating_index_number is not None
else None
)
# Heat-network override (Table 12 note (k)) sets efficiency = 1/DLF so
# `main_fuel_kwh = q_useful × DLF = q_generated`, matching the spec's
# "unit prices per kWh of heat generated" convention.
@ -2333,6 +2452,20 @@ def cert_to_inputs(
main_category=main_category,
main_fuel=main_fuel,
)
# SAP 10.2 Appendix N3.6 + N3.7(a) — when an HP cert lodges a PCDB
# Table 362 record, the cascade replaces the Table 4a defaults with
# APM-interpolated η_space and η_water at the dwelling's PSR.
hlc_annual_avg_w_per_k = ht.total_w_per_k + 0.33 * dim.volume_m3 * sum(
ventilation.effective_monthly_ach
) / 12.0
apm_efficiencies = _heat_pump_apm_efficiencies(
main=main,
hp_record=pcdb_hp_record,
hlc_annual_avg_w_per_k=hlc_annual_avg_w_per_k,
epc=epc,
)
if apm_efficiencies is not None:
eff, water_eff = apm_efficiencies
if (
_is_heat_network_main(main)
and epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES

View file

@ -1086,6 +1086,67 @@ def test_cert_with_hot_water_cylinder_computes_storage_loss_56m_from_sap_tables_
)
def test_air_source_heat_pump_pcdb_104568_derives_apm_efficiencies_per_sap_app_n() -> None:
"""SAP 10.2 Appendix N3.6 / N3.7(a) (PDF p.108) replace the Table 4a
HP defaults with PSR-interpolated efficiencies from the PCDB Table
362 record:
(206) = 0.95 × η_space,1_interp (N3.6 in-use factor)
(217) = in_use_factor × η_water,3_interp (N3.7 + table p.6097)
For cert 0380 (PCDB index 104568, separate-but-specified vessel,
cert cylinder 160 L > PCDB 150 L but heat exchanger area unknown
AND cert heat loss 2.21 > PCDB 1.86 kWh/day 2 criteria fail
in-use factor = 0.60):
PSR 4.39 / (127.16 W/K × 24.2 K / 1000) 1.4266
η_space,1_interp(1.4266) 235.24
η_water,3_interp(1.4266) 285.13
(206) 0.95 × 235.24 / 100 2.235
(217) 0.60 × 285.13 / 100 1.7108
Worksheet pins (206) = 2.2305 and (217) = 1.7107; the spec-formula
PSR is within 0.4% of the worksheet-implied 1.4321 the residual
propagates through η_space at ~2e-3 (slice 102f decides whether
further PSR refinement is needed to land SAP at 1e-4).
HW fuel kWh after applying η_water:
HW_kwh = output_kwh / η_water = 1502.16 / 1.7108 878.05
Worksheet 877.97 (the cohort's first cert to land HW kWh
within 1 kWh of the dr87 worksheet pin).
"""
# Arrange — cert 0380 golden fixture
import json
from pathlib import Path
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
)
doc = json.loads(
Path(
"/workspaces/model/domain/sap10_calculator/rdsap/tests/"
"fixtures/golden/0380-2471-3250-2596-8761.json"
).read_text()
)
epc = EpcPropertyDataMapper.from_api_response(doc)
# Act
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
# Assert — main heating COP from APM N3.6 (×0.95 in-use factor).
# Tolerance 1e-2 absorbs the ~0.4% PSR-formula residual vs the
# worksheet's implied PSR; slice 102f tightens this if necessary.
assert abs(inputs.main_heating_efficiency - 2.2305) < 1e-2, (
f"main_heating_efficiency: got {inputs.main_heating_efficiency!r}, "
f"want ≈ 2.2305 per SAP 10.2 Appendix N3.6 (0.95 × η_space,1_interp)"
)
# Assert — HW kWh/yr = output_kwh / η_water lands within 1 kWh of
# worksheet's 877.97 (the 1502.16 / 1.7107 quotient).
assert abs(inputs.hot_water_kwh_per_yr - 877.97) < 1.0, (
f"hot_water_kwh_per_yr: got {inputs.hot_water_kwh_per_yr!r}, "
f"want ≈ 877.97 per SAP 10.2 §4 (64) ÷ Appendix N3.7(a) η_water,3"
)
def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3() -> None:
"""SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) define the primary
circuit loss for an indirect cylinder: