Slice S0380.77: WHC 914 routes primary-loss gate to DHW main; cert 000565 +1175 kWh

SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) defines primary loss
as the loss between "a heat generator (e.g. boiler) connected to a
hot water storage vessel via insulated or uninsulated pipes (the
primary pipework)". The spec keys eligibility off the heat
generator that feeds the cylinder, NOT the space-heating main.

Verbatim spec (Table 3, p.159):

    Primary circuit loss applies when hot water is heated by a heat
    generator (e.g. boiler) connected to a hot water storage vessel
    via insulated or uninsulated pipes (the primary pipework).
    Primary loss is set to zero for the following:
      - Electric immersion heater
      - Combi boiler (including when it is part of a combined heat
        pump and boiler package and provides all the hot water)
      - CPSU (including electric CPSU)
      - Boiler and thermal store within a single casing
      - Separate boiler and thermal store connected by no more than
        1.5 m of insulated pipework
      - Direct-acting electric boiler
      - Heat pump (not combined heat pump and boiler package with a
        non-combi boiler) from PCDB with hot water vessel integral
        to package

    For other cases (indirect cylinders and thermal stores connected
    by uninsulated pipework or more than 1.5 m of insulated
    pipework) the loss in kWh/month is calculated as follows.

    Primary loss = n_m × 14 × [{0.0091 × p + 0.0245 × (1 − p)} × h
                              + 0.0263]

`_water_heating_section` was routing the gate through
`_first_main_heating` (Main 1). Cert 000565 lodges ASHP Main 1 +
gas combi Main 2 + WHC 914 ("from second main system") + 160 L
combined cylinder + cylinder thermostat "N". The cylinder is
heated by Main 2 via uninsulated primary pipework, so the spec
formula applies; the cascade was zeroing (59)m because Main 1's
HP record (or its absence) drove the gate.

Fix: `_primary_loss_override` resolves its `main` via
`_water_heating_main(epc)` (the WHC-914 routing) so the
eligibility check follows the heat generator that physically
incurs the loss. Call site loses the `main` argument; the gate's
internals are unchanged.

Cert 000565 worksheet line (59)m (U985-0001-000565 Block 1):

    128.3772, 115.9536, 128.3772, 124.2360, 128.3772,  41.9160,
     43.3132,  43.3132,  41.9160, 128.3772, 124.2360, 128.3772

sums to 1174.79 kWh/yr. Cascade now matches every month at < 1e-4
kWh under the spec params (uninsulated p=0 from RdSAP §3 age band
A-J default, h_winter=11 / h_summer=3 from "no cylinder
thermostat" row).

Cert 000565 movements at HEAD:
  - sap_score:                   29 ✓ EXACT (unchanged)
  - sap_score_continuous:        29.2905 → 29.1090 (Δ +0.78 → +0.60)
  - space_heating_kwh:           59274.46 → 58725.77 (Δ +266 → −283)
  - main_heating_fuel:           34867.33 → 34544.57 (Δ +157 → −166)
  - hot_water_kwh:                3668.54 →  5154.02 (Δ −86 → +1399)
  - co2_kg:                       6352.61 →  6616.01 (Δ −95 → +168)
  - total_fuel_cost_gbp:          4611.14 →  4627.10 (Δ −69 → −53)
  - ecf:                           5.3073 →   5.3256 (Δ −0.08 → −0.06)

HW pin over-shoots by +1399 as expected — the +1174.79 spec credit
is amplified by the gas combi's 0.88 water efficiency (≈ +1335
kWh) plus cascade coupling (heat-gain feedback into space heating
drops it −549 kWh). The +1399 will be pulled back by the next two
demand-cascade slices:
  - (45)m energy_content over by ~903 kWh (occupancy + shower mix)
  - (56)/(57)m storage-loss over by ~98 kWh + missing solar
    `(57)m = (56)m × (H13 − H12)/H13` adjustment (~298 kWh)

New test pins the full 12-month (59)m tuple for cert 000565's
heating shape at abs=1e-4. Old `_first_main_heating`-based test
(`test_cert_with_hot_water_cylinder_computes_primary_loss_59m_…`)
unchanged — still passes via the WHC-901 fall-through to Main 1.

