Model/tests/domain/modelling/test_optimiser.py
Khalim Conn-Kowlessar 832a30a985 Optimiser test fixtures are shared and domain-plausible 🟪
Review findings on PR #1526:

- tests/domain/modelling/_optimiser_fixtures.py is the one home for the
  overlay constants, the ScoredOption builder, the additive per-kind
  StubScorer and the forced ventilation dependency; test_optimiser.py and
  test_optimiser_fabric_first.py had byte-identical copies of each
  (and _StubScorer / _VentStubScorer fold into one parameterised stub).
- Fixture worlds are domain-plausible per team convention: the fabric-vs-
  heating contrast is a £12,000 EWI against a £3,200 gas boiler rather
  than a £500 heat pump undercutting a £1,000 cavity wall; heating
  overlays carry real identities (SAP Table 4a code 104 for the boiler,
  a PCDF index for the heat pump) instead of code 201 doubling as both;
  whole-dwelling double glazing is £3,500, not £500.
- Dead knobs removed: the unused _ROOF_OVERLAY, the always-zero roof
  gain, the duplicate _BOILER_OVERLAY, and the nested conditional
  expressions in the interaction stubs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:29:45 +00:00

544 lines
20 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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