diff --git a/domain/fuel_rates/__init__.py b/domain/fuel_rates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/domain/fuel_rates/fuel.py b/domain/fuel_rates/fuel.py new file mode 100644 index 00000000..fff51f57 --- /dev/null +++ b/domain/fuel_rates/fuel.py @@ -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 diff --git a/domain/fuel_rates/fuel_rates.py b/domain/fuel_rates/fuel_rates.py new file mode 100644 index 00000000..a5b2eb73 --- /dev/null +++ b/domain/fuel_rates/fuel_rates.py @@ -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 diff --git a/repositories/fuel_rates/__init__.py b/repositories/fuel_rates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/repositories/fuel_rates/data/fuel_rates_2026_q2.json b/repositories/fuel_rates/data/fuel_rates_2026_q2.json new file mode 100644 index 00000000..2b81bd30 --- /dev/null +++ b/repositories/fuel_rates/data/fuel_rates_2026_q2.json @@ -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" + } +} diff --git a/repositories/fuel_rates/fuel_rates_repository.py b/repositories/fuel_rates/fuel_rates_repository.py new file mode 100644 index 00000000..a6d2b2d2 --- /dev/null +++ b/repositories/fuel_rates/fuel_rates_repository.py @@ -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: ... diff --git a/repositories/fuel_rates/static_file_fuel_rates_repository.py b/repositories/fuel_rates/static_file_fuel_rates_repository.py new file mode 100644 index 00000000..cbfd5062 --- /dev/null +++ b/repositories/fuel_rates/static_file_fuel_rates_repository.py @@ -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, + ) diff --git a/tests/domain/fuel_rates/__init__.py b/tests/domain/fuel_rates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/domain/fuel_rates/test_fuel_rates.py b/tests/domain/fuel_rates/test_fuel_rates.py new file mode 100644 index 00000000..a7319274 --- /dev/null +++ b/tests/domain/fuel_rates/test_fuel_rates.py @@ -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 diff --git a/tests/repositories/fuel_rates/__init__.py b/tests/repositories/fuel_rates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/repositories/fuel_rates/test_static_file_fuel_rates_repository.py b/tests/repositories/fuel_rates/test_static_file_fuel_rates_repository.py new file mode 100644 index 00000000..38d3a0a6 --- /dev/null +++ b/tests/repositories/fuel_rates/test_static_file_fuel_rates_repository.py @@ -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)