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>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 22:52:35 +00:00
parent 2fbd7147b7
commit c18968ba3c
8 changed files with 225 additions and 215 deletions

View file

@ -1,168 +1,41 @@
"""Re-export shim + remaining legacy models (ADR-0017 amendment).
"""Re-export shim (ADR-0017 amendment).
`plan`, `recommendation`, `recommendation_materials` and the retiring
`plan_recommendations` moved to `infrastructure/postgres/modelling/` as single
SQLModel definitions (the `epc_property` pattern). This module re-exports them
under their legacy names so the dying `backend/` callers keep working; new code
imports from `infrastructure.postgres.modelling` directly. `ScenarioModel` and
`InstalledMeasure` are not yet migrated and stay here for now.
The Modelling-stage persistence models `plan`, `recommendation`,
`recommendation_materials`, `scenario`, `installed_measure` moved to
`infrastructure/postgres/modelling/` as single SQLModel definitions (the
`epc_property` pattern). This module re-exports them under their legacy names so
the dying `backend/` callers keep working; new code imports from
`infrastructure.postgres.modelling` directly. The `plan_recommendations` m2m is
retired measures link to their Plan via `recommendation.plan_id`.
"""
import enum
from typing import Iterable, List, NamedTuple, Optional, Type
from sqlalchemy import (
BigInteger,
String,
Float,
Boolean,
TIMESTAMP,
ForeignKey,
Column,
Enum,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from datetime import datetime
from backend.app.db.base import Base
from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel
from typing import NamedTuple
from infrastructure.postgres.modelling import (
InstalledMeasureModel,
PlanModel,
PlanType,
RecommendationMaterialModel,
RecommendationModel,
ScenarioModel,
)
# Legacy names → the single SQLModel definitions now in
# `infrastructure/postgres/modelling/`. The `plan_recommendations` m2m is
# retired (ADR-0017 amendment) — measures link to their Plan via
# `recommendation.plan_id`.
# `infrastructure/postgres/modelling/`.
Recommendation = RecommendationModel
RecommendationMaterials = RecommendationMaterialModel
PlanTypeEnum = PlanType
InstalledMeasure = InstalledMeasureModel
def portfolio_goal_values(enum_cls: Type[PortfolioGoal]) -> List[str]:
return [e.value for e in enum_cls]
class ScenarioModel(Base):
__tablename__ = "scenario"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP, nullable=False, server_default=func.now()
)
budget: Mapped[Optional[float]] = mapped_column(Float)
portfolio_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey(Portfolio.id), nullable=False
)
housing_type: Mapped[str] = mapped_column(String, nullable=False)
goal: Mapped[PortfolioGoal] = mapped_column(
Enum(PortfolioGoal, values_callable=portfolio_goal_values, name="goal"),
nullable=False,
)
goal_value: Mapped[str] = mapped_column(String, nullable=False)
trigger_file_path: Mapped[str] = mapped_column(String, nullable=False)
already_installed_file_path: Mapped[Optional[str]] = mapped_column(String)
patches_file_path: Mapped[Optional[str]] = mapped_column(String)
non_invasive_recommendations_file_path: Mapped[Optional[str]] = mapped_column(
String
)
exclusions: Mapped[Optional[str]] = mapped_column(String)
multi_plan: Mapped[bool] = mapped_column(Boolean, default=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Add in the fields we need, which were previously sitting at the portfolio level
cost: Mapped[Optional[float]] = mapped_column(Float)
contingency: Mapped[Optional[float]] = mapped_column(Float)
funding: Mapped[Optional[float]] = mapped_column(Float)
total_work_hours: Mapped[Optional[float]] = mapped_column(Float)
energy_savings: Mapped[Optional[float]] = mapped_column(Float)
co2_equivalent_savings: Mapped[Optional[float]] = mapped_column(Float)
energy_cost_savings: Mapped[Optional[float]] = mapped_column(Float)
epc_breakdown_pre_retrofit: Mapped[Optional[str]] = mapped_column(String)
epc_breakdown_post_retrofit: Mapped[Optional[str]] = mapped_column(String)
number_of_properties: Mapped[Optional[int]] = mapped_column(BigInteger)
n_units_to_retrofit: Mapped[Optional[int]] = mapped_column(BigInteger)
co2_per_unit_pre_retrofit: Mapped[Optional[str]] = mapped_column(String)
co2_per_unit_post_retrofit: Mapped[Optional[str]] = mapped_column(String)
energy_bill_per_unit_pre_retrofit: Mapped[Optional[str]] = mapped_column(String)
energy_bill_per_unit_post_retrofit: Mapped[Optional[str]] = mapped_column(String)
energy_consumption_per_unit_pre_retrofit: Mapped[Optional[str]] = mapped_column(
String
)
energy_consumption_per_unit_post_retrofit: Mapped[Optional[str]] = mapped_column(
String
)
valuation_improvement_per_unit: Mapped[Optional[str]] = mapped_column(String)
cost_per_unit: Mapped[Optional[str]] = mapped_column(String)
cost_per_co2_saved: Mapped[Optional[str]] = mapped_column(String)
cost_per_sap_point: Mapped[Optional[str]] = mapped_column(String)
valuation_return_on_investment: Mapped[Optional[str]] = mapped_column(String)
property_valuation_increase: Mapped[Optional[float]] = mapped_column(Float)
labour_days: Mapped[Optional[float]] = mapped_column(Float)
class MeasureType(enum.Enum):
air_source_heat_pump = "air_source_heat_pump"
boiler_upgrade = "boiler_upgrade"
high_heat_retention_storage_heaters = "high_heat_retention_storage_heaters"
secondary_heating = "secondary_heating"
roomstat_programmer_trvs = "roomstat_programmer_trvs"
time_temperature_zone_control = "time_temperature_zone_control"
cylinder_thermostat = "cylinder_thermostat"
cavity_wall_insulation = "cavity_wall_insulation"
extension_cavity_wall_insulation = "extension_cavity_wall_insulation"
external_wall_insulation = "external_wall_insulation"
internal_wall_insulation = "internal_wall_insulation"
loft_insulation = "loft_insulation"
flat_roof_insulation = "flat_roof_insulation"
room_roof_insulation = "room_roof_insulation"
solid_floor_insulation = "solid_floor_insulation"
suspended_floor_insulation = "suspended_floor_insulation"
double_glazing = "double_glazing"
secondary_glazing = "secondary_glazing"
draught_proofing = "draught_proofing"
mechanical_ventilation = "mechanical_ventilation"
low_energy_lighting = "low_energy_lighting"
solar_pv = "solar_pv"
hot_water_tank_insulation = "hot_water_tank_insulation"
sealing_open_fireplace = "sealing_open_fireplace"
class InstalledMeasure(Base):
__tablename__ = "installed_measure"
id = Column(BigInteger, primary_key=True, autoincrement=True)
uprn = Column(BigInteger, nullable=False)
measure_type = Column(
Enum(
MeasureType,
name="measure_type",
values_callable=lambda e: [m.value for m in e],
create_type=False, # <-- critical
),
nullable=False,
)
installed_at = Column(TIMESTAMP)
sap_points = Column(Float)
carbon_savings = Column(Float)
kwh_savings = Column(Float)
bill_savings = Column(Float)
heat_demand_savings = Column(Float)
source = Column(String)
is_active = Column(Boolean, nullable=False, default=True)
def enum_values(e: Iterable[PlanType]) -> list[str]:
return [m.value for m in e]
__all__ = [
"PlanModel",
"ScenarioModel",
"Recommendation",
"RecommendationMaterials",
"InstalledMeasure",
"PlanTypeEnum",
"PlanPersistence",
]
class PlanPersistence(NamedTuple):

View file

@ -2,10 +2,10 @@
(ADR-0017 amendment).
One canonical SQLModel per physical table `plan`, `recommendation`,
`recommendation_materials` replacing the legacy SQLAlchemy `Base` models in
`backend/app/db/models/recommendations.py` (now a re-export shim, the
`epc_property` pattern). `recommendation` carries `plan_id`; the
`plan_recommendations` m2m is retired.
`recommendation_materials`, `scenario`, `installed_measure` replacing the
legacy SQLAlchemy `Base` models in `backend/app/db/models/recommendations.py`
(now a re-export shim, the `epc_property` pattern). `recommendation` carries
`plan_id`; the `plan_recommendations` m2m is retired.
"""
from infrastructure.postgres.modelling.plan_table import PlanModel, PlanType
@ -13,10 +13,18 @@ from infrastructure.postgres.modelling.recommendation_table import (
RecommendationMaterialModel,
RecommendationModel,
)
from infrastructure.postgres.modelling.scenario_table import ScenarioModel
from infrastructure.postgres.modelling.installed_measure_table import (
InstalledMeasureModel,
MeasureType,
)
__all__ = [
"PlanModel",
"PlanType",
"RecommendationModel",
"RecommendationMaterialModel",
"ScenarioModel",
"InstalledMeasureModel",
"MeasureType",
]

