fix(water-heating): heat-network DHW with no cylinder uses SAP 10.2 HIU default store, not combi keep-hot

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-10 19:59:21 +00:00
parent ba56647401
commit e6543c76ca
2 changed files with 123 additions and 35 deletions

View file

@ -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.

View file

@ -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: