Model/tests/domain/modelling/test_optimiser.py
Khalim Conn-Kowlessar 77983caed8 feat(modelling): Optimiser core — exact grouped knapsack (#1160)
Slice 1 of #1160. Recycles the GainOptimiser/CostOptimiser formulation
(≤1 Option per Recommendation, maximise SAP gain subject to budget) as a
clean typed DDD function — but as an exact pure-Python multiple-choice
knapsack rather than the legacy `mip` MILP, since mip's CBC backend does
not load on aarch64 (so the legacy solver path can't run / be tested
here). At retrofit scale the candidate space Π(|group|+1) is tiny, so
exhaustive enumeration is exact and instant; ADR-0016 only needs the
knapsack as a warm-start signal anyway (the truthful figure comes from
the whole-package re-score + repair, next slice).

`optimise(groups, budget) -> list[ScoredOption]`: maximise total gain,
tie-break toward lower cost, skip-per-group covers "select none". 6 tests
(budget-bound selection, ≤1/group, unconstrained, budget-too-small,
empty groups, partial-affordability); pyright strict clean.

Multi-phase remains descoped (ADR-0005) — single-phase optimiser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:39:47 +00:00

123 lines
4.3 KiB
Python

"""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.optimiser import ScoredOption, optimise
from domain.modelling.recommendation import Cost, MeasureOption
from domain.modelling.simulation import EpcSimulation
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,
)
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"}