Slice S0380.165: §9.4.11 boiler-interlock -5pp applies AFTER Eq D1, not before

SAP 10.2 §9.4.11 (PDF p.30): "The efficiency of gas and liquid fuel
boilers for both space and water heating is reduced by 5% if the
boiler is not interlocked for space and water heating."

S0380.141 had subtracted the -5pp from BOTH `Pwinter` and `Psummer`
PCDB / Table 4b seasonal efficiencies BEFORE running the SAP 10.2
Appendix D §D2.1 (2) Equation D1 monthly cascade. The Elmhurst P960
worksheet for `pcdb 1` (PCDB 716 oil boiler, Pwinter 65 / Psummer 53,
Cylinder Stat=No → no interlock) shows the -5pp is applied to the
η_water,monthly OUTPUT of Eq D1, NOT to its inputs. The two
interpretations diverge because Eq D1's reciprocal weighting (1/η_w
and 1/η_s) is non-linear in η.

Worked example for pcdb 1 Jan (Q_space=1409.77, Q_water=387.86):

  Old cascade:  Eq D1(60, 48, …) = 56.9292 %       (off −0.04 pp)
  Worksheet:    Eq D1(65, 53, …) = 61.9725 %
                                    −5pp = 56.9725 %  ≡ (217)m_jan ✓

Across all 12 months the post-Eq-D1 form matches worksheet (217)m to
1e-4 every month. Cascade HW kWh: 7068.41 → 7063.96 (= worksheet (219)
total exactly), Δ −4.45 kWh.

The spec text "reduced by 5%" does not explicitly state pre- vs post-
Eq D1 ordering. Per [[feedback-software-no-special-handling]] mirror
the Elmhurst engine — the worksheet output is unambiguous.

Changes:
  - `_apply_water_efficiency` gains a `interlock_penalty_pp: float = 0.0`
    kwarg. Eq D1 branch runs on raw (Pwinter, Psummer), then subtracts
    `interlock_penalty_pp / 100` from each monthly efficiency before
    dividing.
  - Caller (`cert_to_inputs` orchestrator) now passes the raw seasonal
    efficiencies in `eq_d1_winter_summer_pct` + the penalty separately.
    The pre-Eq-D1 `eq_d1_winter_summer_pct[0] -= 5` block is removed.
  - SH-side `eff -= 0.05` (line 5349) is unchanged — the SH cascade
    doesn't go through Eq D1, just `(98c)m / eff_sh`.

Closures `pcdb 1`:
  ΔSAP_c −0.0108 → +0.0000 (1e-4)
  Δcost  +£0.24  → +£0.0000
  ΔCO2   +1.33   → +0.0000
  ΔPE    +5.70   → −0.0000

No regressions on the other 25 cascade-OK variants — the gate is
`no_interlock AND eq_d1_winter_summer_pct is not None`, which fires
only when Cylinder Stat=No on a gas/oil boiler cert. The 6 Elmhurst
U985 cohort + cohort-2 Elmhurst fixtures all lodge Cylinder Stat=Yes
(interlock present) → no penalty fires; cohort-1 ASHP certs lodge no
cylinder thermostat at all but route through Appendix N3 instead of
Eq D1. 38 cohort-2 + 9 ASHP golden fixtures all PASS unchanged.

The 41-variant heating-systems corpus cascade-OK tier is now CLOSED:
all 25 variants SAP / cost / CO2 / PE EXACT vs Elmhurst worksheet at
abs < 1e-3 (most < 1e-4). Σ|ΔSAP_c| = 0.0001 (= floating-point noise).

Tests:
  - test_apply_water_efficiency_applies_interlock_penalty_after_equation_d1
  - test_apply_water_efficiency_interlock_penalty_zero_keeps_raw_eq_d1

911 pass / 0 fail; pyright net-zero 43 → 43.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 09:44:10 +00:00
parent df4d271d3b
commit 3de52bcb90
3 changed files with 149 additions and 13 deletions

View file

