Model/domain/modelling/optimisation/optimiser.py
Khalim Conn-Kowlessar e329c50fa3 Fabric-first phase 2 re-scores candidates in the goal objective's currency 🟩
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:11:06 +00:00

555 lines
23 KiB
Python

"""The Optimiser core — a grouped (multiple-choice) knapsack over per-Option
role-1 scores (ADR-0016).
Recycles the formulation of the legacy ``GainOptimiser`` / ``CostOptimiser``
(``recommendations/optimiser/``): pick **at most one** Option per Recommendation
(disjoint groups, no cross-group exclusion constraints — the Recommendation
partition makes selected overlays collision-free), maximising total SAP gain
subject to the Scenario budget. The legacy classes solve this as a `mip` MILP;
here it is an exact pure-Python multiple-choice knapsack — no native solver
dependency, so it runs everywhere and is deterministically testable.
This is the warm-start **signal** only: per ADR-0016 the role-1 per-Option
scores are approximate (independent-vs-baseline), so the truthful figure comes
from the whole-package re-score + greedy repair, not from this selection. Exact
enumeration is therefore more than adequate, and at retrofit scale (a handful
of Recommendations, a few Options each) the candidate space — ``Π(|group|+1)``
— is tiny.
"""
from __future__ import annotations
import itertools
from dataclasses import dataclass
from typing import Callable, Optional, Protocol, Sequence
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.measure_type import FABRIC_MEASURE_TYPES, MeasureType
from domain.modelling.scoring.package_scorer import Score
from domain.modelling.recommendation import MeasureOption
from domain.modelling.simulation import EpcSimulation
@dataclass(frozen=True)
class ScoredOption:
"""A candidate Measure Option paired with its role-1 (independent-vs-
baseline) SAP gain — the optimiser's input signal. Cost is read from the
Option; the gain is supplied by scoring."""
option: MeasureOption
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[MeasureType]
required: ScoredOption
def _option_cost(option: MeasureOption) -> float:
if option.cost is None:
raise ValueError(
f"measure option {option.measure_type!r} has no cost; cannot optimise"
)
return option.cost.total
def optimise(
groups: list[list[ScoredOption]],
budget: Optional[float],
dependencies: Sequence[MeasureDependency] = (),
) -> list[ScoredOption]:
"""Select at most one ScoredOption per group to maximise total SAP gain
subject to ``budget`` (None = unconstrained). Exact: enumerates every
pick-one-or-skip-per-group package, keeps the affordable one with the
greatest gain, breaking ties toward lower cost. Returns the selected
ScoredOptions (empty if nothing affordable beats selecting none).
Candidate cost and gain are evaluated with any forced ``dependencies`` the
candidate triggers folded in (ADR-0016 amendment — ventilation-aware), so a
package is judged on what it will really cost and gain once its dependency
is injected. The returned list holds only the group selections, not the
folded-in dependencies (the caller injects those)."""
choices_per_group: list[list[Optional[ScoredOption]]] = [
[None, *group] for group in groups
]
best: list[ScoredOption] = []
best_gain: float = -1.0
best_cost: float = 0.0
for combo in itertools.product(*choices_per_group):
selected: list[ScoredOption] = [
choice for choice in combo if choice is not None
]
total_cost, total_gain = _augmented_cost_gain(selected, dependencies)
if budget is not None and total_cost > budget:
continue
# Maximise gain; on a tie prefer the cheaper package.
if (total_gain, -total_cost) > (best_gain, -best_cost):
best, best_gain, best_cost = selected, total_gain, total_cost
return best
def _augmented_cost_gain(
selected: list[ScoredOption], dependencies: Sequence[MeasureDependency]
) -> tuple[float, float]:
"""The total cost and total role-1 gain of a candidate **with the forced
dependencies it triggers folded in** — what the package will really cost and
gain once injected. Dependency gains are negative (ventilation), so this is
how selection 'prices' the ventilation a wall drags in."""
augmented: list[ScoredOption] = _inject(selected, dependencies)
total_cost: float = sum(_option_cost(s.option) for s in augmented)
total_gain: float = sum(s.sap_gain for s in augmented)
return total_cost, total_gain
def optimise_min_cost(
groups: list[list[ScoredOption]],
budget: Optional[float],
target_gain: float,
dependencies: Sequence[MeasureDependency] = (),
) -> Optional[list[ScoredOption]]:
"""Select at most one ScoredOption per group to **minimise total cost**
subject to total SAP gain ``>= target_gain`` and total cost ``<= budget``
(None = unconstrained) — the least-cost-to-target objective (ADR-0016
amendment). Exact enumeration over every pick-one-or-skip-per-group package.
Returns the cheapest target-reaching package (ties broken toward the higher
gain — "recommend more"), or ``None`` when no package within budget reaches
the target (the caller falls back to max-gain). A non-positive
``target_gain`` is met by the empty package.
Candidate cost and gain are evaluated with any forced ``dependencies`` the
candidate triggers folded in (ventilation-aware), so a wall whose mandatory
ventilation cancels its gain is not mistaken for a cheap way to the target.
The returned list holds only the group selections, not the dependencies."""
choices_per_group: list[list[Optional[ScoredOption]]] = [
[None, *group] for group in groups
]
best: Optional[list[ScoredOption]] = None
best_cost: float = 0.0
best_gain: float = 0.0
for combo in itertools.product(*choices_per_group):
selected: list[ScoredOption] = [
choice for choice in combo if choice is not None
]
total_cost, total_gain = _augmented_cost_gain(selected, dependencies)
if budget is not None and total_cost > budget:
continue
if total_gain < target_gain:
continue
# Minimise cost; on a tie prefer the higher-gain package.
if best is None or (-total_cost, total_gain) > (-best_cost, best_gain):
best, best_cost, best_gain = selected, total_cost, total_gain
return best
class Scorer(Protocol):
"""The whole-package scoring primitive — `PackageScorer` satisfies it.
Kept structural so the repair loop is testable with a stub scorer."""
def score(
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
) -> Score: ...
@dataclass(frozen=True)
class OptimisedPackage:
"""The package the Optimiser commits to: the selected ScoredOptions and the
**truthful** whole-package re-score (ADR-0016 role 2), after any greedy
repair. The per-Option `sap_gain` on the selections is the approximate
warm-start signal — never the package total, which is `score`."""
selected: list[ScoredOption]
score: Score
def sap_rating(score: Score) -> float:
"""The default Optimiser objective: the un-rounded SAP rating (higher is
better) — what every goal optimised before goal-aligned objectives."""
return score.sap_continuous
def optimise_package(
*,
groups: list[list[ScoredOption]],
scorer: Scorer,
baseline_epc: EpcPropertyData,
budget: Optional[float],
target_sap: Optional[float],
dependencies: Sequence[MeasureDependency] = (),
objective: Callable[[Score], float] = sap_rating,
) -> OptimisedPackage:
"""Select the Optimised Package for one Property + Scenario (ADR-0016 +
its amendment).
With a ``target_sap`` (an Increasing EPC goal) the objective is
**least-cost-to-target**: warm-start with the cheapest package whose role-1
signal reaches the target gain within budget (`optimise_min_cost`), inject
any forced Measure Dependencies, re-score the whole package for the truth,
and greedy-repair toward ``target_sap`` while it undershoots. If the target
is unreachable within budget — the warm-start is infeasible, or the repaired
package still falls short on the true score — fall back to the **maximum
improvement the budget buys** (`optimise`). The min-cost objective stops at
the target and does not overshoot into a higher band; surplus budget is left
unspent.
Without a ``target_sap`` (other goals) it is max-gain-within-budget. Either
way forced dependencies are injected on every path and their cost counts
toward the spend; the returned `selected` includes them. ``budget`` of None
means unconstrained.
``objective`` is the currency every internally-computed figure is measured
in (ADR-0062): the goal's metric, higher is better — SAP by default, CO2
reduction / bill saving for the goal-aligned Scenarios. The caller must
supply the group signals in the same currency; ``target_sap`` (when given)
is a value on the same scale."""
baseline_value: float = objective(_score(scorer, baseline_epc, []))
# Score each forced dependency's independent (role-1) impact so the selection
# can price the ventilation a wall drags in — negative for ventilation.
deps: list[MeasureDependency] = _with_role1_signals(
dependencies, scorer, baseline_epc, baseline_value, objective
)
if target_sap is None:
return _max_gain_package(groups, scorer, baseline_epc, budget, deps)
target_gain: float = target_sap - baseline_value
chosen: Optional[list[ScoredOption]] = optimise_min_cost(
groups, budget, target_gain, deps
)
if chosen is not None:
package: OptimisedPackage = _repair_to_target(
chosen, groups, deps, scorer, baseline_epc, budget, target_sap, objective
)
if objective(package.score) >= target_sap:
return package
# Target unreachable within budget (warm-start infeasible, or the repaired
# package still falls short) → best effort: the most improvement budget buys.
return _max_gain_package(groups, scorer, baseline_epc, budget, deps)
def optimise_package_fabric_first(
*,
groups: list[list[ScoredOption]],
scorer: Scorer,
baseline_epc: EpcPropertyData,
budget: Optional[float],
target_sap: Optional[float],
dependencies: Sequence[MeasureDependency] = (),
objective: Callable[[Score], float] = sap_rating,
) -> OptimisedPackage:
"""Select the Optimised Package under the Fabric First constraint: optimise
the fabric measures (``FABRIC_MEASURE_TYPES``) first with the full budget;
if the truthful post-fabric score meets ``target_sap``, stop there. Otherwise
optimise the remaining groups on top — the starting point for phase 2 is the
dwelling with the phase-1 fabric applied — within the leftover budget."""
fabric_groups: list[list[ScoredOption]] = [
group for group in groups if _is_fabric_group(group)
]
if not fabric_groups:
# Nothing for phase 1 to claim (the envelope is already treated):
# a plain run is identical and skips the phase-2 re-scoring.
return optimise_package(
groups=groups,
scorer=scorer,
baseline_epc=baseline_epc,
budget=budget,
target_sap=target_sap,
dependencies=dependencies,
)
fabric_package: OptimisedPackage = optimise_package(
groups=fabric_groups,
scorer=scorer,
baseline_epc=baseline_epc,
budget=budget,
target_sap=target_sap,
dependencies=dependencies,
objective=objective,
)
if (
target_sap is not None
and objective(fabric_package.score) >= target_sap
):
return fabric_package
if not fabric_package.selected:
# Phase 1 committed nothing (no fabric affordable or worth having), so
# the phase-2 prefix would be empty and its re-scoring would reproduce
# the signals the groups already carry: a plain run is identical.
return optimise_package(
groups=groups,
scorer=scorer,
baseline_epc=baseline_epc,
budget=budget,
target_sap=target_sap,
dependencies=dependencies,
)
# Phase 2 — the upgrade requirement is not met by fabric alone: optimise
# the remaining groups (non-fabric, plus any fabric group phase 1 left
# unpicked) on top of the committed fabric. Every score call is prefixed
# with the phase-1 overlays, so candidates are valued against the
# fabric-applied dwelling and the resulting package score stays the
# truthful whole-package figure against the original baseline.
consumed: set[int] = _used_group_indices(groups, fabric_package.selected)
remaining_groups: list[list[ScoredOption]] = [
group for index, group in enumerate(groups) if index not in consumed
]
if not remaining_groups:
return fabric_package
post_fabric_scorer = _PrefixedScorer(
scorer, [scored.option.overlay for scored in fabric_package.selected]
)
leftover_budget: Optional[float] = (
None if budget is None else budget - _package_cost(fabric_package.selected)
)
# Everything phase 1 committed — its picks plus the dependencies it
# injected. A dependency already injected (e.g. the wall's ventilation) is
# satisfied for the whole package: phase 2 must not force it in again.
phase_one_types: set[MeasureType] = {
scored.option.measure_type for scored in fabric_package.selected
}
outstanding_dependencies: list[MeasureDependency] = [
dependency
for dependency in dependencies
if dependency.required.option.measure_type not in phase_one_types
]
top_up: OptimisedPackage = optimise_package(
groups=_rescored_groups(
remaining_groups,
post_fabric_scorer,
baseline_epc,
objective=objective,
start_value=objective(fabric_package.score),
),
scorer=post_fabric_scorer,
baseline_epc=baseline_epc,
budget=leftover_budget,
target_sap=target_sap,
dependencies=outstanding_dependencies,
objective=objective,
)
return OptimisedPackage(
selected=[*fabric_package.selected, *top_up.selected],
score=top_up.score,
)
def _is_fabric_group(group: list[ScoredOption]) -> bool:
"""A group belongs to phase 1 when every Option in it is a fabric measure
(groups are one Recommendation each, so they are homogeneous in kind)."""
return all(
scored.option.measure_type in FABRIC_MEASURE_TYPES for scored in group
)
def _rescored_groups(
groups: list[list[ScoredOption]],
scorer: Scorer,
baseline_epc: EpcPropertyData,
*,
objective: Callable[[Score], float],
start_value: float,
) -> list[list[ScoredOption]]:
"""The groups with every Option's role-1 warm-start signal re-scored
through ``scorer`` in the ``objective``'s currency — for phase 2, its
independent gain on the post-fabric dwelling rather than the raw baseline,
so options whose worth changes once the envelope is treated (a boiler on
an insulated home) are re-ranked. ``start_value`` is the objective value of
``baseline_epc`` through ``scorer`` with no candidate applied — the caller
already has it (the phase-1 package score in the objective's currency), so
it is threaded in rather than re-computed."""
return [
[
ScoredOption(
option=scored.option,
sap_gain=objective(
scorer.score(baseline_epc, [scored.option.overlay])
)
- start_value,
)
for scored in group
]
for group in groups
]
class _PrefixedScorer:
"""A Scorer view of the dwelling with a committed package already applied:
every score call sees the ``prefix`` overlays before the candidate's own.
Phase 2 of Fabric First scores through this, so its candidates are valued
against the post-fabric dwelling while the returned Score remains the
truthful whole-package figure against the true baseline."""
def __init__(self, inner: Scorer, prefix: Sequence[EpcSimulation]) -> None:
self._inner = inner
self._prefix = list(prefix)
def score(
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
) -> Score:
return self._inner.score(baseline, [*self._prefix, *simulations])
def _with_role1_signals(
dependencies: Sequence[MeasureDependency],
scorer: Scorer,
baseline_epc: EpcPropertyData,
baseline_value: float,
objective: Callable[[Score], float],
) -> list[MeasureDependency]:
"""Replace each dependency's placeholder role-1 signal with its true
independent-vs-baseline impact **in the objective's currency**, so the
selectors price what the dependency really does to the package (ADR-0016
amendment; ADR-0062 for the currency)."""
scored: list[MeasureDependency] = []
for dependency in dependencies:
signal: float = (
objective(
scorer.score(baseline_epc, [dependency.required.option.overlay])
)
- baseline_value
)
scored.append(
MeasureDependency(
triggers=dependency.triggers,
required=ScoredOption(
option=dependency.required.option, sap_gain=signal
),
)
)
return scored
def _max_gain_package(
groups: list[list[ScoredOption]],
scorer: Scorer,
baseline_epc: EpcPropertyData,
budget: Optional[float],
dependencies: Sequence[MeasureDependency],
) -> OptimisedPackage:
"""Max-gain-within-budget, dependencies priced in the selection then
injected and re-scored — the no-target objective and the unreachable-target
fallback."""
chosen: list[ScoredOption] = optimise(groups, budget, dependencies)
selected: list[ScoredOption] = _inject(chosen, dependencies)
return OptimisedPackage(
selected=selected, score=_score(scorer, baseline_epc, selected)
)
def _repair_to_target(
chosen: list[ScoredOption],
groups: list[list[ScoredOption]],
dependencies: Sequence[MeasureDependency],
scorer: Scorer,
baseline_epc: EpcPropertyData,
budget: Optional[float],
target_sap: float,
objective: Callable[[Score], float] = sap_rating,
) -> OptimisedPackage:
"""Inject dependencies onto the warm-start, re-score for the truth, then
greedy-add the untreated-group Option with the best marginal objective-per-£
(its own dependency folded in) until the true objective value clears
``target_sap`` or no affordable improving Option remains."""
selected: list[ScoredOption] = _inject(chosen, dependencies)
score: Score = _score(scorer, baseline_epc, selected)
while objective(score) < target_sap:
candidate = _best_repair_candidate(
groups, chosen, dependencies, scorer, baseline_epc, score, budget, objective
)
if candidate is None:
break
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[MeasureType] = {s.option.measure_type for s in chosen}
injected: list[ScoredOption] = list(chosen)
present: set[MeasureType] = set(chosen_types)
for dependency in dependencies:
required_type: MeasureType = 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:
return scorer.score(baseline_epc, [s.option.overlay for s in selected])
def _used_group_indices(
groups: list[list[ScoredOption]], selected: list[ScoredOption]
) -> set[int]:
"""Indices of groups already represented in the selection (≤1 per group),
matched by object identity — the selection holds the very ScoredOptions
from ``groups``."""
return {
index
for index, group in enumerate(groups)
if any(option is chosen for option in group for chosen in selected)
}
def _best_repair_candidate(
groups: list[list[ScoredOption]],
chosen: list[ScoredOption],
dependencies: Sequence[MeasureDependency],
scorer: Scorer,
baseline_epc: EpcPropertyData,
current: Score,
budget: Optional[float],
objective: Callable[[Score], float] = sap_rating,
) -> 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) 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:
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, trial_selected)
marginal: float = objective(trial) - objective(current)
if marginal <= 0.0:
continue
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