Model/infrastructure/postgres/plan_table.py
Khalim Conn-Kowlessar d66f7eed84 feat(modelling): plan/recommendation SQLModel mirrors + PlanRepository (#1157)
Slice 3 of #1157. Persists a Plan and its Plan Measures to the live
plan / recommendation tables via SQLModel mirrors (ADR-0017).

- infrastructure/postgres/plan_table.py: PlanRow (`plan`) + RecommendationRow
  (`recommendation`) mirrors. RecommendationRow adds the new `plan_id` FK
  (ON DELETE CASCADE) linking each Plan Measure to its Plan, replacing the
  plan_recommendations m2m for new writes. from_domain mappers convert CO2
  kg → tonnes to match the live column contract and derive post_epc_rating
  from the rounded SAP. Only the impact + cost + identity columns the tracer
  fills are declared; energy/bill, U-value, valuation, labour, plan_type are
  left to later slices.
- PlanRepository port + PlanPostgresRepository.save(plan, *, property_id,
  scenario_id, portfolio_id, is_default) -> plan id. Idempotent replace:
  deleting the Plan cascades to its recommendation rows via plan_id, so a
  re-run overwrites (ADR-0012). No commit — the UoW owns the transaction.

2 tests (persist + idempotent re-run); pyright strict clean; 73 pass across
repositories/modelling/orchestration with no regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:51:02 +00:00

118 lines
4.2 KiB
Python

from __future__ import annotations
from typing import ClassVar, Optional
from sqlalchemy import BigInteger, Column, ForeignKey
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, SQLModel
from datatypes.epc.domain.epc import Epc
from domain.modelling.plan import Plan, PlanMeasure
# Calculator metrics are in kg CO₂/yr; the live `plan` / `recommendation`
# columns are tonnes (legacy `emissions_kg / 1000`). Convert on the way in.
_KG_PER_TONNE = 1000.0
class PlanRow(SQLModel, table=True):
"""SQLModel mirror of the live ``plan`` table (ADR-0017).
Declares only the columns the rebuild writes — identity, the flat
post-retrofit headline figures, and the cost aggregates. The legacy
SQLAlchemy model owns the live reads and the columns left for later
slices (valuation, plan_type, the energy/bill cluster). The physical
table is the shared contract.
"""
__tablename__: ClassVar[str] = "plan" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
portfolio_id: int
property_id: int = Field(index=True)
scenario_id: Optional[int] = Field(default=None)
is_default: bool = False
post_sap_points: Optional[float] = Field(default=None)
post_epc_rating: Optional[Epc] = Field(
default=None,
sa_column=Column(SAEnum(Epc, name="epc"), nullable=True),
)
post_co2_emissions: Optional[float] = Field(default=None) # tonnes/yr
co2_savings: Optional[float] = Field(default=None) # tonnes/yr
cost_of_works: Optional[float] = Field(default=None)
contingency_cost: Optional[float] = Field(default=None)
@classmethod
def from_domain(
cls,
plan: Plan,
*,
property_id: int,
scenario_id: int,
portfolio_id: int,
is_default: bool,
) -> "PlanRow":
return cls(
portfolio_id=portfolio_id,
property_id=property_id,
scenario_id=scenario_id,
is_default=is_default,
post_sap_points=plan.post_sap_continuous,
post_epc_rating=plan.post_epc_rating,
post_co2_emissions=plan.post_retrofit.co2_kg_per_yr / _KG_PER_TONNE,
co2_savings=plan.co2_savings_kg_per_yr / _KG_PER_TONNE,
cost_of_works=plan.cost_of_works,
contingency_cost=plan.contingency_cost,
)
class RecommendationRow(SQLModel, table=True):
"""SQLModel mirror of the live ``recommendation`` table — one row per
persisted Plan Measure (ADR-0017). Adds the new ``plan_id`` FK linking the
measure to its Plan (ON DELETE CASCADE), replacing the ``plan_recommendations``
m2m for new writes. Only the impact + cost columns the tracer fills are
declared; the energy/bill, U-value, valuation and labour columns are left
to later slices.
"""
__tablename__: ClassVar[str] = "recommendation" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
property_id: int = Field(index=True)
plan_id: Optional[int] = Field(
default=None,
sa_column=Column(
BigInteger,
ForeignKey("plan.id", ondelete="CASCADE"),
nullable=True,
index=True,
),
)
type: str
measure_type: Optional[str] = Field(default=None)
description: str
estimated_cost: Optional[float] = Field(default=None)
sap_points: Optional[float] = Field(default=None)
co2_equivalent_savings: Optional[float] = Field(default=None) # tonnes/yr
default: bool = True
already_installed: bool = False
@classmethod
def from_domain(
cls, measure: PlanMeasure, *, property_id: int, plan_id: int
) -> "RecommendationRow":
return cls(
property_id=property_id,
plan_id=plan_id,
type=measure.measure_type,
measure_type=measure.measure_type,
description=measure.description,
estimated_cost=measure.cost.total,
sap_points=measure.impact.sap_points,
co2_equivalent_savings=(
measure.impact.co2_savings_kg_per_yr / _KG_PER_TONNE
),
default=True,
already_installed=False,
)