Three load-bearing files that the post-Slice-95 tests and docs cite
but were never tracked:
1. `packages/domain/src/domain/sap/rdsap/tests/fixtures/golden/
0535-9020-6509-0821-6222.json` — API JSON for cert 001479
(Elmhurst worksheet P960-0001-001479, lodged 31 Oct 2025).
Required by `test_api_001479_full_chain_sap_matches_worksheet_pdf_
exactly` (Slice 95's Layer 4 1e-4 gate) and by
`test_golden_cert_residual_matches_pin` (residual-from-integer
pin path). Without this committed, both tests fail to find the
fixture file.
2. `docs/sap-spec/RdSAP 10 Specification 10-06-2025.pdf` — replaces
the previously-tracked `rdsap-10-specification-2025-06-10.pdf`
(same content, cleaner filename). Cited from 5 source files
(`table_32.py`, `pcdb/parser.py`, README.md, SAP_CALCULATOR.md,
NEXT_AGENT_PROMPT.md) and every spec-citation commit message
in Slices 87-95. Git auto-detected the rename.
3. `docs/sap-spec/PCDF_Spec_Rev-06b_12_May_2021.pdf` — cited from
`pcdb/parser.py:69` and the §4-water-heating combi-loss
docstrings; needed to validate the PCDB Table 3a/3b/3c routing
logic.
Also fixes the one stale reference in `test_dimensions.py:471`
that still pointed to the old `rdsap-10-specification-2025-06-10
.pdf` filename — now points to the renamed file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Status: Slice 95 closed Layer 4 (API → cascade SAP) on cert 001479 at
< 1e-4 vs worksheet 69.0094. Production goal MET; the
`test_api_001479_full_chain_sap_matches_worksheet_pdf_exactly` test
formalises this gate. Updates to keep the next agent honest:
- NEXT_AGENT_PROMPT: header + status table + cumulative SAP delta table
+ "First action" + epilogue all reflect Slice 95's close-out.
- NEXT_AGENT_PROMPT §4 (Outlier golden cert investigations): rewrote
the cert 0240 entry. The earlier "Type-1 RR gable_wall_lengths not
extracted" claim is stale — mapper.py:1349-1369 already extracts
them (Slices 71-86). The -15 SAP residual is a mix, dominated by
the windows subsystem (11 windows × 18.28 m² with default U≈2.27
because Slice 93's `_API_GLAZING_TYPE_TO_TRANSMISSION` only covers
glazing codes 3 and 13; cert 0240 lodges code 2). Surfacing
glazing_type=2 (and likely other unmapped codes) is the biggest
single-slice leverage point — and would touch 6035 too.
- test_golden_fixtures.py cert 0240 `notes:` field: replaced the
stale RR hypothesis with the actual cascade subsystem breakdown
and the glazing_type-2 surfacing recommendation.
No production code changed; docs and a `_GoldenExpectation.notes`
string only. test_golden_fixtures.py stays GREEN (14 passed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The end-to-end production cascade `from_api_response → cert_to_inputs →
calculate_sap_from_inputs` now hits cert 001479's worksheet continuous
SAP 69.0094 at abs < 1e-4 (was +0.000584). Two fixes:
1. API mapper: `from_rdsap_schema_21_0_{0,1}` computes `total_floor_
area_m2` as Σ per-bp `sap_floor_dimensions[*].total_floor_area.value`
(cert 001479: 30.45+30.77+5.37+1.92 = 68.51), not the lodged scalar
(rounded integer 69). `water_heating_from_cert` reads `epc.total_
floor_area_m2` directly for occupancy N (Appendix J), which propagates
to HW kWh (+6.31 → ~0), Appendix L lighting (+0.98 → 0), and internal
gains (+25.72 W·months → 0).
2. Cascade window area rounding per RdSAP 10 §15 "Rounding of data"
(p.66): "All element areas (gross) including window areas: 2 d.p."
`solar_gains.py` and `internal_gains.py` now round `w * h` to 2 d.p.
to match the existing `heat_transmission.py` pattern (line 344).
Closes the residual solar gains delta (+1.50 W·months → 0) that
became dominant once TFA was fixed.
Re-pinned 5 golden cert residuals where TFA + area rounding shifted
output: 0240 (SAP -14→-15, PE +14.6650→+17.8450, CO2 +0.8060→+1.0097),
6035 (PE +48.2971→+49.5139, CO2 +1.1016→+1.1423), 8135 (PE -2.4194→
-2.4072, CO2 -0.0198→-0.0195), 2130 (PE -38.1521→-38.1666), 0390
(PE +1.6837→+1.6962, CO2 +0.0637→+0.0639).
New test: `test_api_001479_full_chain_sap_matches_worksheet_pdf_
exactly` formalises Layer 4 of the validation stack as a 1e-4 gate.
Pyright net-zero (mapper.py 33).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two API mapper gaps surfacing the cert 001479 +1.18 SAP gap post
Slice 93:
(1) `SapVentilation.sheltered_sides` from API `built_form`
The API schema doesn't lodge sheltered_sides as a discrete field —
it's derived per RdSAP §S5 from the dwelling's built_form. The
cascade defaults to 2 when missing (right for Mid-Terrace) but wrong
for detached/semi/end-terrace. Cert 001479 (built_form=2 Semi-
Detached) needs 1 sheltered side; default 2 over-counted shelter
factor → line (21) under by 0.185 → ventilation under by ~2 ACH/yr.
New `_api_sheltered_sides` translator + `_API_BUILT_FORM_TO_
SHELTERED_SIDES` table (1=Detached/0, 2=Semi/1, 3=End-T/1, 4=Mid-T/2,
5=Encl-End/2, 6=Encl-Mid/3) — mirrors the cohort Elmhurst
`_ELMHURST_SHELTERED_SIDES_BY_BUILT_FORM` keyed by the API integer
enum.
(2) `SapBuildingPart.floor_type` from API `floor_heat_loss`
The Slice 87 spec rule for §2(12) suspended-timber-floor infiltration
(`_has_suspended_timber_floor_per_spec` in cert_to_inputs) requires
the Main bp's lowest floor to have `floor_type == "Ground floor"` to
apply the (12)=0.2/0.1 rule. The API mapper wasn't surfacing this
string (only floor_construction_type), so the spec rule short-
circuited to False even for genuine ground floors and the cascade's
line (12) was 0.0 instead of 0.2.
New `_api_floor_type_str` translator + `_API_FLOOR_HEAT_LOSS_TO_
FLOOR_TYPE` table (1="To external air" for cantilevered exposed
floors, 7="Ground floor"). Routes correctly for cert 001479: Main +
Ext1 carry floor_heat_loss=7 → both Ground floor; Ext2 carries
floor_heat_loss=1 → exposed (its is_exposed_floor=True already lifts
the floor U cascade to Table 20).
**Result on cert 001479 API path:**
SAP delta: +1.18 → +0.0006 (essentially exact match at integer SAP)
Cascade SAP=69.0100 vs worksheet 69.0094 — within 1e-3 of target.
The remaining ~0.001 SAP gap is dominated by:
- hot_water_kwh_per_yr: +6.7 (API 2365.0 vs target 2358.3)
- internal_gains Σ: +25.7 W·months (subtle gain-cascade differences)
- solar_gains Σ: +1.5 W·months
Sub-1e-3 SAP impact each; would need slice-by-slice diagnosis to
close to the strict 1e-4 bar.
Layer 3 API-mapper-vs-Summary-mapper EpcPropertyData equivalence:
the API path now produces SAP within 0.001 of the Summary path
(Summary Layer 2 = 69.0094 EXACT). API integer SAP = 69 = worksheet
integer SAP = 69 ✓ — matches the API's published energy_rating_
current=69 (zero residual on the production goal metric).
Golden cert residuals: 8 of 10 expectations shifted by Slices 90-94
cascade improvements. Spec-compliance shifts; new residuals pinned.
Pyright: mapper.py 33 → 33.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The API schema lodges `glazing_type` (int code) per window but
`window_transmission_details=None` and `frame_factor=None`. Without
per-window U lodgement the cascade falls back to a single global
`u_window(None,None,None)=2.5` × total area, which over-shot cert
001479's window W/K by +2.63 (cascade 46.23 vs worksheet 43.60).
Fix: `_API_GLAZING_TYPE_TO_TRANSMISSION` lookup translates
`glazing_type` → (u_value, solar_transmittance, frame_factor) and
the mapper populates `WindowTransmissionDetails` + `frame_factor`
per window so the cascade uses its per-window U fast path (each
window contributes A × U_eff_individual rather than total_area ×
U_eff_global). Two codes mapped now:
3 → DG pre-2002 U=2.8 g=0.76 FF=0.70
13 → DG post-2022 Argon U=1.4 g=0.72 FF=0.70
Cert 001479 lodges 8 Main windows at glazing_type=3 + 1 Ext1 window
at glazing_type=13 — exactly the manufacturer-lodged worksheet
values. The cascade now matches the worksheet's
`Windows 1: 13.96 × 2.518 = 35.15 W/K` and
`Windows 2: 6.37 × 1.3258 = 8.45 W/K` → **windows W/K EXACT 43.5962**.
**Cert 001479 API path: fabric heat loss is now COMPLETELY EXACT
across all 6 components** (walls/party/roof/floor/windows/doors all
match worksheet at the worksheet's 4 d.p. precision).
Total fabric: 139.4957 W/K ✓ (was 122.6130 before Slice 87)
walls: 39.7652 ✓
party walls: 17.0700 ✓
roof: 10.3438 ✓
floor: 23.1705 ✓
windows: 43.5962 ✓
doors: 5.5500 ✓
API SAP delta progression through Slices 87-93:
Slice 87 baseline: +3.0752
After Slice 90: +1.5298 (party walls)
After Slice 91: +1.0970 (descriptive strings + roof desc)
After Slice 92: +1.0022 (floor dims)
After Slice 93: +1.1846 (windows — fabric now EXACT)
The +1.18 SAP gap is now PURELY non-fabric: candidates are internal
gains, solar gains, ventilation, MIT, or hot water cascade — to
diagnose in the next slice.
Golden cert residuals updated for the cascade improvements. Pyright
net-zero on mapper.py (33 → 33).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three coupled API-mapper fixes that close the cert 001479 floor-W/K
gap from +4.39 to EXACT 0.
(1) Upper-floor room_height_m += 0.25 m
SAP 10.2 convention: every storey above the lowest adds 0.25 m to the
lodged room_height for the joist/floor-void contribution (cohort
Elmhurst mapper already applies this via `_UPPER_FLOOR_HEIGHT_ADD_M`
at line 2338). The API schema lodges the raw internal height; the
cascade volume computation needs the +0.25 m before computing party-
wall area and ventilation ACH. For cert 001479 Main floor=1, raw
lodge 2.28 m vs worksheet 2.53 m — without the fix, party W/K was
short by 0.87 (party_wall_length × delta_height × U).
(2) `is_exposed_floor=True` when `bp.floor_heat_loss == 1`
API integer code 1 on `floor_heat_loss` signals an exposed floor (a
bp's lowest storey hanging over an unheated space or external air).
Mirrors the cohort Elmhurst mapper's `_is_floor_exposed_to_unheated_
space` for the API path. Applied only to the lowest storey (floor==0)
per the cohort 000490/000487 fixture convention. For cert 001479
Ext2 (cantilevered upper-storey extension over external air), this
routes the cascade through Table 20's `u_exposed_floor` (U=1.20)
rather than the BS EN ISO 13370 ground-floor formula.
(3) `floor_insulation_thickness="NI" → None` for cascade default
API certs commonly lodge "NI" (no measured thickness) on floors that
aren't actually uninsulated — for newer age bands (I-M with non-zero
Table 19 defaults: 25/75/100/100/140 mm) the cascade should use the
age-band default insulation rather than treating "NI" as explicit
zero. Translate "NI" → None at the mapper boundary so `u_floor`
reaches the Table 19 fallback. For cert 001479 Ext1 (age M, suspended
timber, NI lodged) the cascade now returns U=0.20 via the age-M
140 mm default — previously gave U=1.05 from treating thickness as 0.
**Floor W/K is now EXACT for cert 001479** (23.1705 ✓).
Impact on cert 001479 API path:
Before Slice 87: +3.0752 SAP delta
After Slice 90: +1.5298
After Slice 91: +1.0970
After Slice 92: +1.0022 (floor W/K exact; remaining gap is in
windows / gains — Slice 93)
Golden cert residual updates: 7 of 10 expectations shifted from the
floor cascade improvements (NI→None changed many certs with age I-M
extensions). Spec-compliance shifts; new residuals committed.
Pyright: mapper.py 33 → 33.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three tightly-coupled fixes that close another big chunk of cert
001479's API-path SAP gap.
(1) Surface human-readable strings on SapBuildingPart from API ints
The API mapper sets `bp.floor_construction_type` and `bp.roof_
construction_type` strings via int→string lookups so the cascade
fixes from Slices 88 + 89 also apply to the API path:
- `_API_FLOOR_CONSTRUCTION_TO_STR`: 1=Solid, 2=Suspended timber
(drives `u_floor`'s suspended-branch selection)
- `_API_ROOF_CONSTRUCTION_TO_STR`: 1=Flat, 3=Pitched no-loft,
4=Pitched-access-to-loft, 5=Vaulted, 8=Pitched-sloping-ceiling
(drives the cos(30°) inclined-surface factor)
(2) Pre-1950 PS sloping ceiling → thickness=0 (port Slice 57)
`_api_resolve_sloping_ceiling_thickness` mirrors Slice 57's Elmhurst-
mapper logic: when a PS pitched-sloping-ceiling roof (API code 8)
carries no insulation thickness on a pre-1950 dwelling (age bands
A-D), set thickness=0 so the cascade returns the uninsulated U=2.30
rather than the age-band-default (e.g. U=0.40 for age C).
(3) Cascade: per-bp `roof_thickness=0` overrides global "insulated"
description
For cert 001479 the API's `epc.roofs` carries two descriptions
(Main's "Pitched, 300mm loft insulation" + Ext1's "Pitched,
insulated") which the cascade joined into a global
`roof_description`. `u_roof`'s Table 18 footnote (2) ("assumed
insulation if described as insulated") then incorrectly upgraded
Ext2's explicitly-uninsulated thickness=0 to ins_mm=50 → U=0.68
instead of 2.30. Fix: in `heat_transmission.py` per-bp roof loop,
drop `roof_description` when the per-bp `roof_thickness` is
explicitly 0. The per-bp thickness lodgement is the authoritative
signal; the global description is for cases where no thickness was
lodged at all.
Impact on cert 001479 API path (cumulative through Slice 91):
Before Slice 87: +3.0752 SAP delta
After Slice 90: +1.5298 (party wall enum fix)
After Slice 91: +1.0970 (descriptive strings + roof desc fix)
Roof W/K is now EXACT for cert 001479 (10.3438 = worksheet target).
Golden cert residual updates: 8 of 10 expectations shifted by
Slices 87-91 cascade improvements:
0240: SAP -10→-13, PE -2.05→+10.45, CO2 -0.04→+0.59
6035: SAP -4→ -5, PE +34.02→+34.50, CO2 +0.76→+0.77
7536: SAP +3→ +2, PE -22.53→-15.83, CO2 -0.60→-0.42
8135: SAP unchanged, PE -16.51→-16.37, CO2 unchanged
2130: SAP unchanged, PE -51.90→-51.10, CO2 +0.14→+0.15
0240/6035/7536: spec-compliance shifts (more accurate U-values
move further from the assessor's lodged SAP, because the
assessor's SAP was itself produced with the same incorrect
paths the cascade previously matched).
Pyright: mapper.py 33 → 33; heat_transmission.py 13 → 13;
test_golden_fixtures.py 0 → 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RdSAP 10 §3.8 "Roof area" spec:
"Roof area is the greatest of the floor areas on each level...
In the case of a pitched roof with a sloping ceiling, divide the
area so obtained by cos(30°)."
The cascade previously used `top_floor_area_m2` (horizontal projection)
verbatim for the roof area calculation — correct for flat roofs and
pitched-with-loft (where assessors measure on the horizontal), but
~15% under-area for PS pitched-sloping-ceiling roofs (1/cos(30°) =
1.1547). For cert 001479 Ext1 + Ext2 (both PS sloping ceiling):
Ext1: cascade 5.37 m² × 0.15 = 0.81 W/K
worksheet 6.20 m² × 0.15 = 0.93 W/K (delta -0.12)
Ext2: cascade 1.92 m² × 2.30 = 4.42 W/K
worksheet 2.22 m² × 2.30 = 5.11 W/K (delta -0.69)
Total roof W/K shortfall: -0.81
Fix: detect PS pitched-sloping-ceiling roofs via `bp.roof_construction
_type` (string lodgement from the Summary §8 "Roof Type" line) and
apply the 1/cos(30°) inclination factor before rounding the gross
roof area.
Schema addition: `SapBuildingPart.roof_construction_type: Optional[
str] = None` mirrors the existing `floor_construction_type`. Mapper
populates it via `_strip_code(roof.roof_type)` for both Main and
Extension bps — the Elmhurst Summary lodges the roof type
explicitly (e.g. "PS Pitched, sloping ceiling" / "PA Pitched (slates
/tiles), access to loft" / "Flat").
**Result: cert 001479 Summary → mapper → cascade now lands at SAP
69.0094 EXACT (delta -0.0000) — Layer 2 GREEN at 1e-4.** Full fabric
breakdown matches the worksheet exactly:
fabric_heat_loss = 139.4957 W/K ✓
walls = 39.7652 ✓ party = 17.0700 ✓
roof = 10.3438 ✓ floor = 23.1705 ✓
windows = 43.5962 ✓ doors = 5.5500 ✓
Layer 2 status across the 7 cert chain tests:
000477 GREEN (was GREEN)
000516 GREEN (was GREEN)
001479 GREEN (new — was +1.19 before Slice 87)
000474 RED -0.7524 (Elmhurst (12) non-spec — orthogonal)
000480 RED -1.0273 (Elmhurst (12) non-spec — orthogonal)
000487 RED +0.4834 (Elmhurst (12) non-spec — orthogonal)
000490 RED -1.1042 (Elmhurst (12) non-spec — orthogonal)
Cohort cascade pins remain GREEN (66 of 66) — hand-built fixtures
have roof_construction_type=None (default) so the new code path is
inert for them; their roofs use RR detailed_surfaces with explicit
areas already.
Pyright net-zero on every touched file (heat_transmission 13 → 13,
mapper 35 → 35, epc_property_data 0 → 0).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`u_floor` defaulted to the SOLID branch for age bands C+ when both
`construction` (int code) and `description` were None, regardless of
whether the bp's own `floor_construction_type` field said "Suspended
timber". This produced U=0.60 for cert 001479 Main vs the worksheet's
U=0.65 — a -0.05 W/m²K delta × 30.45 m² → -1.52 W/K of fabric loss
shortfall.
Fix: in `heat_transmission_section_from_cert`, prefer the bp's
`floor_construction_type` string over the global `epc.floors[].
description` when computing the per-bp floor U. The bp-level field
is the per-part lodgement Elmhurst surfaces in §3 / §9 of the
Summary; the global `epc.floors` list is often empty when the
mapper sources data from a Summary PDF rather than the full
RdSAP API JSON.
Impact on cert 001479 Summary → mapper → cascade SAP delta:
BEFORE Slice 88: +0.2290 (floor U 0.60 vs target 0.65)
AFTER Slice 88: +0.0898 (floor exact match; only roof gap left)
Floor W/K breakdown for cert 001479 (mapper path):
was: 21.6480 target 23.1705 delta -1.5225
now: 23.1705 target 23.1705 delta +0.0000 ✓ EXACT
Cohort cascade pins remain GREEN (66 of 66) — the cohort hand-builts
already set `floor_construction_type` on their Main bp via the
Slice 72/75/78/82/85 Cat A bulk updates, so the new code path
applies the same suspended-timber branch that previous paths reached
via either explicit `floor_construction` int codes or the age-band
default (cohort certs are all age B which is in
`_SUSPENDED_TIMBER_DEFAULT_BANDS`, so they hit the suspended branch
either way; cert 001479 is age C and needs the explicit string).
Pyright net-zero on heat_transmission.py (13 → 13 errors).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the empirical `_elmhurst_has_suspended_timber_floor` heuristic
(which keyed on Room-in-Roof < Main ground area) with the mechanical
RdSAP 10 Specification §5 rule (page 29):
- Age band A-E: U-value < 0.5 → sealed (0.1); retro insulation + no
U → sealed (0.1); otherwise unsealed (0.2)
- Age band F-M: sealed (0.1)
- Park home: unsealed (0.2)
- Only applies when Main bp's lowest floor is a "Ground floor" with
"Suspended timber" construction
The spec rule is derived in `_has_suspended_timber_floor_per_spec`
(cert_to_inputs.py) and applied in `ventilation_from_cert` whenever
the lodged `epc.sap_ventilation.has_suspended_timber_floor` is None.
Explicit lodged values (cohort hand-built fixtures) take precedence.
Impact on cert 001479 (the load-bearing API↔Elmhurst parity-test
fixture; previously the RR-based heuristic returned False for this
no-RR semi-detached, dropping (12) entirely):
Mapper → cascade → SAP delta vs worksheet 69.0094:
BEFORE: +1.1903 (mapper extracted False; cascade applied (12)=0)
AFTER : +0.2290 (mapper extracts None; spec derives True/unsealed;
cascade applies (12)=0.2 → matches worksheet)
Cohort cascade pins remain GREEN (66 of 66) — cohort hand-built
fixtures retain their explicit `has_suspended_timber_floor` values
which override the spec derivation.
Expected cohort regressions to triage in the next slice:
- 4 cohort chain tests RED (000474, 000480, 000487, 000490) — their
Elmhurst worksheets enter non-spec (12) values (0.0 or 0.2 when
spec predicts the opposite) so the mapper-path cascade now
diverges from the worksheet PDF at 1e-4.
- 6 cohort diff tests RED — mapper now produces
has_suspended_timber_floor=None while the cohort hand-builts
retain explicit True/False overrides, producing a 1-field
divergence per cohort cert.
Pyright net-zero (mapper 35→35; cert_to_inputs 35→35) — dead
`_elmhurst_has_suspended_timber_floor` removed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the final `sap_windows: LEN 5 vs 2` divergence by replacing
the cohort 000516 hand-built's 2-window collapsed encoding with 5
SapWindow entries mirroring the Summary §11 1:1. Single-bp dwelling;
single glazing-type group (PVC double / g⊥=0.76 / U=2.8); per-
orientation totals preserved:
NE (orient=2): 3.88 m² split 2.15 + 1.73 (2 rows)
SW (orient=6): 4.43 m² split 1.94 + 1.67 + 0.82 (3 rows)
Mapper interleaves NE/SW rows; hand-built mirrors that order so
list-position diffs are zero.
Cascade output unchanged: all 11 `_FIXTURE_PINS["000516"]` SapResult
pins remain GREEN at 1e-4 against worksheet `SAP value 62.7937`.
**Cohort 000516 is now fully Layer-2 GREEN.**
**All 6 cohort certs (000474, 000477, 000480, 000487, 000490, 000516)
are now Layer-2 zero-diff** — the mapper produces a load-bearing-
field-equivalent EpcPropertyData for every cohort cert. This clears
the way for closing cert 001479 (the load-bearing API↔Elmhurst
parity-test fixture; Slice 62 skeleton at 2/11 cascade pins green,
gap −3.02 SAP) and then adding the API mapper diff test (Layer 3)
and the production acceptance test (Layer 4 — ±0.5 of published SAP
69 for cert 0535-9020-6509-0821-6222).
Full sweep: 107 passed (was 105 pre-Slice-84; +2 new diff tests for
000490 + 000516), 10 failed (same 10 001479-related). Pyright net-
zero on every touched fixture across Slices 71–86.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 23 of 24 mapper-vs-hand-built load-bearing divergences by
populating fields the Elmhurst mapper extracts from Summary_000516.
pdf but the original hand-built left at their `make_minimal_sap10_
epc` / dataclass-default values. Every change is cascade-equivalent —
all 11 `_FIXTURE_PINS["000516"]` SapResult pins remain GREEN against
worksheet `SAP value 62.7937`.
000516-specific deltas:
- `wall_thickness_measured=True` on Main (Summary lodges 400 mm).
- `floor_type="Above unheated space"` (exposed timber floor, not
Ground floor) — matches the cert's `is_exposed_floor=True` for
the lowest Main floor.
- `roof_insulation_location="None"` — the Summary lodges the literal
string "None" for an uninsulated roof; mapper surfaces it
verbatim.
Standard Cat A additions (per Slice 72/75/78/82 pattern): floor
descriptive fields, 6 ventilation zero counts, draught_lobby=True,
pressure_test="Not available", top-level descriptive strings +
booleans, `number_of_storeys=3` (Main ground + first + RIR),
shower_outlets="Non-electric shower",
central_heating_pump_age_str="Unknown".
Diff count: 24 → **1**. Remaining diff is `sap_windows: LEN 5 vs 2`
— closes via Slice 86.
Pyright net-zero on the touched fixture.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the final `sap_windows: LEN 6 vs 3` divergence by replacing
the cohort 000490 hand-built's 3-window collapsed encoding with 6
SapWindow entries mirroring the Summary §11 1:1. Single glazing-type
group (PVC double / g⊥=0.76 / U=2.8); per-bp totals preserved:
Main NW (orient=8): 2.70 m² split 1.26 + 1.44 (2 rows)
Main NE (orient=2): 0.81 m² (1 row, unchanged)
Ext1 SE (orient=4): 5.52 m² split 1.92 + 2.16 + 1.44 (3 rows)
Cascade output unchanged: all 11 `_FIXTURE_PINS["000490"]` SapResult
pins remain GREEN at 1e-4 against worksheet `SAP value 57.3979`.
**Cohort 000490 is now fully Layer-2 GREEN** — 4 of 6 cohort certs
(000474, 000477, 000480, 000487, 000490) now zero-diff Layer-2;
000516 is the last cohort cert before returning to cert 001479.
Pyright net-zero.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 31 of 32 mapper-vs-hand-built load-bearing divergences by
populating fields the Elmhurst mapper extracts from Summary_000490.
pdf but the original hand-built left at their `make_minimal_sap10_
epc` / dataclass-default values. Every change is cascade-equivalent —
all 11 `_FIXTURE_PINS["000490"]` SapResult pins remain GREEN against
worksheet `SAP value 57.3979`.
000490-specific deltas vs prior cohort certs:
- `dwelling_type="End-Terrace house"`, `built_form="End-Terrace"` —
first end-terrace fixture (vs Mid-Terrace / Enclosed Mid-Terrace
on the other 4 cohort certs); sheltered_sides=1 is already set on
the existing SapVentilation block.
- `number_of_storeys=2` — 000490 has no room-in-roof (2-storey main
+ 2-storey extension), so dwelling height is 2 (vs 3 for the RR
cohort certs).
- `number_baths=1` on sap_heating — mapper extracts 1 from Summary
§16; cascade-equivalent (Appendix J §1a defaults to 1 if absent).
- `wall_thickness_measured=True` on **both** bps (Summary §7 lodges
measured Wall Thickness 400 mm).
Standard Cat A additions (per Slice 72/75/78 pattern): floor
descriptive fields per bp, roof_insulation_location, 6 ventilation
zero counts, draught_lobby=True, pressure_test="Not available",
top-level descriptive strings + booleans + extensions_count=1,
blocked_chimneys_count=0, shower_outlets=Non-electric shower,
central_heating_pump_age_str="Unknown".
Diff count: 32 → **1**. Remaining diff is `sap_windows: LEN 6 vs 3` —
closes via Slice 83.
Pyright net-zero on the touched fixture.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the final `sap_windows: LEN 5 vs 2` divergence by replacing
the cohort 000487 hand-built's 2-window collapsed encoding with 5
SapWindow entries mirroring the Summary §11 1:1. All South-facing
(orient=5) / PVC frame; two glazing-type groups; per-bp totals
preserved (cascade-equivalent):
g=0.76/U=2.8: 0.77 m² (Ext1) — unchanged
g=0.72/U=1.4: 6.69 m² total split per-bp
Main: 1.65 m² (1 row)
Ext1: 5.04 m² split 2.16 + 1.53 + 1.35 (3 rows)
Mapper places the Main window between two Ext1 rows in the §11 table;
the hand-built mirrors that order so list-position diffs are zero.
Cascade output unchanged: all 11 `_FIXTURE_PINS["000487"]` SapResult
pins remain GREEN at 1e-4 against worksheet `SAP value 61.6431`.
**Cohort 000487 is now fully Layer-2 GREEN** —
`test_from_elmhurst_site_notes_matches_hand_built_000487` passes with
zero load-bearing divergences between the mapped EpcPropertyData and
the hand-built fixture.
Full sweep: 105 passed (was 104 pre-Slice-77; +1 new diff test), 10
failed (same 10 001479-related). Pyright net-zero.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 22 of the remaining 23 mapper-vs-hand-built load-bearing
divergences on cohort cert 000487. All 11 `_FIXTURE_PINS["000487"]`
SapResult pins remain GREEN at 1e-4 against worksheet `SAP value
61.6431` (cascade-equivalent — see per-change rationale).
(1) RIR `detailed_surfaces` reorder to match the mapper's per-row
Summary §3.10 extraction order:
was: [gable_wall, gable_wall_external(u=0.86), flat_ceiling,
stud_wall(100mm/min.wool), slope(0mm)]
now: [flat_ceiling, stud_wall, slope, gable_wall,
gable_wall_external(u=0.86)]
The cascade reads these surfaces as a set (sums U × area per kind),
so list order is cascade-inert. Confirmed: all 11 cohort 000487
cascade pins GREEN post-reorder. Per-surface insulation_thickness_mm
and u_value are unchanged from the prior encoding (matches mapper).
(2) Alt-wall `_WC_TIMBER_FRAME` constant: **8 → 5**.
The prior `_WC_TIMBER_FRAME = 8` was a mislabel — SAP10 code 8 is
"Park home" per `_ELMHURST_WALL_CODE_TO_SAP10`. The mapper extracts
"TI Timber Frame" → SAP10 code **5** (Timber frame). Both codes
happen to cascade to U=1.9 at age band B (different default paths),
so the prior encoding produced the right cascade output despite the
wrong semantic; switching to 5 mirrors the cert truth and the mapper.
Dropped the alt-wall's `wall_insulation_thickness='150'` workaround
and `u_value=1.90` explicit pin — the cascade for `wall_construction
=5` at age B resolves to U=1.9 from the age-band default; mapper
passes None for both fields and the cascade computes them.
Remaining diff: 1 (`sap_windows: LEN 5 vs 2`) — Slice 80.
Pyright net-zero on the touched fixture.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 23 of 45 mapper-vs-hand-built load-bearing divergences by
populating fields the Elmhurst mapper extracts from Summary_000487.
pdf but the original hand-built left at their `make_minimal_sap10_
epc` / dataclass-default values. Every change is cascade-equivalent —
none alter `_FIXTURE_PINS["000487"]` SapResult fields (all 11 1e-4
pins remain GREEN against worksheet `SAP value 61.6431`).
Mirrors the Slice 64 / 72 / 75 pattern. 000487-specific deltas:
- `wall_thickness_measured=True` on **both** bps (Summary §7 lodges
measured thickness for Main and Ext1 on this cert).
- Floor descriptive: Main "Ground floor" + suspended timber; Ext1
"Above unheated space" + suspended timber (the cert's
`is_exposed_floor=True` for the lowest Ext1 floor).
- `dwelling_type="Enclosed Mid-Terrace house"`,
`built_form="Enclosed Mid-Terrace"` — the Summary distinguishes
Enclosed from plain Mid-Terrace; mapper preserves the distinction.
- `shower_outlets=ShowerOutlets(shower_outlet_type="Electric
shower")` — 000487 lodges 1 instantaneous electric shower (vs
Non-electric on 000477/000480 cohort certs).
- `extensions_count=1`, plus standard top-level booleans,
`number_of_storeys=3`, ventilation zero counts.
Diff count: 45 → **22**. Remaining diffs are structural / encoding-
choice:
- RIR `detailed_surfaces` ordering mismatch + per-surface encoding
(handbuilt pins explicit `u_value=0.86` on gable_wall_external;
mapper extracts insulation_thickness=100 + mineral_wool) — Slice 79
- Alt-wall `wall_construction=8 (SAP10 Park-home)` is mislabeled in
the hand-built — Elmhurst's "TI Timber Frame" maps to SAP10 code 5
(per `_ELMHURST_WALL_CODE_TO_SAP10`); mapper produces the correct
code 5 — Slice 79
- `sap_windows: LEN 5 vs 2` — Slice 80
11 cohort 000487 cascade pins still GREEN; pyright net-zero.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the final `sap_windows: LEN 7 vs 2` divergence by replacing
the cohort 000480 hand-built's 2-window collapsed encoding with 7
SapWindow entries mirroring the Summary §11 1:1. Single glazing-type
group (PVC double / g⊥=0.76 / U=2.8); per-bp totals preserved:
Main NE (orient=2): 8.74 m² split into 2.16 + 1.92 + 0.6 + 1.32
+ 2.04 + 0.7 (6 rows)
Ext1 SW (orient=6): 1.80 m² unchanged
Mapper interleaves the Ext1 SW row between Main NE rows 4 and 5; the
hand-built mirrors that order so list-position diffs are zero.
`window_location` carries "Main" or "1st Extension" — same string-
encoded per-bp lookup pattern as Slice 69 (cohort 000474).
Cascade output unchanged: all 11 `_FIXTURE_PINS["000480"]` SapResult
pins remain GREEN at 1e-4 against worksheet `SAP value 61.2986`.
**Cohort 000480 is now fully Layer-2 GREEN** —
`test_from_elmhurst_site_notes_matches_hand_built_000480` passes with
zero load-bearing divergences between the mapped EpcPropertyData and
the hand-built fixture.
Full sweep: 104 passed (was 103 pre-Slice-74; +1 new diff test),
10 failed (same 10 001479-related as before). Pyright net-zero.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 31 of 32 mapper-vs-hand-built load-bearing divergences by
populating fields the Elmhurst mapper extracts from Summary_000480.
pdf but the original cohort hand-built left at their `make_minimal_
sap10_epc` / dataclass-default values. Every change is cascade-
equivalent — none alter `_FIXTURE_PINS["000480"]` SapResult fields
(all 11 1e-4 pins remain GREEN against worksheet `SAP value 61.2986`).
Mirrors the Slice 64 / 72 pattern. 000480-specific deltas vs 000477:
- Two SapBuildingParts (Main + Ext1) → Cat A descriptive fields
applied per-bp; Ext1 floor is "Above unheated space" (not "Ground
floor") because the extension hangs over an open passageway (the
cert's `is_exposed_floor=True` for the lowest Ext1 floor).
- `roof_insulation_thickness=300` on Main — cascade-inert because the
RR (19.83 m²) is larger than the Main storey footprint (15.28 m²),
so Main has no external roof line; set for field parity with the
mapper, which extracts the §8 Main row's 300 mm regardless.
- `extensions_count=1` — was 0 by default; the mapper extracts it
from `len(survey.extensions)` (Slice 54 fix).
Standard Cat A additions (per Slice 72 pattern): floor descriptive
fields, roof_insulation_location, 6 ventilation zero counts,
draught_lobby=True, pressure_test="Not available", top-level
descriptive strings + booleans + number_of_storeys=3, shower_outlets,
central_heating_pump_age_str.
Diff count: 32 → **1**. Remaining diff is structural:
- `sap_windows: LEN 7 vs 2` — closed via the next-slice 1:1 expansion.
11 cohort 000480 cascade pins still GREEN; pyright net-zero on the
touched fixture.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the final `sap_windows: LEN 7 vs 3` divergence by replacing
the cohort 000477 hand-built's glazing-type-collapsed 3-window
encoding with 7 SapWindow entries mirroring the Summary §11 1:1 —
the same row breakdown the Elmhurst mapper extracts. Total area per
glazing-type group is preserved (cascade-equivalent):
g=0.72/U=2.0: 8.04 m² total — was 2 rows (E 1.28 + W 6.76),
now 6 rows (E 1.28 + W [1.8 + 1.7 + 1.36 + 1.36 + 0.54])
g=0.76/U=2.8: 1.17 m² in 1 row (unchanged)
Cohort 000477 is a single-bp dwelling, so every window's
`window_location` is "Main" — no per-bp apportionment complexity.
Cascade output unchanged: all 11 `_FIXTURE_PINS["000477"]` SapResult
pins remain GREEN at 1e-4 against worksheet `SAP value 65.0057`.
**Cohort 000477 is now fully Layer-2 GREEN** —
`test_from_elmhurst_site_notes_matches_hand_built_000477` passes with
zero load-bearing divergences between the mapped EpcPropertyData
(from `Summary_000477.pdf`) and the hand-built fixture.
Full sweep: 103 passed (was 102 pre-Slice-71; +1 new diff test),
10 failed (same 10 001479-related as documented in the handover).
Pyright net-zero on the touched fixture.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 23 of 24 mapper-vs-hand-built load-bearing divergences by
populating fields the Elmhurst mapper extracts from Summary_000477.
pdf but the original cohort hand-built left at their `make_minimal_
sap10_epc` / dataclass-default values. Every change is cascade-
equivalent — none alter `_FIXTURE_PINS["000477"]` SapResult fields
(all 11 1e-4 pins remain GREEN against worksheet `SAP value 65.0057`).
Mirrors the Slice 64 pattern on the cohort 000474 hand-built:
SapBuildingPart additions (Main only — 000477 is a single-bp mid-
terrace, no extension):
- `wall_thickness_measured`: False → True. Summary §7 lodges Wall
Thickness 380 mm explicitly; the cascade doesn't consume this flag.
- `floor_type`, `floor_construction_type`, `floor_insulation_type_
str`, `floor_u_value_known`: surfaced from Summary §9 ("G Ground
floor" / "T Suspended timber" / "A As built" / U-value Known = No).
Cascade reads the int codes on SapFloorDimension, not these strings.
- `roof_insulation_location="Joists"`: surfaced from Summary §8.
SapVentilation additions (all cascade-equivalent — `None` defaults to
0 throughout the §2 cascade chain):
- 6 explicit zero counts (`open_flues`, `closed_flues`, `boiler_
flues`, `other_flues`, `passive_vents`, `flueless_gas_fires`)
- `pressure_test="Not available"` (descriptive — cert lodges no test)
- `draught_lobby=True` (legacy field; cascade reads `has_draught_
lobby=False` which stays as set)
Top-level additions via `make_minimal_sap10_epc`:
- `blocked_chimneys_count=0`, `dwelling_type="Mid-Terrace house"`,
`built_form="Mid-Terrace"`, `property_type="House"`
Post-construction mutations (helper doesn't expose these as kwargs):
- `has_conservatory=False`, `any_unheated_rooms=False`,
`number_of_storeys=3` (cohort 000477 has ground + first + RIR)
- `sap_heating.shower_outlets=ShowerOutlets(Non-electric shower)`
- `sap_heating.main_heating_details[0].central_heating_pump_age_str=
"Unknown"`
Diff count: 24 → **1**. The remaining diff is structural:
- `sap_windows: LEN 7 vs 3` — mapper extracts 1:1 from §11 table;
the hand-built collapses by glazing-type group, preserving total
area. Cascade-equivalent but not field-equal. Closes via the same
1:1 expansion that Slice 69 applied to cohort 000474 (5 → 7).
11 cohort 000477 cascade pins still GREEN; pyright net-zero on the
touched fixture file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the final 49 → 0 diffs in two moves:
1. **Filter non-load-bearing SapWindow sub-fields from the diff.** The
Elmhurst mapper surfaces Summary §11 strings (window_type='Window',
glazing_type='Double between 2002 and 2021', glazing_gap='12 mm',
data_source='Manufacturer', permanent_shutters_present='None')
while the cohort `make_window` helper produces API-style int codes
for the same fields. None of these affect the SAP cascade — it
reads only window_width / window_height / orientation /
window_location / frame_factor / window_transmission_details.
{u_value, solar_transmittance}. Adding `_NON_LOAD_BEARING_WINDOW_
SUBFIELDS` + `_is_excluded_path` to the diff helper drops them
from the comparison without changing the load-bearing scope. Per
the user's earlier "load-bearing only" decision — encoding noise
that doesn't change the cascade output is excluded.
2. **`make_window` helper now defaults `frame_factor=0.7`.** The
SAP10.2 Table 6c PVC default (and the modal value the Elmhurst
mapper surfaces from Summary §11). Previously the helper left it
`None`, which the cascade resolves to 0.7 internally; setting it
explicitly is cascade-equivalent and closes the last 7 diffs.
Diff count for cohort 000474:
Slice 63 baseline: 50
Slice 64 (Cat A): 14
Slice 65 (HW): 12
Slice 66+67 (mapper): 5
Slice 68 (party-wall): 1
Slice 69 (windows): 49 (encoding-noise surface)
Slice 70 (filter): **0** — diff test now GREEN
`test_from_elmhurst_site_notes_matches_hand_built_000474` PASSES.
First cohort cert fully validated at the EpcPropertyData load-
bearing-field level. All 66 cohort cascade pins remain GREEN at
1e-4. Pyright net-zero (0 errors on touched files).
Next slices: parametrize the diff test over the 5 other cohort
certs (000477, 000480, 000487, 000490, 000516) — each may have
its own bulk-update + mapper-tweak pattern, but the toolchain
(diff helper, exclusion list, _LOAD_BEARING_FIELDS, helper
defaults) is in place. Then 001479 (after Slice 62 hand-built
hits 1e-4). Then the API mapper diff test (currently the API
mapper has its own gaps — Slice 58/59/60 cascade fixes closed
golden cert residuals but field-level cross-mapper parity isn't
asserted yet).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the `sap_windows: LEN 7 vs 5` divergence by replacing the
cohort hand-built's glazing-type-collapsed 5-window encoding with 7
SapWindow entries mirroring the Summary §11 1:1 — the same row
breakdown the Elmhurst mapper extracts. Per-window curtain-transform
U_eff aggregates to the same total as before:
Group g=0.72/U=2.0: 6.22 m² across 4 rows (was 3 rows × wider W)
Group g=0.76/U=2.8: 5.50 m² across 3 rows (was 2 rows × wider W)
Cascade output is unchanged — all 11 cohort 000474 SapResult pins
remain GREEN at 1e-4. The per-bp window apportionment from Slice 59
(`_window_bp_index` in heat_transmission_from_cert) handles both the
prior int-zero `window_location` and the new "Main"/"Nth Extension"
str locations the mapper surfaces; cohort 000474 has uniform per-bp
wall U so the apportionment is heat-loss-invariant either way.
Surfaces a previously-hidden gap: now that the LEN matches, the
diff test reveals **49 per-window sub-field divergences** between
the cohort `make_window` helper (API-style int codes for
`glazing_type`, `window_type`, `window_wall_type`, `glazing_gap`,
`data_source`, bool `permanent_shutters_present`, None
`frame_factor`) and the Elmhurst mapper (Summary-style strings for
the same fields + `frame_factor=0.7`).
That's the next chunk to address — most likely path: normalise the
Elmhurst mapper to produce API-style int codes for the window
descriptive fields, so both mappers produce the same dataclass
shape. The cascade reads `window_transmission_details.u_value` /
`solar_transmittance` + `window_width` × `window_height` +
`orientation` + `window_location` — none of the descriptive
divergences listed above affect SAP output.
Diff count: 1 → 49 (surface, not regression). Cohort cascade pins
green; pyright 0 errors on the fixture.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 4 of 5 remaining cohort 000474 diffs (5 → 1):
**Mapper:** Add "U" → 0 to `_ELMHURST_PARTY_WALL_CODE_TO_SAP10`. The
modal cohort lodgement Summary §7 "Party Wall Type: U Unable to
determine" was previously falling through to None; the cohort hand-
built convention uses 0 as the explicit "unknown" sentinel. The
cascade resolves both 0 and None to the same `u_party_wall` default
(0.25), so cascade output is unchanged. Closes 3 diffs (one per bp).
**Hand-built:** Set `central_heating_pump_age_str="Unknown"` on cohort
000474 Main heating detail (post-construction since the helper
doesn't expose the kwarg). Matches the Elmhurst mapper's surfaced
value from Summary §14 "Heat pump age: Unknown" — the str dual-
encoding internal_gains.py reads. Closes 1 diff.
All 66 cohort cascade pins remain GREEN at 1e-4. Pyright 35-error
baseline preserved on mapper.py; 0 errors on the hand-built file.
Remaining 1 diff on cohort 000474:
- `sap_windows: LEN 7 vs 5` — the cohort hand-built collapsed §11
by glazing-type × orientation × bp group (preserving total area,
cascade-equivalent but not field-equal); the mapper extracts 1:1
with the worksheet's 7 §11 table rows. Next slice will expand the
hand-built to 7 individual SapWindow entries matching the mapper.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 2 of 14 remaining diffs by populating Appendix J inputs the
Elmhurst mapper surfaces from Summary §16:
- `sap_heating.number_baths=1` (passed via make_sap_heating kwarg)
- `sap_heating.shower_outlets = ShowerOutlets(Non-electric)` (set
post-construction because the helper doesn't expose the field;
added the dataclass imports for SCM completeness)
Cascade-equivalent: number_baths=1 and one non-electric mixer outlet
without WWHRS are the implicit Appendix J defaults when nothing is
lodged. All 11 cohort 000474 cascade pins remain GREEN at 1e-4.
Diff count: 14 → 12. Pyright net-zero (0 errors).
Remaining 12 diffs split:
- 7 mapper-needs-to-surface (country_code, water_heating_fuel,
boiler_flue_type, emitter_temperature, main_heating_number,
has_draught_lobby, central_heating_pump_age int↔str)
- 3 party_wall_construction sentinel (None vs 0) across bps
- 1 sap_windows: LEN 7 vs 5 (collapse vs 1:1 structural decision)
- 1 dwelling_type / built_form casing nuance (resolved in Slice 64
bulk-update; remaining 1 was for one bp's encoding)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 36 of the 50 mapper-vs-hand-built load-bearing divergences by
populating fields the Elmhurst mapper extracts but the original
cohort hand-built left at their `make_minimal_sap10_epc` / dataclass-
default values. Every change is cascade-equivalent — none alter
`_FIXTURE_PINS["000474"]` SapResult fields (all 11 1e-4 pins remain
GREEN against worksheet `SAP value 62.2584`).
Per-SapBuildingPart additions (Main, Ext1, Ext2):
- `wall_thickness_measured`: False → True. Summary §7 lodges Wall
Thickness 280 mm explicitly; the cascade doesn't read this field
(grep `wall_thickness_measured` across domain/sap/ returns no
consumer outside test fixtures), so flipping it is field-level-
only.
- `floor_type`, `floor_construction_type`, `floor_insulation_type_str`,
`floor_u_value_known`: surfaced from Summary §9 ("G Ground floor" /
"U Above unheated space" / "T Suspended timber" / "A As built" /
U-value Known = No). Strings carry the lodged text for cross-mapper
parity; cascade reads the int codes on SapFloorDimension.
- `roof_insulation_location`, `roof_insulation_thickness`: surfaced
from Summary §8 ("J Joists" + "100 mm"). Cascade's `u_roof` for
age B at thickness=100 returns the same 0.40 W/m²K as the age-B
default (thickness=None falls through to `_ROOF_BY_AGE['B']=0.40`),
so the cascade output is identical.
SapVentilation additions (all cascade-equivalent — `None` defaults to
0 throughout the §2 cascade chain):
- 6 explicit zero counts (`open_flues`, `closed_flues`, `boiler_flues`,
`other_flues`, `passive_vents`, `flueless_gas_fires`)
- `pressure_test="Not available"` (descriptive, no test was lodged)
- `draught_lobby=True` (the legacy field; cascade reads
`has_draught_lobby=False` which is set already, so True on the
legacy field has no cascade effect)
Top-level additions via `make_minimal_sap10_epc`:
- `extensions_count=2` (Slice 54 fix on mapper made this surface; the
hand-built was carrying the pre-Slice-54 hard-coded 0)
- `blocked_chimneys_count=0`, `dwelling_type="Mid-Terrace house"`,
`built_form="Mid-Terrace"`, `property_type="House"`
Post-construction mutations (helper doesn't expose these as kwargs):
- `has_conservatory=False`, `any_unheated_rooms=False`,
`number_of_storeys=2`, `hydro=False`, `photovoltaic_array=False`
Diff count: 50 → **14**. The remaining 14 are real semantic gaps for
the next slices to close:
Cat B (mapper needs to surface 7 fields):
- country_code (Elmhurst mapper produces None; should set 'ENG')
- sap_heating.water_heating_fuel (None vs 26 — gas main heating
should imply gas water heating fuel)
- main_heating_details[0].boiler_flue_type (None vs 2 — Summary
§14.1 lodges "Balanced" flue type)
- main_heating_details[0].emitter_temperature ('Unknown' vs 1)
- main_heating_details[0].main_heating_number (None vs 1)
- sap_ventilation.has_draught_lobby (None vs False)
- dual-encoded central_heating_pump_age int/str
Cat C (structural shape, 2 diffs):
- sap_windows: LEN 7 vs 5 (mapper 1:1 with §11 table vs hand-built
collapsed by glazing-type group, preserving total area —
cascade-equivalent but not field-equal)
- sap_building_parts[*].party_wall_construction: None vs 0
(cohort convention sentinel; the cohort 000474 docstring
established `0 = "Unable to determine"`)
Cat B handbuilt-needs (hand-built should add 2 fields the mapper
already surfaces):
- sap_heating.shower_outlets (mapper extracts 'Non-electric shower')
- sap_heating.number_baths (mapper extracts 1)
11 cohort cascade pins still GREEN; pyright net-zero (0 errors on
the touched fixture file). Tracer-bullet diff test stays RED with
14 divergences (was 50).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User-driven pivot from cascade chain-pin chase to the rigorous cohort
pattern: a hand-built EpcPropertyData that cascades to the worksheet
at 1e-4 is the ground truth for cross-mapper parity testing. Both the
Elmhurst mapper and the API mapper should ultimately produce a hand-
built-equivalent EpcPropertyData for cert 001479; every divergence
from the hand-built is a mapper bug.
This skeleton encodes the cert 001479 worksheet inputs:
- 3 building parts (Main C, Ext1 L, Ext2 C) with per-bp wall U
- Main party wall CU (cavity unfilled, U=0.50, lodged via WC_CAVITY=4)
- Cantilevered upper-storey Ext2 with `is_exposed_floor=True` (U=1.20)
- Ext2 PS sloping-ceiling roof at `roof_insulation_thickness=0`
(Slice 57 PS+pre-1950 path → Table 16 row 0 U=2.30)
- Main 300 mm joist roof insulation → U=0.14
- 8 Main windows (U=2.8, g=0.76) + 1 Ext1 window (U=1.4, g=0.72)
- Worcester Greenstar 30i (PCDF 17507) main + SAP 605 gas fire secondary
(Slice 58 mains-gas secondary fuel cost routing)
- Sheltered sides 1, 2 intermittent fans, 90% draught-proof, 23 LEDs
Adds an `001479` entry to `_FIXTURE_PINS` + `_FIXTURE_MODULES` in
`test_e2e_elmhurst_sap_score.py` with the worksheet PDF's 11
cascade-output line refs:
sap_score 69 (258)
sap_score_continuous 69.0094 "SAP value"
ecf 2.2215 (257)
total_fuel_cost_gbp 600.4001 (255)
co2_kg_per_yr 2687.3610 (272)
space_heating_kwh_per_yr 8103.7054 Σ (98c)
main_heating_fuel_kwh_per_yr 8194.7583 (211)
secondary_heating_fuel_kwh_per_yr 2025.9264 (215)
hot_water_kwh_per_yr 2358.3123 (219)
pumps_fans_kwh_per_yr 160.0000 (231)
lighting_kwh_per_yr 163.3584 (232)
Current state of the hand-built cascade vs worksheet:
Pin Cascade Expected PASS?
sap_score_continuous 65.99 69.01 no, -3.02
total_fuel_cost_gbp 658.92 600.40 no, +58.52
main_heating_fuel_kwh_per_yr 9359.6 8194.8 no
pumps_fans_kwh_per_yr 160.0 160.0 PASS
lighting_kwh_per_yr 163.4 163.4 PASS (after
LED/CFL split)
(... 9 others all failing by various deltas)
2/11 pins green. The remaining ~3 SAP gap means the hand-built has
input gaps that produce more loss/cost than Elmhurst's calc. Likely
suspects (slice candidates):
- HW demand: cascade likely over-counts (combi vs cylinder routing,
Tcold model)
- Internal gains: appliance + cooking energy share
- §2 ventilation tuning (chimney/flue counts, suspended-floor flag)
- Thermal mass parameter (250 default — confirm worksheet matches)
- Multiple-glazed proportion (cascade reads None → may default
unfavourably for solar gains)
Documents source-data caveat in the fixture docstring: Summary §3
says Ext1 age "M 2023 onwards"; worksheet header says "Ext1: L".
Hand-built uses 'L' to mirror the worksheet (which is the calc's
input source of truth); Elmhurst mapper produces 'M' from the
Summary — cross-mapper diff will flag this as a known caveat.
All 6 cohort cascade pins remain green at 1e-4 (66/66 fixture pins).
Pyright net-zero on the new fixture file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`heat_transmission_from_cert` computed `y = thermal_bridging_y(age_
band=part.construction_age_band)` per bp, then applied each bp's y
to its own external area. That mis-models multi-age dwellings:
RdSAP10 Table 21 indexes y by the *dwelling's* age band, and Elmhurst's
worksheet reports y as a single user-defined value applied to total
exposed area (cert 001479 worksheet: "Thermal Bridges Bridging User
Input Y 0.15").
For cohort certs with uniform age-band bps the change is heat-loss-
invariant. For cert 001479 (Main=C → 0.15, Ext1=M → 0.08, Ext2=C →
0.15) the cascade was under-counting Ext1's bridging by 0.07 × 27.28
m² ≈ 1.9 W/K. For golden cert 7536-3827 (Main=D, Ext1=L, Ext2=F) the
same per-bp split was costing ~2 W/K of bridging.
Use the primary part's (parts[0]) age band for a single dwelling-wide
`dwelling_y`, applied across all parts in the heat-loss loop.
Cert 001479 chain pin closes another step: cascade SAP 70.38 → 70.20
(target 69.0094, delta 1.37 → 1.19). Golden 7536-3827 residuals
tighten in lockstep: SAP +4 → +3, PE -24.73 → -22.53, CO2 -0.66 → -0.60.
Other 7 golden certs unchanged (single-bp or uniform-age multi-bp).
70 of 71 chain+golden+heat-transmission tests green; chain pin still
RED (load-bearing). Pyright net-zero (13-error baseline on
heat_transmission.py preserved).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`heat_transmission_from_cert` hardcoded all window + door area to the
first sap_building_part (Main) via the `if i == 0` branch. That's
heat-loss-invariant for cohort certs whose per-bp wall U is uniform
(cohort 6 all share wall_construction + wall_insulation_type across
bps) but wrong for cert 001479 where Ext1's wall U=0.26 (filled
cavity, age M) differs sharply from Main's U=0.70 (uninsulated
cavity, age C). Worksheet §3:
External walls Main 47.13 net × 0.70 = 32.99 (29a)
External walls Ext1 10.17 net × 0.26 = 2.64 (29a)
External walls Ext2 5.90 × 0.70 = 4.13 (29a)
Σ walls 39.77
Pre-slice the cascade attributed all 9 windows to Main, leaving
Ext1's 6.37 m² window NOT deducted from Ext1's wall — Ext1 wall area
inflated to 16.54 (gross) instead of 10.17 (net), then multiplied by
the lower U=0.26 → cascade understated walls_w_per_k by ~2.8 W/K.
Add `_window_bp_index` mapping `SapWindow.window_location` (int
from API mapper, "Main"/"Nth Extension" string from Elmhurst) to a
sap_building_parts index. Pre-compute per-bp window areas and use
that in the loop's `net_wall_area` calculation.
Backwards-compat preserved for direct callers passing
`window_total_area_m2` kwarg with an empty `epc.sap_windows` (legacy
single-bp test path): the kwarg total still apportions to Main.
Cohort hand-built fixtures default `window_location=0` so all windows
route to Main — same as the old i==0 logic for those tests.
Cascade behaviour changes for 3 golden certs with non-Main windows
(all 3 in the right direction — residuals tighten toward zero):
6035-7729: SAP -5 → -4, PE +36.15 → +34.02, CO2 +0.81 → +0.76
7536-3827: SAP +4 (same), PE -27.17 → -24.73, CO2 -0.72 → -0.66
8135-1728: SAP +1 (same), PE -16.98 → -16.51, CO2 -0.30 → -0.29
Pins tightened; notes annotated with slice attribution. Cert 001479
chain pin closes from delta 1.63 → 1.37 (cascade SAP 70.64 → 70.38,
target 69.0094) — remaining ~4.4 W/K HLC gap lives in floor U
defaults (Ext1 insulated "As Built") and Ext2 roof area derivation.
70 of 71 chain+golden+heat-transmission tests green; only the cert
001479 chain pin remains RED (load-bearing forcing function).
Pyright net-zero (13-error baseline on heat_transmission.py
preserved).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two coupled bugs surfaced by cert 001479's mains-gas-fire secondary
heating (Summary §14.1 lodges "SAP code 605, Flush fitting live effect
gas fire" → fuel 26 mains gas):
1. **Mapper**: `_map_elmhurst_sap_heating` only set
`secondary_heating_type` (the SAP code int) — `secondary_fuel_type`
stayed None. The Summary PDF doesn't lodge the fuel int separately;
it has to be derived from the SAP code range. Add
`_elmhurst_secondary_fuel_from_sap_code`: codes 601-630 → 26
(mains gas); other codes return None (the cascade defaults to
electric, matching cohort 000490 SAP code 691 electric panel).
2. **Cascade**: `_fuel_cost` in cert_to_inputs hardcoded
`secondary_high_rate_gbp_per_kwh = other_uses_gbp_per_kwh` (the
standard-electricity tariff) regardless of `secondary_fuel_type`.
For gas secondaries this charged 1846 kWh/yr at electric rate
(£0.132/kWh = £243) instead of gas rate (£0.0348/kWh = £64) —
a ~£175/yr ECF distortion ≈ 9 SAP points on cert 001479. Route
the cost through `table_32_unit_price_p_per_kwh(secondary_fuel)`
when lodged.
Worksheet line (242) confirms the gas pricing:
`Space heating - secondary 2025.93 3.4800 70.5022`
Cert 001479 chain pin delta narrows: SAP_continuous 61.39 → 70.64
(was −7.62 vs 69.0094, now +1.63 — overshooting target by 1.63 SAP).
The remaining overshoot maps to the cascade's ~16 W/K HLC undercount
(cascade HLP 2.89 vs worksheet 3.13 × TFA) — work for follow-up
slices.
Cohort 6 chain certs still green at 1e-4 (all-electric or no-
secondary). Golden cohort: cert 0300-2747 (mains-gas secondary)
SAP residual tightens −7 → +2 — biggest single SAP improvement on
the golden cohort to date; pin updated and notes annotated. Other
7 golden certs unchanged (None or electric secondary fuel). Pyright
net-zero (35 baseline each on mapper.py + cert_to_inputs.py).
Chain pin `test_summary_001479_full_chain_sap_matches_worksheet_pdf_
exactly` is the load-bearing RED — committed failing per TDD; closes
to GREEN once the HLC undercount lands.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Floor U is formula-driven (BS EN ISO 13370 + RdSAP10 §5.12), not a table lookup, so cohort pins assert per-geometry values derived by hand from the spec formula. Cert 0240's main + extension building parts cover both the dt < B and dt > B branches of the solid-floor cascade with age J → Table 19 default 75 mm insulation. Hand-derivation matches calculator output to 2 d.p.; the formula cascade is correct on this cohort case. Suspended-floor + Table 19 footnote (2) overrides remain unpinned until cohort coverage demands them.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirror of the wall cohort pin. Worksheet fixtures lodge roofs=[] so the description-driven branch of u_roof was never validated at cascade level. New parametrised test pins 8 (description, age, thickness) tuples from the golden certs against the Table 16 col-1 (loft insulation thickness known) value. All 8 cases match spec: u_roof is correct on the thickness-known path even when joined-description from multiple roof rows contains noise.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Worksheet fixtures lodge walls=[] so the description-driven branches of u_wall — the codepath real API certs trigger — were never validated at cascade level. New parametrised test pins each (description, age) pair seen in the 8 golden certs against the Table 6 value the spec mandates. All 7 clean cases match spec: the description cascade is correct where Table 6 gives a direct value. Cases routing through §5.7 / §5.8 formulas are excluded pending separate pinning.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mapper-drop audit across the 9-fixture cohort: `percent_draughtproofed`
is lodged on 9/9 certs (raw values 85-100) but the schema-21.0.1
mapper never set it on EpcPropertyData. The site-notes mappers always
have (line 312 of mapper.py); only the API path was missing.
cert_to_inputs reads `epc.percent_draughtproofed` for the §2
ventilation cascade (window draught loss); with None → 0 default, the
calc was treating every API-routed cert as fully draughty —
over-counting draught infiltration on every fixture in the cohort.
Fix: `percent_draughtproofed=schema.percent_draughtproofed` in
`from_rdsap_schema_21_0_1`.
Cohort SAP / PE / CO2 shifts (all 9 fixtures move; many shift one
SAP point because the continuous SAP was near a rounding boundary):
cert old SAP new SAP PE shift CO2 shift
0240-0200-5706-2365-8010 -12 -10 -7.63 -0.39
0300-2747-7640-2526-2135 -9 -7 -6.36 -0.55
0390-2254-6420-2126-5561 (LN12) 0 +1 -9.10 -0.13
0390-2954-3640-2196-4175 -7 -4 -4.87 -0.44
2130-1033-4050-5007-8395 (DE22) +8 +9 -3.67 -0.04
6035-7729-2309-0879-2296 -6 -5 -8.90 -0.21
7536-3827-0600-0600-0276 +3 +4 -9.19 -0.24
8135-1728-8500-0511-3296 +1 +1 (cont -7.48 -0.14
72.7→73.5)
9390-2722-3520-2105-8715 +2 +3 -7.32 -0.01
LN12 lost its exact-SAP-match (0 → +1, continuous 65.47 → 66.28); the
other fixtures' rounded SAP residuals tightened or worsened by 1
depending on which side of the rounding boundary they sit. This is
spec-correctness over residual-tightness: the lodged value is correct,
our calc now reads it.
930/930 Elmhurst cascade green. 78/78 mapper tests + 14/14 golden
cohort + PCDB chain green. Pyright net-zero.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Slice 37's per-cert pin refactor pinned PE residuals against
`result.primary_energy_kwh_per_m2` from the rating cascade (UK-avg
climate). But per SAP10.2 Appendix U + the codebase's own
SAP_CALCULATOR.md docs, the EPC's published `energy_consumption_current`
is a postcode-climate value — same as CO2. The CO2 pin was already
correct; PE was an oversight.
Fix: use the public `calculate_sap_from_inputs` entry point twice —
once with `cert_to_inputs` (rating cascade) for SAP, once with
`cert_to_demand_inputs` (demand cascade) for PE + CO2. This drops
the four section-helper imports and reads everything off SapResult,
keeping the test surface minimal.
PE residuals shift on every fixture (sometimes toward zero, sometimes
away — the rating cascade was masking the real gap):
cert old PE new PE Δ
0240-0200-5706-2365-8010 +0.74 +5.58 worse — known RR gap
0300-2747-7640-2526-2135 +17.34 +4.45 tighter
0390-2254-6420-2126-5561 (LN12) -3.14 +0.18 tighter ← bread-and-butter cert now within 0.2 kWh/m²
0390-2954-3640-2196-4175 -27.64 -26.68 ~same
2130-1033-4050-5007-8395 (DE22) -61.25 -65.89 worse — PV PE-offset now correctly accounted
6035-7729-2309-0879-2296 +34.62 +45.05 worse — known wall-insulation + RR gap
7536-3827-0600-0600-0276 -27.45 -17.98 tighter
8135-1728-8500-0511-3296 -14.37 -9.50 tighter
The "worse" certs (0240, 6035, DE22) were never close — the rating
cascade had been coincidentally masking the real PE gap on the certs
with documented mapper gaps. Demand cascade now exposes the real
residual for each; the documented gaps' fixes will close them.
LN12 (bread-and-butter, gas combi, no PV) now reads:
SAP resid +0 (exact match)
PE resid +0.18 (within 0.2 kWh/m² of lodged 241)
CO2 resid +0.04 (within 0.05 t/yr of lodged 3.5)
First cert in the cohort within target ±0.5 on SAP and ±1 on PE/CO2.
930/930 Elmhurst cascade unchanged. 14/14 golden cohort + PCDB chain
green. Pyright net-zero (2 errors before and after).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Audit of raw-JSON keys vs RdSapSchema21_0_1 across the 9-fixture
golden cohort surfaced 7 vent / draught fields silently dropped at
deserialization: blocked_chimneys_count, open_flues_count,
closed_flues_count, boilers_flues_count, other_flues_count, psv_count,
has_draught_lobby. cert_to_inputs reads all of them for the §2
infiltration cascade; without them the calc treats every dwelling as
flue-free / vent-free / no draught lobby and under-counts ACH.
Fix: declare the 7 fields on RdSapSchema21_0_1; extend the mapper to
surface blocked_chimneys_count on EpcPropertyData top-level (already
declared) and the other 6 on SapVentilation (extends the slice 37
extract_fans_count work). has_draught_lobby coerces "true"/"false"
strings to bool to match the SapVentilation type.
Cohort residual shifts after re-pinning:
- LN12 (0390-2254) — SAP +1 → 0 (FIRST CERT TO HIT LODGED SAP EXACTLY).
blocked_chimneys=2 reduces infiltration, tightens both SAP and PE
(PE −10.62 → −3.14, CO2 −0.11 → +0.04).
- 0300 — PE +18.92 → +17.34, CO2 −0.43 → −0.54 (open_flues=1 +
has_draught_lobby=true cross-cancel near-zero).
- 0390-2954 — PE −25.62 → −27.64, CO2 −2.45 → −2.58 (has_draught_lobby=true).
- 8135 — PE −17.58 → −14.37, CO2 −0.22 → −0.15 (blocked_chimneys=1).
- Other 5 fixtures (0240, DE22, 6035, 7536, plus retired 9390): no shift
— their certs lodge zeros or no vent fields beyond what Slice 37 plumbed.
Rounded-SAP cohort distribution post-slice:
0 (LN12), +1 (8135), +2 (9390), +3 (7536), +8 (DE22, spec-drift),
-6 (6035), -7 (0390-2954), -9 (0300), -12 (0240, RR-driven).
Schema scope: 21.0.1 only. 21.0.0 schema's SapBuildingPart shares the
same mapper code but no 21.0.0 fixtures live in the cohort to anchor
against; defer to a future slice if needed.
930/930 Elmhurst cascade green. 14/14 golden cohort green at new
pinned residuals. 77/77 mapper tests green. Pyright net-zero (34
errors before and after).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`_pv_export_credit_gbp_per_kwh` previously read from `prices.unit_price`
(SAP10.2 Table 12 code 60 = 5.59 p/kWh) while the actual rating
cascade inside _fuel_cost reads from `table_32_unit_price_p_per_kwh`
(RdSAP10 Table 32 code 60 = 13.19 p/kWh, same as standard electricity).
The exposed CalculatorInputs.pv_export_credit_gbp_per_kwh therefore
misled about what the cascade applied. The calculator's fallback path
at calculator.py:442 fires for synthetic inputs without `fuel_cost`
and would compute the wrong PV credit by reading the misleading input.
Per ADR-0010 §10 the rating cascade uses Table 32 prices. Unified
both code paths on Table 32 so the input boundary reports the same
13.19 p/kWh the cascade applies. Cert-path math unchanged (cert path
always sets fuel_cost). Synthetic/fallback path now consistent with
cert path.
Also adds cert 2130-1033-4050-5007-8395 (DE22, end-terrace + 1 ext,
gas combi PCDB 17505, 2× 2.04 kWp PV) as 9th golden fixture. First
PV-bearing cert in the cohort. Pinned residual is SAP +8 / PE −61 /
CO2 +0.19 — spec-version drift not a code bug (cert was scored by
SAP10.2 software using Table 12 PV export 5.59 p/kWh = £194 credit
→ SAP 82; calc targets RdSAP10 Table 32 = 13.19 p/kWh = £457 credit
→ SAP 90). Both internally consistent against their own price table.
The PE residual is amplified because PV gen also offsets PE via
inputs.other_primary_factor, which scales with gen kWh independently
of the export-credit price.
930/930 Elmhurst cascade green. 14/14 golden cohort + 1 new
cert_to_inputs unit test green. Pyright net-zero (49 errors before
and after).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
End-terrace + 1 extension, TFA 80 m², gas combi (PCDB index 18119),
no PV, no secondary, postcode LN12 (PCDB Table 172 match). Schema-
21.0.1 / SAP 10.2 — the cleanest bread-and-butter cert in the cohort.
Residuals post sap_ventilation mapper fix:
SAP +1 (calc 66 vs lodged 65)
PE -10.6249 kWh/m²
CO2 -0.1059 t/yr
Residual floor reflects remaining mapper gaps — notably schema-21
not carrying led_/cfl_fixed_lighting_bulbs_count for this cert, so
the §5 lighting efficacy falls back to defaults.
Also added to PCDB chain test — index 18119 flows through to
inputs.main_heating_efficiency (winter eff lookup deferred,
expected_winter_eff=None per the existing non-oil convention).
12/12 golden cohort green. Pyright net-zero.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 21.0.1 mapper produced EpcPropertyData with sap_ventilation=None,
so the cert→inputs cascade defaulted every ventilation count to zero
even when the cert lodged extract fans (most schema-21 certs do).
extract_fans_count was double-mapped — surfaced as a top-level field
the calculator never reads, but missing from the SapVentilation slice
the cascade does read.
Fix: populate sap_ventilation in from_rdsap_schema_21_0_1 with
extract_fans_count. Drives ~⅓ of the rating-cohort drift on a clean
no-PV no-secondary gas-combi cert.
Refactored test_golden_fixtures.py from global tolerance ceilings
(±13 SAP / ±35 PE) to per-cert pinned residuals at abs SAP=0,
PE=0.01 kWh/m², CO2=0.001 t/yr. Each cert's _GoldenExpectation now
records the actual current residual (SAP/PE/CO2 — CO2 newly pinned
via the postcode-cascade environmental section). Drift in either
direction fires the test: tighten the pin on improvement, document
on regression.
Recorded residuals reflect known remaining mapper gaps (RR room-in-
roof extraction on cert 0240, oil cascade on 0390, etc.) — tracked
in each cert's notes: field, not acceptance bounds.
930/930 Elmhurst cascade pins unchanged (site-notes EPCs already
populate sap_ventilation). 257/257 mapper tests green. 10/10 golden
cohort green under the new pins. Pyright net-zero (34 errors before
and after).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The SAP 10.2 / RdSAP 10 calculator is closed at 930/930 pin tests green.
Tidying the docs for hand-off to the API-integration agent.
New: docs/sap-spec/SAP_CALCULATOR.md
Canonical module overview — public API surface, two-cascade
architecture (Rating UK-avg, Demand postcode), simulator-use-case
example, file map, validation contract + hard rules, fixture cohort
notes, spec page references. Replaces the scattered "what's the
shape" knowledge that was previously only in commit messages.
Rewritten: docs/sap-spec/HANDOVER_NEXT.md
Old handover (work queue for slices 26-36) is obsolete. Replaced
with the next agent's brief: build an API → SAP scoring integration
test using the 6 Elmhurst fixtures. Includes a copy-paste reference
scoring path, expected outputs per fixture, list of files to read
on day 1, and scope guardrails.
Refreshed module docstrings:
- cert_to_inputs.py: now describes both cascades, the deferred-edge-
case list reflects current state (RR/secondary/§15 living-area
rounding all DONE; thermal-mass and control-temp adjustment still
deferred).
- calculator.py: per-end-use CO2/PE factor machinery documented;
stale "single-fuel approximation" claim removed (closed in slice 32).
- sap/README.md: validation paragraph now says "930/930 green" and
points to SAP_CALCULATOR.md instead of the obsolete HANDOVER_NEXT.
Verified the API examples in both docs produce the expected per-fixture
outputs (SAP=62, EI=60, Carbon=3104.1222, PE=16931.7227 for 000474).
Wider regression: 1585/1585 PASS, zero failures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pins the EPC's published "Current Carbon" + "Current Primary Energy"
values against the U985 Block 2 (postcode-climate cascade via PCDB
Table 172) for all 6 Elmhurst fixtures at abs=1e-4.
Adds:
- `PrimaryEnergySection` dataclass exposing §13a line refs (275)..(286).
- `primary_energy_section_from_cert(epc, postcode_climate=...)` —
composes §9a per-system fuel kWh × Table 12 (gas) / Table 12e
(electricity, monthly) PE factors. Handles (279) excludes (278a)
electric-shower PE convention (mirrors §12 (265) excludes (264a)).
- Real postcode on each Elmhurst fixture (bd3 8aq / bd3 9DR / bd5 8dn /
bd3 9JZ / bd19 3TF / BD4 7JR) via new `postcode` kwarg on
`make_minimal_sap10_epc`.
- DEMAND_LINE_* constants per fixture for §9a annual kWh, §12 CO2 line
refs (261..272), §13a PE line refs (275..286).
- 16 cascade pins per fixture × 6 fixtures = 96 demand pins.
EXACT match (000474, the canonical test):
EPC Current Carbon (LINE_272) = 3104.1222 kg/yr ✓ (Summary PDF: 3.104t)
EPC Current PE (LINE_286) = 16931.7227 kWh/yr ✓
Reference: SAP 10.2 Appendix U paragraph 1 (p.124) — "For ratings (SAP
rating and environmental impact rating) the calculations are done with
UK average weather. Other calculations (such as for energy use and
costs on EPCs) are done using local weather. Weather data for each
postcode district are taken from the PCDB."
Full scoreboard: 840 rating-cascade pins + 96 demand-cascade pins +
existing 5 postcode-weather unit tests = 941 total pins. Wider
regression: 1585/1585 PASS — zero failures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an optional `postcode_climate: Optional[PostcodeClimate]` parameter
to every cert→inputs section helper that touches climate:
- `cert_to_inputs(epc, postcode_climate=...)`
- `ventilation_from_cert` (overrides UK-avg wind tuple)
- `mean_internal_temperature_section_from_cert`
- `space_heating_section_from_cert`
- `space_cooling_section_from_cert`
- `solar_gains_section_from_cert`
- `energy_requirements_section_from_cert`
- `fuel_cost_section_from_cert`
- `environmental_section_from_cert`
`_climate_source(postcode_climate)` returns `int | PostcodeClimate`
(region 0 = UK-avg fallback). The four Appendix U lookup functions
(`external_temperature_c`, `wind_speed_m_per_s`, `horizontal_solar_
irradiance_w_per_m2`, `_latitude_deg`) now accept the union and
dispatch on isinstance — region path is unchanged, postcode path reads
directly from `PostcodeClimate`.
CalculatorInputs gains `monthly_external_temp_c_override` so the
calculator's per-month solve uses the postcode tuple computed in
cert_to_inputs instead of looking up `external_temperature_c(region, m)`
(which would always be UK-avg).
Adds two public helpers:
- `local_climate_for_cert(epc)` — postcode lookup with None fallback
- `cert_to_demand_inputs(epc)` — convenience: cert_to_inputs with
postcode climate from the cert's postcode field
Verification (000474 with postcode "bd3 8aq" injected — fixtures
currently lodge placeholder "A1 1AA"; real postcodes land in slice 36):
Rating main_1_fuel = 11964.8924 (PDF Block 1: 11964.8924 ✓)
Demand main_1_fuel = 12288.0014 (PDF Block 2: 12288.0014 ✓ EXACT)
Rating ext_temp Jan = 4.3°C (UK-avg)
Demand ext_temp Jan = 4.2°C (BD3)
840/840 existing pins still pass — refactor is backward-compatible.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per SAP 10.2 Appendix U (p.124): "Weather data for each postcode district
are taken from the PCDB" — Table 172 of pcdb10.dat lodges ~3138 postcode
districts × monthly (temp, wind, solar). This is the data source for the
EPC's demand-side cascade (Current Carbon, Current Primary Energy, Fuel
Bill) — distinct from the rating-side cascade which uses UK-average
climate per the same Appendix U paragraph.
Adds:
- `PostcodeClimate` dataclass: area, district, region (1-21 fallback),
country, height, lat/lon, monthly temp/wind/solar tuples.
- `_parse_table_172_rows(text)`: parser over the pcdb10.dat row format
(45 comma-separated fields: 9 metadata + 12 T + 12 W + 12 R).
- `_split_postcode(postcode)`: outward-code splitter handling 1-2 letter
area + 1-2 digit district (e.g. "bd19 3tf" → ("BD", 19)).
- `postcode_climate(postcode)`: cached lookup with None fallback for
unknown postcodes (callers fall back to Appendix U region tables).
Verified BD3 (the Bradford district for Elmhurst fixture 000474) reproduces
U985 Block 2 wind exactly: (5.2, 5.2, 5.0, 4.4, 4.3, 3.9, 4.0, 3.8, 4.1,
4.4, 4.6, 4.9). 5 unit tests pinning the lookup, postcode parsing
(including 2-digit districts), case insensitivity, and graceful None
returns for unknown/malformed postcodes.
Data layer only — slice 35 plumbs this through cert_to_inputs as the
demand-side cascade. No changes to existing tests (1490/1490 still pass).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds Table 12e (p.195) monthly PE factors for electricity to
`tables/table_12.py` + `pe_monthly_factors_kwh_per_kwh(fuel_code)`
helper. Mirrors slice 32's CO2 cascade — same spec text, same
shape: electricity end-uses use Σ(kWh_m × PE_m); non-electricity
fuels keep the annual Table 12 / RdSAP10 Table 32 (p.95) factor.
Calculator now consumes per-end-use PE factors on `CalculatorInputs`
(`secondary_heating_primary_factor`, `pumps_fans_primary_factor`,
`lighting_primary_factor`, `electric_shower_primary_factor`). Defaults
to None → fall back to the global `space_heating_primary_factor` /
`other_primary_factor` (synthetic path). Fixes the stale 1.969 default
to RdSAP10 Table 32 standard-electricity PE = 1.501.
`_effective_monthly_factor(monthly_kwh, monthly_factors)` generalises
the slice-32 weighting helper; `_effective_monthly_co2_factor` and the
new `_effective_monthly_pe_factor` are thin wrappers over it.
Includes the electric-shower kWh in the PE total — closes the audit
loop opened by slice 30 (electric shower had fuel cost + CO2 but no PE
contribution).
§13a cascade pins NOT added — §13a appears only in the Demand-SAP
block (postcode climate); our cascade pins live against the Rating-SAP
block (UK-average climate). The Demand-SAP postcode cascade is a
separate scope, intentionally deferred. The calculator's existing
`primary_energy_kwh_per_yr` SapResult output now uses the spec-correct
PE factors but stays UK-average climate.
Verification (000474):
pumps_fans effective PE factor = 1.5128 (PDF: 1.5128 ✓)
lighting effective PE factor = 1.5338 (PDF: 1.5338 ✓)
pumps_fans PE = 242.0480 kWh (PDF: 242.0480 ✓)
lighting PE = 214.6527 kWh (PDF: 214.6527 ✓)
Wider regression: 1490/1490 PASS — zero failures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
FULL CLOSURE. Cascade 768/768 + e2e 72/72 across all 6 Elmhurst fixtures.
Adds Table 12d (p.194) monthly CO2 emission factors for electricity to
`tables/table_12.py` + `co2_monthly_factors_kg_per_kwh(fuel_code)` helper.
Per the spec text: "Where electricity is the fuel used, the relevant set
of factors in the table below should be used to calculate the monthly
CO2 emissions INSTEAD the annual average factor given in Table 12."
Calculator now consumes per-end-use CO2 factors on `CalculatorInputs`
(`main_heating_co2_factor_kg_per_kwh`, `secondary_heating_co2_factor_
kg_per_kwh`, `hot_water_co2_factor_kg_per_kwh`, `pumps_fans_co2_factor_
kg_per_kwh`, `lighting_co2_factor_kg_per_kwh`, `electric_shower_kwh_
per_yr`, `electric_shower_co2_factor_kg_per_kwh`). Defaults to None →
falls back to the global `co2_factor_kg_per_kwh` (legacy synthetic
path); cert_to_inputs supplies real values.
`_effective_monthly_co2_factor(monthly_kwh, fuel_code)` translates the
Table 12d monthly cascade into the calculator's annual×factor shape:
effective = Σ(kWh_m × CO2_m) / Σ(kWh_m). Used for the 4 electricity
end-uses (secondary, pumps/fans, lighting, electric shower). Gas end-
uses keep the annual Table 12 factor.
Adds `environmental_section_from_cert(epc) -> EnvironmentalSection`
exposing (261)..(274) line refs.
Worksheet display conventions:
- (265) excludes (264a) — electric shower CO2 contributes to (272)
total but not the "space + water heating" subtotal.
- (273) is rounded to 2 d.p. half-up — the PDF displays with trailing
zeros to 4 d.p. but precision is 2 d.p. throughout.
§12 LINE_ constants added to all 6 fixtures: (261), (262), (263),
(264), (264a), (265), (266), (267), (268), (269), (272), (273),
EI continuous, (274). 000487 (electric shower) has non-zero (264a).
FINAL SCOREBOARD:
- Cascade pins: 684/684 → 768/768 (§7..§12 all closed, 100%)
- e2e SapResult: 66/66 → 72/72 (all CO2 + sap + ecf + fuel cost)
- Wider regression: 1490/1490 PASS — zero failures anywhere
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds `sap_rating_section_from_cert(epc) -> SapRatingSection`. Composes
§1 TFA + §10a (255) total fuel cost via `fuel_cost_section_from_cert`,
then runs the SAP rating equations (`energy_cost_factor`, `sap_rating`,
`sap_rating_integer`).
Pins (256) deflator, (257) ECF, SAP continuous, (258) SAP integer for
all 6 fixtures — 24/24 PASS.
Existing e2e pins on `ecf`, `sap_score_continuous`, `sap_score`
already verified these outputs; cascade pins formalise §11a for the
worksheet-conformance test surface.
Cascade scoreboard: 660/660 → 684/684 (§7..§11a closed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>