A room-in-roof carries its insulation on its own sloping/stud/gable surfaces
(RdSAP 10 §3.10, Table 17/18), which the roof overlay's flat
roof_insulation_thickness bump cannot model. Without a guard a RR with an
uninsulated loft fell through to the loft fallback and mis-recommended 300 mm
loft insulation. Return None when the main part lodges a sap_room_in_roof,
deferring until a dedicated RR branch lands (ADR-0021).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The worksheet build_epc() fixtures wrapped a module-level SECTION_6_VERTICAL_
WINDOWS tuple in list(), so every call returned the SAME SapWindow objects. A
test that mutated a returned window (the glazing slices flip glazing_type to
single) leaked that change into every later build_epc() -- which surfaced as
double_glazing-product failures in the first-run integration tests only when
test_console ran first in the same process.
Deep-copy the windows per call in all six fixtures (000474/477/480/487/490/516)
so each EpcPropertyData owns an independent window graph, and drop the
now-redundant defensive copy at the glazing test's call site.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 4 of the glazing generator (ADR-0022): run recommend_glazing in
_candidate_recommendations, threading the Property's PlanningRestrictions so a
protected dwelling is offered secondary glazing instead of double (mirrors
recommend_solid_wall). Price both Measure Types in the offline catalogue
(double £600/window, secondary £510 -- the legacy 0.85x scaling) and the
contingency table (0.15, the legacy windows_glazing rate); the
_GENERATOR_MEASURE_TYPES forcing test enforces both entries exist.
run_modelling tests pin the wiring end-to-end on an all-single-glazed dwelling:
double when unrestricted, secondary when listed. The first-run integration test
seeds a double_glazing Product because its lodged EPC has a single-glazed
window. _single_glazed_epc() deep-copies build_epc() (which shares its window
objects) so the mutation can't leak into other tests' baselines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 of the glazing generator (ADR-0022): a conservation/listed/heritage
protection (PlanningRestrictions.blocks_external) hard-picks secondary_glazing
instead of double_glazing -- an internal second pane, since the external units
can't be replaced on a protected building. Each single-glazed window upgrades
to the secondary target pinned from cert 001431 (glazing_type=7, u_value=2.90,
solar_transmittance=0.85 -- the outer single pane still drives solar gain).
The before/after cascade pins for both measures remain deferred behind the
glazing-label mapper coverage (owned by another agent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The glazing Recommendation Generator (ADR-0022): detect single-glazed
windows (SAP10.2 Table U2 code 1) and emit one "Windows" Recommendation whose
single Option rewrites every single-glazed window to the double-glazing target
pinned from cert 001431's before->after (glazing_type=5, u_value=1.40,
solar_transmittance=0.72). The overlay writes the per-window U/g into
WindowTransmissionDetails because the calculator consumes those directly.
Priced as a flat per-window average x count. No single-glazed windows -> None.
Planning gate (-> secondary) and the before/after cascade pins land next; the
pins are blocked on glazing-label mapper coverage (owned by another agent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The double_glazing / secondary_glazing Elmhurst before→after Summaries the
glazing generator's cascade pins will use (ADR-0022). NB: these don't parse
yet — cert 001431 lodges several unmapped glazing labels ("Secondary glazing",
"Secondary glazing - Normal emissivity", "Triple pre 2002", truncated
"Double…"/"Triple…" variants) that the mapper must cover first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the glazing generator (ADR-0022). `WindowOverlay` (all-optional
partial of one SapWindow) + `EpcSimulation.windows` keyed by sap_windows index.
The applicator folds it onto sap_windows[i]: glazing_type flat on the window,
u_value/solar_transmittance routed into its WindowTransmissionDetails (created
if absent) — the applicator's first nested write, because that's where the
calculator reads window heat loss and solar gain. Baseline left unmutated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grill-with-docs outcome for the glazing generator. Single planning-picked
Measure (double when unrestricted, secondary for conservation/listed/heritage),
upgrading all single-glazed windows together. The overlay writes lodged U-value
+ solar-g directly into WindowTransmissionDetails (our calculator consumes those
as inputs — it does NOT derive them from glazing_type, unlike Elmhurst) plus
glazing_type for the §5 daylight factor; EpcSimulation gains a per-window
`windows` surface. Priced flat average-per-window × single-glazed count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 4 (ADR-0021). The roof dispatcher can now emit sloping_ceiling_insulation
and flat_roof_insulation, so wire both into contingencies and the sample
catalogue; the forcing-function test now asserts every generator measure type
is both priced and has a contingency rate, so an offline/live run over a
sloping or flat roof never dies on a missing entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 (ADR-0021). The dispatcher gains a flat-roof branch: a "flat"
roof_construction_type with no lodged thickness (uninsulated → None on the
Elmhurst path) gets a single flat_roof_insulation Option whose overlay raises
roof_insulation_thickness to 200 mm — tested before the loft fallback so a flat
roof's None doesn't trip the loft trigger. Pinned against the Elmhurst
before→after cert at 1e-4. Golden cohort roof firing unchanged (none across 57).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2 (ADR-0021). `recommend_roof_insulation` now owns the loft branch as the
fallback — a plain pitched loft, a thatched roof (the covering doesn't block
insulating the loft floor), or an unlodged roof type all take loft (joist)
insulation at 300 mm when `roof_insulation_thickness == 0`. Sloping is tested
first; a no-access roof gets nothing. Retired the standalone
`recommend_loft_insulation`; the orchestrator and its tests now call the
dispatcher.
Pinned: thatch before→after (None→300) reproduces at 1e-4; the existing loft pin
still holds through the dispatcher. Behaviour-preserving on the golden cohort
(roof measure unchanged: none across all 57) — the dispatch is strictly more
precise (won't fire loft on a sloping/no-access roof).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the roof-insulation generator (ADR-0021). New `recommend_roof_insulation`
dispatcher keys on the `roof_construction_type` string: a "sloping ceiling" roof
that is uninsulated (roof_insulation_thickness 0/None) gets a single
`sloping_ceiling_insulation` Option whose overlay raises roof_insulation_thickness
to 100 mm. Pinned against the Elmhurst before→after cert 001431 at 1e-4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grill-with-docs outcome for the roof-insulation generator. One dispatching
recommend_roof_insulation, one Measure per roof by type (loft 300mm incl.
thatch / sloping-ceiling 100mm / flat-roof 200mm; no-access → none),
MAIN-only, room-in-roof deferred. Detection keys on the roof_construction_type
string (populated on both paths; the calculator already dispatches on it) with
sloping→flat→no-access→loft ordering; the roof_construction-int cross-mapper
parity is the follow-up Hestia-Homes/Model#1178. Thatch is not excluded — it
takes loft insulation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RdSAP10 `wall_construction == 6` is canonically WALL_SYSTEM_BUILT, but
the gov-EPC basement heuristic hijacked it: Elmhurst lodges both "SY
System build" and "B Basement wall" as code 6, so a system-built wall
was mis-flagged `main_wall_is_basement` and routed to the RdSAP §5.17
`u_basement_wall` override instead of the system-built U-value table.
System-built stays on its canonical code 6; the basement signal moves
to an explicit `is_basement` (SapAlternativeWall) / `wall_is_basement`
(SapBuildingPart) Optional[bool] flag, set by the Elmhurst mapper from
the distinct "SY"/"B" codes via `_elmhurst_wall_is_basement` (True for
B, False for SY, None otherwise). The `main_wall_is_basement` /
`is_basement_wall` properties honour the flag when set and fall back to
the gov-EPC API code-6 heuristic when None — so the API path (basement
lodged as integer 6, no flag) and the cert 000565 "B" cohort are
unchanged.
Acceptance (a recommendation-summary generator depends on it): a
system-built MAIN wall reports wall_construction == 6 AND
main_wall_is_basement is False; a genuine basement main wall still
reports main_wall_is_basement is True.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) row 1: 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 Table
4a hot-water-only codes (PDF p.166) 911 gas / 912 liquid / 913 solid
boiler-circulator + 921-931 range cooker with boiler are each a heat
generator feeding the cylinder through a primary loop.
`_primary_loss_applies` keyed only off the resolved DHW `main` — but for
these certs `_water_heating_main` returns the SPACE main (e.g. electric
storage heaters, SAP code 402, which has no primary loop), so every
boiler branch missed the gas water-boiler's primary circuit and (59)m
went to zero. New branch keys off `water_heating_code` ∈
`_WATER_HEATING_BOILER_CIRCULATOR_CODES`. 941 (electric HP for water
only) is excluded — HP DHW vessels follow the Table 3 integral-vessel
rules.
Simulated case 19 (electric storage main + WHS 911 + 210 L cylinder):
(62)m total HW demand 2493.30 → 3169.98 kWh/yr, matching the worksheet
(the missing 676.68 kWh/yr = the worksheet's (59) primary-loss annual
sum, h=5/p=0). The remaining (64)/(219) gap is the PV diverter (63b),
deferred to its own slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reference the calculator-side wall_construction=6 disambiguation issue from the
strict-xfail tripwire and ADR-0019, so the blocker is traceable both ways.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pin the resolution reached in the grill: planning status persists as a
per-UPRN write-through cache in the existing `property_details_spatial` table
(not FE-property columns), read back off the Property in Modelling; unknown
UPRN defaults to unrestricted, matching legacy `empty_spatial_df` (superseding
the earlier "conservative stance" note).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
System-built (precast/no-fines concrete) takes both solid-wall Options like
solid brick (ADR-0019), keyed on `wall_construction == 6` (WALL_SYSTEM_BUILT,
Elmhurst `SY`). A basement-suitability guard (`main_wall_is_basement`) is added
since a below-ground basement wall is never EWI/IWI-suitable.
This is currently inert: `B Basement wall` also maps to 6 (mapper.py:2100) and
`main_wall_is_basement` is derived as `wall_construction == 6`, so every code-6
wall reads as basement and is guarded out — the live cohort is unchanged. The
system-built EWI/IWI cascade pin is committed as a strict-xfail tripwire that
flips green the moment the calculator disambiguates system-built from basement
(MAIN wall_construction==6 with main_wall_is_basement False). `wall_construction
== 8` is Park home, not system-built — not keyed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 12a Grid 1 (PDF p.191): secondary heating is a direct-
acting electric room heater (RdSAP 10 §A.2.2 default), on the "Other
systems including direct-acting electric" row — 7-hour high-rate fraction
1.00, 10-hour 0.50. A room heater runs on demand, mostly at the high
rate; it does NOT earn the 100%-low-rate of overnight storage charging.
`_secondary_fuel_cost_gbp_per_kwh` previously returned the flat off-peak
LOW rate (5.50 p, £0.0550) for every off-peak electric secondary, under-
charging by 9.79 p/kWh. New `_secondary_off_peak_rate_gbp_per_kwh` mirrors
`_space_heating_fuel_cost_gbp_per_kwh`: it blends the Table 12a high-rate
fraction (OTHER_DIRECT_ACTING_ELECTRIC) against the Table 32 high/low
rates, with the 18-/24-hour fallback to the low rate.
Simulated case 19 (electric storage main + electric secondary, Dual/7-hour
meter) is the worksheet case (242): "Space heating - secondary
(1.00*15.29 + 0.00*5.50)" → 15.29 p/kWh = £0.1529. This was the primary
cat-7-cluster cost driver: total cost 1485.68 → 1835.53 (worksheet
1816.58), SAP cont 60.11 → 50.67 (worksheet ~51.22). Remaining +19 cost
is HW/space-heating kWh (next slices).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3c.6. The integrating proof through real Postgres: two solid-brick
uninsulated dwellings, identical but for the planning status Ingestion caches
per UPRN. Ingestion writes the spatial reference; Modelling reads it back off
the Property and gates the wall measures — the listed dwelling gets neither
EWI nor IWI, the unrestricted one gets a wall measure. Closes slice 3c
(ADR-0019/ADR-0020).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 2b note b (PDF p.159) applies the ×0.9 temperature-factor
reduction only when DHW is "separately timed" relative to space heating
on a SHARED heat generator ("boiler systems, warm air systems and heat
pump systems"). Per RdSAP 10 §10.5.1 (PDF p.55) a separate boiler/
circulator providing DHW only (water-heating code 911 = "Gas boiler/
circulator for water heating only") is NOT the main space-heating system
— so there is no shared timer to apply the ×0.9 against. `_separately_
timed_dhw` now returns False when water_heating_code is not "from main /
2nd-main system" ({901,902,914}), mirroring the existing WHC 903 electric-
immersion carve-out.
Simulated case 19 (electric storage main SAP 402 + WHS 911 + 210 L
loose-jacket cylinder) is the worksheet case. The single flag drives both:
- (53) Temperature factor: 0.54 → 0.6000 (worksheet base, no ×0.9)
- (55) storage loss/day: → 3.4531; (56)/(57)m Jan → 107.0456 (1e-4)
- (59)m primary loss: h=3 (43.31) → h=5 (Jan 64.5792), worksheet-exact
This also worksheet-pins S0380.224's loose-jacket storage loss magnitude
at 1e-4, previously only direction-validated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3c.5. `PropertyPostgresRepository` takes an injected `SpatialRepository`
and hydrates `Property.planning_restrictions` by UPRN (bulk in `get_many`,
single in `get`). A UPRN with no cached row — or a property with no UPRN —
defaults to unrestricted, matching legacy `empty_spatial_df` (ADR-0020). This
closes the loop: Ingestion caches the protections, Modelling reads them off the
Property to gate solid-wall EWI/IWI (ADR-0019).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3c.4. Ingestion now resolves the whole spatial reference in one lookup
(`spatial_for`) — the coordinates drive the Solar fetch as before, and the
reference (coordinates + planning protections) is persisted per-UPRN via
`uow.spatial` in the same write batch, so Modelling can read the protections
back off the Property (ADR-0020). `_Fetched` carries the UPRN and the reference
into the write phase.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3c.3. Ingestion writes the OS spatial reference cache through the same
unit it persists the EPC/solar enrichments with, so `UnitOfWork` declares a
`spatial` repo, `PostgresUnitOfWork` binds a `SpatialPostgresRepository` to the
session, and `FakeUnitOfWork` gains a `FakeSpatialRepo` (seedable for read
tests, recording writes for ingestion-side assertions).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3c.2. The OS Open-UPRN reference set is too large to host in Postgres, so
it lives in S3 and is cached per-UPRN in the existing `property_details_spatial`
table (ADR-0020). `PropertyDetailsSpatialRow` mirrors that table (uprn unique);
`SpatialRepository` / `SpatialPostgresRepository` upsert one shared row per UPRN
and read the planning protections back by UPRN (a null flag reads as
unrestricted; absent UPRNs are omitted so the caller defaults them).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bump HEAD/next-slice/baseline, note the committed scripts toolkit, and add
the active "simulated case 19" section: the electric-storage-heater +
loose-jacket worksheet the user generated, what S0380.226 unblocked, and
the prioritised cluster bugs it exposed (cost (255) -334 = the +9 SAP
driver; Table 2b TF x0.9; WHS-911 storage-vs-combi routing; fabric +1.0).
Updated the "what to generate" ask to the two highest-value follow-ups
(electric room heaters; Sheltered/Adjacent RR gables).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3c.1. Ingestion will persist a UPRN's coordinates and planning
protections together as a write-through cache, so resolve them in a single
partition read rather than two. `SpatialReference` bundles the coordinates
(which drive the Solar fetch) and the `PlanningRestrictions` (which gate wall
insulation per ADR-0019/ADR-0020); `GeospatialRepository.spatial_for(uprn)`
returns it, and `coordinates_for`/`planning_restrictions_for` now delegate to
the one lookup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Summary-path mapper raised UnmappedElmhurstLabel for a §15.1
"Cylinder Insulation Type: Jacket" lodging — only "Foam" (→1, factory)
was mapped. SAP10 cylinder_insulation_type uses 2 for loose jacket
(matching the GOV.UK API codes), and SAP 10.2 Table 2 Note 1 gives it a
separate ~2× storage-loss factor that the cascade now handles
(S0380.224). Add "Jacket" → 2 for cross-mapper parity with the API path
and so the loose-jacket storage-loss branch fires on the Summary path.
Surfaced by simulated case 19 (a 210 L jacket cylinder + electric storage
heaters), which previously couldn't extract at all. §4 suite 2397 passed;
mapper.py pyright unchanged at 32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three reusable scripts (each with a purpose/usage docstring) for wide-scale
testing of the calculator's API front-end against the GOV.UK EPB register —
the toolkit behind the 1000-cert study (docs/HANDOVER_API_SAMPLE_ACCURACY.md):
fetch_2026_epc_sample.py — sample cert numbers across a date window
(random pages) + download full schema-21 JSON
to a cache; resumable, 429/5xx backoff.
eval_api_sap_accuracy.py — % within 0.5 SAP, error histogram, worst-40,
and the mapper/calculator raise breakdown.
analyse_api_sap_clusters.py — error grouped by property + heating type to
locate clusters (electric heating, flats, PV).
Cache dir defaults to /tmp/epc_2026_sample, overridable via EPC_SAMPLE_CACHE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captures the wide-scale 2026-register study (41.8% <0.5, heating-driven
cluster table), the 7 slices shipped (S0380.219-225), the prioritised
remaining work (electric-heating clusters + worksheet-backed raises), and
the single highest-ROI worksheet to generate: an electric-storage-heater
house with a loose-jacket cylinder + a room-in-roof with Sheltered/
Adjacent gables + an extension — one document that validates the #1
accuracy cluster, pins the S0380.224 loose-jacket fix at 1e-4, closes the
gable_wall_type Table 4 raise, and exercises multi-bp fabric.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3b+3d (ADR-0019/0020). Property gains a planning_restrictions attribute
(default unrestricted); the ModellingOrchestrator threads it from the Property
through _plan_for -> _scored_candidate_groups -> _candidate_recommendations into
recommend_solid_wall, replacing the unrestricted default. run_modelling exposes
a planning_restrictions param so the offline harness can inspect restricted
properties. Integration test: a listed solid-brick dwelling that gets IWI when
unrestricted now yields no wall insulation. 145 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The §10.7 no-water-heating default cylinder raised UnmappedSapCode for
age bands A-F (2 certs in a 2026 sample, bands B + C) because Table 29's
"A to F: 12 mm loose jacket" row wasn't plumbed — the loose-jacket
storage-loss branch didn't exist. S0380.224 added it, so this slice
completes the Table 29 lookup.
Restructure _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE to carry
(cylinder_insulation_type, thickness_mm) per band — A-F → (loose jacket,
12), G/H → (factory, 25), I-M → (factory, 38) per RdSAP 10 Table 29
(PDF p.56) — and have the default read both, setting the loose-jacket
type for A-F instead of hardcoding factory. The strict-raise is retained
only for an absent / out-of-A-M age band (no Table 29 row).
Validated: certs 2211 (band B, SAP 49.8 vs lodged 52) and 3420 (band C,
11.2 vs 11) now compute. §4 + golden suite 2395 passed — the corpus
"no system" cert (age G, 25 mm factory) is unchanged. cert_to_inputs.py
pyright unchanged at 32; new test suppresses reportPrivateUsage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3a (ADR-0020). PlanningRestrictions relocated out of the solid-wall
generator into domain/geospatial/ as the shared, Property-level value object
(three distinct flags + measure-specific blocks_external/blocks_internal).
GeospatialRepository gains a non-abstract planning_restrictions_for defaulting
to None (sources without the flags need not implement it); GeospatialS3Repository
reads conservation_status/is_listed_building/is_heritage_building from the same
Open-UPRN partition as the coordinates (legacy column names — to confirm in the
S3 deep-dive). Shared _row_for helper dedups the partition lookup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`_cylinder_storage_loss_override` returned None for any cylinder whose
cylinder_insulation_type wasn't 1 (factory), so a loose-jacket cylinder
(code 2, RdSAP 10 field 7-11) fell to the cascade's zero-storage-loss
combi/instantaneous default — its real storage loss vanished. SAP 10.2
Table 2 Note 1 gives loose jacket a SEPARATE, ~2× higher loss factor
(L = 0.005 + 1.76/(t+12.8) vs factory 0.005 + 0.55/(t+4)); the
cylinder_storage_loss_factor_table_2 helper already implements it — only
the dispatch was missing.
Fix: a `_cylinder_storage_loss_insulation_label` resolver maps the lodged
code to the Table 2 branch (1 → factory_insulated, 2 → loose_jacket;
None/0/unknown → None, keeping the conservative no-loss default). The
override and the HW storage call now route through it instead of
hardcoding "factory_insulated".
Evidence + validation: a random 2026 register sample has 22 loose-jacket
certs that over-predicted SAP by +2.29 mean (18/22 too high, 1/22 within
0.5) — the exact signature of under-counted HW storage loss. After the
fix their mean error collapses to +0.45 and 11/22 land within 0.5, with
ZERO regression across the worksheet-validated cohort (§4 + golden suite
2394 passed — no validated cert lodges loose jacket, so none shifts).
Also unblocks the §10.7 A-F no-water-heating default (next slice) which
needs the loose-jacket branch. cert_to_inputs.py pyright unchanged at 32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2e. recommend_solid_wall joins the orchestrator's fabric generator pool
(restrictions default unrestricted until slice 3 sources them); the harness
catalogue + contingencies (26%) gain external_wall_insulation /
internal_wall_insulation. run_modelling on an uninsulated solid-brick dwelling
(baseline SAP 36.6) now selects internal wall insulation into the optimised
package; the catalogue-completeness guard covers both new measure types.
Golden cohort 57/57 still error-free; IWI now fires on a real cohort cert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2d. A flat can take IWI (its own unit) but not EWI (whole-block
coordination) — ADR-0019. _is_flat handles both ingestion representations:
the Elmhurst name form ('Flat') and the API stringified RdSAP code ('2' = Flat
per PROPERTY_TYPE_LOOKUP). Completes slice 2's eligibility surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2c. recommend_solid_wall takes a PlanningRestrictions value object
(defaults unrestricted): a conservation area removes the EWI Option (external
appearance), a listed or heritage building removes both EWI and IWI (protected
fabric) -> None when nothing survives (ADR-0019). Plus a guard that a cavity
wall yields no solid-wall Recommendation (it is handled by recommend_cavity
_wall). PlanningRestrictions will be sourced onto the Property from the
geospatial layer in slice 3 (ADR-0020).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5 certs in a 2026 API sample raised `KeyError: 'rr_common_wall_area_m2'`
and were blocked from computing. Root cause: `_part_geometry`'s early
return (taken when a building part lodges no sap_floor_dimensions —
e.g. a party-wall-only or RR-only extension as bp[0]) returned only 6 of
the 9 keys the full return exposes, omitting rr_common_wall_area_m2,
rr_gable_area_m2 and cantilever_floor_area_m2. The §3.9 RR contribution
block reads geom["rr_common_wall_area_m2"] / ["rr_gable_area_m2"] for
EVERY part, so the floorless part's truncated dict raised KeyError at
heat_transmission.py:974.
Fix: the early return now exposes all 9 keys, the three RR/cantilever
geometry values defaulting to 0.0 — correct, since a part with no floor
dimensions has no derivable RR shell or cantilever (no floor area).
Pure contract-completion bug; no spec/U-value change.
Regression test pins the invariant directly: a floorless part's
_part_geometry keys must equal a with-floors part's keys. Validated: all
5 certs now compute (4 within ~2 SAP of lodged; the 5th, 8536, has a
separate residual). §4 suite 2393 passed; heat_transmission.py pyright
unchanged at 12, test file at 71.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2b. Timber frame (wall_construction=5) takes internal wall insulation but
not external (not constructable — ADR-0019), so the generator offers IWI only.
Cascade pin: the IWI Option reproduces the re-lodged timber-frame after at
abs(diff) <= 1e-4 (general Table 6 insulation-thickness bucket, not the solid-
brick documentary path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2a. New recommend_solid_wall emits one Main-wall Recommendation carrying
External + Internal wall-insulation Options for an uninsulated (wall_insulation
_type=4) solid-brick (wall_construction=3) main wall, each priced at the heat-
loss wall area. Cascade pin: the generator's EWI and IWI Options reproduce
their respective re-lodged afters at abs(diff) <= 1e-4.
Detection keys on wall_construction code, not description (ADR-0019 note
corrected): the Elmhurst ingestion path leaves walls[].description empty, so
the code is the only cross-path signal; codes 1-5 are consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
above) → None
The 2026 sample lodges roof_construction=6 (1 cert, "Thatched, with
additional insulation") and =7 (6 certs, "(same dwelling above)" /
"(another dwelling above)"), both raising UnmappedApiCode and blocking
the cert. roof_construction_type is read ONLY for the §3 "sloping
ceiling" cos(30°) inclined-surface factor (Slice 89); the base roof
U-value comes from the global roofs[].description. Neither code is a
sloping ceiling:
- 6 = thatched — U set by the description, not this field;
- 7 = same/another dwelling above — an internal ceiling with no roof
heat loss (the roof-side analogue of floor_construction code 0,
governed by the roof_heat_loss / description path).
Map both to None: carries no information the cascade consumes here and
correctly avoids the cos(30°) false-trigger. Empirically inert and
validated — roof W/K is byte-identical whether 6/7 map to None or to an
explicit pitched string across all code-6/7 certs in the sample. 5 of
the 7 now compute (e.g. thatched cert 2276 SAP 62.8 vs lodged 63); the
other 2 also carry a gable_wall_type 2/3 raise (separate, worksheet-
backed slice).
Dict value type widened to Optional[str]. §4 suite 2392 passed; mapper.py
pyright unchanged at 32; new tests suppress reportPrivateUsage (net-zero).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captures the grill-with-docs session for solid-wall insulation: CONTEXT.md
gains EWI/IWI Measure Types + the Wall Insulation Eligibility rule (+ a
flagged-ambiguity that the three planning flags stay distinct, never recollapsed
to legacy restricted_measures). ADR-0019 records the eligibility policy (cavity
-> cavity only; brick/system -> IWI+EWI; timber -> IWI only; cob/stone -> none;
conservation/flat block EWI, listed/heritage block both). ADR-0020 records
conservation/listed/heritage as three distinct Property attributes sourced by
extending the geospatial S3 repo (flags co-located with lat/long).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of solid-wall insulation. BuildingPartOverlay gains a
wall_insulation_thickness field; the generic applicator already folds it onto
SapBuildingPart by name. With wall_insulation_type=1 (EWI) / 3 (IWI) + 100 mm,
the calculator derives the post-insulation U-value (§5.8 documentary path,
λ=0.04 default) — and for IWI also lowers the thermal-mass parameter. Two new
Elmhurst before/after cascade pins (solid-brick EWI + IWI, cert 001431)
reproduce the re-lodged after at abs(diff) <= 1e-4 across SAP/CO2/PE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 2026-register cert (4519-9056-4002-0222-4802) omits the top-level
post_town entirely (its town sits only in address_line_3 "BARNSTAPLE").
RdSapSchema21_0_x declares post_town as a required no-default field, so
from_dict raised "missing required field 'post_town'" and blocked the
whole cert from computing.
post_town is address metadata the SAP cascade never reads (no consumer
in domain/sap10_calculator/), so default an absent post_town to "" in a
from_api_response pre-processor (mirroring _normalize_shower_outlets) —
inert for the calculation, keeps the cert mappable. The schema dataclass
can't simply give post_town a default: it is a plain (non-kw_only)
dataclass with 57 required fields after post_town, so a mid-list default
would break field ordering.
Validated: cert 4519 now maps (post_town="") and computes SAP cont 74.68
vs lodged 75. §4 suite 2392 passed; mapper.py pyright unchanged at 32;
new tests suppress reportPrivateUsage (net-zero).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2026 sample's second-largest mapper raise: 37 certs lodge
sap_floor_dimensions.floor_construction=0, which raised UnmappedApiCode
and blocked the cert. Code 0 is the "not recorded / not applicable"
sentinel — 33/37 pair it with floor_heat_loss=6 ("another dwelling
below", an upper-floor flat with no ground floor to describe); the rest
carry mixed Solid / unheated-space descriptions. There is no single
construction to assert.
Map code 0 → None, which defers to RdSAP 10 Table 19 ("where floor
construction is unknown" → age-band default) — identical to how an
unlodged floor_construction (the 993 None certs) is already handled, and
honest about the absence (cf. the no-misleading-insulation_type rule).
Empirically inert and validated: across all 37 code-0 certs the cascade
floor W/K is byte-identical whether code 0 maps to None or to an explicit
"Solid" string — the another-dwelling-below floors compute to 0.0 W/K
(handled via floor_heat_loss + property_type=Flat + floors[].description,
per the _API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE code-6 note), and the few
genuine ground/unheated floors hit the same age-band default either way.
All 37 now compute (were raising).
Dict value type widened to Optional[str] for the None entry; helper
already returns Optional[str]. §4 suite + schema-mapper tests green
(pre-existing test_total_floor_area failure unrelated); mapper.py pyright
unchanged at 32; new test suppresses reportPrivateUsage (net-zero).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A random 1000-cert Jan–May 2026 EPB-register sample surfaced 53 certs
lodging sap_floor_dimensions.floor_construction=3, which raised
UnmappedApiCode and blocked the whole cert from computing (~44 of the
sample's mapper raises). RdSAP 10 field 3-1 "Floor construction"
enumerates the lowest-floor construction as solid / suspended timber /
suspended, not timber, and the spec's "Suspended not timber (structural
infiltration 0)" makes the split load-bearing.
Map code 3 to the canonical "Suspended, not timber" string (the same
value the site-notes mapper already emits — cross-mapper parity):
- u_floor takes the suspended BS EN ISO 13370 branch via the
"Suspended" prefix (_floor_is_suspended_from_description), and
- _has_suspended_timber_floor_per_spec's exact-match
`!= "Suspended timber"` gate correctly does NOT fire, so the §5 (12)
0.1/0.2 floor-infiltration adjustment is skipped (structural
infiltration 0) — exactly the spec rule for not-timber suspended.
Validated: all 5 sampled code-3 certs now compute (e.g.
0340-2877-5570-2606-5965 floor_construction_type="Suspended, not
timber", SAP cont 60.12 vs lodged 60). Confirmed against the cert's own
global floor descriptions ("Suspended, …", floor_heat_loss=7).
Code semantics established from the RdSAP 10 spec + the lodged certs'
human-readable floor descriptions (the EPB /api/codes endpoint carries
no floor_construction enum). §4 suite + schema-mapper tests green
(the pre-existing test_total_floor_area failure is unrelated). mapper.py
pyright unchanged at 32; new test suppresses reportPrivateUsage to keep
net-zero new errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record S0380.218 shipped, bump HEAD/next-slice, note both certs are
0-residual cross-validated golden fixtures and flag the optional
Summary-path regression guard as the cheap follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two certs fetched fresh from the GOV.UK EPB register, each with an
Elmhurst Summary PDF (input) and a dr87 worksheet PDF (the (1)..(286)
ground truth):
0340-2467-9260-2006-6521 (Summary_000922 / dr87-0001-000922)
5500-5070-0822-0201-3663 (Summary_000920 / dr87-0001-000920)
Both run through BOTH front-ends — from_api_response and
from_elmhurst_site_notes — and through the rating + demand cascades.
Cross-mapper parity holds: the two paths agree to <1e-4 on continuous
SAP, fuel cost, CO2 and PE. Both paths reproduce the worksheet exactly:
0340: (255) cost 776.4295, (272) CO2 2875.0498, (286) PE 16474.5616;
fabric (33) 171.6188, (37) 205.9358; SAP int 70 = lodged.
5500: (255) cost 751.8295, (272) CO2 2423.4547, (286) PE 14397.0118;
fabric (33) 141.1226, (37) 167.3696; SAP int 66 = lodged.
Pinned in two tables of test_golden_fixtures.py:
- _EXPECTATIONS / test_golden_cert_residual_matches_pin — SAP/PE/CO2
residual vs the integer-rounded lodged register (SAP resid +0 both).
- _WORKSHEET_PE_CO2 / test_golden_cert_pe_co2_matches_worksheet —
PE (286)/(4) and CO2 (272) vs the worksheet at +0.0000 (the
load-bearing 1e-4 check; lodged register is integer-rounded).
Dropped-field audit (raw JSON keys vs the schema-21.0.1 dataclass
fields consumed by from_dict) re-run on both fresh JSONs: no new
silently-dropped fields — only created_at metadata and the
shower_outlet_type/shower_wwhrs keys already handled by
_normalize_shower_outlets (mapper.py:2047). No calculator or mapper
change required; this is pure validation + regression-pinning.
Full §4 suite: 2392 passed, 1 skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>