Model/infrastructure/postgres/modelling/scenario_table.py
Khalim Conn-Kowlessar e75798b281 Scenario carries its Fabric First flag from the scenario table 🟩
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:44:31 +00:00

122 lines
5.5 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
# Fabric First constraint (owned by the FE Drizzle schema: boolean NOT
# NULL DEFAULT false — do not deploy this mirror before that migration).
fabric_first: 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),
fabric_first=self.fabric_first,
)