Model/infrastructure/postgres/modelling/scenario_table.py
Khalim Conn-Kowlessar c18968ba3c refactor(modelling): consolidate scenario + installed_measure into the subpackage
Move the scenario and installed_measure tables into
infrastructure/postgres/modelling/ as full-parity SQLModel definitions
(ScenarioModel, InstalledMeasureModel + MeasureType), completing the cluster
consolidation. backend/app/db/models/recommendations.py is now a pure
re-export shim.

ScenarioModel.goal is the PortfolioGoal enum (legacy planning branches on it),
sourced from domain/modelling/portfolio_goal.py; the repo's to_domain maps it to
its value string, so domain Scenario.goal is now the value ("Increasing EPC")
consistent with the orchestrator's check — fixing the latent name-vs-value
inconsistency the old str column masked (the scenario repo test stored the enum
*name*). Parity columns are nullable (mirror convention; live NOT-NULLs owned by
Drizzle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:52:35 +00:00

92 lines
4.1 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.portfolio_goal import PortfolioGoal
from domain.modelling.scenario import Scenario
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,
)