Slice 2 of ADR-0025 costing. _decommission maps the existing system to its
Southern Housing line: gas/oil flat 720, LPG 960 (tank+fuel removal),
electric-storage 570/840 by property-size band. Unmapped systems raise for
now -- the no-system/electric-other/other fallbacks land in the next slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of the per-dwelling ASHP bundle costing (ADR-0025). Products is
the rich catalogue collection over Product, owning the catalogue math: given
a typed AshpCostInputs it sums the applicable Southern Housing rate lines
(decommission + heat-pump band + fixed cylinder + full wet distribution) into
a Cost with the separate 25% ASHP contingency. Pure -- no EpcPropertyData or
calculator. Pinned exact (1e-9) against the real rate sheet. Reuse branch,
decommission variants, fallbacks, band edges and radiator clamp follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pins the cylinder-OVERWRITE path the earlier ASHP pins did not exercise:
boiler-1/boiler-2 added a cylinder where none existed, whereas boiler-3's
before is a mains-gas regular boiler (SAP code 101) that already heats its
own cylinder (size 2 / insulation type 2 / 80 mm). The fixed _ASHP_OVERLAY
overwrites it to the heat-pump cylinder (size 4 / insulation type 1 / 50 mm)
and switches the dwelling off mains gas (fuel 26->30, code 101->index 101413
+ category 4). The existing overlay reproduces the re-lodged after at delta 0
on SAP / CO2 / primary energy -- no overlay change needed; immersion_heating_
type is None in both, so that field (the electric-with-cylinder case) is
untouched here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second ASHP before/after (boiler 2): a gas boiler whose hot water is electric/
instantaneous (water-heating SAP code 909, no cylinder). The cascade pin passes
at 1e-4, exercising the overlay's water_heating_code reset 909 -> 901 that the
boiler-1 pin (already 901) did not. (After lodges control 2209 vs the overlay's
2210 — SAP-equivalent zone controls.)
Adds an xfail(strict) tripwire test_gas_boiler_instant_hw_before_baselines: the
raw before is not scorable on its own because the mapper maps the 'BGB' gas-
boiler EES code to an empty main_fuel_type (boiler-1's 'RGE' resolves to 26),
so Sap10Calculator raises MissingMainFuelType. Harmless to the pin (the overlay
overwrites fuel -> 30); flips green when the mapper derives mains gas from the
gas-boiler SAP code (separate mapper-front fix). ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ASHP bundle is a fixed whole-system end-state (confirmed: always the same
contractor cylinder), so the hot-water arrangement is fixed too. The overlay now
sets water_heating_code=901 ("from main system") absolutely, so a combi (909/611)
or electric (903/908) before is reset to HW-from-the-heat-pump — previously the
overlay relied on the before already lodging 901 (true for boiler-1, not in
general). No-op for the boiler-1 pin (stays 1e-4). Cascade pins for combi /
electric-with-cylinder befores await example certs. ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A typical mains-gas combi house re-lodged as an air-source heat pump closes at
1e-4 (gas-boiler 1 example from the technical specialist). Closes one named gap
the pin surfaced: a whole-system replacement to a PCDB-indexed system left the
old Table 4a sap_main_heating_code (104) beside the new heat-pump index, and the
stale code won the calculator's efficiency dispatch (hot water billed at boiler
not HP efficiency, ΔSAP 3.98). _fold_heating now enforces the mutual exclusion
of the two efficiency anchors (setting an index clears the SAP code and vice
versa). Also fixed a pre-existing pyright annotation in the lighting applicator
test. ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the air-source heat-pump Option to the competing "Heating & Hot Water"
bundles. Its overlay is the absolute heat-pump end-state (fixed representative
PCDB index 101413 + category 4 + control 2210 + HWP cylinder + single meter +
off mains gas), pinned against the relodged after-cert next slice. Eligibility
is physical/planning only (ADR-0024, research-grounded): any non-flat
house/bungalow, not listed/heritage (PlanningRestrictions.blocks_internal —
conservation is offered with a caveat, not excluded), not already a heat pump;
floor area / built form / fuel / fabric are deliberately not gates. recommend_
heating gains a restrictions param (defaulted). An already-HHR electric house
now correctly gets ASHP as a better end-state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The same absolute-target HHR overlay reproduces the common relodged after from
two different base systems (existing electric storage; "no system present"
electric) — proving the bundle is a true whole-system end-state. Closes one
named gap the pin surfaced: the relodged HHR cylinder lodges
cylinder_thermostat='Y', so HeatingOverlay + _fold_heating + the HHRSH overlay
gain cylinder_thermostat (ΔSAP 0.065 -> <1e-4). ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The heating Recommendation Generator (HHRSH first). Emits one "Heating & Hot
Water" Recommendation whose competing whole-system bundles the Optimiser picks
from; this slice builds the high-heat-retention storage Option. Its overlay is
the absolute HHR end-state (Table 4a code 409 + control 2404 + dual off-peak
meter + off-peak electric cylinder), pinned against the relodged after-cert in
the next slice. Eligibility translates legacy is_high_heat_retention_valid to
structured predicates (electric or off-gas main, not already HHR/heat-pump).
mains_gas and the heat emitter are unchanged by the measure, so unset. ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 5th EpcSimulation overlay surface and the deepest applicator fold yet: a
heating bundle is a whole-system replacement, so _fold_heating routes its
absolute-target fields across main_heating_details[0] (fuel/emitter/control +
sap_main_heating_code OR index+category), sap_heating (water_heating_* +
cylinder), the top-level EpcPropertyData (has_hot_water_cylinder), and
sap_energy_source (meter_type, mains_gas). ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Modelling glazing overlay's draught-proofing recompute (RdSAP 10 §8.1 —
a count over openable windows + doors) needs every openable window captured
with its draught_proofed flag. cert 001431's §11 lodges 17 windows but only
14 surfaced, via two distinct gaps:
1. Extractor (_extract_windows_from_layout): the one "Double glazing, known
data" row whose §11 Data-Source cell is "BFRC data" was rejected — it is
laid out as a standalone keyword line with the U-value on the next line
and lodges no Frame Type/Factor/Gap cells, so it never matched the joined
"<source> <U>" Manufacturer-line shape. Now anchored by a standalone
data-source form, with the RdSAP 10 §3.7 default frame factor (0.7) for
the absent frame cell.
2. Mapper (_is_elmhurst_roof_window): the two "Double pre 2002" rows
(U 3.1 / 3.4 > 3.0) were reclassified as roof windows by the U-value
backstop even though both are lodged on an "External wall". A window
lodged on a wall is vertical by definition; guard the U-value backstop so
it only fires when location/BP give no roof signal. The backstop's only
pinned cert (000516 W6) hand-builds its sap_roof_windows and so is
unaffected.
With both closed: 17 sap_windows, 0 misrouted to sap_roof_windows, 14
draught-proofed — reconstructing Elmhurst's lodged 84% (16/19 = (14 windows
+ 2 doors) / (17 windows + 2 doors)). Full calculator + modelling +
orchestration suites green (1885 pass); the 2 glazing draught-proofing
xfails remain (the overlay recompute is the glazing agent's front).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 of the lighting generator (ADR-0023): two real Elmhurst before/after
cascade pins, sourced from the low_energy_lighting recommendation folder. Both
close cleanly at 1e-4 with NO xfail — lighting changes only bulb counts →
Appendix L (232), no fabric coupling (contrast glazing's draught-proofing).
- zero existing LEDs: 20 incandescent → 20 LED
- some existing LEDs: 5 LED + 15 incandescent → 20 LED (partial-upgrade path)
The overlay-emits-correct-counts assertion lives in the Slice 2 unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2 of the lighting generator (ADR-0023): detect non-LED bulbs
(incandescent + CFL + low-energy-unknown > 0) and emit one "Lighting"
Recommendation whose single low_energy_lighting Option converts every bulb to
LED — the overlay sets led = total, the other three counts 0. Priced as a flat
per-bulb average x the non-LED count, contingency 0.26; the description names
"LED" while the measure_type stays MEASURE_MAP-aligned. None when already
all-LED or no bulb counts are lodged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the lighting generator (ADR-0023): the first whole-dwelling,
top-level overlay surface. LightingOverlay carries the four fixed-lighting
bulb-count fields by their exact EPC names (all Optional, absolute counts) +
EpcSimulation.lighting. The applicator's _fold_lighting writes the non-None
counts directly onto the result EpcPropertyData by name (setattr) — simpler
than ventilation's nested fold since the counts live top-level. Baseline
unmutated; pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With the mapper now in main, cert 001431 parses: it lodges four single-glazed
windows — codes 1 ("Single") and 15 ("single glazing, known data", a single
pane with manufacturer U/g). The generator only detected code 1, so it missed
two panes. Detect {1, 15}; set the secondary target to code 11 ("Secondary
glazing - Normal emissivity", what the cert re-lodges; score-neutral vs 7 but
exact).
A deterministic green pin proves the overlay reproduces the after's 14 windows
exactly. The full-SAP before->after pins are xfail(strict) tripwires: the
overlay nails the windows, but the measure also re-lodges percent_draughtproofed
84->100 (sealed units draught-proof the replaced openings) plus a ~0.4 SAP
fabric residual the overlay doesn't model yet — a glazing-measure coupling to
close later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ADR-0019 warns that wall_construction code 8 is Park home (PH), NOT system-
built. It was already excluded (8 isn't in the constructable-options map), but
only implicitly. Add an explicit early-return + named constant so a park home
can never be mis-keyed as system-built, with a pin as the tripwire. A park
home's proprietary panel is never EWI/IWI-suitable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The merged per-cert-mapper-validation work disambiguates Elmhurst 'SY System
build' from 'B Basement wall' (both lodged wall_construction=6), so
main_wall_is_basement is no longer wrongly True for system-built and the
solid-wall generator offers EWI+IWI. The strict xfail now XPASSes; drop the
marker so it stands as a real green cascade pin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A room-in-roof carries its insulation on its own sloping/stud/gable surfaces
(RdSAP 10 §3.10, Table 17/18), which the roof overlay's flat
roof_insulation_thickness bump cannot model. Without a guard a RR with an
uninsulated loft fell through to the loft fallback and mis-recommended 300 mm
loft insulation. Return None when the main part lodges a sap_room_in_roof,
deferring until a dedicated RR branch lands (ADR-0021).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 of the glazing generator (ADR-0022): a conservation/listed/heritage
protection (PlanningRestrictions.blocks_external) hard-picks secondary_glazing
instead of double_glazing -- an internal second pane, since the external units
can't be replaced on a protected building. Each single-glazed window upgrades
to the secondary target pinned from cert 001431 (glazing_type=7, u_value=2.90,
solar_transmittance=0.85 -- the outer single pane still drives solar gain).
The before/after cascade pins for both measures remain deferred behind the
glazing-label mapper coverage (owned by another agent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The glazing Recommendation Generator (ADR-0022): detect single-glazed
windows (SAP10.2 Table U2 code 1) and emit one "Windows" Recommendation whose
single Option rewrites every single-glazed window to the double-glazing target
pinned from cert 001431's before->after (glazing_type=5, u_value=1.40,
solar_transmittance=0.72). The overlay writes the per-window U/g into
WindowTransmissionDetails because the calculator consumes those directly.
Priced as a flat per-window average x count. No single-glazed windows -> None.
Planning gate (-> secondary) and the before/after cascade pins land next; the
pins are blocked on glazing-label mapper coverage (owned by another agent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The double_glazing / secondary_glazing Elmhurst before→after Summaries the
glazing generator's cascade pins will use (ADR-0022). NB: these don't parse
yet — cert 001431 lodges several unmapped glazing labels ("Secondary glazing",
"Secondary glazing - Normal emissivity", "Triple pre 2002", truncated
"Double…"/"Triple…" variants) that the mapper must cover first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the glazing generator (ADR-0022). `WindowOverlay` (all-optional
partial of one SapWindow) + `EpcSimulation.windows` keyed by sap_windows index.
The applicator folds it onto sap_windows[i]: glazing_type flat on the window,
u_value/solar_transmittance routed into its WindowTransmissionDetails (created
if absent) — the applicator's first nested write, because that's where the
calculator reads window heat loss and solar gain. Baseline left unmutated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 (ADR-0021). The dispatcher gains a flat-roof branch: a "flat"
roof_construction_type with no lodged thickness (uninsulated → None on the
Elmhurst path) gets a single flat_roof_insulation Option whose overlay raises
roof_insulation_thickness to 200 mm — tested before the loft fallback so a flat
roof's None doesn't trip the loft trigger. Pinned against the Elmhurst
before→after cert at 1e-4. Golden cohort roof firing unchanged (none across 57).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2 (ADR-0021). `recommend_roof_insulation` now owns the loft branch as the
fallback — a plain pitched loft, a thatched roof (the covering doesn't block
insulating the loft floor), or an unlodged roof type all take loft (joist)
insulation at 300 mm when `roof_insulation_thickness == 0`. Sloping is tested
first; a no-access roof gets nothing. Retired the standalone
`recommend_loft_insulation`; the orchestrator and its tests now call the
dispatcher.
Pinned: thatch before→after (None→300) reproduces at 1e-4; the existing loft pin
still holds through the dispatcher. Behaviour-preserving on the golden cohort
(roof measure unchanged: none across all 57) — the dispatch is strictly more
precise (won't fire loft on a sloping/no-access roof).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the roof-insulation generator (ADR-0021). New `recommend_roof_insulation`
dispatcher keys on the `roof_construction_type` string: a "sloping ceiling" roof
that is uninsulated (roof_insulation_thickness 0/None) gets a single
`sloping_ceiling_insulation` Option whose overlay raises roof_insulation_thickness
to 100 mm. Pinned against the Elmhurst before→after cert 001431 at 1e-4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reference the calculator-side wall_construction=6 disambiguation issue from the
strict-xfail tripwire and ADR-0019, so the blocker is traceable both ways.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
System-built (precast/no-fines concrete) takes both solid-wall Options like
solid brick (ADR-0019), keyed on `wall_construction == 6` (WALL_SYSTEM_BUILT,
Elmhurst `SY`). A basement-suitability guard (`main_wall_is_basement`) is added
since a below-ground basement wall is never EWI/IWI-suitable.
This is currently inert: `B Basement wall` also maps to 6 (mapper.py:2100) and
`main_wall_is_basement` is derived as `wall_construction == 6`, so every code-6
wall reads as basement and is guarded out — the live cohort is unchanged. The
system-built EWI/IWI cascade pin is committed as a strict-xfail tripwire that
flips green the moment the calculator disambiguates system-built from basement
(MAIN wall_construction==6 with main_wall_is_basement False). `wall_construction
== 8` is Park home, not system-built — not keyed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3a (ADR-0020). PlanningRestrictions relocated out of the solid-wall
generator into domain/geospatial/ as the shared, Property-level value object
(three distinct flags + measure-specific blocks_external/blocks_internal).
GeospatialRepository gains a non-abstract planning_restrictions_for defaulting
to None (sources without the flags need not implement it); GeospatialS3Repository
reads conservation_status/is_listed_building/is_heritage_building from the same
Open-UPRN partition as the coordinates (legacy column names — to confirm in the
S3 deep-dive). Shared _row_for helper dedups the partition lookup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2d. A flat can take IWI (its own unit) but not EWI (whole-block
coordination) — ADR-0019. _is_flat handles both ingestion representations:
the Elmhurst name form ('Flat') and the API stringified RdSAP code ('2' = Flat
per PROPERTY_TYPE_LOOKUP). Completes slice 2's eligibility surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2c. recommend_solid_wall takes a PlanningRestrictions value object
(defaults unrestricted): a conservation area removes the EWI Option (external
appearance), a listed or heritage building removes both EWI and IWI (protected
fabric) -> None when nothing survives (ADR-0019). Plus a guard that a cavity
wall yields no solid-wall Recommendation (it is handled by recommend_cavity
_wall). PlanningRestrictions will be sourced onto the Property from the
geospatial layer in slice 3 (ADR-0020).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2b. Timber frame (wall_construction=5) takes internal wall insulation but
not external (not constructable — ADR-0019), so the generator offers IWI only.
Cascade pin: the IWI Option reproduces the re-lodged timber-frame after at
abs(diff) <= 1e-4 (general Table 6 insulation-thickness bucket, not the solid-
brick documentary path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2a. New recommend_solid_wall emits one Main-wall Recommendation carrying
External + Internal wall-insulation Options for an uninsulated (wall_insulation
_type=4) solid-brick (wall_construction=3) main wall, each priced at the heat-
loss wall area. Cascade pin: the generator's EWI and IWI Options reproduce
their respective re-lodged afters at abs(diff) <= 1e-4.
Detection keys on wall_construction code, not description (ADR-0019 note
corrected): the Elmhurst ingestion path leaves walls[].description empty, so
the code is the only cross-path signal; codes 1-5 are consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of solid-wall insulation. BuildingPartOverlay gains a
wall_insulation_thickness field; the generic applicator already folds it onto
SapBuildingPart by name. With wall_insulation_type=1 (EWI) / 3 (IWI) + 100 mm,
the calculator derives the post-insulation U-value (§5.8 documentary path,
λ=0.04 default) — and for IWI also lowers the thermal-mass parameter. Two new
Elmhurst before/after cascade pins (solid-brick EWI + IWI, cert 001431)
reproduce the re-lodged after at abs(diff) <= 1e-4 across SAP/CO2/PE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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 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>