docs(modelling): handover — reflect generators/scoring/optimisation layout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 13:50:21 +00:00
parent 84ec6da032
commit 143f8b0805

View file

@ -1,6 +1,6 @@
# HANDOVER — Modelling stage rebuild
**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `0fec0699`.
**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `84ec6da0`.
**PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153#1161. **All slices #1153#1161 closed.**
## Issue status
@ -33,12 +33,19 @@
- Tracer persists **SAP + CO₂ (tonnes = calc kg ÷ 1000) + cost + derived `post_epc_rating`**. Energy/bill columns deferred. Idempotent replace per (property_id, scenario_id).
- **Optimiser = exact pure-Python multiple-choice knapsack**, NOT `mip`. Recycles `GainOptimiser`/`CostOptimiser`'s *formulation* (≤1/group, maximise gain s.t. budget) but not the dependency — **`mip`'s CBC backend does not load on this aarch64 container** (`NameError: cbclib`), so the legacy solver can't run/be tested here. ADR-0016's MILP is only a warm-start signal, so exact small-scale enumeration is ample. Re-score + greedy-repair toward the goal's SAP target gives the truth.
## `domain/modelling/` layout (grouped `84ec6da0`)
Behaviour lives in subpackages; shared value-object vocabulary stays flat at the top (imported everywhere): `recommendation.py` (Recommendation / MeasureOption / Cost), `plan.py`, `scenario.py`, `product.py`, `contingencies.py`, `simulation.py` (EpcSimulation overlay).
- `generators/``wall_recommendation` / `roof_recommendation` / `floor_recommendation`.
- `scoring/``overlay_applicator` (apply_simulations), `package_scorer` (role 2), `scoring` (role-1 `independent_option_impacts` + role-3 `marginal_impacts`). Note the path is `domain.modelling.scoring.scoring` for the role-1/3 module.
- `optimisation/``optimiser`, `measure_dependency`.
## What's built (all in `domain/modelling/`, `infrastructure/postgres/`, `repositories/`, `orchestration/`)
- Generators: `recommend_cavity_wall` / `recommend_loft_insulation` (300 mm) / `recommend_floor_insulation` (sets `floor_insulation_type_str`).
- `simulation.py` overlay + `overlay_applicator.apply_simulations` (generic field-fold) + `package_scorer.PackageScorer.score` (role 2) + `scoring.py` (`marginal_impacts` role 3, `independent_option_impacts` role 1).
- Generators (`generators/`): `recommend_cavity_wall` / `recommend_loft_insulation` (300 mm) / `recommend_floor_insulation` (sets `floor_insulation_type_str`).
- `simulation.py` overlay + `scoring/overlay_applicator.apply_simulations` (generic field-fold) + `scoring/package_scorer.PackageScorer.score` (role 2) + `scoring/scoring.py` (`marginal_impacts` role 3, `independent_option_impacts` role 1).
- `scenario.py` `Scenario(id, goal, goal_value, budget, is_default)`; `plan.py` `Plan` + `PlanMeasure` (derives cost_of_works/contingency_cost/co2_savings/post_epc_rating).
- `optimiser.py``optimise(groups, budget)` (exact knapsack) + `optimise_package(...)` (re-score + greedy repair, `Scorer` Protocol, `OptimisedPackage`).
- `optimisation/optimiser.py` — `optimise(groups, budget)` (exact knapsack) + `optimise_package(...)` (re-score + greedy repair, `Scorer` Protocol, `OptimisedPackage`).
- `infrastructure/postgres/`: `scenario_table.ScenarioRow`, `plan_table.{PlanRow,RecommendationRow}` (mirrors of live tables; `from_domain`).
- `repositories/`: `scenario/`, `plan/`, `product/` (Postgres + Json) — all on the `UnitOfWork` (`uow.scenario`/`uow.product`/`uow.plan`).
- `ModellingOrchestrator.run(property_ids, scenario_ids, portfolio_id)` — one UoW, commit once; generate (wall/roof/floor) → role-1 score → `optimise_package` → role-3 attribute → persist. Wired into `AraFirstRunPipeline` + `handler.py`.
@ -64,8 +71,8 @@ Commit per TDD slice; conventional-commit message ending `Co-Authored-By: Claude
Forks resolved with the user (AskUserQuestion): **guard now** (skip when already MEV/MVHR), **persist as a Plan Measure** (cost + real negative marginal), **forced but its cost counts toward spend** (mandatory-when-triggered, never budget-gated; repair sees less headroom).
1. **`7c59e919`** — Simulation Overlay grows a dwelling-level segment: `VentilationOverlay` (all-optional partial of `SapVentilation`, field `mechanical_ventilation_kind`) + `EpcSimulation.ventilation`; `apply_simulations` folds it onto `sap_ventilation` (creating one if the baseline lodged none). Until now the overlay was building-part only — ventilation is whole-dwelling.
2. **`6b11c902`** — generic injection in the optimiser: `MeasureDependency(triggers: frozenset[str], required: ScoredOption)` lives in `optimiser.py` (its input contract). `optimise_package(..., dependencies=())` injects any dependency whose triggers ∩ selected-measure-types, before every re-score (initial **and** each repair). `_inject` dedups by required measure-type. Forced (injected even over budget) but its cost is in `_package_cost`, so repair headroom shrinks. `_best_repair_candidate` folds in any dependency a candidate newly triggers, so its marginal SAP and incremental cost are truthful; affordability gates on whole-package cost vs budget. Returned `selected` includes the injected deps. Optimiser stays domain-agnostic — no ventilation import.
3. **`1bf5b410`** — `domain/modelling/measure_dependency.py`: `MEASURES_NEEDING_VENTILATION` (cavity/internal/external wall, cf. legacy `assumptions.measures_needing_ventilation`) + `ventilation_dependency(epc, products)` → MEV Option (`mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"`, decentralised MEV = legacy "mechanical, extract only"), priced at 2 fully-loaded units. Returns **None** when `sap_ventilation.mechanical_ventilation_kind` is already set (= legacy `has_ventilation` — confirmed against `backend/Property.py:1236`). Note: builder fetches the Product up-front, so the catalogue needs a `mechanical_ventilation` row for **every** not-yet-ventilated dwelling, even if no wall is ultimately selected.
2. **`6b11c902`** — generic injection in the optimiser: `MeasureDependency(triggers: frozenset[str], required: ScoredOption)` lives in `optimisation/optimiser.py` (its input contract). `optimise_package(..., dependencies=())` injects any dependency whose triggers ∩ selected-measure-types, before every re-score (initial **and** each repair). `_inject` dedups by required measure-type. Forced (injected even over budget) but its cost is in `_package_cost`, so repair headroom shrinks. `_best_repair_candidate` folds in any dependency a candidate newly triggers, so its marginal SAP and incremental cost are truthful; affordability gates on whole-package cost vs budget. Returned `selected` includes the injected deps. Optimiser stays domain-agnostic — no ventilation import.
3. **`1bf5b410`** — `domain/modelling/optimisation/measure_dependency.py`: `MEASURES_NEEDING_VENTILATION` (cavity/internal/external wall, cf. legacy `assumptions.measures_needing_ventilation`) + `ventilation_dependency(epc, products)` → MEV Option (`mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"`, decentralised MEV = legacy "mechanical, extract only"), priced at 2 fully-loaded units. Returns **None** when `sap_ventilation.mechanical_ventilation_kind` is already set (= legacy `has_ventilation` — confirmed against `backend/Property.py:1236`). Note: builder fetches the Product up-front, so the catalogue needs a `mechanical_ventilation` row for **every** not-yet-ventilated dwelling, even if no wall is ultimately selected.
4. **`0fec0699`** — orchestrator wiring: `_measure_dependencies` builds the (≤1) dependency; `_BEST_PRACTICE_ORDER` gains `"mechanical_ventilation"` between loft and floors (role-3 cascade walls→roof→**vent**→floor); ventilation persists as a Plan Measure with its real negative marginal + cost. Added `mechanical_ventilation: 0.26` contingency (legacy `Costs.CONTINGENCIES`). On 000490 the real calculator scores MEV at **1.275 SAP**.
Gotchas for the next agent: the ventilation Product/contingency must exist for any not-yet-ventilated dwelling (build-time fetch, not inject-time); the stub scorer in `test_optimiser.py` indexes `building_parts[MAIN]`, so vent-only overlays need the separate `_VentStubScorer`.