feat(fuel-rates): FuelRates snapshot + repository foundation (ADR-0014)

Slice 1 of Bill Derivation — the reference-data foundation that later slices
price the calculator's per-end-use kWh against:

- Fuel enum (canonical billing fuels; the join key between the calculator's
  SAP-code fuels and the rates snapshot). COAL + HEAT_NETWORK are members with
  no national rate.
- FuelRates value object: unit_rate_p_per_kwh / standing_charge_p_per_day /
  seg_export_p_per_kwh; raises UnpricedFuel on a fuel it has no rate for rather
  than billing at a wrong default.
- FuelRatesRepository port (ADR-0011 Repo-reads-stored-reference-data) +
  StaticFileFuelRatesRepository reading a committed JSON snapshot.
- Snapshot fuel_rates_2026_q2.json: GB national, Apr-Jun 2026 Ofgem cap
  (gas/electricity) + DESNZ/NEP May 2026 (off-gas). Carries the full researched
  data; the value object exposes single-rate fuels this slice. Off-peak
  (day/night), house coal and heat network raise UnpricedFuel until later slices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 09:29:07 +00:00
parent 57867832f6
commit 14b45a1b3e
11 changed files with 254 additions and 0 deletions

View file

43
domain/fuel_rates/fuel.py Normal file
View file

@ -0,0 +1,43 @@
from __future__ import annotations
from enum import Enum
class Fuel(Enum):
"""A canonical billing fuel — the join key between the calculator's
per-end-use fuel (mapped from SAP fuel codes) and the Fuel Rates snapshot
(ADR-0014). Member names match the snapshot's keys.
``COAL`` (traditional house coal) and ``HEAT_NETWORK`` are carried as
members so a cert lodging them maps to a Fuel, but they have no national
rate pricing them raises ``UnpricedFuel`` (house coal's domestic sale is
illegal in England; heat networks are scheme-specific).
"""
MAINS_GAS = "MAINS_GAS"
ELECTRICITY = "ELECTRICITY"
ELECTRICITY_OFF_PEAK = "ELECTRICITY_OFF_PEAK"
OIL = "OIL"
LPG = "LPG"
COAL = "COAL"
SMOKELESS = "SMOKELESS"
WOOD_LOGS = "WOOD_LOGS"
WOOD_PELLETS = "WOOD_PELLETS"
HEAT_NETWORK = "HEAT_NETWORK"
class UnpricedFuel(ValueError):
"""Bill Derivation was asked for a rate on a fuel the current Fuel Rates
snapshot does not price (ADR-0014).
Raised rather than billing at a wrong default so the gap surfaces
immediately house coal and heat networks have no national rate, and
off-peak electricity needs the day/night split that a later slice adds.
"""
def __init__(self, fuel: Fuel) -> None:
super().__init__(
f"no rate for fuel {fuel.name} in the current Fuel Rates snapshot; "
f"add it to the snapshot or map this end use to a priced fuel"
)
self.fuel = fuel

View file

@ -0,0 +1,46 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from domain.fuel_rates.fuel import Fuel, UnpricedFuel
@dataclass(frozen=True)
class FuelRate:
"""One fuel's current tariff: unit price + daily standing charge.
Off-gas fuels (oil / LPG / solid / wood) carry a ``0.0`` standing charge
they are delivered, not metered, so there is no daily charge.
"""
unit_rate_p_per_kwh: float
standing_charge_p_per_day: float
@dataclass(frozen=True)
class FuelRates:
"""A current Fuel Rates snapshot — the rate per billing Fuel plus the SEG
export credit (ADR-0014). ``period`` records which window it is for, since
a committed snapshot goes stale on the Ofgem-cap (quarterly) cadence.
Pricing a fuel the snapshot does not carry raises ``UnpricedFuel`` rather
than defaulting see [[reference-unmapped-sap-code]] for the same strict
discipline on the calculator side.
"""
period: str
seg_export_p_per_kwh: float
rates: Mapping[Fuel, FuelRate]
def unit_rate_p_per_kwh(self, fuel: Fuel) -> float:
return self._rate(fuel).unit_rate_p_per_kwh
def standing_charge_p_per_day(self, fuel: Fuel) -> float:
return self._rate(fuel).standing_charge_p_per_day
def _rate(self, fuel: Fuel) -> FuelRate:
rate = self.rates.get(fuel)
if rate is None:
raise UnpricedFuel(fuel)
return rate

