diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 2b77cbdb..29bd5a42 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -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 diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index fc6f3fb9..dfacf51a 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -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(