Slice S0380.152: SAP 10.2 Table 3 — primary loss for solid-fuel back-boilers

SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss" verbatim:

  "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)."

The spec rule does NOT restrict to Table 4b gas/oil boilers — any
boiler connected to a cylinder via primary pipework incurs the loss.
The cert's `water_heating_code` is the discriminator:

  - WHC=901/902/914 (HW from main heating system) + wet boiler +
    cylinder → primary loss applies (back-boiler / wet boiler heats
    cylinder via primary loop).
  - WHC=903 (HW from a separate electric immersion / secondary) → no
    primary loss even when the main is a wet boiler.

Pre-slice `_primary_loss_applies` only covered Table 4b gas/oil boiler
codes (101-141). Table 4a solid-fuel boiler codes 151-161 (manual /
auto / range-cooker boilers, closed room heater + back-boiler, open
fire + back-boiler, wood pellet + back-boiler) fell through and
primary loss silently went to zero — under-counting §5 (72) water-
heating internal gain by ~74 W cohort-wide for every WHC=901 solid-
fuel back-boiler variant.

Worksheet evidence on the 001431 corpus (all age G, same cylinder):
  - solid fuel 2 (code 158, WHC=901): ws (59) ≈ 505 kWh/yr   → apply
  - solid fuel 3 (code 160, WHC=901): ws (59) ≈ 643 kWh/yr   → apply
  - solid fuel 5 (code 153, WHC=903): ws (59) = 0            → skip
  - solid fuel 4..11 (633/636 non-boilers, WHC=903): skip

The fix:
  - `_primary_loss_applies(...)` gains a `water_heating_code: Optional[int]`
    parameter (default None for back-compat with synthetic tests).
  - New branch after the Table 4b fallback: `_is_wet_boiler_main(main)`
    + `water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES` → True.
  - Call site `_primary_loss_override` passes
    `epc.sap_heating.water_heating_code`.

Heating-systems corpus impact:
  - solid fuel 3 (code 160, WHC=901): +1.31 → +0.30 SAP
                                       PE -918.6 → -214.3 kWh/yr
  - solid fuel 2 (code 158, WHC=901): +2.77 → +2.06 SAP
                                       PE -1241.7 → -754.1 kWh/yr
  - All other variants: unchanged

SF2 doesn't fully close because the worksheet's (59) is winter-only
(0 in summer) but the cascade applies the year-round Table 3 formula
via `_separately_timed_dhw=True` (cylinder + non-electric HW fuel).
Remaining residual is a follow-up — likely a
`_separately_timed_dhw=False` rule for solid-fuel back-boilers (HW
timing tied to the room fire, not separately programmed).

Pyright net-zero (43 → 43). Extended handover suite: 895 → 896 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-01 12:59:08 +00:00
parent fb173cdf3f
commit d4f6ff0f2f
3 changed files with 132 additions and 3 deletions

View file

@ -241,8 +241,8 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# cost / CO2 / PE all route via the correct Table 32 fuel code.
# Remaining residuals are likely heating-system efficiency or
# control-type gaps — separate slices.
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.7654, expected_cost_resid_gbp=-63.7195, expected_co2_resid_kg=+120.3433, expected_pe_resid_kwh=-1241.7357),
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3086, expected_cost_resid_gbp=-30.1525, expected_co2_resid_kg=-327.2043, expected_pe_resid_kwh=-918.6312),
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.0649, expected_cost_resid_gbp=-47.5795, expected_co2_resid_kg=+295.4889, expected_pe_resid_kwh=-754.0879),
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+0.2968, expected_cost_resid_gbp=-6.8392, expected_co2_resid_kg=-74.2162, expected_pe_resid_kwh=-214.2510),
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+0.0850, expected_cost_resid_gbp=-1.9582, expected_co2_resid_kg=-9.3050, expected_pe_resid_kwh=-5.7762),
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604),
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604),

View file

