From e6543c76ca2868e7aca2c8078df7b9da71ea6f21 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 10 Jun 2026 19:59:21 +0000 Subject: [PATCH] fix(water-heating): heat-network DHW with no cylinder uses SAP 10.2 HIU default store, not combi keep-hot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A heat-network main with DHW from the network and no lodged cylinder was billed the Table 3a keep-hot 600 kWh/yr combi loss (cat 6 sat in `_TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES`). A heat network is not a combi boiler — SAP 10.2 §4 line 7702 says combi loss is 0 for non-combi systems. SAP 10.2 p.24 "Heat networks" (c): when neither a PCDB Heat Interface Unit nor a lodged cylinder applies, "a measured loss of 1.72 kWh/day should be used, corrected using Table 2b. This is equivalent to a cylinder of 110 litres and a factory insulation thickness of 50 mm". RdSAP 10 Table 29 (p.56): a cylinder thermostat is assumed present when DHW is from a heat network (Table 2b temperature factor 0.60). New `_apply_heat_network_hiu_default_store` rebinds the 110 L / 50 mm- factory store (thermostat present) onto a heat-network DHW cert with no cylinder and no PCDB index, mirroring `_apply_rdsap_no_water_heating_ system_default`. The injected store routes storage loss (56) ≈ 376.7 kWh/yr (= 1.72 × 0.60 × 365) + primary loss (59) through the existing machinery and zeroes the combi (61) loss via the has_hot_water_cylinder gate. Verified against the user's faithful case-32 worksheet: storage (56) 376.58 vs worksheet 376.94. Cert 8536 storage 0→376.6, combi 600→0. API eval within-0.5 56.8% → 57.0%; signed err −0.218 → −0.205. Reworked `test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh` to assert the DLF scaling directly (fuel ÷ §4 output = 1.41) since the old two-cert baseline premise (both combi-600) no longer holds. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 55 ++++++++++ .../rdsap/test_cert_to_inputs.py | 103 ++++++++++++------ 2 files changed, 123 insertions(+), 35 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 94d5dd44..20ac377c 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -5151,6 +5151,56 @@ def _apply_rdsap_no_water_heating_system_default( return replace(epc, has_hot_water_cylinder=True, sap_heating=sap_heating) +# SAP 10.2 p.24 "Heat networks" (c): the default storage loss for heat- +# network DHW with no PCDB HIU and no lodged cylinder is "equivalent to a +# cylinder of 110 litres and a factory insulation thickness of 50 mm". +_HEAT_NETWORK_HIU_DEFAULT_INSULATION_MM: Final[int] = 50 + + +def _apply_heat_network_hiu_default_store( + epc: EpcPropertyData, +) -> EpcPropertyData: + """SAP 10.2 p.24 "Heat networks" (c) — when domestic hot water is + provided by a heat network and neither a PCDB Heat Interface Unit nor + a lodged hot-water cylinder applies: + + "a measured loss of 1.72 kWh/day should be used, corrected using + Table 2b. This is equivalent to a cylinder of 110 litres and a + factory insulation thickness of 50 mm." + + RdSAP 10 Table 29 (PDF p.56) assumes a cylinder thermostat is present + when DHW is from a heat network → Table 2b base temperature factor + 0.60 (no ×1.3 absent-thermostat penalty). + + Mirrors `_apply_rdsap_no_water_heating_system_default`: rebinds the + 110 L / 50 mm-factory store onto `epc` (keeping the community water- + heating code + fuel) so every downstream §4 gate — storage loss (56), + primary loss (59) and combi-loss suppression — sees the spec default. + A heat network is NOT a combi boiler, so the injected cylinder zeroes + the Table 3a keep-hot (61) loss via the `has_hot_water_cylinder` gate + in `_water_heating_worksheet_and_gains`; without this the cascade + wrongly billed a 600 kWh/yr keep-hot loss on heat-network DHW. + + No-op (returns `epc` unchanged) unless the DHW main is a heat network, + no cylinder is lodged, and the network is not in the PCDB (an indexed + network uses the PCDB HIU loss per branch (a)).""" + if epc.has_hot_water_cylinder: + return epc + dhw_main = _water_heating_main(epc) + if not _is_heat_network_main(dhw_main): + return epc + if dhw_main is not None and dhw_main.main_heating_index_number is not None: + return epc + sap_heating = replace( + epc.sap_heating, + cylinder_size=_CYLINDER_SIZE_CODE_NORMAL_110L, + cylinder_insulation_type=_CYLINDER_INSULATION_TYPE_FACTORY, + cylinder_insulation_thickness_mm=_HEAT_NETWORK_HIU_DEFAULT_INSULATION_MM, + cylinder_thermostat="Y", + ) + return replace(epc, has_hot_water_cylinder=True, sap_heating=sap_heating) + + # SAP 10.2 Table 4a solid-fuel boiler sub-rows (PDF p.163) — independent # boilers (151, 153, 155, 159), open-fire + back boiler (156), closed # room heater + back boiler (158), range cooker boiler (160, 161). @@ -6601,6 +6651,11 @@ def cert_to_inputs( # means every downstream helper sees the spec default; the demand # cascade reuses this entry point so it is covered too. epc = _apply_rdsap_no_water_heating_system_default(epc) + # SAP 10.2 p.24 "Heat networks" (c) — heat-network DHW with no PCDB + # HIU and no lodged cylinder uses a default 110 L / 50 mm-factory + # store (storage + primary loss, no combi keep-hot). Rebinds before + # the §4 cascade so every loss gate sees the spec default. + epc = _apply_heat_network_hiu_default_store(epc) dim = dimensions_from_cert(epc) # SAP §3 heat transmission + §2 ventilation cascades — see the # respective `_from_cert` helpers for cert→inputs mapping rules. 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 e528ec42..c7e3f2f0 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -48,6 +48,7 @@ from domain.sap10_calculator.exceptions import ( ) from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, + _apply_heat_network_hiu_default_store, # pyright: ignore[reportPrivateUsage] _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] _heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage] _heat_network_community_fuel_code, # pyright: ignore[reportPrivateUsage] @@ -268,6 +269,54 @@ def test_heat_network_dlf_uses_first_non_empty_building_part_age_band() -> None: assert abs(inputs.main_heating_efficiency - 1.0 / 1.20) <= 1e-9 +def test_heat_network_dhw_no_cylinder_applies_sap_10_2_hiu_default_store() -> None: + # Arrange — community heat-network main (Table 4a code 301, cat 6), + # DHW from the network (WHC 901), NO cylinder lodged. SAP 10.2 p.24 + # "Heat networks" (c): when neither a PCDB HIU nor a lodged cylinder + # applies, "a measured loss of 1.72 kWh/day should be used, corrected + # using Table 2b. This is equivalent to a cylinder of 110 litres and a + # factory insulation thickness of 50 mm". RdSAP 10 Table 29: heat- + # network DHW → cylinder thermostat assumed present → Table 2b temp + # factor 0.60. A heat network is NOT a combi boiler, so the Table 3a + # keep-hot combi loss must NOT apply. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, # mains gas (community) + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=6, + sap_main_heating_code=301, + ) + part = make_building_part(construction_age_band="A") + 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=[main], water_heating_code=901, + ), + ) + + # Act — preprocess the HIU default store, then run the §4 cascade. + epc_with_store = _apply_heat_network_hiu_default_store(epc) + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc_with_store, + water_efficiency_pct=100.0, + is_instantaneous=False, + primary_age="A", + pcdb_record=None, + ) + + # Assert — combi keep-hot loss suppressed; storage loss = the SAP p.24 + # default 1.72 kWh/day × Table 2b 0.60 × 365 ≈ 376.7 kWh/yr. + assert wh_result is not None + assert sum(wh_result.combi_loss_monthly_kwh) == 0.0 + expected_storage_kwh = 1.72 * 0.60 * 365.0 + assert abs(sum(wh_result.solar_storage_monthly_kwh) - expected_storage_kwh) <= 1.0 + + def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None: # Arrange — heat-network main (Table 4a code 301 = community heating, # category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution @@ -432,15 +481,13 @@ def test_heat_network_distribution_electricity_none_for_individual_main() -> Non def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None: # Arrange — when main heating is a heat network AND water heating - # inherits from main (water_heating_code=901), the HW also incurs - # the network's distribution losses. The water efficiency must be - # overridden to 1/DLF so that the delivered HW kWh (and therefore - # cost/CO2/PE applied to it) reflects q_useful × DLF. - # Compare against a gas-boiler baseline at the same age band: the - # heat-network HW kWh should be greater by the ratio 0.80/(1/DLF) = - # DLF × 0.80 = 0.80 × 1.41 = 1.128 (i.e. ~13% higher) since the - # non-heat-network baseline inherits water efficiency 0.80 from - # the heat-network main's pre-DLF efficiency. + # inherits from main (water_heating_code=901), the HW also incurs the + # network's distribution losses: the water efficiency is overridden to + # 1/DLF, so the delivered HW FUEL kWh = q_useful (the §4 output) × DLF. + # Asserted directly as fuel ÷ output to isolate the DLF scaling from + # the §4 loss structure (the SAP 10.2 p.24 HIU default store now + # supplies storage + primary loss in q_useful — see + # `_apply_heat_network_hiu_default_store`). part = make_building_part(construction_age_band="E") hn_main = MainHeatingDetail( @@ -463,34 +510,20 @@ def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None: ), ) - # Comparable gas-boiler baseline that ALSO inherits a 0.80 water - # efficiency through `water_heating_code=901` for direct comparison. - # Use sap_main_heating_code = None so cascade returns 0.80 default. - 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, - ) - gas_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 fuel kWh from the full cascade, and the §4 output (q_useful) + # from the worksheet on the same (HIU-store-applied) epc. + hn_hw_fuel = cert_to_inputs(hn_epc).hot_water_kwh_per_yr + wh_result, _ = _water_heating_worksheet_and_gains( + epc=_apply_heat_network_hiu_default_store(hn_epc), + water_efficiency_pct=100.0, + is_instantaneous=False, + primary_age="E", + pcdb_record=None, ) - # Act - hn_hw = cert_to_inputs(hn_epc).hot_water_kwh_per_yr - gas_hw = cert_to_inputs(gas_epc).hot_water_kwh_per_yr - - # Assert — DLF (1.41) for age E × 0.80 baseline / (1/1.41) HN = 1.128. - assert hn_hw / gas_hw == pytest.approx(1.41 * 0.80 / 1.0, abs=0.02) + # Assert — delivered HW fuel = q_useful × DLF; age E → Table 12c 1.41. + assert wh_result is not 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: