From 05a4f5f84a4ba05ca7e9c429ac3332b1f75b4bb8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 15:31:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(modelling):=20optimise=5Fmin=5Fcost=20?= =?UTF-8?q?=E2=80=94=20least-cost-to-target=20selector=20(#1152=20follow-u?= =?UTF-8?q?p)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- domain/modelling/optimisation/optimiser.py | 36 +++++++ tests/domain/modelling/test_optimiser.py | 120 +++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 4c838629..2b77cbdb 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -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.""" diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index 61d0fbcc..fc6f3fb9 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -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.