From 72ef0f0e7b60a9b60edbe5a8a199df359abcd4f3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 18 Jun 2026 13:44:51 +0000 Subject: [PATCH] fix(water): don't apply heat-pump water SCOP to a separate immersion (SAP N3.7a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a heat-pump cert lodges a PCDB Table 362 record, the APM override set BOTH the space efficiency (N3.6) and the water efficiency (N3.7a) from the heat pump unconditionally. But the PCDB η_water applies only when the DHW is heated BY the heat pump (water-heating code "from main": 901/902/914). A separate electric immersion (WHC 903) heats the water at 100% regardless of the space system, so applying the HP's water SCOP (187.5% × 0.6 in-use = 112.5%) under-counted the immersion's hot-water fuel. Gate the η_water override on the DHW-from-main codes; a separate immersion keeps its own 100% efficiency. Space η_space still always uses the APM value (the heat pump is the space main). Worksheet-validated to 1e-4 on simulated case 45 (HP space + WHC-903 immersion): water fuel (62) 1893.57 -> 2130.2639, total cost (255) 619.7433, CO2 692.13 — all matching the P960 exactly; SAP 60.53 -> rounds to the worksheet's 61. RdSAP-21.0.1 corpus unchanged (no HP+WHC903 certs in it). Pinned in test_cert_to_inputs (immersion fuel is main-independent). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 16 +++++- .../rdsap/test_cert_to_inputs.py | 53 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index c8a59fcf..a271f8f5 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -7216,7 +7216,21 @@ def cert_to_inputs( epc=epc, ) if apm_efficiencies is not None: - eff, water_eff = apm_efficiencies + # η_space (N3.6) always replaces the Table 4a default — the heat + # pump is the space main. η_water (N3.7a) applies ONLY when the DHW + # is actually heated by that main (WHC "from main": 901/902/914). A + # separate electric immersion (WHC 903) or other independent DHW + # source keeps its own water efficiency (immersion = 100%), not the + # HP's water SCOP — otherwise a HP-space + immersion-DHW dwelling + # under-counts its hot-water fuel (case 45: water 2130 -> 1894 kWh, + # +1.5 SAP, because 187.5% × 0.6 in-use = 112.5% was applied where + # the worksheet (216) uses 100%). + eff, apm_water_eff = apm_efficiencies + if ( + epc.sap_heating.water_heating_code + in _WATER_INHERIT_FROM_MAIN_CODES + ): + water_eff = apm_water_eff if ( _is_heat_network_main(main) and epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 4531c08d..e3e99e9e 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -38,6 +38,7 @@ from datatypes.epc.domain.epc_property_data import ( from domain.sap10_ml.tests._fixtures import ( make_building_part, make_floor_dimension, + make_main_heating_detail, make_minimal_sap10_epc, make_sap_heating, make_window, @@ -7581,3 +7582,55 @@ def test_index_less_mev_applies_table_4g_note_3_default_data_iuf() -> None: # 2.5x the raw-0.8 value, not the raw default. assert fan_kwh > 0.0 assert abs(fan_kwh - expected) <= 1e-9 + + +def test_heat_pump_water_scop_not_applied_to_separate_immersion_dhw() -> None: + # Arrange — SAP 10.2 Appendix N3.7(a): a heat pump's PCDB water + # efficiency (η_water) applies to the DHW ONLY when the cylinder is + # heated BY the heat pump. A separate electric immersion (WHC 903) heats + # the water at 100% regardless of the space-heating system, so the HP's + # water SCOP must NOT leak onto it. Invariant: a WHC-903 immersion's + # hot-water fuel is INDEPENDENT of the main — a heat-pump main and a gas- + # boiler main yield the SAME immersion fuel (both 100%, no primary loss). + # Before the fix the APM override set η_water = 187.5% × 0.6 in-use = + # 112.5% on the HP cert, under-counting its immersion fuel. Worksheet- + # validated on simulated case 45: water (62) = 2130.26 kWh at η_water=100%, + # not 2130.26 / 1.125 = 1893.57. + hp_main = make_main_heating_detail( + main_fuel_type=29, # electricity + heat_emitter_type=1, + main_heating_category=4, # heat pump + main_heating_index_number=100053, # PCDB Table 362 ASHP (ECODAN 5 kW) + main_heating_data_source=1, + ) + boiler_main = make_main_heating_detail( + main_fuel_type=26, # mains gas + heat_emitter_type=1, + main_heating_category=2, # gas boiler + sap_main_heating_code=102, + ) + + def _immersion_epc(main: MainHeatingDetail) -> EpcPropertyData: + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_heating=make_sap_heating( + main_heating_details=[main], + water_heating_code=903, # separate electric immersion + water_heating_fuel=30, # standard electricity + cylinder_size=2, + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=25, + ), + ) + + # Act + hp_fuel = cert_to_inputs(_immersion_epc(hp_main)).hot_water_kwh_per_yr + boiler_fuel = cert_to_inputs(_immersion_epc(boiler_main)).hot_water_kwh_per_yr + + # Assert — the immersion DHW fuel is identical whether the space main is a + # heat pump or a gas boiler (the HP water SCOP does not apply to it). + assert hp_fuel > 0.0 + assert abs(hp_fuel - boiler_fuel) <= 1e-6