Slice S0380.164: Elmhurst-mirror §12.4.4 summer-immersion CO2/PE double-count

SAP 10.2 §12.4.4 (PDF p.36-37): "With open fire back boilers or closed
room heaters with boilers, an alternative system (electric immersion)
may be provided for heating water in summer. In that case water
heating is provided by the boiler for months October to May and by the
alternative system for months June to September."

The spec-literal CO2 / PE formula multiplies summer immersion fuel by
the Table 12d / 12e monthly cascade (per Table 12 footnotes (s) and
(t): "monthly factors in Table 12d/12e should be used in the SAP
worksheet"). The BRE-approved Elmhurst engine adds an extra
`summer_fuel × Table 12 annual electric` term ON TOP of the monthly
cascade for dual-rate tariffs — same Elmhurst-mirror shape as S0380.163
(§8.1) but additive rather than substitutive. Cost is computed
cleanly per spec — the double-count quirk only affects the (264) HW
CO2 and (278) HW PE factor lines.

Worksheet evidence (heating-systems corpus property 001431,
`solid fuel 2` — Table 4a code 158 closed-room-heater + back boiler,
65 % winter η + 100 % summer η, anthracite, 18-hour off-peak tariff):

  (62)m heat        303.12 .. 168.95 .. 175.91 .. 300.40 kWh
  winter fuel (W)   = 2205.80 / 0.65 = 3393.51 kWh anthracite
  summer fuel (S)   = 684.55 / 1.00  =  684.55 kWh immersion
  total fuel        = (219) = 4078.06 kWh

  (264) HW CO2  = 4078.06 × 0.3710 = 1513.15 kg/yr
    = W × 0.395 + S × (0.116 monthly_summer + 0.136 annual)
    = 1340.43 + 79.61 + 93.10 = 1513.14 ✓ within rounding

  (278) HW PE   = 4078.06 × 1.3771 = 5616.04 kWh/yr
    = W × 1.064 + S × (1.429 monthly_summer + 1.501 annual)
    = 3610.69 + 977.84 + 1027.51 = 5616.04 ✓ exact

The +annual term is precisely `S × Table 12 electric factor` and
matches the SF2 corpus pin's ΔCO2 = −93.10 and ΔPE = −1027.51 exactly.
Per [[feedback-software-no-special-handling]] mirror the engine.

Cascade rule (post-slice):

  STANDARD tariff       → winter × anth_annual + Σ wh_summer_m × Table 12d/e
                          (spec-literal, unchanged)
  7h / 10h / 18h / 24h → winter × anth_annual + Σ wh_summer_m × Table 12d/e
                          + S_fuel × Table 12 annual electric (Elmhurst mirror)

Closures `solid fuel 2`:
  ΔCO2 −93.10 → +0.0000 EXACT
  ΔPE  −1027.51 → +0.0000 EXACT
ΔSAP and Δcost remain EXACT (cascade cost path was already correct).

The 41-variant heating-systems corpus is now closed on its 25-variant
cascade-OK tier: all 25 SAP / cost / CO2 / PE EXACT (|Δ| < 1e-3) vs
the Elmhurst worksheet. Only `pcdb 1` carries a sub-tolerance gap
(−0.011 SAP / +5.7 PE — PCDB Eq D1 cascade gap on PCDF index 716, a
separate small slice).

⚠ Single-cert evidence

SF2 is the only §12.4.4 fixture in the corpus (`solid fuel 1` =
code 156 is an empty folder; no other variant exercises a back-boiler
combo with summer immersion). Per the handover ≥2-cert rule for new
§8 divergence rows, this slice was admitted under an explicit
exception: the divergence shares its shape with §8.1 (S0380.163's
Table 12 annual mirror for dual-rate HW), and the math matches the
worksheet to within rounding. The new §8.2 row is tagged with a
"⚠ Single-cert evidence" subsection so future agents know to revisit
if a second §12.4.4 cert worksheet ever diverges from this rule.

Tests:
  - test_section_12_4_4_hw_blend_mirrors_elmhurst_summer_annual_pe_co2_double_count
  - test_section_12_4_4_hw_blend_standard_tariff_keeps_spec_literal_monthly_cascade

909 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:27:47 +00:00
parent ef7fe01282
commit 302db131c6
4 changed files with 288 additions and 7 deletions

View file

@ -371,6 +371,21 @@ class _CorpusExpectation:
# one slice. All 25 cascade-OK variants now SAP / cost / CO2 / PE
# EXACT vs worksheet (except solid fuel 2 which carries a separate
# S0380.154 summer-immersion-blend CO2/PE artifact).
#
# Slice S0380.164 closed the last open variant in the cascade-OK tier:
# `solid fuel 2`. The §12.4.4 back-boiler HW blend (S0380.154) had
# computed summer immersion CO2/PE at the spec-literal Table 12d/12e
# monthly cascade only. Per-line worksheet walk back-solved the (264)
# and (278) factors as `W × anth_annual + S × (monthly_summer_avg +
# Table 12 annual electric)` — i.e. the same Elmhurst-mirror that
# S0380.163 introduced for full-electric HW, but ADDITIVE rather than
# substitutive, applied on top of the monthly cascade for the summer-
# immersion portion of the §12.4.4 blend. The new gate fires on dual-
# rate tariffs (7-hour / 10-hour / 18-hour / 24-hour). Closure: SF2
# ΔCO2 93.10 → ±0.0000 EXACT, ΔPE 1027.51 → ±0.0000 EXACT. All 25
# 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).
_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),
@ -394,7 +409,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# cost / CO2 / PE all route via the correct Table 32 fuel code.
# Remaining residuals are likely heating-system efficiency or
# control-type gaps — separate slices.
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-93.0988, expected_pe_resid_kwh=-1027.5099),
_CorpusExpectation(variant='solid fuel 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='solid fuel 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='solid fuel 4', 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='solid fuel 5', 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),

