From 872bc585f7bfef9daed4ef7d831990ab08589d53 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 9 Jun 2026 13:40:17 +0000 Subject: [PATCH] fix(hot-water): apply Table 12c distribution loss to HW-only heat networks (whc 950/951/952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heat-network HW distribution-loss override fired only when the MAIN was a heat network AND whc inherited from main ({901,902,914}). Water-heating-only heat networks (SAP 10.2 Table 4a HW codes 950 boilers / 951 CHP / 952 heat pump) were missed entirely: their Table 4a plant efficiency applied with NO distribution loss, so the HW fuel was under-counted by the Table 12c DLF (1.33-1.48x) → under-cost → over-rate. RdSAP 10 §10 (spec p.36): a water-heating-only heat network is calculated 'for plant efficiency, distribution loss and pumping energy - see Table 12c'. Added a whc-gated branch (independent of the main) applying water_eff = plant_eff / DLF — the per-kWh-generated cost model (q_generated = q_useful x DLF). Fires on the WHC alone so a HW-only heat network with a non-network main (cert 9093, whc 950 + warm-air main 502) is covered. The 3 corpus whc=950 certs all improve in |err|: 2153 +2.62->-0.48 (now within 0.5), 7220 +1.27->-0.97, 9093 +6.04->+3.60 (residual is its warm-air main, a separate cause). within-0.5 56.66->56.79%, within-1.0 71.9->72.2%, mean|err| down; only those 3 certs change. New AAA test pins the DLF scaling fires on the WHC independent of the main. Goldens + gate green, pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 17 +++++++ .../rdsap/test_cert_to_inputs.py | 50 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 4c0941ac..395626d8 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -3092,6 +3092,15 @@ def _pumps_fans_fuel_cost_gbp_per_kwh( # the SAME cascade the main heating uses, including the main_heating_ # category fallback (e.g. heat pumps return 2.30 via category 4). _WATER_INHERIT_FROM_MAIN_CODES: Final[frozenset[int]] = frozenset({901, 902, 914}) +# Hot-water-only heat-network codes — SAP 10.2 Table 4a HW section (PDF +# p.167): 950 boilers / 951 CHP / 952 heat pump. The DHW is supplied by a +# heat network independent of the space-heating system, so RdSAP 10 §10 +# (spec p.36 "water heating only ... for plant efficiency, distribution loss +# and pumping energy — see Table 12c") requires the Table 12c distribution +# loss factor applied on top of the Table 4a plant efficiency. Distinct from +# the inherit-from-main codes: the DLF fires on the WHC regardless of whether +# the *main* is a heat network (e.g. cert 9093, whc 950 + warm-air main 502). +_WATER_HEAT_NETWORK_ONLY_CODES: Final[frozenset[int]] = frozenset({950, 951, 952}) # Water-heating code 901 = "From main heating system" — used by the # SAP 10.2 Appendix D §D2.1 (2) Equation D1 gate, which only applies # when "the boiler provides both space and water heating". @@ -6776,6 +6785,14 @@ def cert_to_inputs( # space heating so the delivered HW kWh reflects q_useful × DLF # = q_generated, matching the per-kWh-generated unit price. water_eff = 1.0 / _heat_network_dlf(primary_age) + elif epc.sap_heating.water_heating_code in _WATER_HEAT_NETWORK_ONLY_CODES: + # HW-only heat network (whc 950/951/952): the Table 4a plant + # efficiency is already in `water_eff`; apply the Table 12c + # distribution loss on top per RdSAP 10 §10 (spec p.36 "water + # heating only ... distribution loss"). q_generated = q_useful × + # DLF, so delivered-per-fuel efficiency = plant_eff / DLF. Fires + # on the WHC alone — the HW network is independent of the main. + water_eff = water_eff / _heat_network_dlf(primary_age) is_instantaneous = epc.sap_heating.water_heating_code in _INSTANTANEOUS_WATER_CODES # §9a Table 11 secondary fraction — pulled forward of §4 so the # post-§8 Equation D1 cascade can derive Q_space = (98c)m × (204) 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 584a36be..a0a777d0 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -459,6 +459,56 @@ def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None: assert hn_hw / gas_hw == pytest.approx(1.41 * 0.80 / 1.0, abs=0.02) +def test_hot_water_only_heat_network_whc_950_applies_table12c_dlf() -> None: + # Arrange — water_heating_code 950 = "hot-water-only heat network + # (boilers)" (SAP 10.2 Table 4a HW section, plant eff 0.80). RdSAP 10 + # §10 (spec p.36) requires the Table 12c distribution loss applied on + # top of the plant efficiency for water-heating-only heat networks, so + # the delivered HW fuel = q_useful × DLF / 0.80. The DLF must fire on + # the WHC ALONE — independent of the main, which here is an ordinary + # gas boiler (NOT a heat network), mirroring cert 9093 (whc 950 + a + # warm-air main). Compare against a non-heat-network baseline at the + # same 0.80 water efficiency (whc 901 from the same gas main): the 950 + # HW fuel must exceed it by exactly the DLF (age E → 1.41). + part = make_building_part(construction_age_band="E") + gas_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + ) + hw_network_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[gas_main], + water_heating_code=950, # hot-water-only heat network + ), + ) + # Baseline: HW from the same gas main (eff 0.80), no heat network → no DLF. + baseline_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[gas_main], + water_heating_code=901, + ), + ) + + # Act + hw_network: float = cert_to_inputs(hw_network_epc).hot_water_kwh_per_yr + baseline: float = cert_to_inputs(baseline_epc).hot_water_kwh_per_yr + + # Assert — the HW-only-heat-network fuel is scaled up by the age-E DLF. + assert abs(hw_network / baseline - 1.41) <= 0.02 + + def test_gas_boiler_main_efficiency_unchanged_by_dlf_override() -> None: # Arrange — regression check: the DLF override only fires for heat- # network main heating. A standard gas boiler (cat=2, code=102) must