Model/tests/domain/property_baseline/test_bill_derivation.py
Khalim Conn-Kowlessar 8ae3b56f41 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>
2026-06-02 09:38:44 +00:00

95 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)