From 705c86d5e6d281199387b6c0725fa7af6cdb0e8a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:40:29 +0000 Subject: [PATCH] =?UTF-8?q?Phase-2=20candidates=20are=20valued=20against?= =?UTF-8?q?=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