Section 1 of the property inspection report: PropertyReport compares the
cert's lodged energy_rating_current to Sap10Calculator's un-rounded SAP and
flags |Δ| > 0.5 (the ADR-0010/0013 shadow-validation design target). A
mapping/scoring raise is captured per-cert as calculator_error, never
propagated, so one bad cert can't abort the sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Updated 2130's pin notes with the case-18 finding: our cascade reproduces
the worksheet PV split to the decimal (gen 2684.17 / onsite 970.77 / export
1713.40), so the Appendix M1 β-split is exact, not the suspected bug. With
the gas PE factor also exact (1.13) and the wall measurement now wired
(S0380.215), 2130's +2/-11.72 is the irreducible API-only lodged residual
(0240-like), not a closable calculator bug. Notes-only; pin unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pdftotext dumps of hand-entered Elmhurst worksheets wrap the §11 glazing-
GAP column ("16 mm or more") onto the glazing-TYPE token, yielding labels
like "Double between 2002 and 2021 16 mm or [1st]" that
`_elmhurst_glazing_type_code` didn't recognise → UnmappedElmhurstLabel,
blocking the whole Summary from parsing.
Added a fallback: when the lightly-cleaned label isn't a known key, strip a
trailing wrapped gap descriptor (`\s+\d+\s*mm\b.*$`) and retry. Applied
AFTER the direct lookup so explicitly-mapped interleaved variants (e.g.
"Double with unknown 16 mm or install date more", where the gap splits into
the middle) are unaffected. The gap drives the API-path U-value lookup, not
the site-notes glazing-type enum, so dropping it is loss-free for the
cascade.
Unblocks running our cascade on hand-entered worksheet Summaries — used to
validate the PV β-split against simulated case 18 (our split matches the
P960 worksheet exactly: gen 2684.17, onsite 970.77, export 1713.40).
Suite: 2391 passed, 1 skipped. Zero new pyright errors (mapper 32=32).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Next-phase handover: fetch live EPCs via EpcClientService, run the
offline Modelling harness, and save a per-property report covering
(1) lodged-vs-calculated SAP divergence (>0.5), (2) plans + costings,
(3) recommended measures + the EPC attributes that triggered them. Maps
the EPC API client (the user's blocker), the calculator-error ingredients
(parity_report scaffolding), and each generator's exact trigger fields.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The schema-21 SapBuildingPart never declared `wall_insulation_thickness_
measured`, so `from_dict` silently discarded it. When a cert lodges
`wall_insulation_thickness == "measured"` the actual value (mm) lives in
that dropped field, so the cascade fell back to the 50 mm "insulation
present, unknown thickness" default instead of the lodged measurement.
Cert 2130 Ext1 lodges solid brick band B + INTERNAL insulation
"measured"/100 mm. Per RdSAP 10 §5.7 Table 8 (insulated-wall U by age band
+ insulation thickness) the 100 mm row gives U=0.32; the unknown-thickness
fallback gave 0.55. New `_api_resolve_wall_insulation_thickness` substitutes
the measured value for the "measured" sentinel; the existing
`_insulation_bucket`/Table-8 path then computes the correct U. Field added
to schema 21.0.0/21.0.1 SapBuildingPart; domain field widened to
Union[str, int] to match `roof_insulation_thickness`. Isolated: 2130 Ext1
is the only bp lodging "measured" across all 47 fixtures.
This spec-correct fix EXPOSED an offsetting under-count it had been masking
(per the repo's no-special-handling rule — the pre-fix +1 was two bugs
cancelling): 2130 cont SAP 83.35 → 83.78 (resid +1 → +2), PE -7.56 →
-11.72, CO2 -0.045 → -0.095. The exposed -11.72 PE (~-746 kWh/yr) is the
deferred gas-combi-PE + PV-β-credit under-count from S0380.45/.49, now
un-masked — the next slice. Re-pinned 2130 with the cause documented.
Suite: 2391 passed, 1 skipped. Zero new pyright errors (mapper 32=32).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apply the deep-research off-gas figures so oil/smokeless/wood sit on the
same NEP-Apr-2026 retail / DESNZ DUKES gross-CV basis as the new coal
proxy (fuel-input, not useful-heat): OIL 9.16 -> 12.11 (prior value was
materially low vs current kerosene), SMOKELESS 10.0 -> 8.69, WOOD_LOGS
8.83 -> 8.25, WOOD_PELLETS 7.99 -> 7.38. SEG (15.0, Solar Energy UK) and
LPG (17.61, bottled-propane) kept; gas/electricity (Ofgem cap) unchanged.
CV arithmetic recorded in the snapshot _assumptions. OIL pin updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Coal and heat networks have no national retail/cap rate, so the snapshot
left them null and BillDerivation raised UnpricedFuel — dropping those
certs from an offline cohort run. Add researched proxy rates (fuel-input
basis, sources + arithmetic in the JSON _note/_gaps): COAL 7.13 p/kWh
(NEP Nov 2025 coal uprated + DESNZ DUKES house-coal GCV) and HEAT_NETWORK
16.0 p/kWh + 69.4 p/day (Insite Energy operator sample; indicative, schemes
vary ~8-30). Both flagged proxy/indicative — sense-check estimates, not
market rates. Existing curated fuels are unchanged.
Replaces the unpriced-raises pin for these two with a positive rate pin;
off-peak stays unpriced pending the day/night accessor. Golden cohort now
runs 57/57 offline with zero errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After three faithful-worksheet iterations (simulated cases 15/16/17), the
7536 +1 SAP residual is confirmed 0240-like — an Elmhurst register-rounding
residual not reproducible from the API-only JSON, not a calculator bug.
Case 17 is faithful on windows (Main 16.98 / Ext1 13.59 / Ext2 1.89) and
ground floors; every per-element value matches our cascade: walls
0.70/0.28/0.40, roofs 0.40/0.18/0.68 (S0380.214), window U-eff
2.4368/1.8519, ground floors 0.97/0.26/1.12. The only worksheet divergences
were manual-entry artifacts: case-16 inverted the floor order (put the
50.98 m² upper floor as ground), and case-17 auto-derived spurious "to
external air" exposed floors from the small-ground/big-upper geometry —
real 7536 lodges floor_heat_loss 2/7/3 (unheated-space / ground), none is
code 1 (exposed). Our spec-correct cont SAP is 68.924; lodged 68 carries
Elmhurst's own residual.
Notes-only; pin unchanged (resid +1, PE -6.1952, CO2 -0.1639). Suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CertResult now carries its Plan (with flat baseline/post-SAP/measures
properties), and `format_cohort_csv` renders one browsable row per cert
(SAP transition, band, measures, cost, bill saving, valuation %, error).
`scripts/run_modelling_cohort.py` is turnkey: no args runs the committed
golden cohort, prints a sense-check table for the first measure-bearing
certs (a capped preview so a large dump doesn't flood the terminal), the
summary, and writes modelling_cohort.csv (gitignored). Point it at the
EPC dump when it lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`harness.cohort.run_cohort(paths)` parses each API-shaped EPC JSON with
from_api_response and models it via run_modelling — no database, no
network — capturing per-cert errors instead of aborting the sweep, plus
`format_cohort_summary`. A thin `scripts/run_modelling_cohort.py` CLI
points it at a directory. Proven over the 57 golden API certs: 56 ran
offline, 15 produced measures, 1 errored (COAL has no Fuel Rates entry —
a BillDerivation coverage gap, not a harness one). Ready for the EPC dump.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes that unblock offline, no-database inspection over an arbitrary
EPC dump:
- Complete the harness sample catalogue with loft_insulation and
solid_floor_insulation — the four fabric generators can emit five
Measure Types, but the catalogue priced only three, so an offline run
on a property with an uninsulated loft or solid floor raised mid-run.
A new test pins the catalogue to cover every generator Measure Type.
- Add `run_modelling(epc, ...)` — runs ONLY the Modelling stage (no
Ingestion / Baseline), so it needs no lodged recorded-performance / RHI
and inspects recommendations on any calculator-scorable EPC. `run_one`
(full pipeline) stays for when you want Baseline too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
Slice 3. `harness.console.run_one(epc, goal_band=...)` wires the full
AraFirstRunPipeline against in-memory fakes — no Postgres, no network —
runs one property, prints the sense-check table, and returns the Plan
for interactive poking from a REPL at the worktree root. Defaults to the
committed harness sample catalogue.
Refactors the slice-1 integration test to delegate to run_one (dropping
~70 lines of duplicated wiring + the now-unused test catalogue fixture),
so it exercises the shipped entrypoint rather than a parallel copy. The
new console test covers run_one's print/return contract.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2. `harness.plan_table.format_plan_table(plan)` renders a Plan as a
plain-text table — one package summary line (baseline SAP/band -> post
SAP/band, CO2 saved, cost of works + contingency, bill saved) and one
line per Plan Measure (signed SAP points, cost, delivered kWh + £
savings). Pure presentation: reads the Plan, computes nothing. The
DB-less First Run test now prints it (visible under `pytest -s`) so the
modelled package can be eyeballed and debugged by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the grill-with-docs pass on the depth+scale phase. Splits the
overloaded "valuation" into two glossary terms — Property Valuation
(current market value, a Baseline attribute, mostly missing) and
Valuation Uplift (plan-conditional, percentage-primary; absolute £ only
when a Property Valuation exists, 2x ROI cap on the £ form). ADR-0018
records the percentage-primary decision and why (the EPC scale corpus
has no market values, so a value-primary model produces nothing), plus
the deferred sourcing / per-measure / rental-yield items.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the DB-less inspection harness. Complete the in-memory
FakeUnitOfWork so the ModellingOrchestrator runs with no Postgres:
add FakeScenarioRepository + FakePlanRepository (idempotent, keyed by
(property_id, scenario_id)), expose scenario/product/plan on the fake
unit, and grow FakePropertyRepo to compose the effective EPC from the
EPC repo at read time — mirroring PropertyPostgresRepository, so the
EPC Ingestion persists is visible to Baseline + Modelling (the
through-repos hand-off, in memory).
The new integration test drives the full AraFirstRunPipeline
(Ingestion -> Baseline -> Modelling) against the FakeUnitOfWork — no
Session ever opened — on the uninsulated 000490 fixture with its lodged
recorded-performance filled in (it already carries the RHI block, so
Baseline can run) and asserts a multi-measure Plan is produced. The
committed product catalogue prices the wall/floor/ventilation measures
it fires.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Capture the next phase (close persisted-field gaps + financial uplift, plus a
large-scale e2e run of a SAP 10.2 EPC dump and console manual testing; measure
coverage deferred) and a self-contained handover prompt for a fresh agent to
pick up via a grilling session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
Pin the FE-facing aggregate_portfolio_recommendations (previously untested): it
sums a Scenario's default Recommendations onto the Scenario row, joining
Recommendation → Plan on recommendation.plan_id. Locks the m2m→plan_id read cut
for the FE-critical path, now testable thanks to the full-parity ScenarioModel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the scenario and installed_measure tables into
infrastructure/postgres/modelling/ as full-parity SQLModel definitions
(ScenarioModel, InstalledMeasureModel + MeasureType), completing the cluster
consolidation. backend/app/db/models/recommendations.py is now a pure
re-export shim.
ScenarioModel.goal is the PortfolioGoal enum (legacy planning branches on it),
sourced from domain/modelling/portfolio_goal.py; the repo's to_domain maps it to
its value string, so domain Scenario.goal is now the value ("Increasing EPC")
consistent with the orchestrator's check — fixing the latent name-vs-value
inconsistency the old str column masked (the scenario repo test stored the enum
*name*). Parity columns are nullable (mirror convention; live NOT-NULLs owned by
Drizzle).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Standardise the modelling persistence classes on the …Model suffix (PlanModel,
RecommendationModel, RecommendationMaterialModel) — matching the epc_property
precedent and the legacy names the rest of backend/ already imports, so the
shim's plan re-export becomes literal (no alias) and the eventual shim deletion
needs zero renames. The …Row→…Model sweep for the non-cluster tables
(Property/Task/Material/…) waits until their live legacy …Model counterparts
are retired, to avoid reintroducing dual-definition collisions. No behaviour
change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
Stop writing the m2m (remove create_plan_recommendations + its call, the bulk
link insert and the now-dead plan_ids_by_index, and the plan_recommendations
delete in delete_property_batch) and remove the PlanRecommendationRow model +
its shim alias and the test_export fixture inserts. Measures now link to their
Plan solely via recommendation.plan_id (writers set it, readers join on it).
The live drop of the plan_recommendations table is the FE-owned Drizzle
migration documented in docs/migrations/recommendation-plan-id.md, sequenced
after the read-cut + backfill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite the three structurally-identical m2m-join readers
(portfolio_functions.aggregate_portfolio_recommendations,
Outputs.get_recommendations_from_db, export get_recommendations) to join
PlanModel directly via recommendation.plan_id, dropping the plan_recommendations
join and its now-unused import. The writers set plan_id (prior slice), so the
rows resolve. test_export pins the export reader through the cut (its fixtures
now set recommendation.plan_id). A portfolio_functions DB characterization test
lands with the scenario consolidation (which provides the full-parity scenario
table the aggregation writes to).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
upload_recommendations and bulk_upload_recommendations_and_materials now set
plan_id on each recommendation row (the plan id is already in scope), while
still writing the plan_recommendations m2m — the dual-write that lets readers
move onto plan_id with no breakage during the transition (ADR-0017 amendment /
docs/migrations/recommendation-plan-id.md). The m2m write is removed in a later
slice once no reader depends on it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the live plan, recommendation, recommendation_materials and (retiring)
plan_recommendations tables into a new infrastructure/postgres/modelling/
subpackage as single SQLModel definitions (the epc_property pattern), absorbing
the rebuild's partial PlanRow/RecommendationRow mirrors and carrying full
legacy column parity plus recommendation.plan_id. Out-of-cluster references are
plain indexed ints (mirror convention); the live FKs are owned by the Drizzle
schema. backend/app/db/models/recommendations.py becomes a re-export shim
(ScenarioModel/InstalledMeasure stay for a later slice).
Fix the export conftest to create SQLModel-first (so Base funding_package's FK
to the now-SQLModel plan resolves) and skip the redundant drop_all on its
function-scoped throwaway DB (the epc enum type is now shared across both
metadatas). Resolves the pre-existing dual-definition collision: the rebuild
and legacy export suites are now co-runnable. No behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The EPC renders a recent-band as-built wall as "<material>, as built,
insulated (assumed)". The API mapper populates epc.walls with that string,
and heat_transmission's wall_ins_present gate keyed off the "insulated"
substring → routed the wall to the RdSAP 50 mm "insulation of unknown
thickness" bucket (e.g. sandstone band J U=0.25) instead of the as-built
age-band row (U=0.35).
Per RdSAP 10 Table 8/9 footnote the 50 mm row applies ONLY when insulation
is "known to have been increased subsequently (otherwise 'as built'
applies)". An "as built ... (assumed)" description is the EPC's age-band
assumption — it only renders on RECENT bands (an old band renders "no
insulation (assumed)"), so the as-built row applies. Genuine retrofit is
signalled by wall_insulation_type (External/Internal/Filled), which the
gate still checks independently.
Worksheet-validated by two new Elmhurst worksheets, both As Built band J:
- simulated case 9: sandstone → (29a) U 0.35
- simulated case 10: solid brick → (29a) U 0.35
both the as-built row, NOT 50 mm (0.25).
Fix: restrict the description-based gate to genuine retrofit via the new
local `_described_as_retrofit_insulated` (excludes "as built"/"(assumed)").
The cavity filled-row routing inside `u_wall` (which uses
`_described_as_insulated` directly) is untouched — the 3 cavity API certs
(0390/0535/7536) are unaffected.
test_heat_transmission: the old `..._uses_50mm_row` test asserted 50 mm via
an IMPOSSIBLE band-B + "insulated (assumed)" combination; corrected to a
valid recent-band (J) scenario asserting the as-built row (35 W/K).
Golden 0240: walls 24.45 → 34.23 W/K (U 0.25 → 0.35). SAP integer 72
unchanged; PE residual re-pinned +1.8687 → +5.5044, CO2 +0.0907 → +0.2757.
This spec-correct fix REMOVED the wall under-count that was masking the
Ext1 vaulted-roof over-count (cascade U 0.68 via the same "insulated
(assumed)" description vs case-9 sloping-ceiling 0.25) — that roof
over-count is the next slice; fixing both lands SAP cont ≈ 72.31 (=
Elmhurst case 9).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite the migration spec into the full expand/contract sequence (add plan_id
→ backfill → dual-write → cut reads → drop) with the two load-bearing rules:
backfill before any read cuts over, and dual-write the m2m until all reads are
off it (the Drizzle FE reads the tables directly, so the repos can't deploy
atomically). Amend ADR-0017 from "m2m retired for new writes" to "m2m dropped +
one SQLModel definition per table under infrastructure/postgres/modelling/".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>