mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
9950226267
commit
2e5c519861
2 changed files with 194 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue