"""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 EpcPropertyData from domain.modelling.measure_type import MeasureType from domain.modelling.optimisation.optimiser import ( OptimisedPackage, ScoredOption, optimise_package_fabric_first, ) from domain.modelling.scoring.package_scorer import Score 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, ) _AIRTIGHTNESS_TRIGGERS: frozenset[MeasureType] = frozenset( {MeasureType.CAVITY_WALL_INSULATION, MeasureType.DOUBLE_GLAZING} ) def test_fabric_reaching_the_target_excludes_non_fabric_measures() -> None: # 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_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=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=15000.0, target_sap=69.0, ) # Assert — fabric only: the wall (true 72 ≥ 69); the boiler is never # considered because the upgrade requirement is already met. 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_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, 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.selected) == { "cavity_wall_insulation", "air_source_heat_pump", } 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_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, 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.selected) == {"cavity_wall_insulation"} assert abs(package.score.sap_continuous - 65.0) <= 1e-9 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: 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 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 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_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() # 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, scorer=scorer, baseline_epc=build_epc(), budget=10000.0, target_sap=68.0, dependencies=[ ventilation_dependency(cost=300.0, triggers=_AIRTIGHTNESS_TRIGGERS) ], ) # Assert — one ventilation, and the truthful total counts its penalty once: # 60 + 5 wall + 4 glazing − 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.selected) == { "cavity_wall_insulation", "double_glazing", "mechanical_ventilation", } 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_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)], ] scorer = StubScorer(base=60.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.selected) == {"air_source_heat_pump"} 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_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, 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.selected) == {"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 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 ) 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 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 ) 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_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() # 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.selected) == { "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 # 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_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_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() # 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.selected) == { "cavity_wall_insulation", "air_source_heat_pump", } assert abs(package.score.sap_continuous - 73.0) <= 1e-9