From 353ebe55565ecf7c6baeaaa6b565480a55cf1364 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 10:24:34 +0000 Subject: [PATCH] =?UTF-8?q?Price=20water-only=20heat-network=20DHW=20witho?= =?UTF-8?q?ut=20a=20plant-efficiency=20divisor=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §4.3 (p.24) puts the water-heating efficiency in the Table 12 heat price, and C4 (p.53) scopes the plant efficiency to CO2/PE only, so worksheet (310) = (64) × (305a) × (306) carries no efficiency term. The dwelling-side divisor was applying it a second time. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 36 +++++++++++++++---- .../rdsap/test_cert_to_inputs.py | 29 ++++++++------- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 7ebf4d40e..97adc7ad3 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1187,6 +1187,13 @@ _HEAT_NETWORK_DHW_CHARGING_FACTOR_BY_CODE: Final[dict[int, float]] = { 2306: 1.00, 2308: 1.00, 2309: 1.00, 2310: 1.00, 2312: 1.00, 2314: 1.00, } +# SAP 10.2 Table 4c(3) (PDF p.169) worksheet (305a), "DHW-only system, +# flat rate charging" row — the RdSAP 10 (spec p.59) default for a +# water-ONLY heat network (WHC 950/951/952), whose own charging method is +# never lodged: the dwelling's heating-control code describes the SPACE +# system's charging, not this network's. +_TABLE_4C3_FLAT_RATE_CHARGING_FACTOR: Final[float] = 1.05 + def _heat_network_space_charging_factor(main: Optional[MainHeatingDetail]) -> float: """SAP 10.2 Table 4c(3) (PDF p.169) worksheet (305) — heat-network @@ -8001,13 +8008,28 @@ def cert_to_inputs( * _heat_network_dhw_charging_factor(main) ) 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) + # HW-only heat network (whc 950/951/952). SAP 10.2 §4.3 (p.24): + # "The efficiency for water heating is incorporated in the price of + # heat from a heat network in Table 12" — the dwelling buys + # DELIVERED heat (fuel 51), so the Table 4a plant efficiency must + # NOT divide here as well. C4 (p.53) scopes it: "For heat networks + # not listed in the PCDB, for the calculation of CO2 emissions and + # Primary Energy, the efficiency to be used is that of the heat + # generator" — CO2/PE only, never delivered energy. + # + # C6 "Heat network providing DHW only" (p.54): "Allow for the + # control factor from Table 4c(3), for the distribution loss factor + # as in C3.1" — i.e. worksheet (310) = (64) × (305a) × (306), with + # no efficiency term. Same shape as the WHC-901/902/914 branch + # above, differing only in that (305a) takes the Table 4c(3) + # DHW-only flat-rate default rather than the space system's lodged + # charging code. Fires on the WHC alone — §4.3 keys on the DHW + # source ("Where hot water is provided by a heat network"), not on + # the space main. + water_eff = 1.0 / ( + _heat_network_dlf(primary_age) + * _TABLE_4C3_FLAT_RATE_CHARGING_FACTOR + ) 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 20333b032..85494bbfd 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1399,17 +1399,22 @@ def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None: assert abs(hn_hw_fuel / wh_result.output_kwh_per_yr - 1.41) <= 1e-6 -def test_hot_water_only_heat_network_whc_950_applies_table12c_dlf() -> None: +def test_hot_water_only_heat_network_whc_950_costs_more_fuel_than_boiler_dhw() -> 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). + # (boilers)". The Table 12c distribution loss 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). + # Compared against the SAME dwelling taking DHW from that gas boiler + # (whc 901, Table 4a eff 0.80), the water-only network bills: + # + # 950: q_useful × (306) DLF 1.41 × (305a) 1.05 = q × 1.4805 + # 901: q_useful ÷ 0.80 = q × 1.25 + # + # → the network's delivered fuel is 1.4805/1.25 ≈ 1.18× the boiler's. + # The network pays a distribution loss and a flat-rate charging penalty + # but NO plant-efficiency divisor (SAP 10.2 §4.3 p.24: that efficiency + # is in the Table 12 heat price) — see the absolute-efficiency pin in + # `test_water_only_heat_network_dhw_prices_heat_without_plant_efficiency`. part = make_building_part(construction_age_band="E") gas_main = MainHeatingDetail( has_fghrs=False, @@ -1445,8 +1450,8 @@ def test_hot_water_only_heat_network_whc_950_applies_table12c_dlf() -> None: 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 + # Assert — DLF × flat-rate charging, against the boiler's 1/0.80. + assert abs(hw_network / baseline - (1.41 * 1.05) * 0.80) <= 0.02 def test_water_only_heat_network_dhw_prices_heat_without_plant_efficiency() -> None: