diff --git a/backend/app/db/models/recommendations.py b/backend/app/db/models/recommendations.py index 7d03bdba..608fe346 100644 --- a/backend/app/db/models/recommendations.py +++ b/backend/app/db/models/recommendations.py @@ -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): diff --git a/infrastructure/postgres/modelling/__init__.py b/infrastructure/postgres/modelling/__init__.py index 8236549b..2e0eda4b 100644 --- a/infrastructure/postgres/modelling/__init__.py +++ b/infrastructure/postgres/modelling/__init__.py @@ -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", ] diff --git a/infrastructure/postgres/modelling/installed_measure_table.py b/infrastructure/postgres/modelling/installed_measure_table.py new file mode 100644 index 00000000..c213b5d2 --- /dev/null +++ b/infrastructure/postgres/modelling/installed_measure_table.py @@ -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 diff --git a/infrastructure/postgres/modelling/scenario_table.py b/infrastructure/postgres/modelling/scenario_table.py new file mode 100644 index 00000000..47b40b73 --- /dev/null +++ b/infrastructure/postgres/modelling/scenario_table.py @@ -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, + ) diff --git a/infrastructure/postgres/scenario_table.py b/infrastructure/postgres/scenario_table.py deleted file mode 100644 index 62756cfe..00000000 --- a/infrastructure/postgres/scenario_table.py +++ /dev/null @@ -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, - ) diff --git a/repositories/scenario/scenario_postgres_repository.py b/repositories/scenario/scenario_postgres_repository.py index 64d31553..2afa07a5 100644 --- a/repositories/scenario/scenario_postgres_repository.py +++ b/repositories/scenario/scenario_postgres_repository.py @@ -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] = [] diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index b042ca77..c830325c 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -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 diff --git a/tests/repositories/scenario/test_scenario_postgres_repository.py b/tests/repositories/scenario/test_scenario_postgres_repository.py index eed38c66..8e0df21c 100644 --- a/tests/repositories/scenario/test_scenario_postgres_repository.py +++ b/tests/repositories/scenario/test_scenario_postgres_repository.py @@ -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()