mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
118 lines
4.2 KiB
Python
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,
|
|
)
|