View file

@ -0,0 +1,73 @@
from __future__ import annotations
import enum
from datetime import datetime
from typing import ClassVar, Optional
from sqlalchemy import Column, TIMESTAMP
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, SQLModel
class MeasureType(enum.Enum):
air_source_heat_pump = "air_source_heat_pump"
boiler_upgrade = "boiler_upgrade"
high_heat_retention_storage_heaters = "high_heat_retention_storage_heaters"
secondary_heating = "secondary_heating"
roomstat_programmer_trvs = "roomstat_programmer_trvs"
time_temperature_zone_control = "time_temperature_zone_control"
cylinder_thermostat = "cylinder_thermostat"
cavity_wall_insulation = "cavity_wall_insulation"
extension_cavity_wall_insulation = "extension_cavity_wall_insulation"
external_wall_insulation = "external_wall_insulation"
internal_wall_insulation = "internal_wall_insulation"
loft_insulation = "loft_insulation"
flat_roof_insulation = "flat_roof_insulation"
room_roof_insulation = "room_roof_insulation"
solid_floor_insulation = "solid_floor_insulation"
suspended_floor_insulation = "suspended_floor_insulation"
double_glazing = "double_glazing"
secondary_glazing = "secondary_glazing"
draught_proofing = "draught_proofing"
mechanical_ventilation = "mechanical_ventilation"
low_energy_lighting = "low_energy_lighting"
solar_pv = "solar_pv"
hot_water_tank_insulation = "hot_water_tank_insulation"
sealing_open_fireplace = "sealing_open_fireplace"
class InstalledMeasureModel(SQLModel, table=True):
"""The single SQLModel definition of the live ``installed_measure`` table
(ADR-0017 amendment). ``measure_type`` is the ``MeasureType`` Postgres enum;
the remaining NOT-NULLs are relaxed to nullable (mirror convention the
live constraints are owned by the Drizzle schema)."""
__tablename__: ClassVar[str] = "installed_measure" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
uprn: Optional[int] = Field(default=None, index=True)
measure_type: MeasureType = Field(
sa_column=Column(
SAEnum(
MeasureType,
name="measure_type",
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
create_type=False,
),
nullable=False,
)
)
installed_at: Optional[datetime] = Field(
default=None, sa_column=Column(TIMESTAMP, nullable=True)
)
sap_points: Optional[float] = Field(default=None)
carbon_savings: Optional[float] = Field(default=None)
kwh_savings: Optional[float] = Field(default=None)
bill_savings: Optional[float] = Field(default=None)
heat_demand_savings: Optional[float] = Field(default=None)
source: Optional[str] = Field(default=None)
is_active: bool = True

