mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(modelling): Plan carries baseline/post Bills and derives the energy figures
Plan gains optional baseline_bill / post_bill (the Bills derived for the unmodified and post-package end-states at one Fuel Rates snapshot) and derives the four plan-level columns: post_energy_bill (post total), energy_bill_savings (baseline - post), post_energy_consumption (Σ post section kWh), and energy_consumption_savings (baseline - post delivered kWh). All return None until billing runs (persisted as NULL), so existing Plan construction and the not-yet-wired orchestrator stay green. Plan-level only; per-measure savings are a later slice (ADR-0014 amendment). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2bbc401f0d
commit
26de28aae8
2 changed files with 93 additions and 1 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue