diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index a1e45a58f..f4ce97a2f 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -21,7 +21,7 @@ from __future__ import annotations import itertools from dataclasses import dataclass -from typing import Optional, Protocol, Sequence +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 @@ -171,6 +171,12 @@ class OptimisedPackage: 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]], @@ -179,6 +185,7 @@ def optimise_package( 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). @@ -197,26 +204,32 @@ def optimise_package( 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.""" - baseline_sap: float = _score(scorer, baseline_epc, []).sap_continuous + 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_sap + 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_sap + 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 + chosen, groups, deps, scorer, baseline_epc, budget, target_sap, objective ) - if package.score.sap_continuous >= target_sap: + 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. @@ -383,18 +396,20 @@ def _with_role1_signals( dependencies: Sequence[MeasureDependency], scorer: Scorer, baseline_epc: EpcPropertyData, - baseline_sap: float, + baseline_value: float, + objective: Callable[[Score], float], ) -> list[MeasureDependency]: """Replace each dependency's placeholder role-1 signal with its true - independent-vs-baseline SAP impact, so the selectors price what the - dependency really does to the package (ADR-0016 amendment).""" + 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 = ( - scorer.score( - baseline_epc, [dependency.required.option.overlay] - ).sap_continuous - - baseline_sap + objective( + scorer.score(baseline_epc, [dependency.required.option.overlay]) + ) + - baseline_value ) scored.append( MeasureDependency( @@ -432,16 +447,17 @@ def _repair_to_target( 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 SAP-per-£ (its - own dependency folded in) until the true SAP clears ``target_sap`` or no - affordable improving Option remains.""" + 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 score.sap_continuous < target_sap: + while objective(score) < target_sap: candidate = _best_repair_candidate( - groups, chosen, dependencies, scorer, baseline_epc, score, budget + groups, chosen, dependencies, scorer, baseline_epc, score, budget, objective ) if candidate is None: break @@ -499,6 +515,7 @@ def _best_repair_candidate( 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 @@ -520,7 +537,7 @@ def _best_repair_candidate( if budget is not None and package_cost > budget: continue trial: Score = _score(scorer, baseline_epc, trial_selected) - marginal: float = trial.sap_continuous - current.sap_continuous + marginal: float = objective(trial) - objective(current) if marginal <= 0.0: continue incremental: float = package_cost - base_cost