From 2e5c51986106bb9210d3195ceecd7b4ec6450a23 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 12:34:00 +0000 Subject: [PATCH] Slice 102e: heat-pump APM efficiencies via SAP 10.2 Appendix N3.6 / N3.7(a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../sap10_calculator/rdsap/cert_to_inputs.py | 133 ++++++++++++++++++ .../rdsap/tests/test_cert_to_inputs.py | 61 ++++++++ 2 files changed, 194 insertions(+) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 2fc35875..a6614270 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 619cd93a..f86d13d0 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -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: