mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
147 lines
6.2 KiB
Python
147 lines
6.2 KiB
Python
"""Plan and Plan Measure — the Modelling stage's persisted output (ADR-0017).
|
||
|
||
A **Plan** is the per-Property output of one Scenario's modelling run: the
|
||
selected **Optimised Package** (its **Plan Measures**) plus the Property's
|
||
post-retrofit figures. It is single-phase — multi-phase is deferred
|
||
(ADR-0005) — so the headline figures are flat on the Plan.
|
||
|
||
A **Plan Measure** is the *output* counterpart of a Recommendation's candidate
|
||
Option: the one Option the Optimiser kept, frozen with its installed **Cost**
|
||
and its final-package (role-3) attributed **impact**. See CONTEXT.md.
|
||
"""
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Optional
|
||
|
||
from datatypes.epc.domain.epc import Epc
|
||
from domain.billing.bill import Bill
|
||
from domain.modelling.scoring.package_scorer import Score
|
||
from domain.modelling.recommendation import Cost
|
||
from domain.modelling.scoring.scoring import MeasureImpact
|
||
from domain.modelling.valuation import ValuationUplift, estimate_valuation_uplift
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PlanMeasure:
|
||
"""One selected Measure Option as it lands in a Plan: the measure, its
|
||
installed Cost, and its role-3 (final-package cascade) attributed impact.
|
||
|
||
`kwh_savings` (delivered energy) and `energy_cost_savings` (£) are this
|
||
measure's slice of the telescoping bill cascade — its marginal Bill delta
|
||
over the running package state. They can be negative (e.g. ventilation
|
||
increases energy) and telescope exactly to the Plan totals; `None` until
|
||
billing has run (persisted as NULL — ADR-0014 amendment). They are distinct
|
||
from `impact.energy_savings_kwh_per_yr`, which is *primary* energy."""
|
||
|
||
measure_type: str
|
||
description: str
|
||
cost: Cost
|
||
impact: MeasureImpact
|
||
kwh_savings: Optional[float] = None
|
||
energy_cost_savings: Optional[float] = None
|
||
# The catalogue id of the Product installed (from the selected Option),
|
||
# persisted as ``recommendation.material_id``. None when priced from a
|
||
# catalogue with no ids.
|
||
material_id: Optional[int] = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Plan:
|
||
"""A Property's Plan for one Scenario: the selected Plan Measures and the
|
||
baseline / post-retrofit whole-package Scores. The persisted headline
|
||
figures are derived from these (cost aggregates, CO₂ saving, post band).
|
||
|
||
`baseline_bill` / `post_bill` are the Bills derived (at one Fuel Rates
|
||
snapshot) for the unmodified and post-package end-states; the energy/bill
|
||
headline figures derive from them, and are `None` until billing has run
|
||
(persisted as NULL — ADR-0014 amendment)."""
|
||
|
||
measures: tuple[PlanMeasure, ...]
|
||
baseline: Score
|
||
post_retrofit: Score
|
||
baseline_bill: Optional[Bill] = None
|
||
post_bill: Optional[Bill] = None
|
||
# The Property's current market value (a Property Valuation), when known.
|
||
# Mostly absent — then the Valuation Uplift is percentage-only and its £
|
||
# forms are None (ADR-0018).
|
||
current_market_value: Optional[float] = None
|
||
|
||
@property
|
||
def cost_of_works(self) -> float:
|
||
"""Sum of the Plan Measures' fully-loaded Costs."""
|
||
return sum((measure.cost.total for measure in self.measures), 0.0)
|
||
|
||
@property
|
||
def contingency_cost(self) -> float:
|
||
"""Sum of each Plan Measure's contingency (its Cost total × its
|
||
per-Measure-Type contingency rate)."""
|
||
return sum(
|
||
(
|
||
measure.cost.total * measure.cost.contingency_rate
|
||
for measure in self.measures
|
||
),
|
||
0.0,
|
||
)
|
||
|
||
@property
|
||
def post_sap_continuous(self) -> float:
|
||
"""The whole-package re-score's un-rounded SAP rating."""
|
||
return self.post_retrofit.sap_continuous
|
||
|
||
@property
|
||
def post_epc_rating(self) -> Epc:
|
||
"""The post-retrofit EPC band, from the rounded SAP rating."""
|
||
return Epc.from_sap_score(round(self.post_retrofit.sap_continuous))
|
||
|
||
@property
|
||
def baseline_epc_rating(self) -> Epc:
|
||
"""The baseline EPC band, from the rounded baseline SAP rating."""
|
||
return Epc.from_sap_score(round(self.baseline.sap_continuous))
|
||
|
||
@property
|
||
def valuation(self) -> ValuationUplift:
|
||
"""The Valuation Uplift this Plan produces — the estimated market-value
|
||
increase from the baseline -> post band jump (ADR-0018). Always a
|
||
percentage; the £ forms are populated only when `current_market_value`
|
||
is known, capped at 2x the works + contingency cost."""
|
||
return estimate_valuation_uplift(
|
||
current_band=self.baseline_epc_rating.value,
|
||
target_band=self.post_epc_rating.value,
|
||
current_value=self.current_market_value,
|
||
total_cost=self.cost_of_works + self.contingency_cost,
|
||
)
|
||
|
||
@property
|
||
def co2_savings_kg_per_yr(self) -> float:
|
||
"""Whole-package CO₂ reduction (kg/yr) vs the baseline re-score. The
|
||
persistence mapper converts to tonnes for the live column contract."""
|
||
return self.baseline.co2_kg_per_yr - self.post_retrofit.co2_kg_per_yr
|
||
|
||
@property
|
||
def post_energy_bill(self) -> Optional[float]:
|
||
"""The post-package annual energy bill (£), or None if not billed."""
|
||
return None if self.post_bill is None else self.post_bill.total_gbp
|
||
|
||
@property
|
||
def energy_bill_savings(self) -> Optional[float]:
|
||
"""Annual bill reduction (£) vs the baseline bill, both at the same Fuel
|
||
Rates snapshot. None unless both bills were derived."""
|
||
if self.baseline_bill is None or self.post_bill is None:
|
||
return None
|
||
return self.baseline_bill.total_gbp - self.post_bill.total_gbp
|
||
|
||
@property
|
||
def post_energy_consumption(self) -> Optional[float]:
|
||
"""The post-package total delivered energy (kWh), or None if not billed."""
|
||
return None if self.post_bill is None else self.post_bill.total_consumption_kwh
|
||
|
||
@property
|
||
def energy_consumption_savings(self) -> Optional[float]:
|
||
"""Annual delivered-energy reduction (kWh) vs the baseline. None unless
|
||
both bills were derived."""
|
||
if self.baseline_bill is None or self.post_bill is None:
|
||
return None
|
||
return (
|
||
self.baseline_bill.total_consumption_kwh
|
||
- self.post_bill.total_consumption_kwh
|
||
)
|