diff --git a/domain/property_baseline/bill.py b/domain/property_baseline/bill.py new file mode 100644 index 00000000..fcc49329 --- /dev/null +++ b/domain/property_baseline/bill.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import Enum + +from domain.fuel_rates.fuel import Fuel + + +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" + + +@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 — + produced from SAP10 Calculation in a later slice. ``exported_kwh`` is PV + generation exported to the grid, credited at the SEG rate.""" + + lines: Sequence[EnergyLine] + exported_kwh: float = 0.0 + + +@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 diff --git a/domain/property_baseline/bill_derivation.py b/domain/property_baseline/bill_derivation.py new file mode 100644 index 00000000..2aceeeb3 --- /dev/null +++ b/domain/property_baseline/bill_derivation.py @@ -0,0 +1,71 @@ +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.property_baseline.bill import ( + Bill, + BillSection, + BillSectionCost, + EnergyBreakdown, +) + +_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._rates.unit_rate_p_per_kwh(line.fuel) + ) + 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, + ) diff --git a/tests/domain/property_baseline/test_bill_derivation.py b/tests/domain/property_baseline/test_bill_derivation.py new file mode 100644 index 00000000..73239d0f --- /dev/null +++ b/tests/domain/property_baseline/test_bill_derivation.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import pytest + +from domain.fuel_rates.fuel import Fuel, UnpricedFuel +from domain.fuel_rates.fuel_rates import FuelRate, FuelRates +from domain.property_baseline.bill import BillSection, EnergyBreakdown, EnergyLine +from domain.property_baseline.bill_derivation import BillDerivation + + +def _rates() -> FuelRates: + return FuelRates( + period="test", + seg_export_p_per_kwh=15.0, + rates={ + Fuel.MAINS_GAS: FuelRate(unit_rate_p_per_kwh=5.74, standing_charge_p_per_day=29.09), + Fuel.ELECTRICITY: FuelRate(unit_rate_p_per_kwh=24.67, standing_charge_p_per_day=57.21), + Fuel.OIL: FuelRate(unit_rate_p_per_kwh=9.16, standing_charge_p_per_day=0.0), + }, + ) + + +def test_derive_prices_a_single_gas_heating_line_with_its_standing_charge() -> None: + # Arrange — 10,000 kWh of mains-gas heating. + breakdown = EnergyBreakdown( + lines=[EnergyLine(section=BillSection.HEATING, fuel=Fuel.MAINS_GAS, kwh=10000.0)] + ) + derivation = BillDerivation(_rates()) + + # Act + bill = derivation.derive(breakdown) + + # Assert — heating = 10000 × 5.74p = £574; standing = 29.09p × 365 = £106.1785. + assert abs(bill.sections[BillSection.HEATING].cost_gbp - 574.0) <= 1e-9 + assert abs(bill.standing_charges_gbp - 106.1785) <= 1e-9 + assert abs(bill.total_gbp - 680.1785) <= 1e-9 + + +def test_two_sections_on_the_same_fuel_share_one_standing_charge() -> None: + # Arrange — gas heating + gas hot water are one meter, not two. + breakdown = EnergyBreakdown( + lines=[ + EnergyLine(section=BillSection.HEATING, fuel=Fuel.MAINS_GAS, kwh=8000.0), + EnergyLine(section=BillSection.HOT_WATER, fuel=Fuel.MAINS_GAS, kwh=2000.0), + ] + ) + + # Act + bill = BillDerivation(_rates()).derive(breakdown) + + # Assert — one gas standing charge (29.09p × 365 = £106.1785), not two. + assert abs(bill.standing_charges_gbp - 106.1785) <= 1e-9 + assert abs(bill.sections[BillSection.HOT_WATER].cost_gbp - 114.8) <= 1e-9 + + +def test_distinct_fuels_each_add_their_own_standing_charge() -> None: + # Arrange — gas heating + electric lighting: two meters. + breakdown = EnergyBreakdown( + lines=[ + EnergyLine(section=BillSection.HEATING, fuel=Fuel.MAINS_GAS, kwh=8000.0), + EnergyLine(section=BillSection.LIGHTING, fuel=Fuel.ELECTRICITY, kwh=500.0), + ] + ) + + # Act + bill = BillDerivation(_rates()).derive(breakdown) + + # Assert — gas 29.09 + elec 57.21 = 86.30 p/day × 365 = £314.995. + assert abs(bill.standing_charges_gbp - 314.995) <= 1e-9 + + +def test_exported_pv_is_credited_at_the_seg_rate() -> None: + # Arrange — 1000 kWh exported at 15p, against a single gas heating line. + breakdown = EnergyBreakdown( + lines=[EnergyLine(section=BillSection.HEATING, fuel=Fuel.MAINS_GAS, kwh=10000.0)], + exported_kwh=1000.0, + ) + + # Act + bill = BillDerivation(_rates()).derive(breakdown) + + # Assert — SEG credit £150 subtracted from the £680.1785 gross. + assert abs(bill.seg_credit_gbp - 150.0) <= 1e-9 + assert abs(bill.total_gbp - 530.1785) <= 1e-9 + + +def test_an_unpriced_fuel_in_a_line_raises() -> None: + # Arrange — a heat-network line; the snapshot prices no heat network. + breakdown = EnergyBreakdown( + lines=[EnergyLine(section=BillSection.HEATING, fuel=Fuel.HEAT_NETWORK, kwh=5000.0)] + ) + + # Act / Assert + with pytest.raises(UnpricedFuel): + BillDerivation(_rates()).derive(breakdown)