From 0220bee87cfab0c2319c2b94c71786b347505af8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:41:27 +0000 Subject: [PATCH] =?UTF-8?q?Forced=20ventilation=20is=20injected=20once=20a?= =?UTF-8?q?cross=20both=20fabric-first=20phases=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 | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py index 0fd088627..71ce7a4f6 100644 --- a/tests/domain/modelling/test_optimiser_fabric_first.py +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -18,6 +18,7 @@ from datatypes.epc.domain.epc_property_data import ( ) from domain.modelling.measure_type import MeasureType from domain.modelling.optimisation.optimiser import ( + MeasureDependency, OptimisedPackage, ScoredOption, optimise_package_fabric_first, @@ -29,6 +30,7 @@ from domain.modelling.simulation import ( EpcSimulation, GlazingOverlay, HeatingOverlay, + VentilationOverlay, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, @@ -175,6 +177,104 @@ def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None: assert abs(package.score.sap_continuous - 65.0) <= 1e-9 +_IWI_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=3) + } +) +_VENT_OVERLAY = EpcSimulation( + ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE") +) + + +class _TwoWallScorer: + """A stub with two wall treatments (cavity type=2, internal type=3): the + internal wall is worthless raw but +4 once the cavity is done, and every + ventilation overlay present costs −1 — so both phases trigger the same + forced ventilation dependency and a double injection is visible in the + package score.""" + + def score( + self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] + ) -> Score: + cavity = any( + part.wall_insulation_type == 2 + for sim in simulations + for part in sim.building_parts.values() + ) + internal = any( + part.wall_insulation_type == 3 + for sim in simulations + for part in sim.building_parts.values() + ) + vents = sum(1 for sim in simulations if sim.ventilation is not None) + sap = 60.0 + sap += 5.0 if cavity else 0.0 + sap += (4.0 if cavity else 0.0) if internal else 0.0 + sap -= float(vents) + return Score( + sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0 + ) + + +def _wall_ventilation_dependency(*, cost: float) -> MeasureDependency: + return MeasureDependency( + triggers=frozenset( + { + MeasureType.CAVITY_WALL_INSULATION, + MeasureType.INTERNAL_WALL_INSULATION, + } + ), + required=ScoredOption( + option=MeasureOption( + measure_type=MeasureType.MECHANICAL_VENTILATION, + description="mechanical_ventilation", + overlay=_VENT_OVERLAY, + cost=Cost(total=cost, contingency_rate=0.0), + ), + sap_gain=0.0, + ), + ) + + +def test_ventilation_dependency_is_injected_once_across_both_phases() -> None: + # Arrange — the cavity wall (phase 1) and the internal wall (picked in + # phase 2 on its post-cavity 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("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [_scored("internal_wall_insulation", gain=0.0, cost=2000.0, overlay=_IWI_OVERLAY)], + ] + scorer = _TwoWallScorer() + + # Act — target 68: phase 1 gives 60 + 5 − 1 = 64; the internal wall'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=[_wall_ventilation_dependency(cost=300.0)], + ) + + # Assert — one ventilation, and the truthful total counts its penalty once: + # 60 + 5 cavity + 4 internal − 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) == { + "cavity_wall_insulation", + "internal_wall_insulation", + "mechanical_ventilation", + } + assert abs(package.score.sap_continuous - 68.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