mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
123 lines
4.3 KiB
Python
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"}
|