@ -386,6 +386,25 @@ class _CorpusExpectation:
# cascade-OK variants now SAP / cost / CO2 / PE EXACT on every metric.
# Documented at `SAP_CALCULATOR.md §8.2` with the explicit single-cert
# caveat (heating-systems corpus has only one §12.4.4 fixture).
#
# Slice S0380.165 closed the LAST sub-tolerance gap: `pcdb 1` (Δ0.0108
# SAP / +£0.24 / +1.33 CO2 / +5.70 PE → all ±0.0000 within 1e-4). SAP
# 10.2 §9.4.11 (PDF p.30) "boiler interlock": "The efficiency of gas and
# liquid fuel boilers for both space and water heating is reduced by 5%
# if the boiler is not interlocked." S0380.141 had subtracted the 5pp
# from BOTH `Pwinter` and `Psummer` BEFORE running the SAP 10.2
# Appendix D §D2.1 Equation D1 monthly cascade. The Elmhurst worksheet
# for pcdb 1 (PCDB 716 oil boiler, Pwinter 65 / Psummer 53, Cylinder
# Stat=No → no interlock) shows the 5pp is applied to the η_water,
# monthly OUTPUT of Eq D1, NOT to its inputs — Eq D1's reciprocal
# weighting (1/η_winter and 1/η_summer) is non-linear in η, so the two
# interpretations diverge subtly. Worked example for pcdb 1 Jan
# (Q_space=1409.77, Q_water=387.86):
# Old cascade: Eq D1(60, 48, …) → 56.93% (off 0.04 pp vs worksheet)
# Worksheet: Eq D1(65, 53, …) → 61.97%, 5pp → 56.97% ✓
# Across all 12 months the post-Eq-D1 form matches worksheet (217)m at
# 1e-4. Cascade HW kWh 7068.41 → 7063.96 (= worksheet (219) total) Δ
# 4.45 kWh propagates the closure.
_EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000),
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
@ -401,7 +420,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000),
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=-0.0108, expected_cost_resid_gbp=+0.2420, expected_co2_resid_kg=+1.3254, expected_pe_resid_kwh=+5.6974),
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000),
# 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

