Model/infrastructure/postgres/plan_table.py
Khalim Conn-Kowlessar 7e79c30af1 feat(modelling): Plan Measure carries per-measure kwh/cost savings
`PlanMeasure` grows optional `kwh_savings` (delivered energy) and
`energy_cost_savings` (£) — its slice of the telescoping bill cascade, signed
so positive is a saving and `None` until billing runs. `RecommendationRow`
declares the matching live `recommendation.kwh_savings` /
`energy_cost_savings` columns and maps them in `from_domain` (None → NULL).
The vestigial `recommendation.energy_savings` stays undeclared (legacy = 0).
No FE migration — the columns already exist on the live table (ADR-0014 / 0017).

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

130 lines
5 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)
post_energy_bill: Optional[float] = Field(default=None) # £/yr
energy_bill_savings: Optional[float] = Field(default=None) # £/yr
post_energy_consumption: Optional[float] = Field(default=None) # delivered kWh/yr
energy_consumption_savings: Optional[float] = Field(default=None) # kWh/yr
@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,
post_energy_bill=plan.post_energy_bill,
energy_bill_savings=plan.energy_bill_savings,
post_energy_consumption=plan.post_energy_consumption,
energy_consumption_savings=plan.energy_consumption_savings,
)
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
kwh_savings: Optional[float] = Field(default=None) # delivered kWh/yr
energy_cost_savings: Optional[float] = Field(default=None) # £/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
),
kwh_savings=measure.kwh_savings,
energy_cost_savings=measure.energy_cost_savings,
default=True,
already_installed=False,
)