Model/domain/modelling/plan.py
Khalim Conn-Kowlessar 9ef97be958 refactor(modelling): type measure_type fields as MeasureType
Tighten the recommendation/plan vocabulary off generic str:
MeasureOption.measure_type and PlanMeasure.measure_type are now MeasureType
(also _GlazingTarget.measure_type, MeasureDependency.triggers ->
frozenset[MeasureType], and the optimiser's chosen/required-type locals).
Because MeasureType is a StrEnum the change is transparent to persistence
(the `recommendation` varchar column), the optimiser group-by key, and every
`== "solar_pv"`-style comparison — so pyright now enforces the enum at every
construction site with no runtime behaviour change.

The catalogue boundary stays str: ProductRepository.get(measure_type: str)
and Product.measure_type are unchanged (they map arbitrary DB/JSON rows), so
the fake product repos in tests need no edit. Test construction helpers coerce
their str arg via MeasureType(...); direct constructions use members.

Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3
xfail; pyright clean on production + tests (pre-existing moto + property-
override-rowcount baselines untouched).

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

148 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.measure_type import MeasureType
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: MeasureType
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
)