@ -4818,6 +4818,7 @@ def _apply_water_efficiency(
water_efficiency_pct: float,
eq_d1_winter_summer_pct: Optional[tuple[float, float]],
space_heating_monthly_useful_kwh: tuple[float, ...],
interlock_penalty_pp: float = 0.0,
) -> float:
"""Divide §4 (64)m by the appropriate efficiency to land HW fuel kWh.
@ -4826,7 +4827,21 @@ def _apply_water_efficiency(
fallback (`tables.table_4b.table_4b_seasonal_efficiencies_pct`)
use the SAP 10.2 Appendix D §D2.1 (2) Equation D1 monthly cascade.
Otherwise stay on the legacy scalar `water_efficiency_pct` divisor
(single-value PCDB summer eff, Table 4a inherit, etc.)."""
(single-value PCDB summer eff, Table 4a inherit, etc.).
`interlock_penalty_pp` is the SAP 10.2 §9.4.11 (PDF p.30) "Boiler
interlock" -5pp reduction (or 0 when the boiler IS interlocked).
Pre-S0380.165 the caller subtracted the penalty from the (winter,
summer) PCDB efficiencies BEFORE passing them in. The Elmhurst P960
worksheet for pcdb 1 (PCDB 716, Pwinter 65 / Psummer 53, Cylinder
Stat=No no interlock) shows the -5pp applied to the η_water,
monthly OUTPUT of Eq D1, NOT to its inputs the two interpretations
diverge because Eq D1 weights `1/η_winter` and `1/η_summer`
reciprocally and the penalty does not commute with the reciprocal
interp. The helper now takes the raw seasonal efficiencies + the
penalty separately, runs Eq D1 on the raw inputs, then subtracts
`interlock_penalty_pp / 100` from each monthly eff before dividing.
Matches worksheet (217)m for pcdb 1 to 1e-4 across all 12 months."""
if water_efficiency_pct <= 0:
return 0.0
if eq_d1_winter_summer_pct is not None:
@ -4837,8 +4852,9 @@ def _apply_water_efficiency(
space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh,
water_heating_output_monthly_kwh=wh_output_monthly_kwh,
)
penalty_frac = interlock_penalty_pp / 100.0
return sum(
output / eff if eff > 0 else 0.0
output / max(eff - penalty_frac, 1e-9) if eff > 0 else 0.0
for output, eff in zip(wh_output_monthly_kwh, monthly_eff)
)
return wh_output_annual_kwh / water_efficiency_pct
@ -5348,16 +5364,16 @@ def cert_to_inputs(
)
if no_interlock and pcdb_main is not None:
eff -= 0.05
# §9.4.11 -5pp interlock applies symmetrically to both winter and
# summer columns of the Equation D1 input — matches worksheet
# (217)m for pcdb 1 (PCDB 716 winter 65 / summer 53 → 60 / 48).
# No -5pp on the Table 4b branch when interlock is present (oil 1
# cert has cylinder thermostat → interlock OK → no adjustment).
if no_interlock and eq_d1_winter_summer_pct is not None:
eq_d1_winter_summer_pct = (
eq_d1_winter_summer_pct[0] - 5.0,
eq_d1_winter_summer_pct[1] - 5.0,
)
# SAP 10.2 §9.4.11 -5pp interlock is applied to the Eq D1 OUTPUT
# via `_apply_water_efficiency`'s `interlock_penalty_pp` kwarg —
# NOT pre-subtracted from the Pwinter / Psummer inputs. The two
# forms differ because Eq D1's reciprocal weighting is non-linear
# in η; the worksheet's (217)m for pcdb 1 matches the post-Eq-D1
# form. See `_apply_water_efficiency` docstring + S0380.165 commit.
eq_d1_interlock_penalty_pp = (
5.0 if no_interlock and eq_d1_winter_summer_pct is not None
else 0.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.
@ -5542,6 +5558,7 @@ def cert_to_inputs(
water_efficiency_pct=water_eff,
eq_d1_winter_summer_pct=eq_d1_winter_summer_pct,
space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh,
interlock_penalty_pp=eq_d1_interlock_penalty_pp,
)
# SAP 10.2 §12.4.4 (PDF p.36-37) — back-boiler HW kWh splits at
# boiler efficiency (Oct-May) + 100% electric immersion (Jun-Sep).

View file

@ -1845,6 +1845,106 @@ def test_section_12_4_4_summer_immersion_applies_to_back_boiler_combos() -> None
) is False
def test_apply_water_efficiency_applies_interlock_penalty_after_equation_d1() -> None:
# Arrange — SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock": "The
# efficiency of gas and liquid fuel boilers for both space and water
# heating is reduced by 5% if the boiler is not interlocked for
# space and water heating." Table 4c (PDF p.169-170) Note c) "These
# do not accumulate as no thermostatic control or presence of a
# bypass means that there is no boiler interlock."
#
# Pre-slice the cascade subtracted 5pp from BOTH Pwinter and Psummer
# BEFORE running the SAP 10.2 Appendix D §D2.1 Equation D1 monthly
# cascade. The Elmhurst P960 worksheet for pcdb 1 (PCDB 716, Pwinter
# 65 / Psummer 53, Cylinder Stat=No → no interlock) shows the -5pp
# is applied to the η_water,monthly OUTPUT of Eq D1, NOT to its
# inputs. The two interpretations diverge because Eq D1's reciprocal-
# weighted form is non-linear in η:
#
# Worksheet: Pwinter_raw=65 / Psummer_raw=53 → Eq D1 monthly →
# η_m_raw_jan = 61.9725, → 5pp → 56.9725 (217)m_jan ✓
#
# Old cascade: 60 / 48 → Eq D1 monthly →
# η_m_jan = 56.9292 (off by 0.043 pp)
#
# Verified across all 12 months for pcdb 1: post-Eq-D1 -5pp matches
# worksheet (217)m to 1e-4 every month; total (219) = 7063.96 vs
# cascade pre-slice 7068.41 (Δ +4.45 kWh, source of the 0.011 SAP /
# +£0.24 / +1.33 CO2 / +5.7 PE pcdb 1 corpus residual pin).
#
# Worksheet (98c)m + (64)m + Pwinter / Psummer (raw):
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_apply_water_efficiency, # pyright: ignore[reportPrivateUsage]
)
pcdb1_q_space = (1409.7675, 1178.9199, 1092.7588, 761.5542, 474.6862,
0.0, 0.0, 0.0, 0.0, 642.4138, 1028.9740, 1415.7149)
pcdb1_q_water = (387.8612, 345.4882, 372.5001, 339.6744, 337.7296,
231.1325, 230.5321, 237.6818, 238.0910, 346.4331,
355.2277, 385.1451)
pwinter_raw = 65.0
psummer_raw = 53.0
worksheet_219_total = 7063.9614
# Act — invoke the helper with the raw seasonal efficiencies +
# explicit interlock penalty applied post-Eq D1.
hw_fuel_kwh = _apply_water_efficiency(
wh_output_monthly_kwh=pcdb1_q_water,
wh_output_annual_kwh=sum(pcdb1_q_water),
water_efficiency_pct=psummer_raw / 100.0,
eq_d1_winter_summer_pct=(pwinter_raw, psummer_raw),
space_heating_monthly_useful_kwh=pcdb1_q_space,
interlock_penalty_pp=5.0,
)
# Assert — fuel total reproduces the worksheet (219) sum within 1e-3.
assert abs(hw_fuel_kwh - worksheet_219_total) <= 1e-3, (
f"cascade {hw_fuel_kwh:.4f} vs worksheet {worksheet_219_total:.4f}"
)
def test_apply_water_efficiency_interlock_penalty_zero_keeps_raw_eq_d1() -> None:
# Arrange — when interlock is present (interlock_penalty_pp = 0)
# Eq D1 cascade runs unchanged on the raw seasonal efficiencies.
# Cohort-2 / U985 fixtures lodge cylinder thermostat = Yes; their
# cascade falls through this path with no penalty (S0380.141's
# gate keeps interlock-OK certs at raw η). Guards the no-penalty
# path so it matches the spec-literal Eq D1 reference.
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_apply_water_efficiency, # pyright: ignore[reportPrivateUsage]
)
from domain.sap10_calculator.worksheet.water_heating import (
water_efficiency_monthly_via_equation_d1,
)
q_space = (1000.0, 800.0, 700.0, 500.0, 300.0, 0.0, 0.0, 0.0, 0.0,
400.0, 700.0, 1000.0)
q_water = (300.0, 280.0, 290.0, 270.0, 260.0, 200.0, 195.0, 205.0,
210.0, 270.0, 285.0, 305.0)
# Act — no penalty
hw_fuel = _apply_water_efficiency(
wh_output_monthly_kwh=q_water,
wh_output_annual_kwh=sum(q_water),
water_efficiency_pct=0.53,
eq_d1_winter_summer_pct=(85.0, 80.0),
space_heating_monthly_useful_kwh=q_space,
interlock_penalty_pp=0.0,
)
# Assert — expected = direct Eq D1 cascade without any penalty
monthly_eff = water_efficiency_monthly_via_equation_d1(
winter_efficiency_pct=85.0,
summer_efficiency_pct=80.0,
space_heating_monthly_useful_kwh=q_space,
water_heating_output_monthly_kwh=q_water,
)
expected = sum(qw / e for qw, e in zip(q_water, monthly_eff))
assert abs(hw_fuel - expected) <= 1e-9
def test_section_12_4_4_hw_blend_mirrors_elmhurst_summer_annual_pe_co2_double_count() -> None:
# Arrange — SAP 10.2 §12.4.4 back-boiler combos with the boiler heating
# the cylinder Oct-May + an electric immersion Jun-Sep. The spec-literal