feat(modelling): inject forced Measure Dependencies into the package (#1161)

MeasureDependency(triggers, required) is a data-declared 'A requires B' edge.
optimise_package gains a dependencies param: after the warm-start it injects any
dependency whose triggers intersect the selected measure-types, BEFORE the
whole-package re-score, so the dependency's (negative) SAP lands in the truthful
figure and the undershoot/repair decision (ADR-0016). Forced — injected
regardless of budget — but its cost counts toward package spend, so repair sees
less headroom. Repair candidates fold in any dependency they newly trigger, so
their marginal SAP-per-£ and incremental cost are truthful. The dependency never
competes in the optimiser pool. Returned selected includes the injected deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 13:25:40 +00:00
parent 7c59e9198a
commit 6b11c90295
2 changed files with 229 additions and 22 deletions

View file

@ -39,6 +39,19 @@ class ScoredOption:
sap_gain: float
@dataclass(frozen=True)
class MeasureDependency:
"""A forced "A requires B" edge (ADR-0016 Measure Dependency): when any
selected Option's `measure_type` is in `triggers`, `required` is injected
into the package **before** the whole-package re-score never competing in
the optimiser pool, but its (negative) SAP and its cost land in the truthful
figure, the repair decision, and the persisted package. Held as data so
extending the triggers is a data edit, not control flow."""
triggers: frozenset[str]
required: ScoredOption
def _option_cost(option: MeasureOption) -> float:
if option.cost is None:
raise ValueError(
@ -104,32 +117,57 @@ def optimise_package(
baseline_epc: EpcPropertyData,
budget: Optional[float],
target_sap: Optional[float],
dependencies: Sequence[MeasureDependency] = (),
) -> OptimisedPackage:
"""Warm-start with the grouped knapsack (role-1 signal), re-score the chosen
package on the real scorer (role-2 truth), then while the true SAP
undershoots ``target_sap`` and budget remains greedy-add the untreated-
group Option with the best marginal SAP-per-£ and re-score, until the target
is met, no positive-marginal Option is affordable, or the budget is spent
(ADR-0016). ``target_sap``/``budget`` of None mean unconstrained."""
selected: list[ScoredOption] = optimise(groups, budget)
"""Warm-start with the grouped knapsack (role-1 signal), inject any forced
Measure Dependencies the selection triggers, re-score the whole package on
the real scorer (role-2 truth), then while the true SAP undershoots
``target_sap`` greedy-add the untreated-group Option with the best
marginal SAP-per-£ (its own ventilation dependency folded in) and re-score,
until the target is met or no affordable improving Option remains (ADR-0016).
A forced dependency is mandatory-when-triggered: it is injected regardless of
budget and its cost counts toward the package spend (so repair sees less
headroom). ``target_sap``/``budget`` of None mean unconstrained. The returned
`selected` includes the injected dependencies."""
chosen: list[ScoredOption] = optimise(groups, budget)
selected: list[ScoredOption] = _inject(chosen, dependencies)
score: Score = _score(scorer, baseline_epc, selected)
if target_sap is None:
return OptimisedPackage(selected=selected, score=score)
spent: float = sum(_option_cost(s.option) for s in selected)
while score.sap_continuous < target_sap:
remaining: Optional[float] = None if budget is None else budget - spent
candidate = _best_repair_candidate(
groups, selected, scorer, baseline_epc, score, remaining
groups, chosen, dependencies, scorer, baseline_epc, score, budget
)
if candidate is None:
break
selected = [*selected, candidate]
spent += _option_cost(candidate.option)
chosen = [*chosen, candidate]
selected = _inject(chosen, dependencies)
score = _score(scorer, baseline_epc, selected)
return OptimisedPackage(selected=selected, score=score)
def _inject(
chosen: list[ScoredOption], dependencies: Sequence[MeasureDependency]
) -> list[ScoredOption]:
"""``chosen`` plus every forced dependency whose triggers intersect the
chosen measure-types, de-duplicated by required measure-type (a dependency
several measures trigger is injected once)."""
chosen_types: set[str] = {s.option.measure_type for s in chosen}
injected: list[ScoredOption] = list(chosen)
present: set[str] = set(chosen_types)
for dependency in dependencies:
required_type: str = dependency.required.option.measure_type
if dependency.triggers & chosen_types and required_type not in present:
injected.append(dependency.required)
present.add(required_type)
return injected
def _package_cost(selected: list[ScoredOption]) -> float:
return sum(_option_cost(s.option) for s in selected)
def _score(
scorer: Scorer, baseline_epc: EpcPropertyData, selected: list[ScoredOption]
) -> Score:
@ -151,30 +189,40 @@ def _used_group_indices(
def _best_repair_candidate(
groups: list[list[ScoredOption]],
selected: list[ScoredOption],
chosen: list[ScoredOption],
dependencies: Sequence[MeasureDependency],
scorer: Scorer,
baseline_epc: EpcPropertyData,
current: Score,
remaining_budget: Optional[float],
budget: Optional[float],
) -> Optional[ScoredOption]:
"""The untreated-group Option giving the best **marginal** SAP-per-£ when
added to the current package (re-scored, not the role-1 signal), affordable
within ``remaining_budget`` and strictly improving. None if there is none."""
used: set[int] = _used_group_indices(groups, selected)
added to the current package re-scored (not the role-1 signal) with any
ventilation dependency it newly triggers folded in, so both its SAP and its
incremental cost are truthful. Affordable when the resulting whole-package
cost is within ``budget`` and strictly improving. None if there is none."""
used: set[int] = _used_group_indices(groups, chosen)
base_cost: float = _package_cost(_inject(chosen, dependencies))
best: Optional[ScoredOption] = None
best_ratio: float = 0.0
for index, group in enumerate(groups):
if index in used:
continue
for option in group:
cost: float = _option_cost(option.option)
if remaining_budget is not None and cost > remaining_budget:
trial_selected: list[ScoredOption] = _inject(
[*chosen, option], dependencies
)
package_cost: float = _package_cost(trial_selected)
if budget is not None and package_cost > budget:
continue
trial: Score = _score(scorer, baseline_epc, [*selected, option])
trial: Score = _score(scorer, baseline_epc, trial_selected)
marginal: float = trial.sap_continuous - current.sap_continuous
if marginal <= 0.0:
continue
ratio: float = float("inf") if cost == 0.0 else marginal / cost
incremental: float = package_cost - base_cost
ratio: float = (
float("inf") if incremental <= 0.0 else marginal / incremental
)
if ratio > best_ratio:
best, best_ratio = option, ratio
return best

View file

@ -15,6 +15,7 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
)
from domain.modelling.optimiser import (
MeasureDependency,
OptimisedPackage,
ScoredOption,
optimise,
@ -22,7 +23,11 @@ from domain.modelling.optimiser import (
)
from domain.modelling.package_scorer import Score
from domain.modelling.recommendation import Cost, MeasureOption
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from domain.modelling.simulation import (
BuildingPartOverlay,
EpcSimulation,
VentilationOverlay,
)
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
)
@ -275,3 +280,157 @@ def test_repair_stops_when_no_affordable_improving_option_remains() -> None:
"cavity_wall_insulation"
}
assert abs(package.score.sap_continuous - 45.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_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_dependency_is_forced_even_when_it_pushes_over_budget() -> None:
# Arrange — the budget covers the wall but not the forced ventilation;
# ventilation is mandatory-when-triggered, so it is injected regardless.
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 — budget exactly covers the wall; ventilation (£900) overspends.
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 — forced in despite the overspend.
assert "mechanical_ventilation" in _selected_types(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