mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
544 lines
20 KiB
Python
544 lines
20 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.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
|