feat(modelling): optimise_package targets least-cost, falls back to max-gain

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>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 15:43:06 +00:00
parent 05a4f5f84a
commit 2bf42d046e
2 changed files with 148 additions and 13 deletions

View file

@ -155,22 +155,74 @@ def optimise_package(
target_sap: Optional[float],
dependencies: Sequence[MeasureDependency] = (),
) -> OptimisedPackage:
"""Warm-start with the grouped knapsack (role-1 signal), inject any forced
Measure Dependencies the selection triggers, re-score the whole package on
the real scorer (role-2 truth), then while the true SAP undershoots
``target_sap`` greedy-add the untreated-group Option with the best
marginal SAP-per-£ (its own ventilation dependency folded in) and re-score,
until the target is met or no affordable improving Option remains (ADR-0016).
A forced dependency is mandatory-when-triggered: it is injected regardless of
budget and its cost counts toward the package spend (so repair sees less
headroom). ``target_sap``/``budget`` of None mean unconstrained. The returned
`selected` includes the injected dependencies."""
"""Select the Optimised Package for one Property + Scenario (ADR-0016 +
its amendment).
With a ``target_sap`` (an Increasing EPC goal) the objective is
**least-cost-to-target**: warm-start with the cheapest package whose role-1
signal reaches the target gain within budget (`optimise_min_cost`), inject
any forced Measure Dependencies, re-score the whole package for the truth,
and greedy-repair toward ``target_sap`` while it undershoots. If the target
is unreachable within budget the warm-start is infeasible, or the repaired
package still falls short on the true score fall back to the **maximum
improvement the budget buys** (`optimise`). The min-cost objective stops at
the target and does not overshoot into a higher band; surplus budget is left
unspent.
Without a ``target_sap`` (other goals) it is max-gain-within-budget. Either
way forced dependencies are injected on every path and their cost counts
toward the spend; the returned `selected` includes them. ``budget`` of None
means unconstrained."""
if target_sap is None:
return _max_gain_package(groups, scorer, baseline_epc, budget, dependencies)
baseline_sap: float = _score(scorer, baseline_epc, []).sap_continuous
target_gain: float = target_sap - baseline_sap
chosen: Optional[list[ScoredOption]] = optimise_min_cost(
groups, budget, target_gain
)
if chosen is not None:
package: OptimisedPackage = _repair_to_target(
chosen, groups, dependencies, scorer, baseline_epc, budget, target_sap
)
if package.score.sap_continuous >= target_sap:
return package
# Target unreachable within budget (warm-start infeasible, or the repaired
# package still falls short) → best effort: the most improvement budget buys.
return _max_gain_package(groups, scorer, baseline_epc, budget, dependencies)
def _max_gain_package(
groups: list[list[ScoredOption]],
scorer: Scorer,
baseline_epc: EpcPropertyData,
budget: Optional[float],
dependencies: Sequence[MeasureDependency],
) -> OptimisedPackage:
"""Max-gain-within-budget, dependencies injected and re-scored — the
no-target objective and the unreachable-target fallback."""
chosen: list[ScoredOption] = optimise(groups, budget)
selected: list[ScoredOption] = _inject(chosen, dependencies)
score: Score = _score(scorer, baseline_epc, selected)
if target_sap is None:
return OptimisedPackage(selected=selected, score=score)
return OptimisedPackage(
selected=selected, score=_score(scorer, baseline_epc, selected)
)
def _repair_to_target(
chosen: list[ScoredOption],
groups: list[list[ScoredOption]],
dependencies: Sequence[MeasureDependency],
scorer: Scorer,
baseline_epc: EpcPropertyData,
budget: Optional[float],
target_sap: float,
) -> OptimisedPackage:
"""Inject dependencies onto the warm-start, re-score for the truth, then
greedy-add the untreated-group Option with the best marginal SAP-per-£ (its
own dependency folded in) until the true SAP clears ``target_sap`` or no
affordable improving Option remains."""
selected: list[ScoredOption] = _inject(chosen, dependencies)
score: Score = _score(scorer, baseline_epc, selected)
while score.sap_continuous < target_sap:
candidate = _best_repair_candidate(
groups, chosen, dependencies, scorer, baseline_epc, score, budget

View file

@ -402,6 +402,89 @@ def test_repair_stops_when_no_affordable_improving_option_remains() -> None:
assert abs(package.score.sap_continuous - 45.0) <= 1e-9
# --- optimise_package: least-cost-to-target objective (ADR-0016 amendment) ---
def test_package_stops_at_the_target_and_does_not_overshoot() -> None:
# Arrange — wall alone already clears the target; max-gain would add roof +
# floor too. Least-cost-to-target must stop at the wall.
groups: list[list[ScoredOption]] = [
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
[_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
[_scored_overlay("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=_FLOOR_OVERLAY)],
]
scorer = _StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0)
# Act — target 69 (gain 9); wall (+10 → 70) reaches it for £1000.
package: OptimisedPackage = optimise_package(
groups=groups,
scorer=scorer,
baseline_epc=build_epc(),
budget=10000.0,
target_sap=69.0,
)
# Assert — just the wall; roof + floor (which would reach 80) are left off,
# surplus budget unspent.
assert _selected_types(package.selected) == {"cavity_wall_insulation"}
assert abs(package.score.sap_continuous - 70.0) <= 1e-9
def test_package_falls_back_to_max_gain_when_target_unreachable() -> None:
# Arrange — even all three measures (+20 → 80) cannot reach the target.
groups: list[list[ScoredOption]] = [
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
[_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
[_scored_overlay("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=_FLOOR_OVERLAY)],
]
scorer = _StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0)
# Act — target 90 is out of reach; best effort is the most SAP budget buys.
package: OptimisedPackage = optimise_package(
groups=groups,
scorer=scorer,
baseline_epc=build_epc(),
budget=10000.0,
target_sap=90.0,
)
# Assert — max-gain: all three, SAP 80 (below target, best effort).
assert _selected_types(package.selected) == {
"cavity_wall_insulation",
"loft_insulation",
"suspended_floor_insulation",
}
assert abs(package.score.sap_continuous - 80.0) <= 1e-9
def test_package_repairs_when_the_signal_overshoots_the_true_score() -> None:
# Arrange — the wall's role-1 signal (+10) clears the target gain, so the
# min-cost warm-start picks it alone; but its true gain is only +5, so the
# package undershoots and repair must top it up.
groups: list[list[ScoredOption]] = [
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
[_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
]
scorer = _StubScorer(base=60.0, wall=5.0, roof=4.0, floor=0.0)
# Act — target 69 (gain 9). Warm-start {wall} (signal 10) → true 65 < 69 →
# repair adds the roof (+4) → 69.
package: OptimisedPackage = optimise_package(
groups=groups,
scorer=scorer,
baseline_epc=build_epc(),
budget=10000.0,
target_sap=69.0,
)
# Assert
assert _selected_types(package.selected) == {
"cavity_wall_insulation",
"loft_insulation",
}
assert abs(package.score.sap_continuous - 69.0) <= 1e-9
# --- Measure Dependency injection (ADR-0016) -------------------------------
_VENT_OVERLAY = EpcSimulation(