Adds `"NON": 30` to `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` so the
mapper can derive the main heating fuel for the Elmhurst "no main
heating system" lodging (§14.0 Main Heating EES = NON + SAP code
699 + §14.1 Heating Type = None).
SAP 10.2 §A.2.2: "When no main heating system is identified, the
calculation is for the assumed system consisting of portable electric
heaters." Routes the fuel to Table 32 standard-electricity code 30
(tariff resolved separately from `meter_type` per `_rdsap_tariff`).
Pre-slice the cascade raised `MissingMainFuelType` per S0380.132.
Post-slice the cascade closes most of the way:
no system: ΔSAP_c +1.18, Δcost −£27, ΔCO2 −50, ΔPE −562
The residuals are cascade-side (likely §A.2.2 portable-electric
efficiency / responsiveness / control-type defaults differ slightly
from Elmhurst) — pinned at observed values as forcing function for
follow-up.
Moves `no system` out of `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` into
`_EXPECTATIONS`. Blocked tier now: 5 community-heating variants.
Tests:
- test_elmhurst_main_heating_ees_maps_no_system_code_to_electricity
- corpus pin: no system expected residuals at observed values
916 pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mapper extensions (`_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE`):
"BFD": 71, # HVO — corpus variant oil 2 (SAP 127)
"BXE": 73, # FAME — corpus variant oil 3 (SAP 128)
"BXF": 73, # FAME alt — corpus variant oil 4 (SAP 129)
"BZC": 76, # Bioethanol — corpus variant oil 5 (SAP 126)
"B3C": 75, # B30K — corpus variant oil 6 (SAP 126)
`_ELMHURST_MAIN_FUEL_TO_SAP10` water-side labels:
"Bio-liquid HVO from used cooking oil": 71,
"Bio-liquid FAME from animal/vegetable oils": 73,
"Bioethanol": 76,
"B30K": 75,
Values are direct Table 32 codes (the bio-liquid codes 71/73/75/76
don't collide with any API enum value so they pass through
`unit_price_p_per_kwh` etc. unchanged). Spec: SAP 10.2 Table 12
(PDF p.189) notes (d)/(e)/(f).
Pre-slice all 5 oil 2-6 variants raised `MissingMainFuelType` per
S0380.132. Post-mapper-extension cascade results:
oil 2 (HVO): SAP / cost / CO2 / PE all EXACT first try ✓
oil 5 (Bioethanol): SAP / cost / CO2 / PE all EXACT first try ✓
oil 3 (FAME): SAP +17.34, cost −£398
oil 4 (FAME alt): SAP +16.06, cost −£367
oil 6 (B30K): SAP +3.05, cost −£70
Slice S0380.131 had left a deferred TODO in `table_32.py` for FAME
code 73 ("worksheet 7.64 vs spec 5.44 — flipping has no measurable
cascade effect today, deferred until a cert that exercises it
surfaces"). Now exercised — flipping `73: 5.44 → 7.64` closes 85 %
of the oil 3/4 cost gap:
oil 3 (FAME): SAP +17.34 → +2.59, cost −£398 → −£62
oil 4 (FAME alt): SAP +16.06 → +2.56, cost −£367 → −£57
The Elmhurst-engine canonical 7.64 ↔ spec PDF 5.44 divergence is the
same pattern S0380.131 applied to heating oil (code 4: 7.64 → 5.44)
per [[feedback-software-no-special-handling]].
Remaining residuals on oil 3 / oil 4 / oil 6 are cascade-side
(HW kWh under by ~250-900, SH demand small diff, CO2/PE blend
artifacts) — pinned at observed values as forcing functions for
follow-up slices. Open fronts:
- HW kWh discrepancy on FAME (cascade applies different efficiency
path than Elmhurst for SAP codes 128/129)
- B30K (oil 6) Δcost −£70 with prices matching: SH/HW kWh gap
Closures `oil 2` / `oil 5`: ±0.0000 on all 4 metrics. Moves all 5
oil variants out of `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` into
`_EXPECTATIONS`.
Blocked tier now: 6 variants (community heating × 5, no system).
Cascade-OK tier: 32 variants (up from 30), 30 EXACT + 3 (oil 3/4/6)
pinned with non-zero residuals + 1 (pcdb 1 SH residual closed in
S0380.165).
Tests:
- test_elmhurst_main_heating_ees_maps_bio_liquid_codes_to_table_32_fuel_codes
- test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels
- corpus pins: oil 2/3/4/5/6 expected residuals
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds three Elmhurst EES (Energy Efficiency Standard) codes to
`_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` so the mapper can derive the
main heating fuel for electric storage / direct-acting certs whose
Elmhurst Summary §14.0 does not lodge a "Main Heating Fuel Type"
string (same pattern as the solid-fuel block above):
"WEA": 30, # electric warm-air storage
"REA": 30, # resistive electric (corpus electric 12 SAP 691)
"OEA": 30, # other electric (corpus electric 13/14 SAP 701)
All route to Table 32 standard-electricity code 30; the cascade
resolves the actual price tier (high vs low rate) downstream via
`_rdsap_tariff(epc)` keyed off `meter_type`.
The corpus carries 4 electric-storage variants on the 18-hour tariff:
electric 11 — WEA + SAP 515 (warm-air electric)
electric 12 — REA + SAP 691
electric 13 — OEA + SAP 701
electric 14 — OEA + SAP 701 (differs from 13 by emitter / controls)
Pre-slice all 4 raised `MissingMainFuelType` per S0380.132. Post-slice
all 4 EXACT on first try across all 4 metrics:
electric 11: ΔSAP_c +0.0000 Δcost +£0.0000 ΔCO2 −0.0000 ΔPE −0.0000
electric 12: ΔSAP_c +0.0000 Δcost +£0.0000 ΔCO2 −0.0000 ΔPE −0.0000
electric 13: ΔSAP_c +0.0000 Δcost −£0.0000 ΔCO2 +0.0000 ΔPE −0.0000
electric 14: ΔSAP_c +0.0000 Δcost −£0.0000 ΔCO2 +0.0000 ΔPE −0.0000
Closure on first try because the cascade was already wired for the
electric-storage path (SAP 10.2 Table 4a codes 515 / 691 / 701, Table
4e Group 4 storage controls, Table 5a pump-gain wet-gate from S0380.160,
S0380.144 secondary-fraction by sub-row); only the Elmhurst EES → fuel
mapping was missing.
Moves electric 11/12/13/14 out of `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE`
into `_EXPECTATIONS` at ±0.0000. Blocked tier now: 11 variants
(community heating × 5, no system, oil 2-6).
Tests:
- test_elmhurst_main_heating_ees_maps_electric_storage_codes_to_electricity
- corpus pins: electric 11/12/13/14 expected residuals = ±0.0000
Cascade-OK tier: 30 variants (up from 25), all SAP / cost / CO2 / PE
EXACT (< 1e-4) vs Elmhurst worksheet on every metric.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the single missing dict entry that lets cert `pcdb 3` cascade:
`_ELMHURST_MAIN_FUEL_TO_SAP10["Bulk LPG"] = 27`
API code 27 = "LPG (not community)" — routes via:
- `API_FUEL_TO_TABLE_12[27] = 2` (SAP 10.2 Table 12 bulk LPG: £62
standing, 6.74 p/kWh, 0.241 CO2, 1.141 PE; spec PDF p.189)
- `API_FUEL_TO_TABLE_32[27] = 2` (RdSAP 10 Table 32 bulk LPG: £70
standing, 7.60 p/kWh; spec PDF p.95)
Pre-slice the mapper produced `main_fuel_type=''` for any Elmhurst
fixture lodging "Bulk LPG" as fuel type, so the cascade strict-raised
`MissingMainFuelType` per S0380.132. The legacy `"LPG bulk"` label
(different word order) maps to API code 6 = wood logs — a pre-existing
oddity unexercised by any live fixture; left untouched per
[[feedback-bigger-slices-for-uniform-work]] (different label, different
fix).
Cascade closure `pcdb 3` (Vokera Linea LPG combi 83.10 %, PCDB index
8262, no cylinder, 18-hour tariff) — EXACT on first try across all 4
metrics:
cascade SAP_c = 49.2953 worksheet = 49.2953 Δ = +0.0000
cascade cost = £1165.81 worksheet = £1165.81 Δ = +0.0000
cascade CO2 = 3367.95 worksheet = 3367.95 Δ = +0.0000
cascade PE = 13936.60 worksheet = 13936.60 Δ = +0.0000
Closure on first try because the cascade was already fully wired for
the gas/oil/LPG path; the Elmhurst label was the only gap. Moves
pcdb 3 out of `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` into `_EXPECTATIONS`
at ±0.0000.
Blocked tier now: 15 variants (community heating × 5, electric storage
11-14, no system, oil 2-6).
Tests:
- test_elmhurst_main_fuel_to_sap10_maps_bulk_lpg_to_api_code_27
- corpus pin: pcdb 3 expected residuals = ±0.0000 on all 4 metrics
912 pass / 0 fail; pyright net-zero 43 → 43.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
Wraps the four slices closing the heating-systems corpus from
Σ|ΔSAP_c| 1.24 → 0 (25/25 cascade-OK variants SAP/cost/CO2/PE
EXACT, except solid fuel 2 summer-immersion-blend artifact).
Highest-leverage next slice: close solid fuel 2 (the only remaining
open variant in the cascade-OK tier) via the S0380.154 blend code
path — likely a parallel Elmhurst-mirror gate for the summer-
immersion CO2/PE factors.
Other open fronts: 16 blocked-tier mapper extensions; pcdb 1 sub-
tolerance -0.011 SAP; cohort-2 golden residuals tightening per
[[feedback-golden-residuals-near-zero]].
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 12 footnote (t) (PDF p.189): "PE factors for grid
electricity vary by month. The average figure given in this table is
therefore not used directly. Instead the monthly factors given in
Table 12e should be used in the SAP worksheet." Footnote (s) says the
same for CO2 / Table 12d. Read literally, monthly factors apply to
every electric end-use including dual-rate HW.
The BRE-approved Elmhurst rdSAP engine doesn't follow that reading
for HW. The 41-variant heating-systems corpus controlled-variable
fixture lodges worksheet (278) "Water heating (low-rate cost)" with
factor **1.5010 PE / 0.136 CO2** (Table 12 annual flat) across every
dual-rate tariff cert, while applying the monthly Table 12e/12d
cascade to lighting (1.5338 winter-weighted) and secondary heating
(1.5715) on the same certs. It's an engine implementation choice,
not a documented spec exception.
Per [[feedback-software-no-special-handling]] the calculator
contract is bit-faithful replication of the engine, not literal
compliance with the spec text. This slice flips cascade
`_hot_water_primary_factor` + `_hot_water_co2_factor_kg_per_kwh` to
accept a `tariff: Tariff` parameter:
- STANDARD tariff → Table 12e/12d monthly cascade weighted
by HW demand seasonality (unchanged from
S0380.71 / .72, matches cohort-1 ASHP
standard-tariff worksheet)
- 7-hour / 10-hour /
18-hour / 24-hour → Table 12 annual flat (1.501 / 0.136)
matching the Elmhurst worksheet (278)
"Water heating (low-rate cost)" row
Per-line walk on electric 3 (18-hour tariff, electric immersion HW,
2384.116 kWh annual):
worksheet (278) factor = 1.5010
cascade pre-slice = 1.5214 delta = +0.0204
(1.5214 - 1.5010) × 2384.116 = +48.66 kWh/yr PE — EXACT match
the corpus residual pin.
Same shape for CO2: worksheet 0.1360, cascade pre-slice 0.1410,
delta +0.0050 × 2384.116 = +11.95 kg/yr.
Closures across the 18-variant deferred lighting-PE cohort
(electric 1/2/3/5/6/7/8/9 + solid fuel 4/5/6/7/8/9/10/11 + ashp +
gshp):
ΔCO2 +6.31 / +11.95 → ±0.0000 EXACT
ΔPE +25.51 / +48.66 → ±0.0000 EXACT
ΔSAP_c / Δcost unchanged at ±0.0000 EXACT (already closed
pre-slice by S0380.156..162).
All 25 cascade-OK variants in the heating-systems corpus now
SAP / cost / CO2 / PE EXACT vs worksheet on all 4 metrics, with
solid fuel 2 as the only remaining open residual (separate
S0380.154 summer-immersion-blend CO2/PE artifact — deferred).
Documented in
`domain/sap10_calculator/docs/SAP_CALCULATOR.md §8.1
"HW PE/CO2 factors on dual-rate tariffs use Table 12 annual"` —
the master doc now carries a new §8 "Elmhurst-mirrored spec
divergences" section for cases like this. Validation tally
refreshed from stale "930/930" to current "941/941".
No regressions on the 6 Elmhurst U985 fixtures (gas combi
STANDARD tariff — unaffected) or the cohort-1 ASHP certs
(STANDARD tariff — unaffected). The dual-rate gate fires only
on the 4 off-peak tariffs.
Verbatim spec quote retained for reference (SAP 10.2 Table 12
footnote (t), PDF p.189):
"PE factors for grid electricity vary by month. The average
figure given in this table is therefore not used directly.
Instead the monthly factors given in Table 12e should be used
in the SAP worksheet."
Tests: 907 pass (+1), 0 fail. Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Appendix N3.1 (PDF p.105) "Circulation pump and fan":
"For electric heat pumps: The electricity used by the water
circulation pump or fan is included within the calculated annual
space and hot water heating efficiency and is not included in
worksheet (230c). **The default heat gain from Table 5a is included
via worksheet (70).**"
This rule applies the Table 5a row "Central heating pump in heated
space" GAIN (3 / 10 / 7 W per pump-age bucket) to electric heat
pumps even though the pump ELECTRICITY is hidden in the COP and
excluded from (230c). The "Not applicable for electric heat pumps
from database" clause in Table 5a footnote a) scopes only to the
PCDB-Table-362 cascade case (Appendix N1.2.1: "For heat pumps held
in the PCDB ... a single water circulation pump serving the heat
emitters is sufficient" — pump kWh AND gain embedded in COP).
S0380.160 over-stripped the gain by zeroing pump_w for every HP
category-4 main, conflating the PCDB-Table-362 case with the Table-4a
default cascade. This slice refines the HP gate in
`_any_main_system_has_central_heating_pump`:
- Cat 4 HP WITH `main_heating_index_number` lodged (PCDB Table
362) → continue (skip; pump in COP per N1.2.1);
- Cat 4 HP with SAP code in `_TABLE_4A_WARM_AIR_SAP_CODES` (Cat 5
warm-air HPs distribute via ducted air, no water circulation
pump; warm-air fan handled separately by Table 5a "Warm air
heating system fans" row, S0380.161) → continue;
- Otherwise (Cat 4 HP, Table 4a default cascade, water-emitter)
→ apply Table 5a default per Appendix N3.1.
Per-line walk on ashp (SAP code 214 air-to-water HP, Cat 4, no PCDB,
"Post 2013" pump age):
worksheet (70)[Jan] = 3.0000 W
cascade pre-slice = 0.0000 W delta = -3.000 W
The -3 W winter gain shortfall over-stated cascade (84) Total gains
by -3 W in heating months → cascade SH demand +12.27 kWh/yr
(cascade 9302 vs worksheet 9290), pushing continuous SAP down 0.024
because the cost residual was driven by the +1.5 kWh × 12 month
shortfall flowing through the £0.0741 low-rate cost.
Closures:
ashp: ΔSAP -0.0240 → +0.0000 EXACT, Δcost +£0.55 → +£0.00 EXACT
gshp: ΔSAP -0.0178 → -0.0000 EXACT, Δcost +£0.41 → -£0.00 EXACT
ΔPE +36 → +25.51 (and ΔCO2 +7.33 → +6.31) — residuals narrow to the
Elmhurst-vs-spec HW PE annual-vs-monthly Table 12e/12d quirk only
(same pattern as the 16-variant lighting-PE deferred cohort,
scaled by HW kWh = 1138 vs 2384 → 25.51 vs 48.66). Cohort
Σ |ΔSAP_c| 0.07 → 0.03; all 25 cascade-OK variants now SAP+cost EXACT.
Cohort-1 (cert 0380 et al.) golden fixtures unaffected — those certs
lodge `main_heating_index_number` (PCDB Table 362) → HP gate skips
correctly → (70) = 0 preserved. Cert 000565 (HP main 1 + gas boiler
main 2) unaffected — wet-boiler branch fires for main 2.
Verbatim spec quote (SAP 10.2 Appendix N3.1, PDF p.105):
"For electric heat pumps: The electricity used by the water
circulation pump or fan is included within the calculated annual
space and hot water heating efficiency and is not included in
worksheet (230c). The default heat gain from Table 5a is
included via worksheet (70)."
Tests: 906 pass (+1), 0 fail. Pyright net-zero (35 → 35).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 5a (PDF p.177) row "Warm air heating system fans
a) c)" computes the gain as SFP × 0.04 × V (W). Footnote c) sets
the default SFP to 1.5 W/(l/s) when no PCDB warm-air-unit record
is lodged; footnote a) applies the heating-season-only mask
(zero in summer months). Footnote c) further omits the gain when
the dwelling has balanced whole-house mechanical ventilation
(MVHR / MV) — same omission as the Table 4f kWh-side footnote e).
Pre-slice the cascade's `internal_gains_from_cert` only wired the
central-heating-pump row of Table 5a; the warm-air-fan gain helper
(`warm_air_heating_fan_w`) existed but was unwired. The kWh-side
parallel (Table 4f, 136.35 kWh/yr) was wired in S0380.158 — this
slice closes the symmetry on the gain side.
Per-line walk on electric 2 (SAP code 524 = Cat 5 ASHP with
warm-air distribution, V = 227.25 m³, no balanced MV):
worksheet (70)[Jan] = 13.6350 W
cascade (70)[Jan] = 0.0000 W delta = -13.635 W
worksheet (98c)[Jan] = 1600.43 kWh
cascade (98c)[Jan] = 1608.12 kWh delta = +7.69 kWh
13.635 W = 1.5 × 0.04 × 227.25 exactly. The -13.6 W winter gain
shortfall propagates through the §7 utilisation cascade and over-
states cascade SH demand by ~57 kWh/yr (cascade 9483 vs worksheet
9426), under-charging cost by ~£2.50 with opposite sign to the
S0380.156-.158 closures.
Fix: new `_any_main_system_has_warm_air_distribution(epc)` +
`_has_balanced_mechanical_ventilation(epc)` predicates in
`internal_gains.py`, mirroring `cert_to_inputs._TABLE_4A_WARM_AIR_SAP_CODES`
+ `_BALANCED_MV_KIND_NAMES` (kept here as siblings so the worksheet
layer stays free of rdsap deps). Orchestrator wires
`warm_air_heating_fan_w(sfp=1.5, dwelling_volume_m3)` into the
heating-season term of `pumps_fans_monthly_w` when warm-air
distribution is present and balanced MV is not.
Closures electric 2:
ΔSAP_c -0.1087 → -0.0000 EXACT
Δcost +£2.50 → -£0.00 EXACT
ΔCO2 +16.54 → +11.95 (joins lighting-PE deferred cohort)
ΔPE +97.69 → +48.66 (joins lighting-PE deferred cohort)
Electric 2 joins the 15-variant lighting-PE deferred cohort
(electric 1 + electric 3/5/6/7/8/9 + solid fuel 5/6/7/8 + solid
fuel 4/9/10/11 + electric 2) where SAP/cost are EXACT but PE/CO2
carry an Elmhurst-vs-spec MONTHLY-factor offset (cohort uses
Table 12 annual factors on the off-peak HW immersion line; spec
mandates Table 12d/12e monthly per the header).
Verbatim spec quote (SAP 10.2 Table 5a row "Warm air heating
system fans a) c)", PDF p.177):
"Warm air heating system fans a) c) SFP × 0.04 × V"
Footnote c): "SFP is the specific fan power from the database
record for the warm air unit if applicable; otherwise
1.5 W/(l/s). These values of SFP include an in-use factor.
If the heating system is a warm air unit and there is balanced
whole house mechanical ventilation, the gains for the warm air
system should not be included."
Footnote a): "... Set to zero in summer months. ..."
Σ |ΔSAP_c| across 25-variant cohort: 0.18 → 0.07 (~60% reduction).
No regressions on the other 24 variants or any golden fixture —
gate keyed on Table 4a warm-air SAP code frozenset (only electric
2 in the corpus has a code in that set).
Tests: 905 pass (+1), 0 fail. Pyright net-zero (35 → 35).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 5a (PDF p.177) row "Central heating pump in heated
space" only applies to mains with a water-loop circulation pump.
Footnote a) names two exclusions verbatim ("Does not apply if a
heating system used solely for domestic hot water. ... Not applicable
for electric heat pumps from database."), and the row's name carries
the implicit third: dry mains with no central heating pump (electric
storage heaters, electric direct-acting, solid-fuel room heaters
without back-boilers) — the row simply doesn't list them.
Pre-slice `internal_gains_from_cert` gated only on Note a) (HP
exclusion), applying `central_heating_pump_w(date_category=...)` to
every non-HP main. The default UNKNOWN-date branch added 7 W of pump
gain to (70)m for every dry-system fixture in the controlled-variable
corpus, even though the worksheet (70)m = 0 every month.
Per-line walk on electric 3 (SAP code 401 "Manual charge control"):
cascade (73)[Jan] = 640.21 W
worksheet (73)[Jan] = 633.21 W delta = +7.00 W
cascade (70)[Jan] = 7.00 W
worksheet (70)[Jan] = 0.00 W Table 5a inapplicable
The +7 W winter-month gain lowered cascade SH demand by ~38 kWh/yr
(cascade 11050 vs worksheet 11088). At Table 32 18-hour low-rate
~7.4 p/kWh that's £2.50/yr under-charging — matching the cluster's
uniform Δcost = -£1.96..-£2.80 pattern. Continuous SAP rose ~+0.10
because cost dominates the ECF.
Fix: new `_any_main_system_has_central_heating_pump(epc)` predicate
in `internal_gains.py`, mirroring `cert_to_inputs._is_wet_boiler_main`
(S0380.149 — Table 4f kWh side). Wet if any non-HP main lodges:
- sap_main_heating_code in {101-141, 151-161, 191-196} (gas/oil/
solid-fuel/electric boilers per Table 4a/4b),
- main_heating_index_number (PCDB Table 322 record),
- main_heating_category in {1, 2} (RdSAP central heating), OR
- heat_emitter_type in {1, 3} (radiators / fan-coil per Table 4d).
Dead `_all_main_systems_are_heat_pumps` helper removed (the new
predicate subsumes its role).
Cluster closures (10 variants):
electric 3: SAP +0.1215 → -0.0000, cost -£2.80 → -£0.00
electric 5: SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00
electric 6: SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00
electric 7: SAP +0.1017 → -0.0000, cost -£2.34 → -£0.00
electric 8: SAP +0.0941 → -0.0000, cost -£2.17 → -£0.00
electric 9: SAP +0.1199 → -0.0000, cost -£2.76 → -£0.00
solid fuel 4: SAP +0.0850 → -0.0000, cost -£1.96 → -£0.00
solid fuel 9: SAP +0.1072 → -0.0000, cost -£2.47 → -£0.00
solid fuel 10: SAP +0.1134 → +0.0000, cost -£2.61 → -£0.00
solid fuel 11: SAP +0.0912 → +0.0000, cost -£2.10 → +£0.00
Σ |ΔSAP_c| across 25-variant cohort: 1.24 → 0.18. All 10 cluster
variants now join the lighting-PE +48.66 / CO2 +11.95 deferred
cohort (Elmhurst-vs-spec monthly factor quirk, same shape as
electric 1 + solid fuel 5/6/7/8 from prior closures).
Verbatim spec quote (SAP 10.2 Table 5a row 1, PDF p.177):
"Central heating pump in heated space, 2013 or later 3 a)"
"Central heating pump in heated space, 2012 or earlier 10 a)"
"Central heating pump in heated space, unknown date 7 a)"
The row name ("Central heating pump") gates by construction: dry
systems have no central heating pump and the row's three sub-rows
don't apply.
No regressions on the other 31 variants or any golden fixture; the
6 Elmhurst U985 fixtures lodge PCDB index → the new predicate
returns True → pump_w unchanged.
Tests: 904 pass (+1), 0 fail. Pyright net-zero (35 → 35).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the per-line walk discipline used to close electric 2 + 5
across four slices (.156 Table 3 WHC=903 primary-loss, .157 Table 2b
note b) WHC=903 ×0.9, .158 Table 4f warm-air heating fans, .159
Table 4a Cat 7 R tariff-aware dispatch). Σ |ΔSAP_c| across the
25-variant heating-systems corpus dropped from 2.87 → 1.21 (58%
reduction). All variants now sit under 0.3 SAP.
Next-slice candidate: the 9-variant cluster at ±0.09..0.12 SAP
(electric 3/5/6/7/8/9 + sf 4/9/10/11) — uniform pattern suggesting
a shared shave-the-residual fix. Worth a per-line walk on one
cluster variant before accepting the prior "Elmhurst-vs-spec quirk"
framing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4a (PDF p.166) Cat 7 "Electric storage heaters"
splits the responsiveness R between two sub-tables:
Off-peak tariff:
Slimline storage heaters ... R = 0.2 402
Convector storage heaters ... R = 0.2 403
Slimline + Celect-type control ... R = 0.4 405
Convector + Celect-type ctrl ... R = 0.4 406
24-hour heating tariff:
Slimline storage heaters ... R = 0.4 402
Convector storage heaters ... R = 0.4 403
Slimline + Celect-type control ... R = 0.6 405
Convector + Celect-type ctrl ... R = 0.6 406
Per SAP 10.2 §12.4.3 (PDF p.36) the 18-hour tariff has electricity
at low rate for 18 hours per day with at most 6h of interruption /
2h max each — operationally equivalent to 24-hour for storage-heater
charging. The cascade therefore routes EIGHTEEN_HOUR + TWENTY_FOUR_
HOUR through the 24-hour Table 4a sub-row.
Pre-slice `_responsiveness` keyed on `sap_main_heating_code` only
and returned R=0.2 for code 402 regardless of tariff. The existing
docstring already flagged the gap:
402: 0.20, # Slimline storage heaters (24-hr tariff: 0.40)
... "promote to (sap_code, tariff) lookup when 24-hour fixture
surfaces; until then the off-peak default applies (under-shoots
R for the 24-hour case)."
Per-line walk on electric 5 (sap_main_heating_code=402 +
meter_type="18 Hour"): cascade T_living (87)[Jan] = 20.1213 vs
worksheet 19.6519, (92)[Jan] = 18.6996 vs worksheet 18.2063, (93)
[Jan] = 19.0996 vs worksheet 18.6063 (cascade +0.4933 K throughout
the cascade). Back-solve from worksheet T_living=19.6519 via the
Table 9b Tsc formula:
Tsc(R=0.4) = 0.6 × (21-2) + 0.4 × (4.3 + 0.9933 × 705.4/210.23)
= 11.4 + 0.4 × 7.6325 = 14.4528
ΔT = 21 - 14.4528 = 6.5472
u_sum = 0.5 × 6.5472 × (7² + 8²) / (24 × 11.43) = 1.3481
T_living = 21 - 1.3481 = 19.6519 EXACT match.
Adds:
- `_CONTINUOUS_CHARGING_TARIFFS: frozenset[Tariff]` = {EIGHTEEN_
HOUR, TWENTY_FOUR_HOUR} — the tariffs treated as "24-hour
heating" for Table 4a R selection.
- `_RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE: dict[int, float]`
— the override table for codes 402/403/405/406 (404, 407, 409
keep the same R in both sub-tables).
- `tariff: Optional[Tariff]` parameter to `_responsiveness`, with
the override consulted before the off-peak default.
- Tariff threaded through both call sites of MIT cascade (rating
+ demand paths) via `tariff_from_meter_type`.
Closures electric 5:
ΔSAP −1.1759 → +0.1081 (91% reduction)
Δcost +£27.09 → −£2.49
ΔCO2 +62.72 → +7.30 kg
ΔPE +438.03 → +0.07 kWh (essentially EXACT)
Electric 5 now joins the same residual cluster as electric 3/6/7/8/
9 (+0.09..+0.12 SAP, −£2..−£3 cost, +£7 CO2) — the cluster that
the prior handovers suspected was a shared shave-the-residual gap.
No regressions on the other 24 cohort variants. Extended handover
suite: 903 pass / 0 fail (was 902 — +1 from the new AAA test).
Pyright net-zero (43 → 43).
Σ |ΔSAP_c| across the 25-variant cohort: 2.30 → 1.24 (~46%
reduction from this slice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans"
+ footnote e) — verbatim:
Warm air heating system fans e) SFP × 0.4 × V
e) SFP is the specific fan power from the database record for the
warm air unit if applicable; otherwise 1.5 W/(l/s). These values
of SFP include the in-use factor.
If the heating system is a warm air unit and there is balanced
whole house mechanical ventilation, the electricity for warm
air circulation should not be included in addition to the
electricity for mechanical ventilation. However it is included
for a warm air system and MEV or PIV from outside.
V is the volume of the dwelling in m³.
Per Table 4a (PDF p.165-166), warm-air systems are:
- Category 5: heat pumps with warm-air distribution (codes 521,
523, 524 electric; 525, 526, 527 gas-fired)
- Category 9: warm-air systems NOT heat pump (501-511, 520 gas-
fired; 512-514 liquid-fired; 515 Electricaire electric)
Pre-slice the cascade's `_table_4f_additive_components` docstring
explicitly listed "(230b) Warm-air heating fans + (230c) for warm-
air pump" as "Not yet wired" — every Cat 5 / Cat 9 warm-air corpus
variant resolved `pumps_fans_kwh_per_yr` to 0. For electric 2 (code
524 Cat 5 air-source warm-air HP, no MV, V = 227.25 m³), the P960
worksheet block 11a (249) lodges 136.35 kWh × 13.67 p/kWh = £18.64
where the cascade computed 0.
New `_TABLE_4A_WARM_AIR_SAP_CODES` frozenset (22 codes) + leaf helper
`_table_4f_warm_air_heating_fans_kwh(main, dwelling_volume_m3,
has_balanced_mv)` wired at the orchestrator pumps_fans summation
alongside the existing circulation-pump and gas-flue-fan helpers.
Footnote-e balanced-MV omission reads `epc.sap_ventilation.
mechanical_ventilation_kind` via the new
`_has_balanced_mechanical_ventilation` predicate (returns True for
MVHR / MV; False for MEV / PIV / NATURAL).
Per-line walk evidence: cascade `pumps_fans_kwh_per_yr` = 0.0000 vs
worksheet (249) = 136.3500 = 1.5 × 0.4 × 227.25 exactly. Default SFP
from footnote e matches; PCDB warm-air-unit SFP lookup deferred
until a fixture exercises it.
Closures electric 2:
pumps_fans_kwh_per_yr: 0 → 136.35 (EXACT match to worksheet)
ΔSAP +0.7002 → −0.1087 (residual swung past worksheet — the +0.70
pre-slice was an under-counted-fan offset; spec-correct fix lands
just past zero, exposing a small upstream SH cascade gap likely
in the Cat 5 warm-air HP Table 4a SH efficiency or Table 9c MIT
cascade for warm-air mains — follow-up slice)
Δcost −£16.14 → +£2.50
ΔCO2 −2.37 → +16.54 kg
ΔPE −108.58 → +97.69 kWh
No regressions on the other 24 cohort variants — the warm-air-code
gate fires only when `sap_main_heating_code` is in the new frozenset
and only electric 2 has a warm-air SAP code in the corpus. Extended
handover suite: 902 pass / 0 fail (was 901 — +1 from the new AAA
test). Pyright net-zero (43 → 43).
Σ |ΔSAP_c| across the 25-variant cohort: 2.87 → 2.30 (~20%
reduction from this slice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 2b note b) (PDF p.159) — verbatim:
Multiply Temperature Factor by 0.9 if there is separate time
control of domestic hot water (boiler systems, warm air systems
and heat pump systems).
The parenthetical list restricts the rule to systems where the heat
generator (boiler / warm-air / HP) is the device heating the
cylinder. Electric immersion is NOT in that list because the
immersion isn't a heat-generator system feeding DHW — it sits inside
the cylinder. The ×0.9 multiplier reflects shorter cylinder-heating
periods when a boiler / HP / warm-air operates on a separate timer
for DHW vs SH; if the heat generator doesn't feed the cylinder at all
(because the immersion does), there's no such timing effect.
Pre-slice `_separately_timed_dhw` returned True for any Cat 4 HP
main BEFORE consulting WHC (line 3872 `if main.main_heating_category
== 4: return True`). For electric 2 (sap_main_heating_code=524 Cat 5
warm-air ASHP, main_heating_category=4 per Elmhurst mapper, WHC=903
electric immersion + cylinder + cylinder thermostat lodged), the
cat-4 branch fired before the existing `_is_electric_water` check
could route the cert to False. The cascade applied ×0.9 to the
Temperature Factor (53), pulling (55) from 1.2294 → 1.1064 → cascade
annual (56) = 403.87 vs worksheet (56) annual = 448.73.
Same WHC=903 principle as the prior slice S0380.156 (Table 3 zero-
loss list for electric immersion): when HW is independent of the
main heating, main-heating-specific DHW rules don't apply — even
when the main happens to be a HP / boiler / warm-air system.
Fix: new top-of-function `if epc.sap_heating.water_heating_code ==
_WHC_ELECTRIC_IMMERSION: return False` guard in
`_separately_timed_dhw`. Reuses the constant introduced in S0380.156.
Closures electric 2:
Cylinder (56) storage loss annual 403.87 → 448.73 (matches
worksheet 1.2294 × 365 = 448.73 EXACT within rounding)
HW kWh demand 2339.24 → 2384.12 (matches worksheet (62)/(64) =
2384.116 EXACT)
ΔSAP +0.8118 → +0.7002
Δcost −£18.71 → −£16.14
ΔCO2 −7.21 → −2.37 kg
ΔPE −161.68 → −108.58 kWh
The remaining +0.70 SAP residual is a separate upstream gap (likely
warm-air-HP SH cascade or Table 4a SH efficiency for code 524) —
follow-up slice.
No regressions on the other 24 cohort variants. Cohort-1 ASHP certs
(Cat 4 HP + WHC=901 = HW from HP + cylinder) keep ×0.9 as before
because their WHC=901 doesn't trigger the new guard. Extended
handover suite: 901 pass / 0 fail (was 900 — +1 from the new AAA
test). Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) verbatim:
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
The Elmhurst WHC=903 lodging signals exactly the first row: "HW from
a separate electric immersion heater" — the cylinder is heated by an
immersion element inside the tank, no primary pipework between any
heat generator and the cylinder. The rule is universal: regardless
of what main heating exists for space heating, electric immersion
means no primary circuit means no primary loss.
Pre-slice `_primary_loss_applies` only consulted `water_heating_code`
in the Table 4a wet-boiler branch (codes 151-161 / 191-196). The Cat
4 HP branch returned True unconditionally when no PCDB record was
lodged; the Cat 1/2 boiler branch returned True unconditionally; the
PCDB Table 322 + Table 4b non-PCDB branches likewise. For the
electric 2 corpus variant (sap_main_heating_code=524 Cat 5 warm-air
ASHP, main_heating_category=4 per Elmhurst mapper, no PCDB record,
WHC=903 + cylinder), the Cat-4 branch falsely returned True and the
cascade added ~510 kWh/yr primary loss to a system with no primary
circuit at all.
Per-line walk discipline applied: cascade `water_heating_from_cert`
output dump showed `primary_loss_monthly_kwh_annual = 509.98` while
worksheet (59)m = 0 every month → spec lookup found Table 3 verbatim
"Electric immersion heater" zero-loss line.
Adds `_WHC_ELECTRIC_IMMERSION: Final[int] = 903` constant + a
top-of-function `if water_heating_code == _WHC_ELECTRIC_IMMERSION:
return False` guard that fires before any of the system-type-keyed
branches.
Closures electric 2:
HW kWh 2849.22 → 2339.24 (matches worksheet (62)/(64) = 2384.12
within the residual ~45 kWh storage-loss gap)
ΔSAP −0.4584 → +0.8118 (cascade swung past the worksheet by +1.27
— the pre-slice 'near-correct' value was offsetting cascade bugs
per [[feedback-software-no-special-handling]]; the +0.81 residual
exposes a separate upstream gap to chase in a follow-up slice)
Δcost +£10.56 → −£18.71
ΔCO2 +47.89 → −7.21 kg
ΔPE +443.13 → −161.68 kWh
No regressions on the other 24 cohort variants — only electric 2 has
the (Cat 4 HP, no PCDB, WHC=903) combination in the corpus.
Extended handover suite: 900 pass / 0 fail (was 899 — +1 from the
new AAA test). Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>