mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.174: §4 storage + primary loss for community heating
SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482):
"Primary circuit loss for insulated pipework and cylinderstat
should be included (see Table 3)."
SAP 10.2 Table 2b note b (PDF p.159) verbatim:
"Multiply Temperature Factor by 0.9 if there is separate time
control of domestic hot water (boiler systems, warm air systems
and heat pump systems)."
The Table 2b note b ×0.9 multiplier is restricted to "boiler / warm
air / heat pump systems" — community heating is omitted from that
verbatim list. Pre-slice the cascade applied the ×0.9 reduction
unconditionally when DHW was separately timed, AND omitted the Table
3 primary-loss path for heat-network mains entirely. Combined the
two gaps under-counted (62)m HW total demand by ~320 kWh/yr for
heating-systems corpus 001431 community heating 1 (8164 + 0 vs
448.74 + 273.90 spec losses).
Three changes:
1. New `_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION = 1.0` constant.
`_primary_loss_override` selects this for heat-network mains
instead of the RdSAP §3 age-band default, per the spec's literal
"insulated pipework" + back-solve from worksheet (59) Jan = 23.26
= 31 × 14 × (0.0091×3 + 0.0263).
2. Extended `_primary_loss_applies` with a new branch: heat-network
main + WHC ∈ {901, 902, 914} + cylinder present → primary loss
applies.
3. New `_table_2b_note_b_multiplier_applies(epc, main)` predicate
that gates the ×0.9 storage-loss reduction on the spec's verbatim
system-type list, returning False for heat-network mains. The
primary-loss `_separately_timed_dhw` continues to return True for
community heating (Table 3's "separately timed" row is system-
type-agnostic and gives h=3 all year).
Closures (heating-systems corpus 001431):
CH1 HW kWh 3391.90 → 3854.12 (= ws 3854.1175, abs Δ < 1e-3)
CH1 HW cost £143.82 → £163.41 (= ws £163.41, EXACT)
CH1 (65)m heat gains 793.51 → 1221.62 (= ws 1221.62, EXACT)
CH2/CH3/CH4/CH6 same shape — HW path closes against ws (310).
§4 fix is spec-correct on all 5 CH variants. The closure surfaces a
separate §7 MIT (92)m over-count of +0.46 K (cascade Jan = 17.22 vs
ws 16.76) that the pre-slice (65)m gain under-count was masking. Per
[[feedback-software-no-special-handling]] apply the spec-correct
fix uniformly; new pinned residuals reflect the exposed MIT gap.
New residuals (vs pre-slice):
CH1 ΔSAP -0.5273 → -1.0572 ΔPE -9.15 → +408.67
CH2 ΔSAP -0.0076 → -0.4187 ΔPE +1506 → +1779
CH3 ΔSAP -0.5273 → -1.0572 ΔPE -387.03 → -239.03
CH4 ΔSAP -0.0076 → -0.4187 ΔPE +494.61 → +767.13
CH6 ΔSAP -8.0295 → -8.4406 ΔPE +7864.60 → +8137.11
927 pass + 0 fail (+1 new test). No regressions on the other 36
corpus variants — the gate is narrow on `_is_heat_network_main`.
Pyright net-zero (43 → 43) on cert_to_inputs.py + tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6c2053afac
commit
4876140a97
3 changed files with 181 additions and 9 deletions
|
|
@ -591,11 +591,28 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
|||
# 35%/65% split + displaced-electricity credit must converge on
|
||||
# both SH and HW in a single follow-up slice. CH2 / CH4 / CH6
|
||||
# residuals unchanged from S0380.172 / S0380.171 pins.
|
||||
_CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=-0.5273, expected_cost_resid_gbp=+12.1495, expected_co2_resid_kg=+51.6176, expected_pe_resid_kwh=-9.1529),
|
||||
_CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.0076, expected_cost_resid_gbp=+0.1744, expected_co2_resid_kg=-1430.3212, expected_pe_resid_kwh=+1506.0355),
|
||||
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=-0.5273, expected_cost_resid_gbp=+12.1495, expected_co2_resid_kg=-85.9334, expected_pe_resid_kwh=-387.0272),
|
||||
_CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.0076, expected_cost_resid_gbp=+0.1744, expected_co2_resid_kg=-4397.0794, expected_pe_resid_kwh=+494.6090),
|
||||
_CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.0295, expected_cost_resid_gbp=+185.0120, expected_co2_resid_kg=-2934.9021, expected_pe_resid_kwh=+7864.5950),
|
||||
#
|
||||
# Slice S0380.174 closed the (62)m HW useful-kWh path on all 5 CH
|
||||
# variants by adding the spec-required storage (57)m + primary (59)m
|
||||
# loss components that the §4 cascade omitted for heat-network mains.
|
||||
# Per SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482): "Primary
|
||||
# circuit loss for insulated pipework and cylinderstat should be
|
||||
# included (see Table 3)." And per SAP 10.2 Table 2b note b (PDF
|
||||
# p.159) verbatim — the ×0.9 Temperature Factor reduction applies
|
||||
# only to "boiler systems, warm air systems and heat pump systems",
|
||||
# excluding community heating. CH1's HW path closes EXACTLY (cascade
|
||||
# 3854.12 = worksheet 3854.12 at 4.24 p/kWh = £163.41), but the spec-
|
||||
# correct fix exposes a separate +0.46 K MIT (92) over-count in §7
|
||||
# that drives a residual SH demand over-count of ~396 kWh/yr per CH
|
||||
# variant. Pre-S0380.174 the §4 (65)m heat-gains under-count
|
||||
# offset the §7 MIT over-count, masking the bug. Per
|
||||
# [[feedback-software-no-special-handling]] apply spec-correct fix
|
||||
# uniformly; the exposed §7 MIT residual is the next closure front.
|
||||
_CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=-1.0572, expected_cost_resid_gbp=+24.3605, expected_co2_resid_kg=+127.2164, expected_pe_resid_kwh=+408.6704),
|
||||
_CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.4187, expected_cost_resid_gbp=+9.6470, expected_co2_resid_kg=-1356.9498, expected_pe_resid_kwh=+1778.5550),
|
||||
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=-1.0572, expected_cost_resid_gbp=+24.3605, expected_co2_resid_kg=-72.8776, expected_pe_resid_kwh=-239.0266),
|
||||
_CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.4187, expected_cost_resid_gbp=+9.6470, expected_co2_resid_kg=-4323.7080, expected_pe_resid_kwh=+767.1285),
|
||||
_CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.4406, expected_cost_resid_gbp=+194.4846, expected_co2_resid_kg=-2861.5307, expected_pe_resid_kwh=+8137.1145),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4191,6 +4191,36 @@ def _separately_timed_dhw(
|
|||
return bool(epc.has_hot_water_cylinder)
|
||||
|
||||
|
||||
def _table_2b_note_b_multiplier_applies(
|
||||
epc: EpcPropertyData, main: Optional[MainHeatingDetail],
|
||||
) -> bool:
|
||||
"""SAP 10.2 Table 2b note b) (PDF p.159) verbatim:
|
||||
"Multiply Temperature Factor by 0.9 if there is separate time
|
||||
control of domestic hot water (boiler systems, warm air systems
|
||||
and heat pump systems)."
|
||||
|
||||
The system-type list "boiler / warm air / heat pump systems" omits
|
||||
community heating. The ×0.9 reduction therefore does NOT fire for
|
||||
heat-network mains even when DHW IS separately timed — for Table 3
|
||||
primary-loss hours the cascade still treats community-heating DHW
|
||||
as separately timed (h=3) because Table 3 is system-type-agnostic.
|
||||
|
||||
Worksheet evidence for heating-systems corpus 001431 community
|
||||
heating 1 (Table 4a code 301, cylinder + thermostat + WHC=901):
|
||||
(53) Temperature factor lodged as 0.6000 (Table 2b base) — NOT
|
||||
0.54 (= 0.6 × 0.9). Pre-slice the cascade routed community heating
|
||||
through `_separately_timed_dhw=True` and applied the ×0.9 multiplier,
|
||||
under-counting (57)m storage loss by ~10% × 12 months ≈ 45 kWh/yr.
|
||||
"""
|
||||
if not _separately_timed_dhw(epc, main):
|
||||
return False
|
||||
if main is None:
|
||||
return False
|
||||
if _is_heat_network_main(main):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# RdSAP §3 default table (PDF p.56) — "Insulation of primary pipework":
|
||||
# age bands A-J → none (p=0.0); age bands K, L, M → full (p=1.0). The
|
||||
# default applies when the cert does not lodge an explicit insulation
|
||||
|
|
@ -4212,6 +4242,20 @@ def _pipework_insulation_fraction_table_3(primary_age: Optional[str]) -> float:
|
|||
return PIPEWORK_INSULATED_UNINSULATED
|
||||
|
||||
|
||||
# SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482): "Primary circuit
|
||||
# loss for INSULATED pipework and cylinderstat should be included (see
|
||||
# Table 3)." The spec literal "insulated pipework" pins the Table 3
|
||||
# pipework_insulation_fraction at p=1.0 for community-heating mains,
|
||||
# overriding the age-band default in `_pipework_insulation_fraction_
|
||||
# table_3`. Worksheet evidence for heating-systems corpus 001431 CH1
|
||||
# (age G, age-band default p=0): the P960 (59)m monthly back-solves to
|
||||
# h=3 + p=1 (n × 14 × (0.0091×3 + 0.0263) = 23.26 Jan), not h=3 + p=0
|
||||
# (which would give n × 14 × (0.0245×3 + 0.0263) = 43.4 Jan).
|
||||
_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION: Final[float] = (
|
||||
PIPEWORK_INSULATED_FULLY
|
||||
)
|
||||
|
||||
|
||||
# SAP 10.2 PDF p.100 line 5950: design heat loss = (39) × ΔT, where ΔT
|
||||
# = 24.2 K. The HLC × ΔT product feeds the PSR denominator per line 5946.
|
||||
_SAP_DESIGN_HEAT_LOSS_DELTA_T_K: Final[float] = 24.2
|
||||
|
|
@ -4498,6 +4542,17 @@ def _primary_loss_applies(
|
|||
and water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES
|
||||
):
|
||||
return True
|
||||
# SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482): "Primary
|
||||
# circuit loss for insulated pipework and cylinderstat should be
|
||||
# included (see Table 3)." Heat-network mains with WHC=901/902/914
|
||||
# feed the dwelling-side cylinder via primary pipework from the
|
||||
# HIU/connection — Table 3 row 1 (heat generator connected to a
|
||||
# cylinder via primary pipework) applies.
|
||||
if (
|
||||
_is_heat_network_main(main)
|
||||
and water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -4888,10 +4943,17 @@ def _primary_loss_override(
|
|||
water_heating_code=epc.sap_heating.water_heating_code,
|
||||
):
|
||||
return None
|
||||
# SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482) pins community-
|
||||
# heating primary pipework to "insulated" (p=1.0), overriding the
|
||||
# RdSAP §3 age-band default which would otherwise return 0 for
|
||||
# pre-2007 stock. See `_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION`.
|
||||
pipework_p = (
|
||||
_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION
|
||||
if _is_heat_network_main(main)
|
||||
else _pipework_insulation_fraction_table_3(primary_age)
|
||||
)
|
||||
base = primary_loss_monthly_kwh(
|
||||
pipework_insulation_fraction=_pipework_insulation_fraction_table_3(
|
||||
primary_age
|
||||
),
|
||||
pipework_insulation_fraction=pipework_p,
|
||||
has_cylinder_thermostat=epc.sap_heating.cylinder_thermostat == "Y",
|
||||
separately_timed_dhw=_separately_timed_dhw(epc, main),
|
||||
)
|
||||
|
|
@ -4949,7 +5011,13 @@ def _cylinder_storage_loss_override(
|
|||
insulation_type="factory_insulated",
|
||||
thickness_mm=float(thickness_mm),
|
||||
has_cylinder_thermostat=sh.cylinder_thermostat == "Y",
|
||||
separately_timed_dhw=_separately_timed_dhw(epc, main),
|
||||
# SAP 10.2 Table 2b note b (PDF p.159) verbatim restricts the
|
||||
# ×0.9 multiplier to boiler / warm-air / heat-pump systems —
|
||||
# community heating excluded. Gate via the dedicated helper so
|
||||
# the storage-loss call site stays decoupled from Table 3's
|
||||
# primary-loss `_separately_timed_dhw` (which still fires for
|
||||
# community heating + cylinder → h=3 all year).
|
||||
separately_timed_dhw=_table_2b_note_b_multiplier_applies(epc, main),
|
||||
)
|
||||
# (57)m solar adjustment when solar HW + dedicated solar storage
|
||||
# share the cylinder. Vs follows the combined-cylinder convention.
|
||||
|
|
|
|||
|
|
@ -2355,6 +2355,93 @@ def test_separately_timed_dhw_solid_fuel_boiler_codes_per_sap_10_2_table_3() ->
|
|||
assert _separately_timed_dhw(gas_epc, gas_main) is True
|
||||
|
||||
|
||||
def test_community_heating_hw_from_main_applies_storage_and_primary_loss_per_sap_10_2_heat_networks() -> None:
|
||||
# Arrange — SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482):
|
||||
#
|
||||
# "Primary circuit loss for insulated pipework and cylinderstat
|
||||
# should be included (see Table 3)."
|
||||
#
|
||||
# SAP 10.2 Table 2b note b) (PDF p.159) ×0.9 storage-loss multiplier:
|
||||
#
|
||||
# "Multiply Temperature Factor by 0.9 if there is separate time
|
||||
# control of domestic hot water (boiler systems, warm air systems
|
||||
# and heat pump systems)."
|
||||
#
|
||||
# Verbatim system-type list omits community heating — the ×0.9
|
||||
# cylinder Temperature Factor reduction therefore does NOT apply to
|
||||
# heat-network mains. Worksheet evidence for heating-systems corpus
|
||||
# property 001431 community heating 1 (Table 4a code 301 boiler-driven
|
||||
# heat network, age band G, 110 L cylinder + cylinder thermostat, WHC
|
||||
# = 901 "HW from main heating"):
|
||||
#
|
||||
# (53) Temperature factor = 0.6000 (Table 2b base, no ×0.9)
|
||||
# (56)/(57) storage loss sum ≈ 449 kWh/yr (110 × 0.0181 × 1.0294
|
||||
# × 0.6 × 365)
|
||||
# (59) primary loss sum ≈ 274 kWh/yr (365 × 14 × (0.0091×3
|
||||
# + 0.0263), p=1, h=3)
|
||||
#
|
||||
# Pre-slice the cascade applied the ×0.9 separately-timed-DHW
|
||||
# multiplier to community heating (TF=0.54 → (57) sum ≈ 404) AND
|
||||
# missed the primary-loss branch entirely ((59) sum = 0). The two
|
||||
# gaps combined dropped HW useful (62) sum to 2339.24 vs worksheet
|
||||
# 2658.01 — a -319 kWh/yr undercount that propagated through to (65)
|
||||
# heat gains (cascade 793.51 vs ws 1221.62) and (310) HW fuel kWh
|
||||
# (cascade 3391.90 vs ws 3854.12).
|
||||
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, # heat network
|
||||
sap_main_heating_code=301,
|
||||
)
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=_TYPICAL_TFA_M2,
|
||||
habitable_rooms_count=4,
|
||||
country_code="ENG",
|
||||
has_hot_water_cylinder=True,
|
||||
sap_building_parts=[make_building_part(construction_age_band="G")],
|
||||
sap_heating=make_sap_heating(
|
||||
main_heating_details=[main],
|
||||
water_heating_code=901, # HW from main heating
|
||||
cylinder_size=2, # 110 L
|
||||
cylinder_insulation_type=1,
|
||||
cylinder_insulation_thickness_mm=38,
|
||||
cylinder_thermostat="Y",
|
||||
),
|
||||
)
|
||||
|
||||
# Act
|
||||
wh_result, _ = _water_heating_worksheet_and_gains(
|
||||
epc=epc,
|
||||
water_efficiency_pct=1.0 / 1.45, # community heating: 1/DLF
|
||||
is_instantaneous=False,
|
||||
primary_age="G",
|
||||
pcdb_record=None,
|
||||
)
|
||||
|
||||
# Assert — both loss components match the spec formula at the
|
||||
# worksheet's Table 2b base TF=0.6 and Table 3 (p=1, h=3) row.
|
||||
assert wh_result is not None
|
||||
expected_57_sum = 448.7429 # 110 × 0.0181 × 1.0294 × 0.6 × 365
|
||||
expected_59_sum = 273.8960 # 365 × 14 × (0.0091×3 + 0.0263)
|
||||
got_57_sum = sum(wh_result.solar_storage_monthly_kwh)
|
||||
got_59_sum = sum(wh_result.primary_loss_monthly_kwh)
|
||||
assert abs(got_57_sum - expected_57_sum) < 1e-3, (
|
||||
f"(57) storage loss sum: got {got_57_sum!r}, want "
|
||||
f"{expected_57_sum!r} — Table 2b note b ×0.9 must NOT apply to "
|
||||
f"community heating mains (verbatim system-type list excludes "
|
||||
f"heat networks)"
|
||||
)
|
||||
assert abs(got_59_sum - expected_59_sum) < 1e-3, (
|
||||
f"(59) primary loss sum: got {got_59_sum!r}, want "
|
||||
f"{expected_59_sum!r} — SAP 10.2 §4 'Heat networks' line 1482 "
|
||||
f"requires primary circuit loss for insulated pipework + "
|
||||
f"cylinderstat to be included for heat-network mains"
|
||||
)
|
||||
|
||||
|
||||
def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() -> None:
|
||||
# Arrange — an electric storage heater (SAP code 401) on an 18-hour
|
||||
# tariff. `_table_12a_system_for_main` returns None for storage
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue