mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +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