Slice S0380.142: §4 (61)m/(59)m cascade — cylinder presence gates combi=0 + primary loss applies for PCDB Table 322 boilers

SAP 10.2 §4 line 7702 (PDF p.137):

  Combi loss for each month from Table 3a, 3b or 3c (enter '0' if
  not a combi boiler)

SAP 10.2 Table 3 (PDF p.160) zero-loss list for primary circuit loss:

  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 (...) with hot water vessel integral to package

Combi boilers are defined by Table 3's zero-loss list entry: they
provide instantaneous DHW with no storage vessel. A cert that lodges
a hot-water cylinder therefore has a non-combi heat generator —
the cylinder bypasses any instantaneous-DHW capability and the
boiler acts as a regular boiler for the DHW circuit.

Two compounding gaps for PCDB Table 322 (gas/oil boiler) records
with a lodged cylinder:

(a) (61)m combi loss: pre-slice the cascade routed every PCDB record
    through `pcdb_combi_loss_override` regardless of cylinder
    presence. For PCDB regular boilers (subsidiary_type=0, store_
    type=0, separate_dhw_tests=0) this dispatched to Table 3a row 1
    "Instantaneous without keep-hot" — 600 kWh/yr. Cert pcdb 1
    (Potterton KOA PCDB 716 + 110 L cylinder) exposed this: worksheet
    (61)m = 0 ; cascade was lodging 600 kWh/yr keep-hot loss on a
    regular oil boiler.

(b) (59)m primary loss: `_primary_loss_applies` gated on
    `main_heating_category in {1, 2}`. The Elmhurst path leaves
    `main_heating_category=None`, so the gate returned False even
    when the cert lodged a PCDB Table 322 (gas/oil boiler) record +
    a cylinder. Worksheet (59)m sum ~1177 kWh ; cascade was zero.

Fix:

- `_water_heating_worksheet_and_gains` now zeroes combi_loss_override
  whenever `epc.has_hot_water_cylinder` is True (top-level gate
  preceding the `pcdb_combi_loss_override` dispatch). Preserves the
  existing non-cylinder fallback for HP / no-PCDB / community-heat
  certs that lack a main_heating_category lodgement.

- `_primary_loss_applies` extends the Elmhurst-path fallback: when
  `main_heating_index_number` resolves to a PCDB Table 322 record,
  return True (the cert is implicitly a boiler — Table 3 row 1 covers
  any "heat generator (e.g. boiler) connected to a hot water storage
  vessel via insulated or uninsulated pipes").

Corpus impact:

- pcdb 1 (Potterton KOA + cylinder, the only PCDB Table 322 + cylinder
  combination in the corpus): SAP +3.40 → +2.86; cost -£75.68 →
  -£63.22; CO2 -397.02 → -328.74; PE -1601.74 → -1257.97.

- Golden cert 0390-2954-3640-2196-4175 (Firebird oil combi PCDF 9005
  + cylinder): PE -26.37 → -28.50; CO2 -2.55 → -2.75. Combi-loss
  removal (-600 kWh/yr) exceeded the primary-loss gain (~5-10 kWh
  given the cert's insulated pipework + thermostat lodging), so the
  net (62) shifted down. Direction is more spec-correct: the spec
  treats a combi feeding a cylinder as a regular boiler for DHW,
  matching the (61)m=0 + (59)m>0 worksheet behaviour.

Extended handover suite: 885 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 20:53:19 +00:00 committed by Jun-te Kim
parent 23f087258b
commit c7419ca45a
4 changed files with 177 additions and 16 deletions

View file

@ -233,7 +233,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_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),
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+3.3965, expected_cost_resid_gbp=-75.6799, expected_co2_resid_kg=-397.0228, expected_pe_resid_kwh=-1601.7416),
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+2.8556, expected_cost_resid_gbp=-63.2154, expected_co2_resid_kg=-328.7435, expected_pe_resid_kwh=-1257.9712),
# Slice S0380.133 unblocked 10 solid-fuel variants by routing the
# Elmhurst §14.0 "Main Heating EES Code" through the new
# `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` dict. Pre-slice the

View file