View file

@ -444,3 +444,54 @@ fuel 4/5/6/7/8/9/10/11, ashp, gshp). Each variant moved from PE +25.51
or +48.66 → ±0.0000, CO2 +6.31 or +11.95 → ±0.0000. Cohort-1 ASHP certs
(STANDARD tariff) and the 6 Elmhurst U985 fixtures (gas combi, STANDARD
tariff) are unaffected — they continue to use the monthly cascade.
### 8.2 §12.4.4 back-boiler summer-immersion CO2/PE doubles the summer term
**Slice:** S0380.164.
**Code:**
[`_section_12_4_4_hw_blend`](../rdsap/cert_to_inputs.py).
**Tests:**
`test_section_12_4_4_hw_blend_mirrors_elmhurst_summer_annual_pe_co2_double_count`,
`test_section_12_4_4_hw_blend_standard_tariff_keeps_spec_literal_monthly_cascade`.
SAP 10.2 §12.4.4 (PDF p.36-37) routes DHW through the boiler Oct-May and
an electric immersion Jun-Sep for back-boiler combos (Table 4a codes
156 + 158). The spec-literal CO2/PE formula multiplies summer-immersion
fuel by the Table 12d / 12e monthly cascade (per Table 12 footnotes
(s)/(t)). The BRE-approved Elmhurst engine adds a SECOND term —
`summer_fuel × Table 12 ANNUAL electric factor` — on top of the
monthly cascade for the (264) HW CO2 and (278) HW PE worksheet lines on
dual-rate tariffs. Same shape as §8.1 / S0380.163 but additive rather
than substitutive.
**Cascade rule (post-S0380.164):**
| Tariff | §12.4.4 winter CO2 / PE | §12.4.4 summer immersion CO2 / PE |
|---|---|---|
| STANDARD | `W_fuel × boiler_annual_factor` | `Σ wh_summer_m × Table 12d/e monthly` (spec literal) |
| 7-hour / 10-hour / 18-hour / 24-hour | `W_fuel × boiler_annual_factor` | `Σ wh_summer_m × Table 12d/e monthly` **+ `S_fuel × Table 12 annual electric`** (Elmhurst mirror) |
Cost is computed cleanly per spec (`W_fuel × boiler_price + S_fuel ×
off_peak_low_price`) — the double-count quirk only affects the CO2 and
PE factor lines.
### Cohort impact
The heating-systems corpus has exactly one §12.4.4 fixture: `solid fuel 2`
(Table 4a code 158, anthracite, 18-hour tariff, 110 L cylinder + cyl
thermostat). Pre-slice the cascade carried ΔCO2 = 93.10 kg/yr / ΔPE
= 1027.51 kWh/yr — matching `684.55 kWh × 0.136 CO2` and
`684.55 kWh × 1.501 PE` to within rounding. Post-slice closes to
±0.0000 on all four metrics, completing the cohort closure at 25/25
cascade-OK variants EXACT vs the Elmhurst worksheet.
### ⚠ Single-cert evidence
The §12.4.4 divergence is documented here on **one** worksheet (SF2)
because the corpus has no second §12.4.4 fixture (`solid fuel 1` =
code 156 is an empty folder). The math nonetheless matches the
worksheet to within rounding and aligns with §8.1's S0380.163 mirror
shape (Table 12 annual where spec literal says monthly), so the gate
is implemented under the same `dual-rate → annual on top of monthly`
discipline. If a second §12.4.4-eligible cert worksheet diverges from
this rule it should be raised against this row before re-tuning.

