From 832a30a9858cc202a91cb678025d80c6cc93167b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 10:29:45 +0000 Subject: [PATCH] =?UTF-8?q?Optimiser=20test=20fixtures=20are=20shared=20an?= =?UTF-8?q?d=20domain-plausible=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on PR #1526: - tests/domain/modelling/_optimiser_fixtures.py is the one home for the overlay constants, the ScoredOption builder, the additive per-kind StubScorer and the forced ventilation dependency; test_optimiser.py and test_optimiser_fabric_first.py had byte-identical copies of each (and _StubScorer / _VentStubScorer fold into one parameterised stub). - Fixture worlds are domain-plausible per team convention: the fabric-vs- heating contrast is a £12,000 EWI against a £3,200 gas boiler rather than a £500 heat pump undercutting a £1,000 cavity wall; heating overlays carry real identities (SAP Table 4a code 104 for the boiler, a PCDF index for the heat pump) instead of code 201 doubling as both; whole-dwelling double glazing is £3,500, not £500. - Dead knobs removed: the unused _ROOF_OVERLAY, the always-zero roof gain, the duplicate _BOILER_OVERLAY, and the nested conditional expressions in the interaction stubs. Co-Authored-By: Claude Fable 5 --- tests/domain/modelling/_optimiser_fixtures.py | 144 ++++++++ tests/domain/modelling/test_optimiser.py | 315 +++++------------- .../modelling/test_optimiser_fabric_first.py | 202 ++++------- 3 files changed, 291 insertions(+), 370 deletions(-) create mode 100644 tests/domain/modelling/_optimiser_fixtures.py diff --git a/tests/domain/modelling/_optimiser_fixtures.py b/tests/domain/modelling/_optimiser_fixtures.py new file mode 100644 index 000000000..01927b481 --- /dev/null +++ b/tests/domain/modelling/_optimiser_fixtures.py @@ -0,0 +1,144 @@ +"""Shared fixtures for the Optimiser test files: distinguishable Simulation +Overlays (so a stub scorer can attribute a true gain per measure kind), the +ScoredOption builder, the additive per-kind stub scorer, and the forced +ventilation Measure Dependency edge. + +Fixture values stay domain-plausible: overlay heating codes are real SAP +Table 4a codes (104 = mains-gas combi boiler, heat pumps carry a PCDF index), +and tests price measures at realistic magnitudes (a CWI around £1,000, an +ASHP around £8,000).""" + +from __future__ import annotations + +from typing import Optional, 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, ScoredOption +from domain.modelling.recommendation import Cost, MeasureOption +from domain.modelling.scoring.package_scorer import Score +from domain.modelling.simulation import ( + BuildingPartOverlay, + EpcSimulation, + GlazingOverlay, + HeatingOverlay, + VentilationOverlay, +) + +WALL_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) + } +) +ROOF_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300) + } +) +FLOOR_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(floor_insulation_thickness=100) + } +) +GLAZING_OVERLAY = EpcSimulation(glazing=GlazingOverlay(glazing_type=2)) +VENT_OVERLAY = EpcSimulation( + ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE") +) +# SAP Table 4a code 104: a mains-gas combi boiler. +BOILER_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=104)) +# Heat pumps are expressed as a PCDF product index, as the generator emits them. +ASHP_OVERLAY = EpcSimulation(heating=HeatingOverlay(main_heating_index_number=13000)) + +_WALL_TRIGGERS: frozenset[MeasureType] = frozenset( + {MeasureType.CAVITY_WALL_INSULATION, MeasureType.EXTERNAL_WALL_INSULATION} +) + + +def scored_option( + measure_type: str, + *, + gain: float, + cost: float, + overlay: Optional[EpcSimulation] = None, +) -> ScoredOption: + """A one-Option fixture: ``gain`` is the role-1 warm-start signal, ``cost`` + the total install cost. Omit ``overlay`` where the test never re-scores.""" + return ScoredOption( + option=MeasureOption( + measure_type=MeasureType(measure_type), + description=measure_type, + overlay=overlay if overlay is not None else EpcSimulation(), + cost=Cost(total=cost, contingency_rate=0.0), + ), + sap_gain=gain, + ) + + +class StubScorer: + """A deterministic stand-in for PackageScorer: the package SAP is a base + plus a fixed *true* gain per measure kind present (detected by overlay + field), decoupled from the role-1 signal — so selection, repair and the + two-phase split are exercised without the calculator. Kinds a test does + not use default to 0.""" + + def __init__( + self, + *, + base: float, + wall: float = 0.0, + roof: float = 0.0, + floor: float = 0.0, + heating: float = 0.0, + vent: float = 0.0, + ) -> None: + self._base = base + self._wall = wall + self._roof = roof + self._floor = floor + self._heating = heating + self._vent = vent + + def score( + self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] + ) -> Score: + sap = self._base + for sim in simulations: + if sim.heating is not None: + sap += self._heating + if sim.ventilation is not None: + sap += self._vent + for part in sim.building_parts.values(): + if part.wall_insulation_type is not None: + sap += self._wall + if part.roof_insulation_thickness is not None: + sap += self._roof + if part.floor_insulation_thickness is not None: + sap += self._floor + return Score( + sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0 + ) + + +def selected_types(selection: Sequence[ScoredOption]) -> set[str]: + return {scored.option.measure_type for scored in selection} + + +def ventilation_dependency( + *, cost: float, triggers: frozenset[MeasureType] = _WALL_TRIGGERS +) -> MeasureDependency: + """A forced 'airtightness requires ventilation' edge for the tests.""" + return MeasureDependency( + triggers=triggers, + 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, # placeholder; optimise_package scores the real signal + ), + ) diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index 80f39d7e1..75bec806e 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -8,14 +8,7 @@ selection with synthetic scores and no calculator. from __future__ import annotations -from typing import Sequence - -from datatypes.epc.domain.epc_property_data import ( - BuildingPartIdentifier, - EpcPropertyData, -) from domain.modelling.optimisation.optimiser import ( - MeasureDependency, OptimisedPackage, ScoredOption, optimise, @@ -23,104 +16,30 @@ from domain.modelling.optimisation.optimiser import ( optimise_package, ) from domain.modelling.measure_type import MeasureType -from domain.modelling.scoring.package_scorer import Score -from domain.modelling.recommendation import Cost, MeasureOption -from domain.modelling.simulation import ( - BuildingPartOverlay, - EpcSimulation, - VentilationOverlay, +from tests.domain.modelling._optimiser_fixtures import ( + FLOOR_OVERLAY, + ROOF_OVERLAY, + WALL_OVERLAY, + StubScorer, + scored_option, + selected_types, + ventilation_dependency, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) -def _scored(measure_type: str, *, gain: float, cost: float) -> ScoredOption: - return ScoredOption( - option=MeasureOption( - measure_type=MeasureType(measure_type), - description=measure_type, - overlay=EpcSimulation(), - cost=Cost(total=cost, contingency_rate=0.0), - ), - sap_gain=gain, - ) - - -# Distinguishable overlays so the stub scorer can attribute a true gain per -# measure (wall / roof / floor) regardless of the role-1 signal. -_WALL_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) - } -) -_ROOF_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300) - } -) -_FLOOR_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(floor_insulation_thickness=100) - } -) - - -def _scored_overlay( - 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 _StubScorer: - """A deterministic stand-in for PackageScorer: the package SAP is a base - plus a fixed *true* gain per measure present (by overlay field), decoupled - from the role-1 signal — so the repair loop is exercised without the - calculator (ADR-0016).""" - - def __init__(self, *, base: float, wall: float, roof: float, floor: float) -> None: - self._base = base - self._wall = wall - self._roof = roof - self._floor = floor - - def score( - self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] - ) -> Score: - sap = self._base - for sim in simulations: - part = sim.building_parts[BuildingPartIdentifier.MAIN] - if part.wall_insulation_type is not None: - sap += self._wall - if part.roof_insulation_thickness is not None: - sap += self._roof - if part.floor_insulation_thickness is not None: - sap += self._floor - return Score(sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0) - - -def _selected_types(selection: list[ScoredOption]) -> set[str]: - return {scored.option.measure_type for scored in selection} - - def test_grouped_knapsack_maximises_gain_within_budget() -> None: # Arrange — wall group has two mutually-exclusive options; roof + floor one # each. EWI has the best gain but is unaffordable alongside the rest. groups: list[list[ScoredOption]] = [ [ - _scored("external_wall_insulation", gain=10.0, cost=8000.0), - _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), + scored_option("external_wall_insulation", gain=10.0, cost=8000.0), + scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0), ], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], - [_scored("suspended_floor_insulation", gain=3.0, cost=2000.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("suspended_floor_insulation", gain=3.0, cost=2000.0)], ] # Act @@ -128,7 +47,7 @@ def test_grouped_knapsack_maximises_gain_within_budget() -> None: # Assert — cavity + loft + floor (cost 4500, gain 13) beats any package # containing the 8000 EWI option within the 5000 budget. - assert _selected_types(selection) == { + assert selected_types(selection) == { "cavity_wall_insulation", "loft_insulation", "suspended_floor_insulation", @@ -139,8 +58,8 @@ def test_picks_at_most_one_option_per_group() -> None: # Arrange — both wall options are individually affordable. groups: list[list[ScoredOption]] = [ [ - _scored("external_wall_insulation", gain=10.0, cost=2000.0), - _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), + scored_option("external_wall_insulation", gain=10.0, cost=2000.0), + scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0), ], ] @@ -149,24 +68,24 @@ def test_picks_at_most_one_option_per_group() -> None: # Assert — never both treatments of the same wall; the higher-gain one wins. assert len(selection) == 1 - assert _selected_types(selection) == {"external_wall_insulation"} + assert selected_types(selection) == {"external_wall_insulation"} def test_no_budget_picks_the_best_option_in_every_group() -> None: # Arrange groups: list[list[ScoredOption]] = [ [ - _scored("external_wall_insulation", gain=10.0, cost=8000.0), - _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), + scored_option("external_wall_insulation", gain=10.0, cost=8000.0), + scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0), ], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act — None budget = unconstrained. selection: list[ScoredOption] = optimise(groups, budget=None) # Assert - assert _selected_types(selection) == { + assert selected_types(selection) == { "external_wall_insulation", "loft_insulation", } @@ -175,8 +94,8 @@ def test_no_budget_picks_the_best_option_in_every_group() -> None: def test_budget_too_small_for_any_option_selects_nothing() -> None: # Arrange groups: list[list[ScoredOption]] = [ - [_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act @@ -194,15 +113,15 @@ def test_no_groups_selects_nothing() -> None: def test_within_budget_partial_selection_prefers_the_higher_gain_option() -> None: # Arrange — only one of the two fits the budget; pick the affordable best. groups: list[list[ScoredOption]] = [ - [_scored("external_wall_insulation", gain=10.0, cost=8000.0)], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("external_wall_insulation", gain=10.0, cost=8000.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act selection: list[ScoredOption] = optimise(groups, budget=2000.0) # Assert — EWI is unaffordable; loft alone is the best within £2000. - assert _selected_types(selection) == {"loft_insulation"} + assert selected_types(selection) == {"loft_insulation"} # --- optimise_min_cost: least-cost-to-target selection (ADR-0016 amendment) --- @@ -212,8 +131,8 @@ def test_min_cost_picks_the_cheapest_package_that_reaches_the_target() -> None: # Arrange — two packages both clear the target gain; one is cheaper. groups: list[list[ScoredOption]] = [ [ - _scored("loft_insulation", gain=10.0, cost=2000.0), - _scored("external_wall_insulation", gain=15.0, cost=3000.0), + scored_option("loft_insulation", gain=10.0, cost=2000.0), + scored_option("external_wall_insulation", gain=15.0, cost=3000.0), ], ] @@ -223,7 +142,7 @@ def test_min_cost_picks_the_cheapest_package_that_reaches_the_target() -> None: # Assert — least-cost-to-target takes the +10 @ £2000, NOT the higher-gain # +15 @ £3000 (no overshoot, surplus budget unspent). assert selection is not None - assert _selected_types(selection) == {"loft_insulation"} + assert selected_types(selection) == {"loft_insulation"} def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None: @@ -232,10 +151,10 @@ def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None: # £8000). groups: list[list[ScoredOption]] = [ [ - _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), - _scored("external_wall_insulation", gain=10.0, cost=8000.0), + scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0), + scored_option("external_wall_insulation", gain=10.0, cost=8000.0), ], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act @@ -243,7 +162,7 @@ def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None: # Assert assert selection is not None - assert _selected_types(selection) == { + assert selected_types(selection) == { "cavity_wall_insulation", "loft_insulation", } @@ -254,8 +173,8 @@ def test_min_cost_breaks_cost_ties_toward_the_higher_gain() -> None: # one with more headroom ("recommend more" on a tie). groups: list[list[ScoredOption]] = [ [ - _scored("cavity_wall_insulation", gain=10.0, cost=2000.0), - _scored("external_wall_insulation", gain=14.0, cost=2000.0), + scored_option("cavity_wall_insulation", gain=10.0, cost=2000.0), + scored_option("external_wall_insulation", gain=14.0, cost=2000.0), ], ] @@ -264,13 +183,13 @@ def test_min_cost_breaks_cost_ties_toward_the_higher_gain() -> None: # Assert assert selection is not None - assert _selected_types(selection) == {"external_wall_insulation"} + assert selected_types(selection) == {"external_wall_insulation"} def test_min_cost_returns_none_when_target_unreachable_within_budget() -> None: # Arrange — the only target-reaching package costs more than the budget. groups: list[list[ScoredOption]] = [ - [_scored("external_wall_insulation", gain=10.0, cost=8000.0)], + [scored_option("external_wall_insulation", gain=10.0, cost=8000.0)], ] # Act @@ -283,8 +202,8 @@ def test_min_cost_returns_none_when_target_unreachable_within_budget() -> None: def test_min_cost_returns_none_when_no_package_reaches_the_target() -> None: # Arrange — even everything together falls short of the target gain. groups: list[list[ScoredOption]] = [ - [_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)], - [_scored("loft_insulation", gain=3.0, cost=1500.0)], + [scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0)], + [scored_option("loft_insulation", gain=3.0, cost=1500.0)], ] # Act @@ -298,8 +217,8 @@ def test_min_cost_unbudgeted_picks_cheapest_reaching_target_not_everything() -> # Arrange — no budget cap, but min-cost still means cheapest-to-target, not # "install everything". groups: list[list[ScoredOption]] = [ - [_scored("cavity_wall_insulation", gain=10.0, cost=1000.0)], - [_scored("loft_insulation", gain=4.0, cost=1500.0)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0)], + [scored_option("loft_insulation", gain=4.0, cost=1500.0)], ] # Act — cavity alone (+10 @ £1000) already reaches the target. @@ -307,14 +226,14 @@ def test_min_cost_unbudgeted_picks_cheapest_reaching_target_not_everything() -> # Assert — loft is left off; it would only add cost past the target. assert selection is not None - assert _selected_types(selection) == {"cavity_wall_insulation"} + assert selected_types(selection) == {"cavity_wall_insulation"} def test_min_cost_non_positive_target_selects_nothing() -> None: # Arrange — a target already met (gain 0 needed) is reached by the empty # package at zero cost. groups: list[list[ScoredOption]] = [ - [_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)], + [scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0)], ] # Act @@ -328,11 +247,11 @@ def test_repair_adds_an_untreated_group_option_to_close_the_undershoot() -> None # Arrange — role-1 under-counts roof (signal 0 → warm-start skips it), but # its true re-scored gain (+4) is what closes the target. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)], - [_scored_overlay("suspended_floor_insulation", gain=8.0, cost=1000.0, overlay=_FLOOR_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=0.0, cost=1000.0, overlay=ROOF_OVERLAY)], + [scored_option("suspended_floor_insulation", gain=8.0, cost=1000.0, overlay=FLOOR_OVERLAY)], ] - scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) # Act package: OptimisedPackage = optimise_package( @@ -358,9 +277,9 @@ def test_repair_adds_an_untreated_group_option_to_close_the_undershoot() -> None def test_no_target_returns_the_warm_start_package_without_repair() -> None: # Arrange groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], ] - scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) # Act package: OptimisedPackage = optimise_package( @@ -381,10 +300,10 @@ def test_no_target_returns_the_warm_start_package_without_repair() -> None: def test_repair_stops_when_no_affordable_improving_option_remains() -> None: # Arrange — the only untreated-group option costs more than the budget left. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=0.0, cost=5000.0, overlay=_ROOF_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=0.0, cost=5000.0, overlay=ROOF_OVERLAY)], ] - scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) # Act package: OptimisedPackage = optimise_package( @@ -410,11 +329,11 @@ def test_package_stops_at_the_target_and_does_not_overshoot() -> None: # Arrange — wall alone already clears the target; max-gain would add roof + # floor too. Least-cost-to-target must stop at the wall. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)], - [_scored_overlay("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=_FLOOR_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=5.0, cost=1000.0, overlay=ROOF_OVERLAY)], + [scored_option("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=FLOOR_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0) + scorer = StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0) # Act — target 69 (gain 9); wall (+10 → 70) reaches it for £1000. package: OptimisedPackage = optimise_package( @@ -427,18 +346,18 @@ def test_package_stops_at_the_target_and_does_not_overshoot() -> None: # Assert — just the wall; roof + floor (which would reach 80) are left off, # surplus budget unspent. - assert _selected_types(package.selected) == {"cavity_wall_insulation"} + assert selected_types(package.selected) == {"cavity_wall_insulation"} assert abs(package.score.sap_continuous - 70.0) <= 1e-9 def test_package_falls_back_to_max_gain_when_target_unreachable() -> None: # Arrange — even all three measures (+20 → 80) cannot reach the target. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)], - [_scored_overlay("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=_FLOOR_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=5.0, cost=1000.0, overlay=ROOF_OVERLAY)], + [scored_option("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=FLOOR_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0) + scorer = StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0) # Act — target 90 is out of reach; best effort is the most SAP budget buys. package: OptimisedPackage = optimise_package( @@ -450,7 +369,7 @@ def test_package_falls_back_to_max_gain_when_target_unreachable() -> None: ) # Assert — max-gain: all three, SAP 80 (below target, best effort). - assert _selected_types(package.selected) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "loft_insulation", "suspended_floor_insulation", @@ -463,10 +382,10 @@ def test_package_repairs_when_the_signal_overshoots_the_true_score() -> None: # min-cost warm-start picks it alone; but its true gain is only +5, so the # package undershoots and repair must top it up. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=0.0, cost=1000.0, overlay=ROOF_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=5.0, roof=4.0, floor=0.0) + scorer = StubScorer(base=60.0, wall=5.0, roof=4.0, floor=0.0) # Act — target 69 (gain 9). Warm-start {wall} (signal 10) → true 65 < 69 → # repair adds the roof (+4) → 69. @@ -479,7 +398,7 @@ def test_package_repairs_when_the_signal_overshoots_the_true_score() -> None: ) # Assert - assert _selected_types(package.selected) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "loft_insulation", } @@ -488,57 +407,6 @@ def test_package_repairs_when_the_signal_overshoots_the_true_score() -> None: # --- Measure Dependency injection (ADR-0016) ------------------------------- -_VENT_OVERLAY = EpcSimulation( - ventilation=VentilationOverlay( - mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" - ) -) - - -class _VentStubScorer: - """A stub that adds a fixed gain per wall overlay present and a fixed - (negative) `vent` contribution when a ventilation overlay is present — - so the Measure Dependency's effect on the truthful package total and the - repair decision is exercised without the calculator.""" - - def __init__(self, *, base: float, wall: float, roof: float, vent: float) -> None: - self._base = base - self._wall = wall - self._roof = roof - self._vent = vent - - def score( - self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] - ) -> Score: - sap = self._base - for sim in simulations: - if sim.ventilation is not None: - sap += self._vent - for part in sim.building_parts.values(): - if part.wall_insulation_type is not None: - sap += self._wall - if part.roof_insulation_thickness is not None: - sap += self._roof - return Score(sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0) - - -def _ventilation_dependency(*, cost: float) -> MeasureDependency: - """A forced 'fabric requires ventilation' edge for the tests.""" - return MeasureDependency( - triggers=frozenset( - {MeasureType.CAVITY_WALL_INSULATION, MeasureType.EXTERNAL_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_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() -> None: # Arrange — cavity is dirt cheap (£100) and its role-1 signal (+6) alone @@ -547,21 +415,12 @@ def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() - # package below target. A ventilation-AWARE warm-start prices that −5 into # the candidate and instead takes the wall-free loft path. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=6.0, cost=100.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=8.0, cost=1500.0, overlay=_ROOF_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=6.0, cost=100.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=8.0, cost=1500.0, overlay=ROOF_OVERLAY)], ] - scorer = _VentStubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0) - 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, # placeholder; optimise_package scores the real signal - ), + scorer = StubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0) + dependency = ventilation_dependency( + cost=300.0, triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}) ) # Act — target 66 (gain 6 over the 60 baseline). @@ -576,7 +435,7 @@ def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() - # Assert — the loft path (true 68, £1500), NOT cavity + forced ventilation: # cavity's signal (+6) is cancelled by ventilation (−5) to +1 < target. - assert _selected_types(package.selected) == {"loft_insulation"} + assert selected_types(package.selected) == {"loft_insulation"} assert abs(package.score.sap_continuous - 68.0) <= 1e-9 @@ -584,9 +443,9 @@ def test_dependency_injected_when_a_trigger_measure_is_selected() -> None: # Arrange — the wall is selected, so its ventilation dependency must be # injected before the re-score; ventilation never competes in the pool. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], ] - scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act package: OptimisedPackage = optimise_package( @@ -595,12 +454,12 @@ def test_dependency_injected_when_a_trigger_measure_is_selected() -> None: baseline_epc=build_epc(), budget=None, target_sap=None, - dependencies=[_ventilation_dependency(cost=900.0)], + dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — ventilation is in the package and its negative contribution lands # in the truthful total: 40 base + 5 wall − 2 ventilation = 43. - assert _selected_types(package.selected) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "mechanical_ventilation", } @@ -611,9 +470,9 @@ def test_dependency_not_injected_without_a_trigger_measure() -> None: # Arrange — only loft is selected; the wall-triggered ventilation dependency # must not fire. groups: list[list[ScoredOption]] = [ - [_scored_overlay("loft_insulation", gain=4.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + [scored_option("loft_insulation", gain=4.0, cost=1000.0, overlay=ROOF_OVERLAY)], ] - scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act package: OptimisedPackage = optimise_package( @@ -622,11 +481,11 @@ def test_dependency_not_injected_without_a_trigger_measure() -> None: baseline_epc=build_epc(), budget=None, target_sap=None, - dependencies=[_ventilation_dependency(cost=900.0)], + dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — no trigger, no ventilation; 40 base + 4 roof = 44. - assert _selected_types(package.selected) == {"loft_insulation"} + assert selected_types(package.selected) == {"loft_insulation"} assert abs(package.score.sap_continuous - 44.0) <= 1e-9 @@ -636,9 +495,9 @@ def test_wall_dropped_when_it_cannot_be_ventilated_within_budget() -> None: # wall we can't afford to ventilate is a wall we can't afford, so it is # dropped (the budget is a hard envelope, ventilation is not forced over it). groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], ] - scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act — tight budget; ventilation-aware selection prices the £900 in. package: OptimisedPackage = optimise_package( @@ -647,7 +506,7 @@ def test_wall_dropped_when_it_cannot_be_ventilated_within_budget() -> None: baseline_epc=build_epc(), budget=1000.0, target_sap=None, - dependencies=[_ventilation_dependency(cost=900.0)], + dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — nothing recommended; the budget is respected and the wall is @@ -660,10 +519,10 @@ def test_injected_ventilation_penalty_drives_extra_repair() -> None: # Repair adds the roof (true +4) to reach 47, paying for the ventilation # penalty out of the budget the dependency's cost has already eaten into. groups: list[list[ScoredOption]] = [ - [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)], + [scored_option("loft_insulation", gain=0.0, cost=1000.0, overlay=ROOF_OVERLAY)], ] - scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) # Act package: OptimisedPackage = optimise_package( @@ -672,12 +531,12 @@ def test_injected_ventilation_penalty_drives_extra_repair() -> None: baseline_epc=build_epc(), budget=5000.0, target_sap=46.0, - dependencies=[_ventilation_dependency(cost=900.0)], + dependencies=[ventilation_dependency(cost=900.0)], ) # Assert — repair pulled the roof in to clear the target net of ventilation: # 40 + 5 wall − 2 vent + 4 roof = 47. - assert _selected_types(package.selected) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "loft_insulation", "mechanical_ventilation", diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py index 1f7dce7c2..b58e92e12 100644 --- a/tests/domain/modelling/test_optimiser_fabric_first.py +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -12,129 +12,67 @@ from __future__ import annotations from typing import Sequence -from datatypes.epc.domain.epc_property_data import ( - BuildingPartIdentifier, - EpcPropertyData, -) +from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.measure_type import MeasureType from domain.modelling.optimisation.optimiser import ( - MeasureDependency, OptimisedPackage, ScoredOption, optimise_package_fabric_first, ) -from domain.modelling.recommendation import Cost, MeasureOption from domain.modelling.scoring.package_scorer import Score -from domain.modelling.simulation import ( - BuildingPartOverlay, - EpcSimulation, - GlazingOverlay, - HeatingOverlay, - VentilationOverlay, +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, ) -_WALL_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) - } +_AIRTIGHTNESS_TRIGGERS: frozenset[MeasureType] = frozenset( + {MeasureType.CAVITY_WALL_INSULATION, MeasureType.DOUBLE_GLAZING} ) -_ROOF_OVERLAY = EpcSimulation( - building_parts={ - BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300) - } -) -_HEATING_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201)) -_GLAZING_OVERLAY = EpcSimulation(glazing=GlazingOverlay(glazing_type=2)) -_BOILER_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201)) -_ASHP_OVERLAY = EpcSimulation( - heating=HeatingOverlay(main_heating_index_number=13000) -) - - -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 _StubScorer: - """Deterministic stand-in for PackageScorer: the package SAP is a base plus - a fixed true gain per overlay kind present (wall / roof / heating), so the - two-phase selection is exercised without the calculator.""" - - def __init__( - self, *, base: float, wall: float, roof: float, heating: float - ) -> None: - self._base = base - self._wall = wall - self._roof = roof - self._heating = heating - - def score( - self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] - ) -> Score: - sap = self._base - for sim in simulations: - if sim.heating is not None: - sap += self._heating - for part in sim.building_parts.values(): - if part.wall_insulation_type is not None: - sap += self._wall - if part.roof_insulation_thickness is not None: - sap += self._roof - return Score( - sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0 - ) - - -def _selected_types(package: OptimisedPackage) -> set[str]: - return {scored.option.measure_type for scored in package.selected} def test_fabric_reaching_the_target_excludes_non_fabric_measures() -> None: - # Arrange — the ASHP dominates on both gain and SAP-per-£ (a plain + # 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("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored("air_source_heat_pump", gain=30.0, cost=500.0, overlay=_HEATING_OVERLAY)], + [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=10.0, roof=0.0, heating=30.0) + 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=10000.0, + budget=15000.0, target_sap=69.0, ) - # Assert — fabric only: the wall (true 70 ≥ 69); the heat pump is never + # Assert — fabric only: the wall (true 72 ≥ 69); the boiler is never # considered because the upgrade requirement is already met. - assert _selected_types(package) == {"cavity_wall_insulation"} - assert abs(package.score.sap_continuous - 70.0) <= 1e-9 + 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("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=_HEATING_OVERLAY)], + [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, roof=0.0, heating=20.0) + 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( @@ -147,7 +85,7 @@ def test_fabric_short_of_target_is_topped_up_with_non_fabric_measures() -> None: # 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) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "air_source_heat_pump", } @@ -159,10 +97,10 @@ def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None: # 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("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=_HEATING_OVERLAY)], + [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, roof=0.0, heating=20.0) + scorer = StubScorer(base=60.0, wall=5.0, heating=20.0) # Act — target 78 (gain 18). package: OptimisedPackage = optimise_package_fabric_first( @@ -174,15 +112,10 @@ def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None: ) # Assert — wall only; the target is missed rather than the fabric skipped. - assert _selected_types(package) == {"cavity_wall_insulation"} + assert selected_types(package.selected) == {"cavity_wall_insulation"} assert abs(package.score.sap_continuous - 65.0) <= 1e-9 -_VENT_OVERLAY = EpcSimulation( - ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE") -) - - 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 @@ -200,44 +133,24 @@ class _AirtightnessScorer: 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 - sap += 5.0 if wall else 0.0 - sap += (4.0 if wall else 0.0) if glazing else 0.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 _airtightness_ventilation_dependency(*, cost: float) -> MeasureDependency: - """A forced 'airtightness requires ventilation' edge: both the wall and - the sealed new glazing trigger the same mechanical ventilation.""" - return MeasureDependency( - triggers=frozenset( - { - MeasureType.CAVITY_WALL_INSULATION, - MeasureType.DOUBLE_GLAZING, - } - ), - 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 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("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_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() @@ -249,7 +162,9 @@ def test_ventilation_dependency_is_injected_once_across_both_phases() -> None: baseline_epc=build_epc(), budget=10000.0, target_sap=68.0, - dependencies=[_airtightness_ventilation_dependency(cost=300.0)], + dependencies=[ + ventilation_dependency(cost=300.0, triggers=_AIRTIGHTNESS_TRIGGERS) + ], ) # Assert — one ventilation, and the truthful total counts its penalty once: @@ -260,7 +175,7 @@ def test_ventilation_dependency_is_injected_once_across_both_phases() -> None: if scored.option.measure_type == MeasureType.MECHANICAL_VENTILATION ) assert ventilation_count == 1 - assert _selected_types(package) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "double_glazing", "mechanical_ventilation", @@ -273,9 +188,9 @@ def test_no_fabric_candidates_proceeds_straight_to_the_full_pool() -> None: # survives generation); fabric first must not veto the run, it just means # phase 1 has nothing to do. groups: list[list[ScoredOption]] = [ - [_scored("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=_HEATING_OVERLAY)], + [scored_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)], ] - scorer = _StubScorer(base=60.0, wall=5.0, roof=0.0, heating=20.0) + scorer = StubScorer(base=60.0, heating=20.0) # Act — target 75. package: OptimisedPackage = optimise_package_fabric_first( @@ -287,7 +202,7 @@ def test_no_fabric_candidates_proceeds_straight_to_the_full_pool() -> None: ) # Assert — the heat pump package, exactly as a plain run would produce. - assert _selected_types(package) == {"air_source_heat_pump"} + assert selected_types(package.selected) == {"air_source_heat_pump"} assert abs(package.score.sap_continuous - 80.0) <= 1e-9 @@ -296,10 +211,10 @@ def test_without_a_target_fabric_still_gets_first_claim_on_the_budget() -> None: # 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("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], - [_scored("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=_HEATING_OVERLAY)], + [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, roof=0.0, heating=20.0) + 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( @@ -311,7 +226,7 @@ def test_without_a_target_fabric_still_gets_first_claim_on_the_budget() -> None: ) # Assert — wall first; the heat pump no longer fits the leftover £7000. - assert _selected_types(package) == {"cavity_wall_insulation"} + assert selected_types(package.selected) == {"cavity_wall_insulation"} assert abs(package.score.sap_continuous - 65.0) <= 1e-9 @@ -359,9 +274,12 @@ class _GlazingInteractionScorer: 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 + 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 ) @@ -372,9 +290,9 @@ def test_fabric_unpicked_in_phase_one_can_reenter_phase_two() -> None: # 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)], + [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() @@ -390,7 +308,7 @@ def test_fabric_unpicked_in_phase_one_can_reenter_phase_two() -> None: # Assert — the skipped glazing re-enters on its post-fabric worth: 60 + 5 # wall + 4 glazing = 69, target met. - assert _selected_types(package) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "double_glazing", } @@ -403,10 +321,10 @@ def test_phase_two_values_candidates_against_the_post_fabric_dwelling() -> None: # 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("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)], [ - _scored("gas_boiler_upgrade", gain=10.0, cost=2000.0, overlay=_BOILER_OVERLAY), - _scored("air_source_heat_pump", gain=8.0, cost=6000.0, overlay=_ASHP_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() @@ -424,7 +342,7 @@ def test_phase_two_values_candidates_against_the_post_fabric_dwelling() -> None: ) # Assert - assert _selected_types(package) == { + assert selected_types(package.selected) == { "cavity_wall_insulation", "air_source_heat_pump", }