"""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, HeatingOverlay, 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 _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