From 00921f71e83470a6b8ef212ff6dab2ea027583bd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 10 Jun 2026 20:12:49 +0000 Subject: [PATCH] fix(water-heating): heat-network primary loss uses Table 3 h=3 all months MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 6 +++ .../worksheet/water_heating.py | 18 ++++++- .../rdsap/test_cert_to_inputs.py | 48 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 20ac377c..0f8bdbde 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index 52272d84..0c8ebd5c 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -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 * ( diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index c7e3f2f0..330fe4ce 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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