Files
EOS/src/akkudoktoreos/prediction/priceabc.py
T
Bobby NoelteandGitHub ba76087db9 feat: electricity fee provider framework and generic providers (#1235)
Add new provider class for electricity fees providers.

Add the generic providers:
- ElecFeeFixed
- ElecFeeImport

The providers provide predictions for:

- elecfee_consumption_amt_wh:
  Total fixed fee for consumed energy per Wh [amount/Wh]. This is the accumulation of all
  fixed per-Wh fees payable on "consumed energy - such as network charge, concession fee,
  and electricity charge - into a single amount.
- elecfee_consumption_percent_amt:
  Total fixed surcharge on consumed energy, given as a percentage of the monetary amount
  already charged for that energy [%]. This is the accumulation of all percentage-based
  surcharges payable on top of the consumed-energy fee - such as VAT - into a single
  percentage. This is a percentage of the fee amount, not a per-Wh rate.
- elecfee_feedin_amt_wh:
  Total fixed deduction from feed-in energy per Wh [amount/Wh]. This is the accumulation of
  all fixed per-Wh charges deducted from feed-in energy - such as metering fees or
  grid-operator handling "charges - into a single amount. Applied after the percentage-based
  deduction, i.e. it reduces the price by a flat amount per Wh rather than by a share of the
  raw price.
- elecfee_feedin_percent_amt:
  Total percentage deducted from the raw feed-in price (spot price) [%]. This is the
  accumulation of all percentage-based deductions payable on the feed-in tariff - such as a
  marketing or balancing fee retained by the aggregator - into a single percentage. It is
  applied as `raw_price * (100 - percent) / 100`, i.e. it scales down the raw price rather
  than adding a surcharge to it.

A new _apply_fee() method is added to the base class for ElecPrice and FeedInTariff to be used to
add the fees in a consistent way. Fees are taken from the active ElecFee provider and applied
to the raw prices given to the _apply_fee() method.

The optional application of fees is added to:

- ElecPriceAkkudoktor
- ElecPriceFixed
- ElecPriceEnergyCharts
- ElecPriceSMARD
- FeedInTariffEnergyCharts
- FeedInTariffFixed
- FeedInTariffSMARD

The import providers ElecPriceImport and FeedInTariffImport do not apply fees by intentention.

The following providers currently do not handle fees defined by ElecFee:

- ElecPriceTibber
- FeedInTariffAkkudoktor
- FeedInTariffDvhubOnline
- FeedInTariffTibber

The tests for this feature are either added or existing tests are extended.

The documentation was extended for the electricity fee provider settings.

Besides this feature further improvements are added:

* feat: add SMARD quarter-hour electricty price and feed-in tariff provider

* feat: to_series method for TimeWindows and ValueTimeWindows

  Additional to to_array the time window sequence can now also produce a pandas series.
  Test have been extended to cover the series generation.

* feat: use time windows in fixed feedin tariff provider

  Feedin tariff can now be configured by time windows - not a single value.

* feat: EOSdash select for PVLib inverters and modules

  Provide PVLib inverter and module names in config selection.

* feat: EOSdash lazy select for big option sets

  Add a new form for lazy selection of big option sets. Filtering and
  generation of the option set is done server-side.

* fix: use raw data for ETS/ median prediction

  Use to raw time series data for ETS/ median prediction to avoid interference
  by e.g. dynamic grid charges.

* fix: EOSdash config drops by type only on details resolve

  Drop configuration by type and path. Prevents dropping of configuration items
  with same type and level but different path.

* fix: EOSdash configuration section closes on update

  Open section if searching or if last update touched this category — including
  updates on deeply nested sub-fields.

* chore: make elecfeefixed, elecpricefixed and feedintarifffixed warn about no windows and default to 0

  Missining configuration creates default 0 value and a warning instead of an exception.

* fix: test setup for providers

  Reset db state on each test run.

* chore: improve config option naming for elecpricefixed.

* chore: adapt elecpricefixed test to changed time_windows naming

* chore: factorized common price provider helpers to priceabc.py

  Factorized common price provider helpers to priceabc.py. Add tests for these helpers.
  Reduce/ change testing of elecpriceabc.py and feedintariffabc.py to cover
  only specifics. Rest of testing is already covered by test_priceabc.py.

* chore: update version

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2026-08-23 02:26:16 +02:00

313 lines
14 KiB
Python

"""Shared base for price-like predictions (electricity price, feed-in tariff)."""
from abc import abstractmethod
from typing import cast
import numpy as np
import pandas as pd
from loguru import logger
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from akkudoktoreos.core.coreabc import PredictionMixin
from akkudoktoreos.prediction.predictionabc import PredictionProvider
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration
class PricePredictionProviderBase(PredictionMixin, PredictionProvider):
"""Common forecasting + fee-application logic shared by price-like providers.
Subclasses must supply the raw/gross record keys, the fee keys to pull from
the prediction store, and the formula that combines raw price + fees.
"""
# --- identity hooks -------------------------------------------------
@property
@abstractmethod
def _raw_key(self) -> str:
"""Record key holding the fee-free raw series."""
@property
@abstractmethod
def _gross_key(self) -> str:
"""Record key to write the fee-inclusive series to."""
@property
@abstractmethod
def _fee_keys(self) -> list[str]:
"""Prediction keys to fetch for fee computation."""
@abstractmethod
def _compute_gross(self, raw_amt_wh: pd.Series, df_fee: pd.DataFrame) -> pd.Series:
"""Combine the raw series with the fetched fee dataframe."""
# --- forecasting helpers (verbatim, shared) --------------------------
def _resolution_seconds(self, series: pd.Series) -> int:
"""Infer the native slot size in seconds from the series timestamps.
Uses the median of the timestamp differences so that a single outlier gap does
not distort the result. Falls back to hourly (3600 s) when fewer than two
timestamps are available.
"""
if len(series) < 2:
return 3600
index = pd.DatetimeIndex(series.sort_index().index).drop_duplicates()
deltas = index.to_series().diff().dropna().dt.total_seconds()
deltas = deltas[deltas > 0].tail(96)
if deltas.empty:
return 3600
resolution = int(round(float(deltas.median())))
return resolution if resolution > 0 and 3600 % resolution == 0 else 3600
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
"""Clip extreme values in a price history to a range around the mean.
Values further than ``sigma`` standard deviations from the mean are clipped
to the corresponding bound. Used to keep single-point spikes (e.g. negative
price events or data glitches) from dominating seasonal decomposition or a
median fallback.
Args:
data: The raw price history to clip.
sigma: Number of standard deviations from the mean to allow before
clipping. Defaults to 2.
Returns:
A copy of ``data`` with outliers clipped to ``[mean - sigma * std,
mean + sigma * std]``.
"""
mean = data.mean()
std = data.std()
lower_bound = mean - sigma * std
upper_bound = mean + sigma * std
return data.clip(min=lower_bound, max=upper_bound)
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
"""Forecast future prices with additive Exponential Smoothing (ETS).
Fits a Holt-Winters model with an additive seasonal component to the
outlier-capped history and forecasts the requested number of hours ahead.
Args:
history: Historical price values, ordered oldest to newest.
seasonal_periods: Length of one seasonal cycle in the same unit as
``history`` (e.g. 24 for daily seasonality, 168 for weekly
seasonality on hourly data).
hours: Number of hours to forecast beyond the end of ``history``.
Returns:
An array of ``hours`` forecasted values.
Raises:
ValueError: If ``history`` has fewer than ``2 * seasonal_periods``
observations, which ETS needs to reliably estimate the seasonal
component.
"""
required_observations = 2 * seasonal_periods
if len(history) < required_observations:
raise ValueError(
f"Not enough history for ETS with seasonal_periods="
f"{seasonal_periods}: got {len(history)}, "
f"need at least {required_observations}"
)
clean_history = self._cap_outliers(history)
model = ExponentialSmoothing(
clean_history, seasonal="add", seasonal_periods=seasonal_periods
).fit()
return model.forecast(hours)
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
"""Forecast future prices as a constant equal to the historical median.
Fallback used when there isn't enough history for a seasonal ETS forecast.
Args:
history: Historical price values, ordered oldest to newest.
hours: Number of hours to forecast.
Returns:
An array of ``hours`` values, all equal to the median of the
outlier-capped history.
"""
clean_history = self._cap_outliers(history)
return np.full(hours, np.median(clean_history))
def _predict(self, history: np.ndarray, hours: int, slots_per_hour: int = 1) -> np.ndarray:
"""Forecast future prices, choosing seasonality by available history length.
Uses weekly-seasonal ETS if there's enough history for it, falls back to
daily-seasonal ETS with less, and to a constant median with too little
history for either.
Args:
history: Historical price values, ordered oldest to newest.
hours: Number of forecast steps to produce, at the resolution implied
by ``slots_per_hour`` (despite the name, not necessarily clock hours).
slots_per_hour: Number of samples per hour in ``history`` (e.g. 4 for
15-minute data). Scales the seasonal period so a "week" or "day"
still spans the right number of samples at sub-hourly resolution.
Defaults to 1 (hourly data).
Returns:
An array of ``hours`` forecasted values.
Raises:
ValueError: If ``history`` is empty.
"""
weekly_periods = 168 * slots_per_hour
daily_periods = 24 * slots_per_hour
history_length = len(history)
if history_length >= 2 * weekly_periods:
return self._predict_ets(history, seasonal_periods=weekly_periods, hours=hours)
elif history_length >= 2 * daily_periods:
return self._predict_ets(history, seasonal_periods=daily_periods, hours=hours)
elif history_length > 0:
logger.warning(
"Using median fallback to predict prices with only {} values.", len(history)
)
return self._predict_median(history, hours=hours)
logger.error("No data available for prediction")
raise ValueError("No data available")
# --- fee application (shared plumbing, subclass supplies formula) ----
async def _apply_fees(self, raw_price_amt_wh: pd.Series) -> pd.Series:
"""Apply fees to a raw price-like time series to produce the gross series.
The raw series is first normalized to a strictly uniform, sorted,
duplicate-free DatetimeIndex (resampling to a fixed 15-minute grid with
forward-fill if the input spacing isn't already uniform), since fees are
fetched from the prediction store over the resulting `[start, end)` window
at that resolution. The fee values are then combined with the raw series
via `_compute_gross`, which each subclass implements with its own formula
(e.g. add-then-percent for consumption, percent-then-subtract for feed-in).
Args:
raw_price_amt_wh: Raw price-like series (amount/Wh), indexed by a
timezone-aware DatetimeIndex. Excludes fees.
Returns:
pd.Series: Raw series with fees applied (amount/Wh), named the same
as `raw_price_amt_wh`. If the input index was uniform, the returned
index matches it; otherwise the returned index is the fixed
15-minute, forward-filled resampling of the input index.
Raises:
ValueError: If `raw_price_amt_wh` is empty, or has fewer than two
entries (so no interval can be derived).
TypeError: If `raw_price_amt_wh` is not indexed by a DatetimeIndex,
or if the derived interval is not a `pd.Timedelta`.
"""
if raw_price_amt_wh.empty:
raise ValueError("raw_price_amt_wh must not be empty.")
if len(raw_price_amt_wh.index) < 2:
raise ValueError(
"raw_price_amt_wh must have at least two entries to derive the interval."
)
# Normalize the index: sorted, unique timestamps only. Later duplicate
# timestamps win, since they're assumed to be the more recently written value.
index = raw_price_amt_wh.index.sort_values()
if not isinstance(index, pd.DatetimeIndex):
raise TypeError("raw_price_amt_wh must have a DatetimeIndex")
index = cast(pd.DatetimeIndex, index)
raw_price_amt_wh = raw_price_amt_wh.reindex(index)
raw_price_amt_wh = raw_price_amt_wh[~raw_price_amt_wh.index.duplicated(keep="last")]
index = cast(pd.DatetimeIndex, raw_price_amt_wh.index)
# Determine whether the (deduplicated) index has a single, uniform spacing.
diffs = index.to_series().diff().dropna().unique()
if len(diffs) != 1:
# Spacing is irregular (e.g. gaps or mixed resolutions): fall back to a
# fixed 15-minute grid spanning the same range, forward-filling gaps so
# every slot has a value before fees are fetched/applied.
diff0 = pd.Timedelta(minutes=15)
uniform_index = pd.date_range(start=index[0], end=index[-1], freq=diff0, tz=index.tz)
raw_price_amt_wh = (
raw_price_amt_wh.reindex(raw_price_amt_wh.index.union(uniform_index))
.sort_index()
.ffill()
.reindex(uniform_index)
)
index = uniform_index
logger.warning(
f"raw_price_amt_wh has non uniform spacing {diffs}; "
"resampled to fixed 15-minutes grid with forward filling gaps"
)
else:
# Already uniform: use the single observed spacing as-is.
diff = diffs[0]
if not isinstance(diff, pd.Timedelta):
raise TypeError("Expected a Timedelta")
diff0 = diff
# Window and resolution used to fetch fee data matching the raw series exactly.
# end_datetime is exclusive, so it's one interval past the last raw timestamp.
start_datetime = to_datetime(index[0].to_pydatetime())
end_datetime = to_datetime(index[-1].to_pydatetime() + diff0.to_pytimedelta())
interval = to_duration(f"{diff0.total_seconds()} seconds")
# Fetch the fee series/percentages this provider needs (subclass-specific keys).
keys = self._fee_keys
try:
df_fee = await self.prediction.keys_to_dataframe(
keys=keys,
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
fill_method="linear",
resample_method="mean",
dropna=False,
boundary="context",
align_to_interval=True,
)
except KeyError:
# No fee provider enabled/configured for these keys: treat as zero
# fees rather than failing the whole price calculation.
df_fee = pd.DataFrame(0.0, index=raw_price_amt_wh.index, columns=keys)
# Guard against any boundary/resample mismatch between the fee dataframe
# and the raw price index (e.g. missing edge timestamps) by reindexing
# onto the raw index exactly and treating anything still missing as zero.
df_fee = df_fee.reindex(raw_price_amt_wh.index).fillna(0.0)
# Subclass-specific formula combining raw price and fees.
price_amt_wh = self._compute_gross(raw_price_amt_wh, df_fee)
price_amt_wh.name = raw_price_amt_wh.name
return price_amt_wh
async def _store_gross_series(
self,
start_datetime: DateTime | None = None,
end_datetime: DateTime | None = None,
) -> None:
"""Derive the fee-inclusive series from the fee-free (raw) series.
Recomputes the fee-inclusive series over `[start_datetime, end_datetime)`
only, so that historic and predicted values within that window get their
own correct time-window/weekday-specific fee amount. Deliberately bounded
rather than covering the entire retained history, which can span years -
callers are responsible for choosing bounds that cover every timestamp
written or possibly affected during the current update cycle.
Note: timestamps outside the given bounds keep whatever gross value was
computed for them in an earlier update cycle. If the fee schedule changes
in a way that should retroactively affect already-processed history, that
older range needs to be explicitly recomputed (e.g. via a forced refetch),
it will not happen automatically here.
Args:
start_datetime: Inclusive lower bound of the raw series to recompute.
end_datetime: Exclusive upper bound of the raw series to recompute.
"""
# Read back only the fee-free slice that needs recomputing...
full_raw_series = await self.key_to_raw_series(
key=self._raw_key, start_datetime=start_datetime, end_datetime=end_datetime
)
# ...apply fees to it...
full_series_with_fees = await self._apply_fees(full_raw_series)
# ...and persist the result under the gross key.
await self.key_from_series(self._gross_key, full_series_with_fees)