Test baseline: 547 → 548 pass (new test added) + 9 expected
`test_sap_result_pin[000565-*]` cascade-gap fails. Pyright
net-zero on both touched files (45 baseline = 45 after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 21:04:39 +00:00 committed by Jun-te Kim
parent 69ba7a65bf
commit 9dc60c91d3
2 changed files with 93 additions and 3 deletions

View file

@ -3216,8 +3216,11 @@ def _water_heating_worksheet_and_gains(
storage_loss_override = _cylinder_storage_loss_override(epc, main) storage_loss_override = _cylinder_storage_loss_override(epc, main)
# SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) — primary circuit loss # SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) — primary circuit loss
# (59)m. Only fires for indirect cylinders; HPs with integral # (59)m. Only fires for indirect cylinders; HPs with integral
# vessels and combi boilers are in the spec's zero list. # vessels and combi boilers are in the spec's zero list. The gate
primary_loss_override = _primary_loss_override(epc, main, primary_age) # keys off the *DHW* main (`_water_heating_main`) so WHC 914 ("from
# second main system") routes the primary-loss eligibility check
# to the heat generator that actually feeds the cylinder.
primary_loss_override = _primary_loss_override(epc, primary_age)
# SAP 10.2 Appendix H — solar HW contribution (63c)m. Only fires # SAP 10.2 Appendix H — solar HW contribution (63c)m. Only fires
# when the cert lodges solar HW; orchestrator drives off lodged # when the cert lodges solar HW; orchestrator drives off lodged
# collector geometry + RdSAP 10 §10.11 Table 29 defaults for # collector geometry + RdSAP 10 §10.11 Table 29 defaults for
@ -3260,7 +3263,6 @@ def _water_heating_worksheet_and_gains(
def _primary_loss_override( def _primary_loss_override(
epc: EpcPropertyData, epc: EpcPropertyData,
main: Optional[MainHeatingDetail],
primary_age: Optional[str], primary_age: Optional[str],
) -> Optional[tuple[float, ...]]: ) -> Optional[tuple[float, ...]]:
"""Resolve (59)m for `water_heating_from_cert` from the cert + PCDB """Resolve (59)m for `water_heating_from_cert` from the cert + PCDB
@ -3270,7 +3272,18 @@ def _primary_loss_override(
comes from RdSAP §3 age-band default (no API field); circulation comes from RdSAP §3 age-band default (no API field); circulation
hours h come from Table 3 keyed on cylinder thermostat + separately- hours h come from Table 3 keyed on cylinder thermostat + separately-
timed-DHW lodgement. timed-DHW lodgement.
The gate keys off the DHW main resolved via `_water_heating_main`
(the WHC-914 "from second main system" routing) rather than
`_first_main_heating`. SAP 10.2 §4 line 7700 + Table 3 (PDF p.159)
define primary loss as the loss between the *heat generator that
heats the water* and the storage vessel so the eligibility check
must follow the DHW routing. Cert 000565 (ASHP Main 1 + gas combi
Main 2 + WHC 914 + 160 L cylinder) is the cohort case: Main 1's
HP record is irrelevant; Main 2's combi feeds the cylinder via
primary pipework and incurs the loss.
""" """
main = _water_heating_main(epc)
cylinder_present = bool(epc.has_hot_water_cylinder) cylinder_present = bool(epc.has_hot_water_cylinder)
hp_record: Optional[HeatPumpRecord] = None hp_record: Optional[HeatPumpRecord] = None
if main is not None and main.main_heating_index_number is not None: if main is not None and main.main_heating_index_number is not None:

View file

@ -1802,6 +1802,83 @@ def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3
) )
def test_whc_914_dhw_routes_primary_loss_gate_to_second_main_heating_per_sap_table_3() -> None:
"""SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) primary-loss eligibility
is determined by the heat generator that feeds the hot water storage
vessel, not by the space-heating main. Cert 000565 lodges Main 1 = ASHP
(SAP 224) + Main 2 = gas combi (PCDB 15100) + water_heating_code 914
("from second main system") + 160 L combined cylinder + cylinder
thermostat absent ("N"). The cylinder is heated by Main 2 via uninsulated
primary pipework, so the Table 3 formula applies with p=0 and the
"no cylinder thermostat" hours (h=11 winter, h=3 summer).
Worksheet line (59)m for U985-0001-000565 (Block 1) reads
128.3772, 115.9536, 128.3772, 124.2360, 128.3772, 41.9160,
43.3132, 43.3132, 41.9160, 128.3772, 124.2360, 128.3772 kWh
summing to 1174.79 kWh/yr. The cascade must route the primary-loss gate
to `_water_heating_main` (the WHC-914-resolved DHW main) gating on
`_first_main_heating` mis-keys the gate to Main 1's HP record (or
absent record) and zeroes (59)m even though the gas combi external
cylinder pipework physically incurs the loss.
"""
# Arrange — synthesise cert 000565's heating shape: ASHP Main 1 +
# gas-combi Main 2 servicing DHW via WHC 914 + cylinder lodged with
# no cylinder thermostat. The Elmhurst mapper produces
# `main_heating_category=None` on Main 1 when the cert lodges a SAP
# code without a PCDB Table 362 reference (see
# `_elmhurst_main_heating_category` TODO docstring) — mirror that
# shape here so the current `_first_main_heating` routing fails the
# gate even though Main 2 (the DHW main) plainly carries the loss.
hp_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2206,
main_heating_category=None,
sap_main_heating_code=224,
)
combi_main = _gas_boiler_detail(sap_main_heating_code=102)
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="D")],
sap_heating=make_sap_heating(
main_heating_details=[hp_main, combi_main],
water_heating_code=914, # DHW from second main system
cylinder_size=3, # Medium = 160 L
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=50,
cylinder_thermostat="N", # no cylinder thermostat → h_winter=11
),
)
# Act
wh_result, _ = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=0.88,
is_instantaneous=False,
primary_age="D",
pcdb_record=None,
)
# Assert — full 12-tuple matches cert 000565 worksheet (59)m at 1e-4.
assert wh_result is not None
expected_59m = (
128.3772, 115.9536, 128.3772, 124.2360, 128.3772, 41.9160,
43.3132, 43.3132, 41.9160, 128.3772, 124.2360, 128.3772,
)
got_59m = wh_result.primary_loss_monthly_kwh
for month_idx, (got, want) in enumerate(zip(got_59m, expected_59m)):
assert abs(got - want) < 1e-4, (
f"(59)m month {month_idx + 1}: got {got!r}, want {want!r} per "
f"SAP 10.2 §4 line 7700 + Table 3 (uninsulated p=0, no "
f"cylinder thermostat h=11/3); cert 000565 worksheet line (59)"
)
def test_air_source_heat_pump_main_heating_zeroes_table_3a_combi_loss_per_sap_4_line_7702() -> None: def test_air_source_heat_pump_main_heating_zeroes_table_3a_combi_loss_per_sap_4_line_7702() -> None:
"""SAP 10.2 §4 line 7702 worksheet defines (61)m as 'Combi loss for """SAP 10.2 §4 line 7702 worksheet defines (61)m as 'Combi loss for
each month from Table 3a, 3b or 3c (enter "0" if not a combi each month from Table 3a, 3b or 3c (enter "0" if not a combi