View file

View file

@ -0,0 +1,27 @@
{
"period": "2026-04 to 2026-06",
"basis": "GB national average; Ofgem price cap (gas/electricity), DESNZ/NEP May 2026 (off-gas fuels)",
"sources": {
"gas_electricity": "Ofgem energy price cap unit rates and standing charges, announced 2026-02-25, cap period Apr-Jun 2026",
"off_gas": "DESNZ QEP petroleum table (oil, May 2026) + Nottingham Energy Partnership May 2026 comparison (LPG, smokeless, wood)",
"seg": "Solar Energy UK SEG league table, updated 2026-05-12"
},
"seg_export_p_per_kwh": 15.0,
"fuels": {
"MAINS_GAS": { "unit_rate_p_per_kwh": 5.74, "standing_charge_p_per_day": 29.09 },
"ELECTRICITY": { "unit_rate_p_per_kwh": 24.67, "standing_charge_p_per_day": 57.21 },
"ELECTRICITY_OFF_PEAK": { "day_p_per_kwh": 29.73, "night_p_per_kwh": 13.89, "standing_charge_p_per_day": 56.99 },
"OIL": { "unit_rate_p_per_kwh": 9.16, "standing_charge_p_per_day": 0.0 },
"LPG": { "unit_rate_p_per_kwh": 17.61, "standing_charge_p_per_day": 0.0 },
"SMOKELESS": { "unit_rate_p_per_kwh": 10.0, "standing_charge_p_per_day": 0.0 },
"WOOD_LOGS": { "unit_rate_p_per_kwh": 8.83, "standing_charge_p_per_day": 0.0 },
"WOOD_PELLETS": { "unit_rate_p_per_kwh": 7.99, "standing_charge_p_per_day": 0.0, "_note": "bagged pellets; blown bulk is 6.76 p/kWh" },
"COAL": null,
"HEAT_NETWORK": null
},
"_gaps": {
"COAL": "no standard domestic price (traditional house coal sale for domestic use is illegal in England)",
"HEAT_NETWORK": "scheme-specific; no national tariff or price-cap unit rate",
"ELECTRICITY_OFF_PEAK": "day/night split; priced once the off-peak slice adds the day/night accessor"
}
}

View file

@ -0,0 +1,17 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from domain.fuel_rates.fuel_rates import FuelRates
class FuelRatesRepository(ABC):
"""Reads the current Fuel Rates used to price a Property's bill (ADR-0014).
A Repo, not a Fetcher (ADR-0011): it reads stored reference data, no live
API call. The adapter backs onto a committed static snapshot today; an
Ofgem-cap ETL is a future adapter behind this same port.
"""
@abstractmethod
def get_current(self) -> FuelRates: ...

View file

