mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(modelling): optimise_min_cost — least-cost-to-target selector (#1152 follow-up)
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>
This commit is contained in:
parent
5620f49f18
commit
05a4f5f84a
2 changed files with 156 additions and 0 deletions
|
|
@ -90,6 +90,42 @@ def optimise(
|
|||
return best
|
||||
|
||||
|
||||
def optimise_min_cost(
|
||||
groups: list[list[ScoredOption]],
|
||||
budget: Optional[float],
|
||||
target_gain: float,
|
||||
) -> Optional[list[ScoredOption]]:
|
||||
"""Select at most one ScoredOption per group to **minimise total cost**
|
||||
subject to total SAP gain ``>= target_gain`` and total cost ``<= budget``
|
||||
(None = unconstrained) — the least-cost-to-target objective (ADR-0016
|
||||
amendment). Exact enumeration over every pick-one-or-skip-per-group package.
|
||||
Returns the cheapest target-reaching package (ties broken toward the higher
|
||||
gain — "recommend more"), or ``None`` when no package within budget reaches
|
||||
the target (the caller falls back to max-gain). A non-positive
|
||||
``target_gain`` is met by the empty package."""
|
||||
choices_per_group: list[list[Optional[ScoredOption]]] = [
|
||||
[None, *group] for group in groups
|
||||
]
|
||||
|
||||
best: Optional[list[ScoredOption]] = None
|
||||
best_cost: float = 0.0
|
||||
best_gain: float = 0.0
|
||||
for combo in itertools.product(*choices_per_group):
|
||||
selected: list[ScoredOption] = [
|
||||
choice for choice in combo if choice is not None
|
||||
]
|
||||
total_cost: float = sum(_option_cost(s.option) for s in selected)
|
||||
if budget is not None and total_cost > budget:
|
||||
continue
|
||||
total_gain: float = sum(s.sap_gain for s in selected)
|
||||
if total_gain < target_gain:
|
||||
continue
|
||||
# Minimise cost; on a tie prefer the higher-gain package.
|
||||
if best is None or (-total_cost, total_gain) > (-best_cost, best_gain):
|
||||
best, best_cost, best_gain = selected, total_cost, total_gain
|
||||
return best
|
||||
|
||||
|
||||
class Scorer(Protocol):
|
||||
"""The whole-package scoring primitive — `PackageScorer` satisfies it.
|
||||
Kept structural so the repair loop is testable with a stub scorer."""
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from domain.modelling.optimisation.optimiser import (
|
|||
OptimisedPackage,
|
||||
ScoredOption,
|
||||
optimise,
|
||||
optimise_min_cost,
|
||||
optimise_package,
|
||||
)
|
||||
from domain.modelling.scoring.package_scorer import Score
|
||||
|
|
@ -203,6 +204,125 @@ def test_within_budget_partial_selection_prefers_the_higher_gain_option() -> Non
|
|||
assert _selected_types(selection) == {"loft_insulation"}
|
||||
|
||||
|
||||
# --- optimise_min_cost: least-cost-to-target selection (ADR-0016 amendment) ---
|
||||
|
||||
|
||||
def test_min_cost_picks_the_cheapest_package_that_reaches_the_target() -> None:
|
||||
# Arrange — two packages both clear the target gain; one is cheaper.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[
|
||||
_scored("loft_insulation", gain=10.0, cost=2000.0),
|
||||
_scored("external_wall_insulation", gain=15.0, cost=3000.0),
|
||||
],
|
||||
]
|
||||
|
||||
# Act
|
||||
selection = optimise_min_cost(groups, budget=10000.0, target_gain=10.0)
|
||||
|
||||
# Assert — least-cost-to-target takes the +10 @ £2000, NOT the higher-gain
|
||||
# +15 @ £3000 (no overshoot, surplus budget unspent).
|
||||
assert selection is not None
|
||||
assert _selected_types(selection) == {"loft_insulation"}
|
||||
|
||||
|
||||
def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None:
|
||||
# Arrange — no single option reaches +10; the cheapest combo that does is
|
||||
# cavity (+6, £1000) + loft (+4, £1500) = +10 @ £2500, beating EWI (+10,
|
||||
# £8000).
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[
|
||||
_scored("cavity_wall_insulation", gain=6.0, cost=1000.0),
|
||||
_scored("external_wall_insulation", gain=10.0, cost=8000.0),
|
||||
],
|
||||
[_scored("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
]
|
||||
|
||||
# Act
|
||||
selection = optimise_min_cost(groups, budget=10000.0, target_gain=10.0)
|
||||
|
||||
# Assert
|
||||
assert selection is not None
|
||||
assert _selected_types(selection) == {
|
||||
"cavity_wall_insulation",
|
||||
"loft_insulation",
|
||||
}
|
||||
|
||||
|
||||
def test_min_cost_breaks_cost_ties_toward_the_higher_gain() -> None:
|
||||
# Arrange — two equally-priced packages both reach the target; prefer the
|
||||
# one with more headroom ("recommend more" on a tie).
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[
|
||||
_scored("cavity_wall_insulation", gain=10.0, cost=2000.0),
|
||||
_scored("external_wall_insulation", gain=14.0, cost=2000.0),
|
||||
],
|
||||
]
|
||||
|
||||
# Act
|
||||
selection = optimise_min_cost(groups, budget=10000.0, target_gain=10.0)
|
||||
|
||||
# Assert
|
||||
assert selection is not None
|
||||
assert _selected_types(selection) == {"external_wall_insulation"}
|
||||
|
||||
|
||||
def test_min_cost_returns_none_when_target_unreachable_within_budget() -> None:
|
||||
# Arrange — the only target-reaching package costs more than the budget.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored("external_wall_insulation", gain=10.0, cost=8000.0)],
|
||||
]
|
||||
|
||||
# Act
|
||||
selection = optimise_min_cost(groups, budget=5000.0, target_gain=10.0)
|
||||
|
||||
# Assert — infeasible (caller falls back to max-gain).
|
||||
assert selection is None
|
||||
|
||||
|
||||
def test_min_cost_returns_none_when_no_package_reaches_the_target() -> None:
|
||||
# Arrange — even everything together falls short of the target gain.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)],
|
||||
[_scored("loft_insulation", gain=3.0, cost=1500.0)],
|
||||
]
|
||||
|
||||
# Act
|
||||
selection = optimise_min_cost(groups, budget=None, target_gain=10.0)
|
||||
|
||||
# Assert
|
||||
assert selection is None
|
||||
|
||||
|
||||
def test_min_cost_unbudgeted_picks_cheapest_reaching_target_not_everything() -> None:
|
||||
# Arrange — no budget cap, but min-cost still means cheapest-to-target, not
|
||||
# "install everything".
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored("cavity_wall_insulation", gain=10.0, cost=1000.0)],
|
||||
[_scored("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
]
|
||||
|
||||
# Act — cavity alone (+10 @ £1000) already reaches the target.
|
||||
selection = optimise_min_cost(groups, budget=None, target_gain=10.0)
|
||||
|
||||
# Assert — loft is left off; it would only add cost past the target.
|
||||
assert selection is not None
|
||||
assert _selected_types(selection) == {"cavity_wall_insulation"}
|
||||
|
||||
|
||||
def test_min_cost_non_positive_target_selects_nothing() -> None:
|
||||
# Arrange — a target already met (gain 0 needed) is reached by the empty
|
||||
# package at zero cost.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)],
|
||||
]
|
||||
|
||||
# Act
|
||||
selection = optimise_min_cost(groups, budget=5000.0, target_gain=0.0)
|
||||
|
||||
# Assert — the cheapest target-reaching package is the empty one.
|
||||
assert selection == []
|
||||
|
||||
|
||||
def test_repair_adds_an_untreated_group_option_to_close_the_undershoot() -> None:
|
||||
# Arrange — role-1 under-counts roof (signal 0 → warm-start skips it), but
|
||||
# its true re-scored gain (+4) is what closes the target.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue