"""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)) 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