@ -3649,7 +3649,7 @@ def _primary_loss_applies(
cylinder_present: bool,
hp_record: Optional[HeatPumpRecord],
) -> bool:
"""SAP 10.2 Table 3 (PDF p.159) zero-loss configurations — primary
"""SAP 10.2 Table 3 (PDF p.160) zero-loss configurations — primary
loss only fires when a cylinder is present AND the lodgement falls
outside the zero list. The cohort path: heat-pump main heating with
a separate (not integral) vessel per the PCDB Table 362 record.
@ -3657,11 +3657,19 @@ 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 two paths:
cohort coverage we model three 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
kWh/yr on certs with cylinders).
- PCDB Table 322 (gas/oil boiler) record with cylinder, when
main_heating_category is not lodged: primary loss applies
(cylinder presence + PCDB boiler = "boiler connected to hot-
water storage vessel" per Table 3 row 1 — the spec category
for this fixture is 1, but the Elmhurst mapper currently
leaves `main_heating_category=None`, so the cascade dispatch
falls through to this branch instead of the boiler-category
branch above).
"""
if not cylinder_present:
return False
@ -3676,7 +3684,18 @@ def _primary_loss_applies(
# Spec p.159: zero for "Heat pump from PCDB with hot water vessel
# integral to package". Vessel mode 1 = integral.
return hp_record.hw_vessel_mode != 1
return main.main_heating_category in {1, 2}
if main.main_heating_category in {1, 2}:
return True
# Elmhurst-path fallback: when the cert lodges a PCDB Table 322
# record (gas/oil boiler) but `main_heating_category` is None, the
# presence of the PCDB boiler record is sufficient evidence that
# the main is a boiler — Table 3 row 1 applies ("hot water is
# heated by a heat generator (e.g. boiler) connected to a hot
# water storage vessel via insulated or uninsulated pipes").
if main.main_heating_index_number is not None:
if gas_oil_boiler_record(main.main_heating_index_number) is not None:
return True
return False
# RdSAP 10 §10.11 Table 29 "Heating and hot water parameters" row
@ -3901,13 +3920,27 @@ def _water_heating_worksheet_and_gains(
daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly,
)
main = _first_main_heating(epc)
# SAP 10.2 §4 line 7702: non-combi main heating → (61)m = 0. Without
# this gate the cascade falls through to `combi_loss_monthly_kwh_table_
# 3a_keep_hot_time_clock()` (600 kWh/yr) on every cert lacking a PCDB
# Table 105 boiler record — including all heat pump certs.
if combi_loss_override is None and not _table_3a_combi_loss_default_applies(
# SAP 10.2 §4 line 7702 (PDF p.137): "Combi loss for each month
# from Table 3a, 3b or 3c (enter '0' if not a combi boiler)". The
# SAP 10.2 Table 3 zero-loss list (PDF p.160) defines a combi boiler
# by its instantaneous-DHW operation: combis don't feed a cylinder
# because their heat exchanger heats DHW on demand. A lodged hot-
# water cylinder therefore means the heat generator is NOT a combi
# — even when the cert lodges a PCDB Table 105 record that would
# otherwise route through `pcdb_combi_loss_override` to a Table 3a/
# 3b/3c row. Cert pcdb 1 (Potterton KOA PCDB 716 + 110 L cylinder)
# exposes this: pre-slice the cascade applied Table 3a row 1
# 600 kWh/yr "keep-hot" loss to a PCDB regular oil boiler.
if epc.has_hot_water_cylinder:
combi_loss_override = zero_monthly
elif combi_loss_override is None and not _table_3a_combi_loss_default_applies(
main
):
# SAP 10.2 §4 line 7702 fallback: non-combi main heating → (61)m
# = 0. Without this gate the cascade falls through to `combi_
# loss_monthly_kwh_table_3a_keep_hot_time_clock()` (600 kWh/yr)
# on every cert lacking a PCDB Table 105 boiler record —
# including all heat pump certs.
combi_loss_override = zero_monthly
# SAP 10.2 §4 lines 7670-7693 + Tables 2/2a/2b — cylinder storage loss
# (56)m. Spec p.135 instructs entering 0 in (47) for instantaneous /

View file

@ -2844,6 +2844,129 @@ def test_sap_9_4_11_no_boiler_interlock_applies_minus_5_pcdb_space_heating_when_
# PCDB Eq D1 step.
def test_sap_4_lines_7700_7702_pcdb_regular_boiler_with_cylinder_zeroes_combi_loss_and_applies_primary_loss() -> None:
"""SAP 10.2 §4 line 7702 (PDF p.137):
Combi loss for each month from Table 3a, 3b or 3c
(enter "0" if not a combi boiler)
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 PCDB regular gas/oil boiler (Table 322) feeding a hot-water
cylinder is in neither of the (61)m / (59)m zero-loss lists:
- cylinder presence not a combi (61)m = 0
- boiler + cylinder + indirect pipework (59)m applies
Pre-slice the cascade routed PCDB 716 (Potterton KOA, a regular
oil boiler) through `pcdb_combi_loss_override` and got 600 kWh/yr
"Table 3a row 1 keep-hot" (the spec's *combi* fall-through),
while `_primary_loss_applies` returned False because the Elmhurst
mapper leaves `main_heating_category=None` (cascade gates primary
on `main_heating_category in {1, 2}`). Both gaps masked one
another: 1177 kWh missing primary + +600 kWh excess combi netted
to ~577 kWh on (62).
This slice introduces the cylinder-presence gate for combi loss
(combi boilers are by definition instantaneous per Table 3
zero-loss list a lodged cylinder means the heat generator is
not a combi) and extends primary-loss eligibility to detect PCDB
Table 322 (gas/oil boiler) records when the cascade can't read
main_heating_category from the cert (Elmhurst path).
"""
# Arrange — pcdb 1 corpus variant: PCDB 716 Potterton KOA + 110 L
# cylinder + Cylinder Stat: No (worksheet shows (61)m all zero
# and (59)m monthly = 128.38 / 115.95 / 128.38 / ...; annual sum
# ≈ 1176.79 kWh/yr).
import re
import subprocess
from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record
corpus_pcdb_1 = (
Path(__file__).parents[4]
/ "sap worksheets/heating systems examples/pcdb 1"
)
summary_pdf = next(corpus_pcdb_1.glob("Summary_*.pdf"))
info = subprocess.run(
["pdfinfo", str(summary_pdf)], capture_output=True, text=True, check=True,
).stdout
pc = int(re.search(r"Pages:\s+(\d+)", info).group(1)) # type: ignore[union-attr]
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)
# Act — drive (45..65) directly via the §4 worksheet helper so the
# combi-loss and primary-loss assertions read the per-month tuples
# the cascade hands to `total_water_heating_demand_monthly_kwh`.
main = epc.sap_heating.main_heating_details[0]
pcdb_record = (
gas_oil_boiler_record(main.main_heating_index_number)
if main.main_heating_index_number is not None
else None
)
wh_result, _ = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=0.53,
is_instantaneous=False,
primary_age="G",
pcdb_record=pcdb_record,
)
assert wh_result is not None
# Assert 1 — (61)m all zero per §4 line 7702 ("enter '0' if not a
# combi boiler"). A lodged cylinder means the heat generator is
# not a combi (combi boilers are instantaneous per Table 3
# zero-loss list).
assert sum(wh_result.combi_loss_monthly_kwh) == 0.0, (
f"pcdb 1 combi loss annual: got {sum(wh_result.combi_loss_monthly_kwh)!r}, "
f"want 0.0 per SAP 10.2 §4 line 7702 (cylinder lodged → main is "
f"not a combi boiler → (61)m = 0)"
)
# Assert 2 — (59)m annual ≈ 1176.79 kWh per Table 3 + RdSAP §S10.11
# Table 29 defaults (uninsulated pipework p=0 for age G; no cylinder
# thermostat → winter h=11, summer h=3). Worksheet pcdb 1 sum =
# Jan 128.38 + Feb 115.95 + ... + Dec 128.38 ≈ 1176.79.
expected_primary_annual = 1176.79
got_primary_annual = sum(wh_result.primary_loss_monthly_kwh)
assert abs(got_primary_annual - expected_primary_annual) < 1.0, (
f"pcdb 1 primary loss annual: got {got_primary_annual!r}, "
f"want {expected_primary_annual!r} per SAP 10.2 Table 3 "
f"(PCDB gas/oil boiler + cylinder + uninsulated primary pipework + "
f"no cylinder thermostat)"
)
def test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693() -> None:
"""SAP 10.2 §4 line 7693 (PDF p.137):

View file

@ -127,8 +127,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0390-2954-3640-2196-4175",
actual_sap=60,
expected_sap_resid=+7,
expected_pe_resid_kwh_per_m2=-26.3749,
expected_co2_resid_tonnes_per_yr=-2.5544,
expected_pe_resid_kwh_per_m2=-28.5027,
expected_co2_resid_tonnes_per_yr=-2.7481,
notes=(
"Detached, TFA 360, age F, Firebird oil combi PCDF 9005 "
"(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + "
@ -141,11 +141,16 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"53 → 54 (resid -7 → -6). Slice S0380.131 flipped heating-oil "
"tariff 7.64 → 5.44 (cert 0240 closed exactly), exposing this "
"cert's previously-masked +13 SAP of cascade gaps: residual "
"swung -6 → +7. The oil-price bug was netting against an "
"opposite-direction gap (cert lodges age F + 360 m² detached "
"+ Firebird PCDF — likely fabric or hot-water cascade). PE / "
"CO2 residuals unchanged by the unit-price flip; remaining "
"SAP residual is a follow-up slice candidate."
"swung -6 → +7. Slice S0380.142 re-routed this cert via the "
"SAP 10.2 §4 line 7702 cylinder-presence gate ((61)m = 0 since "
"the combi feeds a cylinder → not a combi for DHW per Table 3) "
"+ Table 3 row 1 primary loss (PCDB Table 322 boiler + cylinder "
"→ primary loss applies). The combi-loss removal (-600 kWh/yr) "
"exceeded the primary-loss gain → cascade HW fuel dropped "
"~650 kWh; PE residual shifted -26.37 → -28.50, CO2 -2.55 → "
"-2.75. SAP integer unchanged because the cascade was already "
"well above SAP 60 (actual). Remaining residual is a fabric or "
"different §4 driver — follow-up slice candidate."
),
),
_GoldenExpectation(