Slice S0380.146: Table 3 primary loss — Table 4b non-PCDB regular boilers with cylinder

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

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

A Table 4b regular (non-combi, non-CPSU) gas or liquid-fuel boiler
feeding a cylinder is in neither zero-loss list, so primary loss must
apply. Pre-slice the Elmhurst-path fallback in `_primary_loss_applies`
only covered PCDB Table 322 records (S0380.142) — when the cert lodges
a Table 4b code (e.g. oil 1 sap_main_heating_code 127 "Condensing oil
boiler") with no PCDB index and no `main_heating_category` lodgement,
primary loss silently fell through to zero.

This slice extends the Elmhurst-path fallback in `_primary_loss_applies`
to fire when `sap_main_heating_code` is in the Table 4b code range
(101-141) and NOT in the combi/CPSU sub-row exclusion set per Table 3:

  Combi codes:  103, 104, 107, 108, 112, 113, 118, 128, 129, 130
  CPSU codes:   120, 121, 122, 123

Oil 1 worksheet (59)m daily rate = 1.3972 kWh/day uniform = 14 ×
[0.0245 × 3 + 0.0263] (uninsulated pipework, has cylinder thermostat +
separately timed DHW → h=3 winter & summer per Table 3 split). Annual
sum = 365 × 1.3972 ≈ 510 kWh/yr — matches the worksheet's (59) annual.

Cascade impact on heating-systems corpus:
  - oil 1 SAP residual +2.66 → +1.76 (Δ -0.90)
         cost  -£61.24 → -£40.60 (Δ +£20.64)
         CO2   -242.27 → -129.22 (Δ +113.05 kg/yr)
         PE  -1050.49 → -590.02   (Δ +460.47 kWh/yr)

Only the oil 1 variant moves — every other cascade-OK variant either
already routes primary loss via the PCDB Table 322 branch (oil pcdb 1/
2/3, pcdb 1) or via the boiler-category {1,2} branch. The other oil
codes 124/125/126/131/132 + range-cooker codes 133-141 are gated for
free by the same dispatch when their certs surface in future cohorts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 22:20:50 +00:00
parent 1636cfbc83
commit bd193e06fc
3 changed files with 149 additions and 2 deletions

View file

@ -229,7 +229,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.2568, expected_cost_resid_gbp=+5.9163, expected_co2_resid_kg=+11.6231, expected_pe_resid_kwh=+126.0896),
_CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.1181, expected_cost_resid_gbp=+2.7217, expected_co2_resid_kg=+5.6819, expected_pe_resid_kwh=+91.4145),
_CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023),
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+2.6578, expected_cost_resid_gbp=-61.2402, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=-1050.4919),
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+1.7621, expected_cost_resid_gbp=-40.6035, expected_co2_resid_kg=-129.2211, expected_pe_resid_kwh=-590.0236),
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239),
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239),
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+1.1597, expected_cost_resid_gbp=-26.7204, expected_co2_resid_kg=-53.1709, expected_pe_resid_kwh=-271.4351),

View file

