"""Behaviour of the Optimiser core: a grouped-knapsack MILP over per-Option role-1 scores (ADR-0016). Picks at most one Option per Recommendation (disjoint groups, no cross-group constraints) to maximise total SAP gain subject to the Scenario budget. This is the warm-start *signal* — the truthful figure comes from the whole-package re-score + repair (a later slice); here we test the selection with synthetic scores and no calculator. """ from __future__ import annotations from domain.modelling.optimisation.optimiser import ( OptimisedPackage, ScoredOption, optimise, optimise_min_cost, optimise_package, ) from domain.modelling.measure_type import MeasureType from tests.domain.modelling._optimiser_fixtures import ( FLOOR_OVERLAY, ROOF_OVERLAY, WALL_OVERLAY, StubScorer, scored_option, selected_types, ventilation_dependency, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) def test_grouped_knapsack_maximises_gain_within_budget() -> None: # Arrange — wall group has two mutually-exclusive options; roof + floor one # each. EWI has the best gain but is unaffordable alongside the rest. groups: list[list[ScoredOption]] = [ [ scored_option("external_wall_insulation", gain=10.0, cost=8000.0), scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0), ], [scored_option("loft_insulation", gain=4.0, cost=1500.0)], [scored_option("suspended_floor_insulation", gain=3.0, cost=2000.0)], ] # Act selection: list[ScoredOption] = optimise(groups, budget=5000.0) # Assert — cavity + loft + floor (cost 4500, gain 13) beats any package # containing the 8000 EWI option within the 5000 budget. assert selected_types(selection) == { "cavity_wall_insulation", "loft_insulation", "suspended_floor_insulation", } def test_picks_at_most_one_option_per_group() -> None: # Arrange — both wall options are individually affordable. groups: list[list[ScoredOption]] = [ [ scored_option("external_wall_insulation", gain=10.0, cost=2000.0), scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0), ], ] # Act selection: list[ScoredOption] = optimise(groups, budget=10000.0) # Assert — never both treatments of the same wall; the higher-gain one wins. assert len(selection) == 1 assert selected_types(selection) == {"external_wall_insulation"} def test_no_budget_picks_the_best_option_in_every_group() -> None: # Arrange groups: list[list[ScoredOption]] = [ [ scored_option("external_wall_insulation", gain=10.0, cost=8000.0), scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0), ], [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act — None budget = unconstrained. selection: list[ScoredOption] = optimise(groups, budget=None) # Assert assert selected_types(selection) == { "external_wall_insulation", "loft_insulation", } def test_budget_too_small_for_any_option_selects_nothing() -> None: # Arrange groups: list[list[ScoredOption]] = [ [scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0)], [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act selection: list[ScoredOption] = optimise(groups, budget=500.0) # Assert — nothing affordable; selecting none is the optimum. assert selection == [] def test_no_groups_selects_nothing() -> None: # Act / Assert assert optimise([], budget=10000.0) == [] def test_within_budget_partial_selection_prefers_the_higher_gain_option() -> None: # Arrange — only one of the two fits the budget; pick the affordable best. groups: list[list[ScoredOption]] = [ [scored_option("external_wall_insulation", gain=10.0, cost=8000.0)], [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act selection: list[ScoredOption] = optimise(groups, budget=2000.0) # Assert — EWI is unaffordable; loft alone is the best within £2000. 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_option("loft_insulation", gain=10.0, cost=2000.0), scored_option("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_option("cavity_wall_insulation", gain=6.0, cost=1000.0), scored_option("external_wall_insulation", gain=10.0, cost=8000.0), ], [scored_option("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_option("cavity_wall_insulation", gain=10.0, cost=2000.0), scored_option("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_option("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_option("cavity_wall_insulation", gain=6.0, cost=1000.0)], [scored_option("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_option("cavity_wall_insulation", gain=10.0, cost=1000.0)], [scored_option("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_option("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. groups: list[list[ScoredOption]] = [ [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], [scored_option("loft_insulation", gain=0.0, cost=1000.0, overlay=ROOF_OVERLAY)], [scored_option("suspended_floor_insulation", gain=8.0, cost=1000.0, overlay=FLOOR_OVERLAY)], ] scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) # Act package: OptimisedPackage = optimise_package( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=5000.0, target_sap=50.0, ) # Assert — warm-start took wall+floor (re-score 48 < 50); repair added the # roof (true +4) to reach 52, the truthful package total. types = {scored.option.measure_type for scored in package.selected} assert "loft_insulation" in types assert types == { "cavity_wall_insulation", "suspended_floor_insulation", "loft_insulation", } assert abs(package.score.sap_continuous - 52.0) <= 1e-9 def test_no_target_returns_the_warm_start_package_without_repair() -> None: # Arrange groups: list[list[ScoredOption]] = [ [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], ] scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) # Act package: OptimisedPackage = optimise_package( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=None, target_sap=None, ) # Assert — no target → no repair; warm-start package re-scored as the truth. assert {s.option.measure_type for s in package.selected} == { "cavity_wall_insulation" } assert abs(package.score.sap_continuous - 45.0) <= 1e-9 def test_repair_stops_when_no_affordable_improving_option_remains() -> None: # Arrange — the only untreated-group option costs more than the budget left. groups: list[list[ScoredOption]] = [ [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], [scored_option("loft_insulation", gain=0.0, cost=5000.0, overlay=ROOF_OVERLAY)], ] scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) # Act package: OptimisedPackage = optimise_package( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=1000.0, target_sap=50.0, ) # Assert — wall only (re-score 45 < 50); roof unaffordable, so repair stops # at the best achievable package rather than overspending. assert {s.option.measure_type for s in package.selected} == { "cavity_wall_insulation" } 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_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], [scored_option("loft_insulation", gain=5.0, cost=1000.0, overlay=ROOF_OVERLAY)], [scored_option("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_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], [scored_option("loft_insulation", gain=5.0, cost=1000.0, overlay=ROOF_OVERLAY)], [scored_option("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_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], [scored_option("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) ------------------------------- def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() -> None: # Arrange — cavity is dirt cheap (£100) and its role-1 signal (+6) alone # reaches the target gain, so a ventilation-BLIND min-cost would pick it. # But the wall forces in ventilation at a true/­signal −5, which sinks the # package below target. A ventilation-AWARE warm-start prices that −5 into # the candidate and instead takes the wall-free loft path. groups: list[list[ScoredOption]] = [ [scored_option("cavity_wall_insulation", gain=6.0, cost=100.0, overlay=WALL_OVERLAY)], [scored_option("loft_insulation", gain=8.0, cost=1500.0, overlay=ROOF_OVERLAY)], ] scorer = StubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0) dependency = ventilation_dependency( cost=300.0, triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}) ) # Act — target 66 (gain 6 over the 60 baseline). package: OptimisedPackage = optimise_package( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=10000.0, target_sap=66.0, dependencies=[dependency], ) # Assert — the loft path (true 68, £1500), NOT cavity + forced ventilation: # cavity's signal (+6) is cancelled by ventilation (−5) to +1 < target. assert selected_types(package.selected) == {"loft_insulation"} assert abs(package.score.sap_continuous - 68.0) <= 1e-9 def test_dependency_injected_when_a_trigger_measure_is_selected() -> None: # Arrange — the wall is selected, so its ventilation dependency must be # injected before the re-score; ventilation never competes in the pool. groups: list[list[ScoredOption]] = [ [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], ] scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act package: OptimisedPackage = optimise_package( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=None, target_sap=None, dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — ventilation is in the package and its negative contribution lands # in the truthful total: 40 base + 5 wall − 2 ventilation = 43. assert selected_types(package.selected) == { "cavity_wall_insulation", "mechanical_ventilation", } assert abs(package.score.sap_continuous - 43.0) <= 1e-9 def test_dependency_not_injected_without_a_trigger_measure() -> None: # Arrange — only loft is selected; the wall-triggered ventilation dependency # must not fire. groups: list[list[ScoredOption]] = [ [scored_option("loft_insulation", gain=4.0, cost=1000.0, overlay=ROOF_OVERLAY)], ] scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act package: OptimisedPackage = optimise_package( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=None, target_sap=None, dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — no trigger, no ventilation; 40 base + 4 roof = 44. assert selected_types(package.selected) == {"loft_insulation"} assert abs(package.score.sap_continuous - 44.0) <= 1e-9 def test_wall_dropped_when_it_cannot_be_ventilated_within_budget() -> None: # Arrange — cavity (£1000) fits the £1000 budget on its own, but its # mandatory ventilation (£900) would bust it. We never blow the budget: a # wall we can't afford to ventilate is a wall we can't afford, so it is # dropped (the budget is a hard envelope, ventilation is not forced over it). groups: list[list[ScoredOption]] = [ [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], ] scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act — tight budget; ventilation-aware selection prices the £900 in. package: OptimisedPackage = optimise_package( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=1000.0, target_sap=None, dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — nothing recommended; the budget is respected and the wall is # never left un-ventilated. assert package.selected == [] def test_injected_ventilation_penalty_drives_extra_repair() -> None: # Arrange — wall (+5) injects ventilation (−2): re-score 43 < target 46. # Repair adds the roof (true +4) to reach 47, paying for the ventilation # penalty out of the budget the dependency's cost has already eaten into. groups: list[list[ScoredOption]] = [ [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], [scored_option("loft_insulation", gain=0.0, cost=1000.0, overlay=ROOF_OVERLAY)], ] scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act package: OptimisedPackage = optimise_package( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=5000.0, target_sap=46.0, dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — repair pulled the roof in to clear the target net of ventilation: # 40 + 5 wall − 2 vent + 4 roof = 47. assert selected_types(package.selected) == { "cavity_wall_insulation", "loft_insulation", "mechanical_ventilation", } assert abs(package.score.sap_continuous - 47.0) <= 1e-9