From ffaf89935ba68e7dab3a2c014c83e1fbe7064d13 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 13:13:22 +0000 Subject: [PATCH] =?UTF-8?q?Fabric-first=20phase=202=20re-scores=20candidat?= =?UTF-8?q?es=20in=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 | 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