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:
Khalim Conn-Kowlessar 2026-06-02 12:50:01 +00:00
parent 6c2053afac
commit 4876140a97
3 changed files with 181 additions and 9 deletions

View file

@ -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),
)

View file

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

View file

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