Model/tests/domain/modelling/test_optimiser.py
Khalim Conn-Kowlessar af501fce0e feat(modelling): ventilation-aware selection — price the forced dependency in
The warm-start (and max-gain fallback) now price each forced Measure Dependency
the candidate triggers, not just inject it afterwards: optimise/optimise_min_cost
fold dependencies into each candidate's cost+gain via _augmented_cost_gain, and
optimise_package scores each dependency's true role-1 signal (_with_role1_signals)
instead of the 0.0 placeholder. This stops the min-cost objective (i) ignoring the
~£900 a wall drags in (a wall-free package reaching target can be cheaper) and
(ii) picking a small-gain wall whose mandatory ventilation (down to -5 SAP) makes
it net-negative, which repair cannot un-pick.

Budget is now a hard envelope: the constraint applies to the augmented (measure +
its ventilation) cost, so a wall that fits alone but whose ventilation would bust
the budget is DROPPED rather than forced over budget. This reverses the earlier
'forced regardless of budget' call (which made sense when selection was
ventilation-blind). Safety invariant intact — presence still injected on every
path; we just never recommend a wall we can't afford to ventilate. ADR-0016
amendment updated. 94 modelling+orchestration tests pass.

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

682 lines
25 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 typing import Sequence
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
)
from domain.modelling.optimisation.optimiser import (
MeasureDependency,
OptimisedPackage,
ScoredOption,
optimise,
optimise_min_cost,
optimise_package,
)
from domain.modelling.scoring.package_scorer import Score
from domain.modelling.recommendation import Cost, MeasureOption
from domain.modelling.simulation import (
BuildingPartOverlay,
EpcSimulation,
VentilationOverlay,
)
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"}
# --- 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("loft_insulation", gain=10.0, cost=2000.0),
_scored("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("cavity_wall_insulation", gain=6.0, cost=1000.0),
_scored("external_wall_insulation", gain=10.0, cost=8000.0),
],
[_scored("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("cavity_wall_insulation", gain=10.0, cost=2000.0),
_scored("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("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("cavity_wall_insulation", gain=6.0, cost=1000.0)],
[_scored("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("cavity_wall_insulation", gain=10.0, cost=1000.0)],
[_scored("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("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_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
# --- 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_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
[_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
[_scored_overlay("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_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
[_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
[_scored_overlay("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_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)],
]
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) -------------------------------
_VENT_OVERLAY = EpcSimulation(
ventilation=VentilationOverlay(
mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"
)
)
class _VentStubScorer:
"""A stub that adds a fixed gain per wall overlay present and a fixed
(negative) `vent` contribution when a ventilation overlay is present —
so the Measure Dependency's effect on the truthful package total and the
repair decision is exercised without the calculator."""
def __init__(self, *, base: float, wall: float, roof: float, vent: float) -> None:
self._base = base
self._wall = wall
self._roof = roof
self._vent = vent
def score(
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
) -> Score:
sap = self._base
for sim in simulations:
if sim.ventilation is not None:
sap += self._vent
for part in sim.building_parts.values():
if part.wall_insulation_type is not None:
sap += self._wall
if part.roof_insulation_thickness is not None:
sap += self._roof
return Score(sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0)
def _ventilation_dependency(*, cost: float) -> MeasureDependency:
"""A forced 'fabric requires ventilation' edge for the tests."""
return MeasureDependency(
triggers=frozenset({"cavity_wall_insulation", "external_wall_insulation"}),
required=ScoredOption(
option=MeasureOption(
measure_type="mechanical_ventilation",
description="mechanical_ventilation",
overlay=_VENT_OVERLAY,
cost=Cost(total=cost, contingency_rate=0.0),
),
sap_gain=0.0,
),
)
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_overlay("cavity_wall_insulation", gain=6.0, cost=100.0, overlay=_WALL_OVERLAY)],
[_scored_overlay("loft_insulation", gain=8.0, cost=1500.0, overlay=_ROOF_OVERLAY)],
]
scorer = _VentStubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0)
dependency = MeasureDependency(
triggers=frozenset({"cavity_wall_insulation"}),
required=ScoredOption(
option=MeasureOption(
measure_type="mechanical_ventilation",
description="mechanical_ventilation",
overlay=_VENT_OVERLAY,
cost=Cost(total=300.0, contingency_rate=0.0),
),
sap_gain=0.0, # placeholder; optimise_package scores the real signal
),
)
# 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_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
]
scorer = _VentStubScorer(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_overlay("loft_insulation", gain=4.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
]
scorer = _VentStubScorer(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_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
]
scorer = _VentStubScorer(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_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)],
]
scorer = _VentStubScorer(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