Commit graph

257 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
c236aa5836 S0380.226: map Elmhurst "Jacket" cylinder insulation → loose-jacket (code 2)
The Summary-path mapper raised UnmappedElmhurstLabel for a §15.1
"Cylinder Insulation Type: Jacket" lodging — only "Foam" (→1, factory)
was mapped. SAP10 cylinder_insulation_type uses 2 for loose jacket
(matching the GOV.UK API codes), and SAP 10.2 Table 2 Note 1 gives it a
separate ~2× storage-loss factor that the cascade now handles
(S0380.224). Add "Jacket" → 2 for cross-mapper parity with the API path
and so the loose-jacket storage-loss branch fires on the Summary path.

Surfaced by simulated case 19 (a 210 L jacket cylinder + electric storage
heaters), which previously couldn't extract at all. §4 suite 2397 passed;
mapper.py pyright unchanged at 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:07:37 +00:00
Khalim Conn-Kowlessar
fe59c4d8a2 S0380.208: case 7 combi e2e fixture — condensing-oil-combi path validated exact
Adds simulated case 7: case 6 (P960-0001-001431) with the heating swapped
to a CONDENSING OIL COMBI (SAP code 130, Table 4b 82/73) and the cylinder
removed — combi instantaneous DHW (WHC 901), Table 3a keep-hot combi loss
(61) = 600 kWh/yr, no primary/storage loss, boiler interlock PRESENT (no
−5pp). This is the heating archetype golden cert 0240-0200-5706-2365-8010
uses, which case 6 (SAP code 127, a *regular* condensing oil boiler +
cylinder) never exercised.

The cascade reproduces the case-7 worksheet EXACTLY at abs=1e-4 on every
top-level SapResult output with ZERO calculator changes:
  (211) 7865.4304  (213) 7556.9821  (219) 3496.8121  (98c) 12646.3783
  (255) 1123.3372  (257) 1.9631     (272) 5738.9315  (258) 73
This validates the SAP 10.2 Appendix D Eq D1 combi efficiency blend +
Table 3a keep-hot combi loss + Table 4b code 130 (82/73) path, and
exonerates the combi mechanism as the source of 0240's API-path residual
— which therefore lives in 0240's fabric/demand or the API mapper.

Test-only slice (no impl change). New fixture file: 0 pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:57:22 +00:00
Khalim Conn-Kowlessar
2b1afa7339 S0380.204: extract Main Heating2's own emitter + control (§14.1)
Prerequisite for the SAP 10.2 p.186 two-systems-different-parts MIT.
When two main systems heat different parts of a dwelling, §14.1 Main
Heating2 lodges its OWN "Heat Emitter" + "Main Heating Controls Sap"
(simulated case 6: Main 1 radiators / control 2106 serving the living
area, Main 2 underfloor / control 2110 serving elsewhere). The extractor
+ mapper dropped both — `MainHeatingDetail.heat_emitter_type` and
`main_heating_control` came through as empty-string sentinels, so the
cascade saw system 2 as having no responsiveness (defaulted R=1.0) and no
control type.

- `MainHeating2` datatype gains `heat_emitter` + `heating_controls_sap`.
- The extractor reads them from the §14.1 block.
- `_map_elmhurst_main_heating_2` maps them via the same helpers as Main 1
  (`_elmhurst_heat_emitter_int` → underfloor-in-screed = emitter 2, Table
  4d R=0.75; `_elmhurst_sap_control_code` → 2110, Table 4e type 3),
  threading the dwelling floor + age band for the underfloor subtype.

Empty-string fallback preserved for the legacy DHW-only Main 2 (cert
000565 §14.1 omits emitter/control). No cascade output changes yet — the
MIT consumer lands in S0380.205. Full suite 2358 pass + 0 fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:53:32 +00:00
Khalim Conn-Kowlessar
2b1f90a7de S0380.199: site-notes "Roof of Room" windows → roof windows (cross-mapper parity with S0380.198)
The Elmhurst extractor crashed parsing simulated-case-6's room-in-roof
window rows: the §11 "Location" cell "Roof of Room in Roof" wraps across
the layout prefix/suffix blocks and leaked into the glazing-type phrase
("Double between 2002 Roof of Room and 2021 in Roof" → UnmappedElmhurst-
Label). Fix (`_parse_window_from_anchors`): detect the roof-of-room
location tokens, strip them from the before/after blocks so the glazing
phrase reconstructs cleanly, and set location="Roof of Room".

Mapper: `_is_elmhurst_roof_window` gains a "Roof of Room" location branch
(highest-confidence rooflight signal, above the BP-roof-type / U>3.0
gates); `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING` gains "Double between 2002
and 2021" → 2.30 (case 6 lodges the already-inclined roof-window U, so
the +0.30 inclination adjustment must not double-apply).

This is the site-notes mirror of S0380.198 (API window_wall_type=4):
both paths now route room-in-roof rooflights to (27a) at the inclined U.
Validated against the case-6 P960 worksheet at abs=1e-4:
  (27)  Windows      = 22.7408 (cascade 22.7407)
  (27a) Roof Windows = 13.0375 (cascade 13.0375, EXACT)
  (31)  ext area     = 336.13

Case 6 is pinned only on the §3 window line refs (new standalone test,
not added to the section-pin `_FIXTURES`) because its DUAL main heating
(51% rads + 49% underfloor, oil) makes the §10/§12 per-system lines
non-comparable to SapResult's aggregated fields — documented in the
fixture module. Summary mirrored to Summary_001431_case6.pdf.

Suite: 2355 passed, 1 skipped. New code: 0 pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:46:18 +00:00
Khalim Conn-Kowlessar
570df83459 S0380.197: simulated case 5 e2e fixture — detached sandstone RR validates S0380.196 (RdSAP 10 §3.9.1 + Table 4 p.22)
Promotes user-simulated "case 5" (detached, sandstone-walled, room-in-roof
cousin of golden cert 0240) to an e2e worksheet fixture pinning the WHOLE
extractor → mapper → calculator pipeline at abs=1e-4 on all 11 Block-1
line refs. Its worksheet prints the exact RR-gable routing S0380.196
implements, validating that fix against ground truth:

  Roof room Main Gable Wall 1  15.68  U=0.35  (29a)  Exposed → walls @ main-wall U
  Roof room Main remaining area 61.73  U=0.30  (30)  A_RR shell − Σ gables
  External roof Main           14.52  U=0.11  (30)  loft residual
  Roof room Main Gable Wall 2  15.68  U=0.25  (32)  Party → party @ 0.25

gable area = 6.40 × 2.45 (§3.9.1 default RR storey height); A_RR remaining
= 12.5√(83.2/1.5) − 2×15.68 = 93.09 − 31.36 = 61.73 (RdSAP 10 §3.9.1(e)).
Confirms a DETACHED dwelling can lodge a Party RR gable (Table 4 p.22
row 2) — so my S0380.196 mapping (gable_wall_type 0=Party, 1=Exposed) is
correct; do not flip it.

Two extractor/mapper gaps surfaced and fixed (case 5 is the forcing test):
- Sandstone wall label "SS Stone: sandstone or limestone" had no
  `_ELMHURST_WALL_CODE_TO_SAP10` entry (raised UnmappedElmhurstLabel).
  Added "SS" → 2 (WALL_STONE_SANDSTONE), matching 0240's API
  wall_construction=2 (cross-mapper parity).
- Roof "Insulation Thickness 400+ mm" was silently dropped: the four
  thickness parsers used `.split()[0].isdigit()`, which rejects the
  trailing "+" → None → u_roof fell back to the age-J default 0.16
  instead of 0.11 (+1.09 W/K roof, the whole 0.12 SAP gap). Added
  `_parse_thickness_mm` (strips to leading digits) and applied it at all
  four sites (walls / alt-wall / roof / floor). The only existing fixture
  with "400+ mm" (000565 Stud Wall) routes via the RIR regex, unaffected.

Result: case 5 cascade ≡ worksheet at 1e-4 on SAP/ECF/cost/CO2 + every
energy stream. Neither gap affects 0240 (its API path captures both the
sandstone code and "400mm+"); 0240's residual is therefore non-fabric.

Suite: 2353 passed, 1 skipped. New code: 0 pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:41:16 +00:00
Khalim Conn-Kowlessar
4a21717de6 S0380.195: pin sim case 4 (6035 floor geometry) e2e at 1e-4 — 6035 +19 PE is lodged divergence
Adds the user-simulated case-4 worksheet as e2e fixture `001431_6035` —
reproduces golden cert 6035's full floor geometry (Main ground-floor HLP
15.99 + first-floor HLP 8.32, the asymmetric upper storey) and 8 windows.
All 11 Block-1 line refs pin at abs=1e-4 against the worksheet (SAP 68,
ECF 2.2802, cost 937.2341, CO2 4682.3494, space 15745.3260, main fuel
18744.4357).

This is the 4th independent 1e-4 confirmation across the 6035 archetype
(sim cases 1-4). Case 4 matches 6035 on floors + window areas; the
residual ~50 kWh / £11 cascade delta vs 6035 is two lodged inputs only
(largest window orientation N vs S; meter type "Dual" vs API 2), not
calculator behaviour.

Conclusion: the cascade reproduces the spec engine exactly for 6035's
geometry, so 6035's +19 PE vs the lodged register is lodged-register
divergence (the gov.uk register's rounded value vs the spec-exact
worksheet), NOT a calculator gap. 6035 is a "pin-forever" lodged-only
cert. Bugs surfaced + fixed along the way: S0380.192 (Simplified-RR
remaining area) and S0380.193 (suspended-floor sealed rule).

2341 passed (+11), 0 failed; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:56:39 +00:00
Khalim Conn-Kowlessar
e7a0c9885e S0380.194: pin sim case 3 (near-exact 6035 replica) e2e at 1e-4
Adds the user-simulated case-3 worksheet as e2e fixture `001431_rr8` —
Main + Extension + Simplified room-in-roof with 8 windows (≈14.15 m²,
reproducing golden cert 6035's glazing) and Main ground-floor HLP 15.99.
All 11 Block-1 line refs pin at abs=1e-4 against the worksheet (SAP 68,
cost 951.3425, CO2 4767.4862, space 16086.3557, main fuel 19150.4235,
HW 3307.2639, lighting 262.0885).

This is the third independent 1e-4 confirmation that the cascade
reproduces the spec engine for the 6035 archetype (after S0380.192
Simplified-RR + S0380.193 suspended-floor). It differs from 6035 in one
input only — the Main first-floor HLP (15.99 here vs 6035's 8.32) — so
6035's +19 PE vs the lodged register is lodged-register divergence, not
a calculator gap. A byte-identical 6035 replica (first-floor HLP 8.32)
would let 6035 itself be pinned directly to close that out.

2330 passed (+11), 0 failed; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:46:56 +00:00
Khalim Conn-Kowlessar
62fc27a5cc S0380.193: suspended-floor (12) sealed rule fires only on a SUPPLIED U-value
RdSAP 10 §5 (PDF p.29) "Floor infiltration (suspended timber ground
floor only)", age band A-E, splits on whether a floor U-value is
supplied:
  a) [U-value supplied] if floor U-value < 0.5 → "sealed", (12) = 0.1
  b) [no U-value supplied] retro-fitted insulation → "sealed" 0.1;
     otherwise "unsealed", (12) = 0.2

`_has_suspended_timber_floor_per_spec` fed the cascade's COMPUTED default
U into rule (a), so an as-built/uninsulated suspended-timber floor whose
default U happens to be < 0.5 was marked "sealed" (0.1) where Elmhurst
uses "unsealed" (0.2). That dropped (18) infiltration 0.85 → 0.75, (25)
effective ACH, HTC, and understated space heating ~450 kWh.

