fix(water-heating): heat-network primary loss uses Table 3 h=3 all months

SAP 10.2 Table 3 (PDF p.160) verbatim: "For heat networks apply the
formula above with p = 1.0 and h = 3 for all months." The primary
circulation hours for a heat-network main are fixed at h=3 winter and
summer, independent of the cylinder-thermostat / separate-timing
lodgement that selects the h=5/h=11 rows for boiler systems.

`primary_loss_monthly_kwh` / `primary_circuit_hours_per_day_table_3` gain
a `heat_network` flag (→ (3, 3)); `_primary_loss_override` passes
`_is_heat_network_main(main)`. p=1.0 was already pinned via
`_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION`; only the hours were wrong.

Before, cert 8536 routed through the h=5/3 row because its community
biomass DHW fuel (31) collides with electricity code 31, so
`_separately_timed_dhw` returned False. The Table 3 heat-network rule
overrides that path: 8536 primary loss (59) 335.81 → 273.90, EXACT to
the faithful case-32 worksheet (storage (56) 376.58 also matches 376.94).

API eval within-0.5 57.0% → 56.9% (one offsetting-error cert crosses
out; signed err −0.205 → −0.202). Applied spec-uniformly per the
determinism principle — the heat-network primary hours are unambiguous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-10 20:12:49 +00:00
parent e6543c76ca
commit 00921f71e8
3 changed files with 70 additions and 2 deletions

View file

@ -6154,6 +6154,12 @@ def _primary_loss_override(
pipework_insulation_fraction=pipework_p,
has_cylinder_thermostat=epc.sap_heating.cylinder_thermostat == "Y",
separately_timed_dhw=_separately_timed_dhw(epc, main),
# SAP 10.2 Table 3 (PDF p.160): "For heat networks apply the
# formula above with p = 1.0 and h = 3 for all months." The h=3
# row applies regardless of the thermostat / separate-timing
# lodgement (and so is robust to the community-fuel-as-electric
# collision that would otherwise route DHW to the h=5 row).
heat_network=_is_heat_network_main(main),
)
# SAP 10.2 §12.4.4 (PDF p.36-37): for back-boiler combos summer DHW
# comes from an electric immersion, not from the boiler — the boiler

View file

@ -550,13 +550,21 @@ def primary_circuit_hours_per_day_table_3(
*,
has_cylinder_thermostat: bool,
separately_timed_dhw: bool,
heat_network: bool = False,
) -> tuple[float, float]:
"""SAP 10.2 Table 3 (PDF p.159) — hours of primary circulation per
day, returned as `(winter_hours, summer_hours)`:
"""SAP 10.2 Table 3 (PDF p.159-160) — hours of primary circulation
per day, returned as `(winter_hours, summer_hours)`:
no thermostat (11, 3)
thermostat, not separately timed ( 5, 3)
thermostat, separately timed ( 3, 3)
SAP 10.2 Table 3 (PDF p.160) verbatim: "For heat networks apply the
formula above with p = 1.0 and h = 3 for all months." So a heat-
network main uses h=3 winter and summer regardless of the cylinder-
thermostat / separate-timing lodgement.
"""
if heat_network:
return (3.0, 3.0)
if not has_cylinder_thermostat:
return (11.0, 3.0)
if separately_timed_dhw:
@ -569,6 +577,7 @@ def primary_loss_monthly_kwh(
pipework_insulation_fraction: float,
has_cylinder_thermostat: bool,
separately_timed_dhw: bool,
heat_network: bool = False,
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (59)m via Table 3 (PDF p.159):
(59)m = n_m × 14 × [{0.0091 × p + 0.0245 × (1 p)} × h + 0.0263]
@ -576,6 +585,10 @@ def primary_loss_monthly_kwh(
hours of primary circulation per day (winter / summer split per
`primary_circuit_hours_per_day_table_3`).
`heat_network=True` selects the SAP 10.2 Table 3 (PDF p.160) heat-
network row h=3 for all months regardless of the cylinder-
thermostat / separate-timing lodgement.
Returns 12 monthly values in calendar order Jan..Dec. Callers must
gate this helper on the spec's zero-loss configurations
(combi boilers, integral-vessel HPs, CPSUs, thermal stores 1.5 m
@ -587,6 +600,7 @@ def primary_loss_monthly_kwh(
winter_h, summer_h = primary_circuit_hours_per_day_table_3(
has_cylinder_thermostat=has_cylinder_thermostat,
separately_timed_dhw=separately_timed_dhw,
heat_network=heat_network,
)
return tuple(
n * 14.0 * (

View file

@ -317,6 +317,54 @@ def test_heat_network_dhw_no_cylinder_applies_sap_10_2_hiu_default_store() -> No
assert abs(sum(wh_result.solar_storage_monthly_kwh) - expected_storage_kwh) <= 1.0
def test_heat_network_primary_loss_uses_p1_h3_all_months_per_table_3() -> None:
# Arrange — heat-network DHW (Table 4a code 301, cat 6, WHC 901) with
# the SAP p.24 HIU default store applied. SAP 10.2 Table 3 (PDF p.160)
# verbatim: "For heat networks apply the formula above with p = 1.0 and
# h = 3 for all months." So the primary loss is independent of the
# cylinder-thermostat / separately-timed hours (which would give h=5/3)
# and equals Σ n_m × 14 × (0.0091×1.0×3 + 0.0263) = 273.9 kWh/yr.
# Community biomass fuel (31) collides with electricity code 31, so
# `_separately_timed_dhw` would route to h=5/3 — but the Table 3 heat-
# network rule must override that to h=3 regardless of the DHW fuel.
# This reproduces cert 8536-0929-6500-0815-7206.
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=31,
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,
water_heating_fuel=31,
),
)
# Act
wh_result, _ = _water_heating_worksheet_and_gains(
epc=_apply_heat_network_hiu_default_store(epc),
water_efficiency_pct=100.0,
is_instantaneous=False,
primary_age="A",
pcdb_record=None,
)
# Assert — p=1.0, h=3 all months → 365 × 14 × (0.0091×3 + 0.0263).
assert wh_result is not None
expected_primary_kwh = 365.0 * 14.0 * (0.0091 * 3.0 + 0.0263)
assert abs(sum(wh_result.primary_loss_monthly_kwh) - expected_primary_kwh) <= 1e-6
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