From 7942a8101a25bb9add1a599347e69308e7b83c52 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 8 Jun 2026 20:32:11 +0000 Subject: [PATCH] feat(modelling): considered_measures allowlist on the orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add domain/modelling/considered_measures.py::restrict_to_considered_measures — the pure allowlist that limits a run to a chosen set of MeasureType (mirroring the legacy engine's `inclusions`). It filters at the Option level, so a multi-option Recommendation (e.g. Heating & Hot Water competing HHRSH against an ASHP bundle) is kept with only its allowed Options; a Recommendation left with none is dropped. None = consider everything (unrestricted default). Thread `considered_measures: frozenset[MeasureType] | None` through ModellingOrchestrator.run -> _plan_for -> _scored_candidate_groups / _candidate_recommendations (applies the filter) and _measure_dependencies (suppresses a forced dependency whose required measure is outside the allowlist, so a restricted run forces nothing it is not considering). The local-run seam (harness.console.run_modelling) gains the same param. The Optimiser still freely chooses among survivors — including none. Tests: the pure filter (3 cases) + an orchestrator-seam test proving a {solar_pv}-restricted run yields only solar_pv options. 257 pass + 3 xfail; pyright clean. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/considered_measures.py | 42 ++++++++++ harness/console.py | 3 + orchestration/modelling_orchestrator.py | 52 ++++++++++-- .../modelling/test_considered_measures.py | 83 +++++++++++++++++++ .../test_modelling_solar_threading.py | 27 +++++- 5 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 domain/modelling/considered_measures.py create mode 100644 tests/domain/modelling/test_considered_measures.py diff --git a/domain/modelling/considered_measures.py b/domain/modelling/considered_measures.py new file mode 100644 index 00000000..530ca053 --- /dev/null +++ b/domain/modelling/considered_measures.py @@ -0,0 +1,42 @@ +"""Restricting a modelling run to a chosen set of measure types. + +The allowlist a run "considers" — mirroring the legacy engine's `inclusions` +(`backend/app/plan/schemas.py`). It filters the candidate Recommendations at the +Option level so a multi-option Recommendation (e.g. Heating & Hot Water competing +HHRSH against an ASHP bundle) is kept with only its allowed Options; a +Recommendation left with no allowed Option is dropped. The Optimiser still +freely chooses among what survives — including choosing nothing. + +A `None` allowlist means "consider every modelled measure" (the unrestricted +default). +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Optional + +from domain.modelling.measure_type import MeasureType +from domain.modelling.recommendation import Recommendation + + +def restrict_to_considered_measures( + recommendations: Iterable[Recommendation], + considered_measures: Optional[frozenset[MeasureType]], +) -> list[Recommendation]: + """Keep only the Options whose measure type is in ``considered_measures``, + dropping any Recommendation left with none. ``None`` keeps everything.""" + if considered_measures is None: + return list(recommendations) + restricted: list[Recommendation] = [] + for recommendation in recommendations: + kept = tuple( + option + for option in recommendation.options + if option.measure_type in considered_measures + ) + if kept: + restricted.append( + Recommendation(surface=recommendation.surface, options=kept) + ) + return restricted diff --git a/harness/console.py b/harness/console.py index dd99f074..f1b9675f 100644 --- a/harness/console.py +++ b/harness/console.py @@ -23,6 +23,7 @@ from typing import Any, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.geospatial.coordinates import Coordinates from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.modelling.measure_type import MeasureType from domain.modelling.plan import Plan from domain.modelling.scenario import Scenario from domain.property.property import Property, PropertyIdentity @@ -171,6 +172,7 @@ def run_modelling( current_market_value: Optional[float] = None, planning_restrictions: PlanningRestrictions = PlanningRestrictions(), solar_insights: Optional[dict[str, Any]] = None, + considered_measures: Optional[frozenset[MeasureType]] = None, print_table: bool = True, ) -> Plan: """Run ONLY the Modelling stage over ``epc`` with no database — skipping @@ -229,6 +231,7 @@ def run_modelling( property_ids=[_PROPERTY_ID], scenario_ids=[_SCENARIO_ID], portfolio_id=_PORTFOLIO_ID, + considered_measures=considered_measures, ) plan = plan_repo.saved[(_PROPERTY_ID, _SCENARIO_ID)] diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 40e29696..01428242 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -7,7 +7,9 @@ from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.billing.bill import Bill, EnergyBreakdown from domain.billing.bill_derivation import BillDerivation +from domain.modelling.considered_measures import restrict_to_considered_measures from domain.modelling.generators.floor_recommendation import recommend_floor_insulation +from domain.modelling.measure_type import MeasureType from domain.modelling.optimisation.measure_dependency import ventilation_dependency from domain.modelling.optimisation.optimiser import ( MeasureDependency, @@ -92,8 +94,16 @@ class ModellingOrchestrator: self._fuel_rates = fuel_rates def run( - self, property_ids: list[int], scenario_ids: list[int], portfolio_id: int + self, + property_ids: list[int], + scenario_ids: list[int], + portfolio_id: int, + *, + considered_measures: Optional[frozenset[MeasureType]] = None, ) -> None: + """Model the batch. ``considered_measures`` restricts the run to those + measure types (mirroring the legacy `inclusions`); None considers every + modelled measure.""" scorer = PackageScorer(self._calculator) # Resolve Fuel Rates once and reuse the BillDerivation across the batch, # so every baseline/post bill is priced at the same snapshot (ADR-0014). @@ -120,6 +130,7 @@ class ModellingOrchestrator: current_market_value=prop.current_market_value, planning_restrictions=prop.planning_restrictions, solar_potential=solar_potential, + considered_measures=considered_measures, ) uow.plan.save( plan, @@ -141,16 +152,22 @@ class ModellingOrchestrator: current_market_value: Optional[float], planning_restrictions: PlanningRestrictions, solar_potential: Optional[SolarPotential], + considered_measures: Optional[frozenset[MeasureType]], ) -> Plan: """Generate → score → optimise → re-score/repair → attribute → bill → assemble the Plan for one Property + Scenario.""" groups: list[list[ScoredOption]] = _scored_candidate_groups( - scorer, effective_epc, products, planning_restrictions, solar_potential + scorer, + effective_epc, + products, + planning_restrictions, + solar_potential, + considered_measures, ) # Forced Measure Dependencies (ventilation) are excluded from the pool # but injected into the package before the re-score (ADR-0016). dependencies: list[MeasureDependency] = _measure_dependencies( - effective_epc, products + effective_epc, products, considered_measures ) package: OptimisedPackage = optimise_package( groups=groups, @@ -224,11 +241,13 @@ def _candidate_recommendations( products: ProductRepository, planning_restrictions: PlanningRestrictions, solar_potential: Optional[SolarPotential], + considered_measures: Optional[frozenset[MeasureType]], ) -> list[Recommendation]: """Run every Recommendation Generator; keep the ones that apply. Solid-wall insulation, glazing, heating and solar are additionally gated by the Property's planning protections (ADR-0019 / ADR-0022 / ADR-0024 / ADR-0026); - solar also needs the Property's Google solar potential.""" + solar also needs the Property's Google solar potential. ``considered_measures`` + then restricts the survivors to the run's allowlist (None = all).""" found = ( recommend_cavity_wall(effective_epc, products), recommend_solid_wall(effective_epc, products, planning_restrictions), @@ -241,19 +260,33 @@ def _candidate_recommendations( effective_epc, products, solar_potential, planning_restrictions ), ) - return [recommendation for recommendation in found if recommendation is not None] + applicable = [ + recommendation for recommendation in found if recommendation is not None + ] + return restrict_to_considered_measures(applicable, considered_measures) def _measure_dependencies( - effective_epc: EpcPropertyData, products: ProductRepository + effective_epc: EpcPropertyData, + products: ProductRepository, + considered_measures: Optional[frozenset[MeasureType]], ) -> list[MeasureDependency]: """The forced Measure Dependencies for this Property — currently just ventilation, suppressed when the dwelling is already mechanically - ventilated (ADR-0016).""" + ventilated (ADR-0016). A dependency whose required measure is outside the + run's allowlist is also suppressed, so a restricted run forces nothing it is + not considering.""" dependency: Optional[MeasureDependency] = ventilation_dependency( effective_epc, products ) - return [dependency] if dependency is not None else [] + if dependency is None: + return [] + if ( + considered_measures is not None + and dependency.required.option.measure_type not in considered_measures + ): + return [] + return [dependency] def _scored_candidate_groups( @@ -262,12 +295,13 @@ def _scored_candidate_groups( products: ProductRepository, planning_restrictions: PlanningRestrictions, solar_potential: Optional[SolarPotential], + considered_measures: Optional[frozenset[MeasureType]], ) -> list[list[ScoredOption]]: """One group per Recommendation: each Option scored independently against the baseline (role-1 warm-start signal, ADR-0016).""" groups: list[list[ScoredOption]] = [] for recommendation in _candidate_recommendations( - effective_epc, products, planning_restrictions, solar_potential + effective_epc, products, planning_restrictions, solar_potential, considered_measures ): options = list(recommendation.options) impacts: list[MeasureImpact] = independent_option_impacts( diff --git a/tests/domain/modelling/test_considered_measures.py b/tests/domain/modelling/test_considered_measures.py new file mode 100644 index 00000000..b5b5b4e7 --- /dev/null +++ b/tests/domain/modelling/test_considered_measures.py @@ -0,0 +1,83 @@ +"""Slice 3 — `restrict_to_considered_measures`, the pure allowlist that limits a +run to a chosen set of measure types (mirroring the legacy engine's +`inclusions`). + +It filters at the Option level, so a multi-option Recommendation (e.g. Heating & +Hot Water offering both HHRSH and an ASHP bundle) is kept with only its allowed +Options; a Recommendation left with no allowed Option is dropped entirely. A +None allowlist means "consider everything" (today's unrestricted behaviour). +""" + +from domain.modelling.considered_measures import restrict_to_considered_measures +from domain.modelling.measure_type import MeasureType +from domain.modelling.recommendation import MeasureOption, Recommendation +from domain.modelling.simulation import EpcSimulation + + +def _option(measure_type: MeasureType) -> MeasureOption: + return MeasureOption( + measure_type=measure_type, description=str(measure_type), overlay=EpcSimulation() + ) + + +def _heating_rec() -> Recommendation: + # Heating & Hot Water competes HHRSH against an ASHP bundle in one rec. + return Recommendation( + surface="Heating & Hot Water", + options=( + _option(MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS), + _option(MeasureType.AIR_SOURCE_HEAT_PUMP), + ), + ) + + +def _solar_rec() -> Recommendation: + return Recommendation(surface="Solar PV", options=(_option(MeasureType.SOLAR_PV),)) + + +def _wall_rec() -> Recommendation: + return Recommendation( + surface="Wall", options=(_option(MeasureType.CAVITY_WALL_INSULATION),) + ) + + +def test_none_allowlist_keeps_everything() -> None: + # Arrange + recommendations = [_heating_rec(), _solar_rec(), _wall_rec()] + + # Act + kept = restrict_to_considered_measures(recommendations, None) + + # Assert + assert kept == recommendations + + +def test_drops_recommendations_with_no_allowed_option() -> None: + # Arrange + considered = frozenset( + {MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS, MeasureType.SOLAR_PV} + ) + + # Act + kept = restrict_to_considered_measures( + [_heating_rec(), _solar_rec(), _wall_rec()], considered + ) + + # Assert — the wall rec is gone; heating + solar survive. + surfaces = {rec.surface for rec in kept} + assert surfaces == {"Heating & Hot Water", "Solar PV"} + + +def test_filters_options_within_a_kept_recommendation() -> None: + # Arrange — HHRSH is allowed but the competing ASHP bundle is not. + considered = frozenset( + {MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS, MeasureType.SOLAR_PV} + ) + + # Act + kept = restrict_to_considered_measures([_heating_rec()], considered) + + # Assert — the heating rec keeps ONLY its HHRSH option, ASHP is dropped. + assert len(kept) == 1 + kept_types = [option.measure_type for option in kept[0].options] + assert kept_types == [MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS] diff --git a/tests/orchestration/test_modelling_solar_threading.py b/tests/orchestration/test_modelling_solar_threading.py index aab5d41d..21a35742 100644 --- a/tests/orchestration/test_modelling_solar_threading.py +++ b/tests/orchestration/test_modelling_solar_threading.py @@ -15,6 +15,7 @@ from typing import Any from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.modelling.measure_type import MeasureType from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation from orchestration.modelling_orchestrator import ( @@ -95,7 +96,7 @@ def test_candidate_recommendations_includes_solar_when_potential_present() -> No # Act recommendations: list[Recommendation] = _candidate_recommendations( - epc, _StubProducts(), PlanningRestrictions(), potential + epc, _StubProducts(), PlanningRestrictions(), potential, None ) # Assert — a "Solar PV" Recommendation is among the candidates. @@ -108,8 +109,30 @@ def test_candidate_recommendations_excludes_solar_without_potential() -> None: # Act recommendations = _candidate_recommendations( - epc, _StubProducts(), PlanningRestrictions(), None + epc, _StubProducts(), PlanningRestrictions(), None, None ) # Assert assert "Solar PV" not in {r.surface for r in recommendations} + + +def test_considered_measures_restricts_candidates_to_the_allowlist() -> None: + # Arrange — a solar-eligible house, with its solar potential present, so the + # unrestricted run offers Solar PV alongside any fabric/heating candidates. + epc = _eligible_house() + potential = _solar_potential_for( + FakeSolarRepo(by_property={1: json.loads(_INSIGHTS_FIXTURE.read_text())}), 1 + ) + + # Act — restrict the run to Solar PV only. + recommendations = _candidate_recommendations( + epc, _StubProducts(), PlanningRestrictions(), potential, frozenset({MeasureType.SOLAR_PV}) + ) + + # Assert — every surviving Option is solar_pv; nothing else leaks through. + option_types = { + option.measure_type + for recommendation in recommendations + for option in recommendation.options + } + assert option_types == {MeasureType.SOLAR_PV}