Model/domain/modelling/scenario.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

44 lines
1.7 KiB
Python

"""Scenario — the named retrofit brief the Modelling stage scores against.
Built by a user in the scenario-builder UI and persisted before any modelling
fires; the pipeline is handed only its id and reads it back via a
`ScenarioRepository`. This is the thin slice the Modelling stage uses today:
the goal + budget that the Optimiser will consume (#1160) and `is_default`
(which drives `plan.is_default`). The legacy file-path / portfolio-aggregate
columns are not modelled. Carries no phases — multi-phase is deferred
(ADR-0005). See CONTEXT.md.
"""
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.
`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