View file

@ -0,0 +1,92 @@
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,
)

View file

@ -1,41 +0,0 @@
from __future__ import annotations
from typing import ClassVar, Optional
from sqlmodel import Field, SQLModel
from domain.modelling.scenario import Scenario
class ScenarioRow(SQLModel, table=True):
"""SQLModel mirror of the live ``scenario`` table (ADR-0017).
Declares only the columns the Modelling stage reads the legacy
file-path columns (`trigger_file_path`, `exclusions`, ) and the
portfolio-level aggregates are left to the legacy SQLAlchemy model
(`backend/app/db/models/recommendations.py::ScenarioModel`), which still
owns the live reads. The physical table is the shared contract; this
mirror is read-only from the rebuild's side.
`goal` is a Postgres enum in production; mapped here as its string value
(the Modelling stage does not yet branch on it #1160).
"""
__tablename__: ClassVar[str] = "scenario" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
goal: str
goal_value: str
budget: Optional[float] = Field(default=None)
is_default: bool = Field(default=False)
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,
goal_value=self.goal_value,
budget=self.budget,
is_default=self.is_default,
)

View file

@ -3,12 +3,12 @@ from __future__ import annotations
from sqlmodel import Session, col, select
from domain.modelling.scenario import Scenario
from infrastructure.postgres.scenario_table import ScenarioRow
from infrastructure.postgres.modelling import ScenarioModel
from repositories.scenario.scenario_repository import ScenarioRepository
class ScenarioPostgresRepository(ScenarioRepository):
"""Reads the live ``scenario`` table (via the ``ScenarioRow`` mirror) and
"""Reads the live ``scenario`` table (via the ``ScenarioModel`` mirror) and
maps each row to the thin domain ``Scenario`` the Modelling stage uses
(ADR-0017). The legacy file-path / aggregate columns are not read."""
@ -17,9 +17,9 @@ class ScenarioPostgresRepository(ScenarioRepository):
def get_many(self, scenario_ids: list[int]) -> list[Scenario]:
rows = self._session.exec(
select(ScenarioRow).where(col(ScenarioRow.id).in_(scenario_ids))
select(ScenarioModel).where(col(ScenarioModel.id).in_(scenario_ids))
).all()
by_id: dict[int, ScenarioRow] = {
by_id: dict[int, ScenarioModel] = {
row.id: row for row in rows if row.id is not None
}
scenarios: list[Scenario] = []

View file

@ -20,7 +20,8 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.property_baseline.rebaseliner import StubRebaseliner
from domain.sap10_calculator.calculator import Sap10Calculator
from infrastructure.postgres.scenario_table import ScenarioRow
from domain.modelling.portfolio_goal import PortfolioGoal
from infrastructure.postgres.modelling import ScenarioModel
from domain.geospatial.coordinates import Coordinates
from infrastructure.postgres.property_baseline_performance_table import (
PropertyBaselinePerformanceModel,
@ -110,8 +111,8 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
# Modelling now runs for real: it reads scenario 7 (the command's
# scenario_ids) through the repo, so the row must exist.
session.add(
ScenarioRow(
id=7, goal="Increasing EPC", goal_value="C", is_default=True
ScenarioModel(
id=7, goal=PortfolioGoal.INCREASING_EPC, goal_value="C", is_default=True
)
)
# The sample EPC's solid floor is uninsulated, so the floor generator
@ -214,8 +215,8 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan(
)
)
session.add(
ScenarioRow(
id=7, goal="Increasing EPC", goal_value="C", is_default=True
ScenarioModel(
id=7, goal=PortfolioGoal.INCREASING_EPC, goal_value="C", is_default=True
)
)
session.add_all(
@ -356,8 +357,8 @@ def test_modelling_recommends_nothing_when_already_at_the_target_band(
)
)
session.add(
ScenarioRow(
id=8, goal="Increasing EPC", goal_value="D", is_default=True
ScenarioModel(
id=8, goal=PortfolioGoal.INCREASING_EPC, goal_value="D", is_default=True
)
)
# The fabric Generators + the ventilation dependency builder still run

View file

@ -14,8 +14,9 @@ import pytest
from sqlalchemy import Engine
from sqlmodel import Session
from domain.modelling.portfolio_goal import PortfolioGoal
from domain.modelling.scenario import Scenario
from infrastructure.postgres.scenario_table import ScenarioRow
from infrastructure.postgres.modelling import ScenarioModel
from repositories.scenario.scenario_postgres_repository import (
ScenarioPostgresRepository,
)
@ -27,18 +28,18 @@ def test_get_many_maps_live_scenario_rows_to_domain_in_input_order(
# Arrange
with Session(db_engine) as session:
session.add(
ScenarioRow(
ScenarioModel(
id=7,
goal="INCREASING_EPC",
goal=PortfolioGoal.INCREASING_EPC,
goal_value="C",
budget=15000.0,
is_default=True,
)
)
session.add(
ScenarioRow(
ScenarioModel(
id=9,
goal="INCREASING_EPC",
goal=PortfolioGoal.INCREASING_EPC,
goal_value="B",
budget=None,
is_default=False,
@ -52,14 +53,14 @@ def test_get_many_maps_live_scenario_rows_to_domain_in_input_order(
[9, 7]
)
# Assert
# Assert — to_domain maps the PortfolioGoal enum to its value string
assert [s.id for s in scenarios] == [9, 7] # input order preserved
assert scenarios[0] == Scenario(
id=9, goal="INCREASING_EPC", goal_value="B", budget=None, is_default=False
id=9, goal="Increasing EPC", goal_value="B", budget=None, is_default=False
)
assert scenarios[1] == Scenario(
id=7,
goal="INCREASING_EPC",
goal="Increasing EPC",
goal_value="C",
budget=15000.0,
is_default=True,
@ -70,8 +71,11 @@ def test_get_many_raises_when_a_scenario_id_is_missing(db_engine: Engine) -> Non
# Arrange
with Session(db_engine) as session:
session.add(
ScenarioRow(
id=7, goal="INCREASING_EPC", goal_value="C", is_default=True
ScenarioModel(
id=7,
goal=PortfolioGoal.INCREASING_EPC,
goal_value="C",
is_default=True,
)
)
session.commit()