Unblocks two 38-cert-cohort certs that previously raised
`UnmappedElmhurstLabel("cylinder_size", 'Normal')` at extraction:
cert 2536-2525-0600-0788-2292 ws SAP=79.7264
cert 9421-3045-3205-1646-6200 ws SAP=87.4495
Both Summary §15.1 lodgements read "Cylinder Size: Normal"; both dr87
worksheets lodge line ref (47) "Store volume = 110.0000" L (extracted
from `Hot Water Cylinder → Cylinder Volume 110.00`). RdSAP 10 §10.5
Table 28 documents the "Normal (90-130 litres)" descriptor whose
midpoint is 110 L — the canonical Elmhurst label string in
`datatypes/epc/surveys/elmhurst_site_notes.py` is "Normal (90-130
litres)", and the worksheet's exact 110 L matches the midpoint.
Two-line fix:
+ "Normal": 2, in `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10`
+ 2: 110.0, in `_CYLINDER_SIZE_CODE_TO_LITRES`
The cascade enum 2 is consistent with the existing
`cert_to_inputs.py` docstring's documented (but not-yet-observed)
code 2 → Normal slot, alongside code 3 (Medium / 160 L) and code 4
(Large / 210 L) added in earlier slices.
Slice keeps tight: two mapping unit tests pinning `cylinder_size == 2`
for both certs at extraction. Post-fix the first-attempt cascade
deltas vs worksheet are:
cert 2536 Δ +0.0244 (was: RAISES)
cert 9421 Δ +0.0296 (was: RAISES)
Both deltas now sit in the same systematic +0.02..+0.07 small-gap
band as ~12 other first-attempt certs in cohort 2 — chain test +
±0.07 pin would just paper over a known systematic residual that the
user has explicitly asked to drive towards 1e-4, not toward ±0.07.
Following slice will investigate the shared systematic offset and
close cert 2536 / 9421 along with the rest of the +0.04 band on
the chain.
Pyright net-zero per file:
- datatypes/epc/domain/mapper.py: 32 (baseline 32)
- domain/sap10_calculator/rdsap/cert_to_inputs.py: 35 (baseline 35)
- backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0
Regression baseline: 691 pass + 10 fail (= prior 689 + 10 + 2 new GREEN).
Spec refs:
- RdSAP 10 §10.5 Table 28 — "Cylinder Volume" Normal band 90-130 L,
midpoint 110 L (also the canonical Elmhurst label suffix).
- Cert 2536 worksheet `dr87-0001-000889.pdf` line ref (47) = 110.0000.
- Cert 9421 worksheet `dr87-0001-000884.pdf` line ref (47) = 110.0000.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hands off the next workstream: the 38 cert subdirs at
`sap worksheets/additional with api 2/`. Each subdir is named after
the 20-digit EPC cert reference and contains a Summary PDF + dr87
worksheet PDF. API JSONs are NOT in the dataset but ARE fetchable
via the existing `EpcClientService` (token in `backend/.env` as
`OPEN_EPC_API_TOKEN`).
User's stated ordering: Elmhurst Summary mapping FIRST, API path
SECOND. Folder names = cert refs; need to verify the matching before
bulk-pinning (any mis-filed PDF would silently invalidate slice
work).
Handover ships with verified dataset and first-attempt baselines:
- Folder-vs-cert sweep: **38/38 match** at handover (postcode
parity check between Summary PDF and Open EPC API).
- First-attempt Summary-path probe across 38 certs:
24 ✅ closed at ±0.07 (first-try, zero new slices needed)
9 ~ small gap (<1 SAP) — likely 1 slice each
3 ✗ big gap (>1 SAP) — multi-slice investigation
2 RAISES UnmappedElmhurstLabel: cylinder_size='Normal'
The two `Normal` cylinder raises are the immediate Phase 1 slice —
Slice S0380.15's strict-enum pattern paid off on its first new
cohort by surfacing the gap at extraction time instead of as a
downstream SAP delta.
Workstream phases documented in the handover:
Phase 0: folder-vs-cert sweep (already done — 38/38)
Phase 1: fix 'Normal' cylinder unmapped-label raise
Phase 2: bulk-pin the 24 first-try-closures as chain tests
Phase 3: close the 9 small-gap certs one slice each
Phase 4: investigate the 3 big-gap certs (likely HP-routing)
Phase 5: fetch + persist API JSON for all 38, run API path tests
Phase 6: cross-mapper EPC parity (Summary EPC ≡ API EPC) — the
user's stated north-star
Includes:
- Paste-able diagnostic probe scripts (Summary path + folder-vs-
cert sweep + .env loader + EpcClientService usage example).
- Full table of first-attempt deltas per cert with classifications.
- All 15 prior-session slice commits indexed.
- Memory references to the slicing / methodology conventions.
- Per-cert diagnostic recipe template.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes cert 2636 to spec floor (Δ +0.5167 → +0.0323) by accepting
both the EPC schema enum-as-string ("0") AND the Elmhurst Summary
mapper's descriptive form ("House") for the cantilever-detection
property-type gate at `heat_transmission.py:768`.
Root cause: slice 102f-prep.9 (commit 06b4ef3d) added cantilever
detection gated on `epc.property_type == _PROPERTY_TYPE_HOUSE` where
`_PROPERTY_TYPE_HOUSE = "0"`. That matches the API mapper's encoding
(schema enum), but the Summary mapper produces "House" (descriptive)
and the hand-built worksheet fixtures also use "House" — so neither
triggers the gate and the cantilever path silently no-ops on the
Summary path. Cert 2636's worksheet (28b) "Exposed floor Main 3.74
× 1.20 = 4.4880" is the cantilever — without surfacing it the
cascade missed 4.488 W/K of floor heat loss.
Three-encoding origins:
- API mapper: property_type='0' (schema enum-as-string)
- Summary mapper: property_type='House' (descriptive from §1)
- Hand-built fixtures: property_type='House' (legacy convention)
Fix: replace the equality check with a `_is_house()` helper that
accepts the {"0", "House"} frozenset. Centralised so future
property-type sensitive gates can call the same helper.
Forcing function: cert 2636 first-attempt Summary SAP closes from
Δ +0.5167 (after S0380.12 walls fix) to Δ **+0.0323** — within the
±0.07 ASHP-cohort spec floor. `floor_w_per_k` moves from 19.1982
(ground floor only) to 23.6862 (ground 19.20 + cantilever 4.49 =
worksheet (28a) + (28b) exact match).
Cohort closure status (6 of 7 ASHP certs at spec floor):
cert Δ vs worksheet spec floor?
0380 +0.0594 ✓
0350 +0.0458 ✓
2225 +0.0441 ✓
2636 +0.0323 ✓ ← this slice
3800 +0.0442 ✓
9285 +0.0502 ✓
9418 +2.5973 ✗ (Daikin EDLQ05CAV3 — final cert)
Boiler hand-built parity verified intact: 5 hand-built cohort certs
(000474, 000477, 000480, 000490, 000516) all use property_type=
"House" and now also fire the cantilever gate, but none have
floor1_area > floor0_area + 1m² (the cantilever-area trigger) so
their cascade output is unchanged. Regression suite 683 pass + 10
fail (= handover baseline 669 + 10 + 17 new GREEN tests across
S0380.2..S0380.13).
Pyright net-zero on edited files:
domain/sap10_calculator/worksheet/heat_transmission.py: 13
(baseline; no new errors)
backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0
Spec / precedent refs:
- Slice 102f-prep.9 (commit 06b4ef3d) — RdSAP cantilever-exposed-
floor detection (originally API-only via `property_type=="0"` gate).
- SAP 10.2 Table 20 — U_exposed_floor (age D + no insulation →
1.20 W/m²K, the cohort's cantilever U-value).
- Cert 2636 worksheet `dr87-0001-000898.pdf` line refs (28a)+(28b)
sum 23.6862 W/K (exact cascade match after this slice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 7-cert ASHP cohort API path is closed at the spec-precision
floor (this session). Next workstream is the Summary path for cert
0380 — the user's preferred starting point because the Summary +
worksheet PDFs surface labelled intermediate values that the API
path lacks.
Cert 0380 Summary PDF (`Summary_000899.pdf`) is already in the
test fixtures dir; just needs a path constant + RED chain test.
Previous handover flagged the extractor at Δ -58.37 SAP for HPs
— the immediate diagnostic is whether the mapper surfaces
main_heating_category=4 and main_heating_index_number=104568.
The handover also documents the user's "Elmhurst-specific"
challenge worth re-exploring: closed boiler certs hit 1e-4 vs
Elmhurst via the same cascade, so the residual is precisely at the
Appendix N3.6 PSR interpolation step. Cross-check with the BRE
xlsx canonical calculator is suggested.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Updates the handover with the final state after 11 slices:
- All 7 ASHP cohort certs cascade SAP integer == lodged (residual 0).
- Continuous SAP residual clusters within +0.030..+0.060.
- BRE web confirmed max_output_kw values (4.39 / 3.933) match cascade
exactly — the remaining drift is NOT a max_output bug.
- Cascade (39) annual avg HLC EXACTLY matches worksheet (39) at 4 dp
for cert 0380 and 2225 — HLC is NOT the bug either.
- Implied drift is ~0.15% in η_space interpolation precision, likely
in Elmhurst's internal rounding convention (not in public SAP 10.2
spec or BRE PCDB).
Recommends Path A (ship Layer 4 chain tests at ±0.07 SAP tolerance)
as the spec-precision floor. Path B (close to 1e-4) requires Elmhurst
implementation access that's outside public docs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fetches the API JSON for each of the 6 previously-missing ASHP
cohort certs (0350, 2225, 2636, 3800, 9285, 9418) into
tests/fixtures/golden/ so they're tracked alongside cert 0380 (the
cohort anchor lodged earlier). Each cert's residual against its
GOV.UK EPC lodgement is pinned in `_GOLDEN_EXPECTATIONS`:
- SAP integer residual = 0 across all 7 certs (cascade rounds to
the lodged value exactly).
- PE residual: -7.93 to -14.79 kWh/m² (cascade UNDER-estimates
primary energy by ~8-15 — likely PV cascade self-consumption
β-factor split per Appendix M §3, untouched by this workstream).
- CO2 residual: +0.16 to +0.28 t/yr (cascade OVER-estimates by ~0.2).
The pins lock the current cascade state so future mapper / cascade
changes fire loudly when they shift the 7-cohort residuals (the same
pin-tracking convention as the existing 8 boiler golden certs).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RdSAP §1.4.2: window openings deduct from the gross of the wall they
pierce. The cert schema lodges `window_wall_type` on each SapWindow:
code 1 = main wall, codes 2/3 = alternative walls 1/2. Cohort
ground-truth: cert 2636 BP0 lodges one window (1.14 × 1.04 ≈ 1.19 m²)
with `window_wall_type=2` → it pierces alt.1 (12.76 m² cavity
unfilled at age D → U=0.70).
Pre-fix the cascade subtracted ALL openings from the BP's (main+alt)
gross then routed each alt at its FULL gross — over-counting alt's
contribution by 1.19 × U_alt and under-counting main by 1.19 × U_main.
For cert 2636: 1.19 × (0.70 − 0.25) = +0.535 W/K cascade walls excess,
matching the observed cascade walls 20.56 vs worksheet 20.024.
`_window_on_alt_wall` translates the per-window `window_wall_type`
code; the per-BP loop aggregates alt-wall windows into
`alt_window_area_by_bp`, passes that opening area through to
`_alt_wall_w_per_k` (alt.1 only — no cohort cert exercises alt.2
windows), and adds the deducted area back to the main wall's net
area so the conservation invariant holds.
Cohort impact: cert 2636 cascade walls closes from 20.5595 → 20.0240
(spec-exact to 1e-3). Cascade (37) closes from 114.7067 → 114.1846
(Δ +0.0134 from a small thermal-bridging area rounding diff). Cert
2636 SAP shifts from -0.0055 → +0.0323 — joining the cohort cluster
(all 7 ASHP certs now within +0.030 to +0.059 SAP).
The current near-zero cancellation state for cert 2636 was hiding
two opposite cascade errors (over-count walls + under-count η_space).
This slice closes walls correctly; the remaining +0.03 SAP cluster
across all 7 certs is the systematic PSR-denominator HLC×ΔT drift
documented in the handover (not max_output, which BRE confirmed
is 4.39 kW exactly).
Zero regressions on Elmhurst hand-built fixtures, closed-cert Layer
4 1e-4 chain gates, or golden cert residual pins.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RdSAP "first floor over passageway" rule — when an upper storey has
larger floor area than the storey immediately below, the excess
overhangs an unheated space or external air and routes through
Table 20's U_exposed_floor (1.20 W/m²K for age-D + no insulation,
the modal cohort lodging).
Cohort ground-truth: cert 2636 BP0 floor 1 (42.92 m²) − floor 0
(39.18 m²) = 3.74 m². Worksheet (28b) "Exposed floor Main: 3.74 ×
1.20 = 4.4880" matches the spec rule exactly.
`_part_geometry` now computes `cantilever_floor_area_m2` per BP.
The per-BP loop in `heat_transmission_from_cert` injects U×A onto
the floor accumulator and includes the area in (31) total external
area (which feeds (36) thermal bridges).
Gated to avoid false positives on flats and sub-ground multi-storey
shapes:
- `property_type == "0"` (house) — excludes flats (cert 9501 BP0
has 6.85 m² floor 0 + 74.43 m² floor 1; the diff is stairwell
access, not a real cantilever).
- `excess >= 1 m²` — excludes 2-dp rounding artefacts (cert 001479
Main BP0 lodges floor 1 = 30.77 vs floor 0 = 30.45 → 0.32 m²
drift that's not a real cantilever; would otherwise add 0.4
W/K and break the closed-cert 1e-4 Layer 4 chain gate).
- `excess / prev_area < 0.25` — excludes sub-ground / partial-
storey shapes (cert 7536 BP0: 33.7/17.28 = 195% — not a real
cantilever; floor 0 likely a partial vestibule, not the full
ground footprint).
Cohort impact: cert 2636 SAP residual closes from +0.4873 → -0.0055
(by far the largest cohort outlier becomes the closest match).
Zero regressions: 654 pass + 10 pre-existing baseline fails (9 cert
001479 hand-built skeleton + 1 FEE). All 7 ASHP certs now cluster
within ±0.06 SAP vs worksheet.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Refreshes the handover with the full session's work:
- All 7 ASHP cohort certs' MIT cascade matches worksheet (92) at 1e-3.
- 6/7 cohort SAP residuals cluster at +0.03..+0.06 vs worksheet.
- Identified PSR-formula drift root cause: max_output_kw ≈ 4.40 kW
back-solved from 3 certs' worksheet η_space pins, vs the 4.39 lodged
at PCDB position 47 (likely a field-position misread; needs BRE web
cross-check for PCDB 104568 / 102421).
- Identified cert 2636's +0.49 outlier as missing cantilever Exposed
floor (3.74 m² = upper-floor 42.92 − ground-floor 39.18 area diff).
Recommends Path A (resolve max_output + cantilever to land 1e-4) or
Path B (widen Layer 4 tolerance to 0.1 with documented limitations).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cert 2225 (Mitsubishi PUZ-WM50VHA, semi-detached 2-bp, TFA 82.49)
lodges `sap_heating.shower_outlets = None` in the Open EPC API
JSON. The worksheet (42a) "Hot water usage for mixer showers" reads
0 every month — Elmhurst's convention is "absent ⇒ no shower".
Pre-fix the API mapper returned `mixer_shower_count = None`,
deferring to the cert→inputs cascade's "RdSAP modal lodging"
default of 1 vented mixer. That added ~7 L/day to (44) daily HW
use, ~113 kWh/yr to (62) HW demand, and shifted cert 2225's SAP
residual from -0.31 → +0.04 (now aligned with the cohort's
+0.03..+0.06 cluster) once the mapper returns 0.
`_count_shower_outlets_by_type` now treats None as 0 (the API
mapper-only path). The cert→inputs cascade's
`_mixer_shower_flow_rates_from_cert` keeps the None→1 default for
the Elmhurst hand-built fixture path that doesn't route through
this helper.
Cohort impact: 6 of 7 ASHP certs now cluster at SAP Δ +0.03 to
+0.06 (vs worksheet); only cert 2636 remains an outlier (+0.49).
Golden cert PE/CO2 pins re-pinned for 6035, 8135, 0390 (the three
certs that previously lodged shower_outlets=None and consumed the
spurious 1-mixer default).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Appendix N3.5 Table N4 (PDF p.107) — heat-pump packages
with fixed daily heating durations:
- "24" → N24,9 = 365 (continuous): every day at heating temperature,
no off period → (days_in_month, 0) per month → MIT_zone = Th.
- "16" → N16,9 = 365 (unimodal, 0700-2300): every day with single
8h off → (0, days_in_month) per month → MIT_zone = Th − u1(8h).
- "9" → standard SAP schedule (bimodal 7+8 off): falls through to
`None` so the orchestrator applies the legacy bimodal path.
Cert 9418 (Daikin Altherma EDLQ05CAV3, PCDB 102421) lodges
`heating_duration_code = "24"` — worksheet (87) MIT_living = 21.0
every month (= Th1, no off period) and (90) MIT_elsewhere collapses
to Th2 directly. Pre-fix the bimodal cascade produced MIT ~17.8-19.8
(2.04°C low at Jan) and SAP was +2.20 over worksheet 84.6305.
Post-fix cert 9418 closes to SAP Δ +0.0296 (from +2.20) — the
residual is consistent with the same ~0.05 PSR-formula drift seen
in 5/7 cohort certs sharing PCDB 104568.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Session shipped 6 slices closing cert 0380's SAP residual from
+0.5999 → +0.0594 vs worksheet 88.5104. The MIT cascade now matches
worksheet line (92) at 1e-3 per month and is spec-faithful through
SAP 10.2 Appendix N3.5 + Equation N5. Remaining residual is a
single PSR-formula divergence (cascade PSR 1.4266 per spec vs
worksheet-implied 1.4321, ~0.4%) that propagates to η_space at 0.2%
and ~0.045 SAP. Three candidate root causes documented; investigation
deferred to next session as the blocker for slice 102f's Layer 4
1e-4 chain test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4f (PDF p.169) — heat-pump packages (main heating
category 4) bundle the circulation pump's electricity into the
system COP, so worksheet line (70) "Pumps, fans" reports zero gain
for every month on HP certs. Cert 0380's worksheet confirms 0.0
through Jan-Dec.
`internal_gains_from_cert` previously called `central_heating_pump_w`
unconditionally and routed the 3/7/10 W (date-bucket) result through
the seasonal mask in `pumps_fans_monthly_w`. For HP certs that added
~7 W of spurious heating-season gains to (73)m → cold-month MIT
drifted +0.008°C above worksheet (92).
Gating the pump-W computation on `_CATEGORIES_WITHOUT_CENTRAL_HEATING
_PUMP = {4}` zeroes the gain for HP certs and leaves every other
category (gas, oil, electric storage, …) on the existing cascade.
Cohort impact:
- Cert 0380 MIT 12-tuple now matches worksheet (92) at 1e-3 per
month (worst Δ at Nov = -0.0009°C).
- SAP residual closes from +0.155 → +0.059 vs worksheet 88.5104.
- Closed certs (001479 / 0330 / 9501 — all boiler cohorts, cat 2
or 1) are unaffected; Layer 4 1e-4 chain gates remain GREEN.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Appendix N3.5 (PDF p.106-107) replaces Table 9c steps 3-4
for heat-pump packages with PCDB data — each month blends the
heating temperature Th, the unimodal (16-hour day, one 8-hour off
period per Table N7 footnote b) zone temperature, and the bimodal
(9-hour day, two off periods per Table N7) zone temperature via
Equation N5:
T = [N24,9 × Th + N16,9 × T_uni + (Nm − N16,9 − N24,9) × T_bi] / Nm
`mean_internal_temperature_monthly` gains an optional
`extended_heating_days_per_month` kwarg (12-tuple of (N24,9_m,
N16,9_m)). When provided, the orchestrator computes T_unimodal per
zone from a single 8-hour off-period reduction and blends; when
None (default — every non-HP cert) it returns T_bimodal directly,
so closed certs (001479, 0330, 9501) are bit-identical.
`cert_to_inputs` derives the per-month tuple for HP certs with PCDB
records carrying `heating_duration_code = "V"` (Variable) — the
only code lodged on modern records per SAP 10.2 PDF p.105 footnote
48. Cohort path: PSR (= max_output_kw × 1000 / (HLC × 24.2 K)) →
Table N5 PSR interpolation → cold-first day allocation. Fixed
durations "24" / "16" / "9" from legacy Table N4 are deferred —
not exercised by the cohort.
Cert 0380 SAP residual closes from +0.5999 → +0.1550 vs worksheet
88.5104. The remaining ~0.16 SAP delta is split between two
orthogonal §5 / §7 residuals (cold-month +0.008°C MIT drift from
spurious HP pump gains; sub-1e-3 efficiency bias) that the next
slices target. Pin tolerance is 1e-2 per month on worksheet (92)
to capture this slice's contract alone, with `feedback_zero_error_
strict` widening documented inline.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Appendix N3.5 Equation N5 (PDF p.107):
T = [N24,9 × Th + N16,9 × T_uni + (Nm − N16,9 − N24,9) × T_bi] / Nm
`extended_zone_mean_temperature_c` is the pure-math leaf: takes
pre-computed bimodal (9-hour heating, two off periods) and unimodal
(16-hour heating, one 8-hour off period per Table N7 footnote b)
zone temperatures and the per-month day allocations, blends across
the three heating patterns (Th for 24-hour days, T_uni for 16-hour,
T_bi for the standard 9-hour SAP schedule).
Pinned against cert 0380's January living-area MIT: Th=21, T_bi
=18.5551 (worksheet "Living" row), T_uni back-solved from (87)
= 19.6153, N24=3, N16=28, Nm=31 → 19.7493 (worksheet (87) Jan).
Collapses cleanly: N24=N16=0 → T_bi (warm months / non-HP certs);
N24=Nm → Th (full 24-hour heating).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Appendix N3.5 (PDF p.107): "Allocate these to months in the
following order: Jan, Dec, Feb, Mar, Nov, Apr, Oct, May (coldest to
the warmest), until all the days N24,9 and N16,9 have been allocated.
Days N24,9 are allocated first."
`allocate_extended_heating_days_to_months` distributes annual N24,9
and N16,9 totals (from Table N5) across the cold-first month order,
with N24 days filling first and N16 days filling whatever space
remains in each month afterward.
Cross-pinned against the spec's PSR=0.2 worked example (PDF p.107):
Jan-Oct each get max N24, May ends up with the residual (6, 6). And
against cert 0380's worksheet: PSR≈1.43 → row 1.2+ (3, 38) →
Jan(3, 28), Dec(0, 10) — matches the worksheet 24/9 + 16/9 rows.
The 8 cold-month order spans 243 days, exceeding every Table N5
row's combined total — no allocation is dropped for Variable
heating duration. Fixed durations ("24" / "16" from Table N4) live
beyond this helper's contract (caller decides when N24=365 means
"all months at Th"); slice 102f-prep.4 wires that in.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Appendix N3.5 + Table N5 (PDF p.107) — for heat pumps with
"Variable" daily heating duration, the annual N24,9 and N16,9 totals
(days operating at 24h or 16h instead of the standard 9h) are obtained
by linear interpolation between Table N5 rows at the dwelling's plant
size ratio, rounded to the nearest whole number of days.
Clamps to the table bounds (PSR ≤ 0.2 → first row; PSR ≥ 1.2 → last
row) per the same convention applied to PSR efficiency lookup in
Appendix N (PDF p.101 lines 6007-6008).
Cohort sanity: cert 0380's PSR ≈ 1.43 → (3, 38) per the last-row
clamp; worksheet shows Jan N24,9=3 + Jan/Dec N16,9=28+10=38 — exact
match to Table N5 row "1.2 or more".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Appendix N3.5 (PDF p.105 line 6099) — heat-pump packages
lodge a "Daily heating duration" field encoded as "24" / "16" / "9"
/ "V" (Variable). Footnote 48 (PDF p.105): "Daily heating durations
of 24, 16 and 9 hours are retained for legacy purposes" — modern
records always lodge "V".
Format-465 position 48 holds the code; cohort ground truth: "V" on
Mitsubishi PUZ-WM50VHA (104568) and Daikin EDLQ05CAV3 (102421).
The field drives Appendix N3.5 + Table N4/N5 day allocation for the
extended-heating MIT cascade (slice 102f-prep.2 onward).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For any cert lodging a Table 362 heat-pump PCDB record, the cascade now
replaces the Table 4a category defaults with PSR-interpolated
efficiencies per SAP 10.2 Appendix N (PDF p.108):
(206) = 0.95 × η_space,1_interp (N3.6 in-use factor)
(217) = in_use_factor × η_water,3_interp (N3.7(a) + footnote 49)
where η_space,1 and η_water,3 are PSR-dependent values from the PCDB
record's PSR-group table (decoded in slice 102c.2), and the dwelling's
PSR is computed per PDF p.100 line 5946-5950:
PSR = max_nominal_output_kw / (HLC_annual_avg_W_per_K × 24.2 K / 1000)
The N3.7 in-use factor (PDF p.6097) tests three cylinder criteria:
1. cert volume ≥ PCDB volume
2. cert heat-exchanger area ≥ PCDB area (unless PCDB area = 0 per fn53)
3. cert heat loss [(47)×(51)×(52)] ≤ PCDB heat loss
All three pass → 0.95; any criterion fails or is unknown → 0.60. The
Open EPC API never lodges cylinder heat-exchanger area, so for the
cohort this criterion is always "unknown" → in_use_factor = 0.60.
Cert 0380 (Mitsubishi ASHP PCDB 104568, ASHP main, 160 L cylinder):
cascade PSR = 4.39 / (127.158 × 24.2 / 1000) ≈ 1.4266
cascade η_space,1_interp ≈ 235.24 (PSR-1.2 row 253.9, PSR-1.5 229.2)
cascade η_water,3_interp ≈ 285.13 (PSR-1.2 row 287.7, PSR-1.5 284.3)
cascade main_heating_eff ≈ 2.2348 (vs worksheet 2.2305, 1.9e-3 diff)
cascade HW kWh/yr ≈ 878.05 (vs worksheet 877.97, 0.08 kWh/yr)
cascade SAP rating ≈ 89.11 (vs worksheet 88.5104, +0.60)
The remaining +0.60 SAP residual is bounded by the ~0.4% PSR-formula
drift (the cascade computes PSR=1.4266 from (39)_annual_avg × 24.2 K
whereas the worksheet back-solves to ≈ 1.4321). Slice 102f decides
whether further PSR refinement is needed to reach a 1e-4 SAP pin.
SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) define the primary circuit
loss for cylinders heated indirectly through primary pipework:
(59)m = n_m × 14 × [{0.0091 × p + 0.0245 × (1 − p)} × h + 0.0263]
Inputs:
p pipework insulation fraction — Table 3 rows: 0.0 uninsulated,
0.1 first 1 m, 0.3 all accessible, 1.0 fully insulated. RdSAP §3
default table (PDF p.56) supplies p by construction age band:
bands A-J → 0.0, K, L, M → 1.0.
h hours per day of primary circulation, winter / summer split:
• no cylinder thermostat → 11 / 3
• thermostat, NOT separately timed → 5 / 3
• thermostat, separately timed → 3 / 3
("Use summer value for June, July, August and September and
winter value for other months" — spec p.159 footer.)
Spec p.159 lists the zero-loss configurations:
- electric immersion heater
- combi boiler
- CPSU
- thermal store within single casing
- separate boiler + thermal store within 1.5 m insulated pipe
- direct-acting electric boiler
- heat pump from PCDB with HW vessel integral to package
The cohort gate is now PCDB-aware: HP main + PCDB Table 362 record
`hw_vessel_mode != 1` (i.e. non-integral) → primary loss applies. All
7 cohort ASHPs lodge `hw_vessel_mode = 2` (separate and specified)
per Table 362 records 104568 (Mitsubishi) and 102421 (Daikin).
Cert 0380 (band D → p=0.0; cylinder thermostat + separately-timed →
h=3 / 3) lands (59)Jan = 31 × 14 × (0.0245 × 3 + 0.0263) = 43.3132
kWh/month (test pinned at 1e-4 vs cert's dr87 worksheet).
Cumulative cert 0380 API state:
HW kWh/yr 431.4 → 653.1 (target 878, slice 102e closes via η_water)
SAP 92.3 → 91.2 (delta to worksheet 88.51 now +2.73, was +3.75)
Cohort regression: cert 0390-2954 (oil boiler + cylinder, age F →
band A-J p=0.0) now picks up ~516 kWh/yr primary loss, tightening PE
residual -27.50 → -26.01 and CO2 -2.66 → -2.52 (improvements). The
higher HW fuel shifts SAP residual -6 → -7. Re-pinned with slice-102d
note. Closed combi boiler certs (001479, 0330, 9501) unaffected:
has_hot_water_cylinder=false gates the primary-loss override to None.
SAP 10.2 Appendix N3.6 / N3.7(a) (PDF p.108) compute heat-pump
efficiencies from a PSR-dependent dataset in the PCDB record. Spec PDF
p.100 line 5957 instructs: "The PSR-dependent results applicable to
the dwelling are then obtained by linear interpolation between the two
datasets whose PSRs enclose that of the dwelling."
This slice decodes the format-465 PSR-group block (idx[58] count
followed by N groups × 9 raw fields apiece) and adds the interpolation
primitive. Field positions within each 9-field group reverse-engineered
against Mitsubishi PUZ-WM50VHA (104568) by back-solving cert 0380's
worksheet pin η_space=223.0480, η_water=171.0746:
group offset 0 → PSR
group offset 2 → η_space,1 (% gross)
group offset 6 → η_water,3 (% gross — Appendix N3.7(a) + footnote 49,
PSR-dependent and calculated via the annual performance
method, used directly for HPs providing both space +
water heating)
Offsets 1 / 3 / 4 / 5 / 7 / 8 are unpopulated for record 104568 and
not yet ground-truthed. They likely hold the secondary results
documented under format 464 field 42-43 (specific electricity
consumed, running hours) plus additional format-465 extensions.
The clamping behaviour at the PSR ends is taken from SAP 10.2 PDF
p.101 lines 6007-6008: "if the PSR is greater than the largest PSR in
the database record then the heat pump space and water heating
fractions for the largest PSR should be used, and if the PSR is less
than the smallest PSR in the database record then the heat pump space
and water heating fractions for the smallest PSR should be used".
Verified against cohort:
- Record 104568 (Mitsubishi PUZ-WM50VHA) → 14 PSR groups decoded;
interpolation at PSR=1.43 yields η_space,1≈234.96 and η_water,3
≈285.09, matching back-solved worksheet values (slice 102e applies
the N3.6 ×0.95 and N3.7 ×0.60 in-use factors to close the chain).
SAP 10.2 Appendix N (N3.6 / N3.7(a)) requires PSR-interpolated values
from PCDB Table 362 for any heat-pump cert. The published PCDF Spec
Rev 6b §A.23 documents format 464 for that table; the live
pcdb10.dat (April 2026) ships format 465, which extends 464 with
additional header fields between fields 11 and 12 and a larger PSR
group set. The parser-layer test pins the format-465 offsets against
the BRE web entry for Mitsubishi Ecodan 5.0 kW PUZ-WM50VHA
(pcdb_id=104568, the cohort's dominant heat-pump model — 6 of 7 ASHP
certs use it).
This slice lands only the header fields the downstream APM cascade
needs (PSR-group decoding + linear interpolation follow in slice 102c.2):
field spec ref format-465 idx
brand_name §A.23 field 7 6
model_name §A.23 field 8 7
model_qualifier §A.23 field 9 8
fuel §A.23 field 13 16
service_provision §A.23 field 17 22
hw_vessel_mode §A.23 field 18 23
vessel_volume_l §A.23 field 19 24
vessel_heat_loss_kwh_per_day §A.23 field 20 25
vessel_heat_exchanger_area_m2 §A.23 field 21 26
max_output_kw §A.23 field 30 47
`max_output_kw` is the PSR-denominator per SAP 10.2 PDF p.100 line 5946
("maximum nominal output of the package … divided by the design heat
loss of the dwelling"); BRE labels it "Output power @ -4.7°C" on the
web entry.
Cohort header parse verified end-to-end against BRE web ground truth
for record 104568. Identical field positions apply to the Daikin
EDLQ05CAV3 (102421, cert 9418), confirmed by spot-checking the
populated raw indices.
SAP 10.2 §4 line 7690 (full spec PDF p.136) defines the cylinder storage
loss cascade for any cert with a hot water cylinder lodged:
(54) = V × L × VF × TF (Table 2 absence-of-declared-loss branch)
(55) = (54) (no manufacturer's declared loss)
(56)m = (55) × n_m (per spec, n_m = days in month)
where
L = Table 2 (PDF p.158) Note 1 formula for the lodged insulation type
(factory-insulated cylinders: 0.005 + 0.55/(t+4.0); loose jacket:
0.005 + 1.76/(t+12.8))
VF = Table 2a (PDF p.158) Note 2 closed form (120/V)^(1/3)
TF = Table 2b (PDF p.159) base 0.60 for indirect / electric-immersion
cylinders, × 1.3 if no thermostat, × 0.9 if DHW separately timed
Prior, `water_heating_from_cert` hard-coded `solar_storage_monthly_kwh
= zero12` and `_water_heating_worksheet_and_gains` had no path to
populate it. The new `cylinder_storage_loss_monthly_kwh` helper in
`worksheet/water_heating.py` exposes Tables 2 / 2a / 2b as small typed
functions plus a composite; the cert-side orchestrator in
`rdsap/cert_to_inputs.py::_cylinder_storage_loss_override` resolves
the lodged cylinder fields and injects the override.
Code → litres mapping ground-truthed against worksheet (47) line refs
in /sap worksheets/Additional data with api/<cert>/dr87-*.pdf for the
7-cert ASHP cohort: code 3 → 160 L (Medium, 6 certs) and code 4 →
210 L (Large, cert 9418). Codes 2 / 5 / 6 (Normal / Inaccessible /
Exact) absent from the cohort and not yet mapped.
Cylinder insulation type code → "factory_insulated" mapping
(_CYLINDER_INSULATION_TYPE_FACTORY = 1) ground-truthed against all 7
ASHP cohort worksheets ("Foam" lodgement → SAP 10.2 Table 2 Note 2
"factory-insulated cylinder where the insulation is applied in the
course of manufacture irrespective of the insulation material used").
RdSAP §3 default table (PDF p.57) — "Hot water separately timed:
Post-1998 boiler: Yes" — applied to heat-pump main heating systems
(cat 4) per the cohort worksheet evidence.
Cert 0380 (Mitsubishi ASHP, 160 L factory 50 mm, thermostat + separately
timed) lands the spec formula at worksheet (56) Jan = 36.9530 kWh/month
(test pinned at 1e-4); HW kWh/yr 242.21 → 431.38, recovering ~189 kWh/yr
of cylinder loss the cascade was previously dropping.
Cohort regression: cert 0390-2954 (oil boiler + 160 L cylinder) tightens
PE residual -28.6783 → -27.5026 kWh/m² and CO2 residual -2.7640 →
-2.6570 t/yr — both move closer to the lodged values (improvement).
Re-pinned with a slice-102b note.
Closed boiler chain tests (001479, 0330, 9501) unaffected: those certs
lodge has_hot_water_cylinder=false so the override stays None and the
existing zero-storage-loss default fires.
SAP 10.2 §4 line 7702 (full spec PDF p.137): "Combi loss for each month
from Table 3a, 3b or 3c (enter '0' if not a combi boiler)". The cascade
in `_water_heating_worksheet_and_gains` was falling through to the
Table 3a keep-hot 600 kWh/yr default whenever no PCDB Table 105 boiler
record was found — including every heat-pump cert (Table 105 only
contains gas/oil boilers).
Open EPC API certs typically lodge `sap_main_heating_code = None`, so
the gate keys off `main_heating_category` instead: {1, 2} for the
gas/oil/solid-fuel boiler family + {3, 6} for community heat networks
(preserves the existing DLF-scaling regression test). Categories 4
(heat pump), 5 (warm air), 7 (electric storage), 10 (room heaters) and
all other non-combi mains zero (61)m per the spec parenthetical.
Cert 0380 (Mitsubishi ASHP, cat=4): HW kWh/yr drops 503.08 → 242.21,
removing the bogus 600 kWh × 0.18 £/kWh = £77/yr inflation. Closed
boiler certs (001479, 0330, 9501 — all cat=2) and heat-network cert
parity unchanged.
Cert 0380 (semi-detached bungalow ASHP) was the prior handover's
"defer until HP go-ahead" pilot. Three slices this session closed
the dwelling-shape part of the gap:
- 101a: glazing_type=14 → DG/TG post-2022 (windows HLC exact)
- 101b: cavity wall + filled cavity + external insulation
(composite U via Table 14 R_ins + 2 d.p. round; walls HLC exact)
+ Table 11 cat-4 secondary fraction = 0
- 101c: Table 4f cat-4 pumps/fans kWh = 0
(37) total fabric heat loss is now EXACT vs worksheet 96.0889.
Remaining gap (Δ +2.92 SAP) is dominated by the hot water cascade:
the cert lodges a 160 L cylinder (storage loss + primary loss) and
the HW HP COP is model-specific (PCDB index 104568 → 1.711 per
worksheet, not the Table 4a generic 2.3 our cascade uses). Both
require new cascade work — HP HW-specific COP from PCDB plus
cylinder storage/primary loss application.
Cert 0380's HW work will benefit all 6 sibling ASHPs sharing PCDB
idx 104568 (and partially the 102421 outlier).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4f lists annual pumps + fans electricity consumption
by main heating category. The cascade's
`_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` only had cat-2 (gas-fired
boilers, 160 kWh = 115 pump + 45 flue fan) — HP certs (cat 4) fell
through to the 130 kWh/yr DEFAULT.
Heat pumps have NO additional pumps/fans contribution per Table 4f:
the HP system's circulation pump + fans are already incorporated
into the seasonal COP. Worksheet line (249) "Pumps, fans and
electric keep-hot" shows 0.0000 kWh for cert 0380 (ASHP).
Added `4: 0.0`. Effect on cert 0380 API path: pumps_fans cost
£17.15 → £0.00 (matches worksheet); total cost £171.36 → £154.21
(worksheet £206.75; remaining Δ -£52 is dominated by the hot-water
cascade gap which is the next slice — cylinder storage + primary
loss + HP HW COP + separate electric shower line all need work).
No golden cert residual shifts (cohort certs are all gas boilers).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two HP-specific cascade gaps blocking cert 0380:
(a) Cavity wall + filled cavity + external insulation:
Cert 0380's `walls[0].description="Cavity wall, filled cavity and
external insulation"` with `wall_insulation_type=6` +
`wall_insulation_thickness="100mm"`. RdSAP 10 §4-4 (page 73) lists
"cavity plus external" as a distinct insulation type code (6 in
the API schema; 7 is "cavity plus internal"). The U-value is the
composite U = 1 / (1/U_filled + R_ins) per §5.8 page 40 + Table 14
R-value lookup, with the cascade-2-d.p. round matching the dr87
worksheet's column display.
For cert 0380: U_filled (age D)=0.7 + R_ins (100mm @ λ=0.04)=2.5
→ U_unrounded=0.2545 → rounded 0.25 (worksheet exact). Walls HLC
14.87 → 11.6150 (= worksheet 11.6150). (37) total fabric heat
loss 99.34 → **96.0889** (= worksheet 96.0889 EXACT).
Added `WALL_INSULATION_CAVITY_PLUS_EXTERNAL: Final[int] = 6` and
`WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7` constants
+ `_WALL_INSULATION_LAMBDA_W_PER_MK = 0.04` default thermal
conductivity. New `u_wall` branch fires when cavity + composite
insulation type + non-zero thickness.
(b) SAP 10.2 Table 11 secondary fraction — missing cat-4 entry:
The dict `_SECONDARY_HEATING_FRACTION_BY_CATEGORY` had entries
for cats 1/2/3/5/6/7/10 but DID NOT include cat 4 (heat pump),
despite the inline comment explicitly noting "Cat 4 (heat pump):
0.00 (HP eff includes any secondary)". Cert 0380 lodges
`secondary_heating_type=691` + `main_heating_category=4` (HP,
PCDB idx 104568), so the cascade fell through to the DEFAULT
fraction 0.10 — billing 547 kWh × 13.19 p/kWh = £72 as
"secondary heating" that the worksheet correctly shows as £0.
Added `4: 0.00` to the dict.
Effect on cert 0380 API path:
- walls HLC 14.87 → 11.62 (worksheet exact)
- (37) total HLC 99.34 → 96.09 (worksheet exact)
- main_heating_cost £282 → £314 (worksheet £316)
- secondary_heating £72 → £0 (worksheet £0)
- sap_continuous 87.62 → 90.48 (Δ -0.89 → +1.97 — over-correcting
because hot-water cascade is still cascade-£66 vs worksheet £204
including electric shower; HP HW-COP + electric-shower cost are
the next slices).
No golden cert residual shifts (cohort certs don't lodge HP cat 4
or composite cavity+EWI walls).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cert 9501 (top-floor flat + RR + measured PV) is now CLOSED on both
Summary and API paths at 1e-4 vs worksheet 68.5252 (Slices 99a-99e
on Summary + 100a-100c on API). Three boiler certs in total now
have Layer 4 production gates.
Updated handover lists the 7 ASHP workstream (still deferred), the
8 cohort certs without worksheets (residuals tightened by Slice
100c's gap-aware DG-pre-2002 glazing lookup), and captures the 7
key learnings from cert 9501 closure as guidance for the HP
workstream.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two final API gaps to close cert 9501 at 1e-4:
(a) PV array surfacing — third shape variant:
Schema-21 EPCs carry `photovoltaic_supply` as one of three shapes:
- legacy `{"none_or_no_details": {...}}` (PV absent / roof-only)
- nested list `[[{...}], ...]` (cohort cert 2130)
- dict wrapper `{"pv_arrays": [{...}]}` (cert 9501)
The schema's `PhotovoltaicSupply` modelled only `none_or_no_details`
— cert 9501's measured arrays under `pv_arrays` were silently
dropped (Δ -£250 PV credit → -9.32 SAP). Added
`SchemaPhotovoltaicArray` dataclass + `pv_arrays:
Optional[List[...]]` sibling field on `PhotovoltaicSupply`; updated
`_map_schema_21_pv` to dispatch on the new shape.
(b) Gap-aware glazing lookup (RdSAP 10 Table 24 row 2):
DG pre-2002 spec U varies by gap: 6mm=3.1 / 12mm=2.8 / 16+=2.7.
The mapper's flat `_API_GLAZING_TYPE_TO_TRANSMISSION[3]` returned
U=2.8 unconditionally — cert 9501 lodges `glazing_gap="16+"` so
the worksheet uses 2.7. Added `_API_GLAZING_TYPE_GAP_TO_
TRANSMISSION` keyed by (type, gap) with the spec-table values for
code 3; `_api_glazing_transmission` consults the per-gap dict
first, falling back to type-only when no gap entry exists.
Refactored the inline `SapWindow(...)` build into
`_api_sap_window` helper (also nets one pyright error: net-zero
actually improved 33 → 32 on mapper.py).
Effect on cert 9501 API path:
- sap_continuous 59.20 → **68.525161** (= worksheet 68.5252 exact;
Δ -0.000039 — well within 1e-4)
- total_fuel_cost £1101 → £849.21 (= worksheet 849.21 exact)
- pv_export_credit £0 → £250.02 (= worksheet 250.02 exact)
Re-pinned residuals (5 cohort certs with glazing_gap="16+" or 6 now
pick up the spec-correct DG-pre-2002 U):
- 0300: PE +8.44 → +8.28, CO2 -0.23 → -0.25
- 6035: PE +48.30 → +47.85, CO2 +1.10 → +1.09
- 7536: PE -6.51 → -7.08, CO2 -0.17 → -0.19
- 8135: PE -5.31 → -3.66 (gap=6 spec U=3.1), CO2 -0.07 → -0.04
- 2130: PE -38.18 → -38.63, CO2 +0.30 → +0.30
Layer 4 chain test `test_api_9501_full_chain_sap_matches_worksheet
_pdf_exactly` added — third production gate after cert 001479 +
cert 0330. First flat-shaped cert in the production gate set.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`_total_floor_area_from_building_parts` previously summed only
`sap_floor_dimensions[*].total_floor_area`; the RR floor area lives
under `sap_room_in_roof.floor_area` per RdSAP §3.9 convention and
was dropped from the per-bp TFA sum. Cert 9501 (113.08 m² real
TFA, of which 31.8 m² is RR) showed TFA 81.28 on the API path —
the cascade then under-computed occupancy N (Appendix J), HW kWh
(Appendix J), lighting kWh (Appendix L), and internal gains.
Add the RR contribution to the sum. The top-level
`schema.total_floor_area` scalar (integer-rounded for cert 9501:
113 vs raw 113.08) is still the fallback when no per-bp dims are
lodged.
Re-pinned residuals (improvements — TFA now includes the previously-
dropped RR storey):
- 0240: SAP -15 → -14, PE +15.69 → +12.49, CO2 +0.90 → +0.70
- 6035: PE +49.51 → +48.30, CO2 +1.14 → +1.10
Effect on cert 9501 API path: TFA 81.28 → 113.08 (= worksheet
113.08 exact). SAP delta still -9.32 vs worksheet — the remaining
gap is dominated by the missing PV credit (£250 — next slice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures session state after cert 0330 closed both Summary and API
Layer 4 1e-4 gates (Slices 96-98). Cert 9501 fixtures are staged
(commit 5d1778ac) but the Summary path is RED at Δ -5.25 SAP because
the cert is a flat with RR + party-floor / party-ceiling — a
fundamentally different cascade shape from the boiler houses we've
validated.
Handover quantifies the cascade-component gaps (-69.92 W/K on walls
because RR gables aren't surfaced, +9.25 W/K on floor because the
party-floor exposure isn't recognised, +7.36 W/K on party walls
because U_party=0 isn't being applied), lists the 4 fixes likely
needed in slice order, and leaves the heat-pump workstream sketch
intact for when the user gives the go-ahead.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
API JSON + Summary PDF for cert 9501-3059-8202-7356-0204. RR/Mid-
terrace flat, 4 building storeys, TFA 113.08 m², mains gas boiler
(PCDB idx 19007), age band B. Worksheet target unrounded SAP
**68.5252**.
Second boiler cert per the per-cert mapper validation workflow:
Summary path proves itself against the worksheet (Layer 2 1e-4 pin),
then the API path catches up (Layer 4 1e-4 pin) — mirrors the cert
0330 cycle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the cert 0330 API path Layer 4 gate (Δ -0.000011 vs worksheet
SAP 61.5993) by surfacing two previously-broken inputs to the HW
cascade plus aligning the wall-net-deduction with the worksheet's
2-d.p.-per-window rounding convention.
(a) RdSAP schema 21.0.x `shower_outlets` shape mismatch:
real-API certs lodge `[{"shower_outlet_type": N, "shower_wwhrs":
M}, ...]` (a list of bare ShowerOutlet dicts), but the schema
modelled it as `[ShowerOutlets]` with nested
`{"shower_outlet": {...}}` wrappers. `from_dict` silently dropped
every bare element's payload (left `shower_outlet=None`),
blanking the cascade's mixer/electric counts on cert 0330 (and 4
other golden fixtures). Normalisation in `from_api_response`
rewrites the bare list shape to the wrapped form before
`from_dict` parses, so the schema's `ShowerOutlets` dataclass
sees the data it expects — no schema-class breakage downstream.
New helper `_count_shower_outlets_by_type` walks the normalised
list and counts outlets by integer code:
- code 1 → mixer (drives `mixer_shower_count`)
- code 2 → electric (drives `electric_shower_count`)
Empirically derived from the golden cohort + Summary mapper
cross-check (cert 0330 lodges code 2 + Summary surfaces "Electric
shower"; cert 0240 lodges multiple code-1 outlets on a
conventional oil-boiler + cylinder dwelling). No spec page
reference found.
Wired into both `from_rdsap_schema_21_0_0` and
`from_rdsap_schema_21_0_1`. Effect on cert 0330 API path:
`mixer_shower_count` 1 (cascade default) → 0; `electric_shower_
count` None (= 0) → 1; HW kWh 3172.65 → 2111.93. SAP Δ +2.1155
→ -0.0012.
(b) Per-window 2-d.p. area rounding in wall-net deduction:
RdSAP 10 §15 rounds per-window area at 2 d.p. before any sum.
The cascade's `windows_w_per_k_total` branch already rounds
per-window for the curtain transform; the wall-net deduction
branch (computing `gross_wall - windows - door` for the (29a)
line) was rounding the SUM once, which for cert 0330's 9 Main
windows yields 12.22 m² vs the worksheet's per-window-rounded
12.23 m² — Δ +0.01 m² × U=1.5 = +0.015 W/K on (29a). Aligned
both branches to round per-window, matching worksheet line (27).
SAP Δ -0.0012 → -0.000011.
Layer 4 chain test added:
- `test_api_0330_full_chain_sap_matches_worksheet_pdf_exactly` pins
cert 0330 API path SAP at 1e-4 vs worksheet 61.5993. This is the
second boiler validation cert with a Layer 4 1e-4 gate (cert
001479 is the first).
Re-pinned golden cert residuals (shifted by changes (a) and (b)):
- 0300: PE +7.52 → +8.44, CO2 -0.27 → -0.23 (Slice 98a — electric
shower count surfaced; cert has 1 electric + 1 mixer outlets)
- 2130: PE -38.17 → -38.18, CO2 +0.305 → +0.304 (Slice 98b —
window rounding edge)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cert 0330 API path was at Δ +1.68 SAP after Slice 96 because all 11
windows (`sap_windows[*].glazing_type = 2`) fell through
`_API_GLAZING_TYPE_TO_TRANSMISSION` (which only covered codes 3 +
13) to the cascade's `u_window` default (~U=2.5). The cert's actual
glazing is "Double, England/Wales 2002 or later (before 2022)" per
RdSAP 10 Table 24 page 79 → U=2.0, g=0.72 (PVC/wooden frame).
RdSAP 10 Table 24 verbatim:
Glazing Installed Gap U-value g
Double or England/Wales: 2002 or later 2.0 0.72
triple Scotland: 2003 or later any
glazed N. Ireland: 2006 or later
The cascade's curtain-transform path (`U_eff = 1/(1/U + 0.04)`)
takes U_raw=2.0 to U_eff=1.8519 — matching the worksheet's per-
window (27) U value column to 4 d.p. across all 11 windows.
Effect on cert 0330 API path:
- Windows HLC 36.4545 → 29.7407 (= worksheet exact)
- (37) total fabric heat loss 244.48 → 237.77 (≈ worksheet 237.75)
- SAP Δ +1.68 → +2.12 (windows fix unmasks the standalone HW gap,
which the next slice closes)
Re-pinned residuals (5 affected golden certs):
- 0240: PE +17.85 → +15.69; CO2 +1.01 → +0.90; SAP unchanged at -15
- 0300: PE +7.76 → +7.52; CO2 -0.25 → -0.27; SAP unchanged at +0
- 0390-2954: PE -26.46 → -28.68; CO2 -2.56 → -2.76; SAP unchanged
- 7536: SAP +0 → +1; PE -3.45 → -6.51; CO2 -0.09 → -0.17
- 8135: PE -2.41 → -5.31; CO2 -0.02 → -0.07; SAP unchanged at +0
The PE/CO2 widening on some certs (vs lodged GOV.UK values) reflects
the cascade now using the spec table U=2.0 where those certs may have
lodged a higher project-specific U — the spec-table is the right
floor for the API path; per-window measured U overrides would belong
on the cert's window_transmission_details.u_value field, which the
API JSON doesn't surface uniformly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cert 0330 (mid-terrace boiler, Summary_000897.pdf) Summary path was at
Δ +0.4667 SAP vs worksheet 61.5993 because Ext1's flat roof fell through
`_ROOF_BY_AGE` (Table 18 column (1), pitched-roof "between joists"
defaults) to 0.40 W/m²K for age D — the spec value is 2.30 W/m²K from
column (3) "Flat roof" (RdSAP 10 spec page 45).
RdSAP 10 §5.11 Table 18 column (3) verbatim:
Age A,B,C,D → 2.30; E → 1.50; F → 0.68; G → 0.40; H,I → 0.35;
J,K → 0.25; L → 0.18; M → 0.15.
Footnote (a): "If the roof insulation is 'none' use U = 2.3 (all roof
types, except for thatched roofs)" — confirms the col-3 entries for
old ages are the uninsulated row, applied because cert 0330's Ext1
lodges "Flat" construction with no measured insulation thickness.
Changes:
- `_FLAT_ROOF_BY_AGE` added in rdsap_uvalues.py
- `u_roof` gains `is_flat_roof: bool = False` parameter
- `heat_transmission_from_cert` detects flat roofs from
`part.roof_construction_type` ("flat" substring) and routes through
the new column.
Effect on baseline:
- cert 0330 Summary chain test: RED Δ+0.4667 → GREEN at 1e-4 (worksheet
total fabric heat loss 237.7549 W/K matches cascade to 4 d.p.)
- cert 001479 Layer 4 chain test: unchanged (Main pitched, no flat
components)
- cohort certs 000477/000516: unchanged (no flat roofs)
- golden cert 0300-2747-7640-2526-2135: SAP residual +1 → 0 (improved),
Ext1 is genuinely flat; pe/co2 residuals re-pinned. The dwelling has
the same Main-pitched + Ext1-flat shape as cert 0330; same fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the (API JSON + Summary PDF) fixtures for cert
0380-2471-3250-2596-8761 — the Air Source Heat Pump pilot
identified in the handover. Property: 16 Beech Lea, WIGTON CA7 5JY
(semi-detached bungalow, ASHP PCDB idx 104568).
Source: API JSON fetched via EpcClientService. Summary PDF copied
from `sap worksheets/Additional data with api/
0380-2471-3250-2596-8761/Summary_000899.pdf`.
Worksheet target: SAP 88.5104 (continuous), from `dr87-0001-000899
.pdf`.
**This is the HP pilot, intentionally deferred.** Initial probe on
these fixtures (uncommitted before this slice):
- Summary mapper cascade SAP: 18.08 (Δ -70.43 vs worksheet)
- API mapper cascade SAP: 70.14 (Δ -18.37 vs worksheet)
Both paths are catastrophically RED. The mapper has never been
validated against an ASHP cert and there's substantial cascade
plumbing required:
- API mapper correctly identifies the HP (COP 2.3) but fabric HLC
is 104 W/K vs the ~50 W/K needed for SAP 88.51.
- Summary mapper misreads the HP as an 80%-efficient boiler
(catastrophic).
- 7 of 9 newly-staged certs are ASHPs (6 share PCDB idx 104568,
cert 9418 uses 102421), so a shared HP-cascade fix will likely
close most of them at once.
Stashed here so the next agent can pick up the HP workstream
without needing to refetch from the EPB API. Recommend not
attempting these slices until the boiler workflow (cert 0330) is
proven; the boiler cascade is the reference shape and HP work
should build on a known-good baseline. Handover §"Heat-pump
workstream sketch" outlines the likely 15-30 slice queue.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the (API JSON + Summary PDF) fixtures for cert
0330-2249-8150-2326-4121 — the boiler pilot identified in the
handover. Property: 17 Summerfield Road, MANCHESTER M22 1AE
(mid-terrace house, mains gas boiler PCDB idx 10241, age D).
Source: API JSON fetched via EpcClientService from
https://api.get-energy-performance-data.communities.gov.uk
(OPEN_EPC_API_TOKEN). Summary PDF copied from
`sap worksheets/Additional data with api/0330-2249-8150-2326-4121/
Summary_000897.pdf` (where the user provided the triple).
Worksheet target: SAP 61.5993 (continuous), from `dr87-0001-000897
.pdf` in the same source directory.
Current state on these fixtures (uncommitted before this slice):
- Summary mapper cascade SAP: 62.0660 (Δ +0.4667 vs worksheet)
- API mapper cascade SAP: 63.7446 (Δ +2.1453 vs worksheet)
Both paths RED at 1e-4. Two specific cascade-component gaps
identified in the handover for follow-up slices:
1. Windows HLC +6.71 W/K (API vs Summary) — likely glazing_type=14
not in Slice 93's `_API_GLAZING_TYPE_TO_TRANSMISSION` (only
codes 3 and 13 mapped).
2. HW kWh +1060 (API 3172.65 vs Summary 2112.00) — §4 subsystem
gap; needs occupancy/shower/cylinder probe.
This commit stages the fixtures only — no tests added yet. The
follow-up slice should add a RED Layer 2 test (Summary path 1e-4
vs 61.5993) and proceed slice-by-slice.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rewrites the cert 001479 closure handover into a forward-looking
brief for the new workstream: validating the API EpcPropertyDataMapper
against 9 newly-staged (Summary + worksheet + API) cert triples.
Key contents:
- User's stated workflow (verbatim): Summary path proves itself
against the worksheet → becomes canonical reference for API parity.
- Folder-structure changes since the prior handover were written
(packages/domain/ removed; sap10_calculator + sap10_ml now at the
repo root under a PEP 420 namespace; docs/sap-spec/ moved into
domain/sap10_calculator/docs/; PCDB data into tables/pcdb/data/).
- New test data layout: `sap worksheets/Additional data with api/
<cert-ref>/{Summary_NNNNNN.pdf, dr87-0001-NNNNNN.pdf}`.
- Cert reference table with heating type, PCDB index, worksheet SAP,
TFA, bp count, dwelling type for all 9 triples.
- Major scope discovery: 7 of 9 are Air Source Heat Pumps (PCDB
104568 / 102421). The mapper has never been validated against HPs;
cert 0380 pilot showed catastrophic deltas (Summary -70 / API -18
SAP vs worksheet). Recommended deferring HP certs until boiler
workflow is proven.
- Cert 0330 (mid-terrace gas boiler) pilot status: fixtures staged
uncommitted; Summary path +0.47 SAP, API path +2.15 SAP vs
worksheet 61.5993. Cascade-component diff localises 2 specific
gaps (windows HLC +6.71 W/K likely from glazing_type=14 missing
from Slice 93's transmission map; HW kWh +1060 needs §4
subsystem probe).
- Tooling shortcut: use OPEN_EPC_API_TOKEN (not EPC_AUTH_TOKEN) in
backend/.env with EpcClientService._fetch_certificate(cert_ref)
to fetch raw JSON.
- First actions for next agent: confirm baseline, commit cert 0330
fixtures, add RED Layer 2 test, iterate.
Lesson preserved: cohort hand-builts encode non-spec quirks
(e.g. has_suspended_timber_floor=False to override §(12) spec
inference and match the non-spec worksheet). Cross-check against
spec-inferred mapper output before trusting hand-built fields.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Locality of reference — SAP-specific docs, specs, and runtime data
now live alongside the calculator that consumes them, mirroring the
prior packages→domain layout moves.
Move targets:
- Narrative MDs → domain/sap10_calculator/docs/
NEXT_AGENT_PROMPT.md, HANDOVER_NEXT.md, SAP_CALCULATOR.md
- Spec PDFs → domain/sap10_calculator/docs/specs/
RdSAP 10 Specification 10-06-2025.pdf
PCDF_Spec_Rev-06b_12_May_2021.pdf
sap-10-2-full-specification-2025-03-14.pdf
sap-10-3-full-specification-2026-01-13.pdf
- PCDB runtime data → domain/sap10_calculator/tables/pcdb/data/
pcdb10.dat (8.3MB) + 7× pcdb_table_*.jsonl (18MB total)
Path code rewrites (load-bearing):
- tables/pcdb/__init__.py: replaced parents[4]/'docs'/'sap-spec' with
Path(__file__).resolve().parent/'data' for Table 105 JSONL loading.
- tables/pcdb/postcode_weather.py: same rebase for the pcdb10.dat path
read by _postcode_climate_table().
- tables/pcdb/etl.py __main__: same rebase for the manual ETL invocation
(source + output_dir both now point inside the package).
- tests/test_pcdb_etl.py: _PCDB_DAT_PATH now derives from
parents[1]/'tables'/'pcdb'/'data' (was parents[3]/'docs'/'sap-spec').
Citation rewrites:
- 12 .py docstrings and 4 .md docs (ADRs + READMEs + narrative docs)
had `docs/sap-spec/<file>` strings rewritten to their new locations.
- Two cases where the catch-all sed misfired (an ADR-0009 line about a
PCDB extract; the pcdb __init__.py docstring about ETL output) were
hand-corrected to point at tables/pcdb/data/ rather than docs/specs/.
docs/sap-spec/ is now empty (will be removed in a follow-up sweep or
left as a vestigial empty dir for future repurposing). ADRs 0009 and
0010 remain at docs/adr/ — they're part of the chronological
cross-cutting decision log, not calculator-specific narrative.
Verified:
- Calculator's 1e-4 production gate
(test_api_001479_full_chain_sap_matches_worksheet_pdf_exactly) GREEN.
- Wider sweep (domain/sap10_calculator/ + domain/sap10_ml/): 1654
passed / 20 failed — exact pre-move baseline. All 20 failures
pre-existing (10 hand-built skeleton + 4 cohort chain + 6 cohort
diff).
- Pyright net-zero on the 4 touched runtime/test files (0 errors)
and unchanged on heat_transmission.py (13) / cert_to_inputs.py (35) /
mapper.py (33).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sibling migration to the sap10_calculator move — `domain.ml` now lives
at the root-level layout (`domain/sap10_ml/`) matching the pattern
already used by `domain.addresses`, `domain.tasks`, `domain.postcode`,
and `domain.sap10_calculator`.
Changes:
- `git mv packages/domain/src/domain/ml → domain/sap10_ml` (19 files;
history preserved).
- Subpackage rename: `domain.ml` → `domain.sap10_ml`. 32 references
rewritten across .py and .md files: 11 internal + 21 external
(datatypes/epc/domain/mapper.py, 14 files in domain/sap10_calculator,
2 backend tests, 2 ADRs, 1 README, 1 design doc).
- Path-string updates: `pytest.ini` testpath
`packages/domain/src/domain/ml/tests` → `domain/sap10_ml/tests` so
ML tests stay in the default auto-discovered sweep. `CONTEXT.md`
also updated.
`packages/domain/src/domain/` is now empty — the workspace `domain/`
tree has been fully migrated. Together with the `domain/__init__.py`
deletions from the sap10_calculator commit (29ac35cc), `domain` is
now a single root-level namespace package with subpackages
{addresses, sap10_calculator, sap10_ml, tasks} + the standalone
`postcode.py` module.
Verified:
- Focused sweep (backend mapper-chain + sap10_calculator worksheet
e2e + golden fixtures): 99 passed / 19 failed — identical baseline.
- Wider sweep (all sap10_calculator + sap10_ml): 1654 passed / 20
failed (same pre-existing failures).
- domain/sap10_ml/tests: 210/210 PASSED at new path.
- Pyright net-zero: heat_transmission.py 13, cert_to_inputs.py 35,
mapper.py 33, rdsap_uvalues.py 1 (all unchanged from baseline).
Note: `packages/domain/pyproject.toml` still declares
`packages = ["src/domain"]` for the hatchling wheel — that target
directory is now empty and the wheel build is effectively a no-op.
Retiring the workspace package or repointing the wheel is a follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration of the SAP 10.2 calculator package from the uv-workspace
src-layout (`packages/domain/src/domain/sap`) to the root-level layout
(`domain/sap10_calculator`), matching the pattern already used by
`domain.addresses` / `domain.tasks` / `domain.postcode`.
Changes:
- `git mv packages/domain/src/domain/sap → domain/sap10_calculator`
(92 files; git auto-detected all as renames so blame/history is
preserved).
- Subpackage rename: `domain.sap` → `domain.sap10_calculator`. 48
Python files rewritten (`from domain.sap.X` → `from domain.sap10_
calculator.X`); zero remaining `domain.sap` refs after the sed pass.
- Path-string updates: 3 .py files (test fixtures + xlsx loader) +
6 markdown docs (CONTEXT.md, 2 ADRs, 3 sap-spec docs, sap10_
calculator/README.md) had hard-coded `packages/domain/src/domain/
sap/...` paths rewritten to `domain/sap10_calculator/...`.
- `Path(__file__).parents[N]` rebasing: the old tree was 3 levels
deeper than the new one (`packages/domain/src/`), so 4× `parents[7]`
became `parents[4]` and 1× `parents[6]` became `parents[3]` across
`tables/pcdb/{__init__.py, postcode_weather.py, etl.py}`,
`worksheet/tests/_xlsx_loader.py`, and `tests/test_pcdb_etl.py`.
- PEP 420 namespace package: deleted both `domain/__init__.py`
(root + workspace, both load-bearing only as empty/docstring) so
Python combines `domain.sap10_calculator` (root) and `domain.ml`
(workspace) into one namespace package. Confirmed via
`domain.__path__ == ['/workspaces/model/domain',
'/workspaces/model/packages/domain/src/domain']`. Without this,
the root `domain/__init__.py` shadowed the workspace one and
`domain.ml` was unreachable.
Verified:
- Full sweep (`backend/documents_parser/tests/test_summary_pdf_
mapper_chain.py + domain/sap10_calculator/worksheet/tests/test_
e2e_elmhurst_sap_score.py + domain/sap10_calculator/rdsap/tests/
test_golden_fixtures.py`): 99 passed / 19 failed — exact same
counts as pre-refactor. All 19 failures pre-existing (9 hand-built
001479 + 6 cohort diff + 4 cohort chain non-spec).
- Wider sweep (all sap10_calculator + domain.ml): 1654 passed /
20 failed (the +1 vs the focused sweep is the pre-existing
`test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_
section_5_11_4` which was already failing on the previous baseline).
- Pyright net-zero on the three load-bearing baselines:
`heat_transmission.py` 13, `cert_to_inputs.py` 35, `mapper.py` 33.
Lift-and-shift only — no semantic renames (`Sap10Calculator` stays
`Sap10Calculator`), no testpaths edits in pytest.ini (sap tests
continue to be invoked by explicit pytest paths).
Note: `domain.ml` still lives at `packages/domain/src/domain/ml/`.
Migrating it would close out the dual-`domain/` layout but is
out of scope for this commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This branch's objective is the SAL ingestion handler
(applications/SAL/handler.py) and its dependency tree. Drop work
that crept in but is unreferenced by it:
- EPC feature: domain/epc, infrastructure/epc (gov_uk + historical
clients), tests/infrastructure/epc
- datatypes/epc edits (instantaneous_wwhrs Optional) reverted to main
- asset_list/app.py local data-file/column tweak reverted to main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>