@ -3767,6 +3767,28 @@ def _heat_pump_extended_heating_days_per_month(
return None
# SAP 10.2 Table 4b (PDF p.168) sub-rows that are explicitly combi or
# CPSU boilers — i.e. on the Table 3 zero-loss list ("Combi boiler ...
# CPSU ..."). Every other Table 4b code (101-141) is a regular or
# back-boiler / range-cooker boiler that incurs primary circuit loss
# when feeding a hot-water cylinder.
#
# Combi codes:
# 103, 104 — combi gas 1998+ (non-condensing / condensing)
# 107, 108 — combi gas 1998+ permanent pilot
# 112, 113 — combi gas pre-1998 fan-assisted flue
# 118 — combi gas pre-1998 balanced/open flue
# 128, 129, 130 — combi oil (pre-1998 / 1998+ / condensing)
# CPSU codes:
# 120, 121, 122, 123 — CPSU gas (auto/permanent × non/condensing)
_TABLE_4B_COMBI_OR_CPSU_CODES: Final[frozenset[int]] = frozenset({
103, 104, 107, 108, 112, 113, 118,
120, 121, 122, 123,
128, 129, 130,
})
_TABLE_4B_CODE_RANGE: Final[range] = range(101, 142)
def _primary_loss_applies(
main: Optional[MainHeatingDetail],
cylinder_present: bool,
@ -3780,7 +3802,7 @@ def _primary_loss_applies(
Combi boilers, CPSUs, thermal stores within 1.5 m insulated pipe,
direct-acting electric boilers, electric immersion heaters, and
HPs with `hw_vessel_mode = 1` (integral) all skip the loss. For
cohort coverage we model three paths:
cohort coverage we model four paths:
- HP with PCDB record: gate on `hp_record.hw_vessel_mode != 1`
- Boiler (cat 1, 2) with cylinder: primary loss applies (the
cascade's pre-slice-102d behaviour was zero, masking ~516
@ -3793,6 +3815,15 @@ def _primary_loss_applies(
leaves `main_heating_category=None`, so the cascade dispatch
falls through to this branch instead of the boiler-category
branch above).
- Table 4b non-PCDB boiler (sap_main_heating_code 101-141)
with cylinder, when main_heating_category is not lodged:
primary loss applies UNLESS the code is on the Table 3 zero
list (combi sub-rows + CPSU sub-rows per
`_TABLE_4B_COMBI_OR_CPSU_CODES`). Mirror of the PCDB Table
322 branch Elmhurst's heating-systems corpus leaves
`main_heating_category=None` for Table 4b oil 1 (code 127
"Condensing oil boiler" + 110 L cylinder), so the boiler-
category branch above misses it; this branch picks it up.
"""
if not cylinder_present:
return False
@ -3818,6 +3849,18 @@ def _primary_loss_applies(
if main.main_heating_index_number is not None:
if gas_oil_boiler_record(main.main_heating_index_number) is not None:
return True
# Elmhurst-path fallback for Table 4b non-PCDB boilers: a lodged
# `sap_main_heating_code` in the 101-141 gas/liquid-fuel-boiler
# range that is NOT a combi or CPSU sub-row is a regular / back-
# boiler / range-cooker boiler — primary loss applies per Table 3
# row 1 (boiler + cylinder via primary pipework).
code = main.sap_main_heating_code
if (
code is not None
and code in _TABLE_4B_CODE_RANGE
and code not in _TABLE_4B_COMBI_OR_CPSU_CODES
):
return True
return False

View file

@ -3536,3 +3536,107 @@ def test_rdsap_10_table_32_prices_charge_mains_gas_hot_water_at_3p48_per_kwh() -
f"{inputs.hot_water_fuel_cost_gbp_per_kwh!r}, expected 0.0348 per "
f"RdSAP 10 Table 32 mains gas (§19.1 amendment, ADR-0010)"
)
def test_sap_table_3_primary_loss_applies_to_non_pcdb_table_4b_regular_boiler_with_cylinder() -> None:
"""SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss":
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 ...
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 (...) with hot water vessel integral to package
A Table 4b regular (non-combi, non-CPSU) gas or liquid-fuel boiler
feeding a hot-water cylinder is in neither zero-loss list, so
primary loss must apply. Pre-slice the Elmhurst-path fallback in
`_primary_loss_applies` only covered PCDB Table 322 records when
the cert lodges a Table 4b code (e.g. oil 1 sap_main_heating_code
= 127 "Condensing oil boiler") with no PCDB index and no
`main_heating_category` lodgement, primary loss silently fell back
to zero.
Oil 1 worksheet (59)m daily rate = 1.3972 kWh/day uniform =
14 × [0.0245 × 3 + 0.0263] (uninsulated pipework, has cylinder
thermostat + separately timed DHW h=3 winter & summer per Table
3 split). Annual sum = 365 × 1.3972 510 kWh/yr.
"""
# Arrange — oil 1 corpus variant: Table 4b code 127 (condensing
# oil boiler) + 110 L cylinder + cylinder thermostat Yes. No
# PCDB index lodged; main_heating_category None per Elmhurst
# mapper. Same property shape as pcdb 1 fixture (cert 001431).
import re
import subprocess
from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
corpus_oil_1 = (
Path(__file__).parents[4]
/ "sap worksheets/heating systems examples/oil 1"
)
summary_pdf = next(corpus_oil_1.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)
# Pre-conditions reaffirm the diagnostic shape: cylinder lodged,
# Table 4b regular oil boiler code 127, no PCDB index, no category.
main = epc.sap_heating.main_heating_details[0]
assert epc.has_hot_water_cylinder is True
assert main.sap_main_heating_code == 127
assert main.main_heating_index_number is None
assert main.main_heating_category is None
# Act — drive §4 (45..65) via the cascade helper. Primary loss is
# NOT applied today (pre-slice) because the Table 4b code path is
# missing in `_primary_loss_applies`.
wh_result, _ = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=0.84,
is_instantaneous=False,
primary_age="G",
pcdb_record=None,
)
assert wh_result is not None
# Assert — (59)m annual ≈ 510 kWh per Table 3 + the daily-rate
# back-solve above. Worksheet oil 1 line (59) sum = 7 × 43.3132
# + 4 × 41.9160 + 1 × 39.1216 ≈ 509.98 kWh/yr.
expected_primary_annual = 509.98
got_primary_annual = sum(wh_result.primary_loss_monthly_kwh)
assert abs(got_primary_annual - expected_primary_annual) < 1.0, (
f"oil 1 primary loss annual: got {got_primary_annual!r}, "
f"want {expected_primary_annual!r} per SAP 10.2 Table 3 "
f"(Table 4b regular oil boiler + cylinder + uninsulated "
f"primary pipework + cylinder thermostat + separately timed "
f"DHW)"
)