From fcf46263bc61f1d6566b2e28396323dfa661f4c7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:36:02 +0000 Subject: [PATCH 01/36] =?UTF-8?q?Fabric-first=20scenario=20stops=20at=20fa?= =?UTF-8?q?bric=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 + + From 9ad2c2edfdb860cf8da867ae1e3057b3c8b9f69a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:36:27 +0000 Subject: [PATCH 02/36] =?UTF-8?q?Fabric-first=20scenario=20stops=20at=20fa?= =?UTF-8?q?bric=20when=20the=20target=20is=20met=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 23 ++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 9b076ec4a..b47495f00 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -24,7 +24,7 @@ from dataclasses import dataclass from typing import Optional, Protocol, Sequence from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.modelling.measure_type import MeasureType +from domain.modelling.measure_type import FABRIC_MEASURE_TYPES, MeasureType from domain.modelling.scoring.package_scorer import Score from domain.modelling.recommendation import MeasureOption from domain.modelling.simulation import EpcSimulation @@ -237,7 +237,26 @@ def optimise_package_fabric_first( 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 + fabric_groups: list[list[ScoredOption]] = [ + group for group in groups if _is_fabric_group(group) + ] + fabric_package: OptimisedPackage = optimise_package( + groups=fabric_groups, + scorer=scorer, + baseline_epc=baseline_epc, + budget=budget, + target_sap=target_sap, + dependencies=dependencies, + ) + return fabric_package + + +def _is_fabric_group(group: list[ScoredOption]) -> bool: + """A group belongs to phase 1 when every Option in it is a fabric measure + (groups are one Recommendation each, so they are homogeneous in kind).""" + return all( + scored.option.measure_type in FABRIC_MEASURE_TYPES for scored in group + ) def _with_role1_signals( From e880ae868c61fb40f53d67f50acf8a37658b1d62 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:36:43 +0000 Subject: [PATCH 03/36] =?UTF-8?q?Fabric=20short=20of=20the=20target=20is?= =?UTF-8?q?=20topped=20up=20with=20non-fabric=20measures=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 --- .../modelling/test_optimiser_fabric_first.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py index 483112e69..d1011ed73 100644 --- a/tests/domain/modelling/test_optimiser_fabric_first.py +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -117,3 +117,30 @@ def test_fabric_reaching_the_target_excludes_non_fabric_measures() -> None: 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 + + From e652780873e062a791e4426eb1cc4406f7e87712 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:39:21 +0000 Subject: [PATCH 04/36] =?UTF-8?q?Fabric=20short=20of=20the=20target=20is?= =?UTF-8?q?=20topped=20up=20with=20non-fabric=20measures=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 53 +++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) 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, From 705c86d5e6d281199387b6c0725fa7af6cdb0e8a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:40:29 +0000 Subject: [PATCH 05/36] =?UTF-8?q?Phase-2=20candidates=20are=20valued=20aga?= =?UTF-8?q?inst=20the=20post-fabric=20dwelling=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 --- .../modelling/test_optimiser_fabric_first.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py index d1011ed73..f345d6e75 100644 --- a/tests/domain/modelling/test_optimiser_fabric_first.py +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -44,6 +44,12 @@ _ROOF_OVERLAY = EpcSimulation( } ) _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: @@ -144,3 +150,62 @@ def test_fabric_short_of_target_is_topped_up_with_non_fabric_measures() -> None: assert abs(package.score.sap_continuous - 85.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 From 7f4d31393299af24c6ffa7013b8cb8f564da3fb5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:40:34 +0000 Subject: [PATCH 06/36] =?UTF-8?q?Phase-2=20candidates=20are=20valued=20aga?= =?UTF-8?q?inst=20the=20post-fabric=20dwelling=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 27 +++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index aa220dc07..43a87dfce 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -272,7 +272,7 @@ def optimise_package_fabric_first( None if budget is None else budget - _package_cost(fabric_package.selected) ) top_up: OptimisedPackage = optimise_package( - groups=remaining_groups, + groups=_rescored_groups(remaining_groups, post_fabric_scorer, baseline_epc), scorer=post_fabric_scorer, baseline_epc=baseline_epc, budget=leftover_budget, @@ -293,6 +293,31 @@ def _is_fabric_group(group: list[ScoredOption]) -> bool: ) +def _rescored_groups( + groups: list[list[ScoredOption]], + scorer: Scorer, + baseline_epc: EpcPropertyData, +) -> list[list[ScoredOption]]: + """The groups with every Option's role-1 warm-start signal re-scored + through ``scorer`` — for phase 2, its independent gain on the post-fabric + dwelling rather than the raw baseline, so options whose worth changes once + the envelope is treated (a boiler on an insulated home) are re-ranked.""" + start_sap: float = scorer.score(baseline_epc, []).sap_continuous + return [ + [ + ScoredOption( + option=scored.option, + sap_gain=scorer.score( + baseline_epc, [scored.option.overlay] + ).sap_continuous + - start_sap, + ) + for scored in group + ] + for group in groups + ] + + 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. From 48ce18dbb6083c65b15b01b62865860dbeaf85ff Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:41:22 +0000 Subject: [PATCH 07/36] =?UTF-8?q?Fabric=20spend=20comes=20out=20of=20the?= =?UTF-8?q?=20shared=20budget=20before=20phase=202=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents behaviour already delivered by the phase-2 structure: the leftover budget is the envelope minus the committed fabric cost, so a heating system the plain optimiser would buy can be priced out. Test passed on arrival. Co-Authored-By: Claude Fable 5 --- .../modelling/test_optimiser_fabric_first.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py index f345d6e75..eeffd181b 100644 --- a/tests/domain/modelling/test_optimiser_fabric_first.py +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -150,6 +150,30 @@ def test_fabric_short_of_target_is_topped_up_with_non_fabric_measures() -> None: 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 From adf60b82b1a0d4b7b60cbe7a691ab47ad81c0188 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:41:22 +0000 Subject: [PATCH 08/36] =?UTF-8?q?Fabric=20unpicked=20in=20phase=201=20can?= =?UTF-8?q?=20re-enter=20phase=202=20on=20post-fabric=20worth=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents behaviour already delivered: phase 2 optimises every group phase 1 did not consume, with signals re-scored against the fabric-applied dwelling, so glazing skipped on raw-baseline merit re-enters when it closes the target. Test passed on arrival. Co-Authored-By: Claude Fable 5 --- .../modelling/test_optimiser_fabric_first.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py index eeffd181b..0fd088627 100644 --- a/tests/domain/modelling/test_optimiser_fabric_first.py +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -27,6 +27,7 @@ from domain.modelling.scoring.package_scorer import Score from domain.modelling.simulation import ( BuildingPartOverlay, EpcSimulation, + GlazingOverlay, HeatingOverlay, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( @@ -201,6 +202,64 @@ class _InteractionScorer: ) +_GLAZING_OVERLAY = EpcSimulation(glazing=GlazingOverlay(glazing_type=2)) + + +class _GlazingInteractionScorer: + """A stub where glazing is worthless on the raw dwelling (+0) but worth +4 + once the wall is insulated — so phase 1's max-gain fabric pass leaves it + out, and only a phase 2 that re-admits unpicked fabric can close the + target with it.""" + + 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() + ) + glazing_present = any(sim.glazing is not None for sim in simulations) + heating_present = any(sim.heating is not None for sim in simulations) + sap = 60.0 + sap += 5.0 if wall_present else 0.0 + sap += (4.0 if wall_present else 0.0) if glazing_present else 0.0 + sap += 10.0 if heating_present else 0.0 + return Score( + sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0 + ) + + +def test_fabric_unpicked_in_phase_one_can_reenter_phase_two() -> None: + # Arrange — glazing loses phase 1 on merit (it scores nothing on the raw + # dwelling), but post-wall it is the only affordable way to the target: + # the heat pump that could also close it does not fit the leftover budget. + groups: list[list[ScoredOption]] = [ + [_scored("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [_scored("double_glazing", gain=0.0, cost=500.0, overlay=_GLAZING_OVERLAY)], + [_scored("air_source_heat_pump", gain=10.0, cost=8000.0, overlay=_HEATING_OVERLAY)], + ] + scorer = _GlazingInteractionScorer() + + # Act — target 69 (gain 9); budget £5000 keeps the heat pump out of reach + # after the wall's £1000. + package: OptimisedPackage = optimise_package_fabric_first( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=5000.0, + target_sap=69.0, + ) + + # Assert — the skipped glazing re-enters on its post-fabric worth: 60 + 5 + # wall + 4 glazing = 69, target met. + assert _selected_types(package) == { + "cavity_wall_insulation", + "double_glazing", + } + assert abs(package.score.sap_continuous - 69.0) <= 1e-9 + + 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 From 0220bee87cfab0c2319c2b94c71786b347505af8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:41:27 +0000 Subject: [PATCH 09/36] =?UTF-8?q?Forced=20ventilation=20is=20injected=20on?= =?UTF-8?q?ce=20across=20both=20fabric-first=20phases=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 --- .../modelling/test_optimiser_fabric_first.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py index 0fd088627..71ce7a4f6 100644 --- a/tests/domain/modelling/test_optimiser_fabric_first.py +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -18,6 +18,7 @@ from datatypes.epc.domain.epc_property_data import ( ) from domain.modelling.measure_type import MeasureType from domain.modelling.optimisation.optimiser import ( + MeasureDependency, OptimisedPackage, ScoredOption, optimise_package_fabric_first, @@ -29,6 +30,7 @@ from domain.modelling.simulation import ( EpcSimulation, GlazingOverlay, HeatingOverlay, + VentilationOverlay, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, @@ -175,6 +177,104 @@ def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None: assert abs(package.score.sap_continuous - 65.0) <= 1e-9 +_IWI_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=3) + } +) +_VENT_OVERLAY = EpcSimulation( + ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE") +) + + +class _TwoWallScorer: + """A stub with two wall treatments (cavity type=2, internal type=3): the + internal wall is worthless raw but +4 once the cavity is done, and every + ventilation overlay present costs −1 — so both phases trigger the same + forced ventilation dependency and a double injection is visible in the + package score.""" + + def score( + self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] + ) -> Score: + cavity = any( + part.wall_insulation_type == 2 + for sim in simulations + for part in sim.building_parts.values() + ) + internal = any( + part.wall_insulation_type == 3 + for sim in simulations + for part in sim.building_parts.values() + ) + vents = sum(1 for sim in simulations if sim.ventilation is not None) + sap = 60.0 + sap += 5.0 if cavity else 0.0 + sap += (4.0 if cavity else 0.0) if internal else 0.0 + sap -= float(vents) + return Score( + sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0 + ) + + +def _wall_ventilation_dependency(*, cost: float) -> MeasureDependency: + return MeasureDependency( + triggers=frozenset( + { + MeasureType.CAVITY_WALL_INSULATION, + MeasureType.INTERNAL_WALL_INSULATION, + } + ), + required=ScoredOption( + option=MeasureOption( + measure_type=MeasureType.MECHANICAL_VENTILATION, + description="mechanical_ventilation", + overlay=_VENT_OVERLAY, + cost=Cost(total=cost, contingency_rate=0.0), + ), + sap_gain=0.0, + ), + ) + + +def test_ventilation_dependency_is_injected_once_across_both_phases() -> None: + # Arrange — the cavity wall (phase 1) and the internal wall (picked in + # phase 2 on its post-cavity worth) both trigger the same forced + # ventilation. It must land in the package exactly once — phase 2 sees the + # phase-1 dwelling as already ventilated. + groups: list[list[ScoredOption]] = [ + [_scored("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [_scored("internal_wall_insulation", gain=0.0, cost=2000.0, overlay=_IWI_OVERLAY)], + ] + scorer = _TwoWallScorer() + + # Act — target 68: phase 1 gives 60 + 5 − 1 = 64; the internal wall's + # post-fabric +4 closes it, but only if ventilation is not double-counted. + package: OptimisedPackage = optimise_package_fabric_first( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=10000.0, + target_sap=68.0, + dependencies=[_wall_ventilation_dependency(cost=300.0)], + ) + + # Assert — one ventilation, and the truthful total counts its penalty once: + # 60 + 5 cavity + 4 internal − 1 ventilation = 68. + ventilation_count = sum( + 1 + for scored in package.selected + if scored.option.measure_type == MeasureType.MECHANICAL_VENTILATION + ) + assert ventilation_count == 1 + assert _selected_types(package) == { + "cavity_wall_insulation", + "internal_wall_insulation", + "mechanical_ventilation", + } + assert abs(package.score.sap_continuous - 68.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 From 471728db0a070323a2fc2e1df4d1bd53ceff9c30 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:42:54 +0000 Subject: [PATCH 10/36] =?UTF-8?q?Forced=20ventilation=20is=20injected=20on?= =?UTF-8?q?ce=20across=20both=20fabric-first=20phases=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 43a87dfce..4a5edd28e 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -271,13 +271,23 @@ def optimise_package_fabric_first( leftover_budget: Optional[float] = ( None if budget is None else budget - _package_cost(fabric_package.selected) ) + # A dependency phase 1 already injected (e.g. the wall's ventilation) is + # satisfied for the whole package: phase 2 must not force it in again. + fabric_types: set[MeasureType] = { + scored.option.measure_type for scored in fabric_package.selected + } + outstanding_dependencies: list[MeasureDependency] = [ + dependency + for dependency in dependencies + if dependency.required.option.measure_type not in fabric_types + ] top_up: OptimisedPackage = optimise_package( groups=_rescored_groups(remaining_groups, post_fabric_scorer, baseline_epc), scorer=post_fabric_scorer, baseline_epc=baseline_epc, budget=leftover_budget, target_sap=target_sap, - dependencies=dependencies, + dependencies=outstanding_dependencies, ) return OptimisedPackage( selected=[*fabric_package.selected, *top_up.selected], From dcc9f5d8aa5c5ccf55412f8c268a638b96618e82 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:42:54 +0000 Subject: [PATCH 11/36] =?UTF-8?q?A=20dwelling=20with=20no=20fabric=20candi?= =?UTF-8?q?dates=20proceeds=20straight=20to=20the=20full=20pool=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents behaviour already delivered: an empty phase 1 meets no target, so phase 2 optimises every group — identical to a plain run. Test passed on arrival. Co-Authored-By: Claude Fable 5 --- .../modelling/test_optimiser_fabric_first.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py index 71ce7a4f6..1eb19b696 100644 --- a/tests/domain/modelling/test_optimiser_fabric_first.py +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -275,6 +275,29 @@ def test_ventilation_dependency_is_injected_once_across_both_phases() -> None: assert abs(package.score.sap_continuous - 68.0) <= 1e-9 +def test_no_fabric_candidates_proceeds_straight_to_the_full_pool() -> None: + # Arrange — the envelope work is already done (no fabric Recommendation + # survives generation); fabric first must not veto the run, it just means + # phase 1 has nothing to do. + groups: list[list[ScoredOption]] = [ + [_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. + package: OptimisedPackage = optimise_package_fabric_first( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=20000.0, + target_sap=75.0, + ) + + # Assert — the heat pump package, exactly as a plain run would produce. + assert _selected_types(package) == {"air_source_heat_pump"} + assert abs(package.score.sap_continuous - 80.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 From d51801089b142316541dd5908a811db0af8b333b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:42:54 +0000 Subject: [PATCH 12/36] =?UTF-8?q?Fabric=20gets=20first=20claim=20on=20the?= =?UTF-8?q?=20budget=20for=20every=20goal,=20not=20just=20Increasing=20EPC?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents behaviour already delivered: with no SAP target both phases run max-gain, so the envelope still consumes the budget first. Test passed on arrival. Co-Authored-By: Claude Fable 5 --- .../modelling/test_optimiser_fabric_first.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py index 1eb19b696..09b01e646 100644 --- a/tests/domain/modelling/test_optimiser_fabric_first.py +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -298,6 +298,30 @@ def test_no_fabric_candidates_proceeds_straight_to_the_full_pool() -> None: assert abs(package.score.sap_continuous - 80.0) <= 1e-9 +def test_without_a_target_fabric_still_gets_first_claim_on_the_budget() -> None: + # Arrange — a max-gain goal (no SAP target). Plain max-gain would spend the + # whole £8000 on the heat pump (+20); fabric first commits the wall (+5) + # before the remainder is considered, pricing the heat pump out. + 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 — no target: the flag applies to every goal, not just Increasing EPC. + package: OptimisedPackage = optimise_package_fabric_first( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=8000.0, + target_sap=None, + ) + + # Assert — wall first; the heat pump no longer fits the leftover £7000. + 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 From caa0847b70af6a853138f757acaabce16b99126f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:44:31 +0000 Subject: [PATCH 13/36] =?UTF-8?q?Scenario=20carries=20its=20Fabric=20First?= =?UTF-8?q?=20flag=20from=20the=20scenario=20table=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/scenario.py | 8 ++++++- .../postgres/modelling/scenario_table.py | 3 +++ .../test_scenario_postgres_repository.py | 22 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/domain/modelling/scenario.py b/domain/modelling/scenario.py index 6792e268d..85162d6d0 100644 --- a/domain/modelling/scenario.py +++ b/domain/modelling/scenario.py @@ -26,7 +26,12 @@ class Scenario: `exclusions` are the measure types the brief bars from the run (the only measure-scoping the live ``scenario`` table persists — there is no - inclusions column). Empty means nothing is barred.""" + inclusions column). Empty means nothing is barred. + + `fabric_first` constrains the Optimiser to treat the building envelope + first: fabric measures are optimised with the full budget, and heating / + renewables are only considered on top of the committed fabric when the + fabric alone does not meet the brief's target.""" id: int goal: str @@ -34,6 +39,7 @@ class Scenario: budget: Optional[float] is_default: bool exclusions: frozenset[MeasureType] = _NO_EXCLUSIONS + fabric_first: bool = False def considered_measures(self) -> Optional[frozenset[MeasureType]]: """The measure-type allowlist the Scenario's exclusions imply: every diff --git a/infrastructure/postgres/modelling/scenario_table.py b/infrastructure/postgres/modelling/scenario_table.py index 5f7197bae..5d434b3be 100644 --- a/infrastructure/postgres/modelling/scenario_table.py +++ b/infrastructure/postgres/modelling/scenario_table.py @@ -78,6 +78,9 @@ class ScenarioModel(SQLModel, table=True): exclusions: Optional[str] = Field(default=None) multi_plan: bool = False is_default: bool = False + # Fabric First constraint (owned by the FE Drizzle schema: boolean NOT + # NULL DEFAULT false — do not deploy this mirror before that migration). + fabric_first: bool = False # Portfolio-level aggregates stored against the Scenario. cost: Optional[float] = Field(default=None) diff --git a/tests/repositories/scenario/test_scenario_postgres_repository.py b/tests/repositories/scenario/test_scenario_postgres_repository.py index 8b5804c7d..6fc537fe1 100644 --- a/tests/repositories/scenario/test_scenario_postgres_repository.py +++ b/tests/repositories/scenario/test_scenario_postgres_repository.py @@ -142,6 +142,28 @@ def test_get_many_raises_on_an_exclusion_that_is_not_a_measure_type( ScenarioPostgresRepository(session).get_many([7]) +def test_get_many_maps_the_fabric_first_flag(db_engine: Engine) -> None: + # Arrange — a Fabric First brief created in the scenario-builder. + with Session(db_engine) as session: + session.add( + ScenarioModel( + id=7, + goal=PortfolioGoal.INCREASING_EPC, + goal_value="C", + is_default=True, + fabric_first=True, + ) + ) + session.commit() + + # Act + with Session(db_engine) as session: + scenario: Scenario = ScenarioPostgresRepository(session).get_many([7])[0] + + # Assert + assert scenario.fabric_first is True + + def test_get_many_raises_when_a_scenario_id_is_missing(db_engine: Engine) -> None: # Arrange with Session(db_engine) as session: From e75798b281078e422bf7f2faae69e0f348c1e18c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:44:31 +0000 Subject: [PATCH 14/36] =?UTF-8?q?Scenario=20carries=20its=20Fabric=20First?= =?UTF-8?q?=20flag=20from=20the=20scenario=20table=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/postgres/modelling/scenario_table.py | 1 + 1 file changed, 1 insertion(+) diff --git a/infrastructure/postgres/modelling/scenario_table.py b/infrastructure/postgres/modelling/scenario_table.py index 5d434b3be..9dbe4feb4 100644 --- a/infrastructure/postgres/modelling/scenario_table.py +++ b/infrastructure/postgres/modelling/scenario_table.py @@ -118,4 +118,5 @@ class ScenarioModel(SQLModel, table=True): budget=self.budget, is_default=self.is_default, exclusions=_parse_exclusions(self.exclusions), + fabric_first=self.fabric_first, ) From a9173bd4928979cef2e9a8fbb540b6fc410371c4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:44:31 +0000 Subject: [PATCH 15/36] =?UTF-8?q?A=20Fabric=20First=20Scenario=20spends=20?= =?UTF-8?q?the=20budget=20on=20the=20envelope=20before=20heating=20?= =?UTF-8?q?=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 --- .../test_modelling_fabric_first.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/orchestration/test_modelling_fabric_first.py diff --git a/tests/orchestration/test_modelling_fabric_first.py b/tests/orchestration/test_modelling_fabric_first.py new file mode 100644 index 000000000..86d5e7746 --- /dev/null +++ b/tests/orchestration/test_modelling_fabric_first.py @@ -0,0 +1,60 @@ +"""The ModellingOrchestrator honours a Fabric First Scenario: when +``scenario.fabric_first`` is set, the Optimiser treats the building envelope +with the full budget before heating / renewables are considered, so a budget +the plain optimiser would spend on a heating system is spent on fabric +instead. End-to-end through ``run_modelling`` (no database) with the real +calculator, against the uninsulated solid-brick 001431 dwelling whose plain +plan is heating-led. +""" + +from __future__ import annotations + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.measure_type import FABRIC_MEASURE_TYPES, MeasureType +from domain.modelling.plan import Plan +from domain.modelling.scenario import Scenario +from harness.console import run_modelling +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) + +_HEATING_MEASURES: frozenset[MeasureType] = frozenset( + { + MeasureType.GAS_BOILER_UPGRADE, + MeasureType.AIR_SOURCE_HEAT_PUMP, + MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS, + MeasureType.SOLAR_PV, + } +) + + +def _fabric_first_scenario(*, budget: float) -> Scenario: + return Scenario( + id=999, + goal="Increasing EPC", + goal_value="C", + budget=budget, + is_default=True, + fabric_first=True, + ) + + +def test_fabric_first_scenario_spends_the_budget_on_fabric_before_heating() -> None: + # Arrange — uninsulated solid brick: at a £4000 budget the plain optimiser + # buys the £3200 gas boiler (the cheapest route toward band C). Fabric + # first must commit the envelope work first, after which the boiler no + # longer fits the leftover budget. + epc: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + + # Act + plan: Plan = run_modelling( + epc, scenario=_fabric_first_scenario(budget=4000.0), print_table=False + ) + + # Assert — the plan is fabric-led: at least one envelope measure, and none + # of the budget leaked to a heating system or renewables. + selected = {measure.measure_type for measure in plan.measures} + assert selected & FABRIC_MEASURE_TYPES + assert not selected & _HEATING_MEASURES From 012b0b03bafb31f50dd30391cb35adcf996bdf47 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:44:32 +0000 Subject: [PATCH 16/36] =?UTF-8?q?A=20Fabric=20First=20Scenario=20spends=20?= =?UTF-8?q?the=20budget=20on=20the=20envelope=20before=20heating=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- orchestration/modelling_orchestrator.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 7a30d7e82..23ac42941 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -19,6 +19,7 @@ from domain.modelling.optimisation.optimiser import ( OptimisedPackage, ScoredOption, optimise_package, + optimise_package_fabric_first, ) from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.plan import Plan, PlanMeasure @@ -188,7 +189,13 @@ class ModellingOrchestrator: dependencies: list[MeasureDependency] = _measure_dependencies( effective_epc, products, considered ) - package: OptimisedPackage = optimise_package( + # A Fabric First brief optimises the envelope with the full budget + # before heating / renewables are considered on top (mirroring the + # legacy engine's enforce_fabric_first). + optimise = ( + optimise_package_fabric_first if scenario.fabric_first else optimise_package + ) + package: OptimisedPackage = optimise( groups=groups, scorer=scorer, baseline_epc=effective_epc, From 4d7434a954a95345e04c37e30f9ae2241595cdc1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:45:56 +0000 Subject: [PATCH 17/36] =?UTF-8?q?Hoist=20consumed-group=20lookup=20out=20o?= =?UTF-8?q?f=20the=20phase-2=20comprehension=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 4a5edd28e..73f5e9a1a 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -260,10 +260,9 @@ def optimise_package_fabric_first( # 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. + consumed: set[int] = _used_group_indices(groups, fabric_package.selected) remaining_groups: list[list[ScoredOption]] = [ - group - for index, group in enumerate(groups) - if index not in _used_group_indices(groups, fabric_package.selected) + group for index, group in enumerate(groups) if index not in consumed ] post_fabric_scorer = _PrefixedScorer( scorer, [scored.option.overlay for scored in fabric_package.selected] From 38206c227b4755ab5b9437f2f9b26f958ff4449d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:45:56 +0000 Subject: [PATCH 18/36] Record the fabric-first two-phase decision as ADR-0061 Co-Authored-By: Claude Fable 5 --- ...abric-first-is-a-two-phase-optimisation.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/adr/0061-fabric-first-is-a-two-phase-optimisation.md diff --git a/docs/adr/0061-fabric-first-is-a-two-phase-optimisation.md b/docs/adr/0061-fabric-first-is-a-two-phase-optimisation.md new file mode 100644 index 000000000..31f1794c4 --- /dev/null +++ b/docs/adr/0061-fabric-first-is-a-two-phase-optimisation.md @@ -0,0 +1,73 @@ +--- +status: accepted (extends ADR-0016) +--- + +# Fabric First is a two-phase optimisation with strict envelope priority + +Landlords with a fabric-first retrofit policy require the building envelope — +insulation and windows — to be treated before heating systems and renewables +are considered. The legacy engine carried this as `enforce_fabric_first` on +the plan-API request body (`funding_optimiser.optimise_with_scenarios`): an +optimiser pass over fabric-only measures with the full budget, then a second +pass over the remainder with the leftover budget and the residual target, +summing approximate per-measure SAP points. The new engine needed the same +capability on the truthful-re-score Optimiser core (ADR-0016). + +Decided in a grilling session with Khalim, 2026-07-09. + +## Decision + +**`fabric_first` is a Scenario attribute, and the Optimiser honours it as two +sequential `optimise_package` phases in which the envelope has strict first +claim on the budget** (`optimise_package_fabric_first`, ADR-0016 core reused +per phase). + +- **The flag lives on the `scenario` table** (FE-owned Drizzle schema: + `fabric_first boolean NOT NULL DEFAULT false`), mirrored in `ScenarioModel` + and the domain `Scenario`. A Plan's provenance stays derivable from its + Scenario row alone — unlike the legacy request-body flag, the same + scenario_id cannot produce differently-constrained plans. **Deploy order**: + the Drizzle migration must land before this mirror, or every scenario read + crashes on the missing column. +- **Fabric = the building envelope** (`FABRIC_MEASURE_TYPES`): wall (CWI / + EWI / IWI), roof (loft / sloping-ceiling / flat-roof), floor (suspended / + solid) insulation, plus double / secondary glazing — the legacy list + exactly. Lighting, tune-ups, secondary-heating removal, heating and solar + all wait for phase 2. Mechanical ventilation is unclassified: it is never + selected, only injected as a forced Measure Dependency (ADR-0016) of the + fabric that triggers it, in whichever phase that happens — and only once. +- **Phase 1** runs `optimise_package` over the fabric groups with the full + budget: least-cost-to-target, repair, max-gain fallback. If the truthful + post-fabric score meets the Scenario target, the package is fabric-only — + surplus budget is left unspent, per the ADR-0016 no-overshoot doctrine. +- **Phase 2** (target unmet, or no target) optimises every group phase 1 did + not consume — non-fabric, plus fabric groups it left unpicked, which may + re-enter on their post-fabric worth — under the leftover budget. Candidates + are valued **against the fabric-applied dwelling**: warm-start signals are + re-scored and every package re-score is prefixed with the phase-1 overlays + (`_PrefixedScorer`), so a heating system whose worth changes once the + envelope is treated is re-ranked truthfully, and the returned Score remains + the whole-package figure against the true baseline (bills and the role-3 + cascade stay honest). +- **Strict priority, not target-aware compromise**: phase 1 commits the + max-gain fabric package even when a cheaper fabric/heating split would have + reached the target — a £4,000 budget buys floor insulation and leaves the + £3,200 boiler unaffordable, and the target is missed rather than the fabric + skipped. This is the landlord's explicit trade. +- **Every goal honours the flag**, not just Increasing EPC: with no SAP + target the two phases are both max-gain, so fabric still gets first claim + on the budget. +- **No Plan-contract change**: the Scenario row is the provenance; the + best-practice cascade already orders fabric before heating in the persisted + measures. + +## Consequences + +- The Modelling orchestrator branches once, on `scenario.fabric_first`, + between `optimise_package` and `optimise_package_fabric_first`; everything + downstream (attribution, bills, persistence) is unchanged. +- Phase 2 re-scores each remaining Option once against the post-fabric + dwelling — a handful of extra calculator calls per fabric-first Plan. +- A fabric-first Plan can undershoot a target a plain Plan would have reached + within the same budget. This is by design and should be communicated when + scenario results are compared. From 877d0043cb628f3f22757ea12ef067ac80a14386 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 13:59:14 +0000 Subject: [PATCH 19/36] =?UTF-8?q?Ventilation-once=20test=20pairs=20the=20w?= =?UTF-8?q?all=20with=20glazing,=20not=20a=20competing=20wall=20?= =?UTF-8?q?=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: cavity and internal wall insulation are competing options, so a package selecting both read as nonsense even in a synthetic fixture. The behaviour under test (a dependency triggered in both phases injects once) now uses an airtightness pair that genuinely coexists — cavity wall in phase 1, double glazing re-entering in phase 2 — with the same numbers. Co-Authored-By: Claude Fable 5 --- .../modelling/test_optimiser_fabric_first.py | 60 ++++++++----------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py index 09b01e646..1f7dce7c2 100644 --- a/tests/domain/modelling/test_optimiser_fabric_first.py +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -47,6 +47,7 @@ _ROOF_OVERLAY = EpcSimulation( } ) _HEATING_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201)) +_GLAZING_OVERLAY = EpcSimulation(glazing=GlazingOverlay(glazing_type=2)) _BOILER_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201)) _ASHP_OVERLAY = EpcSimulation( heating=HeatingOverlay(main_heating_index_number=13000) @@ -177,52 +178,44 @@ def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None: assert abs(package.score.sap_continuous - 65.0) <= 1e-9 -_IWI_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=3) - } -) _VENT_OVERLAY = EpcSimulation( ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE") ) -class _TwoWallScorer: - """A stub with two wall treatments (cavity type=2, internal type=3): the - internal wall is worthless raw but +4 once the cavity is done, and every - ventilation overlay present costs −1 — so both phases trigger the same - forced ventilation dependency and a double injection is visible in the - package score.""" +class _AirtightnessScorer: + """A stub where tightening the envelope demands ventilation: the cavity + wall is +5 SAP, the new double glazing is worthless on the raw dwelling + but +4 once the wall is insulated, and every ventilation overlay present + costs −1 — so a double injection is visible in the package score.""" def score( self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] ) -> Score: - cavity = any( - part.wall_insulation_type == 2 - for sim in simulations - for part in sim.building_parts.values() - ) - internal = any( - part.wall_insulation_type == 3 + wall = any( + part.wall_insulation_type is not None for sim in simulations for part in sim.building_parts.values() ) + glazing = any(sim.glazing is not None for sim in simulations) vents = sum(1 for sim in simulations if sim.ventilation is not None) sap = 60.0 - sap += 5.0 if cavity else 0.0 - sap += (4.0 if cavity else 0.0) if internal else 0.0 + sap += 5.0 if wall else 0.0 + sap += (4.0 if wall else 0.0) if glazing else 0.0 sap -= float(vents) return Score( sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0 ) -def _wall_ventilation_dependency(*, cost: float) -> MeasureDependency: +def _airtightness_ventilation_dependency(*, cost: float) -> MeasureDependency: + """A forced 'airtightness requires ventilation' edge: both the wall and + the sealed new glazing trigger the same mechanical ventilation.""" return MeasureDependency( triggers=frozenset( { MeasureType.CAVITY_WALL_INSULATION, - MeasureType.INTERNAL_WALL_INSULATION, + MeasureType.DOUBLE_GLAZING, } ), required=ScoredOption( @@ -238,17 +231,17 @@ def _wall_ventilation_dependency(*, cost: float) -> MeasureDependency: def test_ventilation_dependency_is_injected_once_across_both_phases() -> None: - # Arrange — the cavity wall (phase 1) and the internal wall (picked in - # phase 2 on its post-cavity worth) both trigger the same forced - # ventilation. It must land in the package exactly once — phase 2 sees the - # phase-1 dwelling as already ventilated. + # Arrange — the cavity wall (phase 1) and the double glazing (skipped in + # phase 1 on merit, picked in phase 2 on its post-fabric worth) both + # trigger the same forced ventilation. It must land in the package exactly + # once — phase 2 sees the phase-1 dwelling as already ventilated. groups: list[list[ScoredOption]] = [ [_scored("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored("internal_wall_insulation", gain=0.0, cost=2000.0, overlay=_IWI_OVERLAY)], + [_scored("double_glazing", gain=0.0, cost=500.0, overlay=_GLAZING_OVERLAY)], ] - scorer = _TwoWallScorer() + scorer = _AirtightnessScorer() - # Act — target 68: phase 1 gives 60 + 5 − 1 = 64; the internal wall's + # Act — target 68: phase 1 gives 60 + 5 − 1 = 64; the glazing's # post-fabric +4 closes it, but only if ventilation is not double-counted. package: OptimisedPackage = optimise_package_fabric_first( groups=groups, @@ -256,11 +249,11 @@ def test_ventilation_dependency_is_injected_once_across_both_phases() -> None: baseline_epc=build_epc(), budget=10000.0, target_sap=68.0, - dependencies=[_wall_ventilation_dependency(cost=300.0)], + dependencies=[_airtightness_ventilation_dependency(cost=300.0)], ) # Assert — one ventilation, and the truthful total counts its penalty once: - # 60 + 5 cavity + 4 internal − 1 ventilation = 68. + # 60 + 5 wall + 4 glazing − 1 ventilation = 68. ventilation_count = sum( 1 for scored in package.selected @@ -269,7 +262,7 @@ def test_ventilation_dependency_is_injected_once_across_both_phases() -> None: assert ventilation_count == 1 assert _selected_types(package) == { "cavity_wall_insulation", - "internal_wall_insulation", + "double_glazing", "mechanical_ventilation", } assert abs(package.score.sap_continuous - 68.0) <= 1e-9 @@ -349,9 +342,6 @@ class _InteractionScorer: ) -_GLAZING_OVERLAY = EpcSimulation(glazing=GlazingOverlay(glazing_type=2)) - - class _GlazingInteractionScorer: """A stub where glazing is worthless on the raw dwelling (+0) but worth +4 once the wall is insulated — so phase 1's max-gain fabric pass leaves it From b37ee071b7bf4c32c1e748c86f04a714ec6c7a14 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 10:29:45 +0000 Subject: [PATCH 20/36] =?UTF-8?q?Fabric-first=20skips=20the=20phase-2=20re?= =?UTF-8?q?-score=20when=20it=20cannot=20change=20the=20plan=20?= =?UTF-8?q?=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on PR #1526, behaviour-preserving: - No fabric groups, or phase 1 committed nothing → delegate to the plain optimise_package: the phase-2 prefix would be empty and its re-scoring would reproduce the role-1 signals the groups already carry, one full SAP-calculator run per Option for zero effect (the common case on already-insulated stock). - Phase 1 consumed every group → return the fabric package instead of a phase-2 pass that can only select the empty package. - _rescored_groups takes start_sap from the caller: the post-fabric baseline score is the phase-1 package score already in hand, not a fresh calculator run. - fabric_types → phase_one_types: the set holds everything phase 1 committed, injected dependencies included — that inclusion is what the outstanding-dependencies filter relies on, so the name must not invite narrowing it to FABRIC_MEASURE_TYPES. Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 47 +++++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 73f5e9a1a..a1e45a58f 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -240,6 +240,17 @@ def optimise_package_fabric_first( fabric_groups: list[list[ScoredOption]] = [ group for group in groups if _is_fabric_group(group) ] + if not fabric_groups: + # Nothing for phase 1 to claim (the envelope is already treated): + # a plain run is identical and skips the phase-2 re-scoring. + return optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=baseline_epc, + budget=budget, + target_sap=target_sap, + dependencies=dependencies, + ) fabric_package: OptimisedPackage = optimise_package( groups=fabric_groups, scorer=scorer, @@ -253,6 +264,18 @@ def optimise_package_fabric_first( and fabric_package.score.sap_continuous >= target_sap ): return fabric_package + if not fabric_package.selected: + # Phase 1 committed nothing (no fabric affordable or worth having), so + # the phase-2 prefix would be empty and its re-scoring would reproduce + # the signals the groups already carry: a plain run is identical. + return optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=baseline_epc, + budget=budget, + target_sap=target_sap, + dependencies=dependencies, + ) # Phase 2 — the upgrade requirement is not met by fabric alone: optimise # the remaining groups (non-fabric, plus any fabric group phase 1 left @@ -264,24 +287,32 @@ def optimise_package_fabric_first( remaining_groups: list[list[ScoredOption]] = [ group for index, group in enumerate(groups) if index not in consumed ] + if not remaining_groups: + return fabric_package 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) ) - # A dependency phase 1 already injected (e.g. the wall's ventilation) is + # Everything phase 1 committed — its picks plus the dependencies it + # injected. A dependency already injected (e.g. the wall's ventilation) is # satisfied for the whole package: phase 2 must not force it in again. - fabric_types: set[MeasureType] = { + phase_one_types: set[MeasureType] = { scored.option.measure_type for scored in fabric_package.selected } outstanding_dependencies: list[MeasureDependency] = [ dependency for dependency in dependencies - if dependency.required.option.measure_type not in fabric_types + if dependency.required.option.measure_type not in phase_one_types ] top_up: OptimisedPackage = optimise_package( - groups=_rescored_groups(remaining_groups, post_fabric_scorer, baseline_epc), + groups=_rescored_groups( + remaining_groups, + post_fabric_scorer, + baseline_epc, + start_sap=fabric_package.score.sap_continuous, + ), scorer=post_fabric_scorer, baseline_epc=baseline_epc, budget=leftover_budget, @@ -306,12 +337,16 @@ def _rescored_groups( groups: list[list[ScoredOption]], scorer: Scorer, baseline_epc: EpcPropertyData, + *, + start_sap: float, ) -> list[list[ScoredOption]]: """The groups with every Option's role-1 warm-start signal re-scored through ``scorer`` — for phase 2, its independent gain on the post-fabric dwelling rather than the raw baseline, so options whose worth changes once - the envelope is treated (a boiler on an insulated home) are re-ranked.""" - start_sap: float = scorer.score(baseline_epc, []).sap_continuous + the envelope is treated (a boiler on an insulated home) are re-ranked. + ``start_sap`` is the score of ``baseline_epc`` through ``scorer`` with no + candidate applied — the caller already has it (the phase-1 package score), + so it is threaded in rather than re-computed.""" return [ [ ScoredOption( From 832a30a9858cc202a91cb678025d80c6cc93167b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 10:29:45 +0000 Subject: [PATCH 21/36] =?UTF-8?q?Optimiser=20test=20fixtures=20are=20share?= =?UTF-8?q?d=20and=20domain-plausible=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on PR #1526: - tests/domain/modelling/_optimiser_fixtures.py is the one home for the overlay constants, the ScoredOption builder, the additive per-kind StubScorer and the forced ventilation dependency; test_optimiser.py and test_optimiser_fabric_first.py had byte-identical copies of each (and _StubScorer / _VentStubScorer fold into one parameterised stub). - Fixture worlds are domain-plausible per team convention: the fabric-vs- heating contrast is a £12,000 EWI against a £3,200 gas boiler rather than a £500 heat pump undercutting a £1,000 cavity wall; heating overlays carry real identities (SAP Table 4a code 104 for the boiler, a PCDF index for the heat pump) instead of code 201 doubling as both; whole-dwelling double glazing is £3,500, not £500. - Dead knobs removed: the unused _ROOF_OVERLAY, the always-zero roof gain, the duplicate _BOILER_OVERLAY, and the nested conditional expressions in the interaction stubs. Co-Authored-By: Claude Fable 5 --- tests/domain/modelling/_optimiser_fixtures.py | 144 ++++++++ tests/domain/modelling/test_optimiser.py | 315 +++++------------- .../modelling/test_optimiser_fabric_first.py | 202 ++++------- 3 files changed, 291 insertions(+), 370 deletions(-) create mode 100644 tests/domain/modelling/_optimiser_fixtures.py diff --git a/tests/domain/modelling/_optimiser_fixtures.py b/tests/domain/modelling/_optimiser_fixtures.py new file mode 100644 index 000000000..01927b481 --- /dev/null +++ b/tests/domain/modelling/_optimiser_fixtures.py @@ -0,0 +1,144 @@ +"""Shared fixtures for the Optimiser test files: distinguishable Simulation +Overlays (so a stub scorer can attribute a true gain per measure kind), the +ScoredOption builder, the additive per-kind stub scorer, and the forced +ventilation Measure Dependency edge. + +Fixture values stay domain-plausible: overlay heating codes are real SAP +Table 4a codes (104 = mains-gas combi boiler, heat pumps carry a PCDF index), +and tests price measures at realistic magnitudes (a CWI around £1,000, an +ASHP around £8,000).""" + +from __future__ import annotations + +from typing import Optional, Sequence + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, +) +from domain.modelling.measure_type import MeasureType +from domain.modelling.optimisation.optimiser import MeasureDependency, ScoredOption +from domain.modelling.recommendation import Cost, MeasureOption +from domain.modelling.scoring.package_scorer import Score +from domain.modelling.simulation import ( + BuildingPartOverlay, + EpcSimulation, + GlazingOverlay, + HeatingOverlay, + VentilationOverlay, +) + +WALL_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) + } +) +ROOF_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300) + } +) +FLOOR_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(floor_insulation_thickness=100) + } +) +GLAZING_OVERLAY = EpcSimulation(glazing=GlazingOverlay(glazing_type=2)) +VENT_OVERLAY = EpcSimulation( + ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE") +) +# SAP Table 4a code 104: a mains-gas combi boiler. +BOILER_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=104)) +# Heat pumps are expressed as a PCDF product index, as the generator emits them. +ASHP_OVERLAY = EpcSimulation(heating=HeatingOverlay(main_heating_index_number=13000)) + +_WALL_TRIGGERS: frozenset[MeasureType] = frozenset( + {MeasureType.CAVITY_WALL_INSULATION, MeasureType.EXTERNAL_WALL_INSULATION} +) + + +def scored_option( + measure_type: str, + *, + gain: float, + cost: float, + overlay: Optional[EpcSimulation] = None, +) -> ScoredOption: + """A one-Option fixture: ``gain`` is the role-1 warm-start signal, ``cost`` + the total install cost. Omit ``overlay`` where the test never re-scores.""" + return ScoredOption( + option=MeasureOption( + measure_type=MeasureType(measure_type), + description=measure_type, + overlay=overlay if overlay is not None else EpcSimulation(), + cost=Cost(total=cost, contingency_rate=0.0), + ), + sap_gain=gain, + ) + + +class StubScorer: + """A deterministic stand-in for PackageScorer: the package SAP is a base + plus a fixed *true* gain per measure kind present (detected by overlay + field), decoupled from the role-1 signal — so selection, repair and the + two-phase split are exercised without the calculator. Kinds a test does + not use default to 0.""" + + def __init__( + self, + *, + base: float, + wall: float = 0.0, + roof: float = 0.0, + floor: float = 0.0, + heating: float = 0.0, + vent: float = 0.0, + ) -> None: + self._base = base + self._wall = wall + self._roof = roof + self._floor = floor + self._heating = heating + self._vent = vent + + 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 + if sim.ventilation is not None: + sap += self._vent + 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 + if part.floor_insulation_thickness is not None: + sap += self._floor + return Score( + sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0 + ) + + +def selected_types(selection: Sequence[ScoredOption]) -> set[str]: + return {scored.option.measure_type for scored in selection} + + +def ventilation_dependency( + *, cost: float, triggers: frozenset[MeasureType] = _WALL_TRIGGERS +) -> MeasureDependency: + """A forced 'airtightness requires ventilation' edge for the tests.""" + return MeasureDependency( + triggers=triggers, + required=ScoredOption( + option=MeasureOption( + measure_type=MeasureType.MECHANICAL_VENTILATION, + description="mechanical_ventilation", + overlay=VENT_OVERLAY, + cost=Cost(total=cost, contingency_rate=0.0), + ), + sap_gain=0.0, # placeholder; optimise_package scores the real signal + ), + ) diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index 80f39d7e1..75bec806e 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -8,14 +8,7 @@ selection with synthetic scores and no calculator. from __future__ import annotations -from typing import Sequence - -from datatypes.epc.domain.epc_property_data import ( - BuildingPartIdentifier, - EpcPropertyData, -) from domain.modelling.optimisation.optimiser import ( - MeasureDependency, OptimisedPackage, ScoredOption, optimise, @@ -23,104 +16,30 @@ from domain.modelling.optimisation.optimiser import ( optimise_package, ) from domain.modelling.measure_type import MeasureType -from domain.modelling.scoring.package_scorer import Score -from domain.modelling.recommendation import Cost, MeasureOption -from domain.modelling.simulation import ( - BuildingPartOverlay, - EpcSimulation, - VentilationOverlay, +from tests.domain.modelling._optimiser_fixtures import ( + FLOOR_OVERLAY, + ROOF_OVERLAY, + WALL_OVERLAY, + StubScorer, + scored_option, + selected_types, + ventilation_dependency, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) -def _scored(measure_type: str, *, gain: float, cost: float) -> ScoredOption: - return ScoredOption( - option=MeasureOption( - measure_type=MeasureType(measure_type), - description=measure_type, - overlay=EpcSimulation(), - cost=Cost(total=cost, contingency_rate=0.0), - ), - sap_gain=gain, - ) - - -# Distinguishable overlays so the stub scorer can attribute a true gain per -# measure (wall / roof / floor) regardless of the role-1 signal. -_WALL_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) - } -) -_ROOF_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300) - } -) -_FLOOR_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(floor_insulation_thickness=100) - } -) - - -def _scored_overlay( - 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: - """A deterministic stand-in for PackageScorer: the package SAP is a base - plus a fixed *true* gain per measure present (by overlay field), decoupled - from the role-1 signal — so the repair loop is exercised without the - calculator (ADR-0016).""" - - def __init__(self, *, base: float, wall: float, roof: float, floor: float) -> None: - self._base = base - self._wall = wall - self._roof = roof - self._floor = floor - - def score( - self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] - ) -> Score: - sap = self._base - for sim in simulations: - part = sim.building_parts[BuildingPartIdentifier.MAIN] - if part.wall_insulation_type is not None: - sap += self._wall - if part.roof_insulation_thickness is not None: - sap += self._roof - if part.floor_insulation_thickness is not None: - sap += self._floor - return Score(sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0) - - -def _selected_types(selection: list[ScoredOption]) -> set[str]: - return {scored.option.measure_type for scored in selection} - - def test_grouped_knapsack_maximises_gain_within_budget() -> None: # Arrange — wall group has two mutually-exclusive options; roof + floor one # each. EWI has the best gain but is unaffordable alongside the rest. groups: list[list[ScoredOption]] = [ [ - _scored("external_wall_insulation", gain=10.0, cost=8000.0), - _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), + scored_option("external_wall_insulation", gain=10.0, cost=8000.0), + scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0), ], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], - [_scored("suspended_floor_insulation", gain=3.0, cost=2000.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("suspended_floor_insulation", gain=3.0, cost=2000.0)], ] # Act @@ -128,7 +47,7 @@ def test_grouped_knapsack_maximises_gain_within_budget() -> None: # Assert — cavity + loft + floor (cost 4500, gain 13) beats any package # containing the 8000 EWI option within the 5000 budget. - assert _selected_types(selection) == { + assert selected_types(selection) == { "cavity_wall_insulation", "loft_insulation", "suspended_floor_insulation", @@ -139,8 +58,8 @@ def test_picks_at_most_one_option_per_group() -> None: # Arrange — both wall options are individually affordable. groups: list[list[ScoredOption]] = [ [ - _scored("external_wall_insulation", gain=10.0, cost=2000.0), - _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), + scored_option("external_wall_insulation", gain=10.0, cost=2000.0), + scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0), ], ] @@ -149,24 +68,24 @@ def test_picks_at_most_one_option_per_group() -> None: # Assert — never both treatments of the same wall; the higher-gain one wins. assert len(selection) == 1 - assert _selected_types(selection) == {"external_wall_insulation"} + assert selected_types(selection) == {"external_wall_insulation"} def test_no_budget_picks_the_best_option_in_every_group() -> None: # Arrange groups: list[list[ScoredOption]] = [ [ - _scored("external_wall_insulation", gain=10.0, cost=8000.0), - _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), + scored_option("external_wall_insulation", gain=10.0, cost=8000.0), + scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0), ], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act — None budget = unconstrained. selection: list[ScoredOption] = optimise(groups, budget=None) # Assert - assert _selected_types(selection) == { + assert selected_types(selection) == { "external_wall_insulation", "loft_insulation", } @@ -175,8 +94,8 @@ def test_no_budget_picks_the_best_option_in_every_group() -> None: def test_budget_too_small_for_any_option_selects_nothing() -> None: # Arrange groups: list[list[ScoredOption]] = [ - [_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act @@ -194,15 +113,15 @@ def test_no_groups_selects_nothing() -> None: def test_within_budget_partial_selection_prefers_the_higher_gain_option() -> None: # Arrange — only one of the two fits the budget; pick the affordable best. groups: list[list[ScoredOption]] = [ - [_scored("external_wall_insulation", gain=10.0, cost=8000.0)], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("external_wall_insulation", gain=10.0, cost=8000.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act selection: list[ScoredOption] = optimise(groups, budget=2000.0) # Assert — EWI is unaffordable; loft alone is the best within £2000. - assert _selected_types(selection) == {"loft_insulation"} + assert selected_types(selection) == {"loft_insulation"} # --- optimise_min_cost: least-cost-to-target selection (ADR-0016 amendment) --- @@ -212,8 +131,8 @@ def test_min_cost_picks_the_cheapest_package_that_reaches_the_target() -> None: # Arrange — two packages both clear the target gain; one is cheaper. groups: list[list[ScoredOption]] = [ [ - _scored("loft_insulation", gain=10.0, cost=2000.0), - _scored("external_wall_insulation", gain=15.0, cost=3000.0), + scored_option("loft_insulation", gain=10.0, cost=2000.0), + scored_option("external_wall_insulation", gain=15.0, cost=3000.0), ], ] @@ -223,7 +142,7 @@ def test_min_cost_picks_the_cheapest_package_that_reaches_the_target() -> None: # Assert — least-cost-to-target takes the +10 @ £2000, NOT the higher-gain # +15 @ £3000 (no overshoot, surplus budget unspent). assert selection is not None - assert _selected_types(selection) == {"loft_insulation"} + assert selected_types(selection) == {"loft_insulation"} def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None: @@ -232,10 +151,10 @@ def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None: # £8000). groups: list[list[ScoredOption]] = [ [ - _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), - _scored("external_wall_insulation", gain=10.0, cost=8000.0), + scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0), + scored_option("external_wall_insulation", gain=10.0, cost=8000.0), ], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act @@ -243,7 +162,7 @@ def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None: # Assert assert selection is not None - assert _selected_types(selection) == { + assert selected_types(selection) == { "cavity_wall_insulation", "loft_insulation", } @@ -254,8 +173,8 @@ def test_min_cost_breaks_cost_ties_toward_the_higher_gain() -> None: # one with more headroom ("recommend more" on a tie). groups: list[list[ScoredOption]] = [ [ - _scored("cavity_wall_insulation", gain=10.0, cost=2000.0), - _scored("external_wall_insulation", gain=14.0, cost=2000.0), + scored_option("cavity_wall_insulation", gain=10.0, cost=2000.0), + scored_option("external_wall_insulation", gain=14.0, cost=2000.0), ], ] @@ -264,13 +183,13 @@ def test_min_cost_breaks_cost_ties_toward_the_higher_gain() -> None: # Assert assert selection is not None - assert _selected_types(selection) == {"external_wall_insulation"} + assert selected_types(selection) == {"external_wall_insulation"} def test_min_cost_returns_none_when_target_unreachable_within_budget() -> None: # Arrange — the only target-reaching package costs more than the budget. groups: list[list[ScoredOption]] = [ - [_scored("external_wall_insulation", gain=10.0, cost=8000.0)], + [scored_option("external_wall_insulation", gain=10.0, cost=8000.0)], ] # Act @@ -283,8 +202,8 @@ def test_min_cost_returns_none_when_target_unreachable_within_budget() -> None: def test_min_cost_returns_none_when_no_package_reaches_the_target() -> None: # Arrange — even everything together falls short of the target gain. groups: list[list[ScoredOption]] = [ - [_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)], - [_scored("loft_insulation", gain=3.0, cost=1500.0)], + [scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0)], + [scored_option("loft_insulation", gain=3.0, cost=1500.0)], ] # Act @@ -298,8 +217,8 @@ def test_min_cost_unbudgeted_picks_cheapest_reaching_target_not_everything() -> # Arrange — no budget cap, but min-cost still means cheapest-to-target, not # "install everything". groups: list[list[ScoredOption]] = [ - [_scored("cavity_wall_insulation", gain=10.0, cost=1000.0)], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act — cavity alone (+10 @ £1000) already reaches the target. @@ -307,14 +226,14 @@ def test_min_cost_unbudgeted_picks_cheapest_reaching_target_not_everything() -> # Assert — loft is left off; it would only add cost past the target. assert selection is not None - assert _selected_types(selection) == {"cavity_wall_insulation"} + assert selected_types(selection) == {"cavity_wall_insulation"} def test_min_cost_non_positive_target_selects_nothing() -> None: # Arrange — a target already met (gain 0 needed) is reached by the empty # package at zero cost. groups: list[list[ScoredOption]] = [ - [_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)], + [scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0)], ] # Act @@ -328,11 +247,11 @@ def test_repair_adds_an_untreated_group_option_to_close_the_undershoot() -> None # Arrange — role-1 under-counts roof (signal 0 → warm-start skips it), but # its true re-scored gain (+4) is what closes the target. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)], - [_scored_overlay("suspended_floor_insulation", gain=8.0, cost=1000.0, overlay=_FLOOR_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=0.0, cost=1000.0, overlay=ROOF_OVERLAY)], + [scored_option("suspended_floor_insulation", gain=8.0, cost=1000.0, overlay=FLOOR_OVERLAY)], ] - scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) # Act package: OptimisedPackage = optimise_package( @@ -358,9 +277,9 @@ def test_repair_adds_an_untreated_group_option_to_close_the_undershoot() -> None def test_no_target_returns_the_warm_start_package_without_repair() -> None: # Arrange groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], ] - scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) # Act package: OptimisedPackage = optimise_package( @@ -381,10 +300,10 @@ def test_no_target_returns_the_warm_start_package_without_repair() -> None: def test_repair_stops_when_no_affordable_improving_option_remains() -> None: # Arrange — the only untreated-group option costs more than the budget left. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=0.0, cost=5000.0, overlay=_ROOF_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=0.0, cost=5000.0, overlay=ROOF_OVERLAY)], ] - scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) # Act package: OptimisedPackage = optimise_package( @@ -410,11 +329,11 @@ def test_package_stops_at_the_target_and_does_not_overshoot() -> None: # Arrange — wall alone already clears the target; max-gain would add roof + # floor too. Least-cost-to-target must stop at the wall. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)], - [_scored_overlay("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=_FLOOR_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=5.0, cost=1000.0, overlay=ROOF_OVERLAY)], + [scored_option("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=FLOOR_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0) + scorer = StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0) # Act — target 69 (gain 9); wall (+10 → 70) reaches it for £1000. package: OptimisedPackage = optimise_package( @@ -427,18 +346,18 @@ def test_package_stops_at_the_target_and_does_not_overshoot() -> None: # Assert — just the wall; roof + floor (which would reach 80) are left off, # surplus budget unspent. - assert _selected_types(package.selected) == {"cavity_wall_insulation"} + assert selected_types(package.selected) == {"cavity_wall_insulation"} assert abs(package.score.sap_continuous - 70.0) <= 1e-9 def test_package_falls_back_to_max_gain_when_target_unreachable() -> None: # Arrange — even all three measures (+20 → 80) cannot reach the target. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)], - [_scored_overlay("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=_FLOOR_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=5.0, cost=1000.0, overlay=ROOF_OVERLAY)], + [scored_option("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=FLOOR_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0) + scorer = StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0) # Act — target 90 is out of reach; best effort is the most SAP budget buys. package: OptimisedPackage = optimise_package( @@ -450,7 +369,7 @@ def test_package_falls_back_to_max_gain_when_target_unreachable() -> None: ) # Assert — max-gain: all three, SAP 80 (below target, best effort). - assert _selected_types(package.selected) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "loft_insulation", "suspended_floor_insulation", @@ -463,10 +382,10 @@ def test_package_repairs_when_the_signal_overshoots_the_true_score() -> None: # min-cost warm-start picks it alone; but its true gain is only +5, so the # package undershoots and repair must top it up. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=0.0, cost=1000.0, overlay=ROOF_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=5.0, roof=4.0, floor=0.0) + scorer = StubScorer(base=60.0, wall=5.0, roof=4.0, floor=0.0) # Act — target 69 (gain 9). Warm-start {wall} (signal 10) → true 65 < 69 → # repair adds the roof (+4) → 69. @@ -479,7 +398,7 @@ def test_package_repairs_when_the_signal_overshoots_the_true_score() -> None: ) # Assert - assert _selected_types(package.selected) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "loft_insulation", } @@ -488,57 +407,6 @@ def test_package_repairs_when_the_signal_overshoots_the_true_score() -> None: # --- Measure Dependency injection (ADR-0016) ------------------------------- -_VENT_OVERLAY = EpcSimulation( - ventilation=VentilationOverlay( - mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" - ) -) - - -class _VentStubScorer: - """A stub that adds a fixed gain per wall overlay present and a fixed - (negative) `vent` contribution when a ventilation overlay is present — - so the Measure Dependency's effect on the truthful package total and the - repair decision is exercised without the calculator.""" - - def __init__(self, *, base: float, wall: float, roof: float, vent: float) -> None: - self._base = base - self._wall = wall - self._roof = roof - self._vent = vent - - def score( - self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] - ) -> Score: - sap = self._base - for sim in simulations: - if sim.ventilation is not None: - sap += self._vent - 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 _ventilation_dependency(*, cost: float) -> MeasureDependency: - """A forced 'fabric requires ventilation' edge for the tests.""" - return MeasureDependency( - triggers=frozenset( - {MeasureType.CAVITY_WALL_INSULATION, MeasureType.EXTERNAL_WALL_INSULATION} - ), - required=ScoredOption( - option=MeasureOption( - measure_type=MeasureType.MECHANICAL_VENTILATION, - description="mechanical_ventilation", - overlay=_VENT_OVERLAY, - cost=Cost(total=cost, contingency_rate=0.0), - ), - sap_gain=0.0, - ), - ) - def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() -> None: # Arrange — cavity is dirt cheap (£100) and its role-1 signal (+6) alone @@ -547,21 +415,12 @@ def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() - # package below target. A ventilation-AWARE warm-start prices that −5 into # the candidate and instead takes the wall-free loft path. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=6.0, cost=100.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=8.0, cost=1500.0, overlay=_ROOF_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=6.0, cost=100.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=8.0, cost=1500.0, overlay=ROOF_OVERLAY)], ] - scorer = _VentStubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0) - dependency = MeasureDependency( - triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}), - required=ScoredOption( - option=MeasureOption( - measure_type=MeasureType.MECHANICAL_VENTILATION, - description="mechanical_ventilation", - overlay=_VENT_OVERLAY, - cost=Cost(total=300.0, contingency_rate=0.0), - ), - sap_gain=0.0, # placeholder; optimise_package scores the real signal - ), + scorer = StubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0) + dependency = ventilation_dependency( + cost=300.0, triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}) ) # Act — target 66 (gain 6 over the 60 baseline). @@ -576,7 +435,7 @@ def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() - # Assert — the loft path (true 68, £1500), NOT cavity + forced ventilation: # cavity's signal (+6) is cancelled by ventilation (−5) to +1 < target. - assert _selected_types(package.selected) == {"loft_insulation"} + assert selected_types(package.selected) == {"loft_insulation"} assert abs(package.score.sap_continuous - 68.0) <= 1e-9 @@ -584,9 +443,9 @@ def test_dependency_injected_when_a_trigger_measure_is_selected() -> None: # Arrange — the wall is selected, so its ventilation dependency must be # injected before the re-score; ventilation never competes in the pool. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], ] - scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act package: OptimisedPackage = optimise_package( @@ -595,12 +454,12 @@ def test_dependency_injected_when_a_trigger_measure_is_selected() -> None: baseline_epc=build_epc(), budget=None, target_sap=None, - dependencies=[_ventilation_dependency(cost=900.0)], + dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — ventilation is in the package and its negative contribution lands # in the truthful total: 40 base + 5 wall − 2 ventilation = 43. - assert _selected_types(package.selected) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "mechanical_ventilation", } @@ -611,9 +470,9 @@ def test_dependency_not_injected_without_a_trigger_measure() -> None: # Arrange — only loft is selected; the wall-triggered ventilation dependency # must not fire. groups: list[list[ScoredOption]] = [ - [_scored_overlay("loft_insulation", gain=4.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + [scored_option("loft_insulation", gain=4.0, cost=1000.0, overlay=ROOF_OVERLAY)], ] - scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act package: OptimisedPackage = optimise_package( @@ -622,11 +481,11 @@ def test_dependency_not_injected_without_a_trigger_measure() -> None: baseline_epc=build_epc(), budget=None, target_sap=None, - dependencies=[_ventilation_dependency(cost=900.0)], + dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — no trigger, no ventilation; 40 base + 4 roof = 44. - assert _selected_types(package.selected) == {"loft_insulation"} + assert selected_types(package.selected) == {"loft_insulation"} assert abs(package.score.sap_continuous - 44.0) <= 1e-9 @@ -636,9 +495,9 @@ def test_wall_dropped_when_it_cannot_be_ventilated_within_budget() -> None: # wall we can't afford to ventilate is a wall we can't afford, so it is # dropped (the budget is a hard envelope, ventilation is not forced over it). groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], ] - scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act — tight budget; ventilation-aware selection prices the £900 in. package: OptimisedPackage = optimise_package( @@ -647,7 +506,7 @@ def test_wall_dropped_when_it_cannot_be_ventilated_within_budget() -> None: baseline_epc=build_epc(), budget=1000.0, target_sap=None, - dependencies=[_ventilation_dependency(cost=900.0)], + dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — nothing recommended; the budget is respected and the wall is @@ -660,10 +519,10 @@ def test_injected_ventilation_penalty_drives_extra_repair() -> None: # Repair adds the roof (true +4) to reach 47, paying for the ventilation # penalty out of the budget the dependency's cost has already eaten into. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=0.0, cost=1000.0, overlay=ROOF_OVERLAY)], ] - scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act package: OptimisedPackage = optimise_package( @@ -672,12 +531,12 @@ def test_injected_ventilation_penalty_drives_extra_repair() -> None: baseline_epc=build_epc(), budget=5000.0, target_sap=46.0, - dependencies=[_ventilation_dependency(cost=900.0)], + dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — repair pulled the roof in to clear the target net of ventilation: # 40 + 5 wall − 2 vent + 4 roof = 47. - assert _selected_types(package.selected) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "loft_insulation", "mechanical_ventilation", diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py index 1f7dce7c2..b58e92e12 100644 --- a/tests/domain/modelling/test_optimiser_fabric_first.py +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -12,129 +12,67 @@ from __future__ import annotations from typing import Sequence -from datatypes.epc.domain.epc_property_data import ( - BuildingPartIdentifier, - EpcPropertyData, -) +from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.measure_type import MeasureType from domain.modelling.optimisation.optimiser import ( - MeasureDependency, 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, - GlazingOverlay, - HeatingOverlay, - VentilationOverlay, +from domain.modelling.simulation import EpcSimulation +from tests.domain.modelling._optimiser_fixtures import ( + ASHP_OVERLAY, + BOILER_OVERLAY, + GLAZING_OVERLAY, + WALL_OVERLAY, + StubScorer, + scored_option, + selected_types, + ventilation_dependency, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) -_WALL_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) - } +_AIRTIGHTNESS_TRIGGERS: frozenset[MeasureType] = frozenset( + {MeasureType.CAVITY_WALL_INSULATION, MeasureType.DOUBLE_GLAZING} ) -_ROOF_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300) - } -) -_HEATING_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201)) -_GLAZING_OVERLAY = EpcSimulation(glazing=GlazingOverlay(glazing_type=2)) -_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 + # Arrange — the £3,200 boiler is the cheapest route to the target (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)], + [scored_option("external_wall_insulation", gain=12.0, cost=12000.0, overlay=WALL_OVERLAY)], + [scored_option("gas_boiler_upgrade", gain=15.0, cost=3200.0, overlay=BOILER_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=10.0, roof=0.0, heating=30.0) + scorer = StubScorer(base=60.0, wall=12.0, heating=15.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, + budget=15000.0, target_sap=69.0, ) - # Assert — fabric only: the wall (true 70 ≥ 69); the heat pump is never + # Assert — fabric only: the wall (true 72 ≥ 69); the boiler 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 + assert selected_types(package.selected) == {"external_wall_insulation"} + assert abs(package.score.sap_continuous - 72.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)], + [scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=5.0, roof=0.0, heating=20.0) + scorer = StubScorer(base=60.0, wall=5.0, heating=20.0) # Act — target 75 (gain 15); fabric alone tops out at 65. package: OptimisedPackage = optimise_package_fabric_first( @@ -147,7 +85,7 @@ def test_fabric_short_of_target_is_topped_up_with_non_fabric_measures() -> None: # 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) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "air_source_heat_pump", } @@ -159,10 +97,10 @@ def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None: # 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)], + [scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=5.0, roof=0.0, heating=20.0) + scorer = StubScorer(base=60.0, wall=5.0, heating=20.0) # Act — target 78 (gain 18). package: OptimisedPackage = optimise_package_fabric_first( @@ -174,15 +112,10 @@ def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None: ) # Assert — wall only; the target is missed rather than the fabric skipped. - assert _selected_types(package) == {"cavity_wall_insulation"} + assert selected_types(package.selected) == {"cavity_wall_insulation"} assert abs(package.score.sap_continuous - 65.0) <= 1e-9 -_VENT_OVERLAY = EpcSimulation( - ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE") -) - - class _AirtightnessScorer: """A stub where tightening the envelope demands ventilation: the cavity wall is +5 SAP, the new double glazing is worthless on the raw dwelling @@ -200,44 +133,24 @@ class _AirtightnessScorer: glazing = any(sim.glazing is not None for sim in simulations) vents = sum(1 for sim in simulations if sim.ventilation is not None) sap = 60.0 - sap += 5.0 if wall else 0.0 - sap += (4.0 if wall else 0.0) if glazing else 0.0 + if wall: + sap += 5.0 + if wall and glazing: + sap += 4.0 sap -= float(vents) return Score( sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0 ) -def _airtightness_ventilation_dependency(*, cost: float) -> MeasureDependency: - """A forced 'airtightness requires ventilation' edge: both the wall and - the sealed new glazing trigger the same mechanical ventilation.""" - return MeasureDependency( - triggers=frozenset( - { - MeasureType.CAVITY_WALL_INSULATION, - MeasureType.DOUBLE_GLAZING, - } - ), - required=ScoredOption( - option=MeasureOption( - measure_type=MeasureType.MECHANICAL_VENTILATION, - description="mechanical_ventilation", - overlay=_VENT_OVERLAY, - cost=Cost(total=cost, contingency_rate=0.0), - ), - sap_gain=0.0, - ), - ) - - def test_ventilation_dependency_is_injected_once_across_both_phases() -> None: # Arrange — the cavity wall (phase 1) and the double glazing (skipped in # phase 1 on merit, picked in phase 2 on its post-fabric worth) both # trigger the same forced ventilation. It must land in the package exactly # once — phase 2 sees the phase-1 dwelling as already ventilated. groups: list[list[ScoredOption]] = [ - [_scored("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored("double_glazing", gain=0.0, cost=500.0, overlay=_GLAZING_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("double_glazing", gain=0.0, cost=3500.0, overlay=GLAZING_OVERLAY)], ] scorer = _AirtightnessScorer() @@ -249,7 +162,9 @@ def test_ventilation_dependency_is_injected_once_across_both_phases() -> None: baseline_epc=build_epc(), budget=10000.0, target_sap=68.0, - dependencies=[_airtightness_ventilation_dependency(cost=300.0)], + dependencies=[ + ventilation_dependency(cost=300.0, triggers=_AIRTIGHTNESS_TRIGGERS) + ], ) # Assert — one ventilation, and the truthful total counts its penalty once: @@ -260,7 +175,7 @@ def test_ventilation_dependency_is_injected_once_across_both_phases() -> None: if scored.option.measure_type == MeasureType.MECHANICAL_VENTILATION ) assert ventilation_count == 1 - assert _selected_types(package) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "double_glazing", "mechanical_ventilation", @@ -273,9 +188,9 @@ def test_no_fabric_candidates_proceeds_straight_to_the_full_pool() -> None: # survives generation); fabric first must not veto the run, it just means # phase 1 has nothing to do. groups: list[list[ScoredOption]] = [ - [_scored("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=_HEATING_OVERLAY)], + [scored_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=5.0, roof=0.0, heating=20.0) + scorer = StubScorer(base=60.0, heating=20.0) # Act — target 75. package: OptimisedPackage = optimise_package_fabric_first( @@ -287,7 +202,7 @@ def test_no_fabric_candidates_proceeds_straight_to_the_full_pool() -> None: ) # Assert — the heat pump package, exactly as a plain run would produce. - assert _selected_types(package) == {"air_source_heat_pump"} + assert selected_types(package.selected) == {"air_source_heat_pump"} assert abs(package.score.sap_continuous - 80.0) <= 1e-9 @@ -296,10 +211,10 @@ def test_without_a_target_fabric_still_gets_first_claim_on_the_budget() -> None: # whole £8000 on the heat pump (+20); fabric first commits the wall (+5) # before the remainder is considered, pricing the heat pump out. 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)], + [scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=5.0, roof=0.0, heating=20.0) + scorer = StubScorer(base=60.0, wall=5.0, heating=20.0) # Act — no target: the flag applies to every goal, not just Increasing EPC. package: OptimisedPackage = optimise_package_fabric_first( @@ -311,7 +226,7 @@ def test_without_a_target_fabric_still_gets_first_claim_on_the_budget() -> None: ) # Assert — wall first; the heat pump no longer fits the leftover £7000. - assert _selected_types(package) == {"cavity_wall_insulation"} + assert selected_types(package.selected) == {"cavity_wall_insulation"} assert abs(package.score.sap_continuous - 65.0) <= 1e-9 @@ -359,9 +274,12 @@ class _GlazingInteractionScorer: glazing_present = any(sim.glazing is not None for sim in simulations) heating_present = any(sim.heating is not None for sim in simulations) sap = 60.0 - sap += 5.0 if wall_present else 0.0 - sap += (4.0 if wall_present else 0.0) if glazing_present else 0.0 - sap += 10.0 if heating_present else 0.0 + if wall_present: + sap += 5.0 + if wall_present and glazing_present: + sap += 4.0 + if heating_present: + sap += 10.0 return Score( sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0 ) @@ -372,9 +290,9 @@ def test_fabric_unpicked_in_phase_one_can_reenter_phase_two() -> None: # dwelling), but post-wall it is the only affordable way to the target: # the heat pump that could also close it does not fit the leftover budget. groups: list[list[ScoredOption]] = [ - [_scored("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored("double_glazing", gain=0.0, cost=500.0, overlay=_GLAZING_OVERLAY)], - [_scored("air_source_heat_pump", gain=10.0, cost=8000.0, overlay=_HEATING_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("double_glazing", gain=0.0, cost=3500.0, overlay=GLAZING_OVERLAY)], + [scored_option("air_source_heat_pump", gain=10.0, cost=8000.0, overlay=ASHP_OVERLAY)], ] scorer = _GlazingInteractionScorer() @@ -390,7 +308,7 @@ def test_fabric_unpicked_in_phase_one_can_reenter_phase_two() -> None: # Assert — the skipped glazing re-enters on its post-fabric worth: 60 + 5 # wall + 4 glazing = 69, target met. - assert _selected_types(package) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "double_glazing", } @@ -403,10 +321,10 @@ def test_phase_two_values_candidates_against_the_post_fabric_dwelling() -> None: # 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_option("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), + scored_option("gas_boiler_upgrade", gain=10.0, cost=3200.0, overlay=BOILER_OVERLAY), + scored_option("air_source_heat_pump", gain=8.0, cost=8000.0, overlay=ASHP_OVERLAY), ], ] scorer = _InteractionScorer() @@ -424,7 +342,7 @@ def test_phase_two_values_candidates_against_the_post_fabric_dwelling() -> None: ) # Assert - assert _selected_types(package) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "air_source_heat_pump", } From a8e2d990185e27e13dd894206a33100a69ab65ec Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 12:09:06 +0000 Subject: [PATCH 22/36] =?UTF-8?q?Dependency=20signals=20are=20priced=20in?= =?UTF-8?q?=20the=20goal=20objective's=20currency=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 --- .../test_optimiser_goal_objective.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/domain/modelling/test_optimiser_goal_objective.py diff --git a/tests/domain/modelling/test_optimiser_goal_objective.py b/tests/domain/modelling/test_optimiser_goal_objective.py new file mode 100644 index 000000000..57e3c8240 --- /dev/null +++ b/tests/domain/modelling/test_optimiser_goal_objective.py @@ -0,0 +1,123 @@ +"""Behaviour of the Optimiser under a goal-aligned objective (ADR-0062): a +Scenario whose goal is Reducing CO2 emissions / Energy Savings optimises its +own metric, not SAP. The caller supplies group signals already measured in the +objective's currency; the optimiser must price everything *it* computes — the +forced Measure Dependency signals — in the same currency, so a ventilation +that costs SAP but is carbon-neutral cannot sink a carbon-improving wall. +""" + +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 ( + MeasureDependency, + OptimisedPackage, + ScoredOption, + optimise_package, +) +from domain.modelling.recommendation import Cost, MeasureOption +from domain.modelling.scoring.package_scorer import Score +from domain.modelling.simulation import ( + BuildingPartOverlay, + EpcSimulation, + VentilationOverlay, +) +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + +_WALL_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) + } +) +_VENT_OVERLAY = EpcSimulation( + ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE") +) + + +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 _CarbonScorer: + """A stub where the wall is a small carbon win (−20 kg/yr) and a large SAP + win (+6), while its forced ventilation is carbon-neutral but SAP-ruinous + (−30): SAP-priced dependency signals sink the wall; carbon-priced ones + keep it.""" + + def score( + self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] + ) -> Score: + sap, co2 = 60.0, 500.0 + for sim in simulations: + if sim.ventilation is not None: + sap -= 30.0 + for part in sim.building_parts.values(): + if part.wall_insulation_type is not None: + sap += 6.0 + co2 -= 20.0 + return Score( + sap_continuous=sap, co2_kg_per_yr=co2, primary_energy_kwh_per_yr=0.0 + ) + + +def _carbon_reduction(score: Score) -> float: + return -score.co2_kg_per_yr + + +def test_dependency_signals_are_priced_in_the_objective_currency() -> None: + # Arrange — the wall's signal (supplied by the caller, +20 kg CO2 saved) + # and the ventilation it forces in (carbon-neutral). Under legacy SAP + # pricing the ventilation's −30 SAP would outweigh the wall's +20 signal + # and the package would collapse to nothing. + groups: list[list[ScoredOption]] = [ + [_scored("cavity_wall_insulation", gain=20.0, cost=1000.0, overlay=_WALL_OVERLAY)], + ] + dependency = MeasureDependency( + triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}), + required=ScoredOption( + option=MeasureOption( + measure_type=MeasureType.MECHANICAL_VENTILATION, + description="mechanical_ventilation", + overlay=_VENT_OVERLAY, + cost=Cost(total=300.0, contingency_rate=0.0), + ), + sap_gain=0.0, + ), + ) + + # Act — a Reducing-CO2 brief: maximise carbon reduction within budget. + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=_CarbonScorer(), + baseline_epc=build_epc(), + budget=5000.0, + target_sap=None, + dependencies=[dependency], + objective=_carbon_reduction, + ) + + # Assert — the wall survives with its ventilation: the dependency is worth + # 0 kg CO2, not −30 SAP, so the package is a net +20 kg saving. + assert {s.option.measure_type for s in package.selected} == { + "cavity_wall_insulation", + "mechanical_ventilation", + } + assert abs(package.score.co2_kg_per_yr - 480.0) <= 1e-9 From 66748ba26df0625706345aa47c2fd7e81817d552 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 12:09:07 +0000 Subject: [PATCH 23/36] =?UTF-8?q?Dependency=20signals=20are=20priced=20in?= =?UTF-8?q?=20the=20goal=20objective's=20currency=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 57 ++++++++++++++-------- 1 file changed, 37 insertions(+), 20 deletions(-) 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 From aac35327f7450ebd873fcc0c84ba7bf686da07e6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 12:13:06 +0000 Subject: [PATCH 24/36] =?UTF-8?q?A=20Reducing-CO2=20scenario=20maximises?= =?UTF-8?q?=20carbon=20reduction,=20not=20SAP=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 --- .../test_modelling_goal_objectives.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/orchestration/test_modelling_goal_objectives.py diff --git a/tests/orchestration/test_modelling_goal_objectives.py b/tests/orchestration/test_modelling_goal_objectives.py new file mode 100644 index 000000000..962c94e24 --- /dev/null +++ b/tests/orchestration/test_modelling_goal_objectives.py @@ -0,0 +1,59 @@ +"""The ModellingOrchestrator aligns the Optimiser's objective with the +Scenario's goal (ADR-0062): Reducing CO2 emissions maximises the carbon +reduction the budget buys, Energy Savings maximises the annual bill saving, +and Increasing EPC keeps its SAP target semantics. End-to-end through +``run_modelling`` (no database) with the real calculator, against the +uninsulated solid-brick 001431 dwelling where the SAP-optimal and +carbon-optimal packages diverge at a £16,000 budget. +""" + +from __future__ import annotations + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.measure_type import MeasureType +from domain.modelling.plan import Plan +from domain.modelling.scenario import Scenario +from harness.console import run_modelling +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) + + +def _solid_brick_dwelling() -> EpcPropertyData: + return parse_recommendation_summary("solid_brick_ewi_001431_before.pdf") + + +def _scenario(goal: str, *, budget: float) -> Scenario: + return Scenario( + id=999, goal=goal, goal_value="", budget=budget, is_default=True + ) + + +def test_reducing_co2_scenario_buys_carbon_not_sap() -> None: + # Arrange — at £16,000 the SAP objective buys the wall + floor + £3,200 + # gas boiler package (~2,069 kg CO2/yr, SAP 72.9). The carbon objective + # swaps the boiler for electric storage heaters (~1,098 kg/yr) — a lower + # SAP, but ~970 kg/yr less carbon on the low-carbon grid. + epc = _solid_brick_dwelling() + + # Act — the same dwelling and budget under each goal. + sap_led: Plan = run_modelling( + epc, + scenario=_scenario("Valuation Improvement", budget=16000.0), + print_table=False, + ) + carbon_led: Plan = run_modelling( + epc, + scenario=_scenario("Reducing CO2 emissions", budget=16000.0), + print_table=False, + ) + + # Assert — the goal changes the outcome in the goal's favour: the carbon + # plan cuts materially more CO2 than the SAP plan buys with the same + # money, and the gas boiler that wins on SAP-per-£ is rejected. + assert ( + carbon_led.post_retrofit.co2_kg_per_yr + < sap_led.post_retrofit.co2_kg_per_yr - 500.0 + ) + selected = {measure.measure_type for measure in carbon_led.measures} + assert MeasureType.GAS_BOILER_UPGRADE not in selected From 48d54675c3b585c015fa8dda4c7625dc0b08be9f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 12:13:06 +0000 Subject: [PATCH 25/36] =?UTF-8?q?A=20Reducing-CO2=20scenario=20maximises?= =?UTF-8?q?=20carbon=20reduction,=20not=20SAP=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 2 + domain/modelling/scoring/scoring.py | 29 +++++++++++++- orchestration/modelling_orchestrator.py | 44 +++++++++++++++++----- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index f4ce97a2f..2f556e2a2 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -244,6 +244,7 @@ def optimise_package_fabric_first( budget: Optional[float], target_sap: Optional[float], dependencies: Sequence[MeasureDependency] = (), + objective: Callable[[Score], float] = sap_rating, ) -> OptimisedPackage: """Select the Optimised Package under the Fabric First constraint: optimise the fabric measures (``FABRIC_MEASURE_TYPES``) first with the full budget; @@ -271,6 +272,7 @@ def optimise_package_fabric_first( budget=budget, target_sap=target_sap, dependencies=dependencies, + objective=objective, ) if ( target_sap is not None diff --git a/domain/modelling/scoring/scoring.py b/domain/modelling/scoring/scoring.py index ea995380a..8ac0701c9 100644 --- a/domain/modelling/scoring/scoring.py +++ b/domain/modelling/scoring/scoring.py @@ -15,7 +15,7 @@ truthful. The whole-package re-score (role 2) is `PackageScorer.score` directly. """ from dataclasses import dataclass -from typing import Sequence +from typing import Callable, Sequence from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.scoring.package_scorer import PackageScorer, Score @@ -113,3 +113,30 @@ def independent_option_impacts( scored.append((option.overlay, cached)) impacts.append(cached) return impacts + + +def independent_option_signals( + scorer: PackageScorer, + baseline: EpcPropertyData, + options: Sequence[MeasureOption], + objective: Callable[[Score], float], +) -> list[float]: + """Each Option's independent-vs-baseline gain **in the objective's + currency** (role 1 — the optimiser's approximate input signal, ADR-0062): + SAP points for an Increasing-EPC goal, kg CO2 saved for Reducing CO2, £ + saved for Energy Savings. Each distinct Simulation Overlay is scored once + (Options sharing an overlay reuse the result); results follow the input + order.""" + base_value: float = objective(scorer.score(baseline, [])) + scored: list[tuple[EpcSimulation, float]] = [] + signals: list[float] = [] + for option in options: + cached: float | None = next( + (signal for overlay, signal in scored if overlay == option.overlay), + None, + ) + if cached is None: + cached = objective(scorer.score(baseline, [option.overlay])) - base_value + scored.append((option.overlay, cached)) + signals.append(cached) + return signals diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 23ac42941..5a50c12c9 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -20,6 +20,7 @@ from domain.modelling.optimisation.optimiser import ( ScoredOption, optimise_package, optimise_package_fabric_first, + sap_rating, ) from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.plan import Plan, PlanMeasure @@ -29,7 +30,7 @@ from domain.modelling.scenario import Scenario from domain.modelling.scoring.scoring import ( MeasureImpact, cascade_scores, - independent_option_impacts, + independent_option_signals, marginals_from_scores, ) from domain.modelling.generators.wall_recommendation import recommend_cavity_wall @@ -50,10 +51,12 @@ from repositories.solar.solar_repository import SolarRepository from repositories.unit_of_work import UnitOfWork # The PortfolioGoal value that targets a SAP band (cf. -# backend.app.db.models.portfolio.PortfolioGoal.INCREASING_EPC). Other goals -# (Energy Savings, Reducing CO2 emissions) don't yet set a SAP repair target — -# the optimiser just maximises SAP gain within budget for them (later slice). +# backend.app.db.models.portfolio.PortfolioGoal.INCREASING_EPC). The +# goal-aligned goals (ADR-0062) set no target: they maximise their own metric +# within the Scenario budget. _INCREASING_EPC_GOAL: Final[str] = "Increasing EPC" +_REDUCING_CO2_GOAL: Final[str] = "Reducing CO2 emissions" +_ENERGY_SAVINGS_GOAL: Final[str] = "Energy Savings" # Best-practice install sequence for the role-3 attribution cascade (ADR-0016): # walls → roof → ventilation → floor, per the legacy `Recommendations` class. @@ -176,6 +179,10 @@ class ModellingOrchestrator: considered: Optional[frozenset[MeasureType]] = combine_considered_measures( scenario.considered_measures(), considered_measures ) + # The Optimiser speaks the goal's currency (ADR-0062): group signals, + # dependency pricing and repair marginals are all measured by this + # objective — SAP by default, carbon reduction for a Reducing-CO2 goal. + objective: Callable[[Score], float] = _objective_for(scenario) groups: list[list[ScoredOption]] = _scored_candidate_groups( scorer, effective_epc, @@ -183,6 +190,7 @@ class ModellingOrchestrator: planning_restrictions, solar_potential, considered, + objective, ) # Forced Measure Dependencies (ventilation) are excluded from the pool # but injected into the package before the re-score (ADR-0016). @@ -202,6 +210,7 @@ class ModellingOrchestrator: budget=scenario.budget, target_sap=_target_sap(scenario), dependencies=dependencies, + objective=objective, ) # Role-3 attribution: re-apply the *selected* set in best-practice order @@ -395,9 +404,11 @@ def _scored_candidate_groups( planning_restrictions: PlanningRestrictions, solar_potential: Optional[SolarPotential], considered_measures: Optional[frozenset[MeasureType]], + objective: Callable[[Score], float] = sap_rating, ) -> list[list[ScoredOption]]: """One group per Recommendation: each Option scored independently against - the baseline (role-1 warm-start signal, ADR-0016).""" + the baseline (role-1 warm-start signal, ADR-0016), in the goal objective's + currency (ADR-0062).""" # The SAP design heat loss sizes the ASHP to the dwelling (ADR-0049); read it # off a baseline score, which the group scoring computes anyway. baseline_result = scorer.score(effective_epc, []).sap_result @@ -414,18 +425,33 @@ def _scored_candidate_groups( design_heat_loss_kw, ): options = list(recommendation.options) - impacts: list[MeasureImpact] = independent_option_impacts( - scorer, effective_epc, options + signals: list[float] = independent_option_signals( + scorer, effective_epc, options, objective ) groups.append( [ - ScoredOption(option=option, sap_gain=impact.sap_points) - for option, impact in zip(options, impacts, strict=True) + ScoredOption(option=option, sap_gain=signal) + for option, signal in zip(options, signals, strict=True) ] ) return groups +def _carbon_reduction(score: Score) -> float: + """The Reducing-CO2 objective: annual kg CO2 below zero-point, negated so + higher is better (a saved kg scores +1).""" + return -score.co2_kg_per_yr + + +def _objective_for(scenario: Scenario) -> Callable[[Score], float]: + """The metric the Scenario's goal maximises (ADR-0062), as an Optimiser + objective (higher is better). Goals without an aligned metric optimise + SAP, as every goal did before.""" + if scenario.goal == _REDUCING_CO2_GOAL: + return _carbon_reduction + return sap_rating + + def _target_sap(scenario: Scenario) -> Optional[float]: """The SAP rating the Optimiser repairs toward — the floor of the goal band for an INCREASING_EPC goal, else None (no SAP target).""" From 6b64104dc50e1a62b142a6ede602d36cde11b756 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 12:54:46 +0000 Subject: [PATCH 26/36] =?UTF-8?q?An=20Energy-Savings=20scenario=20prices?= =?UTF-8?q?=20packages=20at=20the=20live=20fuel=20rates=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 --- harness/console.py | 4 +- .../test_modelling_goal_objectives.py | 53 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/harness/console.py b/harness/console.py index a9aa222b5..06c16d81e 100644 --- a/harness/console.py +++ b/harness/console.py @@ -39,6 +39,7 @@ from orchestration.modelling_orchestrator import ( _candidate_recommendations, # pyright: ignore[reportPrivateUsage] ) from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator +from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository from repositories.fuel_rates.fuel_rates_static_file_repository import ( FuelRatesStaticFileRepository, ) @@ -182,6 +183,7 @@ def run_modelling( considered_measures: Optional[frozenset[MeasureType]] = None, products: Optional[ProductRepository] = None, scenario: Optional[Scenario] = None, + fuel_rates: Optional[FuelRatesRepository] = None, print_table: bool = True, ) -> Plan: """Run ONLY the Modelling stage over ``epc`` with no database — skipping @@ -240,7 +242,7 @@ def run_modelling( ModellingOrchestrator( unit_of_work=lambda: unit, calculator=Sap10Calculator(), - fuel_rates=FuelRatesStaticFileRepository(), + fuel_rates=fuel_rates or FuelRatesStaticFileRepository(), ).run( property_ids=[_PROPERTY_ID], scenario_ids=[scenario_id], diff --git a/tests/orchestration/test_modelling_goal_objectives.py b/tests/orchestration/test_modelling_goal_objectives.py index 962c94e24..ac7492085 100644 --- a/tests/orchestration/test_modelling_goal_objectives.py +++ b/tests/orchestration/test_modelling_goal_objectives.py @@ -9,11 +9,19 @@ carbon-optimal packages diverge at a £16,000 budget. from __future__ import annotations +import dataclasses + from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.fuel_rates.fuel import Fuel +from domain.fuel_rates.fuel_rates import FuelRate, FuelRates from domain.modelling.measure_type import MeasureType from domain.modelling.plan import Plan from domain.modelling.scenario import Scenario from harness.console import run_modelling +from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository +from repositories.fuel_rates.fuel_rates_static_file_repository import ( + FuelRatesStaticFileRepository, +) from tests.domain.modelling._elmhurst_recommendation import ( parse_recommendation_summary, ) @@ -57,3 +65,48 @@ def test_reducing_co2_scenario_buys_carbon_not_sap() -> None: ) selected = {measure.measure_type for measure in carbon_led.measures} assert MeasureType.GAS_BOILER_UPGRADE not in selected + + +class _FixedFuelRates(FuelRatesRepository): + def __init__(self, rates: FuelRates) -> None: + self._rates = rates + + def get_current(self) -> FuelRates: + return self._rates + + +def _cheap_electricity_snapshot() -> FuelRates: + """The committed snapshot with electricity at 1p/kWh — a world where any + electric heating out-bills gas, while SAP's internal price book (which the + calculator rates against) is unmoved.""" + base = FuelRatesStaticFileRepository().get_current() + rates = dict(base.rates) + rates[Fuel.ELECTRICITY] = FuelRate( + unit_rate_p_per_kwh=1.0, + standing_charge_p_per_day=rates[Fuel.ELECTRICITY].standing_charge_p_per_day, + ) + return dataclasses.replace(base, rates=rates) + + +def test_energy_savings_scenario_prices_packages_at_the_live_fuel_rates() -> None: + # Arrange — SAP is itself a cost metric, but it prices energy from its + # internal tariff book. The Energy Savings goal must price at the *live* + # Fuel Rates snapshot: with 1p/kWh electricity, electric heating slashes + # the bill even though SAP still scores the gas boiler package higher. + epc = _solid_brick_dwelling() + + # Act + plan: Plan = run_modelling( + epc, + scenario=_scenario("Energy Savings", budget=16000.0), + fuel_rates=_FixedFuelRates(_cheap_electricity_snapshot()), + print_table=False, + ) + + # Assert — the bill objective abandons the boiler for electric heating. + selected = {measure.measure_type for measure in plan.measures} + assert MeasureType.GAS_BOILER_UPGRADE not in selected + assert selected & { + MeasureType.AIR_SOURCE_HEAT_PUMP, + MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS, + } From 3576d0537059adfb985f74a0acaeb5385d92bf65 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 12:54:47 +0000 Subject: [PATCH 27/36] =?UTF-8?q?An=20Energy-Savings=20scenario=20prices?= =?UTF-8?q?=20packages=20at=20the=20live=20fuel=20rates=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- orchestration/modelling_orchestrator.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 5a50c12c9..111023694 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -182,7 +182,7 @@ class ModellingOrchestrator: # The Optimiser speaks the goal's currency (ADR-0062): group signals, # dependency pricing and repair marginals are all measured by this # objective — SAP by default, carbon reduction for a Reducing-CO2 goal. - objective: Callable[[Score], float] = _objective_for(scenario) + objective: Callable[[Score], float] = _objective_for(scenario, bill_derivation) groups: list[list[ScoredOption]] = _scored_candidate_groups( scorer, effective_epc, @@ -443,12 +443,28 @@ def _carbon_reduction(score: Score) -> float: return -score.co2_kg_per_yr -def _objective_for(scenario: Scenario) -> Callable[[Score], float]: +def _bill_saving(bill_derivation: BillDerivation) -> Callable[[Score], float]: + """The Energy-Savings objective: the annual Bill at the current Fuel Rates + snapshot, negated so higher is better (a saved £ scores +1). Priced at the + live snapshot, not SAP's internal tariff book — that difference is the + point of the goal (ADR-0062).""" + + def objective(score: Score) -> float: + return -_bill_for(bill_derivation, score).total_gbp + + return objective + + +def _objective_for( + scenario: Scenario, bill_derivation: BillDerivation +) -> Callable[[Score], float]: """The metric the Scenario's goal maximises (ADR-0062), as an Optimiser objective (higher is better). Goals without an aligned metric optimise SAP, as every goal did before.""" if scenario.goal == _REDUCING_CO2_GOAL: return _carbon_reduction + if scenario.goal == _ENERGY_SAVINGS_GOAL: + return _bill_saving(bill_derivation) return sap_rating From 102b250e5328167998ef58f06ae5f623d68783d6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 12:59:45 +0000 Subject: [PATCH 28/36] =?UTF-8?q?A=20goal-aligned=20scenario=20without=20a?= =?UTF-8?q?=20budget=20fails=20loudly=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 --- .../test_modelling_goal_objectives.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/orchestration/test_modelling_goal_objectives.py b/tests/orchestration/test_modelling_goal_objectives.py index ac7492085..be007ac80 100644 --- a/tests/orchestration/test_modelling_goal_objectives.py +++ b/tests/orchestration/test_modelling_goal_objectives.py @@ -11,6 +11,8 @@ from __future__ import annotations import dataclasses +import pytest + from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.fuel_rates.fuel import Fuel from domain.fuel_rates.fuel_rates import FuelRate, FuelRates @@ -67,6 +69,25 @@ def test_reducing_co2_scenario_buys_carbon_not_sap() -> None: assert MeasureType.GAS_BOILER_UPGRADE not in selected +def test_a_goal_aligned_scenario_without_a_budget_fails_loudly() -> None: + # Arrange — 'reduce as much as possible within this budget' is undefined + # without a budget: unconstrained it would recommend every beneficial + # measure. A budget-less goal-aligned Scenario is a misconfiguration and + # must fail visibly, not produce a maximal plan. + epc = _solid_brick_dwelling() + budgetless = Scenario( + id=999, + goal="Reducing CO2 emissions", + goal_value="", + budget=None, + is_default=True, + ) + + # Act / Assert + with pytest.raises(ValueError, match="budget"): + run_modelling(epc, scenario=budgetless, print_table=False) + + class _FixedFuelRates(FuelRatesRepository): def __init__(self, rates: FuelRates) -> None: self._rates = rates From 6296b2d8fc0fdb35f29a3e5974f68093d79ad601 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 12:59:46 +0000 Subject: [PATCH 29/36] =?UTF-8?q?A=20goal-aligned=20scenario=20without=20a?= =?UTF-8?q?=20budget=20fails=20loudly=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- orchestration/modelling_orchestrator.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 111023694..102199f25 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -182,6 +182,7 @@ class ModellingOrchestrator: # The Optimiser speaks the goal's currency (ADR-0062): group signals, # dependency pricing and repair marginals are all measured by this # objective — SAP by default, carbon reduction for a Reducing-CO2 goal. + _require_budget_for_goal_aligned(scenario) objective: Callable[[Score], float] = _objective_for(scenario, bill_derivation) groups: list[list[ScoredOption]] = _scored_candidate_groups( scorer, @@ -437,6 +438,20 @@ def _scored_candidate_groups( return groups +def _require_budget_for_goal_aligned(scenario: Scenario) -> None: + """A goal-aligned Scenario is 'reduce as much as possible within this + budget' — undefined without one (unconstrained, it would recommend every + beneficial measure). Fail the misconfiguration loudly (ADR-0062).""" + if scenario.budget is None and scenario.goal in ( + _REDUCING_CO2_GOAL, + _ENERGY_SAVINGS_GOAL, + ): + raise ValueError( + f"scenario {scenario.id} has goal {scenario.goal!r} but no budget; " + "goal-aligned scenarios require a budget" + ) + + def _carbon_reduction(score: Score) -> float: """The Reducing-CO2 objective: annual kg CO2 below zero-point, negated so higher is better (a saved kg scores +1).""" From ffaf89935ba68e7dab3a2c014c83e1fbe7064d13 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 13:13:22 +0000 Subject: [PATCH 30/36] =?UTF-8?q?Fabric-first=20phase=202=20re-scores=20ca?= =?UTF-8?q?ndidates=20in=20the=20goal=20objective's=20currency=20?= =?UTF-8?q?=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 --- .../test_optimiser_goal_objective.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/domain/modelling/test_optimiser_goal_objective.py b/tests/domain/modelling/test_optimiser_goal_objective.py index 57e3c8240..f64371cc4 100644 --- a/tests/domain/modelling/test_optimiser_goal_objective.py +++ b/tests/domain/modelling/test_optimiser_goal_objective.py @@ -26,6 +26,7 @@ from domain.modelling.scoring.package_scorer import Score from domain.modelling.simulation import ( BuildingPartOverlay, EpcSimulation, + HeatingOverlay, VentilationOverlay, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( @@ -121,3 +122,76 @@ def test_dependency_signals_are_priced_in_the_objective_currency() -> None: "mechanical_ventilation", } assert abs(package.score.co2_kg_per_yr - 480.0) <= 1e-9 + + +_IWI_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=3) + } +) +_BOILER_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201)) +_ASHP_OVERLAY = EpcSimulation( + heating=HeatingOverlay(main_heating_index_number=13000) +) + + +class _CarbonHeatingScorer: + """A stub where the boiler wins on SAP (+10 vs +2) but the heat pump wins + on carbon (−50 vs −5 kg/yr): a fabric-first phase 2 that re-scores its + candidates in SAP picks the wrong heating for a Reducing-CO2 brief.""" + + def score( + self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] + ) -> Score: + sap, co2 = 60.0, 500.0 + for sim in simulations: + for part in sim.building_parts.values(): + if part.wall_insulation_type is not None: + sap += 5.0 + co2 -= 10.0 + if sim.heating is None: + continue + if sim.heating.sap_main_heating_code is not None: + sap += 10.0 + co2 -= 5.0 + if sim.heating.main_heating_index_number is not None: + sap += 2.0 + co2 -= 50.0 + return Score( + sap_continuous=sap, co2_kg_per_yr=co2, primary_energy_kwh_per_yr=0.0 + ) + + +def test_fabric_first_phase_two_rescores_in_the_objective_currency() -> None: + # Arrange — a fabric-first Reducing-CO2 brief. Phase 1 commits the wall; + # phase 2 must choose the heating on its post-fabric *carbon* worth, not + # its SAP worth. Signals are supplied in kg CO2 saved (the caller's job). + from domain.modelling.optimisation.optimiser import ( + optimise_package_fabric_first, + ) + + groups: list[list[ScoredOption]] = [ + [_scored("internal_wall_insulation", gain=10.0, cost=1000.0, overlay=_IWI_OVERLAY)], + [ + _scored("gas_boiler_upgrade", gain=5.0, cost=2000.0, overlay=_BOILER_OVERLAY), + _scored("air_source_heat_pump", gain=50.0, cost=6000.0, overlay=_ASHP_OVERLAY), + ], + ] + + # Act — no target (goal-aligned briefs have none), generous budget. + package: OptimisedPackage = optimise_package_fabric_first( + groups=groups, + scorer=_CarbonHeatingScorer(), + baseline_epc=build_epc(), + budget=10000.0, + target_sap=None, + objective=_carbon_reduction, + ) + + # Assert — the wall plus the heat pump (−50 kg), not the SAP-favoured + # boiler; the truthful package carbon is 500 − 10 − 50 = 440. + assert {s.option.measure_type for s in package.selected} == { + "internal_wall_insulation", + "air_source_heat_pump", + } + assert abs(package.score.co2_kg_per_yr - 440.0) <= 1e-9 From e329c50fa36ab2e5f51b50cbe4ac762e37555e67 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 13:13:23 +0000 Subject: [PATCH 31/36] =?UTF-8?q?Fabric-first=20phase=202=20re-scores=20ca?= =?UTF-8?q?ndidates=20in=20the=20goal=20objective's=20currency=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 30 ++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 2f556e2a2..57976c4ca 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -276,7 +276,7 @@ def optimise_package_fabric_first( ) if ( target_sap is not None - and fabric_package.score.sap_continuous >= target_sap + and objective(fabric_package.score) >= target_sap ): return fabric_package if not fabric_package.selected: @@ -326,13 +326,15 @@ def optimise_package_fabric_first( remaining_groups, post_fabric_scorer, baseline_epc, - start_sap=fabric_package.score.sap_continuous, + objective=objective, + start_value=objective(fabric_package.score), ), scorer=post_fabric_scorer, baseline_epc=baseline_epc, budget=leftover_budget, target_sap=target_sap, dependencies=outstanding_dependencies, + objective=objective, ) return OptimisedPackage( selected=[*fabric_package.selected, *top_up.selected], @@ -353,23 +355,25 @@ def _rescored_groups( scorer: Scorer, baseline_epc: EpcPropertyData, *, - start_sap: float, + objective: Callable[[Score], float], + start_value: float, ) -> list[list[ScoredOption]]: """The groups with every Option's role-1 warm-start signal re-scored - through ``scorer`` — for phase 2, its independent gain on the post-fabric - dwelling rather than the raw baseline, so options whose worth changes once - the envelope is treated (a boiler on an insulated home) are re-ranked. - ``start_sap`` is the score of ``baseline_epc`` through ``scorer`` with no - candidate applied — the caller already has it (the phase-1 package score), - so it is threaded in rather than re-computed.""" + through ``scorer`` in the ``objective``'s currency — for phase 2, its + independent gain on the post-fabric dwelling rather than the raw baseline, + so options whose worth changes once the envelope is treated (a boiler on + an insulated home) are re-ranked. ``start_value`` is the objective value of + ``baseline_epc`` through ``scorer`` with no candidate applied — the caller + already has it (the phase-1 package score in the objective's currency), so + it is threaded in rather than re-computed.""" return [ [ ScoredOption( option=scored.option, - sap_gain=scorer.score( - baseline_epc, [scored.option.overlay] - ).sap_continuous - - start_sap, + sap_gain=objective( + scorer.score(baseline_epc, [scored.option.overlay]) + ) + - start_value, ) for scored in group ] From c70f6730a3a613ae3e517045081f8078883bb723 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 13:19:22 +0000 Subject: [PATCH 32/36] =?UTF-8?q?Remove=20the=20superseded=20role-1=20impa?= =?UTF-8?q?cts=20scorer;=20signals=20carry=20the=20objective=20currency=20?= =?UTF-8?q?=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 5 ++-- domain/modelling/scoring/scoring.py | 32 ---------------------- tests/domain/modelling/test_scoring.py | 12 ++++---- 3 files changed, 9 insertions(+), 40 deletions(-) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 57976c4ca..a40db733c 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -33,8 +33,9 @@ from domain.modelling.simulation import EpcSimulation @dataclass(frozen=True) class ScoredOption: """A candidate Measure Option paired with its role-1 (independent-vs- - baseline) SAP gain — the optimiser's input signal. Cost is read from the - Option; the gain is supplied by scoring.""" + baseline) gain in the goal objective's currency — SAP points by default, + kg CO2 / £ saved for the goal-aligned Scenarios (ADR-0062). Cost is read + from the Option; the gain is supplied by scoring.""" option: MeasureOption sap_gain: float diff --git a/domain/modelling/scoring/scoring.py b/domain/modelling/scoring/scoring.py index 8ac0701c9..5145385b8 100644 --- a/domain/modelling/scoring/scoring.py +++ b/domain/modelling/scoring/scoring.py @@ -83,38 +83,6 @@ def marginal_impacts( return marginals_from_scores(cascade_scores(scorer, baseline, overlays)) -def independent_option_impacts( - scorer: PackageScorer, - baseline: EpcPropertyData, - options: Sequence[MeasureOption], -) -> list[MeasureImpact]: - """Score each Option's overlay independently against the baseline (role 1 — - the optimiser's approximate input signal). Each *distinct* Simulation Overlay - is scored once (Options sharing an overlay reuse the result), so the baseline - is scored once plus one score per distinct overlay. Results follow the input - order. These figures are an approximate signal — never surface them as a - measure's true impact.""" - base: Score = scorer.score(baseline, []) - scored: list[tuple[EpcSimulation, MeasureImpact]] = [] - impacts: list[MeasureImpact] = [] - for option in options: - cached = next( - (impact for overlay, impact in scored if overlay == option.overlay), None - ) - if cached is None: - current: Score = scorer.score(baseline, [option.overlay]) - cached = MeasureImpact( - sap_points=current.sap_continuous - base.sap_continuous, - co2_savings_kg_per_yr=base.co2_kg_per_yr - current.co2_kg_per_yr, - energy_savings_kwh_per_yr=( - base.primary_energy_kwh_per_yr - current.primary_energy_kwh_per_yr - ), - ) - scored.append((option.overlay, cached)) - impacts.append(cached) - return impacts - - def independent_option_signals( scorer: PackageScorer, baseline: EpcPropertyData, diff --git a/tests/domain/modelling/test_scoring.py b/tests/domain/modelling/test_scoring.py index 61cfb4544..35541ccc6 100644 --- a/tests/domain/modelling/test_scoring.py +++ b/tests/domain/modelling/test_scoring.py @@ -16,7 +16,7 @@ from domain.modelling.recommendation import MeasureOption from domain.modelling.scoring.scoring import ( MeasureImpact, cascade_scores, - independent_option_impacts, + independent_option_signals, marginal_impacts, marginals_from_scores, ) @@ -64,7 +64,7 @@ def _option(overlay: EpcSimulation) -> MeasureOption: ) -def test_independent_option_impacts_score_each_distinct_overlay_once() -> None: +def test_independent_option_signals_score_each_distinct_overlay_once() -> None: # Arrange baseline: EpcPropertyData = build_epc() scorer = _CountingScorer() @@ -86,15 +86,15 @@ def test_independent_option_impacts_score_each_distinct_overlay_once() -> None: options = [_option(overlay_a), _option(overlay_a_dup), _option(overlay_b)] # Act - impacts: list[MeasureImpact] = independent_option_impacts( - scorer, baseline, options + signals: list[float] = independent_option_signals( + scorer, baseline, options, lambda score: score.sap_continuous ) # Assert # baseline scored once + one score per DISTINCT overlay (a, b) = 3, not 4 assert scorer.calls == 3 - assert impacts[0].sap_points == impacts[1].sap_points == 2.0 - assert impacts[2].sap_points == 3.0 + assert signals[0] == signals[1] == 2.0 + assert signals[2] == 3.0 def test_single_overlay_marginal_is_its_improvement_over_baseline() -> None: From b71bc788a2f115f7bd31060fdd6d1aaf8c6201b6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 13:19:56 +0000 Subject: [PATCH 33/36] Record goal-aligned Optimiser objectives as ADR-0062 Co-Authored-By: Claude Fable 5 --- .../0062-goal-aligned-optimiser-objectives.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/adr/0062-goal-aligned-optimiser-objectives.md diff --git a/docs/adr/0062-goal-aligned-optimiser-objectives.md b/docs/adr/0062-goal-aligned-optimiser-objectives.md new file mode 100644 index 000000000..afc87518b --- /dev/null +++ b/docs/adr/0062-goal-aligned-optimiser-objectives.md @@ -0,0 +1,62 @@ +--- +status: accepted (extends ADR-0016; composes with ADR-0061) +--- + +# Goal-aligned Optimiser objectives: each goal maximises its own metric + +Every Scenario goal used to optimise SAP. The legacy engine returned no +target for Energy Savings / Reducing CO2 (`optimiser_functions.calculate_gain` +→ `None`) and maximised SAP gain within budget regardless of the goal, and the +new engine inherited that: the goal label changed nothing but the words on the +brief. The scorer already computes each package's carbon and (via SapResult → +EnergyBreakdown → BillDerivation) its annual bill, so aligning the objective +is a selection change, not a calculator change. + +Decided in a grilling session with Khalim, 2026-07-09. + +## Decision + +**The Optimiser maximises the Scenario goal's own metric, as a pluggable +`objective: Callable[[Score], float]` (higher is better), with no target: +goal-aligned briefs are "reduce as much as possible within this budget".** + +- **Reducing CO2 emissions** maximises annual kg CO2 saved + (`-score.co2_kg_per_yr`). +- **Energy Savings** maximises the annual bill £ saved, priced at the **live + Fuel Rates snapshot** (ADR-0014), not SAP's internal tariff book — that + difference is the point of the goal. SAP is itself a cost-shaped rating, so + the two frequently agree; they diverge exactly when current tariffs disagree + with SAP's assumptions (e.g. the gas/electricity price ratio). +- **Increasing EPC** keeps its SAP objective and band-target semantics + (least-cost-to-target, repair, max-gain fallback) unchanged. +- **Valuation Improvement / None** stay max-SAP-within-budget — SAP is a + defensible valuation proxy and `None` has no semantics to encode. +- **`goal_value` is ignored for the goal-aligned goals** — no percentage or + absolute target exists yet. If targets arrive later they slot into the + existing target machinery on the objective's scale. +- **A budget is mandatory** for the goal-aligned goals: unconstrained + "as much as possible" would recommend every beneficial measure. A + budget-less Energy/CO2 Scenario raises a `ValueError` naming the scenario + and goal — a loud misconfiguration, not a maximal plan. +- **One currency everywhere**: the role-1 group signals + (`independent_option_signals`), the forced Measure Dependency pricing, the + greedy-repair marginals, and Fabric First's phase-2 re-scoring + (ADR-0061) are all measured by the same objective, so a ventilation that + costs SAP but is carbon-neutral cannot sink a carbon-improving wall, and a + fabric-first phase 2 picks its heating on post-fabric carbon, not + post-fabric SAP. + +## Consequences + +- Selection changes; truth-telling does not. The Plan's persisted Scores, + Bills, and role-3 SAP attribution are computed exactly as before — only + *which* package is chosen responds to the goal. +- At a £16,000 budget on the uninsulated solid-brick corpus dwelling + (001431), the SAP objective buys wall + floor + gas boiler (SAP 72.9, + 2,069 kg CO2/yr, £2,088/yr) while the carbon objective buys wall + floor + + storage heaters (SAP 69.2, 1,098 kg CO2/yr, £2,635/yr) — goals now trade + SAP, carbon and bills against each other visibly. +- The Energy Savings objective inherits the Fuel Rates snapshot's staleness + characteristics (quarterly Ofgem-cap cadence, ADR-0014). +- `independent_option_impacts` (role-1 SAP/CO2/kWh triple) is removed — + superseded by `independent_option_signals` in the objective's currency. From daf4449d0d21fdfd3601d28ce8c3cafb542871dd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 11:17:27 +0000 Subject: [PATCH 34/36] =?UTF-8?q?Optimiser=20objective=20is=20required=20o?= =?UTF-8?q?n=20the=20private=20helpers,=20hoisted=20in=20repair=20?= =?UTF-8?q?=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on PR #1527: - The objective is required (no sap_rating default) on _repair_to_target, _best_repair_candidate and _rescored_groups: every caller already passes it, and a default would let a future call path silently optimise SAP for a carbon/bill goal while pyright stayed green. The default stays on the public optimise_package / optimise_package_fabric_first entry points. - _best_repair_candidate hoists objective(current) out of the candidate loop: current is loop-invariant, so for the Energy-Savings bill objective this was one full BillDerivation.derive per candidate per repair iteration for the same score. Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index a40db733c..afb3fc4e8 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -454,7 +454,7 @@ def _repair_to_target( baseline_epc: EpcPropertyData, budget: Optional[float], target_sap: float, - objective: Callable[[Score], float] = sap_rating, + objective: Callable[[Score], float], ) -> OptimisedPackage: """Inject dependencies onto the warm-start, re-score for the truth, then greedy-add the untreated-group Option with the best marginal objective-per-£ @@ -522,15 +522,16 @@ def _best_repair_candidate( baseline_epc: EpcPropertyData, current: Score, budget: Optional[float], - objective: Callable[[Score], float] = sap_rating, + objective: Callable[[Score], float], ) -> 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 - ventilation dependency it newly triggers folded in, so both its SAP and its - incremental cost are truthful. Affordable when the resulting whole-package + """The untreated-group Option giving the best **marginal** objective-per-£ + when added to the current package — re-scored (not the role-1 signal) with + any ventilation dependency it newly triggers folded in, so both its gain and + its incremental cost are truthful. Affordable when the resulting whole-package cost is within ``budget`` and strictly improving. None if there is none.""" used: set[int] = _used_group_indices(groups, chosen) base_cost: float = _package_cost(_inject(chosen, dependencies)) + current_value: float = objective(current) best: Optional[ScoredOption] = None best_ratio: float = 0.0 for index, group in enumerate(groups): @@ -544,7 +545,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 = objective(trial) - objective(current) + marginal: float = objective(trial) - current_value if marginal <= 0.0: continue incremental: float = package_cost - base_cost From 98d2f7aa1648a7a2e03f49a006717abe70857335 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 11:17:27 +0000 Subject: [PATCH 35/36] =?UTF-8?q?Goal-aligned=20dispatch=20reads=20one=20e?= =?UTF-8?q?num-keyed=20table=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on PR #1527: - The 'which goals are goal-aligned' set lived in two adjacent if-chains (_objective_for and _require_budget_for_goal_aligned) that had to stay in sync — a new goal-aligned goal added to one but not the other would slip the budget guard. Both now read a single _GOAL_ALIGNED_OBJECTIVES table. - The goal strings are the canonical PortfolioGoal enum values, not re-declared string constants, so goal-value drift can't silently degrade a goal to max-SAP; _target_sap reads the enum too. - _scored_candidate_groups takes objective without a default (its only caller passes it). - scoring.py: 'cached: float | None' -> Optional[float] per the CLAUDE.md 'Use Optional over | None' rule. Co-Authored-By: Claude Fable 5 --- domain/modelling/scoring/scoring.py | 4 +- orchestration/modelling_orchestrator.py | 64 ++++++++++++++----------- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/domain/modelling/scoring/scoring.py b/domain/modelling/scoring/scoring.py index 5145385b8..9c0766995 100644 --- a/domain/modelling/scoring/scoring.py +++ b/domain/modelling/scoring/scoring.py @@ -15,7 +15,7 @@ truthful. The whole-package re-score (role 2) is `PackageScorer.score` directly. """ from dataclasses import dataclass -from typing import Callable, Sequence +from typing import Callable, Optional, Sequence from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.scoring.package_scorer import PackageScorer, Score @@ -99,7 +99,7 @@ def independent_option_signals( scored: list[tuple[EpcSimulation, float]] = [] signals: list[float] = [] for option in options: - cached: float | None = next( + cached: Optional[float] = next( (signal for overlay, signal in scored if overlay == option.overlay), None, ) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 102199f25..69e88feac 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -26,6 +26,7 @@ from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.plan import Plan, PlanMeasure from domain.modelling.recommendation import MeasureOption, Recommendation from domain.modelling.generators.roof_recommendation import recommend_roof_insulation +from domain.modelling.portfolio_goal import PortfolioGoal from domain.modelling.scenario import Scenario from domain.modelling.scoring.scoring import ( MeasureImpact, @@ -50,14 +51,6 @@ from repositories.product.product_repository import ProductRepository from repositories.solar.solar_repository import SolarRepository from repositories.unit_of_work import UnitOfWork -# The PortfolioGoal value that targets a SAP band (cf. -# backend.app.db.models.portfolio.PortfolioGoal.INCREASING_EPC). The -# goal-aligned goals (ADR-0062) set no target: they maximise their own metric -# within the Scenario budget. -_INCREASING_EPC_GOAL: Final[str] = "Increasing EPC" -_REDUCING_CO2_GOAL: Final[str] = "Reducing CO2 emissions" -_ENERGY_SAVINGS_GOAL: Final[str] = "Energy Savings" - # Best-practice install sequence for the role-3 attribution cascade (ADR-0016): # walls → roof → ventilation → floor, per the legacy `Recommendations` class. # Ventilation sits after the fabric that triggers it so its (negative) marginal @@ -405,7 +398,7 @@ def _scored_candidate_groups( planning_restrictions: PlanningRestrictions, solar_potential: Optional[SolarPotential], considered_measures: Optional[frozenset[MeasureType]], - objective: Callable[[Score], float] = sap_rating, + objective: Callable[[Score], float], ) -> list[list[ScoredOption]]: """One group per Recommendation: each Option scored independently against the baseline (role-1 warm-start signal, ADR-0016), in the goal objective's @@ -438,20 +431,6 @@ def _scored_candidate_groups( return groups -def _require_budget_for_goal_aligned(scenario: Scenario) -> None: - """A goal-aligned Scenario is 'reduce as much as possible within this - budget' — undefined without one (unconstrained, it would recommend every - beneficial measure). Fail the misconfiguration loudly (ADR-0062).""" - if scenario.budget is None and scenario.goal in ( - _REDUCING_CO2_GOAL, - _ENERGY_SAVINGS_GOAL, - ): - raise ValueError( - f"scenario {scenario.id} has goal {scenario.goal!r} but no budget; " - "goal-aligned scenarios require a budget" - ) - - def _carbon_reduction(score: Score) -> float: """The Reducing-CO2 objective: annual kg CO2 below zero-point, negated so higher is better (a saved kg scores +1).""" @@ -470,23 +449,50 @@ def _bill_saving(bill_derivation: BillDerivation) -> Callable[[Score], float]: return objective +# The goal-aligned goals (ADR-0062): each maximises its own metric within the +# Scenario budget and sets no SAP target. One table is the single source of +# "which goals are goal-aligned" — both the objective dispatch and the +# budget-required guard read it, so a new goal-aligned goal cannot be added to +# one without the other. Each entry builds its objective from the plan's +# BillDerivation (the carbon objective ignores it; the bill objective needs it). +# A goal absent from the table optimises SAP, as every goal did before. +_GOAL_ALIGNED_OBJECTIVES: Final[ + dict[str, Callable[[BillDerivation], Callable[[Score], float]]] +] = { + PortfolioGoal.REDUCING_CO2_EMISSIONS.value: lambda _bill_derivation: ( + _carbon_reduction + ), + PortfolioGoal.ENERGY_SAVINGS.value: _bill_saving, +} + + +def _require_budget_for_goal_aligned(scenario: Scenario) -> None: + """A goal-aligned Scenario is 'reduce as much as possible within this + budget' — undefined without one (unconstrained, it would recommend every + beneficial measure). Fail the misconfiguration loudly (ADR-0062).""" + if scenario.budget is None and scenario.goal in _GOAL_ALIGNED_OBJECTIVES: + raise ValueError( + f"scenario {scenario.id} has goal {scenario.goal!r} but no budget; " + "goal-aligned scenarios require a budget" + ) + + def _objective_for( scenario: Scenario, bill_derivation: BillDerivation ) -> Callable[[Score], float]: """The metric the Scenario's goal maximises (ADR-0062), as an Optimiser objective (higher is better). Goals without an aligned metric optimise SAP, as every goal did before.""" - if scenario.goal == _REDUCING_CO2_GOAL: - return _carbon_reduction - if scenario.goal == _ENERGY_SAVINGS_GOAL: - return _bill_saving(bill_derivation) - return sap_rating + build_objective = _GOAL_ALIGNED_OBJECTIVES.get(scenario.goal) + if build_objective is None: + return sap_rating + return build_objective(bill_derivation) def _target_sap(scenario: Scenario) -> Optional[float]: """The SAP rating the Optimiser repairs toward — the floor of the goal band for an INCREASING_EPC goal, else None (no SAP target).""" - if scenario.goal != _INCREASING_EPC_GOAL: + if scenario.goal != PortfolioGoal.INCREASING_EPC.value: return None return float(Epc(scenario.goal_value).sap_lower_bound()) From 7996eedf550e70580dbc60e67345097e10b23bc1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 11:17:27 +0000 Subject: [PATCH 36/36] =?UTF-8?q?Goal-objective=20test=20uses=20the=20shar?= =?UTF-8?q?ed=20fixtures=20and=20a=20real=20boiler=20code=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on PR #1527: - The overlay constants, ScoredOption builder, ventilation dependency and selected_types helper come from the shared _optimiser_fixtures module (landed on the fabric-first base) instead of local copies; the boiler overlay is the shared BOILER_OVERLAY (SAP Table 4a code 104, a mains-gas combi) rather than code 201, which is neither a boiler nor a heat pump. _IWI_OVERLAY (solid-wall internal, type 3) stays local — no shared equivalent — and the carbon stubs stay bespoke (the shared StubScorer has no CO2 knob). - The optimise_package_fabric_first import is lifted to module scope. Co-Authored-By: Claude Fable 5 --- .../test_optimiser_goal_objective.py | 75 +++++-------------- 1 file changed, 20 insertions(+), 55 deletions(-) diff --git a/tests/domain/modelling/test_optimiser_goal_objective.py b/tests/domain/modelling/test_optimiser_goal_objective.py index f64371cc4..acd14b3ec 100644 --- a/tests/domain/modelling/test_optimiser_goal_objective.py +++ b/tests/domain/modelling/test_optimiser_goal_objective.py @@ -16,46 +16,25 @@ from datatypes.epc.domain.epc_property_data import ( ) from domain.modelling.measure_type import MeasureType from domain.modelling.optimisation.optimiser import ( - MeasureDependency, OptimisedPackage, ScoredOption, optimise_package, + 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, - VentilationOverlay, +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from tests.domain.modelling._optimiser_fixtures import ( + ASHP_OVERLAY, + BOILER_OVERLAY, + WALL_OVERLAY, + scored_option, + selected_types, + ventilation_dependency, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) -_WALL_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) - } -) -_VENT_OVERLAY = EpcSimulation( - ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE") -) - - -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 _CarbonScorer: """A stub where the wall is a small carbon win (−20 kg/yr) and a large SAP @@ -89,19 +68,10 @@ def test_dependency_signals_are_priced_in_the_objective_currency() -> None: # pricing the ventilation's −30 SAP would outweigh the wall's +20 signal # and the package would collapse to nothing. groups: list[list[ScoredOption]] = [ - [_scored("cavity_wall_insulation", gain=20.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=20.0, cost=1000.0, overlay=WALL_OVERLAY)], ] - dependency = MeasureDependency( - triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}), - required=ScoredOption( - option=MeasureOption( - measure_type=MeasureType.MECHANICAL_VENTILATION, - description="mechanical_ventilation", - overlay=_VENT_OVERLAY, - cost=Cost(total=300.0, contingency_rate=0.0), - ), - sap_gain=0.0, - ), + dependency = ventilation_dependency( + cost=300.0, triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}) ) # Act — a Reducing-CO2 brief: maximise carbon reduction within budget. @@ -117,22 +87,21 @@ def test_dependency_signals_are_priced_in_the_objective_currency() -> None: # Assert — the wall survives with its ventilation: the dependency is worth # 0 kg CO2, not −30 SAP, so the package is a net +20 kg saving. - assert {s.option.measure_type for s in package.selected} == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "mechanical_ventilation", } assert abs(package.score.co2_kg_per_yr - 480.0) <= 1e-9 +# Internal wall insulation — a distinct fabric overlay so the fabric-first +# phase-1 pick is unambiguous. No shared fixture (the shared WALL_OVERLAY is a +# cavity fill, type 2); this is a solid-wall internal treatment, type 3. _IWI_OVERLAY = EpcSimulation( building_parts={ BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=3) } ) -_BOILER_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201)) -_ASHP_OVERLAY = EpcSimulation( - heating=HeatingOverlay(main_heating_index_number=13000) -) class _CarbonHeatingScorer: @@ -166,15 +135,11 @@ def test_fabric_first_phase_two_rescores_in_the_objective_currency() -> None: # Arrange — a fabric-first Reducing-CO2 brief. Phase 1 commits the wall; # phase 2 must choose the heating on its post-fabric *carbon* worth, not # its SAP worth. Signals are supplied in kg CO2 saved (the caller's job). - from domain.modelling.optimisation.optimiser import ( - optimise_package_fabric_first, - ) - groups: list[list[ScoredOption]] = [ - [_scored("internal_wall_insulation", gain=10.0, cost=1000.0, overlay=_IWI_OVERLAY)], + [scored_option("internal_wall_insulation", gain=10.0, cost=1000.0, overlay=_IWI_OVERLAY)], [ - _scored("gas_boiler_upgrade", gain=5.0, cost=2000.0, overlay=_BOILER_OVERLAY), - _scored("air_source_heat_pump", gain=50.0, cost=6000.0, overlay=_ASHP_OVERLAY), + scored_option("gas_boiler_upgrade", gain=5.0, cost=2000.0, overlay=BOILER_OVERLAY), + scored_option("air_source_heat_pump", gain=50.0, cost=6000.0, overlay=ASHP_OVERLAY), ], ] @@ -190,7 +155,7 @@ def test_fabric_first_phase_two_rescores_in_the_objective_currency() -> None: # Assert — the wall plus the heat pump (−50 kg), not the SAP-favoured # boiler; the truthful package carbon is 500 − 10 − 50 = 440. - assert {s.option.measure_type for s in package.selected} == { + assert selected_types(package.selected) == { "internal_wall_insulation", "air_source_heat_pump", }