diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index b47495f00..aa220dc07 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -248,7 +248,41 @@ def optimise_package_fabric_first( target_sap=target_sap, dependencies=dependencies, ) - return fabric_package + if ( + target_sap is not None + and fabric_package.score.sap_continuous >= target_sap + ): + return fabric_package + + # 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. + remaining_groups: list[list[ScoredOption]] = [ + group + for index, group in enumerate(groups) + if index not in _used_group_indices(groups, fabric_package.selected) + ] + 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) + ) + top_up: OptimisedPackage = optimise_package( + groups=remaining_groups, + scorer=post_fabric_scorer, + baseline_epc=baseline_epc, + budget=leftover_budget, + target_sap=target_sap, + dependencies=dependencies, + ) + return OptimisedPackage( + selected=[*fabric_package.selected, *top_up.selected], + score=top_up.score, + ) def _is_fabric_group(group: list[ScoredOption]) -> bool: @@ -259,6 +293,23 @@ def _is_fabric_group(group: list[ScoredOption]) -> bool: ) +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,