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