"""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 typing import Sequence from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, ) from domain.modelling.optimisation.optimiser import ( MeasureDependency, OptimisedPackage, ScoredOption, optimise, optimise_min_cost, optimise_package, ) from domain.modelling.scoring.package_scorer import Score from domain.modelling.recommendation import Cost, MeasureOption from domain.modelling.simulation import ( BuildingPartOverlay, EpcSimulation, VentilationOverlay, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) def _scored(measure_type: str, *, gain: float, cost: float) -> ScoredOption: return ScoredOption( option=MeasureOption( measure_type=measure_type, description=measure_type, overlay=EpcSimulation(), cost=Cost(total=cost, contingency_rate=0.0), ), sap_gain=gain, ) # Distinguishable overlays so the stub scorer can attribute a true gain per # measure (wall / roof / floor) regardless of the role-1 signal. _WALL_OVERLAY = EpcSimulation( building_parts={ BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) } ) _ROOF_OVERLAY = EpcSimulation( building_parts={ BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300) } ) _FLOOR_OVERLAY = EpcSimulation( building_parts={ BuildingPartIdentifier.MAIN: BuildingPartOverlay(floor_insulation_thickness=100) } ) def _scored_overlay( measure_type: str, *, gain: float, cost: float, overlay: EpcSimulation ) -> ScoredOption: return ScoredOption( option=MeasureOption( measure_type=measure_type, description=measure_type, overlay=overlay, cost=Cost(total=cost, contingency_rate=0.0), ), sap_gain=gain, ) class _StubScorer: """A deterministic stand-in for PackageScorer: the package SAP is a base plus a fixed *true* gain per measure present (by overlay field), decoupled from the role-1 signal — so the repair loop is exercised without the calculator (ADR-0016).""" def __init__(self, *, base: float, wall: float, roof: float, floor: float) -> None: self._base = base self._wall = wall self._roof = roof self._floor = floor def score( self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] ) -> Score: sap = self._base for sim in simulations: part = sim.building_parts[BuildingPartIdentifier.MAIN] if part.wall_insulation_type is not None: sap += self._wall if part.roof_insulation_thickness is not None: sap += self._roof if part.floor_insulation_thickness is not None: sap += self._floor return Score(sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0) def _selected_types(selection: list[ScoredOption]) -> set[str]: return {scored.option.measure_type for scored in selection} 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("external_wall_insulation", gain=10.0, cost=8000.0), _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), ], [_scored("loft_insulation", gain=4.0, cost=1500.0)], [_scored("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("external_wall_insulation", gain=10.0, cost=2000.0), _scored("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("external_wall_insulation", gain=10.0, cost=8000.0), _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), ], [_scored("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("cavity_wall_insulation", gain=6.0, cost=1000.0)], [_scored("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("external_wall_insulation", gain=10.0, cost=8000.0)], [_scored("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("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. 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)], [_scored_overlay("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_overlay("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_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], [_scored_overlay("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_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( ventilation=VentilationOverlay( mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" ) ) class _VentStubScorer: """A stub that adds a fixed gain per wall overlay present and a fixed (negative) `vent` contribution when a ventilation overlay is present — so the Measure Dependency's effect on the truthful package total and the repair decision is exercised without the calculator.""" def __init__(self, *, base: float, wall: float, roof: float, vent: float) -> None: self._base = base self._wall = wall self._roof = roof self._vent = vent def score( self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] ) -> Score: sap = self._base for sim in simulations: if sim.ventilation is not None: sap += self._vent for part in sim.building_parts.values(): if part.wall_insulation_type is not None: sap += self._wall if part.roof_insulation_thickness is not None: sap += self._roof return Score(sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0) def _ventilation_dependency(*, cost: float) -> MeasureDependency: """A forced 'fabric requires ventilation' edge for the tests.""" return MeasureDependency( triggers=frozenset({"cavity_wall_insulation", "external_wall_insulation"}), required=ScoredOption( option=MeasureOption( measure_type="mechanical_ventilation", description="mechanical_ventilation", overlay=_VENT_OVERLAY, cost=Cost(total=cost, contingency_rate=0.0), ), sap_gain=0.0, ), ) 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_overlay("cavity_wall_insulation", gain=6.0, cost=100.0, overlay=_WALL_OVERLAY)], [_scored_overlay("loft_insulation", gain=8.0, cost=1500.0, overlay=_ROOF_OVERLAY)], ] scorer = _VentStubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0) dependency = MeasureDependency( triggers=frozenset({"cavity_wall_insulation"}), required=ScoredOption( option=MeasureOption( measure_type="mechanical_ventilation", description="mechanical_ventilation", overlay=_VENT_OVERLAY, cost=Cost(total=300.0, contingency_rate=0.0), ), sap_gain=0.0, # placeholder; optimise_package scores the real signal ), ) # 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_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], ] scorer = _VentStubScorer(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_overlay("loft_insulation", gain=4.0, cost=1000.0, overlay=_ROOF_OVERLAY)], ] scorer = _VentStubScorer(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_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], ] scorer = _VentStubScorer(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_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 = _VentStubScorer(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