Slice S0380.141: §9.4.11 boiler interlock — extend −5pp adjustment to both space-heating efficiency and the PCDB Equation D1 water cascade

SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock":

  For the purposes of the SAP, an interlocked system is one in which
  both the space and stored water heating are interlocked. If either
  is not, the 5% seasonal efficiency reduction is applied to both
  space and water heating; if both are interlocked no reductions are
  made.

Table 4c (PDF p.169-170) lodges -5 for both Space and DHW columns on
the "No boiler interlock — regular boiler" row. Pre-slice the cascade
applied the -5pp adjustment ONLY to the `water_eff` scalar fallback
(`cert_to_inputs.py:4354`) and missed:

  (a) the SH efficiency path (cascade kept the raw PCDB winter eff for
      space heating);
  (b) the PCDB Equation D1 monthly cascade (Eq D1 received raw
      winter/summer values without the -5pp adjustment).

RdSAP §3 (PDF p.57) defines boiler interlock as "Assumed present if
there is a room thermostat and (for stored hot water systems heated
by the boiler) a cylinder thermostat. Otherwise not interlocked."
Cert pcdb 1 (Potterton KOA PCDB 716 + 110 L cylinder + Cylinder Stat:
No) reproduces the pattern: worksheet (210) = 60% = PCDB winter
65 - 5; worksheet (217)m monthly Eq D1 pivots on (winter 60,
summer 48) not (65, 53).

The SH path is further gated on `pcdb_main is not None` because
§9.4.11 only applies to "gas and liquid fuel boilers" — cert 000565
(ASHP Main 1) keeps its raw SH eff. The combi-fed-cylinder DHW path
(cert 000565 WHC 914 to PCDB combi Main 2) continues to receive its
existing -5pp via the `water_pcdb_main` gate (unchanged).

Corpus impact: pcdb 1 SAP residual +6.95 → +3.40; cost -£157.61 →
-£75.68; CO2 -845.81 → -397.02; PE -3135.30 → -1601.74. No other
variant has PCDB main + cylinder + no thermostat, so the other 24
corpus pins are unchanged.

Extended handover suite: 884 pass, 0 fail (was 883 + 1 new AAA test
pinning the §9.4.11 SH eff path).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 20:33:01 +00:00 committed by Jun-te Kim
parent de148743ef
commit 23f087258b
3 changed files with 141 additions and 18 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=+6.9521, expected_cost_resid_gbp=-157.6055, expected_co2_resid_kg=-845.8065, expected_pe_resid_kwh=-3135.2991),
_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),
# 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

@ -50,7 +50,7 @@ Reference: RdSAP 10 specification (10-06-2025); SAP 10.2 specification
from __future__ import annotations
import math
from dataclasses import dataclass
from dataclasses import dataclass, replace
from decimal import ROUND_HALF_UP, Decimal
from typing import Callable, Final, Literal, Optional
@ -4334,24 +4334,53 @@ def cert_to_inputs(
main_category=water_main.main_heating_category if water_main is not None else None,
main_fuel=_main_fuel_code(water_main),
)
# SAP 10.2 Table 4c row "No boiler interlock — regular boiler:
# DHW 5%" (PDF p.169). RdSAP §3 (PDF p.57) defines boiler
# interlock as "Assumed present if there is a room thermostat and
# (for stored hot water systems heated by the boiler) a cylinder
# thermostat. Otherwise not interlocked." A combi-fed cylinder
# routes the boiler as a regular boiler for the DHW circuit (the
# combi's instantaneous-DHW capability is bypassed), so the
# regular-boiler row applies. Note c): the adjustment caps at
# 5pp (no thermostatic control and no boiler interlock do not
# accumulate). Cert 000565 (cylinder lodged + cyl-stat absent +
# WHC 914 to PCDB combi Main 2) closes 79% → 74% — matches
# worksheet (217)m exactly.
if (
# SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock": "For the purposes
# of the SAP, an interlocked system is one in which both the space
# and stored water heating are interlocked. If either is not, the
# 5% seasonal efficiency reduction is applied to BOTH space and
# water heating; if both are interlocked no reductions are made."
# Table 4c (PDF p.169-170) row "No boiler interlock — regular
# boiler" lodges -5 for both Space and DHW columns. Table 4c
# Note c): "These do not accumulate as no thermostatic control or
# presence of a bypass means that there is no boiler interlock."
#
# RdSAP §3 (PDF p.57) defines boiler interlock as "Assumed present
# if there is a room thermostat and (for stored hot water systems
# heated by the boiler) a cylinder thermostat. Otherwise not
# interlocked." A combi-fed cylinder routes the boiler as a
# regular boiler for the DHW circuit (the combi's instantaneous-
# DHW capability is bypassed), so the regular-boiler row applies.
#
# The DHW path adjusts (a) the `water_eff` scalar fallback and
# (b) the PCDB winter/summer efficiencies fed into the Equation D1
# monthly cascade so worksheet (217)m matches (e.g. pcdb 1: PCDB
# 716 winter 65, summer 53 → 60, 48). The SH path adjusts `eff`
# only when the SH main is itself a PCDB gas/oil boiler — §9.4.11
# only applies to "gas and liquid fuel boilers", so cert 000565
# (ASHP Main 1) keeps its raw SH eff. Cert pcdb 1 (PCDB 716 + 110 L
# cylinder + Cylinder Stat: No) closes 65% → 60% — matches
# worksheet (210) exactly. Cert 000565 closes WH 79% → 74%
# unchanged from S0380.79.
no_interlock = (
epc.has_hot_water_cylinder
and epc.sap_heating.cylinder_thermostat != "Y"
and water_pcdb_main is not None
):
)
if no_interlock and water_pcdb_main is not None:
water_eff -= 0.05
pcdb_main_for_eq_d1 = pcdb_main
if no_interlock and pcdb_main is not None:
eff -= 0.05
# Equation D1 reads PCDB winter/summer directly; apply -5pp
# to both so the monthly cascade matches worksheet (217)m.
if (
pcdb_main.winter_efficiency_pct is not None
and pcdb_main.summer_efficiency_pct is not None
):
pcdb_main_for_eq_d1 = replace(
pcdb_main,
winter_efficiency_pct=pcdb_main.winter_efficiency_pct - 5.0,
summer_efficiency_pct=pcdb_main.summer_efficiency_pct - 5.0,
)
# SAP 10.2 Appendix N3.6 + N3.7(a) — when an HP cert lodges a PCDB
# Table 362 record, the cascade replaces the Table 4a defaults with
# APM-interpolated η_space and η_water at the dwelling's PSR.
@ -4532,7 +4561,7 @@ def cert_to_inputs(
wh_output_monthly_kwh=wh_result.output_monthly_kwh,
wh_output_annual_kwh=wh_result.output_kwh_per_yr,
water_efficiency_pct=water_eff,
pcdb_record=pcdb_main,
pcdb_record=pcdb_main_for_eq_d1,
space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh,
)
else:

