From fcf46263bc61f1d6566b2e28396323dfa661f4c7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:36:02 +0000 Subject: [PATCH] =?UTF-8?q?Fabric-first=20scenario=20stops=20at=20fabric?= =?UTF-8?q?=20when=20the=20target=20is=20met=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/measure_type.py | 22 ++++ domain/modelling/optimisation/optimiser.py | 17 +++ .../modelling/test_optimiser_fabric_first.py | 119 ++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 tests/domain/modelling/test_optimiser_fabric_first.py diff --git a/domain/modelling/measure_type.py b/domain/modelling/measure_type.py index a1882853e..9b4fd4818 100644 --- a/domain/modelling/measure_type.py +++ b/domain/modelling/measure_type.py @@ -13,6 +13,7 @@ change. It is also the vocabulary the ``considered_measures`` allowlist speaks from __future__ import annotations from enum import StrEnum +from typing import Final class MeasureType(StrEnum): @@ -37,3 +38,24 @@ class MeasureType(StrEnum): SYSTEM_TUNE_UP_ZONED = "system_tune_up_zoned" SOLAR_PV = "solar_pv" SECONDARY_HEATING_REMOVAL = "secondary_heating_removal" + + +# The measure types a Fabric First Scenario treats in phase 1 — the building +# envelope: wall / roof / floor insulation and glazing. Everything else +# (heating, solar, lighting, tune-ups, secondary-heating removal) waits for +# phase 2. Mechanical ventilation is deliberately absent: it is never selected, +# only injected as a forced Measure Dependency of the fabric that triggers it. +FABRIC_MEASURE_TYPES: Final[frozenset[MeasureType]] = frozenset( + { + MeasureType.CAVITY_WALL_INSULATION, + MeasureType.EXTERNAL_WALL_INSULATION, + MeasureType.INTERNAL_WALL_INSULATION, + MeasureType.LOFT_INSULATION, + MeasureType.SLOPING_CEILING_INSULATION, + MeasureType.FLAT_ROOF_INSULATION, + MeasureType.SUSPENDED_FLOOR_INSULATION, + MeasureType.SOLID_FLOOR_INSULATION, + MeasureType.DOUBLE_GLAZING, + MeasureType.SECONDARY_GLAZING, + } +) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 0e5776577..9b076ec4a 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -223,6 +223,23 @@ def optimise_package( 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] = (), +) -> 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.""" + raise NotImplementedError + + def _with_role1_signals( dependencies: Sequence[MeasureDependency], scorer: Scorer, diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py new file mode 100644 index 000000000..483112e69 --- /dev/null +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -0,0 +1,119 @@ +"""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 + +