feat(modelling): drive measure scoping from the Scenario's exclusions

The measures a run considers should come from the Scenario, not a CLI flag.
The live scenario table persists exclusions only (no inclusions column), as a
Postgres text-array of exact MeasureType values.

- Scenario gains `exclusions: frozenset[MeasureType]` + `considered_measures()`
  (all measures minus the excluded ones, or None when none are excluded).
- ScenarioModel.to_domain parses the `{a,b,c}` exclusions array into
  MeasureTypes, raising on a token that is not an exact MeasureType value
  (no high-level category expansion), per the strict-enum convention.
- ModellingOrchestrator._plan_for derives the allowlist from the Scenario's
  exclusions, combined (intersection) with any explicit considered_measures
  override via the new `combine_considered_measures`.
- run_modelling_e2e sources the allowlist from the Scenario; --measures /
  --exclude-measures become optional overlays (e.g. the technical
  secondary_heating_removal exclusion the catalogue cannot yet stock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-16 15:26:25 +00:00
parent 31ced27162
commit 3580d059ec
9 changed files with 243 additions and 9 deletions

View file

@ -20,6 +20,20 @@ from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Recommendation
def combine_considered_measures(
a: Optional[frozenset[MeasureType]],
b: Optional[frozenset[MeasureType]],
) -> Optional[frozenset[MeasureType]]:
"""Intersect two allowlists, treating ``None`` as "all measures". Used to
layer an explicit override over the allowlist a Scenario's exclusions imply:
None x = x, and both present narrows to their intersection."""
if a is None:
return b
if b is None:
return a
return a & b
def restrict_to_considered_measures(
recommendations: Iterable[Recommendation],
considered_measures: Optional[frozenset[MeasureType]],

View file

@ -12,16 +12,33 @@ columns are not modelled. Carries no phases — multi-phase is deferred
from dataclasses import dataclass
from typing import Optional
from domain.modelling.measure_type import MeasureType
_NO_EXCLUSIONS: frozenset[MeasureType] = frozenset()
@dataclass(frozen=True)
class Scenario:
"""A retrofit brief: its goal, optional budget, and whether it is the
Property's default Scenario. `goal` / `goal_value` are the lodged target
(e.g. "INCREASING_EPC" band "C"); carried for the Optimiser, not yet
enforced."""
enforced.
`exclusions` are the measure types the brief bars from the run (the only
measure-scoping the live ``scenario`` table persists there is no
inclusions column). Empty means nothing is barred."""
id: int
goal: str
goal_value: str
budget: Optional[float]
is_default: bool
exclusions: frozenset[MeasureType] = _NO_EXCLUSIONS
def considered_measures(self) -> Optional[frozenset[MeasureType]]:
"""The measure-type allowlist the Scenario's exclusions imply: every
modelled measure minus the excluded ones, or None (consider every
measure) when nothing is excluded."""
if not self.exclusions:
return None
return frozenset(MeasureType) - self.exclusions

View file

@ -8,10 +8,35 @@ from sqlalchemy import Enum as SAEnum
from sqlalchemy.sql import func
from sqlmodel import Field, SQLModel
from domain.modelling.measure_type import MeasureType
from domain.modelling.portfolio_goal import PortfolioGoal
from domain.modelling.scenario import Scenario
def _parse_exclusions(raw: Optional[str]) -> frozenset[MeasureType]:
"""Parse the live ``scenario.exclusions`` column — a Postgres text-array
literal like ``{solar_pv,internal_wall_insulation}`` into the excluded
MeasureTypes. Each token must be an exact MeasureType value (no high-level
category expansion); an unknown token is a data error and raises, matching
the repo's strict-enum convention."""
if not raw:
return frozenset()
inner = raw.strip()
if inner.startswith("{") and inner.endswith("}"):
inner = inner[1:-1]
tokens = [token.strip().strip('"') for token in inner.split(",") if token.strip()]
excluded: set[MeasureType] = set()
for token in tokens:
try:
excluded.add(MeasureType(token))
except ValueError as error:
raise ValueError(
f"scenario excludes unknown measure type {token!r}; the "
f"exclusions column must hold exact MeasureType values"
) from error
return frozenset(excluded)
class ScenarioModel(SQLModel, table=True):
"""The single SQLModel definition of the live ``scenario`` table (ADR-0017
amendment). Full legacy column parity; ``goal`` is the ``PortfolioGoal``
@ -89,4 +114,5 @@ class ScenarioModel(SQLModel, table=True):
goal_value=self.goal_value,
budget=self.budget,
is_default=self.is_default,
exclusions=_parse_exclusions(self.exclusions),
)

View file

@ -7,7 +7,10 @@ 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.considered_measures import (
combine_considered_measures,
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
@ -159,18 +162,23 @@ class ModellingOrchestrator:
) -> Plan:
"""Generate → score → optimise → re-score/repair → attribute → bill →
assemble the Plan for one Property + Scenario."""
# The Scenario's own exclusions scope the run; an explicit
# ``considered_measures`` (e.g. from a harness) narrows it further.
considered: Optional[frozenset[MeasureType]] = combine_considered_measures(
scenario.considered_measures(), considered_measures
)
groups: list[list[ScoredOption]] = _scored_candidate_groups(
scorer,
effective_epc,
products,
planning_restrictions,
solar_potential,
considered_measures,
considered,
)
# 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, considered_measures
effective_epc, products, considered
)
package: OptimisedPackage = optimise_package(
groups=groups,

View file

@ -59,6 +59,9 @@ sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import
from datatypes.epc.domain.epc_property_data import EpcPropertyData # noqa: E402
from domain.geospatial.planning_restrictions import PlanningRestrictions # noqa: E402
from domain.geospatial.spatial_reference import SpatialReference # noqa: E402
from domain.modelling.considered_measures import ( # noqa: E402
combine_considered_measures,
)
from domain.modelling.measure_type import MeasureType # noqa: E402
from domain.modelling.plan import Plan, PlanMeasure # noqa: E402
from domain.modelling.recommendation import Recommendation # noqa: E402
@ -339,13 +342,15 @@ def main() -> None:
parser.add_argument(
"--measures",
default=None,
help="comma-separated measure types to consider (default: all)",
help="optional override: comma-separated measure types to consider. The "
"Scenario's exclusions already drive this; the flag narrows it further.",
)
parser.add_argument(
"--exclude-measures",
default=None,
help="comma-separated measure types to exclude (default: none) — e.g. "
"secondary_heating_removal, which the live catalogue does not yet stock",
help="optional override: comma-separated measure types to exclude on top "
"of the Scenario's own exclusions (e.g. secondary_heating_removal, which "
"the live catalogue does not yet stock)",
)
parser.add_argument(
"--portfolio-id",
@ -377,7 +382,7 @@ def main() -> None:
geospatial = GeospatialS3Repository(_s3_parquet_reader(os.environ["DATA_BUCKET"]))
solar_client = GoogleSolarApiClient(os.environ["GOOGLE_SOLAR_API_KEY"])
engine = _engine()
considered = _resolve_considered(
cli_considered = _resolve_considered(
_parse_measures(args.measures), _parse_measures(args.exclude_measures)
)
uprns = _uprns_for(engine, args.property_ids)
@ -390,6 +395,12 @@ def main() -> None:
if args.scenario_id is not None
else None
)
# The Scenario's own exclusions drive which measures the run considers; the
# --measures/--exclude-measures flags are an optional override layered on top.
considered = combine_considered_measures(
scenario.considered_measures() if scenario is not None else None,
cli_considered,
)
target = (
f"scenario {scenario.id} (band {scenario.goal_value})"

View file

@ -8,7 +8,10 @@ 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.considered_measures import (
combine_considered_measures,
restrict_to_considered_measures,
)
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation
@ -68,6 +71,18 @@ def test_drops_recommendations_with_no_allowed_option() -> None:
assert surfaces == {"Heating & Hot Water", "Solar PV"}
def test_combine_treats_none_as_all_and_intersects_two_allowlists() -> None:
# Arrange
a = frozenset({MeasureType.SOLAR_PV, MeasureType.LOFT_INSULATION})
b = frozenset({MeasureType.SOLAR_PV, MeasureType.CAVITY_WALL_INSULATION})
# Act / Assert — None means "all", so it never narrows; two sets intersect.
assert combine_considered_measures(None, b) == b
assert combine_considered_measures(a, None) == a
assert combine_considered_measures(None, None) is None
assert combine_considered_measures(a, b) == frozenset({MeasureType.SOLAR_PV})
def test_filters_options_within_a_kept_recommendation() -> None:
# Arrange — HHRSH is allowed but the competing ASHP bundle is not.
considered = frozenset(

View file

@ -0,0 +1,43 @@
"""The Scenario's measure scoping: its exclusions imply the allowlist the run
considers (the live `scenario` table persists exclusions only no inclusions)."""
from domain.modelling.measure_type import MeasureType
from domain.modelling.scenario import Scenario
def _scenario(exclusions: frozenset[MeasureType]) -> Scenario:
return Scenario(
id=1,
goal="Increasing EPC",
goal_value="C",
budget=None,
is_default=True,
exclusions=exclusions,
)
def test_no_exclusions_considers_every_measure() -> None:
# Arrange
scenario = _scenario(frozenset())
# Act
considered = scenario.considered_measures()
# Assert — None means "consider all" (the unrestricted default).
assert considered is None
def test_exclusions_imply_the_complement_allowlist() -> None:
# Arrange — exclude solar PV and ASHP.
scenario = _scenario(
frozenset({MeasureType.SOLAR_PV, MeasureType.AIR_SOURCE_HEAT_PUMP})
)
# Act
considered = scenario.considered_measures()
# Assert — every modelled measure survives except the two excluded ones.
assert considered is not None
assert MeasureType.SOLAR_PV not in considered
assert MeasureType.AIR_SOURCE_HEAT_PUMP not in considered
assert considered == frozenset(MeasureType) - scenario.exclusions

View file

@ -10,6 +10,8 @@ from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.contingencies import contingency_rate
from domain.modelling.measure_type import MeasureType
from domain.modelling.scenario import Scenario
from domain.modelling.generators.heating_recommendation import recommend_heating
from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall
from harness.console import (
@ -254,6 +256,29 @@ def test_candidate_recommendations_surface_unselected_options_with_cost() -> Non
)
def test_run_modelling_honours_a_scenarios_exclusions() -> None:
# Arrange — an electric dwelling whose optimised Plan normally leads with the
# ASHP bundle; a Scenario that excludes ASHP must keep it out of the Plan.
epc: EpcPropertyData = _electric_storage_lit_epc()
scenario = Scenario(
id=1,
goal="Increasing EPC",
goal_value="C",
budget=None,
is_default=True,
exclusions=frozenset({MeasureType.AIR_SOURCE_HEAT_PUMP}),
)
# Act — the orchestrator derives the allowlist from the Scenario's exclusions.
plan = run_modelling(epc, scenario=scenario, print_table=False)
# Assert — ASHP is excluded; the Plan still improves the dwelling via other
# measures (e.g. the HHR storage bundle).
measure_types = {measure.measure_type for measure in plan.measures}
assert MeasureType.AIR_SOURCE_HEAT_PUMP not in measure_types
assert measure_types # a non-empty Plan still came back
def test_sample_catalogue_prices_every_generator_measure_type() -> None:
# Arrange — the default offline catalogue.
products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE)

View file

@ -14,6 +14,7 @@ import pytest
from sqlalchemy import Engine
from sqlmodel import Session
from domain.modelling.measure_type import MeasureType
from domain.modelling.portfolio_goal import PortfolioGoal
from domain.modelling.scenario import Scenario
from infrastructure.postgres.modelling import ScenarioModel
@ -67,6 +68,80 @@ def test_get_many_maps_live_scenario_rows_to_domain_in_input_order(
)
def test_get_many_parses_the_exclusions_array_into_measure_types(
db_engine: Engine,
) -> None:
# Arrange — the live `exclusions` column is a Postgres text-array literal of
# exact MeasureType values.
with Session(db_engine) as session:
session.add(
ScenarioModel(
id=7,
goal=PortfolioGoal.INCREASING_EPC,
goal_value="C",
is_default=True,
exclusions="{solar_pv,internal_wall_insulation}",
)
)
session.commit()
# Act
with Session(db_engine) as session:
scenario: Scenario = ScenarioPostgresRepository(session).get_many([7])[0]
# Assert
assert scenario.exclusions == frozenset(
{MeasureType.SOLAR_PV, MeasureType.INTERNAL_WALL_INSULATION}
)
def test_get_many_treats_a_null_exclusions_column_as_no_exclusions(
db_engine: Engine,
) -> None:
# Arrange
with Session(db_engine) as session:
session.add(
ScenarioModel(
id=7,
goal=PortfolioGoal.INCREASING_EPC,
goal_value="C",
is_default=True,
exclusions=None,
)
)
session.commit()
# Act
with Session(db_engine) as session:
scenario: Scenario = ScenarioPostgresRepository(session).get_many([7])[0]
# Assert
assert scenario.exclusions == frozenset()
def test_get_many_raises_on_an_exclusion_that_is_not_a_measure_type(
db_engine: Engine,
) -> None:
# Arrange — a legacy high-level category (`heating`) is not an exact
# MeasureType value; exact-only resolution must reject it loudly.
with Session(db_engine) as session:
session.add(
ScenarioModel(
id=7,
goal=PortfolioGoal.INCREASING_EPC,
goal_value="C",
is_default=True,
exclusions="{heating}",
)
)
session.commit()
# Act / Assert
with Session(db_engine) as session:
with pytest.raises(ValueError):
ScenarioPostgresRepository(session).get_many([7])
def test_get_many_raises_when_a_scenario_id_is_missing(db_engine: Engine) -> None:
# Arrange
with Session(db_engine) as session: