mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
57867832f6
commit
14b45a1b3e
11 changed files with 254 additions and 0 deletions
0
domain/fuel_rates/__init__.py
Normal file
0
domain/fuel_rates/__init__.py
Normal file
43
domain/fuel_rates/fuel.py
Normal file
43
domain/fuel_rates/fuel.py
Normal 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
|
||||||
46
domain/fuel_rates/fuel_rates.py
Normal file
46
domain/fuel_rates/fuel_rates.py
Normal 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
|
||||||
0
repositories/fuel_rates/__init__.py
Normal file
0
repositories/fuel_rates/__init__.py
Normal file
27
repositories/fuel_rates/data/fuel_rates_2026_q2.json
Normal file
27
repositories/fuel_rates/data/fuel_rates_2026_q2.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
repositories/fuel_rates/fuel_rates_repository.py
Normal file
17
repositories/fuel_rates/fuel_rates_repository.py
Normal 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: ...
|
||||||
43
repositories/fuel_rates/static_file_fuel_rates_repository.py
Normal file
43
repositories/fuel_rates/static_file_fuel_rates_repository.py
Normal 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,
|
||||||
|
)
|
||||||
0
tests/domain/fuel_rates/__init__.py
Normal file
0
tests/domain/fuel_rates/__init__.py
Normal file
33
tests/domain/fuel_rates/test_fuel_rates.py
Normal file
33
tests/domain/fuel_rates/test_fuel_rates.py
Normal 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
|
||||||
0
tests/repositories/fuel_rates/__init__.py
Normal file
0
tests/repositories/fuel_rates/__init__.py
Normal 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 Apr–Jun 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)
|
||||||
Loading…
Add table
Reference in a new issue