Commit graph

551 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
f33bb9d52d feat(modelling): room-in-roof safety guard defers the roof generator
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>
2026-06-05 10:06:08 +00:00
Khalim Conn-Kowlessar
8323d9cf07 Merge branch 'feature/per-cert-mapper-validation' of https://github.com/Hestia-Homes/Model into feature/bill-derivation 2026-06-05 09:38:40 +00:00
Khalim Conn-Kowlessar
456a81df0a feat(modelling): wire glazing generator into the candidate pool
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>
2026-06-05 09:29:09 +00:00
Khalim Conn-Kowlessar
276dd1a500 feat(modelling): planning protection picks secondary over double glazing
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>
2026-06-05 09:18:17 +00:00
Khalim Conn-Kowlessar
8d081cb9d6 feat(modelling): recommend_glazing upgrades single-glazed windows to double
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>
2026-06-05 09:17:09 +00:00
Khalim Conn-Kowlessar
275a521071 feat(modelling): per-window overlay surface on EpcSimulation
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>
2026-06-04 22:57:02 +00:00
Khalim Conn-Kowlessar
1a807a4c4c feat(modelling): price sloping-ceiling + flat-roof measures in the catalogue
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>
2026-06-04 21:20:38 +00:00
Khalim Conn-Kowlessar
13b18ce9fb feat(modelling): roof dispatcher insulates a flat roof
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>
2026-06-04 21:16:00 +00:00
Khalim Conn-Kowlessar
7d40cddf3b feat(modelling): fold loft into the roof dispatcher; thatch routes to loft
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>
2026-06-04 21:12:01 +00:00
Khalim Conn-Kowlessar
6484610b6c feat(modelling): recommend_roof_insulation insulates a sloping ceiling
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>
2026-06-04 21:02:06 +00:00
Khalim Conn-Kowlessar
9a483b8711 docs: handover — fold in S0380.227-229 + PV diverter (G4) as the case-19 next slice
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:36:13 +00:00
Khalim Conn-Kowlessar
0f6b402345 S0380.229: primary loss applies for a dedicated water-heating boiler/circulator (WHS 911-931)
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>
2026-06-04 18:35:12 +00:00
Khalim Conn-Kowlessar
ea4534f3af feat(modelling): system-built walls take EWI+IWI (blocked on basement-code fix)
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>
2026-06-04 18:26:08 +00:00
Khalim Conn-Kowlessar
4911c56200 S0380.228: electric secondary on off-peak bills at Table 12a direct-acting high rate
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>
2026-06-04 18:00:38 +00:00
Khalim Conn-Kowlessar
5d4b55d7f9 S0380.227: dedicated DHW-only system is not separately timed (Table 2b note b)
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>
2026-06-04 17:44:11 +00:00
Khalim Conn-Kowlessar
796dce9d69 docs: handover — fold in S0380.224-226 + simulated case 19 debug state
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>
2026-06-04 17:14:05 +00:00
Khalim Conn-Kowlessar
9be95a0d3b feat(geospatial): one-read spatial reference (coords + restrictions)
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>
2026-06-04 17:13:39 +00:00
Khalim Conn-Kowlessar
19ed29e13c docs: handover — 1000-cert API accuracy study + next-steps + worksheet ask
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>
2026-06-04 16:37:03 +00:00
Khalim Conn-Kowlessar
c5182627ba feat(modelling): thread Property planning restrictions to the solid-wall gate
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>
2026-06-04 16:32:26 +00:00
Khalim Conn-Kowlessar
9c0a373f7d S0380.225: §10.7 no-water-heating default — A-F → 12mm loose jacket
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>
2026-06-04 16:28:25 +00:00
Khalim Conn-Kowlessar
dab2e759bf feat(geospatial): read planning restrictions co-located with coordinates
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>
2026-06-04 16:26:51 +00:00
Khalim Conn-Kowlessar
2e351be957 S0380.224: compute storage loss for loose-jacket cylinders (Table 2 Note 1)
`_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>
2026-06-04 16:19:35 +00:00
Khalim Conn-Kowlessar
7648032d73 feat(modelling): wire solid-wall insulation into the candidate pool
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>
2026-06-04 16:15:56 +00:00
Khalim Conn-Kowlessar
0cef044503 feat(modelling): flat gate drops EWI on solid-wall insulation
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>
2026-06-04 15:54:44 +00:00
Khalim Conn-Kowlessar
51ea4993a0 feat(modelling): planning-restriction gate on solid-wall insulation
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>
2026-06-04 15:41:22 +00:00
Khalim Conn-Kowlessar
69fdbf9f1d S0380.223: complete _part_geometry early-return key contract (RR KeyError)
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>
2026-06-04 15:33:52 +00:00
Khalim Conn-Kowlessar
ac78771258 feat(modelling): solid-wall generator offers IWI-only for timber frame
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>
2026-06-04 15:33:46 +00:00
Khalim Conn-Kowlessar
1c7997c471 feat(modelling): solid-wall generator offers EWI+IWI for solid brick
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>
2026-06-04 15:28:15 +00:00
Khalim Conn-Kowlessar
68aa80c174 feat(modelling): overlay models solid-wall insulation (IWI/EWI), pinned
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>
2026-06-04 15:11:26 +00:00
Khalim Conn-Kowlessar
d3def1e254 docs: handover — S0380.218 closed the "with api 3" pair (both clean)
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>
2026-06-04 12:36:23 +00:00
Khalim Conn-Kowlessar
1085842395 docs: handover — flagged numbers were stale (different branch), Part 1 is the task
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:11:12 +00:00
Khalim Conn-Kowlessar
8bd8ff8e5c docs: handover — fresh-API cross-comparison + flagged-cert debugging
Next-agent brief: fetch certs fresh from the EPC API (two new API+Summary+
worksheet triples for cross-mapper parity, plus six dashboard-flagged certs).
Flags the critical reconciliation: the user's flagged numbers don't match the
golden-fixtures cascade (0390-2954-3640 pinned +0 but flagged -6.85; 7536/2130
flags are pre-this-session), so fresh-raw-JSON-vs-curated-fixture or a
different engine must be reconciled before debugging. Documents the EPC API
fetch mechanism, the dropped-field audit method, this session's 4 fixes, and
the conventions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:08:28 +00:00
Khalim Conn-Kowlessar
f895dd3ab7 S0380.217: capture wall_insulation_thermal_conductivity (was dropped)
Second silently-dropped field from the 2130 audit: the schema-21
SapBuildingPart never declared `wall_insulation_thermal_conductivity`, so
`from_dict` discarded it. Captured it through schema 21.0.0/21.0.1 → domain
SapBuildingPart → API mapper, and wired it into u_wall's RdSAP 10 §5.8
documentary-evidence R-value calc (both the solid-brick §5.7/§5.8 path and
the cavity-composite path), replacing the bare 0.04 λ constant with a
resolved λ.

Resolver: absent / "Unknown" → the §5.8 default 0.04 W/m·K (mineral wool /
EPS); a mapped code → its λ; an unmapped integer code RAISES so the enum is
confirmed against a worksheet rather than silently mis-factored (same
incremental-coverage discipline as the glazing-type map). Only code 1
(= the default 0.04) is mapped — the sole observed value (cert 2130 Ext1).

Zero cascade effect today: the λ path fires only for solid-brick/cavity
walls with a *measured* wall thickness, and 2130 Ext1 lodges no wall
thickness, so its conductivity is captured-but-unused; all existing §5.8
certs lodge no conductivity → 0.04 default unchanged. The point is to stop
dropping lodged data and make λ correct when a future cert exercises it.

Suite: 2523 passed (1 pre-existing TFA fail); sap10_ml 237 passed (2
pre-existing stone-formula fails). Zero new pyright errors (46=46).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:57:00 +00:00
Khalim Conn-Kowlessar
b3f4609c2d feat(modelling): wire Valuation Uplift onto the Plan
The Plan derives its Valuation Uplift (ADR-0018) from its baseline -> post
band jump and works+contingency cost, given one external input — the
Property's current market value (a Property Valuation, mostly absent).
`Plan.valuation` / `Plan.baseline_epc_rating` are derived like the other
headline figures; `PlanModel.from_domain` maps the £ forms to the live
plan.valuation_* columns (NULL when no value — the percentage is not
persisted on those columns). `Property.current_market_value` is the new
optional source; the orchestrator threads it onto the Plan. `run_one`
takes a `current_market_value` so the harness can value the uplift, and
the sense-check table shows the average % (always) plus the £ forms when
known.

Sourcing the current market value (upload / default) remains deferred
(ADR-0018); it is None throughout until that lands, so the columns stay
NULL at scale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:59:04 +00:00
Khalim Conn-Kowlessar
ac7f510ccb S0380.214: as-built sloping-ceiling roof → Table 18 col (3)
A "Pitched, sloping ceiling" roof (roof_construction code 8) lodged with
"As Built" insulation (no measured thickness → None) was wrongly routed to
RdSAP 10 Table 18 column (1) "insulation between joists or unknown". A
sloping ceiling has no joist void, so per RdSAP 10 §5.11 roof-input item
5-5 ("Sloping ceiling insulation … unknown / as built → Table 18") and
Table 18 note (b) ("Applies also to roof with sloping ceiling") it takes
column (3) — band F = 0.68, band L = 0.18 (vs col 1 0.40 / 0.16).

Discriminator is the code-8 "sloping ceiling" string only: code-5 vaulted
ceilings stay on column (1) per the 33 cohort-2 "ND" vaulted certs
(S0380.211), and the "NI"/"ND" unknown case is untouched. New
`is_pitched_sloping_ceiling` flag threaded from heat_transmission to
`u_roof`; pre-1950 bands already reach the same col (3) value (2.30) via
the mapper's thickness=0 → Table 16 row-0 override, so the new branch
carries the post-1950 bands where col 1 ≠ col 3.

Worksheet-validated by simulated case 15 (the 7536 replica): our cascade
on its Summary matches the P960 worksheet exactly — roof HLC 29.17 W/K,
cont SAP 65.04 vs 65. Re-pins golden cert 7536: roof 26.77 → 29.17, cont
SAP 69.071 → 68.924, PE -7.0776 → -6.1952, CO2 -0.1875 → -0.1639 (SAP
integer 68, resid +1 unchanged — the remaining +0.92 is a diffuse demand
under-count needing a fully-faithful worksheet). Blast radius: 7536 only.

Suite: 2388 passed, 1 skipped (main); sap10_ml 233 passed + 2 pre-existing
stone-formula failures (out of scope). Zero new pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:58:37 +00:00
Khalim Conn-Kowlessar
e6f54df92b feat(modelling): ValuationUplift domain class (percentage-primary)
The financial-uplift model per ADR-0018. `estimate_valuation_uplift(
current_band, target_band, current_value=None, total_cost=None)` returns
a `ValuationUplift`: band-transition uplift compounded from four broker
tables (MoneySupermarket / Lloyds per-step, Knight Frank / Rightmove
whole-jump), taking min/max/mean across the covering sources. Always a
percentage; absolute £ forms (increase at each bound + post-retrofit
value) only when a current market value is supplied; the 2x ROI cap
rescales the percentages and can only bite once a value is known. A
non-improving jump is a clean 0% no-op.

Pure function, no external dependency. Persisting it (where the value
lands) and sourcing the current market value stay deferred (ADR-0018).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:33:19 +00:00
Khalim Conn-Kowlessar
31da90f5eb feat(modelling): persist recommendation.material_id from the catalogue
Expand half of the recommendation_materials retirement (ADR-0017). A
Plan Measure installs a single Product, so thread its catalogue id end to
end — Product.id -> MeasureOption.material_id -> PlanMeasure.material_id
-> recommendation.material_id — replacing the per-material BOM child
table with one nullable column on the row. ProductPostgresRepository
reads the id from MaterialRow; the four fabric generators set it on their
Option; the orchestrator carries it onto the Plan Measure; the mirror
declares + maps the column. Optional throughout (the JSON stopgap
catalogue carries no ids -> NULL).

The multi-measure integration test now pins each persisted measure's
material_id to its seeded MaterialRow id. Migration spec (live column
must be added before this deploys; contraction is the owner's next step)
in docs/migrations/recommendation-material-id.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:26:58 +00:00
Khalim Conn-Kowlessar
ec64c39d74 docs: correct 2130/7536 drive-to-zero diagnosis (distinct causes, not shared)
Per each cert's notes: 7536 is a glazing-U gap (S0380.97 glazing_type=2
Table 24 default vs the cert's higher lodged U on multi-age bps) — the
tractable target; 2130's SAP +1 is a PV-β cohort cascade interaction, not
a fabric line. The earlier "multi-part wall, shared cause" label was wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:14:10 +00:00
Khalim Conn-Kowlessar
f50195ac54 docs: handover — golden SAP drive-to-zero priority (2130/7536); 0240 architectural
Add a CURRENT PRIORITY section: of 53 pinned golden certs, 3 have non-zero
SAP residual. 2130 (+0.85 cont) + 7536 (+0.57 cont) are real multi-part-wall
fabric over-predictions (the drive-to-zero targets, possibly one shared
cause). 0240 (-1) is architectural — the lodged 73 needs an unpreserved
2013+ pump; document the cause, do NOT re-pin (user decision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:00:23 +00:00
Khalim Conn-Kowlessar
b9bbcecb42 docs: Thread 2 cost +4 closed by S0380.213 heat-network standing charge
Record the £120 standing-charge fix (Table 12 note (l) + §C3.2, case 14
(351)), the corrected diagnosis (standing charge, not cost scaling — the
4.24 p/kWh heat price was already right), the double-count avoidance, and
the remaining ~7% demand over-count (SAP -2). Bump HEAD/baseline/next-slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:46:32 +00:00
Khalim Conn-Kowlessar
ee484d9f4a S0380.213: heat-network standing charge (£120) — fixes 9390 cost under-count
Cert 9390 (community mains-gas boiler, API main_fuel_type=20) drew £0
standing charge → fuel cost under-counted → SAP read +4 high (71 vs 67).

Root cause: the standing-charge logic (`additional_standing_charges_gbp`)
only knows the GAS branch (`_is_gas_code`) and the off-peak-electric branch.
A heat-network community fuel is not a Table-32 gas code — EPC 20 = "mains
gas (community)" normalises to Table-32 code 20 (biomass), so
`_is_gas_code(20)` is False and the standing came out £0. The Summary path
masks this because it lodges community gas as Table-32 code 1 (ordinary
mains gas), which IS gas-recognised and already draws the £120 gas standing
— so the CH1-6 corpus was unaffected while the API path lost the charge.

Spec basis (verified against SAP 10.2 spec PDF):
- Table 12 (p.191) "Heat networks" row standing charge = £120/yr, note (k).
- Note (l): "Include half this value if only DHW is provided by a heat
  network."
- §C3.2 (p.58): the full charge applies when the space heating is also a
  heat network.
Worksheet-validated: simulated case 14 (community boilers + mains gas,
space + water) → worksheet (351) Additional standing charges = £120.

Fix: new `_heat_network_standing_charge_gbp(epc, main)` returns the
heat-network standing (£120 full when the space main is a heat network;
£60 when only DHW is on the network) or None otherwise. Applied at both
fuel-cost call sites, REPLACING the fuel-based `additional_standing_charges
_gbp` for heat-network mains (NOT additive) so a Summary-path community-gas
main — already £120 via the gas branch — is not double-counted to £240. The
CH1-6 community corpus stays exactly £120 (59 corpus tests pass).

9390 SAP +4 → -2 (cont 65.39 vs lodged 67): the spec-correct £120 standing
EXPOSES a separate ~7% demand over-count (also visible as PE 220 vs lodged
205) — a heat-source-efficiency-default / fabric residual, follow-up scope.
9390 is unpinned (retired P2.2 per ADR-0010 §10); helper locked by 2 unit
tests. Full suite 2386 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:45:24 +00:00
Khalim Conn-Kowlessar
f658f7ce71 docs: Thread 2 CO2/PE collision fixed by S0380.212; cost +4 tail open
Record the heat-network fuel-code collision fix (EPC 20 'mains gas
(community)' → Table-12 51), case-14 validation, and the remaining
cost-scaling gap (heat-network cost path missing 1/heat_source_eff).
Bump HEAD/next-slice; update shipped + audit tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:27:45 +00:00
Khalim Conn-Kowlessar
08dd0b4c73 S0380.212: fix community fuel-code collision in heat-network CO2/PE/cost
Cert 9390-2722-3520 (community mains-gas boiler scheme, sap_main_heating_
code=301, main_fuel_type=20) emitted CO2 0.44 t vs lodged 2.8 t — 6.4x low.

Root cause: the EPC `main_fuel_type` enum and the SAP Table 12 / Table 32
fuel-code numbering COLLIDE in the 18-25 range. Per
`datatypes/epc/domain/epc_codes.csv` (RdSAP-Schema-17.0) EPC fuel
20 = "mains gas (community)", but Table 12/32 code 20 is a solid biomass
fuel (CO2 0.028, PE 1.046, wood-logs price). The factor lookups
(`co2_factor_kg_per_kwh` / `primary_energy_factor` / `unit_price_p_per_kwh`)
check the Table-12/32 dict FIRST, so the EPC community fuel 20 silently
returned the biomass factor instead of translating 20 -> Table 12 code 51
(community mains gas: CO2 0.210, PE 1.130, mains-gas price).

Fix: new `_heat_network_factor_fuel_code(main)` translates the EPC community
fuel to its Table-12 code via `API_FUEL_TO_TABLE_12`, but ONLY for
heat-network mains (`_is_heat_network_main`) — a genuine biomass boiler
(non-community) keeps its raw Table-12 factor. Applied at the five
heat-network factor sites: space-heating CO2 / PE / unit-price and
water-heating (WHC 901) CO2 / PE. The Summary path is unaffected (it maps
"Mains gas - community" to code 1, no collision), so the community-heating
corpus (CH1-6) is untouched.

Worksheet-validated against simulated case 14 (community boilers + mains
gas, SAP code 301): worksheet (367) CO2 factor 0.2100, (467) PE factor
1.1300 — exactly the Table-12 code-51 values the translator now reaches.
9390 CO2 0.44 -> 3.03 t (lodged 2.8; spec-correct factors over the API-only
register residual per [[feedback-worksheet-not-api-reference]]), PE 204 ->
220 (the spec-correct 1.13 factor; the prior 204≈205 match was the
collision coinciding with the register residual). 9390 is unpinned (retired
at P2.2 per ADR-0010 §10); the translator is locked by two unit tests.

REMAINING (separate follow-up): 9390 SAP +4 is a cost-side gap — the
heat-network cost path does not apply the 1/heat_source_eff (1/0.80)
scaling that the CO2/PE paths do, so community fuel cost under-counts.

Suite: 2616 passed, 1 skipped (community corpus green); the 2
test_rdsap_uvalues stone-formula failures are pre-existing (HEAD 58ff7d88).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:25:53 +00:00
Khalim Conn-Kowlessar
c3c44fa3d0 docs: Thread 2 unblocked — case 14 (code-301 boiler+gas) arbitrates 9390
Record the community mains-gas BOILER worksheet (case 14): target (386)
heat-network CO2 factor 0.2640, distribution loss 1.49, code 301. 9390
decomposition: PE matches (204 vs 205), CO2 6.5x low (collision), SAP +4
separate cost gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:07:05 +00:00
Khalim Conn-Kowlessar
55af4ee2d7 docs: Thread 1 (roof) CLOSED by S0380.211; Thread 2 needs code-301 gas ws
Record the roof closure (vaulted NI → Table 18 col 1 0.16, cohort-arbitrated
not the guessed 0.25), the AGENT_GUIDE suite-command gap (sap10_ml/tests/ not
run) + pre-existing stone failures, cases 11/12/13 now available, and the
fuel-20 = community-gas (Table 12 code 51) note. Thread 2 still needs a
code-301 community-boiler + mains-gas worksheet (case 13 is code-302 CHP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:59:35 +00:00
Khalim Conn-Kowlessar
90f6720cae S0380.211: vaulted/sloping roof NI insulation → Table 18 col (1), not 50 mm
Closes the Ext1 vaulted-roof over-count that S0380.209 exposed on golden
cert 0240-0200-5706. BP2 lodges roof_construction=5 (vaulted ceiling),
roof_insulation_thickness="NI" (parsed to 0), description "Pitched,
insulated (assumed)", band J. The cascade returned U=0.68 — the RdSAP 10
§5.11.4 (p.44) retrofit-50 mm "insulation at joists" row. A vaulted /
sloping ceiling has no ceiling-joist void, so that row does not apply; per
RdSAP 10 §5.11 Table 18 (p.45) it takes the column (1) age-band default
(band J = 0.16).

The arbiter is the cohort, not the spec text alone: 33 cohort-2 certs
lodge "ND" (thickness None) vaulted roofs (roof_construction=5, band D)
that already pin to their dr87 worksheets at U=0.40 = Table 18 col (1) by
falling through the age-band default. 0240's only difference is the "NI"
sentinel (insulation present, unknown thickness) which uniquely hit the
0.68 override. (The S0380.209 note's predicted "cont ≈ 72.31" assumed a
col-3 0.25 value; the cohort's ND vaulted roofs disprove that — they use
col (1), so 0240 lands at cont 72.4617.)

Implementation: new `u_roof(is_sloping_ceiling=...)` flag, threaded from
heat_transmission for roof_construction_type containing "sloping ceiling"
(code 8) or "vaulted" (code 5). It fires only for the NI case
(thickness 0 + "insulated (assumed)"), routing to the col (1) age-band
default; the "ND"/None path is untouched (already col 1) and a NORMAL
pitched-with-loft roof still takes the §5.11.4 50 mm row (flag defaults
False). roof 76.93 → ~68 W/K → 0240 PE +5.5044 → +1.5181, CO2 +0.2757 →
+0.0728 (SAP integer 72 unchanged — the true value; lodged 73 needs the
unpreserved 2013+ pump).

Also corrects test_u_wall_cavity_as_built_partial_insulation_routes_to_
filled_cavity_row → ..._routes_to_as_built_row: a missed S0380.210
follow-up. That test (in domain/sap10_ml/tests/, which the AGENT_GUIDE §4
suite command does not run) asserted the pre-S0380.210 "partial insulation
→ filled" behavior on legacy-map parity, not worksheet evidence; S0380.210
corrected it to the as-built row per RdSAP 10 Table 6 + golden cert 0390's
four-metric closure.

Suite: 2614 passed, 1 skipped; the 2 remaining failures in
test_rdsap_uvalues.py (stone §5.6 thin-wall formula vs Table-6 1.7 cap)
are pre-existing (fail at HEAD 58ff7d88, before this branch's work).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:57:22 +00:00
Khalim Conn-Kowlessar
2fbd7147b7 refactor(modelling): move PortfolioGoal to domain/modelling/
PortfolioGoal is domain vocabulary (a Scenario's goal — legacy planning branches
on PortfolioGoal.INCREASING_EPC), so it belongs in domain/ co-located with
scenario.py, mirroring how domain/epc/wall_type.py holds an enum that
infrastructure/ imports. This lets the consolidated ScenarioModel (next slice)
source the goal enum from domain without an infra→backend dependency.
portfolio.py keeps a re-export so every existing
`from ...portfolio import PortfolioGoal` caller is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:44:48 +00:00
Khalim Conn-Kowlessar
0add6b6a59 docs: mark Thread 3 (cert 0390) CLOSED by S0380.210
Update the mapper-bugs handover: Thread 3 closed via the cavity
"partial insulation (assumed)" → "Cavity as built" routing fix; record
the latent open question about the unvalidated "insulated (assumed)" →
filled-cavity test (slice S-B25). Bump HEAD/baseline/next-slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:58:37 +00:00
Khalim Conn-Kowlessar
c75ef6417f S0380.210: cert 0390 cavity "partial insulation (assumed)" → as-built row, not filled
Golden cert 0390-2954-3640 (detached, TFA 360, age F) carried a +7 SAP /
-28 kWh/m² PE residual the audit attributed to a demand-side fabric gap.
Walking the §3 cascade localised it to the Main wall: lodged
wall_construction=4 (cavity), wall_insulation_type=4 (as-built / assumed),
description "Cavity wall, as built, partial insulation (assumed)". The
cascade mis-routed it to the Table 6 "Filled cavity" row (band F = 0.40)
because `_described_as_insulated` matches the "partial insulation"
substring.

RdSAP 10 Specification (10-06-2025) Table 6 — Wall U-values, England
distinguishes two cavity rows:
  "Cavity as built"  A-E 1.5, F 1.0, G 0.60, H 0.60, I 0.45, J 0.35, ...
  "Filled cavity"    A-E 0.7, F 0.40, G 0.35, H 0.35, I 0.45†, J 0.35†, ...
An "as built ... partial insulation (assumed)" cavity is the as-built
partial fill of the age band, NOT a retrofit cavity fill (a genuine fill
lodges the distinct "Cavity wall, filled cavity", wall_insulation_type=2).
It therefore routes to "Cavity as built" (band F = 1.0), mirroring the
worksheet-validated solid-brick rule in S0380.209 (cases 9/10: "as built,
insulated (assumed)" → as-built age-band row, not retrofit).

New `_cavity_described_as_filled` predicate is used only in u_wall's
cavity filled-row branch; it excludes the "partial insulation" substring
while keeping "insulated (assumed)" → filled (the unrelated, separately
asserted test_cavity_as_built_insulated_assumed_uses_filled_cavity_row is
unchanged). The shared `_described_as_insulated` (also consumed by the
roof/floor paths) is left untouched.

Wall HLC +53.6 W/K (U 0.40 → 1.0 over ~268 m²) lifts all four metrics
together — the signature of a real fabric bug, not a tuned offset:
  SAP  +7      → +0
  PE   -27.9745 → +0.5281 kWh/m²
  CO2  -2.7134  → -0.1189 t/yr
Bands I-M are unaffected (the two rows coincide per the † footnote), so
golden certs 0535 (band M) / 7536 (band L) with "insulated (assumed)"
cavities continue to pin at 0. Full suite 2384 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:57:00 +00:00
Khalim Conn-Kowlessar
58ff7d8881 docs: handover for golden-cert mapper/cascade bugs (roof S0380.210 + community fuel collision)
Records post-S0380.209 state: 0240 verdict (true SAP 72, register 73 = unpreserved
2013+ pump, proven 0=Unknown via 13 pairs), and three open threads — roof Ext1
"insulated (assumed)" U over-count (needs case 11 worksheet), community fuel-code
collision (API 18-25 vs Table-12 biomass 18-25; cert 9390 CO2 6x low; needs 9390
worksheet), and 0390 +7 demand-side gap. Plus the audit table of all 5 non-zero-SAP
golden certs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:22:04 +00:00