@ -4006,6 +4006,7 @@ def _primary_loss_applies(
main: Optional[MainHeatingDetail],
cylinder_present: bool,
hp_record: Optional[HeatPumpRecord],
water_heating_code: Optional[int] = None,
) -> bool:
"""SAP 10.2 Table 3 (PDF p.160) zero-loss configurations — primary
loss only fires when a cylinder is present AND the lodgement falls
@ -4074,6 +4075,25 @@ def _primary_loss_applies(
and code not in _TABLE_4B_COMBI_OR_CPSU_CODES
):
return True
# Table 4a solid-fuel + electric boilers (codes 151-161 / 191-196):
# the spec rule applies to ANY heat generator connected to a cylinder
# via primary pipework — not just Table 4b gas/oil boilers. The
# discriminator is the cert's `water_heating_code`: 901 / 902 / 914
# (HW from main heating) means the back-boiler / electric boiler
# feeds the cylinder through a primary loop and the loss applies.
# WHC=903 (HW from a separate electric immersion) means the cylinder
# isn't on the boiler's primary loop and no loss applies. Cohort
# evidence (1431 corpus, age G, cylinder thermostat lodged):
# - solid fuel 2 (code 158, WHC=901): ws (59) ≈ 505 kWh/yr → apply
# - solid fuel 3 (code 160, WHC=901): ws (59) ≈ 643 kWh/yr → apply
# - solid fuel 5 (code 153, WHC=903): ws (59) = 0 → skip
# - solid fuel 4..11 (codes 633/636 non-boiler, WHC=903): skip
if (
code is not None
and _is_wet_boiler_main(main)
and water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES
):
return True
return False
@ -4400,7 +4420,12 @@ def _primary_loss_override(
hp_record: Optional[HeatPumpRecord] = None
if main is not None and main.main_heating_index_number is not None:
hp_record = heat_pump_record(main.main_heating_index_number)
if not _primary_loss_applies(main, cylinder_present, hp_record):
if not _primary_loss_applies(
main,
cylinder_present,
hp_record,
water_heating_code=epc.sap_heating.water_heating_code,
):
return None
return primary_loss_monthly_kwh(
pipework_insulation_fraction=_pipework_insulation_fraction_table_3(

View file

@ -3771,6 +3771,110 @@ def test_sap_table_3_primary_loss_applies_to_non_pcdb_table_4b_regular_boiler_wi
)
def test_sap_table_3_primary_loss_applies_to_solid_fuel_back_boiler_with_cylinder_and_whc_901() -> None:
"""SAP 10.2 Table 3 (PDF p.160) — primary circuit loss applies when
hot water is heated by a heat generator (e.g. boiler) connected to
a hot water storage vessel via primary pipework. The spec doesn't
restrict the rule to Table 4b gas/oil boilers Table 4a solid-fuel
boilers (codes 151-161: manual/auto-feed boilers, range cookers,
closed room heaters with back-boiler, open fires with back-boiler)
also feed cylinders via primary pipework when WHC=901 (HW from main
heating).
The discriminator is the lodged `water_heating_code`:
- WHC=901/902/914 (HW from main heating) + wet boiler + cylinder
primary loss applies (the back-boiler's primary loop incurs
the standing loss).
- WHC=903 (HW from a separate immersion or secondary system)
no primary loss, even if the main is a wet boiler the
cylinder isn't connected to the boiler's primary loop.
Worksheet evidence across the 001431 corpus (all age G, same
cylinder + cylinder thermostat lodged):
- solid fuel 2 (code 158, WHC=901): (59) 505 kWh/yr (winter only)
- solid fuel 3 (code 160, WHC=901): (59) 643 kWh/yr (year-round)
- solid fuel 5 (code 153, WHC=903): (59) = 0 (separate immersion)
Pre-slice `_primary_loss_applies` only covered Table 4b codes
101-141 (gas/oil) Table 4a solid-fuel boiler codes 151-161 fell
through and primary loss silently went to zero, leaving the §5 (72)
water-heating internal gain ~74 W lower than the worksheet for
every WHC=901 solid-fuel back-boiler variant. Knock-on: SH demand
~+330 kWh/yr (less internal gain more SH needed) ~+2.3% SAP
over-shoot pattern documented in the Cluster B audit (electric 5
has the same +2.3% pattern but a separate cause).
"""
# Arrange — solid fuel 2 corpus variant: Table 4a code 158
# (anthracite closed room heater with back-boiler) + 110 L cylinder +
# cylinder thermostat Yes + WHC=901. No PCDB index lodged.
import re
import subprocess
from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
corpus_sf2 = (
Path(__file__).parents[4]
/ "sap worksheets/heating systems examples/solid fuel 2"
)
summary_pdf = next(corpus_sf2.glob("Summary_*.pdf"))
info = subprocess.run(
["pdfinfo", str(summary_pdf)], capture_output=True, text=True, check=True,
).stdout
pc_match = re.search(r"Pages:\s+(\d+)", info)
assert pc_match is not None
pc = int(pc_match.group(1))
pages: list[str] = []
for i in range(1, pc + 1):
layout = subprocess.run(
["pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(summary_pdf), "-"],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(notes)
main = epc.sap_heating.main_heating_details[0]
assert epc.has_hot_water_cylinder is True
assert main.sap_main_heating_code == 158
assert epc.sap_heating.water_heating_code == 901
# Act — drive §4 (45..65) via the cascade helper. Cascade post-slice
# should now apply primary loss (Table 4a solid-fuel boiler + WHC=901
# + cylinder → loss applies).
wh_result, _ = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=0.65,
is_instantaneous=False,
primary_age="G",
pcdb_record=None,
)
assert wh_result is not None
# Assert — primary loss must be non-zero (the worksheet's (59) sums
# to ~505 kWh/yr for SF2). The cascade uses (h=3, h=3) per
# `_separately_timed_dhw=True` so the cascade output is the
# year-round Table 3 formula = 31×14×(0.0245×3 + 0.0263) ≈ 43.3 kWh
# per 31-day month ≈ 510 kWh/yr — within ~5 kWh of the worksheet.
annual_primary = sum(wh_result.primary_loss_monthly_kwh)
assert annual_primary > 400.0, (
f"solid fuel 2 (Table 4a code 158, WHC=901) primary loss "
f"annual = {annual_primary:.2f} kWh/yr; pre-slice the cascade "
f"returned 0 because `_primary_loss_applies` only covered "
f"Table 4b codes. Per SAP 10.2 Table 3 the spec rule applies "
f"to any boiler connected to a cylinder via primary pipework."
)
def test_sap_table_4f_circulation_pump_dispatches_per_central_heating_pump_age() -> None:
"""SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and
other auxiliary uses" — Heating system circulation pump rows: