mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
df4d271d3b
commit
3de52bcb90
3 changed files with 149 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue