Model/domain/modelling/considered_measures.py
Khalim Conn-Kowlessar 3580d059ec 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>
2026-06-16 15:26:25 +00:00

56 lines
2.1 KiB
Python

"""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 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]],
) -> 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