From adf60b82b1a0d4b7b60cbe7a691ab47ad81c0188 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:41:22 +0000 Subject: [PATCH] =?UTF-8?q?Fabric=20unpicked=20in=20phase=201=20can=20re-e?= =?UTF-8?q?nter=20phase=202=20on=20post-fabric=20worth=20=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