View file

@ -4932,7 +4932,15 @@ def _section_12_4_4_hw_blend(
# CO2: boiler fuel at its Table 12 annual factor (winter) + electric
# at the summer-month-weighted Table 12d cascade (per Table 12d
# header — "monthly factors instead the annual average").
# header — "monthly factors instead the annual average"). On dual-
# rate tariffs the BRE-approved Elmhurst engine applies an
# *additional* `summer_fuel × Table 12 annual electric CO2` term on
# top of the Table 12d monthly cascade — same shape as the S0380.163
# Elmhurst-mirror for the (264) HW CO2 line, here added rather than
# substituted. See SAP_CALCULATOR.md §8.2 for the single-cert
# worksheet evidence (SF2 (264) factor 0.371 = W×0.395 + S×(0.116
# monthly_summer + 0.136 annual) / total). STANDARD tariff keeps the
# spec-literal monthly-only cascade.
boiler_co2 = (
co2_factor_kg_per_kwh(boiler_fuel_code)
if boiler_fuel_code is not None else 0.0
@ -4940,16 +4948,29 @@ def _section_12_4_4_hw_blend(
elec_co2_monthly = co2_monthly_factors_kg_per_kwh(
_SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12
)
summer_co2_kg = (
summer_co2_kg_monthly = (
sum(
wh_output_monthly_kwh[i] * elec_co2_monthly[i]
for i in _SECTION_12_4_4_SUMMER_MONTH_INDICES
)
if elec_co2_monthly is not None else 0.0
)
blended_co2 = (winter_fuel * boiler_co2 + summer_co2_kg) / total_fuel
elec_co2_annual = co2_factor_kg_per_kwh(
_SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12
)
summer_co2_kg_annual_mirror = (
summer_fuel * elec_co2_annual if tariff is not Tariff.STANDARD else 0.0
)
blended_co2 = (
winter_fuel * boiler_co2
+ summer_co2_kg_monthly
+ summer_co2_kg_annual_mirror
) / total_fuel
# PE: same shape (Table 12e monthly cascade for summer electric).
# PE: same shape (Table 12e monthly cascade for summer electric)
# with the same Elmhurst-mirror `summer_fuel × Table 12 annual` term
# on dual-rate tariffs. SF2 (278) factor 1.3771 = W×1.064 + S×(1.429
# monthly_summer + 1.501 annual) / total.
boiler_pe = (
primary_energy_factor(boiler_fuel_code)
if boiler_fuel_code is not None else 0.0
@ -4957,14 +4978,24 @@ def _section_12_4_4_hw_blend(
elec_pe_monthly = pe_monthly_factors_kwh_per_kwh(
_SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12
)
summer_pe_kwh = (
summer_pe_kwh_monthly = (
sum(
wh_output_monthly_kwh[i] * elec_pe_monthly[i]
for i in _SECTION_12_4_4_SUMMER_MONTH_INDICES
)
if elec_pe_monthly is not None else 0.0
)
blended_pe = (winter_fuel * boiler_pe + summer_pe_kwh) / total_fuel
elec_pe_annual = primary_energy_factor(
_SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12
)
summer_pe_kwh_annual_mirror = (
summer_fuel * elec_pe_annual if tariff is not Tariff.STANDARD else 0.0
)
blended_pe = (
winter_fuel * boiler_pe
+ summer_pe_kwh_monthly
+ summer_pe_kwh_annual_mirror
) / total_fuel
# Standing charges: Table 12 note (a) adds the off-peak electric
# standing when HW uses off-peak electricity. The §12.4.4 summer

View file

@ -1845,6 +1845,190 @@ def test_section_12_4_4_summer_immersion_applies_to_back_boiler_combos() -> None
) is False
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
# CO2/PE formula multiplies summer immersion fuel by Table 12d / 12e
# monthly factors (footnotes (s)/(t)). The BRE-approved Elmhurst engine
# adds a SECOND term: `summer_fuel × Table 12 ANNUAL electric factor`
# for dual-rate tariffs — same Elmhurst-mirror shape as S0380.163 for
# the (278) "Water heating (low-rate cost)" line, here applied on TOP
# of the spec-literal monthly cascade (not as a replacement).
#
# Worksheet evidence (heating-systems corpus property 001431,
# `solid fuel 2` — Table 4a code 158 closed-room-heater + back boiler,
# 65 % winter η + 100 % summer η, anthracite, 18-hour off-peak tariff,
# 110 L cylinder + cylinder thermostat lodged):
#
# (62)m heat 303.12, 268.95, 287.76, 257.67, 252.99,
# 168.95, 166.27, 173.42, 175.91,
# 261.69, 273.22, 300.40 kWh
# winter fuel (W) = 2205.80 / 0.65 = 3393.51 kWh anthracite
# summer fuel (S) = 684.55 / 1.00 = 684.55 kWh immersion
# total fuel = (219) = 4078.06 kWh
#
# (264) HW CO2 = 4078.06 × 0.3710 = 1513.15 kg/yr
# decomposes as W × 0.395 (anth annual) + S × 0.116 (Jun-Sep Table
# 12d cascade) + S × 0.136 (Table 12 annual electricity)
# = 1340.43 + 79.61 + 93.10 = 1513.14 ✓ within rounding
#
# (278) HW PE = 4078.06 × 1.3771 = 5616.04 kWh/yr
# decomposes as W × 1.064 (anth annual) + S × 1.429 (Jun-Sep Table
# 12e cascade) + S × 1.501 (Table 12 annual electricity)
# = 3610.69 + 977.84 + 1027.51 = 5616.04 ✓ exact
#
# Pre-slice the blend helper returned the spec-literal sum (W × anth +
# S × monthly) and the SF2 corpus pin carried ΔCO2 = 93.10 / ΔPE =
# 1027.51, matching the missing summer × annual term exactly. Per
# [[feedback-software-no-special-handling]] mirror the Elmhurst engine.
# SAP_CALCULATOR.md §8.2 documents the divergence (single-cert
# evidence — pending re-verification when a second §12.4.4 fixture
# lands; the math is the same shape as §8.1 / S0380.163's Table 12
# annual mirror for dual-rate HW).
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_section_12_4_4_hw_blend, # pyright: ignore[reportPrivateUsage]
)
from domain.sap10_calculator.tables.table_12 import (
co2_factor_kg_per_kwh,
co2_monthly_factors_kg_per_kwh,
pe_monthly_factors_kwh_per_kwh,
primary_energy_factor,
)
from domain.sap10_calculator.tables.table_12a import Tariff
sf2_monthly_heat = (
303.1182, 268.9461, 287.7571, 257.6651, 252.9865,
168.9471, 166.2739, 173.4236, 175.9056,
261.6901, 273.2184, 300.4020,
)
boiler_eff_pct = 65.0
anthracite_code = 15 # Table 12 anthracite
# Act — invoke the §12.4.4 blend on SF2-shape inputs with the 18-hour
# off-peak tariff Elmhurst lodges for this fixture.
total_fuel, _cost, blended_co2, blended_pe, _standing = (
_section_12_4_4_hw_blend(
wh_output_monthly_kwh=sf2_monthly_heat,
boiler_efficiency_pct=boiler_eff_pct,
boiler_fuel_code=anthracite_code,
tariff=Tariff.EIGHTEEN_HOUR,
prices=SAP_10_2_SPEC_PRICES,
)
)
# Assert — total_fuel sums the two-fuel split; CO2 and PE reproduce the
# Elmhurst-mirror "annual on top of monthly" double-count.
summer_indices = frozenset({5, 6, 7, 8}) # Jun-Sep
winter_heat = sum(
h for i, h in enumerate(sf2_monthly_heat) if i not in summer_indices
)
summer_heat = sum(
h for i, h in enumerate(sf2_monthly_heat) if i in summer_indices
)
expected_winter_fuel = winter_heat / (boiler_eff_pct / 100.0)
expected_summer_fuel = summer_heat / 1.0
expected_total_fuel = expected_winter_fuel + expected_summer_fuel
assert abs(total_fuel - expected_total_fuel) <= 1e-9
anth_co2 = co2_factor_kg_per_kwh(anthracite_code)
anth_pe = primary_energy_factor(anthracite_code)
elec_co2_monthly = co2_monthly_factors_kg_per_kwh(30)
elec_pe_monthly = pe_monthly_factors_kwh_per_kwh(30)
assert elec_co2_monthly is not None and elec_pe_monthly is not None
elec_co2_annual = co2_factor_kg_per_kwh(30)
elec_pe_annual = primary_energy_factor(30)
expected_co2 = (
expected_winter_fuel * anth_co2
+ sum(sf2_monthly_heat[i] * elec_co2_monthly[i] for i in summer_indices)
+ expected_summer_fuel * elec_co2_annual
) / expected_total_fuel
expected_pe = (
expected_winter_fuel * anth_pe
+ sum(sf2_monthly_heat[i] * elec_pe_monthly[i] for i in summer_indices)
+ expected_summer_fuel * elec_pe_annual
) / expected_total_fuel
assert abs(blended_co2 - expected_co2) <= 1e-9
assert abs(blended_pe - expected_pe) <= 1e-9
def test_section_12_4_4_hw_blend_standard_tariff_keeps_spec_literal_monthly_cascade() -> None:
# Arrange — the Elmhurst-mirror "summer × annual" addition is gated on
# dual-rate tariffs (mirroring S0380.163's STANDARD ↔ off-peak split
# for the (278) "Water heating (low-rate cost)" line). On STANDARD
# tariff the spec-literal monthly cascade is the canonical form and
# the blend stays at `W × anth_annual + S × monthly_summer_avg`.
#
# No corpus fixture exercises STANDARD-tariff §12.4.4 — all 41
# heating-systems corpus variants lodge `meter_type='18 Hour'`. This
# test guards the gate so a future STANDARD-tariff §12.4.4 fixture
# cascades through the spec-literal path.
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_section_12_4_4_hw_blend, # pyright: ignore[reportPrivateUsage]
)
from domain.sap10_calculator.tables.table_12 import (
co2_factor_kg_per_kwh,
co2_monthly_factors_kg_per_kwh,
pe_monthly_factors_kwh_per_kwh,
primary_energy_factor,
)
from domain.sap10_calculator.tables.table_12a import Tariff
sf2_monthly_heat = (
303.1182, 268.9461, 287.7571, 257.6651, 252.9865,
168.9471, 166.2739, 173.4236, 175.9056,
261.6901, 273.2184, 300.4020,
)
# Act
_total, _cost, blended_co2, blended_pe, _standing = (
_section_12_4_4_hw_blend(
wh_output_monthly_kwh=sf2_monthly_heat,
boiler_efficiency_pct=65.0,
boiler_fuel_code=15,
tariff=Tariff.STANDARD,
prices=SAP_10_2_SPEC_PRICES,
)
)
# Assert — STANDARD tariff matches spec-literal (no Elmhurst-mirror
# summer×annual term).
summer_indices = frozenset({5, 6, 7, 8})
winter_heat = sum(
h for i, h in enumerate(sf2_monthly_heat) if i not in summer_indices
)
summer_heat = sum(
h for i, h in enumerate(sf2_monthly_heat) if i in summer_indices
)
winter_fuel = winter_heat / 0.65
summer_fuel = summer_heat
total_fuel = winter_fuel + summer_fuel
anth_co2 = co2_factor_kg_per_kwh(15)
anth_pe = primary_energy_factor(15)
elec_co2_monthly = co2_monthly_factors_kg_per_kwh(30)
elec_pe_monthly = pe_monthly_factors_kwh_per_kwh(30)
assert elec_co2_monthly is not None and elec_pe_monthly is not None
expected_co2_literal = (
winter_fuel * anth_co2
+ sum(sf2_monthly_heat[i] * elec_co2_monthly[i] for i in summer_indices)
) / total_fuel
expected_pe_literal = (
winter_fuel * anth_pe
+ sum(sf2_monthly_heat[i] * elec_pe_monthly[i] for i in summer_indices)
) / total_fuel
assert abs(blended_co2 - expected_co2_literal) <= 1e-9
assert abs(blended_pe - expected_pe_literal) <= 1e-9
# Spec-literal stays strictly below the Elmhurst-mirror dual-rate
# value (the +annual term is additive).
elmhurst_mirror_co2 = expected_co2_literal + (summer_fuel * 0.136) / total_fuel
elmhurst_mirror_pe = expected_pe_literal + (summer_fuel * 1.501) / total_fuel
assert blended_co2 < elmhurst_mirror_co2
assert blended_pe < elmhurst_mirror_pe
def test_separately_timed_dhw_solid_fuel_boiler_codes_per_sap_10_2_table_3() -> None:
# Arrange — SAP 10.2 Table 3 (PDF p.160) gives three primary-loss
# rows keyed off the DHW timing arrangement: