Model/domain/billing/bill.py
Khalim Conn-Kowlessar b976c3abd2 feat(modelling): attribute per-measure bill savings via a telescoping cascade
`_plan_for` now scores the baseline + every cumulative prefix once
(`cascade_scores`, best-practice order) and reuses those Scores for both the
role-3 marginal attribution and a per-measure bill cascade: bill each prefix at
one Fuel Rates snapshot and take consecutive Bill deltas as each measure's
marginal delivered-kWh and £ saving. Saving is signed (ventilation is
negative) and telescopes exactly to the Plan headline savings, because the
Plan's baseline/post Bills are now the same cascade endpoints (`bills[0]` /
`bills[-1]`) — which also drops the redundant standalone baseline `calculate`.

`recommendation.kwh_savings` / `energy_cost_savings` are filled from these.
Adds `Bill.total_consumption_kwh` (shared by Plan + the orchestrator). Pinned
end-to-end on the real calculator: Σ per-measure savings == the Plan totals
(ADR-0014 amendment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:01:11 +00:00

135 lines
4.9 KiB
Python

from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from enum import Enum
from typing import Optional, TYPE_CHECKING
from domain.fuel_rates.fuel import Fuel
from domain.billing.sap_fuel import sap_code_to_fuel
if TYPE_CHECKING:
from domain.sap10_calculator.calculator import SapResult
class BillSection(Enum):
"""A user-meaningful slice of the annual energy bill — the calculator's raw
end uses folded into the sections the UI shows (ADR-0014)."""
HEATING = "HEATING"
HOT_WATER = "HOT_WATER"
LIGHTING = "LIGHTING"
APPLIANCES = "APPLIANCES"
COOKING = "COOKING"
PUMPS_FANS = "PUMPS_FANS"
COOLING = "COOLING"
@dataclass(frozen=True)
class EnergyLine:
"""One section's delivered energy on one fuel. A section may have more than
one line (e.g. gas main heating + electric secondary heating)."""
section: BillSection
fuel: Fuel
kwh: float
@dataclass(frozen=True)
class EnergyBreakdown:
"""A Property's delivered energy per end use, the input to Bill Derivation.
``exported_kwh`` is PV generation exported to the grid, credited at the SEG
rate."""
lines: Sequence[EnergyLine]
exported_kwh: float = 0.0
@classmethod
def from_sap_result(cls, result: "SapResult") -> "EnergyBreakdown":
"""Fold a calculator `SapResult`'s per-end-use delivered kWh into billable
`EnergyLine`s (ADR-0014). Heating (main / main-2 / secondary) and hot water
are billed at their resolved fuel (`sap_code_to_fuel`); lighting / pumps-
fans / appliances / cooking / cooling are electricity by construction. A
line is emitted only when its kWh is positive; PV export carries to
`exported_kwh` for the SEG credit. The `from_*` factory mirrors
`Performance.from_sap_result`; living on the target keeps the calculator
free of any `property_baseline` dependency."""
candidates = [
_fuelled_line(
BillSection.HEATING,
result.main_heating_fuel_code,
result.main_heating_fuel_kwh_per_yr,
),
_fuelled_line(
BillSection.HEATING,
result.main_2_heating_fuel_code,
result.main_2_heating_fuel_kwh_per_yr,
),
_fuelled_line(
BillSection.HEATING,
result.secondary_heating_fuel_code,
result.secondary_heating_fuel_kwh_per_yr,
),
_fuelled_line(
BillSection.HOT_WATER,
result.hot_water_fuel_code,
result.hot_water_kwh_per_yr,
),
_electric_line(BillSection.LIGHTING, result.lighting_kwh_per_yr),
_electric_line(BillSection.PUMPS_FANS, result.pumps_fans_kwh_per_yr),
_electric_line(BillSection.APPLIANCES, result.appliances_kwh_per_yr),
_electric_line(BillSection.COOKING, result.cooking_kwh_per_yr),
_electric_line(BillSection.COOLING, result.space_cooling_fuel_kwh_per_yr),
]
return cls(
lines=[line for line in candidates if line is not None],
exported_kwh=result.pv_exported_kwh_per_yr,
)
def _fuelled_line(
section: BillSection, fuel_code: Optional[int], kwh: float
) -> Optional[EnergyLine]:
"""An `EnergyLine` for a fuelled end use, or None when it has no energy. A
positive kWh with no resolved fuel code is a data gap — raise rather than
bill it at a default (mirrors the calculator's strict-raise discipline)."""
if kwh <= 0:
return None
if fuel_code is None:
raise ValueError(
f"{section.value} has {kwh} kWh but no fuel code on the SapResult; "
"cannot attribute a billing fuel"
)
return EnergyLine(section=section, fuel=sap_code_to_fuel(fuel_code), kwh=kwh)
def _electric_line(section: BillSection, kwh: float) -> Optional[EnergyLine]:
"""An electricity `EnergyLine` for an electric end use, or None when zero."""
if kwh <= 0:
return None
return EnergyLine(section=section, fuel=Fuel.ELECTRICITY, kwh=kwh)
@dataclass(frozen=True)
class BillSectionCost:
"""One section's rolled-up delivered kWh and annual cost (£)."""
kwh: float
cost_gbp: float
@dataclass(frozen=True)
class Bill:
"""A Property's annual energy bill, composed per section plus the per-meter
standing charges and the SEG export credit, and the total (ADR-0014)."""
sections: Mapping[BillSection, BillSectionCost]
standing_charges_gbp: float
seg_credit_gbp: float
total_gbp: float
@property
def total_consumption_kwh(self) -> float:
"""Total delivered energy (kWh) across the billed sections. Standing
charges and the SEG credit are £, not energy, so they don't count."""
return sum((section.kwh for section in self.sections.values()), 0.0)