"""Behaviour of the Fabric First two-phase Optimiser: phase 1 optimises the fabric measures (wall / roof / floor insulation + glazing) with the full budget; if the truthful post-fabric score meets the Scenario target the package is fabric-only. Otherwise phase 2 optimises the remaining measures on top, where the starting point is the dwelling with the phase-1 fabric applied and only the leftover budget is spendable. Mirrors the legacy engine's ``enforce_fabric_first`` (funding_optimiser.optimise_with_scenarios) on the new truthful-re-score core (ADR-0016). """ from __future__ import annotations from typing import Sequence from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, ) from domain.modelling.measure_type import MeasureType from domain.modelling.optimisation.optimiser import ( OptimisedPackage, ScoredOption, optimise_package_fabric_first, ) from domain.modelling.recommendation import Cost, MeasureOption from domain.modelling.scoring.package_scorer import Score from domain.modelling.simulation import ( BuildingPartOverlay, EpcSimulation, HeatingOverlay, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) _WALL_OVERLAY = EpcSimulation( building_parts={ BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) } ) _ROOF_OVERLAY = EpcSimulation( building_parts={ BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300) } ) _HEATING_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201)) _BOILER_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201)) _ASHP_OVERLAY = EpcSimulation( heating=HeatingOverlay(main_heating_index_number=13000) ) def _scored( measure_type: str, *, gain: float, cost: float, overlay: EpcSimulation ) -> ScoredOption: return ScoredOption( option=MeasureOption( measure_type=MeasureType(measure_type), description=measure_type, overlay=overlay, cost=Cost(total=cost, contingency_rate=0.0), ), sap_gain=gain, ) class _StubScorer: """Deterministic stand-in for PackageScorer: the package SAP is a base plus a fixed true gain per overlay kind present (wall / roof / heating), so the two-phase selection is exercised without the calculator.""" def __init__( self, *, base: float, wall: float, roof: float, heating: float ) -> None: self._base = base self._wall = wall self._roof = roof self._heating = heating def score( self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] ) -> Score: sap = self._base for sim in simulations: if sim.heating is not None: sap += self._heating 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 _selected_types(package: OptimisedPackage) -> set[str]: return {scored.option.measure_type for scored in package.selected} def test_fabric_reaching_the_target_excludes_non_fabric_measures() -> None: # Arrange — the ASHP dominates on both gain and SAP-per-£ (a plain # least-cost-to-target run would take it alone), but the wall by itself # reaches the target: fabric first means the package stops at the fabric. groups: list[list[ScoredOption]] = [ [_scored("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], [_scored("air_source_heat_pump", gain=30.0, cost=500.0, overlay=_HEATING_OVERLAY)], ] scorer = _StubScorer(base=60.0, wall=10.0, roof=0.0, heating=30.0) # Act — target 69 (gain 9 over the 60 baseline). package: OptimisedPackage = optimise_package_fabric_first( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=10000.0, target_sap=69.0, ) # Assert — fabric only: the wall (true 70 ≥ 69); the heat pump is never # considered because the upgrade requirement is already met. assert _selected_types(package) == {"cavity_wall_insulation"} assert abs(package.score.sap_continuous - 70.0) <= 1e-9 def test_fabric_short_of_target_is_topped_up_with_non_fabric_measures() -> None: # Arrange — all the fabric there is (the wall, +5) cannot reach the target; # phase 2 must add the heat pump on top of the retained fabric. groups: list[list[ScoredOption]] = [ [_scored("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], [_scored("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=_HEATING_OVERLAY)], ] scorer = _StubScorer(base=60.0, wall=5.0, roof=0.0, heating=20.0) # Act — target 75 (gain 15); fabric alone tops out at 65. package: OptimisedPackage = optimise_package_fabric_first( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=20000.0, target_sap=75.0, ) # Assert — the fabric is kept and the heat pump lands on top of it; the # score is the truthful whole-package figure (60 + 5 + 20). assert _selected_types(package) == { "cavity_wall_insulation", "air_source_heat_pump", } assert abs(package.score.sap_continuous - 85.0) <= 1e-9 def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None: # Arrange — the £8000 heat pump alone would fit the £8500 budget and reach # the target, but fabric first commits the £1000 wall first, leaving £7500: # the heat pump no longer fits. Fabric priority wins over the target. groups: list[list[ScoredOption]] = [ [_scored("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], [_scored("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=_HEATING_OVERLAY)], ] scorer = _StubScorer(base=60.0, wall=5.0, roof=0.0, heating=20.0) # Act — target 78 (gain 18). package: OptimisedPackage = optimise_package_fabric_first( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=8500.0, target_sap=78.0, ) # Assert — wall only; the target is missed rather than the fabric skipped. assert _selected_types(package) == {"cavity_wall_insulation"} assert abs(package.score.sap_continuous - 65.0) <= 1e-9 class _InteractionScorer: """A stub whose boiler gain collapses once the wall is insulated (+10 raw, +3 post-fabric) while the heat pump's holds (+8 either way) — so a phase 2 that keeps valuing candidates against the raw baseline picks the wrong heating system.""" def score( self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] ) -> Score: wall_present = any( part.wall_insulation_type is not None for sim in simulations for part in sim.building_parts.values() ) sap = 60.0 + (5.0 if wall_present else 0.0) for sim in simulations: if sim.heating is None: continue if sim.heating.sap_main_heating_code is not None: sap += 3.0 if wall_present else 10.0 if sim.heating.main_heating_index_number is not None: sap += 8.0 return Score( sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0 ) def test_phase_two_values_candidates_against_the_post_fabric_dwelling() -> None: # Arrange — one heating Recommendation, two Options. The boiler's role-1 # signal (vs the raw baseline, +10) beats the heat pump's (+8) and it is # cheaper — but on the insulated dwelling the boiler is only worth +3. # Only a heat pump gets the fabric-applied dwelling to the target. groups: list[list[ScoredOption]] = [ [_scored("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], [ _scored("gas_boiler_upgrade", gain=10.0, cost=2000.0, overlay=_BOILER_OVERLAY), _scored("air_source_heat_pump", gain=8.0, cost=6000.0, overlay=_ASHP_OVERLAY), ], ] scorer = _InteractionScorer() # Act — target 73: wall (65) + boiler-post-fabric (+3) = 68 misses; wall + # heat pump (+8) = 73 reaches. The heating group is consumed by whichever # option phase 2 warm-starts with, so the choice must be made on # post-fabric values, not raw-baseline signals. package: OptimisedPackage = optimise_package_fabric_first( groups=groups, scorer=scorer, baseline_epc=build_epc(), budget=20000.0, target_sap=73.0, ) # Assert assert _selected_types(package) == { "cavity_wall_insulation", "air_source_heat_pump", } assert abs(package.score.sap_continuous - 73.0) <= 1e-9