feat(baseline): BillDerivation prices an energy breakdown at Fuel Rates (ADR-0014)

Slice 2 of Bill Derivation. BillDerivation(fuel_rates).derive(breakdown) takes a
delivered-energy breakdown (per-section EnergyLine(section, fuel, kwh) +
exported_kwh) and produces a Bill: per-section kWh + cost, standing charges,
SEG credit, and total.

- Each end-use line billed at its fuel's unit rate.
- Standing charge added ONCE per distinct fuel used (a meter, not an end use);
  off-gas fuels carry 0 so contribute nothing — no metered/unmetered special case.
- SEG export credit subtracted.
- Deterministic (ADR-0006); raises UnpricedFuel (via FuelRates) on an unpriced
  fuel (e.g. heat network) rather than billing at a wrong default.

Pure domain — no calculator dependency; the SapResult->EnergyBreakdown adapter
is slice 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 09:38:44 +00:00
parent 14b45a1b3e
commit 8ae3b56f41
3 changed files with 224 additions and 0 deletions

View file

@ -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

View file

@ -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,
)

View file

@ -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)