mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
118 lines
5.3 KiB
Python
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),
|
|
)
|