mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
ba56647401
commit
e6543c76ca
2 changed files with 123 additions and 35 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue