"""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 )