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>
domain/building_geometry.gross_heat_loss_wall_area(epc, identifier) sums
heat_loss_perimeter x room_height across a building part's storeys — the
heat-loss wall area (party walls excluded by construction), not total
wall area. Lives outside the calculator so Modelling cost quantities can
reuse it; the calculator computes the same quantity inline today and
should be DRY'd onto this later (coordinated with the calculator branch).
Pinned at 45.93 m^2 against the 000490 MAIN part. Toward #1155 cost
(behaviour 4). pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recommend_cavity_wall(epc) detects an uninsulated main cavity wall
(wall_construction=4, wall_insulation_type=4) and emits a Recommendation
whose single Measure Option carries the Simulation Overlay setting MAIN
wall_insulation_type=2 (Table 6 'Filled cavity'; cf. domain/sap10_ml/
rdsap_uvalues.py u_wall). Returns None for already-insulated or
non-cavity main walls.
Recommendation/MeasureOption reshaped per design review: the target is
encoded in the Option's overlay (addresses a building part / window /
system), not a typed key on Recommendation — generalises to glazing and
heating without changing the type. CONTEXT partition wording generalised
to match.
Three behaviour tests (hand-built EPD, no PDF). Cost (behaviour 4 of
#1155) outstanding — needs net heat-loss wall area + ProductRepository.
WIP on #1155. pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the user-simulated 001431 case (the cert that drove S0380.189/.190)
as an Elmhurst-only e2e fixture: Summary PDF → extractor → mapper →
calculator, every Block-1 SapResult field pinned against the
P960-0001-001431 worksheet at abs=1e-4. All 11 pins pass with zero
residual — the case is clean, confirming the S0380.190 gas-combi fuel
derivation closes the Summary path natively.
Verified the handover's flagged "+0.0007 SAP" was a target artifact, not
a cascade gap: the worksheet displays ECF (257) rounded to 1.6047 and
integer SAP (258)=78; the cascade's continuous SAP is computed from the
UNROUNDED ECF = (255)*(256)/((4)+45) = 660.9750*0.4200/173.0, giving
77.6147 — which matches the worksheet's own unrounded value. Pinning the
continuous SAP from the display-rounded ECF (→ 77.6144) was the wrong
target. Block-1 line refs all match exactly: (211) 10699.7225, (219)
3327.1592, (231) 86.0, (232) 283.2229, (255) 660.9750, (272) 3000.1664,
Σ(98) 8987.7669.
Summary mirrored into the tracked fixtures dir as
Summary_001431_gas_combi.pdf (distinct name — the corpus reuses cert
001431 across every heating variant); source Summary + worksheet tracked
under sap worksheets/golden fixture debugging/ as the pin ground truth.
2302 passed (+11), 0 failed; pyright net-zero on new/changed files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The newer Elmhurst Summary export lodges a gas combi as §14.0 "Fuel Type"
empty + "Main Heating SAP Code" 104 (EES "BGW"), with no fuel string. The
site-notes mapper left `main_fuel_type=''`, so `cert_to_inputs` raised
`MissingMainFuelType` — blocking the whole gas-combi Summary path
(reproduced on the simulated 001431 case).
SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas boilers (including
mains gas, LPG and biogas)": the code fixes the boiler type/efficiency but
NOT the carrier, so 104 alone can't distinguish mains gas from LPG. The
disambiguator is §15.0 "Water Heating Fuel Type" — a combi/boiler heats
space + water from one appliance — exactly mirroring the existing
liquid-fuel (codes 120-141) fallback. `_elmhurst_gas_boiler_main_fuel`
adopts the §15.0 carrier only when the SAP code is in 101-119 AND §15.0
resolves to a gas/LPG fuel, so a regular boiler + electric immersion
(§15.0 = "Electricity") still strict-raises rather than mis-billing gas
as electric.
2291 passed (+1), 0 failed; pyright net-zero on both files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EpcSimulation is the Simulation Overlay — a narrow all-optional partial
mirror of EpcPropertyData/SapBuildingPart (wall surface first), targeting
building parts by BuildingPartIdentifier (composition, not inheritance).
apply_simulations(baseline, simulations) deep-copies the baseline, folds
overlays in order (later wins on a shared field) via a generic non-None
field write, and returns a throwaway EpcPropertyData for the calculator;
the baseline is never mutated.
Four behaviour tests (hand-built EPD from the 000490 fixture, no PDF):
targeted-write-leaves-others-untouched, empty-overlay no-op, sequential
last-wins, baseline-immutability. pyright strict clean.
Slice 1 of the Modelling stage rebuild (ADR-0016). Closes#1153.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reframe Recommendation as a target surface (partitions the EpcPropertyData
surface, so selected overlays never collide); add Measure Option,
Simulation Overlay (EpcSimulation), Product, Cost, Contingency, and
Measure Dependency. ADR-0016 fixes the scoring/optimisation approach
(warm-start grouped-knapsack MILP -> deterministic package re-score ->
greedy repair, with a final-package marginal cascade for display
attribution), resolving the open question in ADR-0005 §14.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Point-in-time note for the next agent: what S0380.185-189 shipped (worksheet
PE/CO2 pins, the two D_PV electricity-vs-gain fixes, and the thermal-mass-
parameter Table 22 fix), the per-line diagnosis template, the two worksheet-
block / gains-vs-solar traps, and the ranked open slices (Summary-path fuel
derivation first, then pin the simulated 001431 case, then cert 6035).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The §7 mean-internal-temperature cascade hardcoded the thermal mass parameter
(TMP) to 250 kJ/m²K at all 5 call sites, ignoring construction. RdSAP 10
§5.16 Table 22 (PDF p.48) makes TMP construction-dependent:
100 kJ/m²K — timber frame, cob, park home (regardless of internal
insulation); OR masonry (stone/solid brick/cavity/system
built) WITH internal insulation.
250 kJ/m²K — masonry WITHOUT internal insulation.
A too-high TMP inflates the §7 time constant τ = Cm/(3.6·H) (e.g. 40 h vs
16 h), under-cuts the temperature reduction between heating periods, and
over-states mean internal temperature → over-states space heating.
`_thermal_mass_parameter_kj_per_m2_k(epc)` classifies the MAIN building's
wall via the RdSAP `wall_construction` codes (5/7/8 = timber/cob/park) and
`wall_insulation_type` codes (3/7 = internal); unknown/curtain fall back to
the masonry 250 (no regression on unlisted classes). 17-case parametrised
test covers every Table 22 branch.
Diagnosis (per-line walk vs the user-simulated 001431 worksheet, same
archetype as golden cert 6035): fabric (26-37), internal gains (73), climate
(96)m and HTC (39) all EXACT; the entire +8.78 PE / -1.76 SAP gap was §7 MIT
(92) +0.71 °C, traced to TMP 250 vs Table 22's 100 (solid brick WITH internal
insulation). Fix closes the simulated case to 1e-4 on PE and CO2.
Blast radius: only golden cert 6035 re-pins (solid brick + internal
insulation) — SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. The
47 dr87 cohort, 6 U985 fixtures and 41-variant heating corpus are all
masonry-no-internal → TMP unchanged at 250, all still pass. 2290 pass
(+17 new), 0 fail; pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single durable doc so agents can pick up the calculator without reading
historical handovers: (1) the accuracy bar for the two input paths
(site-notes 1e-4 vs worksheet; API 1e-4 when a worksheet exists, ±0.5
register fallback otherwise; cross-mapper parity); (2) the per-line-walk
debugging loop incl. comparing site-notes vs API; (3) the tools &
pipeline (Summary PDF → extractor → from_elmhurst_site_notes →
cert_to_inputs → calculate_sap_from_inputs → SapResult, plus the API
from_api_response front-end, section helpers, and where the test vectors
live). Pointer added from SAP_CALCULATOR.md; HANDOVER_* flagged as
point-in-time notes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Appendix M1 §3a (p.93) defines PV-eligible demand as
D_PV,m = E_L,m + E_A,m + E_cook,m + E_ES,m + (231)·n_m/365 + E_space,m + E_water,m
where E_L,m is the lighting ELECTRICITY (Appendix L eq L10, = line (232)).
The cascade fed `internal_gains_result.lighting_monthly_w` — the L12 internal
heat GAIN G_L,m = E_L,m × 0.85 ("assuming 15%" of lighting energy does not
become internal heat) — into D_PV, understating it by 15% of lighting on
every PV cert. That depressed the monthly β onsite/export split and
under-credited PV primary energy uniformly across the year.
Same gain-vs-electricity class as the cooking fix S0380.73 (L18 gain vs L20
electricity). Fix: scale the (shape-identical) lighting gain profile to the
annual E_L `lighting_kwh_per_yr` (= (232)), mirroring the (219)m hot-water
scale-to-annual. Magnitude-only, so the shape-weighted lighting CO2/PE
effective factor (Σkwh×f/Σkwh, magnitude-invariant) is unchanged; appliances
need no scaling (G_A = E_A, no 0.85). Diagnosis was empirical first (calc
lighting D_PV 95.1 vs worksheet (232) 111.88, ratio exactly 0.85) then
confirmed against the spec text (L9d/L10/L12, M1 §3a).
Impact (calc − full-precision dr87 worksheet): ALL 47 worksheet certs now
match at <1e-4 on BOTH PE (max |Δ| 0.0000 kWh/m²) and CO2 (max |Δ| 0.0000 kg)
— the convergence target, met cohort-wide. Combined with S0380.187 this
closes the entire gas+PV + ASHP PV residual. Re-pinned 47 worksheet residuals
to 0.0000 and 31 drifted lodged residuals (PV certs). SAP integers unchanged;
chain SAP 1e-4 intact (164 pass). 2273 pass, 0 regressions; pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PV onsite/export β-split (SAP 10.2 Appendix M1 §3a, p.93) divides PV
generation by the monthly PV-eligible electricity demand D_PV,m. The cascade
included main and water electricity (when those fuels are electric) but had
no term for SECONDARY space heating. For the 10 cohort-2 gas-main +
electric-secondary + PV certs, the (215)m secondary electric fuel was dropped
from D_PV,m — understating demand in the heating months only, depressing the
monthly β, and under-crediting onsite PV primary energy.
Spec: Appendix M1 §3a counts E_space,m as the dwelling's TOTAL electric
space-heating demand; for a gas-main/electric-secondary dwelling that is the
secondary fuel. Diagnosis was decisive: E_PV (generation) matched the
worksheet exactly every month, the onsite (233a) split diverged ONLY in
heating months (Jun-Sep near-exact), and all 10 affected certs have PV while
all clean gas certs have none. Empirically adding (215)m to D_PV closed cert
3136 onsite 726.9 → 790.3 (worksheet 792.1).
Impact (calc − full-precision dr87 worksheet), the 10 certs:
PE +0.5..+1.5 → +0.02..+0.046 kWh/m²; CO2 −0.5..−1.1 → +0.002..+0.0095 kg.
The whole 47-cert cohort now matches at PE <0.05 / CO2 <0.025. SAP integers
unchanged; chain SAP 1e-4 pins intact (164 pass). The uniform ~0.03 PE remnant
on PV certs is the separate (233a)/(233b) summer-month D_PV discrepancy.
Re-pinned the 10 worksheet + 9 lodged golden residuals (improvements).
2273 pass, 0 regressions; pyright net-zero (file's 32 errors pre-existing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>