`PlanMeasure` grows optional `kwh_savings` (delivered energy) and
`energy_cost_savings` (£) — its slice of the telescoping bill cascade, signed
so positive is a saving and `None` until billing runs. `RecommendationRow`
declares the matching live `recommendation.kwh_savings` /
`energy_cost_savings` columns and maps them in `from_domain` (None → NULL).
The vestigial `recommendation.energy_savings` stays undeclared (legacy = 0).
No FE migration — the columns already exist on the live table (ADR-0014 / 0017).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull the cumulative-prefix scoring out of `marginal_impacts` into a reusable
`cascade_scores(scorer, baseline, overlays) -> list[Score]` (index 0 the
baseline, one calculator run per prefix) plus a pure `marginals_from_scores`.
Each Score carries its SapResult, so the next slice's telescoping per-measure
bill cascade can re-bill the same prefixes the role-3 attribution already
scores — no extra `calculate` calls (ADR-0014 / ADR-0016). `marginal_impacts`
now delegates; behaviour unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ModellingOrchestrator gains a constructor-injected FuelRatesRepository (mirrors
Baseline): run() resolves get_current() once and reuses one BillDerivation across
the batch. _plan_for prices the baseline and post-package end-states from the
SapResults already on their Scores (no extra calculate) and passes the Bills to
Plan. PlanRow mirror + from_domain gain the four live columns post_energy_bill /
energy_bill_savings / post_energy_consumption / energy_consumption_savings.
Pipeline/handler wire the fuel-rates repo. Integration tests assert the columns
persist: the multi-measure (fallback) plan shows positive bill+consumption
savings; the already-at-target zero-measure plan shows the current bill with
exactly zero savings. Fuel-switch measures price at the new fuel for free (we
bill the simulated end-state). 183 modelling/billing/orchestration/repo tests
pass, pyright strict clean. Plan-level only; per-measure savings next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plan gains optional baseline_bill / post_bill (the Bills derived for the
unmodified and post-package end-states at one Fuel Rates snapshot) and derives
the four plan-level columns: post_energy_bill (post total), energy_bill_savings
(baseline - post), post_energy_consumption (Σ post section kWh), and
energy_consumption_savings (baseline - post delivered kWh). All return None until
billing runs (persisted as NULL), so existing Plan construction and the
not-yet-wired orchestrator stay green. Plan-level only; per-measure savings are a
later slice (ADR-0014 amendment).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Score gains sap_result: Optional[SapResult], populated by PackageScorer with the
calculator output its headline figures came from. This lets the Modelling stage
price the post-package (and baseline) end-state via Bill Derivation reusing a
SapResult already computed by the optimiser's re-score / the orchestrator's
baseline score — no second calculate (ADR-0014 amendment). The optimiser reads
only sap_continuous, so it stays domain-agnostic and the stub scorers (which omit
sap_result) are unaffected — all optimiser tests pass unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bill / EnergyBreakdown / BillDerivation / sap_fuel were under
domain/property_baseline/ only because Baseline was built first. The Modelling
stage now needs them too, so move them (and their tests) to a neutral
domain/billing/ — Fuel/FuelRates already live in the shared domain/fuel_rates/.
Avoids a modelling -> property_baseline cross-stage import and a package name
that wrongly implies ownership (ADR-0011, ADR-0014 amendment). Pure git mv +
import rewrite across 10 files; 40 billing/baseline/repo tests pass, pyright
strict clean. CONTEXT.md Bill Derivation location updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records the /grill-with-docs design for the Modelling Bill-Derivation slice:
Bill Derivation is cross-stage (relocate Bill/EnergyBreakdown/BillDerivation/
sap_fuel to a neutral domain/billing/); Modelling bills the fully-overlaid
post-package SapResult (so fuel-switch measures price at the new fuel for free),
diffing against the baseline at the same FuelRates snapshot; the post-package
and baseline SapResults are captured from scores the optimiser/orchestrator
already compute (Score.sap_result), so no second calculate; FuelRatesRepository
is constructor-injected into ModellingOrchestrator mirroring Baseline; plan-level
columns this slice, per-measure telescoping bill cascade next (energy_savings is
vestigial, left NULL).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The orchestrator already threads budget/target_sap/dependencies into
optimise_package, so no orchestrator change was needed. Add an integration test
proving the new objective end-to-end on the real calculator: a band-D property
(~57.4) with a goal of band D — already met — yields a Plan with NO measures and
zero cost (the old max-gain objective would have recommended wall+floor+vent,
improving within the band it is already in). Clarified that the existing
multi-measure test now exercises the max-gain fallback (goal C unreachable from
D, tops out ~61). Narrowed Optional sap_points/estimated_cost through locals to
keep pyright strict-clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The warm-start (and max-gain fallback) now price each forced Measure Dependency
the candidate triggers, not just inject it afterwards: optimise/optimise_min_cost
fold dependencies into each candidate's cost+gain via _augmented_cost_gain, and
optimise_package scores each dependency's true role-1 signal (_with_role1_signals)
instead of the 0.0 placeholder. This stops the min-cost objective (i) ignoring the
~£900 a wall drags in (a wall-free package reaching target can be cheaper) and
(ii) picking a small-gain wall whose mandatory ventilation (down to -5 SAP) makes
it net-negative, which repair cannot un-pick.
Budget is now a hard envelope: the constraint applies to the augmented (measure +
its ventilation) cost, so a wall that fits alone but whose ventilation would bust
the budget is DROPPED rather than forced over budget. This reverses the earlier
'forced regardless of budget' call (which made sense when selection was
ventilation-blind). Safety invariant intact — presence still injected on every
path; we just never recommend a wall we can't afford to ventilate. ADR-0016
amendment updated. 94 modelling+orchestration tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewire the objective per the ADR-0016 amendment. With a target_sap (Increasing
EPC): warm-start optimise_min_cost (cheapest package reaching target_gain =
target_sap - baseline within budget) -> inject dependencies -> re-score ->
repair toward target; if the warm-start is infeasible or the repaired package
still falls short on the true score, fall back to max-gain-within-budget (best
effort). Without a target_sap: max-gain (unchanged). The min-cost objective
stops at the target without overshooting into a higher band; surplus budget is
left unspent. Extracted _max_gain_package (no-target path + fallback) and
_repair_to_target (inject + re-score + greedy repair). Dependency injection and
the repair loop are preserved; all prior optimiser + dependency tests pass
unchanged. Ventilation-aware *selection* is the next slice; injection is still
post-warm-start here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exact-enumeration sibling to optimise(): pick <=1 option per group to minimise
total cost subject to total gain >= target_gain and cost <= budget (None =
unconstrained). Ties broken toward higher gain ('recommend more'). Returns None
when no package within budget reaches the target (caller falls back to
max-gain); a non-positive target is met by the empty package. This is the
warm-start objective for an Increasing EPC goal per the ADR-0016 amendment
(least-cost-to-target, not max-gain). Dependency-blind for now; ventilation-aware
selection lands in a later slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The original ADR-0016 mis-specified the warm-start objective as maximise-gain-
subject-to-budget (with the target a repair floor); the rebuild faithfully
implemented that wrong objective. The intended behaviour is the legacy
StrategicOptimiser Case 1: minimise cost subject to (true) SAP gain >= target and
cost <= budget, falling back to max-gain-within-budget only when the target is
unreachable. For Increasing EPC this is least-cost-to-target: cheapest package
reaching the band, stops at the target (no overshoot into a higher band), surplus
budget unspent.
Also records: target predicate sap_continuous >= band floor (conservative, no
legacy slack — re-score+repair supersede it); ventilation-aware selection (the
forced dependency, -1 to -5 SAP, is folded into candidate evaluation with a real
negative role-1 signal, not just injected afterwards); presence-vs-awareness
enforcement; warm-start+re-score+repair structure and scalability rationale kept.
Sharpened the CONTEXT.md Optimised Package definition to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
measure_dependency.py now owns only the selection semantics: the trigger set and
the forced-edge wrapping. It delegates production (detection + pricing) to
recommend_ventilation and wraps the returned Recommendation into the
MeasureDependency, picking the cheapest Option (one MEV today; readies the seam
for MEV-c / MVHR). The orchestrator's _measure_dependencies call is unchanged.
Trimmed the now-redundant option-detail assertions — those live in
test_ventilation_recommendation. 138 pass, behaviour-preserving.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recommend_ventilation(epc, products) does the same two jobs as wall/roof/floor —
detect applicability (the has_ventilation guard) and price the work (2 MEV units
+ contingency) — and returns a Recommendation. Ventilation is a Recommendation
like the others; what makes it special (forced when fabric is selected, excluded
from the free pool) stays in the Measure Dependency layer. Detect + price now
live in generators/, not inline in measure_dependency.py. Note it is NOT run by
the candidate-pool runner — it is consumed only by the dependency path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
domain/modelling/ had grown to 15 flat modules. Group the behavioural ones into
subpackages — generators/ (wall/roof/floor Recommendation Generators), scoring/
(overlay applicator, package scorer, role-1/3 scoring), optimisation/ (optimiser
+ measure dependency) — and leave the shared value-object vocabulary
(recommendation, plan, scenario, product, contingencies, simulation) flat at the
top, since it is imported everywhere. Pure move + import-path rewrite across 89
import sites; no behaviour change. 136 pass, pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ModellingOrchestrator builds the ventilation dependency per Property
(suppressed when already mechanically ventilated) and passes it to
optimise_package, so a selected wall measure forces MEV into the package before
the re-score. Ventilation joins the role-3 cascade in best-practice order
(walls -> roof -> ventilation -> floor) and persists as a Plan Measure carrying
its real negative marginal and its cost. Added the mechanical_ventilation
contingency rate (0.26, per legacy Costs.CONTINGENCIES). Integration test now
seeds the ventilation Product and asserts the forced measure persists with
<=0 SAP and 2x900 cost; the full-pipeline test seeds the Product too (the
dependency is built for every not-yet-ventilated dwelling). On 000490 the real
calculator scores MEV at -1.275 SAP.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ventilation_dependency(epc, products) returns the forced 'fabric requires
ventilation' edge: triggers = MEASURES_NEEDING_VENTILATION (cavity/internal/
external wall, mirroring legacy assumptions.measures_needing_ventilation), and a
required Option installing decentralised MEV (mechanical_ventilation_kind=
EXTRACT_OR_PIV_OUTSIDE), priced at two fully-loaded units. Returns None when the
dwelling already lodges a mechanical ventilation kind (legacy has_ventilation
guard), so MEV is never forced onto an already-ventilated dwelling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MeasureDependency(triggers, required) is a data-declared 'A requires B' edge.
optimise_package gains a dependencies param: after the warm-start it injects any
dependency whose triggers intersect the selected measure-types, BEFORE the
whole-package re-score, so the dependency's (negative) SAP lands in the truthful
figure and the undershoot/repair decision (ADR-0016). Forced — injected
regardless of budget — but its cost counts toward package spend, so repair sees
less headroom. Repair candidates fold in any dependency they newly trigger, so
their marginal SAP-per-£ and incremental cost are truthful. The dependency never
competes in the optimiser pool. Returned selected includes the injected deps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VentilationOverlay (all-optional partial of SapVentilation) + EpcSimulation.
ventilation; apply_simulations folds it onto sap_ventilation, creating one when
the baseline lodged none. This is the surface a Measure Dependency (ventilation)
writes — whole-dwelling, no building part.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings HANDOVER_MODELLING.md fully current: #1157 (Plan persistence) and
#1160 (Optimiser) closed this session; records the locked design
decisions (multi-phase deferred, Plan Measure term, reuse-live-tables
via SQLModel mirrors, pure-Python knapsack not mip), the gotchas (mip/CBC
broken on aarch64, moto missing, drive-Modelling-directly for fixtures
without lodged perf, seed materials per fired measure type), and the
remaining work (#1161 ventilation Measure Dependency + deferred fronts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3b — closes#1160. ModellingOrchestrator._plan_for now runs the
full ADR-0016 flow instead of a single cavity measure:
generate wall + roof + floor Recommendations → score each Option
independently (role 1) into grouped ScoredOptions → optimise_package
(grouped knapsack within budget + whole-package re-score + greedy
repair toward the Scenario's SAP target) → attribute the selected set
via the best-practice marginal cascade (role 3) → persist the Plan
with its Plan Measures.
The repair target comes from the goal: INCREASING_EPC → the goal_value
band floor via Epc.sap_lower_bound(); other goals carry no SAP target
yet (later slice). Best-practice order walls → roof → floor.
Integration test: an uninsulated cavity wall + suspended floor (000490)
driven directly through the Modelling stage off a repo-seeded EPC
(the calculator fixture has no lodged recorded-performance fields, so
Baseline can't run it) persists a Plan with two attributed, priced Plan
Measures. The existing first-run test keeps full-pipeline coverage and
now exercises real modelling (its sample EPC's uninsulated solid floor
yields a floor measure). Replaces the single-measure cavity integration
test (subsumed). 138 pass; pyright strict clean.
Multi-phase remains descoped (ADR-0005); single-phase optimiser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3a. The inverse of Epc.from_sap_score: the minimum SAP rating in a
band (C → 69, B → 81, …), used as the Optimiser's repair target for an
INCREASING_EPC goal (goal_value "C" → target SAP 69). Keeps the
band-target derivation in the domain rather than re-coupling to
backend.app.utils.epc_to_sap_lower_bound. 8 tests incl. round-trip
through from_sap_score; pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2 of #1160 — the ADR-0016 truth step on top of the warm-start
knapsack. optimise_package(groups, scorer, baseline_epc, budget,
target_sap) -> OptimisedPackage:
warm-start optimise() (role-1 signal) → re-score the chosen package on
the real scorer (role-2 truth) → while the true SAP undershoots
target_sap and budget remains, greedy-add the untreated-group Option
with the best *marginal* SAP-per-£ (re-scored, not the role-1 signal),
re-score, repeat until the target is met, nothing positive-marginal is
affordable, or the budget is spent.
`Scorer` is a structural Protocol (PackageScorer satisfies it) so the
repair loop is tested with a stub scorer — no calculator, runs on ARM.
The key case: role-1 under-counts roof so the warm-start skips it, the
re-score undershoots, and repair adds roof back to hit the target. 3
repair tests + the 6 core tests; pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of #1160. Recycles the GainOptimiser/CostOptimiser formulation
(≤1 Option per Recommendation, maximise SAP gain subject to budget) as a
clean typed DDD function — but as an exact pure-Python multiple-choice
knapsack rather than the legacy `mip` MILP, since mip's CBC backend does
not load on aarch64 (so the legacy solver path can't run / be tested
here). At retrofit scale the candidate space Π(|group|+1) is tiny, so
exhaustive enumeration is exact and instant; ADR-0016 only needs the
knapsack as a warm-start signal anyway (the truthful figure comes from
the whole-package re-score + repair, next slice).
`optimise(groups, budget) -> list[ScoredOption]`: maximise total gain,
tie-break toward lower cost, skip-per-group covers "select none". 6 tests
(budget-bound selection, ≤1/group, unconstrained, budget-too-small,
empty groups, partial-affordability); pyright strict clean.
Multi-phase remains descoped (ADR-0005) — single-phase optimiser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 4b — closes the #1157 tracer. ModellingOrchestrator.run(property_ids,
scenario_ids, portfolio_id) now does real work in one Unit of Work,
committed once (ADR-0011/0012/0016/0017):
read Property (effective EPC) + Scenario via repos → recommend_cavity_wall
→ select its Option → PackageScorer.score (role-2 package total) +
marginal_impacts (role-3 attribution) → build Plan/PlanMeasure →
uow.plan.save → commit.
- AraFirstRunPipeline / ModellingStage thread portfolio_id from the trigger
body (one source of truth); handler builds the real orchestrator
(unit_of_work + Sap10Calculator), dropping the Scenario/Materials stubs.
- ScenarioRepository.get_many promoted to @abstractmethod now the bare-stub
instantiations are gone.
- New ara_first_run-style integration test: a property with an uninsulated
cavity wall yields a persisted Plan + one cavity_wall_insulation Plan
Measure (priced from the Product, figures present, linked by plan_id).
Numeric SAP correctness is pinned separately in test_elmhurst_cascade_pins.
- Existing pipeline integration test updated: seeds scenario 7 and runs the
real Modelling stage (its already-insulated sample wall yields an empty
package — no crash).
121 pass across repositories/modelling/orchestration/app; pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 4a. The Modelling stage reads the Scenario + Product catalogue and
writes the Plan + its Plan Measures on one session, committed once
(ADR-0012/0017). Adds uow.scenario / uow.product / uow.plan to the
UnitOfWork port and constructs them in PostgresUnitOfWork.__enter__.
Additive — existing stages and the bare-stub Modelling wiring are
unaffected. Wiring test asserts the unit exposes the three ports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 of #1157. Persists a Plan and its Plan Measures to the live
plan / recommendation tables via SQLModel mirrors (ADR-0017).
- infrastructure/postgres/plan_table.py: PlanRow (`plan`) + RecommendationRow
(`recommendation`) mirrors. RecommendationRow adds the new `plan_id` FK
(ON DELETE CASCADE) linking each Plan Measure to its Plan, replacing the
plan_recommendations m2m for new writes. from_domain mappers convert CO2
kg → tonnes to match the live column contract and derive post_epc_rating
from the rounded SAP. Only the impact + cost + identity columns the tracer
fills are declared; energy/bill, U-value, valuation, labour, plan_type are
left to later slices.
- PlanRepository port + PlanPostgresRepository.save(plan, *, property_id,
scenario_id, portfolio_id, is_default) -> plan id. Idempotent replace:
deleting the Plan cascades to its recommendation rows via plan_id, so a
re-run overwrites (ADR-0012). No commit — the UoW owns the transaction.
2 tests (persist + idempotent re-run); pyright strict clean; 73 pass across
repositories/modelling/orchestration with no regressions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2 of #1157. The per-Property output of one Scenario's modelling
run, per ADR-0017.
- PlanMeasure: a selected Measure Option frozen with its installed Cost
and role-3 (final-package cascade) attributed MeasureImpact — the
output counterpart of a Recommendation's candidate Option.
- Plan: the selected Plan Measures + baseline/post-retrofit Scores.
Single-phase (ADR-0005); derives the persisted headline figures —
cost_of_works, contingency_cost, co2_savings_kg_per_yr (kg; the mapper
converts to tonnes), post_sap_continuous, and post_epc_rating (band
from the rounded SAP via Epc.from_sap_score).
1 unit test, pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the #1157 build. The FE creates a Scenario and passes only
its id to the pipeline; the Modelling stage reads it back here.
- domain/modelling/scenario.py: thin `Scenario(id, goal, goal_value,
budget, is_default)` — the slice the stage uses today (goal/budget for
the Optimiser later; is_default drives plan.is_default). No phases
(ADR-0005); legacy file-path/aggregate columns not modelled.
- infrastructure/postgres/scenario_table.py: `ScenarioRow` SQLModel
mirror of the live `scenario` table (ADR-0017), declaring only the
read columns; goal mapped as its string value.
- ScenarioPostgresRepository.get_many(scenario_ids) -> list[Scenario]:
bulk read, input-order-preserving, raises on a missing id.
The method shape lives on the concrete repo for now; it is promoted to
an @abstractmethod on the port when the real orchestrator is wired and
the bare-stub instantiations retire (keeps the stubbed Modelling wiring
composing meanwhile). 2 tests, pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Outcome of the /grill-with-docs session scoping #1157.
- CONTEXT.md: add **Plan Measure** (the persisted selected Option +
role-3 attribution + cost); Recommendation stays the candidate.
Remove Scenario Phase / Plan Phase / Rolled-over Options — multi-phase
is deferred. Reshape Scenario + Plan to single-phase; fix relationships,
dialogue, and the "phase" ambiguity note.
- ADR-0005: rewritten to Deferred (multi-phase was speculative
prospective-client work; single-phase now; future plan_phase back-fill
path preserved). Stray phase refs cleaned in ADR-0016 / ADR-0009.
- ADR-0017 (new): Plan persistence — reuse the live plan/recommendation
tables via SQLModel mirrors + a PlanRepository on the UoW; add
recommendation.plan_id, retire the plan_recommendations m2m; flat
post-retrofit on plan; idempotent replace; CO2 in tonnes. Unselected
alternatives + bills noted as deferred directions.
- docs/migrations/recommendation-plan-id.md: the FE-owned Drizzle change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the file-backed Product catalogue — the stopgap source for costs
the ETL does not yet supply, behind the same ProductRepository port as
ProductPostgresRepository. The JSON file maps each Measure Type to its
fully-loaded unit cost; the per-Measure-Type contingency is joined from
config (not stored in the file), so config stays the single source of
truth for contingency — mirroring the Postgres repo's mapping.
Strict-raises (ValueError) on an absent measure type, a non-object
entry, or a missing/non-numeric unit_cost_per_m2, matching the
repo-wide strict-no-silent-default convention. tmp_path-backed tests,
no DB fixture needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records that the Elmhurst recommendation Summaries parse via the
extractor chain (not parse_site_notes_pdf), so the "parser gate" never
blocked the cascade pins. All four pins close at delta 0; loft 270→300
and the suspended-floor insulation-type field were the two gaps fixed.
Remaining: #1157 (HITL schema review) + ProductJsonRepository.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes #1159 end-to-end with solid and suspended-floor before/after
cascade pins on cert 001431, both closing at delta 0.000000.
Adds floor_insulation_type_str to BuildingPartOverlay (the generic
field-fold applicator picks it up with no change) and has
recommend_floor_insulation set it to "Retro-fitted". Insulating an
as-built floor re-lodges its insulation as retro-fitted; the calculator
keys on this for a suspended timber floor's sealed/unsealed
determination (cert_to_inputs.py: "retro" + no U-value supplied →
sealed). Without it the suspended-floor cascade left a +1.40 SAP gap
(the floor stayed "unsealed", wrong U-value); with it the cascade
closes exactly. Solid floors are unaffected by the seal logic and stay
at delta 0; both Elmhurst after-certs lodge "Retro-fitted", so setting
it uniformly is faithful.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes #1158 end-to-end. recommend_loft_insulation now emits a
300 mm overlay (was 270 mm). The Elmhurst before/after re-lodgement of
the loft-insulation measure on cert 001431 lodges the after-cert at
300 mm roof insulation; pinning before→overlay→after requires the
overlay to match that depth — at 270 mm the cascade left a +0.173 SAP
residual, at 300 mm it closes at delta 0.000000 on SAP/CO2/PE.
Adds test_loft_overlay_reproduces_the_relodged_after and updates the
roof generator unit test's thickness assertion to 300.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes#1154 — the Package Scorer's Elmhurst cascade pin. Drives
recommend_cavity_wall on the parsed `before` Summary, scores its
Option's overlay through PackageScorer, and asserts delta 0 (abs<=1e-4
on SAP/CO2/PE) vs the calculator's score on the re-lodged `after`
Summary.
Key finding: the handover's stated parser gate (parse_site_notes_pdf
throwing 'Manufacturer' on cert 001431) does NOT block these pins. The
Elmhurst recommendation Summaries route cleanly through the same
ElmhurstSiteNotesExtractor + EpcPropertyDataMapper chain the worksheet
e2e fixtures use (_elmhurst_worksheet_001431.build_epc). The Textract
path's window bug is unrelated and unused here.
The before→after field change is exactly wall_insulation_type 4
(uninsulated) → 2 (filled cavity), which is precisely the overlay
recommend_cavity_wall emits; the cascade closes at delta 0.000000 on
all three metrics. Before/after Summaries mirrored into
tests/domain/modelling/fixtures/ so the pin does not depend on the
unstaged workspace.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captures issue status (#1153-#1161), the built compute spine, key
facts/gotchas (hand-built 000490 fixture, calculator entry, worktree-vs-main
import trap, test/commit conventions), and the two gates (parser fix -> wire
Elmhurst cascade pins; #1157 persist-Plan HITL schema review). For picking
the work back up in a fresh session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recommend_floor_insulation(epc, products) detects an uninsulated ground floor
(SapBuildingPart.floor_insulation_thickness blank/zero) and its construction
from floor_construction_type — 'Suspended timber' -> suspended_floor_insulation,
'Solid' -> solid_floor_insulation — emitting the matching single Option (a
floor is one construction, like a cavity wall) with the overlay
(floor_insulation_thickness = 100 mm) and a priced Cost (ground-floor area x
the Product's fully-loaded unit cost + contingency).
- building_geometry.ground_floor_area(epc, identifier): the lowest floor's
(floor == 0) area. Pinned 14.85 m^2 on 000490 MAIN.
- BuildingPartOverlay gains floor_insulation_thickness (generic Applicator
writes it unchanged). suspended (0.20) / solid (0.26) floor contingencies.
Progress on #1159 (generator + geometry); end-to-end + Elmhurst pin pending
the orchestrator (#1157) and parser. Four behaviour tests (suspended / solid
/ none / cost) + geometry pin. pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recommend_loft_insulation(epc, products) detects an uninsulated main loft
(SapBuildingPart.roof_insulation_thickness == 0) and emits a
Recommendation("Roof") with one loft_insulation Option carrying the overlay
(roof_insulation_thickness = 270 mm, the recommended top-up) and a priced
Cost (roof area x the Product's fully-loaded unit cost + contingency).
- building_geometry.roof_area(epc, identifier): the part's greatest
per-storey floor area (RdSAP 10 §3.8). Pinned 14.85 m^2 on 000490 MAIN.
- BuildingPartOverlay gains roof_insulation_thickness; the generic Overlay
Applicator writes it with NO change (validated by the tracer) — the
deep-module field-fold paying off.
- loft_insulation contingency (0.10) added.
Progress on #1158 (generator + geometry); end-to-end + Elmhurst pin pending
the orchestrator (#1157) and the parser fix. Four behaviour tests
(geometry pin; detect / none / cost). pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
scoring.py adds the telescoping marginal cascade that serves two of the three
ADR-0016 scoring roles:
- marginal_impacts(scorer, baseline, overlays): applies overlays cumulatively
in order and reports each measure's marginal MeasureImpact (sap_points +
carbon/energy savings). Role 3 (final-package attribution) — the marginals
telescope EXACTLY to the whole-package total.
- independent_option_impacts(scorer, baseline, options): role 1 — scores each
Option's overlay independently vs baseline, scoring each DISTINCT overlay
once (Options sharing an overlay reuse the result). Approximate signal for
the optimiser; never surfaced as a measure's true impact.
Role 2 (whole-package re-score) is PackageScorer.score directly. Three
behaviour tests on the real Sap10Calculator / a counting stand-in (hand-built
EPD): single-overlay marginal == improvement-over-baseline; two-overlay
marginals telescope to the package total; per-Option dedup scores each
distinct overlay once. Closes#1156. pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PackageScorer(calculator: SapCalculator).score(baseline, simulations) folds
the Simulation Overlays onto the baseline via the Overlay Applicator and
scores the throwaway EpcPropertyData on the injected deterministic SAP
calculator, returning Score(sap_continuous, co2_kg_per_yr,
primary_energy_kwh_per_yr). Depends on the SapCalculator abstraction, not a
concrete engine. This is the reusable scoring primitive (ADR-0016) — the
same call serves the optimiser's whole-package re-score and a future live
re-score of a user-assembled plan.
Two behaviour tests against the real Sap10Calculator on a hand-built EPD:
filling the main cavity improves SAP (right-directional through the real
physics); an empty package scores the unmodified baseline (pins the
SapResult->Score mapping). The Elmhurst before/after cascade PIN (#1154's
acceptance) lands once cert 001431 parses (external _extract_windows fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recommend_cavity_wall now takes a ProductRepository and prices the Measure
Option: Cost(total = gross_heat_loss_wall_area(MAIN) x product.unit_cost_per_m2,
contingency_rate = product.contingency_rate). Detection is unchanged and runs
before pricing, so ineligible walls still return None without a catalogue hit.
Completes #1155 — the cavity-wall Recommendation Generator now detects an
uninsulated main cavity wall and emits a priced Option carrying the filled-
cavity overlay. Four behaviour tests (detection x3 + fully-loaded cost).
pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Product(measure_type, unit_cost_per_m2, contingency_rate). ProductRepository
is the DDD port abstracting the catalogue source; ProductPostgresRepository
reads the externally-owned material table (defensive SQLModel view
MaterialRow) and maps an active row to a Product — total_cost becomes the
fully-loaded unit_cost_per_m2 — joining the per-measure-type contingency
(contingencies.py, mirrors Costs.CONTINGENCIES; cavity 0.10). Strict-raise
on missing/inactive row. A JSON-backed impl will follow behind the same
port for ETL-gap costs.
Two DB tests against an ephemeral Postgres (map active row; raise on
inactive-only). Toward #1155 cost (4b). Also generalises the CONTEXT
Simulation Overlay wording: windows are targeted by index, building-part
association carried via window_location (_window_bp_index). pyright clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary PDFs preprocessed from `pdftotext -layout` wrap the windows-table
header across several lines. The third header line's tail ("U value / g
value / Draught Proofed / Permanent Shutters") tokenises to "value value
Proofed Shutters" and lands directly above the FIRST window's data row.
Because the first window in a building part has `before_start = 0`, its
prefix block reaches back into that header remnant. The remnant is
neither an orientation nor a building-part fragment, so it survived the
pops in `_compose_window_descriptors` and leaked into glazing_type as
"value value Proofed Shutters Double between 2002 and 2021" (windows 2-3,
whose prefix starts after the previous window's manufacturer line, were
clean).
Fix: the glazing-type phrase always starts with a glazing-start word
(Single/Double/Triple/Secondary), so trim any prefix fragments preceding
that word before joining the glazing type. Orientation/bp pops still run
on the full prefix, so they are unaffected.
Reproduced from `sap worksheets/Recommendations Elmhurst Files/
cavity_wall_insulation - main wall/before/Summary_001431.pdf`. Added a
regression test driving the real `_extract_windows_from_layout` path with
the verbatim tokenised header+rows. 2306 passed (+4), pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>