feat(modelling): considered_measures allowlist on the orchestrator

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 20:32:11 +00:00
parent 9ef97be958
commit 7942a8101a
5 changed files with 196 additions and 11 deletions

View file

@ -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

View file

@ -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)]

View file

@ -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(

View file

@ -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]

View file

@ -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}