View file

@ -2750,6 +2750,100 @@ def test_table_4c_no_boiler_interlock_applies_minus_5_dhw_adjustment_when_cylind
)
def test_sap_9_4_11_no_boiler_interlock_applies_minus_5_pcdb_space_heating_when_main_is_gas_oil_boiler_with_cylinder_no_thermostat() -> None:
"""SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock":
For the purposes of the SAP, an interlocked system is one in
which both the space and stored water heating are interlocked.
If either is not, **the 5% seasonal efficiency reduction is
applied to both space and water heating**; if both are
interlocked no reductions are made.
Pre-slice the cascade applied the -5pp adjustment ONLY to the
`water_eff` scalar fallback (line 4354 in `cert_to_inputs.py`) and
missed the space-heating efficiency path entirely; PCDB-Eq-D1 also
received raw winter/summer values without the -5pp adjustment.
Per §9.4.11 the reduction applies to BOTH SH and DHW when interlock
is absent which the corpus pcdb 1 variant (PCDB 716 Potterton KOA
+ cylinder + no cylinder thermostat) makes observable: worksheet
(210) = 60% = PCDB winter 65 - 5 ; worksheet (217)m monthly Eq D1
pivots on (winter 60, summer 48) not (65, 53).
Gate: cylinder present + no cylinder thermostat (RdSAP §3
definition of "no interlock for stored hot water"). SH path
further gated on `pcdb_main is not None` (SH main is a PCDB
gas/oil boiler §9.4.11 only applies to "gas and liquid fuel
boilers"). Cert 000565 (ASHP Main 1) keeps its raw SH eff because
its Main 1 is not a boiler.
"""
# Arrange — pcdb 1 corpus variant: property 001431 with Potterton
# KOA PCDB 716 oil boiler + 110 L cylinder (inaccessible) + cyl-stat
# absent (worksheet "Cylinder Stat: No"). Route the Summary PDF
# through the full extractor → mapper → cascade chain so the test
# exercises real-world cert lodgement.
import re
import subprocess
from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
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 — the cascade computes a per-month water heating efficiency
# via Equation D1 when the PCDB record carries winter+summer effs.
# Calling `cert_to_inputs` exercises the full §4 → Eq D1 → §9a chain.
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
# Assert — space-heating efficiency drops from PCDB winter 65% to
# the -5pp adjusted 60% per §9.4.11. The cascade exposes this via
# `inputs.main_heating_efficiency` (a fraction). Pin abs=1e-4 vs
# spec target 0.60.
expected_sh_eff = 0.60
assert abs(inputs.main_heating_efficiency - expected_sh_eff) < 1e-4, (
f"pcdb 1 main_heating_efficiency: got {inputs.main_heating_efficiency!r}, "
f"want {expected_sh_eff!r} per SAP 10.2 §9.4.11 (PCDB winter 65% "
f"- 5pp no-interlock adjustment); cert lodges PCDB 716 + cylinder "
f"+ no thermostat → no interlock per RdSAP §3."
)
# And Eq D1 monthly cascade lands on the worksheet (217)m values.
# Cert 000565's (217)m matches when winter/summer drop by -5pp; the
# pcdb 1 worksheet target is the same arithmetic with PCDB 716's
# (65, 53) → (60, 48). Summer-only months (Jun-Sep — Q_space = 0)
# collapse to the summer eff: 0.53 - 0.05 = 0.48.
# Indirectly observable via `result.hot_water_kwh_per_yr` matching
# the worksheet (219) target for pcdb 1 = 7063.96 kWh/yr.
# NOTE: full closure needs the other §4 fixes (insulation defaults
# + combi gate + primary loss); this test pins only the SH eff +
# PCDB Eq D1 step.
def test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693() -> None:
"""SAP 10.2 §4 line 7693 (PDF p.137):