@ -0,0 +1,43 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Optional
from domain.fuel_rates.fuel import Fuel
from domain.fuel_rates.fuel_rates import FuelRate, FuelRates
from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository
_DEFAULT_SNAPSHOT = Path(__file__).parent / "data" / "fuel_rates_2026_q2.json"
class StaticFileFuelRatesRepository(FuelRatesRepository):
"""Reads Fuel Rates from a committed JSON snapshot (ADR-0014).
Only **single-rate** fuels (those lodging a ``unit_rate_p_per_kwh``) are
exposed. Off-peak (day/night) and the unpriced gaps (null entries house
coal, heat network) are skipped, so pricing them raises ``UnpricedFuel``.
The day/night accessor for off-peak lands in a later slice.
"""
def __init__(self, snapshot_path: Optional[Path] = None) -> None:
self._snapshot_path = snapshot_path or _DEFAULT_SNAPSHOT
def get_current(self) -> FuelRates:
payload: dict[str, Any] = json.loads(self._snapshot_path.read_text())
fuels: dict[str, Any] = payload["fuels"]
rates: dict[Fuel, FuelRate] = {}
for name, entry in fuels.items():
if entry is None:
continue # an unpriced gap (house coal / heat network)
if "unit_rate_p_per_kwh" not in entry:
continue # off-peak day/night — priced in a later slice
rates[Fuel[name]] = FuelRate(
unit_rate_p_per_kwh=float(entry["unit_rate_p_per_kwh"]),
standing_charge_p_per_day=float(entry["standing_charge_p_per_day"]),
)
return FuelRates(
period=str(payload["period"]),
seg_export_p_per_kwh=float(payload["seg_export_p_per_kwh"]),
rates=rates,
)

View file

View file

@ -0,0 +1,33 @@
from __future__ import annotations
import pytest
from domain.fuel_rates.fuel import Fuel, UnpricedFuel
from domain.fuel_rates.fuel_rates import FuelRate, FuelRates
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)},
)
def test_unit_rate_and_standing_charge_read_back_for_a_priced_fuel() -> None:
# Arrange
rates = _rates()
# Act / Assert
assert rates.unit_rate_p_per_kwh(Fuel.MAINS_GAS) == 5.74
assert rates.standing_charge_p_per_day(Fuel.MAINS_GAS) == 29.09
def test_a_fuel_absent_from_the_snapshot_raises_unpriced_fuel() -> None:
# Arrange — LPG is not in this snapshot.
rates = _rates()
# Act / Assert — the raise carries the offending fuel for the operator.
with pytest.raises(UnpricedFuel) as excinfo:
rates.unit_rate_p_per_kwh(Fuel.LPG)
assert excinfo.value.fuel is Fuel.LPG

View file

@ -0,0 +1,45 @@
from __future__ import annotations
import pytest
from domain.fuel_rates.fuel import Fuel, UnpricedFuel
from repositories.fuel_rates.static_file_fuel_rates_repository import (
StaticFileFuelRatesRepository,
)
def test_get_current_loads_the_committed_snapshot_mains_gas_rate() -> None:
# Arrange
repository = StaticFileFuelRatesRepository()
# Act
rates = repository.get_current()
# Assert — the committed AprJun 2026 snapshot prices mains gas at 5.74 p/kWh.
assert rates.unit_rate_p_per_kwh(Fuel.MAINS_GAS) == 5.74
def test_snapshot_prices_metered_and_delivered_fuels_plus_seg() -> None:
# Arrange
rates = StaticFileFuelRatesRepository().get_current()
# Act / Assert — electricity carries a daily standing charge; oil is
# delivered (no meter) so its standing charge is 0; SEG is a flat credit.
assert rates.unit_rate_p_per_kwh(Fuel.ELECTRICITY) == 24.67
assert rates.standing_charge_p_per_day(Fuel.ELECTRICITY) == 57.21
assert rates.unit_rate_p_per_kwh(Fuel.OIL) == 9.16
assert rates.standing_charge_p_per_day(Fuel.OIL) == 0.0
assert rates.seg_export_p_per_kwh == 15.0
@pytest.mark.parametrize(
"fuel", [Fuel.HEAT_NETWORK, Fuel.COAL, Fuel.ELECTRICITY_OFF_PEAK]
)
def test_unpriced_fuels_raise_rather_than_defaulting(fuel: Fuel) -> None:
# Arrange — house coal + heat network have no national rate, and off-peak
# needs the day/night split a later slice adds (ADR-0014).
rates = StaticFileFuelRatesRepository().get_current()
# Act / Assert
with pytest.raises(UnpricedFuel):
rates.unit_rate_p_per_kwh(fuel)