Model/tests/domain/modelling/test_optimiser.py
Khalim Conn-Kowlessar 49e86344d2 feat(modelling): whole-package re-score + greedy repair (#1160)
Slice 2 of #1160 — the ADR-0016 truth step on top of the warm-start
knapsack. optimise_package(groups, scorer, baseline_epc, budget,
target_sap) -> OptimisedPackage:

  warm-start optimise() (role-1 signal) → re-score the chosen package on
  the real scorer (role-2 truth) → while the true SAP undershoots
  target_sap and budget remains, greedy-add the untreated-group Option
  with the best *marginal* SAP-per-£ (re-scored, not the role-1 signal),
  re-score, repeat until the target is met, nothing positive-marginal is
  affordable, or the budget is spent.

`Scorer` is a structural Protocol (PackageScorer satisfies it) so the
repair loop is tested with a stub scorer — no calculator, runs on ARM.
The key case: role-1 under-counts roof so the warm-start skips it, the
re-score undershoots, and repair adds roof back to hit the target. 3
repair tests + the 6 core tests; pyright strict clean.

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

277 lines
9.5 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 typing import Sequence
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
)
from domain.modelling.optimiser import (
OptimisedPackage,
ScoredOption,
optimise,
optimise_package,
)
from domain.modelling.package_scorer import Score
from domain.modelling.recommendation import Cost, MeasureOption
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
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"}
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