Fix: gate rule (a) on `floor_u_value_known` — a computed default U is not
a supplied value, so it falls through to (b). Verified against the
cert 001431 sim-case-2 worksheet: floor "As built", U=0.43 (matches the
worksheet's (28a) 0.4300 exactly), (12)=0.2 unsealed. Golden cert 6035
(also a suspended uninsulated floor) is unaffected — its U=0.63 ≥ 0.5
already routed to unsealed.

Promotes sim case 2 to the e2e harness as `001431_rr` (Main + Extension
+ Simplified room-in-roof — the 6035 archetype). All 11 Block-1 line
refs pin at abs=1e-4, locking BOTH this fix and S0380.192 (Simplified-RR
remaining area) end-to-end: SAP 69, cost 920.5046, CO2 4566.7090, space
15269.8593, main fuel 18178.4039. 2319 passed (+11), 0 failed; pyright
net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:16:25 +00:00
Khalim Conn-Kowlessar
ec9ef0e8bb fix(extractor): drop windows-table header remnant from first window glazing type
Summary PDFs preprocessed from `pdftotext -layout` wrap the windows-table
header across several lines. The third header line's tail ("U value / g
value / Draught Proofed / Permanent Shutters") tokenises to "value value
Proofed Shutters" and lands directly above the FIRST window's data row.

Because the first window in a building part has `before_start = 0`, its
prefix block reaches back into that header remnant. The remnant is
neither an orientation nor a building-part fragment, so it survived the
pops in `_compose_window_descriptors` and leaked into glazing_type as
"value value Proofed Shutters Double between 2002 and 2021" (windows 2-3,
whose prefix starts after the previous window's manufacturer line, were
clean).

Fix: the glazing-type phrase always starts with a glazing-start word
(Single/Double/Triple/Secondary), so trim any prefix fragments preceding
that word before joining the glazing type. Orientation/bp pops still run
on the full prefix, so they are unaffected.

Reproduced from `sap worksheets/Recommendations Elmhurst Files/
cavity_wall_insulation - main wall/before/Summary_001431.pdf`. Added a
regression test driving the real `_extract_windows_from_layout` path with
the verbatim tokenised header+rows. 2306 passed (+4), pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:54:49 +00:00
Khalim Conn-Kowlessar
896b5740c3 S0380.191: pin simulated 001431 gas-combi end-to-end at 1e-4 (e2e harness)
Adds the user-simulated 001431 case (the cert that drove S0380.189/.190)
as an Elmhurst-only e2e fixture: Summary PDF → extractor → mapper →
calculator, every Block-1 SapResult field pinned against the
P960-0001-001431 worksheet at abs=1e-4. All 11 pins pass with zero
residual — the case is clean, confirming the S0380.190 gas-combi fuel
derivation closes the Summary path natively.

Verified the handover's flagged "+0.0007 SAP" was a target artifact, not
a cascade gap: the worksheet displays ECF (257) rounded to 1.6047 and
integer SAP (258)=78; the cascade's continuous SAP is computed from the
UNROUNDED ECF = (255)*(256)/((4)+45) = 660.9750*0.4200/173.0, giving
77.6147 — which matches the worksheet's own unrounded value. Pinning the
continuous SAP from the display-rounded ECF (→ 77.6144) was the wrong
target. Block-1 line refs all match exactly: (211) 10699.7225, (219)
3327.1592, (231) 86.0, (232) 283.2229, (255) 660.9750, (272) 3000.1664,
Σ(98) 8987.7669.

Summary mirrored into the tracked fixtures dir as
Summary_001431_gas_combi.pdf (distinct name — the corpus reuses cert
001431 across every heating variant); source Summary + worksheet tracked
under sap worksheets/golden fixture debugging/ as the pin ground truth.

2302 passed (+11), 0 failed; pyright net-zero on new/changed files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:44:32 +00:00
Khalim Conn-Kowlessar
57241322ea S0380.185: record CH6 pin-forever proof — distribution-loss is a Summary-export gap
CH6's P960 worksheet input lodges Distribution Loss = "Two adjoining
dwellings sharing a single heating system" → (306) DLF = 1.0000, vs CH4's
"Calculated" → 1.5 → (306) = 1.4500. That DLF choice swings SAP/cost/CO2/PE
materially, but it is NOT present in the Summary PDF that the corpus pipeline
consumes (Summary → ElmhurstSiteNotesExtractor → mapper → calculator).

Proven empirically with a user-supplied controlled pair (CH adjoined
dwellings/Summary_001431 (1) vs (2)): the two Summaries are byte-identical
across every RdSAP INPUT field, differing only in the derived header
(SAP 80 vs 75, bill £954 vs £1237, emissions 5.407 vs 7.394 t). A
case-insensitive scan of the CH6 Summary for "distribution"/"adjoin" returns
0 hits. Since CH4/CH6 Summaries are themselves identical bar fuel type, no
Summary-derivable rule can yield CH4=1.45 AND CH6=1.0.

Doc-only change (comment in _EXPECTATIONS); 20/20 community-heating corpus
tests pass. Closes the CH6 re-litigation: pin held.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:21:28 +00:00
Khalim Conn-Kowlessar
82f7315f8d S0380.184: community electric-HP network CO2/PE uses monthly Table 12d/12e — closes CH3
SAP 10.2 worksheet block 12b/13b (367)/(467) for a community heating
electric heat pump (Table 4a code 304 → Table 12 fuel 41 "heat from
electric heat pump"). The HP meters grid electricity, so per Table 12
note (s)/(t) + block 12b/13b footnote (a) its emission/PE factor is the
MONTHLY Table 12d/12e cascade (fuel 41 = standard-electricity profile),
weighted by the network heat profile, then × 1/heat-source-eff (1/COP):

  (367)/(467) = [(307)+(310)] / COP × Σ((307+310)_m × factor_m)/Σ(...)

Per-line walk of CH3 (the displayed (367) 0.1535 / (467) 1.5717 are PDF
artifacts; the (373)/(473) totals reconcile only with):
  CO2 factor = 0.15040 (monthly Table 12d wtd) vs cascade annual 0.136
  PE  factor = 1.55692 (monthly Table 12e wtd) vs cascade annual 1.501

Pre-slice the cascade routed code 304 through the non-electric branch
(`_co2_factor_kg_per_kwh(main) × 1/COP` = annual × scaling). New
`_is_heat_network_electric_main` (heat-network main whose fuel has a
Table 12d monthly set — i.e. fuel 41) routes all four factor helpers
(main + HW, CO2 + PE) through the monthly cascade × 1/COP. Non-electric
heat networks (gas 51 / oil 53 / coal 54) have no monthly set → annual
path unchanged (CH1, CH6 untouched).

Closure (CH3 was already SAP+cost EXACT):
  CH3 (HP/Elec)  CO2 −75.32→+0.0000 (= [(307+310)/3]×(0.1504−0.136)),
                 PE −249.32→−0.0000 (× (1.5569−1.501))  — FULLY EXACT

Corpus now 40/41 EXACT on all four metrics. Only CH6 remains: its
worksheet lodges a manual DLF=1.0 ("two adjoining dwellings") absent
from the Summary PDF (byte-identical to CH4 bar fuel type) — an
architectural limit, not a cascade gap. 2226 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:43:16 +00:00
Khalim Conn-Kowlessar
803da062a2 S0380.183: community-heating HW bills at heat-network rate (§10b) — closes CH2/CH4 fully
SAP 10.2 §10b: hot water for a community-heating dwelling bills at the
heat-network rate, not the cert-lodged fuel. Elmhurst §15.0 lodges
`water_heating_fuel_type = "Mains gas"` (3.48 p/kWh) as a placeholder on
community certs; the worksheet (342) Water-heating cost = (310) × the
S0380.171 CHP heat-fraction blend — the SAME rate as space heating (340).

Per-line walk of the CH2 block 10b:
  (340) space   = 11837.83 × 0.037955 = 449.3047  (cascade EXACT)
  (342) water   =  3854.12 × 0.037955 = 146.2830  (cascade billed
                  3854.12 × 0.0348 = 134.12 → −£12.16, the whole residual)
  (350) lighting + (351) standing → (355) 754.1502.

`_hot_water_fuel_cost_gbp_per_kwh`'s `inherit_main_for_community_heating`
path already routes HW cost through `_fuel_cost_gbp_per_kwh(main)` (the
CHP blend), but its gate `_is_community_heating_hw_from_main` excluded
code 302. S0380.182 wired the 302 CO2/PE credit via
`_heat_network_code_302_effective_factor`, which intercepts the HW
CO2/PE helpers ABOVE this predicate's branch — so extending the
predicate to include 302 now affects ONLY the cost path.

Closures:
  CH2 (CHP/Gas)  SAP +0.5277→−0.0000, cost −£12.16→−£0.00  — FULLY EXACT
  CH4 (CHP/Oil)  SAP +0.5277→−0.0000, cost −£12.16→−£0.00  — FULLY EXACT
  CH6 (CHP/Coal) SAP −7.49→−8.02, cost +£172.68→+£184.84 — its HW now
                 also bills the blend, compounding the DLF=1.0 quirk
                 (cascade DLF=1.45); same separate CH6 DLF front.

Corpus now 39 variants EXACT on all four metrics (CH2/CH4 join). Open:
CH3 CO2/PE (code-304 community-HP COP), CH6 all-metric (DLF=1.0 manual
override the Summary doesn't carry). 2225 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 32→32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:29:57 +00:00
Khalim Conn-Kowlessar
8e86de2257 S0380.182: community-heating CHP+boilers CO2/PE credit (§12b/13b) — closes CH2/CH4 CO2+PE
SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community heating
"CHP and boilers" (SAP code 302). Per unit of network heat fuel
H = (307)+(310) the effective generation factor is:

  chp×100/(362)×f_fuel − chp×(361)/(362)×f_disp + (1−chp)×100/(367)×f_fuel

  (363)/(463) CHP fuel      = chp_frac × 100/heat_eff × f_fuel
  (364)/(464) less credit   = −chp_frac × elec_eff/heat_eff × f_disp
  (368)/(468) boiler fuel   = (1−chp_frac) × 100/boiler_eff × f_fuel

f_fuel = Table 12 heat-network fuel factor (the CHP unit and the back-up
boilers burn the same community fuel — verified vs CH2 gas / CH4 oil /
CH6 coal worksheets (363)/(368)); f_disp = Table 12f (PDF p.196) credit
for the CHP-generated electricity. RdSAP 10 §C (p.58) defaults: heat eff
50% (362), electrical eff 25% (361), boiler eff 80% (367); CHP heat frac
0.35 per-cert via community_heating_chp_fraction.

New `_heat_network_code_302_effective_factor` + Table 12f flexible
constants (0.420 CO2 / 2.369 PE) + RdSAP §C efficiency constants, wired
into all four factor helpers (main + HW, CO2 + PE) ahead of the existing
single-fuel / 1-over-heat-source-eff path. The worksheet (368)/(468)
boiler emissions DISPLAY rounded/mis-aligned in the PDF, but the
(373)/(473)/(386)/(486) totals reconcile only with the boiler at the
full Table 12 factor — verified EXACT.

Two spec citations applied:
- Table 12f flexible-operation default for RdSAP community CHP is an
  Elmhurst engine choice (Table 12f notes make "standard" the default);
  mirrored per [[feedback-software-no-special-handling]] and documented
  in SAP_CALCULATOR.md §8.3.
- Table 12 heat-network oil/biodiesel CO2 (codes 53/56) corrected
  0.298 → 0.335 per Table 12 (p.189) "assumes 'gas oil'"; the code-302
  oil cascade (CH4) was the first to exercise it. PE 1.180 was already
  correct. No other variant uses these codes (no regression).

Closures (CO2 + PE only — the CHP credit does not touch cost/SAP):
  CH2 (CHP/Gas)  CO2 −1411.49→+0.0000, PE +1331.23→+0.0000  EXACT
  CH4 (CHP/Oil)  CO2 −4378.24→−0.0000, PE  +319.81→−0.0000  EXACT
  CH6 (CHP/Coal) CO2/PE re-pinned (+2411.54 / +5023.48) — its worksheet
                 lodges a manual DLF=1.0 the Summary doesn't carry, so
                 cascade DLF=1.45 over-scales H; same root as the CH6
                 SAP −7.49 / cost +£172 (separate DLF front).

CH2/CH4 are now CO2+PE-exact but still carry the heat-network cost/SAP
residual (+0.5277 SAP / −£12.16 cost, exposed by S0380.175 — cost-side,
untouched here). CH3 unchanged (code 304 community-HP COP front).

Corpus state: 37 variants EXACT on all four metrics (incl. CH1);
remaining residuals are CH2/CH4 cost+SAP, CH3 CO2+PE (HP COP), CH6
all-metric (DLF quirk). 2223 pass + 1 skip + 0 fail (tolerances 1e-4 all
metrics per S0380.181); pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:23:17 +00:00
Khalim Conn-Kowlessar
02a89bcb39 S0380.181: tighten heat-systems corpus residual tolerances to 1e-4 (all metrics)
The corpus residual-pin tolerances had drifted looser than the comment
above them claimed ("pin at 1e-4 relative to lodged precision"): SAP was
1e-3, cost ±£0.01, CO2 ±0.1 kg, PE ±0.1 kWh. A ±0.1 kg CO2 band could
silently mask a ~0.09 kg drift on a variant we report as EXACT.

The worksheet pins are extracted from the P960 PDF text, which prints
4 d.p., so the hard residual floor is ~5e-5 (half a unit in the last
printed digit) regardless of cascade precision. 1e-4 sits just above
that floor. All 41 variants hold at uniform 1e-4 on continuous SAP,
cost, CO2 AND PE — confirming the 37 EXACT variants are genuinely exact
to PDF print-rounding and the looser bands were masking nothing.

Aligns the guard with [[feedback-zero-error-strict]] /
[[feedback-continuous-sap-tolerance]] (basically zero error across all
four metrics). Test-only change; no cascade behaviour touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:11:28 +00:00
Khalim Conn-Kowlessar
8452cf9e2d S0380.180: heat-network distribution pumping electricity (§C3.2) — closes CH1
SAP 10.2 Appendix C §C3.2 (PDF p.51), verbatim: "CO2 emissions and
Primary Energy associated with the electricity used for pumping water
through the distribution system are allowed for by adding electrical
energy equal to 1% of the energy required for space and water heating."

Worksheet line (313) = 0.01 × [(307)+(310)]; its CO2 (372) and PE (472)
bill on the Table 12d/12e monthly factors for fuel code 50 ("electricity
for pumping in distribution network"), weighted by the monthly heat
profile per worksheet footnote (a). (307)m/(310)m = (space_demand +
hw_output) / efficiency (the cascade models a heat network's generator
efficiency as 1/DLF).

This un-defers the (372)/(472) front the post-S0380.179 handover flagged
"don't guess until the factor source is identified": the source is
§C3.2 + Table 12d/12e code 50, NOT an empirical constant. The apparent
0.1994/0.2114 "factor" is an Elmhurst DISPLAY artifact — the worksheet
shows the (372) energy column as 0.01×(307) (space only) while computing
emissions on 0.01×(307+310) per the §C3.2 text. Verified EXACT line-by-
line against the CH2 corpus worksheet: (372)=23.6007 CO2 (rating),
(472)=208.2267 PE (demand).

New `_heat_network_distribution_electricity` helper (gated on
`_is_heat_network_main`) precomputes the energy + effective CO2/PE
factors; three new CalculatorInputs fields + calculator.py CO2/PE
summation terms (0.0/None → no-op for individually-heated certs).

Closures:
  CH1 (Boilers/Gas)  CO2 −23.60→−0.00, PE −208.23→+0.00  — FULLY EXACT
  CH3 (HP/Elec)      CO2 −98.92→−75.32, PE −457.54→−249.32 (distribution
                     component closed; code-304 community-HP COP remains)
  CH2/CH4/CH6        gain their (372)/(472) component (CO2 +23.6, PE
                     +208.2); dominant CHP displaced-electricity credit
                     residual (Table 12f + block 12b/13b) is next slice.

No regression on the other 36 corpus variants (helper returns None off
heat-network mains) + golden + U985 fixtures. 2223 pass + 1 skip + 0
fail; pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:04:16 +00:00
Khalim Conn-Kowlessar
d7d5084f90 Move sap10_calculator tests to tests/domain/sap10_calculator/ for CI
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.

Mechanics:
- git mv preserves history (110 files).
- Flattening the trailing /tests keeps each file's depth-to-repo-root
  identical, so all 16 repo-root parents[4] fixture refs stay valid. Only
  test_pcdb_etl.py's parents[1] (→ pcdb data) and one hardcoded absolute
  golden-fixture path in test_cert_to_inputs.py needed rebasing.
- Cross-imports rewritten domain.sap10_calculator.worksheet.tests →
  tests.domain.sap10_calculator.worksheet (21 files incl. the external
  importer backend/documents_parser/tests/test_summary_pdf_mapper_chain.py).
- Golden-fixture path strings in test_summary_pdf_mapper_chain.py +
  scripts/fetch_cohort2_api_jsons.py updated to the new location (the JSONs
  moved with the rdsap tests).

load_cells / gitignored worksheet xlsx: the xlsx-pinned tests (test_dimensions
/ ventilation / water_heating) read 2026-05-19-17-18 RdSap10Worksheet.xlsx,
which is gitignored (.gitignore `*.xlsx`) and so absent in CI. _xlsx_loader.
load_cells now pytest.skip()s when the file is absent, so those tests run
locally and skip cleanly in CI instead of erroring — no new CI failures from
the move, and the gitignore policy is respected.

Verified: tests/domain/sap10_calculator + backend/documents_parser +
tests/domain/property_baseline = 2248 pass, 1 skipped; pyright resolves the
new import paths with zero import-resolution errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:58:00 +00:00
Khalim Conn-Kowlessar
f2062a2fbe Slice S0380.179: RdSAP §10.7 electric-immersion default for no-system certs
Closes the "no system" corpus variant fully (ΔSAP +1.18 → <1e-4 on all
four metrics).

The cert lodges §15.0 "Water Heating Code: NON / SapCode 999" and §15.1
"Hot Water Cylinder Present: No". Per RdSAP 10 §10.7 (PDF p.55) "No
water heating system" verbatim: "the calculation is done for an
electric immersion heater. If the electric meter is dual the immersion
heater is also dual, but is a single immersion otherwise... for a
cylinder defined by the first row of Table 28 (110 litres) and the
first row of Table 29." Table 29 row 1 gives age-band cylinder
insulation (age G -> 25 mm foam) and assumes a cylinder thermostat
present for immersion-heated DHW.

The BRE-approved Elmhurst engine confirms the substitution: the P960
worksheet header lodges "WHS: 903 Electric immersion, Single", a 110 L
cylinder, and storage loss (56) = 594.32 kWh/yr, so HW (64) = (45)
1935.37 + 594.32 = 2529.6927.

Pre-slice the cascade trusted the lodged "no cylinder" -> added no
storage loss and a spurious Table 3a keep-hot combi loss; the wrong HW
heat-gains also propagated through §5/§7, over-stating the base MIT by
+0.25 K and space fuel by +228 kWh. New
`_apply_rdsap_no_water_heating_system_default(epc)` rebinds the epc at
the top of cert_to_inputs (the demand cascade delegates here too) when
water_heating_code == 999, injecting WHC 903 + electricity fuel +
110 L cylinder + Table 29 insulation + assumed cylinder thermostat.
This closes HW fuel AND the downstream space residual in one move.

Age bands A-F (12 mm loose jacket) raise UnmappedSapCode — no corpus
member exercises that and the Table 2 loss-factor dispatch only has the
factory-foam path plumbed. Gate is keyed on code 999, unique to "no
system" in the corpus; 40 other variants + 858 section pins + 6 U985
fixtures unchanged. 936 pass; pyright net-zero 32 -> 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:44:45 +00:00
Khalim Conn-Kowlessar
c054d71284 Slice S0380.178: oil 6 circulation pump x1.3 for absent room thermostat
Closes the residual S0380.177 exposed on oil 6. The cascade's central
heating pump used the bare Table 4f age default (41 kWh for "2013 or
later") but the worksheet (230c) = 53.3 kWh.

SAP 10.2 Table 4f (PDF p.175) footnote a) on the "Circulation pump"
rows reads verbatim: "Multiply by a factor of 1.3 if room thermostat
is absent." oil 6 lodges control code 2101 ("No time or thermostatic
control of room temperature") = no room thermostat, so 41 x 1.3 = 53.3
= ws (230c) EXACTLY; pumps/fans (231) = 53.3 + 100 (liquid-fuel boiler
flue fan/pump) = 153.3 EXACT. Same root cause (absent room thermostat)
as the S0380.177 Table 4c(2) interlock fix — both keyed on the new
`_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES = {2101, 2102}`.

`_table_4f_circulation_pump_kwh` now multiplies the resolved pump kWh
by `_TABLE_4F_NO_ROOM_THERMOSTAT_PUMP_MULTIPLIER = 1.3` when the main's
control code is in that set.

oil 6 now FULLY EXACT on all four metrics (ΔSAP/cost/CO2/PE < 1e-4).
The sibling oil 5 (same "2013 or later" pump age but control 2106 WITH
a room thermostat) keeps the bare 41 kWh and is unaffected — as do the
other 39 corpus variants (2101/2102 appear only on oil 6). 935 pass;
pyright net-zero 32 -> 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:20:18 +00:00
Khalim Conn-Kowlessar
5276282d8c Slice S0380.177: oil 6 boiler interlock from room thermostat absence
oil 6 (B30K standard liquid-fuel boiler, Table 4b code 126 winter 80 /
summer 68) lodges Main Heating Controls Sap code 2101 ("No time or
thermostatic control of room temperature") WITH a cylinder thermostat.
The cascade's `no_interlock` gate only checked the cylinder thermostat,
so oil 6 kept raw efficiency despite the P960 worksheet header lodging
"Boiler Interlock: No".

Per RdSAP 10 §3 (PDF p.57): boiler interlock is "assumed present if
there is a room thermostat and (for stored hot water systems heated by
the boiler) a cylinder thermostat. Otherwise not interlocked." Control
code 2101 (and 2102 "Programmer, no room thermostat") provides no room
thermostat — the two Table 4e Group 1 rows carrying the "+0.6 °C /
Table 4c(2)" annotation — so the boiler is NOT interlocked regardless
of the cylinderstat. SAP 10.2 Table 4c(2) (PDF p.169) "No thermostatic
control of room temperature – regular boiler" then deducts 5pp from
BOTH the Space and DHW seasonal efficiency.

Three changes in cert_to_inputs.py:
- new `_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES = {2101, 2102}`;
- `no_interlock` now ORs room-thermostat absence with the existing
  stored-HW cylinderstat-absence test (the RdSAP §3 conjunction);
- the Space -5pp leg fires for Table 4b non-PCDB boilers (code
  101-141), not only PCDB-record boilers; the DHW leg is gated on a
  cylinder being present (Table 4c(2) combi DHW = 0).

Result for oil 6: space fuel (211) = 13446.3457 EXACT, HW fuel (219) =
4099.5872 EXACT. ΔSAP +3.0518 → +0.0782, Δcost -£69.79 → -£1.68,
ΔCO2 -240.66 → -1.71, ΔPE -1112.66 → -18.61.

The spec-correct fix exposes a single residual cause (per
[[feedback-software-no-special-handling]]): the central heating pump
(230c) — cascade reads pump_age=2 → Table 4f 41 kWh but ws (230c) =
53.3 kWh. The 12.3 kWh gap fully accounts for the residual across all
three metrics; pinned as the S0380.178 forcing function.

All other 40 corpus variants + 858 section pins + 6 U985 fixtures
unchanged (2101/2102 boiler codes appear only on oil 6). Pyright
net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:13:51 +00:00
Khalim Conn-Kowlessar
326066ee12 Slice S0380.176: Table 4b combi sub-row dispatch for (61)m
SAP 10.2 §4 line 7702 (PDF p.137) defines (61)m as "Combi loss for
each month from Table 3a, 3b or 3c (enter '0' if not a combi
boiler)". Table 4b sub-rows 128 / 129 / 130 are explicit combi sub-
rows per the spec row names:
    128: Combi oil boiler, pre-1998
    129: Combi oil boiler, 1998 or later
    130: Condensing combi oil boiler

Pre-slice `_table_3a_combi_loss_default_applies` gated only on
`main_heating_category ∈ {1, 2, 3, 6}`. The Elmhurst mapper leaves
`main_heating_category=None` on Table 4b liquid-fuel boilers (FAME,
HVO, B30K) — the cascade fell through to (61)m=0 despite the lodged
SAP code being a combi sub-row, under-counting (62)m by 600 kWh/yr
for FAME combi certs.

Extended the helper with a `_TABLE_4B_COMBI_OR_CPSU_CODES` fall-
through (set already exists for the symmetric `_primary_loss_
applies` Table 4b non-combi branch — see S0380.146). The set carries
the canonical combi + CPSU sub-row codes (103/104/107/108/112/113/
118/120-123/128-130). For cylinder-lodged certs the existing
`if epc.has_hot_water_cylinder: combi_loss_override = zero_monthly`
guard in `_water_heating_worksheet_and_gains` still pre-empts the
combi-loss fall-through correctly — non-combi codes with cylinders
remain (61)m=0.

Closures (heating-systems corpus 001431):
  oil 3 (code 128, FAME, no cylinder) ALL EXACT (±0.0000):
    ΔSAP_c +2.5863 → -0.0000
    Δcost  -£61.89 → -£0.00
    ΔCO2   -14.58  → +0.00
    ΔPE    -967.10 → +0.00
  oil 4 (code 129, FAME, no cylinder) ALL EXACT (±0.0000):
    ΔSAP_c +2.5603 → +0.0000
    Δcost  -£56.66 → +£0.00
    ΔCO2   -13.35  → +0.00
    ΔPE    -884.90 → +0.00

Oil 6 (code 126, NOT a combi, with cylinder) unchanged — the fix
is gated on the combi sub-row set. Cohort moves from 9 pinned
residuals to 7.

933 pass + 0 fail (+1 new mapper test). Pyright net-zero on cert_
to_inputs.py + tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:36:17 +00:00
Khalim Conn-Kowlessar
eda07d12dc Slice S0380.175: Community heating main_heating_control extraction
SAP 10.2 Table 4e Group 3 (PDF p.173) — heat-network control codes
2301-2314 dispatch to control_type 1, 2, or 3. Code 2306 = "Charging
system linked to use of heating, programmer and TRVs" →
control_type=3, temperature_adjustment=0. Per Table 9 the elsewhere-
zone off-hours depend on control_type: type 1/2 → (7, 8); type 3 →
(9, 8). The two extra off-hours change the §7 (90) T_rest mean by
~0.6 K → (92) MIT by ~0.4 K → (98) SH demand by ~390 kWh/yr.

Pre-slice diagnosis: cascade defaulted `main_heating_control=2`
(modal RdSAP) when the §14.0 "Main Heating Controls Sap" field was
empty. The 5 community heating corpus variants ALL lodge the SAP
code in §14.1 Community Heating "Heating Controls SAP" instead
(format: bare 4-digit integer, e.g. "2306"). The extractor was
storing this in `CommunityHeating.heating_controls_sap` but the
mapper only read `mh.heating_controls_sap` (§14.0).

Two changes:

1. `_elmhurst_sap_control_code` extended to accept bare 4-digit form
   ("2306") in addition to the §14.0 narrative form ("SAP code 2106,
   Programmer, room thermostat and TRVs"). Empty-string returns None
   instead of swallowing through the original `re.match` regex.

2. `_map_elmhurst_sap_heating` falls through to
   `mh.community_heating.heating_controls_sap` when the §14.0 main
   block leaves `heating_controls_sap` empty.

Closures (heating-systems corpus 001431):
  CH1 ΔSAP_c -1.0572 → +0.0000  EXACT
      Δcost  +£24.36 → -£0.00   EXACT
  CH3 ΔSAP_c -1.0572 → +0.0000  EXACT
      Δcost  +£24.36 → -£0.00   EXACT
  CH2/CH4 SAP-side flip ±0.42 → ±0.53 (CHP-split blend reacts to
        the now-lower SH demand × CHP rate)
  CH6 ΔSAP_c -8.4406 → -7.4942 (DLF=1.0 P960 quirk untouched)

Remaining CH1/CH3 ΔCO2 -23.60 / ΔPE -208.23 is the §13a (372)
"Electrical energy for heat distribution" line (118.38 kWh × electric
factors 0.1993 CO2 / 1.760 PE). Cascade doesn't currently meter this
electricity overhead separately from heat-network heat — next slice.

932 pass + 0 fail (+5 new mapper tests). No regressions on the other
36 corpus variants — the mapper change is gated on `mh.community_
heating is not None` and only fires when §14.0 leaves the control
field empty. Pyright net-zero on mapper.py + corpus test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:07:59 +00:00
Khalim Conn-Kowlessar
4876140a97 Slice S0380.174: §4 storage + primary loss for community heating
SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482):

    "Primary circuit loss for insulated pipework and cylinderstat
     should be included (see Table 3)."

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 Table 2b note b ×0.9 multiplier is restricted to "boiler / warm
air / heat pump systems" — community heating is omitted from that
verbatim list. Pre-slice the cascade applied the ×0.9 reduction
unconditionally when DHW was separately timed, AND omitted the Table
3 primary-loss path for heat-network mains entirely. Combined the
two gaps under-counted (62)m HW total demand by ~320 kWh/yr for
heating-systems corpus 001431 community heating 1 (8164 + 0 vs
448.74 + 273.90 spec losses).

Three changes:

1. New `_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION = 1.0` constant.
   `_primary_loss_override` selects this for heat-network mains
   instead of the RdSAP §3 age-band default, per the spec's literal
   "insulated pipework" + back-solve from worksheet (59) Jan = 23.26
   = 31 × 14 × (0.0091×3 + 0.0263).

2. Extended `_primary_loss_applies` with a new branch: heat-network
   main + WHC ∈ {901, 902, 914} + cylinder present → primary loss
   applies.

3. New `_table_2b_note_b_multiplier_applies(epc, main)` predicate
   that gates the ×0.9 storage-loss reduction on the spec's verbatim
   system-type list, returning False for heat-network mains. The
   primary-loss `_separately_timed_dhw` continues to return True for
   community heating (Table 3's "separately timed" row is system-
   type-agnostic and gives h=3 all year).

Closures (heating-systems corpus 001431):
  CH1 HW kWh 3391.90 → 3854.12 (= ws 3854.1175, abs Δ < 1e-3)
  CH1 HW cost £143.82 → £163.41 (= ws £163.41, EXACT)
  CH1 (65)m heat gains 793.51 → 1221.62 (= ws 1221.62, EXACT)
  CH2/CH3/CH4/CH6 same shape — HW path closes against ws (310).

§4 fix is spec-correct on all 5 CH variants. The closure surfaces a
separate §7 MIT (92)m over-count of +0.46 K (cascade Jan = 17.22 vs
ws 16.76) that the pre-slice (65)m gain under-count was masking. Per
[[feedback-software-no-special-handling]] apply the spec-correct
fix uniformly; new pinned residuals reflect the exposed MIT gap.

New residuals (vs pre-slice):
  CH1   ΔSAP -0.5273 → -1.0572  ΔPE -9.15  → +408.67
  CH2   ΔSAP -0.0076 → -0.4187  ΔPE +1506  → +1779
  CH3   ΔSAP -0.5273 → -1.0572  ΔPE -387.03 → -239.03
  CH4   ΔSAP -0.0076 → -0.4187  ΔPE +494.61 → +767.13
  CH6   ΔSAP -8.0295 → -8.4406  ΔPE +7864.60 → +8137.11

927 pass + 0 fail (+1 new test). No regressions on the other 36
corpus variants — the gate is narrow on `_is_heat_network_main`.
Pyright net-zero (43 → 43) on cert_to_inputs.py + tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 12:50:01 +00:00
Khalim Conn-Kowlessar
e71987c239 Slice S0380.173: Community heating HW path routes through main fuel
Closes CH1 (boilers) + CH3 (HP) HW CO2 / PE residuals by routing
the HW cost / CO2 / PE factor lookups through the heat-network main
when WHC ∈ {901, 902, 914} ("HW from main heating system"). Pre-
slice the cascade honoured Elmhurst Summary §15.0's
`water_heating_fuel_type = "Mains gas"` placeholder on community-
heated certs, mis-routing HW through Table 12 code 1 (mains gas,
3.48 p/kWh / 0.21 CO2 / 1.13 PE) instead of the heat-network code
(4.24 p/kWh + Table 12 code 41 / 51 / 53 / 54 with Table 4a heat-
source-eff scaling per S0380.172).

Per SAP 10.2 §C1 + RdSAP 10 §C (PDF p.49 + p.58) the HW heat
delivered by a heat-network main is supplied through the same
network as SH: spec block 10b (342a)/(342b) computes HW cost as
`(310a) × CHP_price + (310b) × boiler_price`, mirroring SH's
(340a)/(340b) split. Block 12b (365)/(366) and 13a (465)/(466)
likewise apply the heat-source-eff division on HW.

Three layers wired:

1. New `_is_community_heating_hw_from_main(epc)` predicate. Gates
   on WHC ∈ {901, 902, 914} + heat-network main + SAP code in
   `_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` table (S0380.172 — only
   301 boilers + 304 HP). SAP 302 (CHP+boilers) is excluded
   because the 35%/65% split needs the displaced-electricity
   credit cascade per spec block 13b (464)/(466) on BOTH SH and HW
   paths — both converge in a single follow-up slice.

2. `_hot_water_fuel_cost_gbp_per_kwh` gains a keyword-only
   `inherit_main_for_community_heating: bool = False` parameter.
   When True, returns `_fuel_cost_gbp_per_kwh(main, prices)` —
   same helper that already applies the S0380.171 CHP blend +
   heat-network rate. The orchestrator passes
   `inherit_main_for_community_heating=_is_community_heating_hw_
   from_main(epc)` at the cost-rate construction site.

3. `_hot_water_co2_factor_kg_per_kwh` and `_hot_water_primary_
   factor` get top-level branches: when the predicate fires, return
   `Table_12_factor × _heat_network_heat_source_efficiency_scaling
   (main)` — same scaled-factor return as the SH path in S0380.172.

Closures (heating-systems corpus block 11b):

  CH1 (Boilers/Gas) ΔPE   −967 → −9    (essentially closed)
  CH1               ΔCO2 −126 → +52    (shifted across worksheet)
  CH3 (HP/Elec)     ΔPE  +1749 → −387  (~78% closure)
  CH3               ΔCO2 +473 → −86    (~82% closure)

Cost / SAP signs flip on CH1 / CH3 (was −£14 / +0.59 SAP, now
+£12 / −0.53 SAP) — HW cost now matches the worksheet's (342) line
exactly, exposing a +£12 lighting / standing overage that was
previously masked by the HW under-charge. Per [[feedback-software-
no-special-handling]] the pre-slice near-zero on CH1 / CH3 cost was
an offsetting-bugs artifact; the spec-correct fix surfaces the real
lighting / standing gap as the next forcing function.

CH2 / CH4 / CH6 (SAP 302) unchanged from S0380.171 / S0380.172 pins
— gated out per the heat-source-eff-table membership check.

Test baseline at HEAD: 926 pass + 1 skipped (was 926 + 1 at
predecessor 36d4bf87). Pyright net-zero on affected files
(cert_to_inputs.py, test_heating_systems_corpus.py): 32 → 32.

Per [[feedback-spec-citation-in-commits]] the rule cites SAP 10.2
§C1 verbatim ("heat from CHP + back-up boilers, via a heat main")
and RdSAP 10 §C defaults (PDF p.58).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 12:01:09 +00:00
Khalim Conn-Kowlessar
36d4bf8750 Slice S0380.172: Heat-network heat-source-eff CO2/PE factor scaling
Closes the CO2 / PE residuals for CH1 (boiler community heating, SAP
code 301) and CH3 (HP community heating, SAP code 304) via SAP 10.2
Table 4a (PDF p.164) heat-network heat-source efficiency:

  "Boilers (RdSAP)"     → 80%   → code 301
  "Heat pump (RdSAP)"   → 300%  → code 304

Spec block 13a (PDF p.153) (467) "PE associated with heat source 2"
= [(307b)+(310b)] × 100 / (467b) — i.e. fuel input = network_input ×
100 / heat_source_eff before applying Table 12 PE factor. Block 12b
(367) mirrors for CO2. The cascade meters network_input directly
(eff = 1/DLF for the cost path via Table 12 heat-network rate), so
PE / CO2 factors are scaled by 1/heat_source_eff at lookup time —
mathematically equivalent to spec's (network_input / eff) × factor.

Three changes:

1. New `_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]]`
   keyed on SAP code: 301 → 0.80, 304 → 3.00. SAP 302 (CHP+boilers)
   is omitted — the 35%/65% split + displaced-electricity credit per
   spec block 13b (464)/(466)/(364)/(366) needs the .171 follow-up.

2. New `_heat_network_heat_source_efficiency_scaling(main)` helper
   returning 1.0 for non-heat-network mains + SAP 302, and
   1/heat_source_eff for SAP 301 / 304.

3. Wired into `_main_heating_co2_factor_kg_per_kwh` and
   `_main_heating_primary_factor` non-electric branches (heat
   networks are non-electric per `_is_electric_main`). Both functions
   return `Table_12_factor × scaling` so the cascade's
   `network_input × scaled_factor` lands on the spec
   `(network_input / eff) × Table_12_factor`.

Closures vs pre-S0380.172 residuals (heating-systems corpus block 11b):

  variant            ΔCO2          ΔPE        notes
  CH1 (Boilers/Gas)  -787→-126    -3827→-967  ~75-84% closure
  CH2 (CHP/Gas)      unchanged     unchanged   excluded — SAP 302
  CH3 (HP/Elec)      +1614→+473  +11879→+1749 ~71-85% closure
  CH4 (CHP/Oil)      unchanged     unchanged   excluded — SAP 302
  CH6 (CHP/Coal)     unchanged     unchanged   excluded — SAP 302

Cost + SAP unchanged on all 5 (heat-network rate × network_input via
Table 12 is correct regardless of heat-source efficiency).

Residual CH1 / CH3 gap drivers (follow-up scope):
- WHC=901 HW path: cascade reads cert-lodged "Mains gas" as HW fuel
  on community-heating certs; should fall through to main fuel for
  the heat-network so the scaling applies on HW side too.
- Elmhurst 0.8523 multiplier on heat-network energy column (worksheet
  (467) energy = spec_formula × 0.8523 uniformly across non-CHP
  heat-network rows; mechanism not yet identified — spec divergence
  candidate for SAP_CALCULATOR.md §8).

Cohort no-regression verified: 9 ASHP + 38 cohort-2 golden fixtures
pass unchanged; the 41-variant heating-systems corpus has identical
residuals for non-heat-network certs. The 2 closed CH variants are
re-pinned at their new sub-1000 magnitudes.

Test baseline at HEAD: 926 pass + 1 skipped (was 926 + 1 at
predecessor a4b5f4e7; pin updates net to 0). Pyright net-zero on
affected files (cert_to_inputs.py, test_heating_systems_corpus.py):
32 → 32.

Per [[feedback-spec-citation-in-commits]] the dispatch table cites
SAP 10.2 Table 4a (PDF p.164) verbatim row labels.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:44:06 +00:00
Khalim Conn-Kowlessar
a4b5f4e74d Slice S0380.171: CHP heat-fraction split for community heating cost
Closes the +£104 cost / +4.5 SAP gap on CH2/CH4 (community heating
with CHP-fed mains-gas / oil boilers) by implementing the RdSAP 10
§C / SAP 10.2 Appendix C (PDF p.58) default heat-fraction split:

  "If CHP (waste heat or geothermal treat as CHP):
   - fraction of heat from CHP = 0.35
   - CHP overall efficiency 75%
   - heat to power ratio = 2.0
   - boiler efficiency 80%"

Verified against the corpus block 9b lodgement: CH2 worksheet (303a)
= 0.3500 + (303b) = 0.6500 + (305) = 1.00 + (306) DLF = 1.45. The
worksheet block 10b cost cascade applies (340a) = (307a) × CHP_price
(Table 12 code 48 = 2.97 p/kWh) + (340b) = (307b) × boiler_price
(Table 12 codes 51-58 = 4.24 p/kWh) with (307a) = 0.35 × (307),
(307b) = 0.65 × (307).

Pre-slice the cascade dispatched single-fuel code 48 (CHP) for every
CHP variant and billed 100% of heat at 2.97 p/kWh, under-charging by
~£104/yr versus the worksheet's 35% × 2.97 + 65% × 4.24 = 3.7945
p/kWh blended rate.

Three layers wired:

1. Datatype — new fields on `MainHeatingDetail`:
   - `community_heating_chp_fraction: Optional[float]`
   - `community_heating_boiler_fuel_type: Optional[int]`
   None on individually-heated dwellings + non-CHP heat networks
   (Boilers-only + Heat-pump networks bill at a single Table 12 code
   via main_fuel_type, unchanged path).

2. Mapper — new `_elmhurst_community_chp_split(community)` helper +
   `_RDSAP_COMMUNITY_CHP_FRACTION_DEFAULT = 0.35` constant. When the
   §14.1 Community Heat Source is "Combined Heat and Power": returns
   (0.35, boiler_fuel_code) where boiler_fuel_code is resolved from
   the §14.1 Community Fuel Type via the existing
   `_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12` dispatch (gas → 51,
   oil → 53, coal → 54).

3. Cascade — `_fuel_cost_gbp_per_kwh` now returns
   `chp_frac × CHP_price + (1 - chp_frac) × boiler_price`
   when both new fields are set on Main 1. Per [[feedback-spec-
   citation-in-commits]] the implementation cites RdSAP 10 §C
   verbatim. Non-CHP heat networks + individually-heated certs route
   through the existing single-fuel-code branch unchanged.

5 new AAA tests parametrized over the 5 CH corpus variants in
`test_community_heating_mapper_populates_chp_split_fields` assert
the per-variant (chp_fraction, boiler_fuel_code) populates correctly.

Closures vs pre-S0380.171 residuals (heating-systems corpus block 11b):

  variant            ΔSAP      Δcost      status
  CH1  (Boilers/Gas) +0.5915   -£13.63    unchanged (no CHP split)
  CH2  (CHP/Gas)     +4.50→-0.0076  -£104→+£0.17   ✓ CLOSED
  CH3  (HP/Elec)     +0.5915   -£13.63    unchanged (no CHP split)
  CH4  (CHP/Oil)     +4.50→-0.0076  -£104→+£0.17   ✓ CLOSED
  CH6  (CHP/Coal)    -3.52→-8.03   +£81→+£185     REGRESSED

The CH6 regression is exposed (not caused) by the spec-correct split:
pre-slice CH6 sat at -3.52 SAP / +£81 by coincidence — the cascade's
CHP-only pricing (2.97 p/kWh) cancelled with cascade DLF=1.45
(Table 12c age G default) against the CH6 worksheet's lodged DLF=1.0.
Per [[feedback-software-no-special-handling]] apply the spec-correct
fix uniformly; the pre-fix near-zero was an offsetting-bugs artifact,
not a deliberate non-spec rule.

The CH6 worksheet (306) DLF=1.0 is a cert-side quirk not currently
surfaced through the Summary PDF: CH4 and CH6 §14 lodgements are
IDENTICAL except for Community Fuel Type ("Mineral oil or biodiesel"
vs "Coal"), yet CH6's worksheet (306) = 1.0000 while CH4's = 1.4500.
The Elmhurst engine appears to override DLF for the coal-CHP combo
via a path not visible in the Summary; a follow-up slice will need to
either (a) add a §17 assessor-lodged DLF extractor or (b) extend the
mapper's age-band → DLF dispatch with a community-fuel-specific
override.

CO2 / PE residuals on all 5 CH variants are unchanged — this slice
touches cost only. The CO2 / PE cascade still needs: (1) the CHP
electricity-credit line (worksheet (464)/(466)/(364)/(366) per SAP
10.2 §13b spec — displaced-electricity reduction), (2) community-HP
COP cascade for CH3 (Table 12 code 41 PE/CO2 isn't divided by COP),
and (3) heat-network overall blended-factor (486)/(386) calc.

Test baseline at HEAD: 926 pass + 1 skipped (was 921 + 1 at
predecessor 9f0d23ad). Pyright net-zero on affected files
(epc_property_data.py, mapper.py, cert_to_inputs.py,
test_heating_systems_corpus.py + elmhurst_site_notes.py): 65 → 65.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:21:01 +00:00
Khalim Conn-Kowlessar
9f0d23adc6 Slice S0380.170: Community heating mapper unblock (Table 12 dispatch)
Closes the 5 community-heating variants in the heating-systems corpus
(community heating 1/2/3/4/6 on property 001431). Pre-slice the
mapper returned `MainHeatingDetail.main_fuel_type=''` for every
community-heating cert because §14.0 lodges no Fuel Type — only EES
'COM' + a Table 4a heat-network SAP code (301/302/304). The cascade
strict-raised `MissingMainFuelType` per S0380.132. The actual fuel
that bills the cascade lives in the §14.1 Community Heating/Heat
Network block, which the extractor was skipping entirely.

SAP 10.2 Table 12 (PDF p.189) defines the heat-network fuel codes:

  Boilers + Mains Gas        → 51 (heat from boilers — mains gas)
  Boilers + Mineral oil      → 53 (heat from boilers — oil)
  Boilers + Coal             → 54 (heat from boilers — coal)
  Boilers + Biomass          → 43 (heat from boilers — biomass)
  Combined Heat and Power    → 48 (heat from CHP; fuel-agnostic)
  Heat pump + Electricity    → 41 (heat from electric heat pump)

Per spec text the upstream fuel determines the boiler-side code; CHP
is fuel-agnostic at the Table 12 cost / CO2 / PE level.

Three layers wired:

1. Survey schema — new `CommunityHeating` dataclass alongside
   `MainHeating2` carrying the §14.1 fields (heating_type,
   community_heat_source, community_fuel_type, heating_controls_ees,
   heating_controls_sap, chp_fuel_factor). Mutually exclusive with
   `main_heating_2` at the §14.1 level. Attached as
   `MainHeating.community_heating: Optional[CommunityHeating] = None`.

2. Extractor — new `_extract_community_heating()` method bracketed by
   "14.1 Community Heating/Heat Network" / "14.2 Meters". Returns
   None on individually-heated dwellings (no Community Heat Source
   lodged). Wired into `_extract_main_heating()`.

3. Mapper — new `_resolve_community_heating_fuel_code(heat_source,
   fuel)` dispatch helper + `_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12`
   constant for the boiler upstream-fuel split. Wired in
   `_map_elmhurst_sap_heating` after the EES-code-to-fuel dispatch
   and before the strict-raise on absent SAP code.

Per the standard slice workflow + [[feedback-aaa-test-convention]]:

- 5 new AAA tests in `test_community_heating_mapper_resolves_table_12_
  fuel_code` parametrized over the 5 corpus variants, asserting the
  mapper resolves the expected Table 12 code per variant.

- The existing parametrized residual-pin test in
  `test_heating_systems_corpus_residual_matches_pin` picks up the
  5 community-heating variants with cascade-side residuals pinned as
  forcing functions for follow-up slices:

      variant            dSAP    dcost     dCO2     dPE
      CH1 (Boilers/Gas)  +0.59   -£14    -787    -3827
      CH2 (CHP/Gas)      +4.50  -£104   -1430    +1506
      CH3 (HP/Elec)      +0.59   -£14   +1614   +11879
      CH4 (CHP/Oil)      +4.50  -£104   -4397     +495
      CH6 (CHP/Coal)     -3.52   +£81   -2935    +7865

  These reflect open cascade-side work (SAP 10.2 Appendix C CHP/
  boiler heat-fraction split missing — cascade treats CHP+Boilers as
  100% CHP; community-HP COP cascade missing — cascade doesn't divide
  delivered heat by COP for Table 12 code 41; heat-network overall
  CO2/PE blended-factor cascade missing — cascade doesn't compute
  worksheet rows (386)/(486)). Pinned per [[feedback-zero-error-strict]];
  follow-up slices close gaps and re-pin smaller residuals.

- `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` tuple now empty; the
  blocked-tier test pytest-skipped via `pytest.mark.skipif` with a
  reason naming this slice.

Test baseline at HEAD: 921 pass + 1 skipped (was 916 + 0 at
predecessor 7e08e7af). Pyright net-zero on affected files
(elmhurst_site_notes.py, elmhurst_extractor.py, mapper.py,
test_heating_systems_corpus.py): 32 → 32.

Per [[feedback-spec-citation-in-commits]] the dispatch is grounded
in SAP 10.2 Table 12 (PDF p.189). Per
[[feedback-bigger-slices-for-uniform-work]] all 5 variants land in
one slice — the work is uniform (single Elmhurst label dict + single
dispatch helper) and the per-variant residuals surface together
because of cascade-side gaps, not mapper-side variation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 10:50:21 +00:00
Khalim Conn-Kowlessar
9ed003a503 Slice S0380.169: EES "NON" → electricity (no-system unblock per SAP 10.2 §A.2.2)
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>
2026-06-02 10:22:48 +00:00
Khalim Conn-Kowlessar
58a9547210 Slice S0380.168: Bio-liquid mapper extensions + Table 32 FAME price flip
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>
2026-06-02 10:14:10 +00:00
Khalim Conn-Kowlessar
7901dda455 Slice S0380.167: EES codes WEA/REA/OEA → electricity (electric storage 11-14 unblock)
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>
2026-06-02 09:53:01 +00:00
Khalim Conn-Kowlessar
589a8631b7 Slice S0380.166: Elmhurst "Bulk LPG" label → API code 27 (mapper unblock)
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>
2026-06-02 09:48:37 +00:00
Khalim Conn-Kowlessar
3de52bcb90 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>
2026-06-02 09:44:10 +00:00
Khalim Conn-Kowlessar
302db131c6 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>
2026-06-02 09:27:47 +00:00
Khalim Conn-Kowlessar
9896644c4c Slice S0380.163: Elmhurst-mirror HW PE/CO2 factor on dual-rate tariffs
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>
2026-06-02 08:34:20 +00:00
Khalim Conn-Kowlessar
8d465d973f Slice S0380.162: SAP 10.2 Appendix N3.1 default pump gain for electric HPs
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>
2026-06-01 23:59:29 +00:00
Khalim Conn-Kowlessar
482ce88b55 Slice S0380.161: SAP 10.2 Table 5a warm-air fan gain (SFP × 0.04 × V)
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>
2026-06-01 23:16:57 +00:00
Khalim Conn-Kowlessar
af34ad9846 Slice S0380.160: SAP 10.2 Table 5a wet-pump gate for central heating gain
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>
2026-06-01 23:02:42 +00:00
Khalim Conn-Kowlessar
fba45d1111 Slice S0380.159: SAP 10.2 Table 4a R tariff-aware dispatch for electric storage
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>
2026-06-01 22:25:51 +00:00
Khalim Conn-Kowlessar
8843df1b46 Slice S0380.158: SAP 10.2 Table 4f warm-air heating system fans
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>
2026-06-01 21:56:03 +00:00
Khalim Conn-Kowlessar
a2a4b6824a Slice S0380.157: SAP 10.2 Table 2b note b) WHC=903 electric-immersion guard
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>
2026-06-01 20:56:52 +00:00
Khalim Conn-Kowlessar
02092c8041 Slice S0380.156: SAP 10.2 Table 3 WHC=903 electric-immersion zero-loss guard
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>
2026-06-01 19:28:25 +00:00
Khalim Conn-Kowlessar
152db1aef4 Slice S0380.155: SAP 10.2 Table 4a — heat-pump water-efficiency column dispatch
SAP 10.2 Table 4a (PDF p.163-164) heat-pump rows split efficiency into
two columns — "space" and "water":

    Code  System                                            space  water
    211   Ground source HP with flow temp <= 35°C            230    170
    213   Water source HP with flow temp <= 35°C             230    170
    215   Gas-fired GSHP with flow temp <= 35°C              120     84
    216   Gas-fired WSHP with flow temp <= 35°C              120     84
    217   Gas-fired ASHP with flow temp <= 35°C              110     77
    521   Warm-air electric GSHP                             230    170
    523   Warm-air electric WSHP                             230    170
    525   Warm-air gas-fired GSHP                            120     84
    526   Warm-air gas-fired WSHP                            120     84
    527   Warm-air gas-fired ASHP                            110     77

The split reflects real physics: heat pumps lose efficiency raising
water to ~55°C DHW temperatures vs ~35°C space-heating flow. ASHP
"in other cases" (codes 214, 221, 223, 224) and the "other cases"
gas-fired rows (225-227) have space == water = 170 / 84 / 77 — no
distinct DHW column.

Pre-slice the cascade routed WHC ∈ {901, 902, 914} ("HW from main
heating") through `seasonal_efficiency(main_code)`, which only consults
the Space column. For SAP code 211 the cascade returned 2.30 (= space)
when the spec requires 1.70 (= water). HW fuel kWh undercounted by
26% on the heating-systems corpus gshp variant: cascade 841.47 kWh vs
worksheet 1138.46 kWh.

New `_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY` dict (10 codes where Space
≠ Water) consulted in `_water_efficiency_with_category_inherit` before
falling through to the existing `seasonal_efficiency` path. Codes
where Space == Water keep the legacy inheritance — no behaviour
change. Non-HP main heating (boilers, storage heaters) likewise
unchanged.

Closures (gshp variant — SAP code 211 + WHC=901 + cylinder):
  HW fuel kWh:  841.47 → 1138.45 (matches worksheet 1138.46)
  ΔSAP_c:       +0.9373 → -0.0178
  Δcost:        -£21.60 → +£0.41
  ΔCO2:         -34.98  → +7.06 kg/yr
  ΔPE:          -418.92 → +33.52 kWh/yr

No regressions on 40 other corpus variants — gshp is the only fixture
that lodges a heat-pump code with diverging Space/Water columns.

Cohort-1 ASHP closure (S0380.28 reciprocal interpolation) is unaffected
because that path runs through `heat_pump_record` PCDB Appendix N3
when a PCDB Table 362 record is lodged; this fix is the Table 4a
fallback for cases without a PCDB record.

Extended handover suite: 899 pass / 0 fail. Pyright net-zero (43 → 43).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:13:21 +00:00
Khalim Conn-Kowlessar
5e941b9295 Slice S0380.154: SAP 10.2 §12.4.4 — back-boiler summer-immersion HW split
SAP 10.2 §12.4.4 (PDF p.36-37):

  "Independent boilers that provide domestic hot water usually do so
   throughout the year. 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."

Scope is verbatim Table 4a codes 156 (Open fire with back boiler to
radiators) and 158 (Closed room heater with boiler to radiators). Range
cooker boilers (160, 161), pellet stoves with boilers (159), and
independent solid-fuel boilers (151, 153, 155) are NOT covered.

Pre-slice, the cascade treated the back-boiler cohort identically to
year-round solid-fuel mains: (59)m primary loss applied Jun-Sep, HW
fuel kWh was billed entirely at the boiler's solid-fuel rate, the HW
CO2 / PE factors used the boiler fuel's annual factor, and the off-peak
electric standing charge (£40 for 18-hour tariff) was not added because
the cert's lodged water-heating fuel code was anthracite.

Implementation (4 wired pieces):

1. `_section_12_4_4_summer_immersion_applies(epc, main)` — predicate
   gate keyed on back-boiler SAP code (156, 158) + WHC ∈ {901, 902, 914}
   "HW from main heating" + cylinder present.

2. `_primary_loss_override` zeroes (59)m for Jun-Sep when the predicate
   fires — matches the Elmhurst P960 worksheet which has (59) Jun-Sep =
   0 for SF2 (vs ~42 kWh/month for SF3 range cooker).

3. `_section_12_4_4_hw_blend(...)` — returns the 5-tuple
   (annual_hw_fuel_kwh, blended_cost_gbp_per_kwh, blended_co2_factor,
   blended_pe_factor, extra_standing_charge_gbp). The blend is kWh-
   weighted across:
   - Winter Oct-May: boiler fuel at the boiler's Table 32 unit price /
     Table 12 annual CO2 / Table 12 annual PE factor
   - Summer Jun-Sep: standard electricity (Table 12d/12e monthly
     factors weighted by summer (62)m demand) priced at the tariff's
     off-peak low rate per Table 13 note 2 (the 6.8 - 0.036V × N -
     0.105V dual-immersion formula clamps to zero high-rate for
     normal V/N combos on tariffs with ≥18 hrs low rate; SF2 has
     V=110, N≈2 → 100% low-rate)
   - The Table 32 off-peak electric standing charge that fires when
     hot water uses off-peak electricity per Table 12 note (a). For
     EIGHTEEN_HOUR tariff this is Table 32 code 38 = £40.

4. Orchestrator (`cert_to_inputs`) resolves the blend once and overrides
   `hot_water_kwh_per_yr`, `hot_water_fuel_cost_gbp_per_kwh`,
   `hot_water_co2_factor_kg_per_kwh`, `hot_water_primary_factor`, and
   `standing_charges_gbp` when the predicate fires. Other certs fall
   back to the existing single-fuel HW helpers (no behaviour change).

Worksheet evidence (heating-systems corpus property 001431 SF2 — code
158 + WHC=901 + cylinder thermostat + 18-hour tariff):
  - (62) Oct-May = 2205.80 kWh, Jun-Sep = 684.55 kWh
  - (217)m = 65 winter / 100 summer, (219) = 3393.5 anthr + 684.55 elec
    = 4078.06 fuel kWh
  - (247) HW cost = 4078.06 × 4.27 p/kWh blended = £174.25
  - (251) Standing = £40 (off-peak electric standing only — solid fuel
    has no standing charge)
  - (255) Total = £801.13

Closures (SF2):
  ΔSAP_c   +1.86 → -0.0000  (EXACT)
  Δcost   -£42.84 → -£0.00  (EXACT)
  ΔCO2  +346.87  → -93.10 kg/yr (residual: Elmhurst CO2 blend uses a
                                  different summer-month weighting that
                                  the SAP 10.2 Table 12d cascade does
                                  not reproduce — spec-correct per
                                  Table 12d header).
  ΔPE   -605.76  → -1027.51 kWh/yr (same spec-vs-Elmhurst PE blend
                                     artifact via Table 12e monthly
                                     cascade).

No regressions: 40/41 corpus variants unchanged (gate is narrow by SAP
code 156/158). Extended handover suite 898 pass / 0 fail. Pyright net-
zero (43 → 43).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:18:44 +00:00
Khalim Conn-Kowlessar
e4bf4e70e8 Slice S0380.153: SAP 10.2 Table 3 — not-separately-timed DHW for solid-fuel boilers
SAP 10.2 Table 3 (PDF p.160) provides three primary-loss rows keyed off
the DHW timing arrangement, the middle row giving winter h=5 / summer
h=3 for "Cylinder thermostat, water heating NOT separately timed".

Solid-fuel boiler systems (Table 4a codes 151-161 — independent boilers,
open-fire + back boilers, closed room heaters with boilers, range cooker
boilers, stoves with boilers) do not ship with dual programmers. Per
SAP 10.2 §9.2.4 (PDF p.27) these are "independent solid fuel boilers,
open fires with a back boiler and room heaters with a boiler" — the
appliance itself is the timer. DHW timing follows the burn schedule,
not a separate cylinder programmer, so the middle Table 3 row applies.

Pre-slice `_separately_timed_dhw` returned True for any cylinder +
non-electric HW fuel cert (the S0380.140 gate), routing solid-fuel
boilers through h=3 year-round (the third row, "Cylinder thermostat,
water heating separately timed"). That under-counted winter (59)m
by ~21 kWh/month × 8 winter months across the affected cohort, with
the under-counted water-heating gain propagating into MIT / SH / SAP.

New gate: `sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES`
(frozenset of {151, 153, 155, 156, 158, 159, 160, 161}) — added before
the existing cylinder-present fallback. The post-S0380.140 electric-
immersion / heat-pump / no-main branches are unchanged. Table 4b
liquid-fuel boilers (101-141) keep the True default — modern gas/oil
installations standardly include dual programmers and the worksheet
confirms `oil 1` / `oil pcdb 1..3` / `pcdb 1` are pinned exact at
h=3 year-round.

Worksheet evidence (heating-systems corpus property 001431):
  - solid fuel 3 (SAP code 160 range cooker boiler + WHC=901
    cylinder thermostat): worksheet (59)m winter = 64.58 (h=5, p=0)
    and summer = 41.92 / 43.31 (h=3, p=0). Cascade closes ΔSAP +0.30
    → −0.0000, Δcost −£6.84 → −0.00, ΔPE −214 → −0.00 (4-metric exact).
  - solid fuel 2 (SAP code 158 closed room heater + back boiler):
    same Table 3 fix narrows ΔSAP +2.06 → +1.86. Remaining ~1.86 SAP
    is the SAP 10.2 §12.4.4 immersion-in-summer rule for back-boilers
    (codes 156, 158) — the worksheet has summer (59)m = 0 because the
    Elmhurst P960 lodges `Summer Immersion: Yes` + the spec routes
    Jun-Sep HW through an electric immersion at η=100%. That's a
    bigger lift (monthly HW efficiency + fuel-split plumbing) and is
    a follow-up slice.

Other corpus variants: no impact (verified via cohort sweep). The
gate is narrow by SAP code so only the 2 affected variants move.

Extended handover suite: 897 pass / 0 fail (+1 from new AAA test).
Pyright net-zero (43 → 43, transient +1 fixed via `EpcPropertyData`
import on the new test's `_cylinder_epc_for` return annotation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:27:12 +00:00
Khalim Conn-Kowlessar
d4f6ff0f2f Slice S0380.152: SAP 10.2 Table 3 — primary loss for solid-fuel back-boilers
SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss" verbatim:

  "Primary circuit loss applies when hot water is heated by a heat
   generator (e.g. boiler) connected to a hot water storage vessel
   via insulated or uninsulated pipes (the primary pipework)."

The spec rule does NOT restrict to Table 4b gas/oil boilers — any
boiler connected to a cylinder via primary pipework incurs the loss.
The cert's `water_heating_code` is the discriminator:

  - WHC=901/902/914 (HW from main heating system) + wet boiler +
    cylinder → primary loss applies (back-boiler / wet boiler heats
    cylinder via primary loop).
  - WHC=903 (HW from a separate electric immersion / secondary) → no
    primary loss even when the main is a wet boiler.

Pre-slice `_primary_loss_applies` only covered Table 4b gas/oil boiler
codes (101-141). Table 4a solid-fuel boiler codes 151-161 (manual /
auto / range-cooker boilers, closed room heater + back-boiler, open
fire + back-boiler, wood pellet + back-boiler) fell through and
primary loss silently went to zero — under-counting §5 (72) water-
heating internal gain by ~74 W cohort-wide for every WHC=901 solid-
fuel back-boiler variant.

Worksheet evidence on the 001431 corpus (all age G, same cylinder):
  - solid fuel 2 (code 158, WHC=901): ws (59) ≈ 505 kWh/yr   → apply
  - solid fuel 3 (code 160, WHC=901): ws (59) ≈ 643 kWh/yr   → apply
  - solid fuel 5 (code 153, WHC=903): ws (59) = 0            → skip
  - solid fuel 4..11 (633/636 non-boilers, WHC=903): skip

The fix:
  - `_primary_loss_applies(...)` gains a `water_heating_code: Optional[int]`
    parameter (default None for back-compat with synthetic tests).
  - New branch after the Table 4b fallback: `_is_wet_boiler_main(main)`
    + `water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES` → True.
  - Call site `_primary_loss_override` passes
    `epc.sap_heating.water_heating_code`.

Heating-systems corpus impact:
  - solid fuel 3 (code 160, WHC=901): +1.31 → +0.30 SAP
                                       PE -918.6 → -214.3 kWh/yr
  - solid fuel 2 (code 158, WHC=901): +2.77 → +2.06 SAP
                                       PE -1241.7 → -754.1 kWh/yr
  - All other variants: unchanged

SF2 doesn't fully close because the worksheet's (59) is winter-only
(0 in summer) but the cascade applies the year-round Table 3 formula
via `_separately_timed_dhw=True` (cylinder + non-electric HW fuel).
Remaining residual is a follow-up — likely a
`_separately_timed_dhw=False` rule for solid-fuel back-boilers (HW
timing tied to the room fire, not separately programmed).

Pyright net-zero (43 → 43). Extended handover suite: 895 → 896 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:59:08 +00:00
Khalim Conn-Kowlessar
fb173cdf3f Slice S0380.151: RdSAP 10 §4.1 Table 5 — extract-fans age-band default
RdSAP 10 Specification §4.1 Table 5 "Ventilation parameters" (PDF p.28)
verbatim — "Extract fans" entry:

  • Number of extract fans if known
  • If number is unknown:
      Not park home:
        Age bands A to E      all cases             → 0
        Age bands F to G      all cases             → 1
        Age bands H to M      up to 2 hab. rooms    → 1
                              3 to 5 hab. rooms     → 2
                              6 to 8 hab. rooms     → 3
                              more than 8 hab. rooms → 4
      Park home:
        Age band F            all cases             → 0
        Age bands G onwards   all cases             → 2

The Elmhurst Summary §12.0 renders "No. of intermittent extract fans: 0"
as the form for *unknown*; every other §2 chimney/flue line item follows
"number if known, or 0 if not present" and the cascade trusts the lodged
value verbatim. Only extract fans have a non-zero age-band default.

Pre-slice the cascade read the lodged 0 verbatim → cohort-wide -0.044
ACH ventilation deficit (= -2.6 W/K HLC, = -1.2% SH demand, = ~-0.3 SAP
per variant). All 25 cascade-OK corpus variants are age G + 4 habitable
rooms + not park home → Table 5 default = 1 fan.

New helper `_rdsap_extract_fans_default(age_band, habitable_rooms, *,
is_park_home)` + wiring in `ventilation_from_cert` applies
`max(lodged, table_5_default)` so the spec minimum fires when lodging
is below it.

Heating-systems corpus impact (25 cascade-OK variants):

  oil 1, oil pcdb 1/2/3            +0.27..+0.29 → EXACT (<1e-4)
  electric 1, solid fuel 5/6/7/8   +0.28..+0.43 → EXACT
  pcdb 1, ashp                     +0.41 / +0.18 → ±0.02
  electric 3/6/7/8/9, sf 4/9/10/11 +0.39..+0.60 → +0.08..+0.12
  electric 5                       -0.74 → -1.18  (Cluster B over-shoot)
  electric 2                       -0.24 → -0.46  (Cluster C HW gap)
  gshp                             +1.09 → +0.94  (Cluster C HW gap)
  solid fuel 2/3                   +3.08 / +1.76  → +2.77 / +1.31

Cluster A (cohort-wide HLC deficit) is closed. The four remaining open
fronts (Clusters B + C) are now visible without offsetting bugs:
  - Cluster B (Table 9c step 12 R sign): electric 5, solid fuel 2/3
  - Cluster C (HW kWh cascade): gshp + electric 2 (Appendix N3)
                                solid fuel 2/3 (Table 4b HW efficiency)

Golden-fixture re-pins:
  cert 0240 (age J, TFA 118): PE +2.18 → +5.80, CO2 +0.13 → +0.32
  cert 0390-2954 (age F, TFA 360): PE -28.27 → -27.97, CO2 -2.74 → -2.71

Pyright net-zero (44 → 44). Extended handover suite: 893 → 895 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:26:53 +00:00
Khalim Conn-Kowlessar
a658f73613 Slice S0380.150: SAP 10.2 §12 / Appendix F2 — 18-hour high-rate for pumps + lighting
SAP 10.2 §12 (PDF p.45 lines 2280-2283):

  "The 18-hour tariff is only for use with electric CPSUs with
   sufficient energy storage to provide space (and possibly water)
   heating requirements for 2 hours. Electricity at the low-rate price
   is available for 18 hours per day, with interruptions totalling 6
   hours per day, with the proviso that no interruption will exceed 2
   hours. The low-rate price applies to space and water heating, while
   electricity for all other purposes is at the high-rate price."

SAP 10.2 Appendix F2 (PDF p.63 lines 3809-3812):

  "F2 Electric CPSUs using 18-hour electricity tariff. The 18-hour
   low rate applies to all space heating and water heating provided
   by the CPSU. The CPSU must have sufficient energy stored to provide
   heating during a 2-hour shut-off period. The 18-hour high rate
   applies to all other electricity uses."

Table 12a Grid 2 omits 18-hour / 24-hour from its 7-hour / 10-hour
table; pre-slice the cascade's `_other_fuel_cost_gbp_per_kwh` fell
through Grid 2's `NotImplementedError` to
`prices.standard_electricity_p_per_kwh` (Table 32 code 30 = 13.19
p/kWh). Per §12 + Appendix F2 the 18-hour rule is explicit fraction =
1.0 at the high rate — pumps, fans, and lighting bill at the 18-hour
high rate (Table 32 code 38 = 13.67 p/kWh).

All 41 heating-systems corpus variants lodge `meter_type='18 Hour'`,
so this gap was cohort-wide. Pre-slice the cascade undercounted
pumps + lighting cost by (13.67 − 13.19) × kWh on every variant:

  oil 1            Δcost -£9.31 → -£6.69   (closed £2.62, pumps 265 +
                                            lighting 282 × £0.0048)
  oil pcdb 1/2     Δcost -£8.32 → -£6.29   (closed £2.03)
  oil pcdb 3       Δcost -£8.91 → -£6.29   (closed £2.62)
  pcdb 1           Δcost -£11.10 → -£9.07  (closed £2.03)
  ashp             Δcost -£5.57 → -£4.22   (closed £1.35, lighting only)
  electric 1..9    Δcost shift ~ -£1.35..+£1.35  (lighting only;
                                                  storage / room-heater
                                                  certs carry pumps_fans
                                                  = 0)
  solid fuel 4..11 Δcost ~ -£1.55 (lighting only)
  gshp             Δcost -£26.48 → -£25.12 (closed £1.35)

Pyright net-zero (43 → 43). Extended handover suite: 892 → 893 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 09:34:09 +00:00
Khalim Conn-Kowlessar
35ea664db8 Slice S0380.149: Table 4f — circulation pump dispatch by pump age + wet-boiler gate
SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other
auxiliary uses" — Heating system circulation pump rows:

  Circulation pump, 2013 or later                 41 kWh/yr
  Circulation pump, 2012 or earlier              165 kWh/yr
  Circulation pump, unknown date                 115 kWh/yr

Pre-slice the cascade hardcoded `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY[2]
= 160 kWh/yr` (115 Unknown CH + 45 gas flue fan) for category=2 gas
boilers and fell through to `_DEFAULT_PUMPS_FANS_KWH_PER_YR = 130`
for any other category. Both shortcuts ignored the per-cert
`central_heating_pump_age` lodging AND incorrectly applied
circulation pump electricity to dry electric storage / direct-acting
/ room heater systems (no primary water loop).

Implementation:

  - Mapper: `_elmhurst_pump_age_int` now recognises both "Pre 2013"
    and "2012 or earlier" string forms as the SAP10 enum 1 (Pre 2013).
    Pre-slice "2012 or earlier" silently returned 2 (2013 or later)
    on the entire oil corpus, mis-applying the 41 kWh post-2013
    circulation pump to certs that lodge "2012 or earlier" via
    Elmhurst Summary §14 "Heat pump age".
  - New `_is_wet_boiler_main(main)` gate: identifies wet-boiler
    systems by Table 4a/4b code range (101-141 gas/oil, 151-161
    solid fuel, 191-196 electric boilers), PCDB Table 322 record,
    or category ∈ {1, 2} fallback. Heat pumps (cat 4) return False
    per Table 4f note "Not applicable for electric heat pumps from
    database". Electric storage / direct / room heater codes
    (401-499, 601-699) return False — they have no primary loop.
  - New `_table_4f_circulation_pump_kwh(main)` dispatches on
    `central_heating_pump_age`:
        None / 0 → 115 kWh (Unknown date)
        1        → 165 kWh (Pre 2013 / 2012 or earlier)
        2        →  41 kWh (2013 or later)
  - New `_table_4f_main_1_gas_boiler_flue_fan_kwh(main)` extracts
    the gas-flue-fan 45 kWh logic from the old category dispatch.
    Gated on `_is_wet_boiler_main` + gas fuel + fan_flue_present.
  - Remove `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` and
    `_DEFAULT_PUMPS_FANS_KWH_PER_YR` constants (the new helpers
    replace both).

Worksheet evidence for the wet-boiler gate:

  electric 1 (code 191 electric boiler):   ws (230c) = 41 kWh ✓
  electric 5 (code 402 electric storage):  ws (231)  =  0 kWh ✗
  solid fuel 2 (code 158 anthracite):      ws (230c) = 41 kWh ✓
  solid fuel 9 (code 636 wood stove):      ws (231)  =  0 kWh ✗
  oil 1 (code 127 condensing oil):         ws (230c) = 165 kWh ✓
  oil pcdb 3 (PCDB 18573):                 ws (230c) = 41 kWh ✓

Cascade impact across heating-systems corpus (vs S0380.148 state):

  | Variant        | SAP Δ        | Cause |
  |----------------|--------------|-------|
  | oil 1          | +0.60→+0.40  | 165 + 100 = 265 ≡ worksheet exact |
  | oil pcdb 1/2   | -0.15→+0.36  | 41 + 100 = 141 ≡ ws exact |
  | oil pcdb 3     | +0.59→+0.39  | same |
  | pcdb 1         | -0.03→+0.50  | 41 + 100 = 141 ≡ ws (was over) |
  | electric 1     | -0.06→+0.45  | 41 (wet electric boiler) |
  | electric 3-9   | -0.1..-1.4→  | 0 (dry storage/UFH) |
  |                | +0.5..+0.6   | was 130 default; now 0 |
  | solid fuel 2-8 | various      | 41 (boilers) — partial closures |
  | solid fuel 9-11| -0.2→+0.5    | 0 (room heaters) — was 130 |

Re-pins reflect spec-correct application. Per
[[feedback-software-no-special-handling]]: pre-slice near-zero pins
were masking pre-existing offsetting cascade gaps; spec correctness
unmasks them.

Golden fixtures impact:

  - cert 0240 (dual oil combi, pump_age=0 Unknown): PE +2.52→+2.18
  - cert 0390 (Firebird PCDF oil, pump_age=0): PE -28.08→-28.27
  - cert 6035 (gas combi, pump_age=2 post-2013): PE +47.29→+46.42

Cert 6035 closer to zero (post-2013 41 kWh < pre-slice 115 unknown).
Cert 0240/0390 small shifts from removing the gas-cat-2 hardcoded
160 path for oil mains.

Tests:
  - test_sap_table_4f_circulation_pump_dispatches_per_central_heating_
    pump_age — asserts oil 1 inputs.pumps_fans_kwh_per_yr == 265
    (165 Pre 2013 + 100 liquid fuel) ± 1.0.
  - test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_
    100_kwh (S0380.148) still passes.

Extended handover suite: 892 pass, 0 fail. Pyright net-improved
(removed unused `main_category` variable, file 33→32 errors).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 09:14:11 +00:00
Khalim Conn-Kowlessar
1b1f45b679 Slice S0380.148: Table 4f — liquid fuel boiler flue fan and fuel pump (100 kWh/yr)
SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other
auxiliary uses" row:

  Liquid fuel boiler — flue fan and fuel pump   100 kWh/yr  c) d)

Note c): "Applies to all liquid fuel boilers that provide main heating,
but not if boiler provides hot water only. Where there are two main
heating systems include two figures from this table."

Pre-slice the cascade's `_table_4f_additive_components` only wired:
  - (230a) MEV / MVHR
  - (230e) Main 2 gas-boiler flue fan (45 kWh)
  - (230g) Solar HW pump

The liquid-fuel sibling row was missing — oil 1 worksheet (230d) and
oil pcdb 3 worksheet (230d) both lodge 100 kWh/yr "oil boiler pump"
that the cascade was silently skipping.

Implementation:

  - Add `_LIQUID_FUEL_CODES = frozenset({4, 71, 73, 75, 76})` and new
    `is_liquid_fuel_code(fuel_code)` helper in
    `domain/sap10_calculator/tables/table_32.py`. Mirror of
    `is_electric_fuel_code` — routes through `_to_table_32_code`
    normalisation so Elmhurst-derived Table 32 codes (e.g. code 23
    = bulk wood pellets, solid) don't collide with API enum codes
    (where 23 = B30D community).
  - Extend `_table_4f_additive_components` to add 100 kWh for Main 1
    when `is_liquid_fuel_code(main.main_fuel_type)` returns True
    (`isinstance(int)` guard for the `Union[int, str]` field). Mirror
    the same gate for Main 2 per Note c) "Where there are two main
    heating systems include two figures".
  - LPG is GAS (Table 4b/4f convention, Ecodesign classification) —
    `_LIQUID_FUEL_CODES` deliberately excludes 2/3/5/9 LPG codes.

Cascade impact across heating-systems corpus:

  | Variant   | SAP Δ       | Cost Δ      | PE Δ        |
  |-----------|-------------|-------------|-------------|
  | oil 1     | +1.18→+0.60 | -£27→-£14   | -276→-124   |
  | oil pcdb 1| +0.42→-0.15 |  -£10→+£3.4 |  -84→+67    |
  | oil pcdb 2| +0.42→-0.15 |  -£10→+£3.4 |  -84→+67    |
  | oil pcdb 3| +1.16→+0.59 | -£27→-£14   | -271→-120   |
  | pcdb 1    | +0.57→-0.03 | -£13→+£0.6  | -109→+42    |

Cohort closures: pcdb 1 EXACT (-0.03), oil pcdb 1/2 closed to -0.15.

Golden fixtures impact:

  - cert 0240 (dual-main oil combi 130): SAP integer 73→72 (resid
    +0→-1), PE +1.02→+2.52, CO2 +0.11→+0.14. Dual-main certs add
    2 × 100 = 200 kWh aux per Note c). Cert's published SAP 73
    suggests the dual-main Q_space split (main_heating_fraction)
    may also need wiring — slice candidate.
  - cert 0390 (Firebird PCDF 9005 oil combi): PE -28.50→-28.08
    (CLOSER to zero), CO2 -2.75→-2.73 (CLOSER to zero), SAP +7
    unchanged.

Test:
  test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_
  100_kwh — asserts oil pcdb 3 inputs.pumps_fans_kwh_per_yr ≥ 230
  (130 base + 100 liquid fuel boiler aux).

Extended handover suite: 891 pass, 0 fail. Pyright net-zero (44=44).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:53:23 +00:00
Khalim Conn-Kowlessar
7dceeff24b Slice S0380.147: Appendix D Eq D1 — Table 4b non-PCDB boilers (winter/summer monthly cascade)
SAP 10.2 Appendix D §D2.1 (2) Equation (D1) (PDF p.57):

  If the boiler provides both space and water heating, and the summer
  seasonal efficiency is lower than the winter seasonal efficiency,
  the efficiency is a combination of winter and summer seasonal
  efficiencies according to the relative proportion of heat needed
  from the boiler for space and water heating in the month concerned:

              Q_space + Q_water
  η_water,m = ───────────────────────────────
              Q_space/η_winter + Q_water/η_summer

  where Q_space (kWh/month) is the quantity calculated at (98c)m
  multiplied by (204) or by (205);
        Q_water (kWh/month) is the quantity calculated at (64)m;
        η_winter and η_summer are the winter and summer seasonal
        efficiencies (from Table 4b).

Pre-slice the cascade only wired Eq D1 for PCDB-tested boilers (the
`pcdb_record` branch in `_apply_water_efficiency`). For non-PCDB
Table 4b boilers (`sap_main_heating_code` 101-141) where the cert
lodges no `main_heating_index_number`, the cascade fell through to
the scalar `water_efficiency_pct` divisor — which resolved via WHC
901 inherit to Table 4b WINTER eff (wrong direction; spec wants the
monthly Eq D1 blend).

This slice:

  - Adds `domain/sap10_calculator/tables/table_4b.py` with the full
    41-row Table 4b (winter, summer) pair dict for codes 101-141
    verbatim from SAP 10.2 PDF p.168 (Table 4b).
  - Refactors `_apply_water_efficiency` parameter from
    `pcdb_record: Optional[GasOilBoilerRecord]` to
    `eq_d1_winter_summer_pct: Optional[tuple[float, float]]` —
    decouples the Eq D1 input from the PCDB record so a Table 4b
    fallback can populate it without faking a PCDB record.
  - Resolves Eq D1 inputs at the call site with priority order:
        1. PCDB Table 105 winter/summer (existing path)
        2. SAP 10.2 Table 4b (PDF p.168) winter/summer when PCDB
           absent + WHC=901 (`_WHC_FROM_MAIN_HEATING`, the spec form
           of "boiler provides both space and water heating").
    §9.4.11 -5pp interlock applies symmetrically to both columns of
    whichever (winter, summer) tuple is resolved.

Oil 1 cert worksheet (217)m verified Jan 81.83 / Apr 81.42 / May
79.94 / Jun-Sep 72.00 / Dec 81.86 — exact back-solve to Eq D1 with
Table 4b code 127 (winter 84, summer 72). Annual HW fuel (219) =
Σ (64)m × 100 / (217)m = 3638.99 kWh/yr ≡ cascade post-slice.

Cascade impact:

  Heating-systems corpus (worksheet-pinned, oil 1 only on pin grid):
    oil 1  SAP +1.76 → +1.18  (Δ -0.59)
           cost -£40.60 → -£27.12  (Δ +£13.48)
           CO2  -129.22 → -55.36   (Δ +73.86 kg/yr)
           PE   -590.02 → -275.52  (Δ +314.50 kWh/yr)
    Remaining oil 1 residual is Table 4f auxiliary energy (cascade
    pumps_fans 130 kWh vs worksheet 265 kWh — missing the oil-boiler
    pump 100 kWh + CH pump 130 vs ws 165). Follow-up slice.

  Golden fixtures (cert-pinned, integer-rounded PE):
    cert 0240 (dual oil combi 130, no cylinder): PE +0.05 → +1.02
    cert 6035 (gas combi 104, no cylinder):      PE +46.10 → +47.29
    Both shifts reflect spec-correct Eq D1 now firing for non-PCDB
    combi-no-cylinder configs. The pre-slice near-zero pin on cert
    0240 was masking offsetting cascade gaps (likely Table 4f
    auxiliary energy and/or dual-main Q_space split per (98c)m ×
    (204) which the cascade currently treats as full demand).

Following [[reference-unmapped-sap-code]] discipline, the new Table
4b dict is the canonical spec-source — `domain.sap10_ml.sap_
efficiencies._SPACE_EFF_BY_CODE` still carries the winter column for
the ML feature cascade and is left in place per the sap10_ml
deprecation plan (separate migration).

Test:
  test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_
  table_4b_boiler_with_cylinder — asserts cert 1431 oil 1 HW fuel
  annual = 3638.99 ± 1.0 kWh/yr (matches worksheet (219)).

Extended handover suite: 890 pass, 0 fail. Pyright net-zero (44=44).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:22:46 +00:00