diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py index 86063ebd..76a98ad2 100644 --- a/domain/modelling/plan.py +++ b/domain/modelling/plan.py @@ -11,13 +11,21 @@ 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 +def _total_consumption_kwh(bill: Bill) -> float: + """A Bill's total delivered energy (kWh) — the sum of its section kWh + (standing charges and the SEG credit are £, not energy).""" + return sum((section.kwh for section in bill.sections.values()), 0.0) + + @dataclass(frozen=True) class PlanMeasure: """One selected Measure Option as it lands in a Plan: the measure, its @@ -33,11 +41,18 @@ class PlanMeasure: 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).""" + 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 @property def cost_of_works(self) -> float: @@ -71,3 +86,31 @@ class Plan: """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 _total_consumption_kwh(self.post_bill) + + @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 _total_consumption_kwh(self.baseline_bill) - _total_consumption_kwh( + self.post_bill + ) diff --git a/tests/domain/modelling/test_plan.py b/tests/domain/modelling/test_plan.py index d2e3a68b..678d281f 100644 --- a/tests/domain/modelling/test_plan.py +++ b/tests/domain/modelling/test_plan.py @@ -8,12 +8,24 @@ band). Single-phase, flat post-retrofit figures (ADR-0005 / ADR-0017). from __future__ import annotations from datatypes.epc.domain.epc import Epc +from domain.billing.bill import Bill, BillSection, BillSectionCost from domain.modelling.scoring.package_scorer import Score from domain.modelling.plan import Plan, PlanMeasure from domain.modelling.recommendation import Cost from domain.modelling.scoring.scoring import MeasureImpact +def _bill(*, heating_kwh: float, total_gbp: float) -> Bill: + return Bill( + sections={ + BillSection.HEATING: BillSectionCost(kwh=heating_kwh, cost_gbp=total_gbp) + }, + standing_charges_gbp=0.0, + seg_credit_gbp=0.0, + total_gbp=total_gbp, + ) + + def _measure(measure_type: str, total: float, rate: float) -> PlanMeasure: return PlanMeasure( measure_type=measure_type, @@ -45,3 +57,40 @@ def test_plan_aggregates_cost_and_savings_and_bands_the_post_sap() -> None: assert abs(plan.co2_savings_kg_per_yr - 400.0) <= 1e-9 # baseline - post assert abs(plan.post_sap_continuous - 70.4) <= 1e-9 assert plan.post_epc_rating is Epc.C # round(70.4) = 70 → band C (69–80) + + +def test_plan_derives_post_bill_and_savings_from_the_baseline_and_post_bills() -> None: + # Arrange — a Plan whose baseline and post-package Bills have been derived. + baseline = Score( + sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0 + ) + post = Score( + sap_continuous=70.0, co2_kg_per_yr=3600.0, primary_energy_kwh_per_yr=18000.0 + ) + plan = Plan( + measures=(), + baseline=baseline, + post_retrofit=post, + baseline_bill=_bill(heating_kwh=10000.0, total_gbp=2000.0), + post_bill=_bill(heating_kwh=6000.0, total_gbp=1400.0), + ) + + # Act / Assert — plan-level energy/bill figures (ADR-0014 amendment). + assert plan.post_energy_bill == 1400.0 + assert plan.energy_bill_savings == 600.0 # 2000 − 1400 + assert plan.post_energy_consumption == 6000.0 # Σ post section kWh + assert plan.energy_consumption_savings == 4000.0 # 10000 − 6000 + + +def test_plan_energy_figures_are_none_without_bills() -> None: + # Arrange — a Plan with no bills derived (the figures persist as NULL). + score = Score( + sap_continuous=55.0, co2_kg_per_yr=3000.0, primary_energy_kwh_per_yr=15000.0 + ) + plan = Plan(measures=(), baseline=score, post_retrofit=score) + + # Act / Assert + assert plan.post_energy_bill is None + assert plan.energy_bill_savings is None + assert plan.post_energy_consumption is None + assert plan.energy_consumption_savings is None