Model/domain/billing/bill_derivation.py
Khalim Conn-Kowlessar dc55d3b899 Price an off-peak meter line day/night by its high-rate fraction 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:24:50 +00:00

82 lines
3 KiB
Python

from __future__ import annotations
from collections import defaultdict
from typing import Final
from domain.fuel_rates.fuel import Fuel
from domain.fuel_rates.fuel_rates import FuelRates
from domain.billing.bill import (
Bill,
BillSection,
BillSectionCost,
EnergyBreakdown,
EnergyLine,
)
_DAYS_PER_YEAR: Final[float] = 365.0
_PENCE_PER_POUND: Final[float] = 100.0
class BillDerivation:
"""Derives a Property's annual energy Bill by pricing a delivered-energy
breakdown at current Fuel Rates (ADR-0014).
Each end-use line is billed at its fuel's unit rate; **standing charges are
added once per distinct fuel used** (a meter, not an end use — off-gas fuels
carry a 0 standing charge so they contribute nothing); the SEG export credit
is subtracted. Deterministic (ADR-0006). Raises ``UnpricedFuel`` (via
``FuelRates``) on a fuel the snapshot does not price.
"""
def __init__(self, fuel_rates: FuelRates) -> None:
self._rates = fuel_rates
def derive(self, breakdown: EnergyBreakdown) -> Bill:
section_kwh: defaultdict[BillSection, float] = defaultdict(float)
section_cost_p: defaultdict[BillSection, float] = defaultdict(float)
fuels_used: set[Fuel] = set()
for line in breakdown.lines:
section_kwh[line.section] += line.kwh
section_cost_p[line.section] += line.kwh * self._unit_rate_p_per_kwh(line)
if line.kwh > 0:
fuels_used.add(line.fuel)
sections = {
section: BillSectionCost(
kwh=section_kwh[section], cost_gbp=section_cost_p[section] / _PENCE_PER_POUND
)
for section in section_kwh
}
standing_charges_gbp = (
sum(
(self._rates.standing_charge_p_per_day(fuel) * _DAYS_PER_YEAR for fuel in fuels_used),
0.0,
)
/ _PENCE_PER_POUND
)
seg_credit_gbp = (
breakdown.exported_kwh * self._rates.seg_export_p_per_kwh / _PENCE_PER_POUND
)
total_gbp = (
sum((section.cost_gbp for section in sections.values()), 0.0)
+ standing_charges_gbp
- seg_credit_gbp
)
return Bill(
sections=sections,
standing_charges_gbp=standing_charges_gbp,
seg_credit_gbp=seg_credit_gbp,
total_gbp=total_gbp,
)
def _unit_rate_p_per_kwh(self, line: EnergyLine) -> float:
"""Price one line's fuel (p/kWh). An Off-Peak Meter line blends day/night
by its High-Rate Fraction; every other fuel bills at its flat unit rate."""
if line.fuel is Fuel.ELECTRICITY_OFF_PEAK:
if line.high_rate_fraction is None:
raise ValueError(
f"{line.section.value} bills on an off-peak meter but carries "
"no high-rate fraction; cannot split day/night"
)
return self._rates.off_peak_blended_p_per_kwh(line.high_rate_fraction)
return self._rates.unit_rate_p_per_kwh(line.fuel)