diff --git a/domain/modelling/optimiser.py b/domain/modelling/optimiser.py index beeb630e..e3a32c26 100644 --- a/domain/modelling/optimiser.py +++ b/domain/modelling/optimiser.py @@ -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 diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index 77e83680..1631b69a 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -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