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>
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>
The newer Elmhurst Summary export lodges a gas combi as §14.0 "Fuel Type"
empty + "Main Heating SAP Code" 104 (EES "BGW"), with no fuel string. The
site-notes mapper left `main_fuel_type=''`, so `cert_to_inputs` raised
`MissingMainFuelType` — blocking the whole gas-combi Summary path
(reproduced on the simulated 001431 case).
SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas boilers (including
mains gas, LPG and biogas)": the code fixes the boiler type/efficiency but
NOT the carrier, so 104 alone can't distinguish mains gas from LPG. The
disambiguator is §15.0 "Water Heating Fuel Type" — a combi/boiler heats
space + water from one appliance — exactly mirroring the existing
liquid-fuel (codes 120-141) fallback. `_elmhurst_gas_boiler_main_fuel`
adopts the §15.0 carrier only when the SAP code is in 101-119 AND §15.0
resolves to a gas/LPG fuel, so a regular boiler + electric immersion
(§15.0 = "Electricity") still strict-raises rather than mis-billing gas
as electric.
2291 passed (+1), 0 failed; pyright net-zero on both files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Point-in-time note for the next agent: what S0380.185-189 shipped (worksheet
PE/CO2 pins, the two D_PV electricity-vs-gain fixes, and the thermal-mass-
parameter Table 22 fix), the per-line diagnosis template, the two worksheet-
block / gains-vs-solar traps, and the ranked open slices (Summary-path fuel
derivation first, then pin the simulated 001431 case, then cert 6035).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The §7 mean-internal-temperature cascade hardcoded the thermal mass parameter
(TMP) to 250 kJ/m²K at all 5 call sites, ignoring construction. RdSAP 10
§5.16 Table 22 (PDF p.48) makes TMP construction-dependent:
100 kJ/m²K — timber frame, cob, park home (regardless of internal
insulation); OR masonry (stone/solid brick/cavity/system
built) WITH internal insulation.
250 kJ/m²K — masonry WITHOUT internal insulation.
A too-high TMP inflates the §7 time constant τ = Cm/(3.6·H) (e.g. 40 h vs
16 h), under-cuts the temperature reduction between heating periods, and
over-states mean internal temperature → over-states space heating.
`_thermal_mass_parameter_kj_per_m2_k(epc)` classifies the MAIN building's
wall via the RdSAP `wall_construction` codes (5/7/8 = timber/cob/park) and
`wall_insulation_type` codes (3/7 = internal); unknown/curtain fall back to
the masonry 250 (no regression on unlisted classes). 17-case parametrised
test covers every Table 22 branch.
Diagnosis (per-line walk vs the user-simulated 001431 worksheet, same
archetype as golden cert 6035): fabric (26-37), internal gains (73), climate
(96)m and HTC (39) all EXACT; the entire +8.78 PE / -1.76 SAP gap was §7 MIT
(92) +0.71 °C, traced to TMP 250 vs Table 22's 100 (solid brick WITH internal
insulation). Fix closes the simulated case to 1e-4 on PE and CO2.
Blast radius: only golden cert 6035 re-pins (solid brick + internal
insulation) — SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. The
47 dr87 cohort, 6 U985 fixtures and 41-variant heating corpus are all
masonry-no-internal → TMP unchanged at 250, all still pass. 2290 pass
(+17 new), 0 fail; pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single durable doc so agents can pick up the calculator without reading
historical handovers: (1) the accuracy bar for the two input paths
(site-notes 1e-4 vs worksheet; API 1e-4 when a worksheet exists, ±0.5
register fallback otherwise; cross-mapper parity); (2) the per-line-walk
debugging loop incl. comparing site-notes vs API; (3) the tools &
pipeline (Summary PDF → extractor → from_elmhurst_site_notes →
cert_to_inputs → calculate_sap_from_inputs → SapResult, plus the API
from_api_response front-end, section helpers, and where the test vectors
live). Pointer added from SAP_CALCULATOR.md; HANDOVER_* flagged as
point-in-time notes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Appendix M1 §3a (p.93) defines PV-eligible demand as
D_PV,m = E_L,m + E_A,m + E_cook,m + E_ES,m + (231)·n_m/365 + E_space,m + E_water,m
where E_L,m is the lighting ELECTRICITY (Appendix L eq L10, = line (232)).
The cascade fed `internal_gains_result.lighting_monthly_w` — the L12 internal
heat GAIN G_L,m = E_L,m × 0.85 ("assuming 15%" of lighting energy does not
become internal heat) — into D_PV, understating it by 15% of lighting on
every PV cert. That depressed the monthly β onsite/export split and
under-credited PV primary energy uniformly across the year.
Same gain-vs-electricity class as the cooking fix S0380.73 (L18 gain vs L20
electricity). Fix: scale the (shape-identical) lighting gain profile to the
annual E_L `lighting_kwh_per_yr` (= (232)), mirroring the (219)m hot-water
scale-to-annual. Magnitude-only, so the shape-weighted lighting CO2/PE
effective factor (Σkwh×f/Σkwh, magnitude-invariant) is unchanged; appliances
need no scaling (G_A = E_A, no 0.85). Diagnosis was empirical first (calc
lighting D_PV 95.1 vs worksheet (232) 111.88, ratio exactly 0.85) then
confirmed against the spec text (L9d/L10/L12, M1 §3a).
Impact (calc − full-precision dr87 worksheet): ALL 47 worksheet certs now
match at <1e-4 on BOTH PE (max |Δ| 0.0000 kWh/m²) and CO2 (max |Δ| 0.0000 kg)
— the convergence target, met cohort-wide. Combined with S0380.187 this
closes the entire gas+PV + ASHP PV residual. Re-pinned 47 worksheet residuals
to 0.0000 and 31 drifted lodged residuals (PV certs). SAP integers unchanged;
chain SAP 1e-4 intact (164 pass). 2273 pass, 0 regressions; pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PV onsite/export β-split (SAP 10.2 Appendix M1 §3a, p.93) divides PV
generation by the monthly PV-eligible electricity demand D_PV,m. The cascade
included main and water electricity (when those fuels are electric) but had
no term for SECONDARY space heating. For the 10 cohort-2 gas-main +
electric-secondary + PV certs, the (215)m secondary electric fuel was dropped
from D_PV,m — understating demand in the heating months only, depressing the
monthly β, and under-crediting onsite PV primary energy.
Spec: Appendix M1 §3a counts E_space,m as the dwelling's TOTAL electric
space-heating demand; for a gas-main/electric-secondary dwelling that is the
secondary fuel. Diagnosis was decisive: E_PV (generation) matched the
worksheet exactly every month, the onsite (233a) split diverged ONLY in
heating months (Jun-Sep near-exact), and all 10 affected certs have PV while
all clean gas certs have none. Empirically adding (215)m to D_PV closed cert
3136 onsite 726.9 → 790.3 (worksheet 792.1).
Impact (calc − full-precision dr87 worksheet), the 10 certs:
PE +0.5..+1.5 → +0.02..+0.046 kWh/m²; CO2 −0.5..−1.1 → +0.002..+0.0095 kg.
The whole 47-cert cohort now matches at PE <0.05 / CO2 <0.025. SAP integers
unchanged; chain SAP 1e-4 pins intact (164 pass). The uniform ~0.03 PE remnant
on PV certs is the separate (233a)/(233b) summer-month D_PV discrepancy.
Re-pinned the 10 worksheet + 9 lodged golden residuals (improvements).
2273 pass, 0 regressions; pyright net-zero (file's 32 errors pre-existing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The existing golden test compares calc PE/CO2 against the integer-rounded
lodged register values (energy_consumption_current / co2_emissions_current),
which conflates real calculator gaps with register rounding. This adds a
parallel pin against each cert's Elmhurst dr87 worksheet (286)/(272) at full
precision — a clean calculator-vs-Elmhurst signal for the 47 worksheet-backed
certs (9 ASHP + 38 cohort-2).
Findings at capture (calc − worksheet, on the worksheet's own decimal TFA):
- 37/47 exact on both PE (<0.05 kWh/m²) and CO2 (<0.02 kg).
- 10 higher-consumption gas certs carry PE +0.5..+1.5 kWh/m² AND
CO2 -0.5..-1.1 kg simultaneously. PE-over + CO2-under on the same
certs is the fingerprint of a small gas→electricity fuel-split
difference (elec PE 1.51 > gas 1.13, but elec CO2 0.136 < gas 0.21),
not a factor-value error — next slice candidate.
An earlier "41/47 PE gaps" reading was a JSON-integer-TFA division artifact;
comparing on the worksheet's decimal TFA (which the calculator also uses)
collapses it to the real 10. Worksheet values frozen as literals (the dr87
PDFs are untracked, so not parsed at test time) per the worksheet_unrounded_sap
convention. Also replaced a pre-existing pytest.approx with abs-diff to keep
the file at zero pyright errors (feedback_abs_diff_over_pytest_approx).
106 passed (was 59); pyright 0 errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Captures the corpus state (36 EXACT + 5 pinned community-heating
variants), the SAP 302 CHP credit cluster as the highest-leverage
remaining front, the unresolved 0.8523 / 0.1994 worksheet-factor
mysteries to per-line-walk before hypothesising, and — importantly —
the new test layout (tests/domain/sap10_calculator/) that changes every
verification command.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
test_heating_systems_corpus.py (and one pcdb-1 cross-check in
test_cert_to_inputs.py) read the 001431 controlled-variable corpus PDFs
directly at runtime from `sap worksheets/heating systems examples/`, but
that directory was never committed — it was supplied locally on
2026-05-30 and only ever existed on dev machines. CI therefore errored
with "no Summary PDF in …" for all 57 corpus variants.
Commit the 82 corpus PDFs (41 populated variant folders × Summary +
P960, 4.7 MB) in place so the cascade-vs-worksheet residual pins run in
CI, matching the existing convention where the U985 / 000565
conformance fixtures are committed under
backend/documents_parser/tests/fixtures/ (31 PDFs already tracked).
Only the .pdf fixtures are added; the stray .DS_Store and a P960 .txt
dump in pcdb 1/ are left untracked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two unrelated breakages surfaced after merging the PR into this branch;
neither was caused by the appliances/cooking work.
test_appendix_u.py (9 failures) — signature drift + wrong methodology
label. The climate lookups were renamed `external_temperature_c(region=…)`
→ `(region_or_climate, month)` when PostcodeClimate support landed for
the demand cascade, but the tests still passed `region=`. The expected
values match our SAP 10.2 _TABLE_U1/U2/U3 exactly (UK-avg Jan 4.3 °C,
Thames Jul 17.9 °C, solar Jul 189 W/m², Shetland Jan wind 9.5 m/s), so
these are valid 10.2 coverage — fixed the call signature to positional
and corrected the mislabelled "SAP 10.3" docstrings to SAP 10.2 (we
track 10.2 deliberately). Also converted pytest.approx → abs(x-y)<=tol
per the repo convention; pyright on the file drops 48 → 0.
test_table_32.py (2 failures) — the parametrised "match PDF p.95" test
pinned heating oil (code 4) = 7.64 and FAME (code 73) = 5.44, but the
table deliberately diverges from the PDF for these two carriers: oil =
5.44 (Slice S0380.131, two independent lodging engines agree the PDF
7.64 is the outlier) and FAME = 7.64 (Slice S0380.168). Updated the two
expected values to the worksheet-canonical figures the table actually
uses, with inline citations + a docstring note on the divergence.
Full calculator + property_baseline + heating-corpus suites: 1748 pass,
0 fail. pyright net-improving on both files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ADR-0014 BillDerivation prices a per-end-use EnergyBreakdown
(HEATING / HOT_WATER / LIGHTING / PUMPS_FANS / APPLIANCES / COOKING).
SapResult already carried the first four but not appliances or cooking,
so a downstream SapResult→EnergyBreakdown adapter had to stub those two
at 0 kWh — understating the bill by the whole unregulated electricity
load. Surface them so the property_baseline side can wire the sections.
Adds two output-only fields to CalculatorInputs + SapResult, threaded
exactly like lighting_kwh_per_yr:
appliances_kwh_per_yr — SAP 10.2 Appendix L L13/L14/L16a annual E_A
(sum of the §5 (68) monthly appliances kWh)
cooking_kwh_per_yr — SAP 10.2 Appendix L L20 (p.91) ELECTRICITY
estimate E_cook = 138 + 28×N
Both values already existed in cert_to_inputs.py (appliances_monthly_kwh,
cooking_monthly_kwh) — reused, not recomputed.
Fuel attribution: cooking_kwh_per_yr is the L20 ELECTRICITY figure (the
field docstring says so), distinct from the L18 cooking heat GAIN
(35 + 7N W) the §5 internal-gains cascade uses. The bill adapter should
treat cooking as an electricity carrier; a gas-cooker split, if ever
needed, is a separate follow-up.
HARD CONSTRAINT honoured — output-only, zero rating drift. Appliances +
cooking are unregulated and are NOT fed into ECF / total_fuel_cost /
CO2 / primary energy / sap_score. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical (1165 rated pins green). The synthetic CalculatorInputs
fixtures set the new fields non-zero on purpose so the existing cost/PE
reconciliation assertions act as leak detectors.
New focused test asserts both fields are populated (non-zero) and
threaded unchanged onto SapResult, with cooking equal to the L20
electricity figure (138 + 28×occupancy) to 1e-9. pyright net-zero
111 → 111.
Note: 11 pre-existing failures in test_appendix_u.py / test_table_32.py
arrived with the recently absorbed PR and are unrelated to this change
(they fail identically on the clean branch); flagged separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR feedback: the SapResult -> Performance mapping should be a method, not a
free function you must know exists in the rebaseliner. Put the factory on
the target as `Performance.from_sap_result`, beside its sibling
`lodged_performance` and mirroring `Epc.from_sap_score` (the factory this
mapping already calls).
Not a `SapResult.to_performance()`: that would make the SAP calculator
import `Performance` (a property_baseline type), re-introducing the
engine->consumer coupling removed by the SapCalculator ABC. SapResult is a
TYPE_CHECKING-only import in performance.py (the body only reads attributes),
so the calculator module is not pulled in at runtime.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR feedback: annotate locals assigned from a method-call return or
attribute access, even though pyright infers them — the type is visible at
the assignment without chasing the callee. `result: SapResult` and
`sap_version: Optional[float]` in rebaseline(). Local annotations are not
evaluated at runtime, so the TYPE_CHECKING-only SapResult import stands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR feedback: prefer an abstract base the calculator inherits from over a
structural Protocol. Define `SapCalculator(ABC)` in the calculator package
(the engine owns its own contract) and have `Sap10Calculator` inherit it;
a future methodology is another subclass. Placing the ABC with the engine —
not in property_baseline — keeps the dependency pointing consumer -> engine
(sap10_calculator imports nothing from property_baseline). Consistent with
the repo's existing port convention (FuelRatesRepository(ABC)).
CalculatorRebaseliner keeps its reference to SapCalculator type-only (under
TYPE_CHECKING), so the module still does not import the calculator at
runtime. Test fakes now inherit the ABC since structural conformance no
longer applies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 5b: update the FE-owned migration spec so the other repo can create the
bill columns in parallel.
- Bill block: per-section delivered kWh + cost (heating, hot water, lighting,
appliances, cooking, pumps/fans, cooling) + standing_charges_gbp,
seg_credit_gbp, total_annual_bill_gbp, fuel_rates_period.
- space_heating_kwh / water_heating_kwh (RHI recorded demand) marked SUPERSEDED
by heating_kwh / hot_water_kwh (calculator delivered fuel); kept until the bill
populates, then dropped.
- Cooling section kept (mostly 0 but affects the bill, cheap to store).
- Records the calculator-load-bearing posture (effective_* may differ from
lodged_* for pre-10.2) and that columns are defined now / populated when the
SapResult->EnergyBreakdown adapter + BillDerivation wiring land.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 5a: the promotion. Replaces StubRebaseliner in production and collapses the
shadow runner into the rebaseliner (ADR-0013 amendment).
- CalculatorRebaseliner runs Sap10Calculator on every Property:
* sap_version < 10.2 -> Effective Performance IS the calculator output
(band via Epc.from_sap_score, CO2 kg->t, PEUI rounded), reason "pre_sap10".
* sap_version >= 10.2 -> Effective = lodged (API figures on-target), and the
calculator only logs divergence (SAP>0.5, PEUI/CO2 1%) as a validation signal.
* a calculator raise propagates -> batch aborts (ADR-0012); fix the cert at once.
- Rebaseliner.rebaseline gains property_id (for the divergence log).
- LoggingCalculatorShadow / the calculator_shadow seam removed from the
orchestrator; its divergence-comparison logic now lives in the rebaseliner.
- StubRebaseliner kept (signature updated) for orchestrator/repo unit tests.
The SapResult->EnergyBreakdown adapter + BillDerivation wiring (to populate the
bill block) follow once the appliances/cooking SapResult fields land.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 of Bill Derivation. sap_code_to_fuel(code) maps a SAP 10.2 / Table 32
fuel code to the canonical billing Fuel — bounded to the ~47 Table 32 codes (the
carrier, orthogonal to the PCDB product index, so all PCDB heat pumps share one
electricity code). Mains gas / LPG / oil+bioliquids / coal / smokeless / wood /
electricity (standard + off-peak) / heat-network groupings; an unmapped code
(dual fuel, grid-export) raises UnmappedSapCode rather than guessing.
Also: ADR-0014 deferred/TODO section records the stubbed appliances+cooking
(pending the SapResult fields), the off-peak day/night split, the heat-network
rate gap, and regional rates / ETL.
The SapResult -> EnergyBreakdown adapter (next slice) is gated on the
appliances/cooking fields landing on SapResult.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2 of Bill Derivation. BillDerivation(fuel_rates).derive(breakdown) takes a
delivered-energy breakdown (per-section EnergyLine(section, fuel, kwh) +
exported_kwh) and produces a Bill: per-section kWh + cost, standing charges,
SEG credit, and total.
- Each end-use line billed at its fuel's unit rate.
- Standing charge added ONCE per distinct fuel used (a meter, not an end use);
off-gas fuels carry 0 so contribute nothing — no metered/unmetered special case.
- SEG export credit subtracted.
- Deterministic (ADR-0006); raises UnpricedFuel (via FuelRates) on an unpriced
fuel (e.g. heat network) rather than billing at a wrong default.
Pure domain — no calculator dependency; the SapResult->EnergyBreakdown adapter
is slice 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of Bill Derivation — the reference-data foundation that later slices
price the calculator's per-end-use kWh against:
- Fuel enum (canonical billing fuels; the join key between the calculator's
SAP-code fuels and the rates snapshot). COAL + HEAT_NETWORK are members with
no national rate.
- FuelRates value object: unit_rate_p_per_kwh / standing_charge_p_per_day /
seg_export_p_per_kwh; raises UnpricedFuel on a fuel it has no rate for rather
than billing at a wrong default.
- FuelRatesRepository port (ADR-0011 Repo-reads-stored-reference-data) +
StaticFileFuelRatesRepository reading a committed JSON snapshot.
- Snapshot fuel_rates_2026_q2.json: GB national, Apr-Jun 2026 Ofgem cap
(gas/electricity) + DESNZ/NEP May 2026 (off-gas). Carries the full researched
data; the value object exposes single-rate fuels this slice. Off-peak
(day/night), house coal and heat network raise UnpricedFuel until later slices.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pin the bills design from a /grill-with-docs session:
- ADR-0014: whole-home annual bill from SAP10 Calculation's delivered kWh per
end use, re-priced at real Fuel Rates (NOT the calculator's SAP-notional
total_fuel_cost_gbp, which is RdSAP Table 32 standardised prices ~half real
electricity). Fuel enum + FuelRates + FuelRatesRepository static snapshot;
per-section + total flat columns; raise on unpriced fuel (house coal /
heat network are the named gaps).
- ADR-0013 amendment: the shadow stepping-stone is collapsed — the calculator
is load-bearing now. effective=calculated for sap_version<10.2 (StubRebaseliner
floor 10.0->10.2); >=10.2 keeps lodged + logs divergence; a strict-raise
aborts the batch (load-bearing for bills regardless of version).
- CONTEXT: EPC Energy Derivation -> Bill Derivation (no "service" suffix);
Baseline Performance energy block = per-end-use kWh + per-section bill + total;
Fuel Rates = committed static snapshot; Rebaselining trigger threshold 10.2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire Sap10Calculator into PropertyBaselineOrchestrator as a non-load-bearing
shadow runner. For each property it scores the Effective EPC beside the
load-bearing Lodged/Effective write, catches any strict-raise -> log.error
(never aborts the batch), and on success log.warning's divergence from Lodged:
SAP |continuous - lodged| > 0.5; PEUI/CO2 > 1% relative (CO2 after kg->tonnes).
Every line is tagged with sap_version so SAP-10.2 signal separates from
older-spec drift (ADR-0010 Validation Cohort).
Per ADR-0013, Calculated SAP10 Performance is not a persisted third value-set:
effective = calculated in every baselining scenario, so the calculator IS the
mechanism that produces Effective Performance (the Rebaseliner). It runs in
shadow only while being hardened; when overrides/estimation land it is promoted
to drive Effective and the failure posture flips to abort (ADR-0012, calculator
now load-bearing). No table change.
- ADR-0013 + CONTEXT (Calculated SAP10 Performance / Effective Performance /
Rebaselining) record the decision.
- CalculatorShadow port + LoggingCalculatorShadow + Calculator protocol.
- FakeCalculatorShadow for orchestrator unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Factual staleness fix flagged in the handover; the calculator lives in
domain/sap10_calculator/calculator.py. Glossary term 'Baseline Performance'
deliberately left unchanged (concept vs PropertyBaselinePerformance class).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Orientation for the next chat picking up the two open fronts after the
ara_first_run rebuild shipped:
- where things stand (merged to main via per-cert; branch/worktree layout;
PRs into per-cert), authoritative ADRs/CONTEXT to read,
- current architecture + key files (post baseline→property_baseline /
FirstRun→AraFirstRun rename),
- conventions + gotchas (TDD, ephemeral PG, FakeUnitOfWork, pyright noise to
ignore, gh-credential push workaround),
- Task 1: wire Sap10Calculator into PropertyBaselineOrchestrator (Calculated
SAP10 Performance as a third value-set; failure-posture decision),
- Task 2: Modelling (stubs to build out; MaterialsRepository naming open;
needs a UoW when writing Plans),
- the raising/no-op seams not to mistake for done,
- known doc drift flagged (CONTEXT term vs PropertyBaselinePerformance class;
stale domain/sap/ path → domain/sap10_calculator).
Also banners ara_backend_design.md as superseded (architecture) by ADR-0011/0012.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>