Model/infrastructure/postgres/modelling/plan_table.py
Khalim Conn-Kowlessar b3f4609c2d feat(modelling): wire Valuation Uplift onto the Plan
The Plan derives its Valuation Uplift (ADR-0018) from its baseline -> post
band jump and works+contingency cost, given one external input — the
Property's current market value (a Property Valuation, mostly absent).
`Plan.valuation` / `Plan.baseline_epc_rating` are derived like the other
headline figures; `PlanModel.from_domain` maps the £ forms to the live
plan.valuation_* columns (NULL when no value — the percentage is not
persisted on those columns). `Property.current_market_value` is the new
optional source; the orchestrator threads it onto the Plan. `run_one`
takes a `current_market_value` so the harness can value the uplift, and
the sense-check table shows the average % (always) plus the £ forms when
known.

Sourcing the current market value (upload / default) remains deferred
(ADR-0018); it is None throughout until that lands, so the columns stay
NULL at scale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:59:04 +00:00

113 lines
4.5 KiB
Python

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 sqlalchemy.sql import func
from sqlmodel import Field, SQLModel
from datatypes.epc.domain.epc import Epc
from domain.modelling.plan import Plan
# Calculator metrics are in kg CO₂/yr; the live ``plan`` columns are tonnes
# (legacy ``emissions_kg / 1000``). Convert on the way in.
_KG_PER_TONNE = 1000.0
class PlanType(enum.Enum):
SOLAR_ECO4 = "solar_eco4"
SOLAR_HHRSH_ECO4 = "solar_hhrsh_eco4"
EMPTY_CAVITY_ECO = "empty_cavity_eco"
PARTIAL_CAVITY_ECO = "partial_cavity_eco"
EXTRACTION_ECO = "extraction_eco"
class PlanModel(SQLModel, table=True):
"""The single SQLModel definition of the live ``plan`` table (ADR-0017
amendment). Full legacy column parity; out-of-cluster references
(``portfolio_id`` / ``property_id`` / ``scenario_id``) are plain indexed
ints, not FK constraints (mirror convention — the live FKs are owned by the
Drizzle schema)."""
__tablename__: ClassVar[str] = "plan" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
name: Optional[str] = Field(default="")
portfolio_id: int
property_id: int = Field(index=True)
scenario_id: Optional[int] = Field(default=None)
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column(TIMESTAMP, nullable=False, server_default=func.now()),
)
is_default: bool = False
valuation_increase_lower_bound: Optional[float] = Field(default=None)
valuation_increase_upper_bound: Optional[float] = Field(default=None)
valuation_increase_average: Optional[float] = Field(default=None)
plan_type: Optional[PlanType] = Field(
default=None,
sa_column=Column(
SAEnum(
PlanType,
name="plan_type",
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
create_type=False,
),
nullable=True,
),
)
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
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) # kWh/yr
energy_consumption_savings: Optional[float] = Field(default=None) # kWh/yr
valuation_post_retrofit: Optional[float] = Field(default=None)
valuation_increase: Optional[float] = Field(default=None)
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,
) -> "PlanModel":
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,
# Valuation Uplift £ forms (NULL when no Property Valuation is known;
# the percentage is not persisted on the live plan columns — ADR-0018).
valuation_increase_lower_bound=plan.valuation.lower_value,
valuation_increase_upper_bound=plan.valuation.upper_value,
valuation_increase_average=plan.valuation.average_value,
valuation_post_retrofit=plan.valuation.post_retrofit_value,
valuation_increase=plan.valuation.average_value,
)