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 0380 (ASHP semi-detached bungalow, worksheet SAP 88.5104)
lodges glazing_type=14 on all windows. The worksheet uses U=1.3258
(post-curtain) for line (27), back-calculating to a raw U=1.40 —
the SAP10.2 Table 24 row for "Double or triple glazed, 2022 or
later" (England/Wales 2022+ / Scotland 2023+ / NI 2022+). Without
code 14 in `_API_GLAZING_TYPE_TO_TRANSMISSION` the cascade falls
back to `u_window`'s default (~U=2.50 post-curtain), inflating
windows HLC by 5 W/K on cert 0380 (6.80 → 11.68).
Added `14: (1.4, 0.72, 0.70)` — same U/g/frame as code 13. Codes
13 and 14 are schema siblings within the post-2022 product family
(the cert lodgement integer differentiates between DG and TG
sealed-unit variants but Table 24 collapses them to the same row).
Effect on cert 0380 API path:
- windows HLC 11.68 → 6.80 (= worksheet 6.80 exact)
- (37) total HLC 104.22 → 99.34 (worksheet 96.09; Δ +3.25 left
on walls — next slice closes it)
- sap_continuous 86.82 → 87.62 (Δ -1.69 → -0.89; closer to
worksheet 88.51)
No golden cert residuals shifted (cohort + 9501 don't lodge
glazing_type=14).
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>
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>
`EpcPropertyData.PhotovoltaicArray.pitch` is the RdSAP 10 §11.1
integer code (1=0°, 2=30°, 3=45°, 4=60°, 5=90°) — NOT degrees. The
cascade's `cert_to_inputs._PV_PITCH_DEG_BY_CODE` reads the code, not
the value. Slice 99d's mapper passed the raw degrees (45) directly,
which fell through to the default 30° lookup (Appendix U3.3 S(SW,
30°) ≈ 1029 kWh/m²/yr vs S(SW, 45°) ≈ 1004 — 2.5% over-credit on
the PV generation, manifesting as -£6.27 over-credit on total cost
→ +0.23 SAP delta).
Added `_elmhurst_pv_pitch_code` helper that maps the lodged degrees
to the nearest tabulated code (snap-to-nearest fallback for non-
tabulated tilts; defaults to code 2 / 30° per the cascade's own
`_PV_PITCH_DEG_DEFAULT`).
Effect on cert 9501 Summary path:
- pv_export_credit £256.30 → £250.02 (= worksheet 250.02 exact)
- total_fuel_cost £842.94 → £849.21 (= worksheet 849.21 exact)
- sap_continuous 68.7577 → **68.5252** (= worksheet 68.5252 exact;
Δ -0.0000 at 1e-4)
`test_summary_9501_full_chain_sap_matches_worksheet_pdf_exactly`
added — the second flat-shaped cert pinned to worksheet SAP at 1e-4
after the cert 0330 / 001479 boiler-house chain tests. Third boiler
validation cert closed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cert 9501 lodges measured PV: 2.36 kWp South-West, 45° pitch, "None
Or Little" overshading. The worksheet's §10a credit (-250.02 GBP =
PV used in dwelling £-129.49 + PV exported £-120.53) depends on the
Appendix M / Appendix U3.3 cascade reading these from
`SapEnergySource.photovoltaic_arrays`. The prior extractor only
captured the `photovoltaic_panel: "Panel details"` label — the
actual kW / orientation / elevation / overshading were silently
dropped, so the cascade computed total cost ~£250 too high → ECF
2.92 vs worksheet 2.26 → SAP 59.26 vs 68.53 (Δ -9.27).
Changes:
- Extend `surveys.elmhurst_site_notes.Renewables` with 4 new
optional fields: pv_peak_power_kw / pv_orientation /
pv_elevation_deg / pv_overshading.
- Add `ElmhurstSiteNotesExtractor._extract_pv_array_detail` —
anchors on "Photovoltaic panel details" then reads the 4
consecutive value lines (kWp, orientation, elevation, overshading).
- Add `_elmhurst_pv_arrays` mapper helper to build the
`[PhotovoltaicArray(...)]` list when all 4 values are present;
return None for the "PV absent" path the cascade already handles.
- Add `_ELMHURST_PV_OVERSHADING_TO_RDSAP` map: "None Or Little" → 1
(ZPV=1.0 per cert_to_inputs._PV_OVERSHADING_FACTOR), "Modest" →
2, "Significant" → 3, "Heavy" → 4. RdSAP omits SAP10.2 Table M1's
5th "Severe" bucket.
- Wire `photovoltaic_arrays=_elmhurst_pv_arrays(survey.renewables)`
into `from_elmhurst_site_notes`'s `SapEnergySource(...)` call.
Effect on cert 9501 Summary path:
- sap_continuous 59.2585 → 68.7577 (target 68.5252; Δ +0.23)
- total_fuel_cost £1099 → £843 (worksheet £849; -£6 over-credit)
- ECF 2.92 → 2.24 (worksheet 2.26; -0.02 over-credit)
The remaining +0.23 SAP / +£6 cost drift is a precision gap in the
Appendix M cost-offset cascade for measured PV (not a missing-data
gap); next slice closes it to 1e-4.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cert 9501 worksheet line (29a) lodges both RR gable walls (13.50 +
15.95 m²) as EXTERNAL walls at U=1.7 (the main-wall U for age B
Solid Brick), contributing +50.07 W/K on top of the 168.74 W/K main-
wall HLC for a (29a) total of 218.81 W/K. Two mapper gaps blocked
this:
1. The Summary mapper defaulted un-typed RR gable walls
(`surface.gable_type=None`) to `gable_wall` (party, U=0.25 per
RdSAP Table 4 row 2). For flats with RR — top-floor dwellings
that sit at the end of a building block with no neighbour above
— the gable walls are exposed external, not party. Threading
`is_flat=property_type.lower()=='flat'` through
`_map_elmhurst_building_parts` → `_map_elmhurst_room_in_roof` →
`_map_elmhurst_rir_surface` switches the default for un-typed
gables on flats to `gable_wall_external` (cascade falls through
to main-wall U `uw`).
2. The Elmhurst wall-construction code map was missing "SO Solid
Brick" (newer Elmhurst PDF variant; the cohort certs lodge "SB
Solid Brick"). Cert 9501's main wall fell through to
wall_construction=None → cascade uw=1.5 (Table-18 unknown-cons
age-B default) instead of 1.7 (Table-18 solid-brick age-B).
Added "SO": 3 alongside "SB": 3 — same SAP10 mapping.
Joint effect on cert 9501 Summary path:
- walls HLC 148.89 → 218.81 (exact worksheet match)
- party_walls HLC 7.36 → 0.00 (gables no longer route to party)
- (37) total HLC 229.71 → 296.68 (exact worksheet match)
Cohort regression check: 259/0 mapper-chain + extractor + golden
tests pass. Houses keep the historical un-typed-gable → party
default. Houses lodging "SO" instead of "SB" now also pick up the
correct solid-brick U-value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For flats, `EpcPropertyData.dwelling_type` needs a "Top-floor" /
"Mid-floor" / "Ground-floor" prefix so the cascade's
`_dwelling_exposure` (cert_to_inputs.py) gates floor + roof party-
surface routing correctly per RdSAP 10 §5. Before Slice 99a, the
broken `built_form` ("2.0 Number of Storeys:") meant cert 9501's
`dwelling_type` was "2.0 Number of Storeys: flat" — never matched
any flat-prefix in the cascade, so the cert was treated as a fully-
exposed dwelling (worksheet had floor U=0 / party-ceiling-down, but
cascade routed both as exposed → Δ +9.25 W/K on floor alone). After
99a's empty-attachment fix the prefix was just " flat" — still no
match.
Slice 99b composes the position prefix from the Summary's lodged
floor location + RR presence:
- floor.location lodges "dwelling below" → floor is party
- + RR present → Top-floor (roof exposed)
- + no RR → Mid-floor (roof party)
- floor.location doesn't lodge dwelling below → Ground-floor
For cert 9501: floor.location="A Another dwelling below" + RR
present (cert lodges Room-in-Roof with gable walls + flat ceiling).
Resulting `dwelling_type` = "Top-floor flat" — matches the cascade's
`_dwelling_exposure` "top-floor" prefix → has_exposed_floor=False,
has_exposed_roof=True, the worksheet's exposure shape.
Houses keep the historical contract: `f"{built_form}
{property_type.lower()}"` — cohort hand-builts and the 2 boiler
chain tests (001479 + 0330) unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cert 9501 (Summary_000784.pdf) is a flat. The Elmhurst Summary's
§1.0 "Property type" section lodges the built-form descriptor
("M Mid-Terrace", "D Detached", ...) only for houses — flats have no
attachment line, and the §2.0 "Number of Storeys" header follows
immediately after the "F Flat" property-type value.
The extractor's prior `_extract_attachment` regex captured the line
right after the property-type value unconditionally, so cert 9501
ended up with `attachment="2.0 Number of Storeys:"` — section-header
noise that the mapper surfaced on `EpcPropertyData.built_form`.
Downstream, this broke the cascade's `_dwelling_exposure` routing
(no prefix match → defaulted to fully-exposed houses) and so the
cert 9501 Summary path was Δ -5.25 SAP vs worksheet 68.5252.
Detect section-header noise via the leading `<digit>.<digit> `
pattern and the "Number of Storeys" substring; return "" in that
case so flats produce empty `built_form`. Houses still pick up their
real attachment (cohort 0330's "M Mid-Terrace" remains correct).
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>
Orientation for the next chat picking up the two open fronts after the
ara_first_run rebuild shipped:
- where things stand (merged to main via per-cert; branch/worktree layout;
PRs into per-cert), authoritative ADRs/CONTEXT to read,
- current architecture + key files (post baseline→property_baseline /
FirstRun→AraFirstRun rename),
- conventions + gotchas (TDD, ephemeral PG, FakeUnitOfWork, pyright noise to
ignore, gh-credential push workaround),
- Task 1: wire Sap10Calculator into PropertyBaselineOrchestrator (Calculated
SAP10 Performance as a third value-set; failure-posture decision),
- Task 2: Modelling (stubs to build out; MaterialsRepository naming open;
needs a UoW when writing Plans),
- the raising/no-op seams not to mistake for done,
- known doc drift flagged (CONTEXT term vs PropertyBaselinePerformance class;
stale domain/sap/ path → domain/sap10_calculator).
Also banners ara_backend_design.md as superseded (architecture) by ADR-0011/0012.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 4a (PDF p.163-164) heat-pump rows split efficiency into
two columns — "space" and "water":
Code System space water
211 Ground source HP with flow temp <= 35°C 230 170
213 Water source HP with flow temp <= 35°C 230 170
215 Gas-fired GSHP with flow temp <= 35°C 120 84
216 Gas-fired WSHP with flow temp <= 35°C 120 84
217 Gas-fired ASHP with flow temp <= 35°C 110 77
521 Warm-air electric GSHP 230 170
523 Warm-air electric WSHP 230 170
525 Warm-air gas-fired GSHP 120 84
526 Warm-air gas-fired WSHP 120 84
527 Warm-air gas-fired ASHP 110 77
The split reflects real physics: heat pumps lose efficiency raising
water to ~55°C DHW temperatures vs ~35°C space-heating flow. ASHP
"in other cases" (codes 214, 221, 223, 224) and the "other cases"
gas-fired rows (225-227) have space == water = 170 / 84 / 77 — no
distinct DHW column.
Pre-slice the cascade routed WHC ∈ {901, 902, 914} ("HW from main
heating") through `seasonal_efficiency(main_code)`, which only consults
the Space column. For SAP code 211 the cascade returned 2.30 (= space)
when the spec requires 1.70 (= water). HW fuel kWh undercounted by
26% on the heating-systems corpus gshp variant: cascade 841.47 kWh vs
worksheet 1138.46 kWh.
New `_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY` dict (10 codes where Space
≠ Water) consulted in `_water_efficiency_with_category_inherit` before
falling through to the existing `seasonal_efficiency` path. Codes
where Space == Water keep the legacy inheritance — no behaviour
change. Non-HP main heating (boilers, storage heaters) likewise
unchanged.
Closures (gshp variant — SAP code 211 + WHC=901 + cylinder):
HW fuel kWh: 841.47 → 1138.45 (matches worksheet 1138.46)
ΔSAP_c: +0.9373 → -0.0178
Δcost: -£21.60 → +£0.41
ΔCO2: -34.98 → +7.06 kg/yr
ΔPE: -418.92 → +33.52 kWh/yr
No regressions on 40 other corpus variants — gshp is the only fixture
that lodges a heat-pump code with diverging Space/Water columns.
Cohort-1 ASHP closure (S0380.28 reciprocal interpolation) is unaffected
because that path runs through `heat_pump_record` PCDB Appendix N3
when a PCDB Table 362 record is lodged; this fix is the Table 4a
fallback for cases without a PCDB record.
Extended handover suite: 899 pass / 0 fail. Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Aligns the composition with its entry point (the `ara_first_run` lambda +
`AraFirstRunTriggerBody`): clearer what the file does.
- orchestration/first_run_pipeline.py → ara_first_run_pipeline.py
- FirstRunPipeline → AraFirstRunPipeline; FirstRunCommand → AraFirstRunCommand
- test files renamed to match
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`property` is an FE-owned table the backend only ever reads — every row read
carries an id — so the autoincrement-PK `Optional[int]` idiom doesn't apply
here. Make it `int` and drop the now-redundant None guard in get_many.
(Contrast: solar_table keeps Optional id — the backend DOES insert those, so
id is genuinely None pre-flush.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the stored units explicit on the property_baseline_performance columns:
- `*_co2_emissions` → `*_co2_emissions_t_per_yr` (tonnes CO₂/yr, whole dwelling)
- `*_primary_energy_intensity` → `*_primary_energy_intensity_kwh_per_m2_yr`
Column names only; the domain `Performance` VO stays unit-suffix-free (units are
a storage concern, mapped in from_domain/to_domain). Migration doc updated.
Round-trip stays green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 §12.4.4 (PDF p.36-37):
"Independent boilers that provide domestic hot water usually do so
throughout the year. With open fire back boilers or closed room
heaters with boilers, an alternative system (electric immersion)
may be provided for heating water in summer. In that case water
heating is provided by the boiler for months October to May and by
the alternative system for months June to September."
Scope is verbatim Table 4a codes 156 (Open fire with back boiler to
radiators) and 158 (Closed room heater with boiler to radiators). Range
cooker boilers (160, 161), pellet stoves with boilers (159), and
independent solid-fuel boilers (151, 153, 155) are NOT covered.
Pre-slice, the cascade treated the back-boiler cohort identically to
year-round solid-fuel mains: (59)m primary loss applied Jun-Sep, HW
fuel kWh was billed entirely at the boiler's solid-fuel rate, the HW
CO2 / PE factors used the boiler fuel's annual factor, and the off-peak
electric standing charge (£40 for 18-hour tariff) was not added because
the cert's lodged water-heating fuel code was anthracite.
Implementation (4 wired pieces):
1. `_section_12_4_4_summer_immersion_applies(epc, main)` — predicate
gate keyed on back-boiler SAP code (156, 158) + WHC ∈ {901, 902, 914}
"HW from main heating" + cylinder present.
2. `_primary_loss_override` zeroes (59)m for Jun-Sep when the predicate
fires — matches the Elmhurst P960 worksheet which has (59) Jun-Sep =
0 for SF2 (vs ~42 kWh/month for SF3 range cooker).
3. `_section_12_4_4_hw_blend(...)` — returns the 5-tuple
(annual_hw_fuel_kwh, blended_cost_gbp_per_kwh, blended_co2_factor,
blended_pe_factor, extra_standing_charge_gbp). The blend is kWh-
weighted across:
- Winter Oct-May: boiler fuel at the boiler's Table 32 unit price /
Table 12 annual CO2 / Table 12 annual PE factor
- Summer Jun-Sep: standard electricity (Table 12d/12e monthly
factors weighted by summer (62)m demand) priced at the tariff's
off-peak low rate per Table 13 note 2 (the 6.8 - 0.036V × N -
0.105V dual-immersion formula clamps to zero high-rate for
normal V/N combos on tariffs with ≥18 hrs low rate; SF2 has
V=110, N≈2 → 100% low-rate)
- The Table 32 off-peak electric standing charge that fires when
hot water uses off-peak electricity per Table 12 note (a). For
EIGHTEEN_HOUR tariff this is Table 32 code 38 = £40.
4. Orchestrator (`cert_to_inputs`) resolves the blend once and overrides
`hot_water_kwh_per_yr`, `hot_water_fuel_cost_gbp_per_kwh`,
`hot_water_co2_factor_kg_per_kwh`, `hot_water_primary_factor`, and
`standing_charges_gbp` when the predicate fires. Other certs fall
back to the existing single-fuel HW helpers (no behaviour change).
Worksheet evidence (heating-systems corpus property 001431 SF2 — code
158 + WHC=901 + cylinder thermostat + 18-hour tariff):
- (62) Oct-May = 2205.80 kWh, Jun-Sep = 684.55 kWh
- (217)m = 65 winter / 100 summer, (219) = 3393.5 anthr + 684.55 elec
= 4078.06 fuel kWh
- (247) HW cost = 4078.06 × 4.27 p/kWh blended = £174.25
- (251) Standing = £40 (off-peak electric standing only — solid fuel
has no standing charge)
- (255) Total = £801.13
Closures (SF2):
ΔSAP_c +1.86 → -0.0000 (EXACT)
Δcost -£42.84 → -£0.00 (EXACT)
ΔCO2 +346.87 → -93.10 kg/yr (residual: Elmhurst CO2 blend uses a
different summer-month weighting that
the SAP 10.2 Table 12d cascade does
not reproduce — spec-correct per
Table 12d header).
ΔPE -605.76 → -1027.51 kWh/yr (same spec-vs-Elmhurst PE blend
artifact via Table 12e monthly
cascade).
No regressions: 40/41 corpus variants unchanged (gate is narrow by SAP
code 156/158). Extended handover suite 898 pass / 0 fail. Pyright net-
zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) provides three primary-loss rows keyed off
the DHW timing arrangement, the middle row giving winter h=5 / summer
h=3 for "Cylinder thermostat, water heating NOT separately timed".
Solid-fuel boiler systems (Table 4a codes 151-161 — independent boilers,
open-fire + back boilers, closed room heaters with boilers, range cooker
boilers, stoves with boilers) do not ship with dual programmers. Per
SAP 10.2 §9.2.4 (PDF p.27) these are "independent solid fuel boilers,
open fires with a back boiler and room heaters with a boiler" — the
appliance itself is the timer. DHW timing follows the burn schedule,
not a separate cylinder programmer, so the middle Table 3 row applies.
Pre-slice `_separately_timed_dhw` returned True for any cylinder +
non-electric HW fuel cert (the S0380.140 gate), routing solid-fuel
boilers through h=3 year-round (the third row, "Cylinder thermostat,
water heating separately timed"). That under-counted winter (59)m
by ~21 kWh/month × 8 winter months across the affected cohort, with
the under-counted water-heating gain propagating into MIT / SH / SAP.
New gate: `sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES`
(frozenset of {151, 153, 155, 156, 158, 159, 160, 161}) — added before
the existing cylinder-present fallback. The post-S0380.140 electric-
immersion / heat-pump / no-main branches are unchanged. Table 4b
liquid-fuel boilers (101-141) keep the True default — modern gas/oil
installations standardly include dual programmers and the worksheet
confirms `oil 1` / `oil pcdb 1..3` / `pcdb 1` are pinned exact at
h=3 year-round.
Worksheet evidence (heating-systems corpus property 001431):
- solid fuel 3 (SAP code 160 range cooker boiler + WHC=901
cylinder thermostat): worksheet (59)m winter = 64.58 (h=5, p=0)
and summer = 41.92 / 43.31 (h=3, p=0). Cascade closes ΔSAP +0.30
→ −0.0000, Δcost −£6.84 → −0.00, ΔPE −214 → −0.00 (4-metric exact).
- solid fuel 2 (SAP code 158 closed room heater + back boiler):
same Table 3 fix narrows ΔSAP +2.06 → +1.86. Remaining ~1.86 SAP
is the SAP 10.2 §12.4.4 immersion-in-summer rule for back-boilers
(codes 156, 158) — the worksheet has summer (59)m = 0 because the
Elmhurst P960 lodges `Summer Immersion: Yes` + the spec routes
Jun-Sep HW through an electric immersion at η=100%. That's a
bigger lift (monthly HW efficiency + fuel-split plumbing) and is
a follow-up slice.
Other corpus variants: no impact (verified via cohort sweep). The
gate is narrow by SAP code so only the 2 affected variants move.
Extended handover suite: 897 pass / 0 fail (+1 from new AAA test).
Pyright net-zero (43 → 43, transient +1 fixed via `EpcPropertyData`
import on the new test's `_cylinder_epc_for` return annotation).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three slices closed:
- S0380.150 18-hour tariff for pumps+lighting (§12 + App F2)
- S0380.151 RdSAP 10 §4.1 Table 5 extract-fans default
- S0380.152 Table 3 primary loss for solid-fuel back-boilers
Cluster A closed; Cluster B partial (SF3 done, SF2 partial); Cluster
C open. Σ|ΔSAP| 14.5 → 6.4 across the 25 cascade-OK cohort variants.
Mid-session pivot documented: my Cluster B hypothesis was wrong
(Table 9c step 12), the actual gap was Table 3 primary loss for
solid-fuel boilers. Discipline added: dump per-line worksheet data
before forming a spec hypothesis.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss" verbatim:
"Primary circuit loss applies when hot water is heated by a heat
generator (e.g. boiler) connected to a hot water storage vessel
via insulated or uninsulated pipes (the primary pipework)."
The spec rule does NOT restrict to Table 4b gas/oil boilers — any
boiler connected to a cylinder via primary pipework incurs the loss.
The cert's `water_heating_code` is the discriminator:
- WHC=901/902/914 (HW from main heating system) + wet boiler +
cylinder → primary loss applies (back-boiler / wet boiler heats
cylinder via primary loop).
- WHC=903 (HW from a separate electric immersion / secondary) → no
primary loss even when the main is a wet boiler.
Pre-slice `_primary_loss_applies` only covered Table 4b gas/oil boiler
codes (101-141). Table 4a solid-fuel boiler codes 151-161 (manual /
auto / range-cooker boilers, closed room heater + back-boiler, open
fire + back-boiler, wood pellet + back-boiler) fell through and
primary loss silently went to zero — under-counting §5 (72) water-
heating internal gain by ~74 W cohort-wide for every WHC=901 solid-
fuel back-boiler variant.
Worksheet evidence on the 001431 corpus (all age G, same cylinder):
- solid fuel 2 (code 158, WHC=901): ws (59) ≈ 505 kWh/yr → apply
- solid fuel 3 (code 160, WHC=901): ws (59) ≈ 643 kWh/yr → apply
- solid fuel 5 (code 153, WHC=903): ws (59) = 0 → skip
- solid fuel 4..11 (633/636 non-boilers, WHC=903): skip
The fix:
- `_primary_loss_applies(...)` gains a `water_heating_code: Optional[int]`
parameter (default None for back-compat with synthetic tests).
- New branch after the Table 4b fallback: `_is_wet_boiler_main(main)`
+ `water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES` → True.
- Call site `_primary_loss_override` passes
`epc.sap_heating.water_heating_code`.
Heating-systems corpus impact:
- solid fuel 3 (code 160, WHC=901): +1.31 → +0.30 SAP
PE -918.6 → -214.3 kWh/yr
- solid fuel 2 (code 158, WHC=901): +2.77 → +2.06 SAP
PE -1241.7 → -754.1 kWh/yr
- All other variants: unchanged
SF2 doesn't fully close because the worksheet's (59) is winter-only
(0 in summer) but the cascade applies the year-round Table 3 formula
via `_separately_timed_dhw=True` (cylinder + non-electric HW fuel).
Remaining residual is a follow-up — likely a
`_separately_timed_dhw=False` rule for solid-fuel back-boilers (HW
timing tied to the room fire, not separately programmed).
Pyright net-zero (43 → 43). Extended handover suite: 895 → 896 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RdSAP 10 Specification §4.1 Table 5 "Ventilation parameters" (PDF p.28)
verbatim — "Extract fans" entry:
• Number of extract fans if known
• If number is unknown:
Not park home:
Age bands A to E all cases → 0
Age bands F to G all cases → 1
Age bands H to M up to 2 hab. rooms → 1
3 to 5 hab. rooms → 2
6 to 8 hab. rooms → 3
more than 8 hab. rooms → 4
Park home:
Age band F all cases → 0
Age bands G onwards all cases → 2
The Elmhurst Summary §12.0 renders "No. of intermittent extract fans: 0"
as the form for *unknown*; every other §2 chimney/flue line item follows
"number if known, or 0 if not present" and the cascade trusts the lodged
value verbatim. Only extract fans have a non-zero age-band default.
Pre-slice the cascade read the lodged 0 verbatim → cohort-wide -0.044
ACH ventilation deficit (= -2.6 W/K HLC, = -1.2% SH demand, = ~-0.3 SAP
per variant). All 25 cascade-OK corpus variants are age G + 4 habitable
rooms + not park home → Table 5 default = 1 fan.
New helper `_rdsap_extract_fans_default(age_band, habitable_rooms, *,
is_park_home)` + wiring in `ventilation_from_cert` applies
`max(lodged, table_5_default)` so the spec minimum fires when lodging
is below it.
Heating-systems corpus impact (25 cascade-OK variants):
oil 1, oil pcdb 1/2/3 +0.27..+0.29 → EXACT (<1e-4)
electric 1, solid fuel 5/6/7/8 +0.28..+0.43 → EXACT
pcdb 1, ashp +0.41 / +0.18 → ±0.02
electric 3/6/7/8/9, sf 4/9/10/11 +0.39..+0.60 → +0.08..+0.12
electric 5 -0.74 → -1.18 (Cluster B over-shoot)
electric 2 -0.24 → -0.46 (Cluster C HW gap)
gshp +1.09 → +0.94 (Cluster C HW gap)
solid fuel 2/3 +3.08 / +1.76 → +2.77 / +1.31
Cluster A (cohort-wide HLC deficit) is closed. The four remaining open
fronts (Clusters B + C) are now visible without offsetting bugs:
- Cluster B (Table 9c step 12 R sign): electric 5, solid fuel 2/3
- Cluster C (HW kWh cascade): gshp + electric 2 (Appendix N3)
solid fuel 2/3 (Table 4b HW efficiency)
Golden-fixture re-pins:
cert 0240 (age J, TFA 118): PE +2.18 → +5.80, CO2 +0.13 → +0.32
cert 0390-2954 (age F, TFA 360): PE -28.27 → -27.97, CO2 -2.74 → -2.71
Pyright net-zero (44 → 44). Extended handover suite: 893 → 895 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 §12 (PDF p.45 lines 2280-2283):
"The 18-hour tariff is only for use with electric CPSUs with
sufficient energy storage to provide space (and possibly water)
heating requirements for 2 hours. Electricity at the low-rate price
is available for 18 hours per day, with interruptions totalling 6
hours per day, with the proviso that no interruption will exceed 2
hours. The low-rate price applies to space and water heating, while
electricity for all other purposes is at the high-rate price."
SAP 10.2 Appendix F2 (PDF p.63 lines 3809-3812):
"F2 Electric CPSUs using 18-hour electricity tariff. The 18-hour
low rate applies to all space heating and water heating provided
by the CPSU. The CPSU must have sufficient energy stored to provide
heating during a 2-hour shut-off period. The 18-hour high rate
applies to all other electricity uses."
Table 12a Grid 2 omits 18-hour / 24-hour from its 7-hour / 10-hour
table; pre-slice the cascade's `_other_fuel_cost_gbp_per_kwh` fell
through Grid 2's `NotImplementedError` to
`prices.standard_electricity_p_per_kwh` (Table 32 code 30 = 13.19
p/kWh). Per §12 + Appendix F2 the 18-hour rule is explicit fraction =
1.0 at the high rate — pumps, fans, and lighting bill at the 18-hour
high rate (Table 32 code 38 = 13.67 p/kWh).
All 41 heating-systems corpus variants lodge `meter_type='18 Hour'`,
so this gap was cohort-wide. Pre-slice the cascade undercounted
pumps + lighting cost by (13.67 − 13.19) × kWh on every variant:
oil 1 Δcost -£9.31 → -£6.69 (closed £2.62, pumps 265 +
lighting 282 × £0.0048)
oil pcdb 1/2 Δcost -£8.32 → -£6.29 (closed £2.03)
oil pcdb 3 Δcost -£8.91 → -£6.29 (closed £2.62)
pcdb 1 Δcost -£11.10 → -£9.07 (closed £2.03)
ashp Δcost -£5.57 → -£4.22 (closed £1.35, lighting only)
electric 1..9 Δcost shift ~ -£1.35..+£1.35 (lighting only;
storage / room-heater
certs carry pumps_fans
= 0)
solid fuel 4..11 Δcost ~ -£1.55 (lighting only)
gshp Δcost -£26.48 → -£25.12 (closed £1.35)
Pyright net-zero (43 → 43). Extended handover suite: 892 → 893 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the four slices that closed the oil-cohort Table 4f gap:
.146 primary loss for Table 4b regular boilers, .147 Eq D1 for
non-PCDB Table 4b, .148 liquid fuel boiler aux 100 kWh, .149
per-pump-age circulation + wet-boiler gate.
Documents the cohort-wide ~-£10/yr cost residual that S0380.149's
spec correctness exposed — the new next-slice front. Highlights the
user directive [[feedback-software-no-special-handling]] that
surfaced during S0380.147 and continued to apply through .149.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other
auxiliary uses" row:
Liquid fuel boiler — flue fan and fuel pump 100 kWh/yr c) d)
Note c): "Applies to all liquid fuel boilers that provide main heating,
but not if boiler provides hot water only. Where there are two main
heating systems include two figures from this table."
Pre-slice the cascade's `_table_4f_additive_components` only wired:
- (230a) MEV / MVHR
- (230e) Main 2 gas-boiler flue fan (45 kWh)
- (230g) Solar HW pump
The liquid-fuel sibling row was missing — oil 1 worksheet (230d) and
oil pcdb 3 worksheet (230d) both lodge 100 kWh/yr "oil boiler pump"
that the cascade was silently skipping.
Implementation:
- Add `_LIQUID_FUEL_CODES = frozenset({4, 71, 73, 75, 76})` and new
`is_liquid_fuel_code(fuel_code)` helper in
`domain/sap10_calculator/tables/table_32.py`. Mirror of
`is_electric_fuel_code` — routes through `_to_table_32_code`
normalisation so Elmhurst-derived Table 32 codes (e.g. code 23
= bulk wood pellets, solid) don't collide with API enum codes
(where 23 = B30D community).
- Extend `_table_4f_additive_components` to add 100 kWh for Main 1
when `is_liquid_fuel_code(main.main_fuel_type)` returns True
(`isinstance(int)` guard for the `Union[int, str]` field). Mirror
the same gate for Main 2 per Note c) "Where there are two main
heating systems include two figures".
- LPG is GAS (Table 4b/4f convention, Ecodesign classification) —
`_LIQUID_FUEL_CODES` deliberately excludes 2/3/5/9 LPG codes.
Cascade impact across heating-systems corpus:
| Variant | SAP Δ | Cost Δ | PE Δ |
|-----------|-------------|-------------|-------------|
| oil 1 | +1.18→+0.60 | -£27→-£14 | -276→-124 |
| oil pcdb 1| +0.42→-0.15 | -£10→+£3.4 | -84→+67 |
| oil pcdb 2| +0.42→-0.15 | -£10→+£3.4 | -84→+67 |
| oil pcdb 3| +1.16→+0.59 | -£27→-£14 | -271→-120 |
| pcdb 1 | +0.57→-0.03 | -£13→+£0.6 | -109→+42 |
Cohort closures: pcdb 1 EXACT (-0.03), oil pcdb 1/2 closed to -0.15.
Golden fixtures impact:
- cert 0240 (dual-main oil combi 130): SAP integer 73→72 (resid
+0→-1), PE +1.02→+2.52, CO2 +0.11→+0.14. Dual-main certs add
2 × 100 = 200 kWh aux per Note c). Cert's published SAP 73
suggests the dual-main Q_space split (main_heating_fraction)
may also need wiring — slice candidate.
- cert 0390 (Firebird PCDF 9005 oil combi): PE -28.50→-28.08
(CLOSER to zero), CO2 -2.75→-2.73 (CLOSER to zero), SAP +7
unchanged.
Test:
test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_
100_kwh — asserts oil pcdb 3 inputs.pumps_fans_kwh_per_yr ≥ 230
(130 base + 100 liquid fuel boiler aux).
Extended handover suite: 891 pass, 0 fail. Pyright net-zero (44=44).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the two slices that closed oil 1 from +2.66 → +1.18 SAP via
Table 3 primary-loss extension (.146) + Appendix D §D2.1 (2) Equation
D1 wiring for non-PCDB Table 4b boilers (.147). Highlights the user
directive that surfaced this session ("BRE/Elmhurst software follows
spec exactly; no special non-spec handling") and the resulting pin
shifts on cert 0240 + 6035 (combi-no-cylinder golden fixtures
re-pinned per spec correctness).
Ranks next-slice candidates: oil 1 Table 4f auxiliary energy (~+0.4
SAP closure remaining), electric 5 -1.43 regressed by .145, solid
fuel 2/3 anthracite outliers, community heating + electric storage
unblocking.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Appendix D §D2.1 (2) Equation (D1) (PDF p.57):
If the boiler provides both space and water heating, and the summer
seasonal efficiency is lower than the winter seasonal efficiency,
the efficiency is a combination of winter and summer seasonal
efficiencies according to the relative proportion of heat needed
from the boiler for space and water heating in the month concerned:
Q_space + Q_water
η_water,m = ───────────────────────────────
Q_space/η_winter + Q_water/η_summer
where Q_space (kWh/month) is the quantity calculated at (98c)m
multiplied by (204) or by (205);
Q_water (kWh/month) is the quantity calculated at (64)m;
η_winter and η_summer are the winter and summer seasonal
efficiencies (from Table 4b).
Pre-slice the cascade only wired Eq D1 for PCDB-tested boilers (the
`pcdb_record` branch in `_apply_water_efficiency`). For non-PCDB
Table 4b boilers (`sap_main_heating_code` 101-141) where the cert
lodges no `main_heating_index_number`, the cascade fell through to
the scalar `water_efficiency_pct` divisor — which resolved via WHC
901 inherit to Table 4b WINTER eff (wrong direction; spec wants the
monthly Eq D1 blend).
This slice:
- Adds `domain/sap10_calculator/tables/table_4b.py` with the full
41-row Table 4b (winter, summer) pair dict for codes 101-141
verbatim from SAP 10.2 PDF p.168 (Table 4b).
- Refactors `_apply_water_efficiency` parameter from
`pcdb_record: Optional[GasOilBoilerRecord]` to
`eq_d1_winter_summer_pct: Optional[tuple[float, float]]` —
decouples the Eq D1 input from the PCDB record so a Table 4b
fallback can populate it without faking a PCDB record.
- Resolves Eq D1 inputs at the call site with priority order:
1. PCDB Table 105 winter/summer (existing path)
2. SAP 10.2 Table 4b (PDF p.168) winter/summer when PCDB
absent + WHC=901 (`_WHC_FROM_MAIN_HEATING`, the spec form
of "boiler provides both space and water heating").
§9.4.11 -5pp interlock applies symmetrically to both columns of
whichever (winter, summer) tuple is resolved.
Oil 1 cert worksheet (217)m verified Jan 81.83 / Apr 81.42 / May
79.94 / Jun-Sep 72.00 / Dec 81.86 — exact back-solve to Eq D1 with
Table 4b code 127 (winter 84, summer 72). Annual HW fuel (219) =
Σ (64)m × 100 / (217)m = 3638.99 kWh/yr ≡ cascade post-slice.
Cascade impact:
Heating-systems corpus (worksheet-pinned, oil 1 only on pin grid):
oil 1 SAP +1.76 → +1.18 (Δ -0.59)
cost -£40.60 → -£27.12 (Δ +£13.48)
CO2 -129.22 → -55.36 (Δ +73.86 kg/yr)
PE -590.02 → -275.52 (Δ +314.50 kWh/yr)
Remaining oil 1 residual is Table 4f auxiliary energy (cascade
pumps_fans 130 kWh vs worksheet 265 kWh — missing the oil-boiler
pump 100 kWh + CH pump 130 vs ws 165). Follow-up slice.
Golden fixtures (cert-pinned, integer-rounded PE):
cert 0240 (dual oil combi 130, no cylinder): PE +0.05 → +1.02
cert 6035 (gas combi 104, no cylinder): PE +46.10 → +47.29
Both shifts reflect spec-correct Eq D1 now firing for non-PCDB
combi-no-cylinder configs. The pre-slice near-zero pin on cert
0240 was masking offsetting cascade gaps (likely Table 4f
auxiliary energy and/or dual-main Q_space split per (98c)m ×
(204) which the cascade currently treats as full demand).
Following [[reference-unmapped-sap-code]] discipline, the new Table
4b dict is the canonical spec-source — `domain.sap10_ml.sap_
efficiencies._SPACE_EFF_BY_CODE` still carries the winter column for
the ML feature cascade and is left in place per the sap10_ml
deprecation plan (separate migration).
Test:
test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_
table_4b_boiler_with_cylinder — asserts cert 1431 oil 1 HW fuel
annual = 3638.99 ± 1.0 kWh/yr (matches worksheet (219)).
Extended handover suite: 890 pass, 0 fail. Pyright net-zero (44=44).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss":
"Primary circuit loss applies when hot water is heated by a heat
generator (e.g. boiler) connected to a hot water storage vessel via
insulated or uninsulated pipes (the primary pipework). Primary loss
is set to zero for the following:
Electric immersion heater
Combi boiler ...
CPSU ..."
A Table 4b regular (non-combi, non-CPSU) gas or liquid-fuel boiler
feeding a cylinder is in neither zero-loss list, so primary loss must
apply. Pre-slice the Elmhurst-path fallback in `_primary_loss_applies`
only covered PCDB Table 322 records (S0380.142) — when the cert lodges
a Table 4b code (e.g. oil 1 sap_main_heating_code 127 "Condensing oil
boiler") with no PCDB index and no `main_heating_category` lodgement,
primary loss silently fell through to zero.
This slice extends the Elmhurst-path fallback in `_primary_loss_applies`
to fire when `sap_main_heating_code` is in the Table 4b code range
(101-141) and NOT in the combi/CPSU sub-row exclusion set per Table 3:
Combi codes: 103, 104, 107, 108, 112, 113, 118, 128, 129, 130
CPSU codes: 120, 121, 122, 123
Oil 1 worksheet (59)m daily rate = 1.3972 kWh/day uniform = 14 ×
[0.0245 × 3 + 0.0263] (uninsulated pipework, has cylinder thermostat +
separately timed DHW → h=3 winter & summer per Table 3 split). Annual
sum = 365 × 1.3972 ≈ 510 kWh/yr — matches the worksheet's (59) annual.
Cascade impact on heating-systems corpus:
- oil 1 SAP residual +2.66 → +1.76 (Δ -0.90)
cost -£61.24 → -£40.60 (Δ +£20.64)
CO2 -242.27 → -129.22 (Δ +113.05 kg/yr)
PE -1050.49 → -590.02 (Δ +460.47 kWh/yr)
Only the oil 1 variant moves — every other cascade-OK variant either
already routes primary loss via the PCDB Table 322 branch (oil pcdb 1/
2/3, pcdb 1) or via the boiler-category {1,2} branch. The other oil
codes 124/125/126/131/132 + range-cooker codes 133-141 are gated for
free by the same dispatch when their certs surface in future cohorts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>