Model/infrastructure/postgres/plan_table.py
Khalim Conn-Kowlessar 198122d145 feat(modelling): derive + persist plan-level post-retrofit bills (#1152 follow-up)
ModellingOrchestrator gains a constructor-injected FuelRatesRepository (mirrors
Baseline): run() resolves get_current() once and reuses one BillDerivation across
the batch. _plan_for prices the baseline and post-package end-states from the
SapResults already on their Scores (no extra calculate) and passes the Bills to
Plan. PlanRow mirror + from_domain gain the four live columns post_energy_bill /
energy_bill_savings / post_energy_consumption / energy_consumption_savings.
Pipeline/handler wire the fuel-rates repo. Integration tests assert the columns
persist: the multi-measure (fallback) plan shows positive bill+consumption
savings; the already-at-target zero-measure plan shows the current bill with
exactly zero savings. Fuel-switch measures price at the new fuel for free (we
bill the simulated end-state). 183 modelling/billing/orchestration/repo tests
pass, pyright strict clean. Plan-level only; per-measure savings next.

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

126 lines
4.8 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
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,
)