Model/infrastructure/postgres/modelling/scenario_table.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

118 lines
5.3 KiB
Python

from __future__ import annotations
from datetime import datetime
from typing import ClassVar, Optional
from sqlalchemy import Column, TIMESTAMP
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``
enum (legacy planning branches on it, so it must stay an enum — the stored
string is the enum *value*, e.g. ``"Increasing EPC"``).
Only ``goal`` / ``goal_value`` are required; everything else is nullable
(mirror convention — the live NOT-NULLs are owned by the Drizzle schema),
so the Modelling stage can construct the thin slice it uses while the legacy
writers still supply the full row.
"""
__tablename__: ClassVar[str] = "scenario" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
name: Optional[str] = Field(default=None)
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column(TIMESTAMP, nullable=False, server_default=func.now()),
)
budget: Optional[float] = Field(default=None)
portfolio_id: Optional[int] = Field(default=None)
housing_type: Optional[str] = Field(default=None)
goal: PortfolioGoal = Field(
sa_column=Column(
SAEnum(
PortfolioGoal,
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
name="goal",
),
nullable=False,
)
)
goal_value: str
trigger_file_path: Optional[str] = Field(default=None)
already_installed_file_path: Optional[str] = Field(default=None)
patches_file_path: Optional[str] = Field(default=None)
non_invasive_recommendations_file_path: Optional[str] = Field(default=None)
exclusions: Optional[str] = Field(default=None)
multi_plan: bool = False
is_default: bool = False
# Portfolio-level aggregates stored against the Scenario.
cost: Optional[float] = Field(default=None)
contingency: Optional[float] = Field(default=None)
funding: Optional[float] = Field(default=None)
total_work_hours: Optional[float] = Field(default=None)
energy_savings: Optional[float] = Field(default=None)
co2_equivalent_savings: Optional[float] = Field(default=None)
energy_cost_savings: Optional[float] = Field(default=None)
epc_breakdown_pre_retrofit: Optional[str] = Field(default=None)
epc_breakdown_post_retrofit: Optional[str] = Field(default=None)
number_of_properties: Optional[int] = Field(default=None)
n_units_to_retrofit: Optional[int] = Field(default=None)
co2_per_unit_pre_retrofit: Optional[str] = Field(default=None)
co2_per_unit_post_retrofit: Optional[str] = Field(default=None)
energy_bill_per_unit_pre_retrofit: Optional[str] = Field(default=None)
energy_bill_per_unit_post_retrofit: Optional[str] = Field(default=None)
energy_consumption_per_unit_pre_retrofit: Optional[str] = Field(default=None)
energy_consumption_per_unit_post_retrofit: Optional[str] = Field(default=None)
valuation_improvement_per_unit: Optional[str] = Field(default=None)
cost_per_unit: Optional[str] = Field(default=None)
cost_per_co2_saved: Optional[str] = Field(default=None)
cost_per_sap_point: Optional[str] = Field(default=None)
valuation_return_on_investment: Optional[str] = Field(default=None)
property_valuation_increase: Optional[float] = Field(default=None)
labour_days: Optional[float] = Field(default=None)
def to_domain(self) -> Scenario:
if self.id is None:
raise ValueError("scenario row has no id")
return Scenario(
id=self.id,
goal=self.goal.value,
goal_value=self.goal_value,
budget=self.budget,
is_default=self.is_default,
exclusions=_parse_exclusions(self.exclusions),
)