mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
14b45a1b3e
commit
8ae3b56f41
3 changed files with 224 additions and 0 deletions
58
domain/property_baseline/bill.py
Normal file
58
domain/property_baseline/bill.py
Normal 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
|
||||
71
domain/property_baseline/bill_derivation.py
Normal file
71
domain/property_baseline/bill_derivation.py
Normal 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,
|
||||
)
|
||||
95
tests/domain/property_baseline/test_bill_derivation.py
Normal file
95
tests/domain/property_baseline/test_bill_derivation.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue