mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
7c59e9198a
commit
6b11c90295
2 changed files with 229 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue