mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
05a4f5f84a
commit
2bf42d046e
2 changed files with 148 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue