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>
Two RR shapes coexist in real-API JSON: cohort certs (6035, 0240,
schema test 21_0_1.json) lodge `room_in_roof_type_1` (RdSAP §3.9.1
Simplified Type 1 — gable lengths only, cascade applies the 2.45 m
default storey height); cert 9501 lodges `room_in_roof_details`
(RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-
ceiling detail). The schema only modelled the Simplified-Type-1
wrapper, so `from_dict` parsed cert 9501's Detailed-RR block as
None and the API mapper built `SapRoomInRoof` with `detailed_
surfaces=None`. The cascade then defaulted to Simplified Type 2
"all elements" (RR floor area × Table 18 col(4) age-B U=2.30) for
the whole RR → roof HLC 149.43 W/K vs worksheet 18.10 (Δ +131.32).
Changes:
- Add `RoomInRoofDetails` dataclass to both schema 21.0.0 and 21.0.1
with the 10 fields the JSON lodges: gable_wall_type_{1,2} +
gable_wall_length_{1,2} + gable_wall_height_{1,2} + flat_ceiling_
length_1 + flat_ceiling_height_1 + flat_ceiling_insulation_
type_1 + flat_ceiling_insulation_thickness_1. `SapRoomInRoof`
gains a sibling `room_in_roof_details` field next to the legacy
`room_in_roof_type_1`; both shapes are now lossless.
- Extract `_api_build_room_in_roof` mapper helper that reads from
whichever block is present and populates
`SapRoomInRoof.detailed_surfaces` from the Detailed-RR block.
Gables route to `gable_wall_external` for flats (top-floor flats
with RR sit at the end of the building, no neighbour above) and
to `gable_wall` (party at U=0.25) otherwise — mirrors the Summary
mapper's `_map_elmhurst_rir_surface` heuristic.
- Replace both inline `SapRoomInRoof(...)` builds in
`from_rdsap_schema_21_0_0` and `from_rdsap_schema_21_0_1` with
the helper.
Effect on cert 9501 API path:
- roof HLC 149.43 → 18.10 (= worksheet 18.10 exact)
- walls HLC 168.74 → 218.81 (= worksheet 218.81 exact)
- (37) total HLC 382.19 → 297.54 (worksheet 296.68; Δ +0.86)
- sap_continuous still -9.27 vs worksheet because TFA on the API
path is still 81.28 (missing the 31.8 m² RR floor area) — next
slice closes that.
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>
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>
Schema-21.0.0/0.1's SapRoomInRoof dataclass declared only floor_area
and construction_age_band. Real certs lodge gable wall lengths under
sap_room_in_roof.room_in_roof_type_1 (RdSAP §3.9.1 Simplified Type 1).
from_dict silently dropped the whole block at deserialization, so the
mapper never had a chance to surface the lengths on EpcPropertyData.
Fix: add RoomInRoofType1 dataclass to both schema-21 variants;
extend SapRoomInRoof with `room_in_roof_type_1: Optional[...]`;
update the mapper to populate EpcPropertyData.SapRoomInRoof
gable_1_length_m / gable_2_length_m from the new field.
Calculator behaviour unchanged this slice: heat_transmission.py:243
requires BOTH length AND height to contribute gable area, and the
cert lodges length only (RdSAP §3.9.1 uses a default 2.45 m storey
height — not yet plumbed). Cert 0240's −12 SAP residual unchanged.
Schema scope: both 21.0.0 and 21.0.1 schemas (identical SapBuildingPart
mapper code, kept consistent). Older schemas (17/18/19/20) don't carry
this RR shape on their dataclasses and are out of scope per the prior
cohort scope decision.
Unblocks the follow-up slices that close the RR cascade: default
H_gable in calculator or mapper, parse "Roof room(s), insulated
(assumed)" description for the U-value override, etc.
930/930 Elmhurst cascade green. 14/14 golden cohort green at pinned
residuals (no shift, as expected). 76/76 mapper tests green.
Pyright net-zero (32 errors before and after).
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>
15 new features wired through schema -> domain -> mapper -> transform:
Main Dwelling fabric (11):
- wall_insulation_type, wall_insulation_thickness_mm, wall_dry_lined,
wall_thickness_mm, party_wall_construction
- roof_insulation_location, roof_insulation_thickness_mm
- floor_construction, floor_insulation, floor_insulation_thickness_mm,
floor_heat_loss
Dwelling-level scalars (4):
- multiple_glazed_proportion, number_baths, number_baths_wwhrs,
extract_fans_count
Thickness strings like '50mm'/'NI'/'ND' parsed via _parse_thickness_mm; NI
(no insulation) lands as 0mm so the model sees the physical zero rather than
a missing value. Categorical sentinels ('NA'/'NI'/'ND') become None.
Also fixed long-standing typo `multiple_glazed_propertion` -> `_proportion`
in domain dataclass + its lone DB-model usage.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Currently fails on SapWindow.glazing_gap (first of ~30 fields the dataclass
incorrectly treats as required). Will go GREEN once 14j sweeps Optional.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP10 EPCs with measured PV carry photovoltaic_supply as a nested
list of arrays (peak_power, pitch, orientation, overshading) rather
than the legacy unmeasured wrapper {none_or_no_details:
{percent_roof_area: N}}. The schema-21 dataclasses now accept both
shapes via Union[PhotovoltaicSupply, List[List[PhotovoltaicArray]]],
and from_dict._coerce now dispatches list values onto list type
variants of multi-type Unions.
EpcPropertyData.SapEnergySource gains
photovoltaic_arrays: Optional[List[PhotovoltaicArray]] — populated
when the measured shape is present, otherwise None. The legacy
photovoltaic_supply field is preserved for the fallback case.
Both schema-21.0.0 and 21.0.1 mappers dispatch via the new
_map_schema_21_pv helper.
Unblocks Slice 11 (PV feature aggregation in EpcMlTransform).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>