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>
This commit is contained in:
Bobby Noelte
2026-08-23 02:26:16 +02:00
committed by GitHub
parent 6849b731b0
commit ba76087db9
71 changed files with 6584 additions and 1191 deletions
+466 -1
View File
@@ -10,7 +10,7 @@ Timezone contract under test:
* When a timezone-aware datetime is supplied, ``start_time`` is
interpreted as wall-clock time **in that timezone** — no tz
conversion is applied to ``start_time`` itself.
* Constructing a ``TimeWindow`` with an aware ``start_time`` raises
* Constructing a ``TimeWindow`` with a naive ``start_time`` raises
``ValidationError``.
"""
@@ -20,7 +20,10 @@ import sys
sys.path.insert(0, os.path.dirname(__file__))
from typing import cast
import numpy as np
import pandas as pd
import pendulum
import pytest
from pydantic import ValidationError
@@ -721,6 +724,152 @@ class TestTimeWindowSequenceToArray:
assert np.all(arr_tue == 0.0) # Tuesday — all outside
# ===========================================================================
# TimeWindowSequence.to_series
# ===========================================================================
class TestTimeWindowSequenceToSeries:
"""Tests for TimeWindowSequence.to_series.
Window layout:
win1: 08:0010:00
win2: 14:0017:00
"""
def setup_method(self, method):
self.seq = TimeWindowSequence(
windows=[
make_window(8, 2),
make_window(14, 3),
]
)
def test_basic_1h_steps_naive(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert isinstance(series, pd.Series)
assert series.shape == (24,)
assert isinstance(series.index, pd.DatetimeIndex)
assert series.iloc[8] == pytest.approx(1.0)
assert series.iloc[9] == pytest.approx(1.0)
assert series.iloc[10] == pytest.approx(0.0)
assert series.iloc[14] == pytest.approx(1.0)
assert series.iloc[15] == pytest.approx(1.0)
assert series.iloc[16] == pytest.approx(1.0)
assert series.iloc[17] == pytest.approx(0.0)
def test_values_match_to_array(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
interval = pendulum.duration(hours=1)
arr = self.seq.to_array(start, end, interval)
series = self.seq.to_series(start, end, interval)
np.testing.assert_array_equal(series.to_numpy(), arr)
def test_dtype_is_float64(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert series.dtype == np.float64
def test_end_is_exclusive(self):
start = naive_dt(2024, 6, 15, 6)
end = naive_dt(2024, 6, 15, 8)
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (2,)
assert list(index.hour) == [6, 7]
assert np.all(series.to_numpy() == 0.0)
def test_align_to_interval_false_preserves_start(self):
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
series = self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=False,
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (2,)
assert index[0] == pd.Timestamp(start)
assert index[1] == pd.Timestamp(start.add(hours=1))
assert np.all(series.to_numpy() == 1.0)
def test_align_to_interval_true_floors_start(self):
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
series = self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=True,
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (3,)
assert list(index.hour) == [8, 9, 10]
assert series.iloc[0] == pytest.approx(1.0)
assert series.iloc[1] == pytest.approx(1.0)
assert series.iloc[2] == pytest.approx(0.0)
def test_aware_datetime_preserves_timezone(self):
start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
end = aware_dt(2024, 6, 15, 4, tz="Europe/Berlin")
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (4,)
assert str(index.tz) == "Europe/Berlin"
def test_unsupported_boundary_raises(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
with pytest.raises(ValueError, match="boundary"):
self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
boundary="strict",
)
def test_empty_sequence_all_zeros(self):
seq = TimeWindowSequence()
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
series = seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert series.shape == (4,)
assert np.all(series.to_numpy() == 0.0)
# ===========================================================================
# ValueTimeWindowSequence.to_array
# ===========================================================================
@@ -881,6 +1030,254 @@ class TestValueTimeWindowSequenceToArray:
assert arr[1] == pytest.approx(0.10)
# ===========================================================================
# ValueTimeWindowSequence.to_series
# ===========================================================================
class TestValueTimeWindowSequenceToSeries:
"""Tests for ValueTimeWindowSequence.to_series."""
def setup_method(self, method):
self.seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="08:00:00",
duration="4 hours",
value=0.25,
),
ValueTimeWindow(
start_time="18:00:00",
duration="4 hours",
value=0.35,
),
]
)
def test_basic_1h_steps_values(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert isinstance(series, pd.Series)
assert isinstance(series.index, pd.DatetimeIndex)
assert series.shape == (24,)
assert series.iloc[8] == pytest.approx(0.25)
assert series.iloc[11] == pytest.approx(0.25)
assert series.iloc[12] == pytest.approx(0.0)
assert series.iloc[18] == pytest.approx(0.35)
assert series.iloc[21] == pytest.approx(0.35)
assert series.iloc[22] == pytest.approx(0.0)
def test_values_match_to_array(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
interval = pendulum.duration(hours=1)
arr = self.seq.to_array(start, end, interval)
series = self.seq.to_series(start, end, interval)
np.testing.assert_array_equal(series.to_numpy(), arr)
def test_dtype_is_float64(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert series.dtype == np.float64
def test_dropna_false_none_value_emits_nan(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="08:00:00",
duration="2 hours",
value=None,
),
ValueTimeWindow(
start_time="12:00:00",
duration="2 hours",
value=0.5,
),
]
)
start = naive_dt(2024, 6, 15, 8)
end = naive_dt(2024, 6, 15, 15)
series = seq.to_series(
start,
end,
pendulum.duration(hours=1),
dropna=False,
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (7,)
assert list(index.hour) == [8, 9, 10, 11, 12, 13, 14]
assert np.isnan(series.iloc[0])
assert np.isnan(series.iloc[1])
assert series.iloc[2] == pytest.approx(0.0)
assert series.iloc[3] == pytest.approx(0.0)
assert series.iloc[4] == pytest.approx(0.5)
assert series.iloc[5] == pytest.approx(0.5)
assert series.iloc[6] == pytest.approx(0.0)
def test_dropna_true_none_value_omits_timestamp(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="08:00:00",
duration="2 hours",
value=None,
),
ValueTimeWindow(
start_time="12:00:00",
duration="2 hours",
value=0.5,
),
]
)
start = naive_dt(2024, 6, 15, 8)
end = naive_dt(2024, 6, 15, 15)
series = seq.to_series(
start,
end,
pendulum.duration(hours=1),
dropna=True,
)
index = cast(pd.DatetimeIndex, series.index)
# 08:00 and 09:00 are omitted completely.
assert series.shape == (5,)
assert list(index.hour) == [10, 11, 12, 13, 14]
np.testing.assert_allclose(
series.to_numpy(),
[0.0, 0.0, 0.5, 0.5, 0.0],
)
def test_dropna_no_none_values_same_result(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 6)
interval = pendulum.duration(hours=1)
series_true = self.seq.to_series(
start, end, interval, dropna=True
)
series_false = self.seq.to_series(
start, end, interval, dropna=False
)
pd.testing.assert_series_equal(series_true, series_false)
def test_aware_datetime_preserves_timezone(self):
start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
end = aware_dt(2024, 6, 15, 4, tz="Europe/Berlin")
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (4,)
assert str(index.tz) == "Europe/Berlin"
def test_align_to_interval_true_floors_start(self):
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
series = self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=True,
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (3,)
assert list(index.hour) == [8, 9, 10]
assert series.iloc[0] == pytest.approx(0.25)
assert series.iloc[1] == pytest.approx(0.25)
assert series.iloc[2] == pytest.approx(0.25)
def test_align_to_interval_false_preserves_start(self):
start = naive_dt(2024, 6, 15, 8, 30)
end = naive_dt(2024, 6, 15, 12, 30)
series = self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=False,
)
assert series.shape == (4,)
assert series.index[0] == pd.Timestamp(start)
assert series.iloc[0] == pytest.approx(0.25)
assert np.all(series.to_numpy() == pytest.approx(0.25))
def test_unsupported_boundary_raises(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
with pytest.raises(ValueError, match="boundary"):
self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
boundary="inner",
)
def test_empty_sequence_all_zeros(self):
seq = ValueTimeWindowSequence()
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
series = seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert series.shape == (4,)
assert np.all(series.to_numpy() == 0.0)
def test_overlapping_windows_first_wins(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="08:00:00",
duration="4 hours",
value=0.10,
),
ValueTimeWindow(
start_time="09:00:00",
duration="4 hours",
value=0.99,
),
]
)
start = naive_dt(2024, 6, 15, 9)
end = naive_dt(2024, 6, 15, 11)
series = seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert series.iloc[0] == pytest.approx(0.10)
assert series.iloc[1] == pytest.approx(0.10)
# ===========================================================================
# align_to_interval — timezone-invariance
#
@@ -957,6 +1354,22 @@ class TestAlignToIntervalTimezoneInvariance:
assert arr[1] == pytest.approx(1.0)
assert arr[2] == pytest.approx(0.0)
def test_tws_series_naive_floor_non_utc(self, set_other_timezone):
set_other_timezone()
series = self._tws_naive().to_series(
self._tws_naive_start(),
self._tws_naive_end(),
pendulum.duration(hours=1),
align_to_interval=True,
)
assert series.shape == (3,)
assert list(series.index.hour) == [8, 9, 10]
assert series.iloc[0] == pytest.approx(1.0)
assert series.iloc[1] == pytest.approx(1.0)
assert series.iloc[2] == pytest.approx(0.0)
# ------------------------------------------------------------------
# TimeWindowSequence — naive datetime, 30-min steps
# floor 08:10 → 08:00; expect steps 08:00(1), 08:30(1), 09:00(1), 09:30(1), 10:00(0)
@@ -1009,6 +1422,28 @@ class TestAlignToIntervalTimezoneInvariance:
assert arr[1] == pytest.approx(1.0)
assert arr[2] == pytest.approx(0.0)
def test_tws_series_aware_floor_non_utc(self, set_other_timezone):
set_other_timezone()
seq = self._tws_naive()
start = aware_dt(2024, 6, 15, 8, 10, tz="Europe/Berlin")
end = aware_dt(2024, 6, 15, 10, 10, tz="Europe/Berlin")
series = seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=True,
)
assert series.shape == (3,)
assert list(series.index.hour) == [8, 9, 10]
assert str(series.index.tz) == "Europe/Berlin"
assert series.iloc[0] == pytest.approx(1.0)
assert series.iloc[1] == pytest.approx(1.0)
assert series.iloc[2] == pytest.approx(0.0)
# ------------------------------------------------------------------
# ValueTimeWindowSequence — naive datetime, 1-hour steps
# floor 08:10 → 08:00; values 0.25 at 08:00, 09:00; 0.0 at 10:00
@@ -1039,3 +1474,33 @@ class TestAlignToIntervalTimezoneInvariance:
assert arr[0] == pytest.approx(0.25)
assert arr[1] == pytest.approx(0.25)
assert arr[2] == pytest.approx(0.0)
def test_vtws_series_naive_floor_non_utc(self, set_other_timezone):
set_other_timezone()
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="08:00:00",
duration="2 hours",
value=0.25,
)
]
)
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
series = seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=True,
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (3,)
assert list(index.hour) == [8, 9, 10]
assert series.iloc[0] == pytest.approx(0.25)
assert series.iloc[1] == pytest.approx(0.25)
assert series.iloc[2] == pytest.approx(0.0)
+463
View File
@@ -0,0 +1,463 @@
"""Tests for fixed electricity fee prediction module."""
import asyncio
import json
from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
import pytest
from akkudoktoreos.config.configabc import ValueTimeWindow, ValueTimeWindowSequence
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecfeefixed import (
ElecFeeFixed,
ElecFeeFixedCommonSettings,
)
from akkudoktoreos.utils.datetimeutil import Duration, to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_ELECFEEFIXED_CONFIG_JSON = DIR_TESTDATA.joinpath("elecfeefixed_config.json")
class TestElecFeeFixedCommonSettings:
"""Tests for ElecFeeFixedCommonSettings model."""
def test_create_settings_with_consumption_amt_kwh(self):
"""Test creating settings with consumption_amt_kwh windows."""
settings_dict = {
"consumption_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "8 hours", "value": 0.00288},
{"start_time": "08:00", "duration": "16 hours", "value": 0.0034},
]
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
assert settings is not None
assert settings.consumption_amt_kwh is not None
assert settings.consumption_amt_kwh.windows is not None
assert len(settings.consumption_amt_kwh.windows) == 2
def test_create_settings_with_consumption_percent_amt(self):
"""Test creating settings with consumption_percent_amt windows."""
settings_dict = {
"consumption_percent_amt": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": 19.0},
]
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
assert settings is not None
assert settings.consumption_percent_amt is not None
assert len(settings.consumption_percent_amt.windows) == 1
def test_create_settings_with_feedin_amt_kwh(self):
"""Test creating settings with feedin_amt_kwh windows."""
settings_dict = {
"feedin_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "8 hours", "value": 0.00008},
{"start_time": "08:00", "duration": "16 hours", "value": 0.0001},
]
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
assert settings is not None
assert settings.feedin_amt_kwh is not None
assert len(settings.feedin_amt_kwh.windows) == 2
def test_create_settings_with_feedin_percent_amt(self):
"""Test creating settings with feedin_percent_amt windows."""
settings_dict = {
"feedin_percent_amt": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": 5.0},
]
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
assert settings is not None
assert settings.feedin_percent_amt is not None
assert len(settings.feedin_percent_amt.windows) == 1
def test_create_settings_without_windows(self):
"""Test creating settings without any windows configured."""
settings = ElecFeeFixedCommonSettings()
assert settings.consumption_amt_kwh is not None
assert settings.consumption_amt_kwh.windows == []
assert settings.consumption_percent_amt is not None
assert settings.consumption_percent_amt.windows == []
assert settings.feedin_amt_kwh is not None
assert settings.feedin_amt_kwh.windows == []
assert settings.feedin_percent_amt is not None
assert settings.feedin_percent_amt.windows == []
@pytest.fixture
def elecfeefixed_settings():
"""Fully configured ElecFeeFixedCommonSettings covering all 4 sequences.
Rates are chosen to be distinguishable per sequence and per window so
that assertions can pin down exactly which value landed at which
timestamp.
"""
consumption_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.288),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.34),
]
)
consumption_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=19.0),
]
)
feedin_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.08),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.10),
]
)
feedin_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
]
)
return ElecFeeFixedCommonSettings(
consumption_amt_kwh=consumption_amt_kwh,
consumption_percent_amt=consumption_percent_amt,
feedin_amt_kwh=feedin_amt_kwh,
feedin_percent_amt=feedin_percent_amt,
)
@pytest.fixture
def provider(config_eos, elecfeefixed_settings):
"""Fixture to create an ElecFeeFixed provider instance."""
# Assign settings to config
config_eos.merge_settings_from_dict(
{
"elecfee": {
"provider": "ElecFeeFixed",
},
}
)
config_eos.elecfee.elecfeefixed = elecfeefixed_settings
provider = ElecFeeFixed()
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
def cache_store():
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
return CacheFileStore()
class TestElecFeeFixed:
"""Tests for ElecFeeFixed provider."""
def test_provider_id(self, provider):
"""Test provider ID returns correct value."""
assert provider.provider_id() == "ElecFeeFixed"
def test_singleton_instance(self, provider):
"""Test that ElecFeeFixed behaves as a singleton."""
another_instance = ElecFeeFixed()
assert provider is another_instance
def test_invalid_provider(self, provider, monkeypatch):
"""Test requesting an unsupported provider."""
monkeypatch.setenv("EOS_ELECFEE__ELECFEE_PROVIDER", "<invalid>")
provider.config.reset_settings()
assert not provider.enabled()
@pytest.mark.asyncio
async def test_update_data_15min_intervals_all_sequences(self, provider, config_eos):
"""Test updating data with 15-minute intervals across all 4 fee sequences."""
ems_eos = get_ems()
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start_dt)
config_eos.prediction.hours = 10 # spans both windows: 00:00-10:00 = 40 intervals
await provider.update_data(force_enable=True, force_update=True)
# 10 hours * 4 intervals per hour = 40 intervals
assert len(provider) == 40
records = provider.records
# Check timestamps are on 15-minute boundaries
for record in records:
assert record.date_time.minute in (0, 15, 30, 45)
assert record.date_time.second == 0
# --- consumption_amt_kwh -> elecfee_consumption_amt_wh (converted /1000) ---
# First 32 intervals: 00:00-08:00, night rate (8h * 4 = 32)
for i in range(32):
assert abs(records[i].elecfee_consumption_amt_wh - 0.000288) < 1e-9, (
f"Expected night consumption fee at interval {i}, "
f"got {records[i].elecfee_consumption_amt_wh}"
)
# Remaining 8 intervals: 08:00-10:00, day rate (2h * 4 = 8)
for i in range(32, 40):
assert abs(records[i].elecfee_consumption_amt_wh - 0.00034) < 1e-9, (
f"Expected day consumption fee at interval {i}, "
f"got {records[i].elecfee_consumption_amt_wh}"
)
# --- consumption_percent_amt -> elecfee_consumption_percent_amt (no conversion) ---
for i in range(40):
assert abs(records[i].elecfee_consumption_percent_amt - 19.0) < 1e-9, (
f"Expected constant consumption percent fee at interval {i}, "
f"got {records[i].elecfee_consumption_percent_amt}"
)
# --- feedin_amt_kwh -> elecfee_feedin_amt_wh (converted /1000) ---
for i in range(32):
assert abs(records[i].elecfee_feedin_amt_wh - 0.00008) < 1e-9, (
f"Expected night feedin fee at interval {i}, "
f"got {records[i].elecfee_feedin_amt_wh}"
)
for i in range(32, 40):
assert abs(records[i].elecfee_feedin_amt_wh - 0.0001) < 1e-9, (
f"Expected day feedin fee at interval {i}, "
f"got {records[i].elecfee_feedin_amt_wh}"
)
# --- feedin_percent_amt -> elecfee_feedin_percent_amt (no conversion) ---
for i in range(40):
assert abs(records[i].elecfee_feedin_percent_amt - 5.0) < 1e-9, (
f"Expected constant feedin percent fee at interval {i}, "
f"got {records[i].elecfee_feedin_percent_amt}"
)
@pytest.mark.asyncio
async def test_update_data_without_config(self, caplog, provider, config_eos):
"""Test update_data fails without any elecfeefixed configuration."""
# Remove elecfeefixed settings entirely
config_eos.elecfee.elecfeefixed = {}
with caplog.at_level("WARNING"):
await provider.update_data(force_enable=True, force_update=True)
assert "No time windows configured for `elecfee_consumption_amt_wh`" in caplog.text
assert "No time windows configured for `elecfee_consumption_percent_amt`" in caplog.text
assert "No time windows configured for `elecfee_feedin_amt_wh`" in caplog.text
assert "No time windows configured for `elecfee_feedin_percent_amt`" in caplog.text
@pytest.mark.asyncio
async def test_update_data_without_time_windows(self, caplog, provider, config_eos):
"""Test update_data fails when all 4 sequences are empty."""
empty_settings = ElecFeeFixedCommonSettings(
consumption_amt_kwh=ValueTimeWindowSequence(windows=[]),
consumption_percent_amt=ValueTimeWindowSequence(windows=[]),
feedin_amt_kwh=ValueTimeWindowSequence(windows=[]),
feedin_percent_amt=ValueTimeWindowSequence(windows=[]),
)
config_eos.elecfee.elecfeefixed = empty_settings
with caplog.at_level("WARNING"):
await provider.update_data(force_enable=True, force_update=True)
assert "No time windows configured for `elecfee_consumption_amt_wh`" in caplog.text
assert "No time windows configured for `elecfee_consumption_percent_amt`" in caplog.text
assert "No time windows configured for `elecfee_feedin_amt_wh`" in caplog.text
assert "No time windows configured for `elecfee_feedin_percent_amt`" in caplog.text
@pytest.mark.asyncio
async def test_update_data_missing_single_sequence(self, caplog, provider, config_eos):
"""Test that a single empty sequence among 4 still raises, naming that key.
`consumption_amt_kwh` is populated (first in insertion order), so the
loop should fail on the first sequence that is actually empty:
`consumption_percent_amt` -> `elecfee_consumption_percent_amt`.
"""
partial_settings = ElecFeeFixedCommonSettings(
consumption_amt_kwh=ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=0.3),
]
),
consumption_percent_amt=ValueTimeWindowSequence(windows=[]),
feedin_amt_kwh=ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=0.1),
]
),
feedin_percent_amt=ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
]
),
)
config_eos.elecfee.elecfeefixed = partial_settings
with caplog.at_level("WARNING"):
await provider.update_data(force_enable=True, force_update=True)
assert "No time windows configured for `elecfee_consumption_percent_amt`" in caplog.text
@pytest.mark.asyncio
async def test_key_to_array_resampling(self, provider, config_eos):
"""Test that key_to_array can resample the consumption fee to different intervals."""
# Provider provides 15-minutes data
ems_eos = get_ems()
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start_dt)
config_eos.prediction.hours = 24
await provider.update_data(force_enable=True, force_update=True)
# Get data as hourly array (original)
hourly_array = await provider.key_to_array(
key="elecfee_consumption_amt_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
fill_method="ffill",
)
assert len(hourly_array) == 24
assert abs(hourly_array[0] - 0.000288) < 1e-9 # Night rate
assert abs(hourly_array[8] - 0.00034) < 1e-9 # Day rate
# Resample to 15-minute intervals
quarter_hour_array = await provider.key_to_array(
key="elecfee_consumption_amt_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
interval="15 minutes",
fill_method="ffill",
)
assert len(quarter_hour_array) == 96 # 24 * 4
# First 4 15-min intervals should be night rate
for i in range(4):
assert abs(quarter_hour_array[i] - 0.000288) < 1e-9
# Resample to 30-minute intervals
half_hour_array = await provider.key_to_array(
key="elecfee_consumption_amt_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
interval="30 minutes",
fill_method="ffill",
)
assert len(half_hour_array) == 48 # 24 * 2
# First 2 30-min intervals should be night rate
for i in range(2):
assert abs(half_hour_array[i] - 0.000288) < 1e-9
# Resample the percent-based feedin fee, which should NOT be
# kWh -> Wh converted and should be constant across the day.
percent_array = await provider.key_to_array(
key="elecfee_feedin_percent_amt",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
fill_method="ffill",
)
assert len(percent_array) == 24
assert np.allclose(percent_array, 5.0)
class TestElecFeeFixedIntegration:
"""Integration tests for ElecFeeFixed."""
@pytest.mark.skip(reason="For development only")
async def test_fixed_fee_development(self, config_eos):
"""Test fixed fee provider with real configuration."""
# Create provider with config
provider = ElecFeeFixed()
# Setup realistic test scenario
ems_eos = get_ems()
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start_dt)
# Configure with realistic German electricity fees (2024)
consumption_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.288),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.34),
]
)
consumption_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=19.0),
]
)
feedin_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.08),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.10),
]
)
feedin_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
]
)
config_eos.elecfee.elecfeefixed = ElecFeeFixedCommonSettings(
consumption_amt_kwh=consumption_amt_kwh,
consumption_percent_amt=consumption_percent_amt,
feedin_amt_kwh=feedin_amt_kwh,
feedin_percent_amt=feedin_percent_amt,
)
config_eos.prediction.hours = 168 # 7 days
# Update data
await provider.update_data(force_enable=True, force_update=True)
# Verify data
expected_intervals = 168 * 4 # 7 days * 24h * 4 intervals
assert len(provider) == expected_intervals
# Save configuration for documentation
config_data = {
"consumption_amt_kwh": [
{
"start_time": str(window.start_time),
"duration": str(window.duration),
"value": window.value,
}
for window in config_eos.elecfee.elecfeefixed.consumption_amt_kwh.windows
],
"consumption_percent_amt": [
{
"start_time": str(window.start_time),
"duration": str(window.duration),
"value": window.value,
}
for window in config_eos.elecfee.elecfeefixed.consumption_percent_amt.windows
],
"feedin_amt_kwh": [
{
"start_time": str(window.start_time),
"duration": str(window.duration),
"value": window.value,
}
for window in config_eos.elecfee.elecfeefixed.feedin_amt_kwh.windows
],
"feedin_percent_amt": [
{
"start_time": str(window.start_time),
"duration": str(window.duration),
"value": window.value,
}
for window in config_eos.elecfee.elecfeefixed.feedin_percent_amt.windows
],
}
with FILE_TESTDATA_ELECFEEFIXED_CONFIG_JSON.open("w", encoding="utf-8") as f:
json.dump(config_data, f, indent=4)
+188
View File
@@ -0,0 +1,188 @@
"""Tests for electricity price prediction abstract/base classes.
Shared `_apply_fees`/`_store_gross_series` plumbing (empty/short-series
validation, non-uniform-spacing fallback, missing-fee-row zero-fill, key
wiring, singleton mechanics) lives in `PricePredictionProviderBase` and is
exercised generically in `test_priceabc.py` - it is not re-tested here. This
module covers what's genuinely specific to `ElecPriceProvider`: the data
record model, config-driven provider identity, and the consumption-fee
formula in `_compute_gross`.
"""
from typing import Optional
from unittest.mock import AsyncMock
import pandas as pd
import pytest
from akkudoktoreos.prediction.elecpriceabc import ElecPriceDataRecord, ElecPriceProvider
from akkudoktoreos.utils.datetimeutil import to_datetime
class _ElecPriceProviderForTest(ElecPriceProvider):
"""Minimal concrete subclass to exercise the abstract ElecPriceProvider base class."""
@classmethod
def provider_id(cls) -> str:
return "ElecPriceProviderForTest"
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""No-op update.
Not exercised by the apply_fees() tests below - they build
raw_price_amt_wh directly and never call update_data() on this
provider - but ElecPriceProvider declares _update_data as abstract,
so a concrete subclass must implement it to be instantiable at all.
"""
return None
class TestElecPriceDataRecord:
"""Tests for ElecPriceDataRecord model."""
def test_marketprice_kwh_computed_from_wh(self):
"""Test that the kWh price is the Wh price scaled by 1000."""
record = ElecPriceDataRecord(elecprice_marketprice_wh=0.0003)
assert record.elecprice_marketprice_wh == 0.0003
assert record.elecprice_marketprice_kwh is not None
assert abs(record.elecprice_marketprice_kwh - 0.3) < 1e-9
def test_marketprice_kwh_none_when_wh_none(self):
"""Test that the kWh price is None when the underlying Wh price is unset."""
record = ElecPriceDataRecord()
assert record.elecprice_marketprice_wh is None
assert record.elecprice_marketprice_kwh is None
def test_marketprice_kwh_zero_when_wh_zero(self):
"""Test that a genuine zero Wh price computes to a zero kWh price, not None."""
record = ElecPriceDataRecord(elecprice_marketprice_wh=0.0)
assert record.elecprice_marketprice_kwh == 0.0
@pytest.fixture
def provider(monkeypatch, config_eos):
"""Fixture to create a concrete ElecPriceProvider instance for testing apply_fees()."""
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceProviderForTest")
_ElecPriceProviderForTest.reset_instance()
return _ElecPriceProviderForTest()
def _patch_keys_to_dataframe(monkeypatch, provider, df_elecfee: pd.DataFrame) -> AsyncMock:
"""Monkeypatch Prediction.keys_to_dataframe to return fixed fee data.
apply_fees() requires a real fee provider to already be registered and
have generated data in the prediction registry for keys_to_dataframe to
return anything - which we sidestep here by mocking the call directly,
so apply_fees() can be tested in isolation.
provider.prediction is a pydantic model with validate_assignment enabled,
so assigning directly onto the *instance* (`provider.prediction.keys_to_dataframe
= mock`) is rejected by pydantic - keys_to_dataframe is a real method, not
a declared field. Patching the *class* method instead is plain attribute
replacement and bypasses pydantic's __setattr__ validation.
"""
mock = AsyncMock(return_value=df_elecfee)
monkeypatch.setattr(type(provider.prediction), "keys_to_dataframe", mock)
return mock
class TestElecPriceProvider:
"""Tests for the ElecPriceProvider base class itself (via a minimal subclass).
Only config-driven behavior is tested here - `enabled()` wiring against
`config.elecprice.provider` specifically. Provider-identity/singleton
mechanics themselves come from PredictionMixin and are covered generically
in test_priceabc.py.
"""
def test_provider_id(self, provider):
"""Test provider ID returns correct value."""
assert provider.provider_id() == "ElecPriceProviderForTest"
def test_invalid_provider(self, provider, monkeypatch):
"""Test requesting an unsupported provider."""
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "<invalid>")
provider.config.reset_settings()
assert not provider.enabled()
class TestElecPriceProviderApplyFees:
"""Tests for ElecPriceProvider._compute_gross(), via _apply_fees(), with keys_to_dataframe() mocked.
Only the consumption-fee formula is under test here. Input validation,
non-uniform-spacing handling, and missing-fee-row zero-fill are shared
`_apply_fees` plumbing, already covered generically in test_priceabc.py
against PricePredictionProviderBase directly.
"""
@pytest.mark.asyncio
async def test_apply_fees_combines_amt_and_percent(self, provider, monkeypatch):
"""Test combined price = (raw + per-Wh fee) * (100 + percent fee) / 100."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0001, 0.0002, 0.0003, 0.0004], index=idx, name="raw_price")
df_elecfee = pd.DataFrame(
{
"elecfee_consumption_amt_wh": [0.000288, 0.000288, 0.00034, 0.00034],
"elecfee_consumption_percent_amt": [19.0, 19.0, 19.0, 19.0],
},
index=idx,
)
mock = _patch_keys_to_dataframe(monkeypatch, provider, df_elecfee)
result = await provider._apply_fees(raw_price_amt_wh)
assert mock.await_count == 1
assert mock.await_args
called_kwargs = mock.await_args.kwargs
# Verifies _fee_keys resolves to the real consumption-fee key names,
# not just that *some* keys get passed through (already covered
# generically in test_priceabc.py).
assert set(called_kwargs["keys"]) == {
"elecfee_consumption_amt_wh",
"elecfee_consumption_percent_amt",
}
assert called_kwargs["start_datetime"] == start_dt
assert called_kwargs["boundary"] == "context"
assert called_kwargs["align_to_interval"] is True
assert result.name == "raw_price"
assert len(result) == 4
assert not result.isna().any()
expected = [
(0.0001 + 0.000288) * (100.0 + 19.0) / 100.0,
(0.0002 + 0.000288) * (100.0 + 19.0) / 100.0,
(0.0003 + 0.00034) * (100.0 + 19.0) / 100.0,
(0.0004 + 0.00034) * (100.0 + 19.0) / 100.0,
]
for i, exp in enumerate(expected):
assert abs(result.iloc[i] - exp) < 1e-9, (
f"interval {i}: expected {exp}, got {result.iloc[i]}"
)
@pytest.mark.asyncio
async def test_apply_fees_zero_percent_fee_passes_amt_fee_through(self, provider, monkeypatch):
"""Test that with a 0% surcharge, the result is raw price plus the per-Wh fee only."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0002] * 4, index=idx)
df_elecfee = pd.DataFrame(
{
"elecfee_consumption_amt_wh": [0.0003] * 4,
"elecfee_consumption_percent_amt": [0.0] * 4,
},
index=idx,
)
_patch_keys_to_dataframe(monkeypatch, provider, df_elecfee)
result = await provider._apply_fees(raw_price_amt_wh)
expected = 0.0002 + 0.0003
for i in range(4):
assert abs(result.iloc[i] - expected) < 1e-9
+13 -4
View File
@@ -25,11 +25,20 @@ FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON = DIR_TESTDATA.joinpath(
@pytest.fixture
def provider(monkeypatch, config_eos):
def provider(config_eos):
"""Fixture to create a ElecPriceProvider instance."""
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceAkkudoktor")
config_eos.reset_settings()
return ElecPriceAkkudoktor()
config_eos.merge_settings_from_dict(
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
},
}
)
provider = ElecPriceAkkudoktor()
provider.highest_orig_datetime = None
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
+337 -14
View File
@@ -4,12 +4,14 @@ from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
import pytest
import requests
from loguru import logger
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecfeefixed import ElecFeeFixed
from akkudoktoreos.prediction.elecpriceakkudoktor import (
AkkudoktorElecPrice,
AkkudoktorElecPriceValue,
@@ -19,7 +21,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import (
ElecPriceEnergyCharts,
EnergyChartsElecPrice,
)
from akkudoktoreos.utils.datetimeutil import to_datetime
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
@@ -29,20 +31,46 @@ FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON = DIR_TESTDATA.joinpath(
@pytest.fixture
def provider(monkeypatch, config_eos):
def provider(config_eos):
"""Fixture to create a ElecPriceProvider instance."""
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceEnergyCharts")
config_eos.reset_settings()
return ElecPriceEnergyCharts()
config_eos.merge_settings_from_dict(
{
"elecprice": {
"provider": "ElecPriceEnergyCharts",
"energycharts": {"bidding_zone": "DE-LU"},
},
}
)
provider = ElecPriceEnergyCharts()
provider.highest_orig_datetime = None
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
def elecfee_provider(config_eos):
"""Fixture to create a ElecFeeFixed instance."""
config_eos.merge_settings_from_dict(
{
"elecfee": {
"provider": "ElecFeeFixed",
},
}
)
provider = ElecFeeFixed()
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
def sample_energycharts_json():
"""Fixture that returns sample forecast data report."""
with FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON.open(
"r", encoding="utf-8", newline=None
) as f_res:
input_data = json.load(f_res)
"""Fixture that returns sample forecast data report."""
return input_data
@@ -69,7 +97,7 @@ class TestElecPriceEnergyCharts:
assert not provider.enabled()
# ------------------------------------------------
# Akkudoktor
# EnergyCharts
# ------------------------------------------------
@patch("akkudoktoreos.prediction.elecpriceenergycharts.logger.error")
@@ -130,16 +158,311 @@ class TestElecPriceEnergyCharts:
@pytest.mark.asyncio
@patch("requests.get")
async def test_update_data_with_incomplete_forecast(self, mock_get, provider):
"""Test `_update_data` with incomplete or missing forecast data."""
incomplete_data: dict = {"license_info": "", "unix_seconds": [], "price": [], "unit": "", "deprecated": False}
async def test_update_data_with_incomplete_forecast(self, mock_get, caplog, provider):
"""Test `_update_data` with incomplete or missing forecast data (cold start, fatal)."""
incomplete_data: dict = {
"license_info": "",
"unix_seconds": [],
"price": [],
"unit": "",
"deprecated": False
}
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(incomplete_data)
mock_get.return_value = mock_response
logger.info("The following errors are intentional and part of the test.")
with pytest.raises(ValueError):
with caplog.at_level("WARNING"):
with pytest.raises(ValueError, match="No Energy-Charts electricity price data available"):
await provider._update_data(force_update=True)
@pytest.mark.asyncio
async def test_update_data_keeps_quarter_hour_resolution(self, provider):
# Use a range that does not overlap the hourly fixture data used by the
# neighbouring tests; the provider is a singleton by design.
start = to_datetime("2025-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
provider.highest_orig_datetime = None
raw_slots = provider.config.prediction.hours * 2
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[100.0] * raw_slots,
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider._update_data(force_update=True)
result = await provider.key_to_series(
key="elecprice_marketprice_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
interval=to_duration("15 minutes"),
)
assert len(result) == provider.config.prediction.hours * 4
assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
@pytest.mark.asyncio
async def test_update_data_adds_fees(self, provider, elecfee_provider, config_eos):
"""Build the gross retail price from market price and the matching Module 3 fee.
Also verifies the raw market price series stays fee-free, since it's what
ETS/median training relies on.
"""
fixed_fees_amt_kwh: float = (
0.0205 # electricity_tax
+ 0.0132 # concession_fee
+ 0.00446 # kwkg_levy
+ 0.01559 # section_19_levy
+ 0.00941 # offshore_grid_levy
)
amt_kwh: list[float] = [ # includes dynamic network fees
0.0095 + fixed_fees_amt_kwh,
0.0953 + fixed_fees_amt_kwh,
0.1565 + fixed_fees_amt_kwh,
0.0953 + fixed_fees_amt_kwh,
]
percent_amt: float = 19.0 # VAT %
config_eos.merge_settings_from_dict(
{
"prediction": {
"hours": 48,
},
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "7 hours", "value": amt_kwh[0]},
{"start_time": "07:00", "duration": "8 hours", "value": amt_kwh[1]},
{"start_time": "15:00", "duration": "5 hours", "value": amt_kwh[2]},
{"start_time": "20:00", "duration": "4 hours", "value": amt_kwh[3]},
],
},
"consumption_percent_amt": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": percent_amt},
],
},
},
},
},
)
ems_eos = get_ems()
start = to_datetime("2026-01-15 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start)
# Create fees prediction
await elecfee_provider._update_data(force_update=True)
timestamps = [start, start.add(hours=7), start.add(hours=15), start.add(hours=20)]
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(timestamp.timestamp()) for timestamp in timestamps],
price=[100.0] * len(timestamps),
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider._update_data(force_update=True)
# Raw series must stay pure market price, unaffected by fees, at every
# timestamp - including the ones covered by the ETS/median-predicted tail.
raw_result = await provider.key_to_series(
key="elecprice_marketprice_raw_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
interval=to_duration("15 minutes"),
)
raw_result_kwh = raw_result * 1000
slots_for_test = (0*4, 7*4, 15*4, 20*4)
for slot in slots_for_test:
assert raw_result_kwh.iloc[slot] == pytest.approx(0.1)
result = await provider.key_to_series(
key="elecprice_marketprice_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
interval=to_duration("15 minutes"),
)
result_kwh = result * 1000
rate_amt = 1.0 + percent_amt / 100.0
for idx, slot in enumerate(slots_for_test):
assert result_kwh.iloc[slot] == pytest.approx((raw_result_kwh.iloc[slot] + amt_kwh[idx]) * rate_amt)
@pytest.mark.asyncio
async def test_update_data_applies_fees_to_predicted_tail(self, provider, elecfee_provider, config_eos):
"""Predicted timestamps beyond the fetched data must still get fees applied.
Regression test for a bug where the ETS/median-extrapolated tail of the
series was written to elecprice_marketprice_wh without ever going through
apply_fees(), silently dropping VAT and all fee components for any
timestamp past what Energy-Charts had actually published.
"""
fixed_fees_amt_kwh: float = (
0.0205 # electricity_tax
+ 0.0132 # concession_fee
+ 0.00446 # kwkg_levy
+ 0.01559 # section_19_levy
+ 0.00941 # offshore_grid_levy
)
amt_kwh: list[float] = [ # includes dynamic network fees
0.0095 + fixed_fees_amt_kwh,
0.0953 + fixed_fees_amt_kwh,
0.1565 + fixed_fees_amt_kwh,
0.0953 + fixed_fees_amt_kwh,
]
percent_amt: float = 19.0 # VAT %
config_eos.merge_settings_from_dict(
{
"prediction": {
"hours": 48,
},
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "7 hours", "value": amt_kwh[0]},
{"start_time": "07:00", "duration": "8 hours", "value": amt_kwh[1]},
{"start_time": "15:00", "duration": "5 hours", "value": amt_kwh[2]},
{"start_time": "20:00", "duration": "4 hours", "value": amt_kwh[3]},
],
},
"consumption_percent_amt": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": percent_amt},
],
},
},
},
"elecprice": {
"provider": "ElecPriceEnergyCharts",
},
},
)
ems_eos = get_ems()
start = to_datetime("2026-01-15 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start)
await elecfee_provider._update_data(force_update=True)
# Only 4 known market-price points, spanning just 20 hours of day 1.
# With a 48h prediction horizon, everything from hour 21 onward has to
# come from the median/ETS fallback rather than from the mocked API data.
timestamps = [start, start.add(hours=7), start.add(hours=15), start.add(hours=20)]
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(timestamp.timestamp()) for timestamp in timestamps],
price=[100.0] * len(timestamps), # 100 EUR/MWh = 0.1 EUR/kWh
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider._update_data(force_update=True)
# Day 2, 06:00 - inside the predicted (non-fetched) range, and inside the
# same 00:00-07:00 fee window as amt_kwh[0] on day 1.
predicted_timestamp = start.add(hours=30)
assert predicted_timestamp <= start.add(hours=provider.config.prediction.hours)
raw_result = await provider.key_to_series(
key="elecprice_marketprice_raw_wh",
start_datetime=predicted_timestamp,
end_datetime=predicted_timestamp.add(minutes=15),
interval=to_duration("15 minutes"),
)
raw_result_kwh = raw_result * 1000
# All four known market prices were equal (0.1 EUR/kWh); ETS on a flat
# series should stay close to that, allowing for optimizer noise.
assert raw_result_kwh.iloc[0] == pytest.approx(0.1, abs=0.01)
result = await provider.key_to_series(
key="elecprice_marketprice_wh",
start_datetime=predicted_timestamp,
end_datetime=predicted_timestamp.add(minutes=15),
interval=to_duration("15 minutes"),
)
result_kwh = result * 1000
rate_amt = 1.0 + percent_amt / 100.0
# Derived from the actually-measured raw value above, not a hardcoded
# 0.1, so this checks fee application on the real predicted price
# rather than re-asserting what the ETS prediction should be.
assert result_kwh.iloc[0] == pytest.approx((raw_result_kwh.iloc[0] + amt_kwh[0]) * rate_amt)
@pytest.mark.asyncio
async def test_update_data_covers_full_horizon_after_stale_fetch_outage(self, provider):
"""Regression test: needed_slots must include the gap when a fetch outage
leaves highest_orig_datetime behind the current ems_start_datetime.
Before the fix, `covered_slots` was clamped to 0 whenever
highest_orig_datetime was older than ems_start_datetime, instead of
being allowed to go negative. That left `needed_slots` at only
`prediction.hours * slots_per_hour`, so the predicted tail only
reached `highest_orig_datetime + prediction.hours` - ending before
the actually-requested `ems_start_datetime + prediction.hours`
whenever an outage persisted long enough for the two to diverge.
"""
provider.config.prediction.hours = 48
start = to_datetime("2026-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
# Seed enough 15-minute history for the weekly-ETS branch of _predict.
raw_start = start.subtract(days=35)
raw_slots = int((start - raw_start).total_seconds() // 900) + 1
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(raw_start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[50.0 + float(i % 96) for i in range(raw_slots)],
unit="EUR/MWh",
deprecated=False,
)
def fake_ets(history, seasonal_periods, hours):
return np.full(hours, 0.00005)
with (
patch.object(provider, "_request_forecast", return_value=energy_charts_data),
patch.object(ElecPriceEnergyCharts, "_predict_ets", side_effect=fake_ets),
):
await provider.update_data(force_enable=True, force_update=True)
last_good = provider.highest_orig_datetime
assert last_good is not None
# Advance ems_start_datetime well past the last known data point, as
# if a fetch outage has persisted for a while - highest_orig_datetime
# is now *before* ems_start_datetime, not just close behind it.
outage_gap_hours = 20
new_start = to_datetime(last_good).add(hours=outage_gap_hours)
get_ems().set_start_datetime(new_start)
with (
patch.object(
provider, "_request_forecast", side_effect=requests.exceptions.ReadTimeout("boom")
),
patch.object(ElecPriceEnergyCharts, "_predict_ets", side_effect=fake_ets),
):
await provider.update_data(force_enable=True, force_update=True)
# Fallback kept the stale history rather than raising (cold-start
# fatality only applies when there's no history at all).
assert provider.highest_orig_datetime == last_good
# The predicted series must reach the end of the horizon measured
# from the *current* ems_start_datetime - i.e. it must also backfill
# the outage_gap_hours gap, not just prediction.hours beyond the
# stale highest_orig_datetime.
horizon_end = new_start.add(hours=provider.config.prediction.hours)
raw_result = await provider.key_to_series(
key="elecprice_marketprice_raw_wh",
start_datetime=horizon_end.subtract(minutes=15),
end_datetime=horizon_end,
interval=to_duration("15 minutes"),
)
assert len(raw_result) == 1
assert not raw_result.isna().any()
@pytest.mark.parametrize(
"status_code, exception",
@@ -226,8 +549,8 @@ class TestElecPriceEnergyCharts:
f"Bidding zone in URL looks like an enum repr: '{bzn_value}'. "
f"Use .value when building the URL, not str(enum)."
)
assert bzn_value == provider.config.elecprice.energycharts.bidding_zone.value, (
f"Expected bzn='{provider.config.elecprice.energycharts.bidding_zone.value}' "
assert bzn_value == provider.config.elecprice.energycharts.bidding_zone, (
f"Expected bzn='{provider.config.elecprice.energycharts.bidding_zone}' "
f"but got bzn='{bzn_value}' in URL: {actual_url}"
)
+32 -26
View File
@@ -28,7 +28,7 @@ class TestElecPriceFixedCommonSettings:
def test_create_settings_with_windows(self):
"""Test creating settings with time windows."""
settings_dict = {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": [
{
"start_time": "00:00",
@@ -46,25 +46,30 @@ class TestElecPriceFixedCommonSettings:
settings = ElecPriceFixedCommonSettings(**settings_dict)
assert settings is not None
assert settings.time_windows is not None
assert settings.time_windows.windows is not None
assert len(settings.time_windows.windows) == 2
assert settings.elecprice_marketprice_amt_kwh is not None
assert settings.elecprice_marketprice_amt_kwh.windows is not None
assert len(settings.elecprice_marketprice_amt_kwh.windows) == 2
def test_create_settings_without_windows(self):
"""Test creating settings without time windows."""
settings = ElecPriceFixedCommonSettings()
assert settings.time_windows is not None
assert settings.time_windows.windows == []
assert settings.elecprice_marketprice_amt_kwh is not None
assert settings.elecprice_marketprice_amt_kwh.windows == []
@pytest.fixture
def provider(monkeypatch, config_eos):
def provider(config_eos):
"""Fixture to create a ElecPriceFixed provider instance."""
# Set environment variables
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceFixed")
# Create settings and assign to config
config_eos.merge_settings_from_dict(
{
"elecprice": {
"provider": "ElecPriceFixed",
},
}
)
# Create time windows
time_windows = ValueTimeWindowSequence(
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="00:00",
@@ -78,12 +83,11 @@ def provider(monkeypatch, config_eos):
)
]
)
# Create settings and assign to config
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(time_windows=time_windows)
ElecPriceFixed.reset_instance()
return ElecPriceFixed()
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(elecprice_marketprice_amt_kwh=elecprice_marketprice_amt_kwh)
provider = ElecPriceFixed()
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
@@ -144,23 +148,25 @@ class TestElecPriceFixed:
)
@pytest.mark.asyncio
async def test_update_data_without_config(self, provider, config_eos):
async def test_update_data_without_config(self, caplog, provider, config_eos):
"""Test update_data fails without configuration."""
# Remove elecpricefixed settings
config_eos.elecprice.elecpricefixed = {}
with pytest.raises(ValueError, match="No time windows configured"):
with caplog.at_level("WARNING"):
await provider.update_data(force_enable=True, force_update=True)
assert "No time windows configured for `elecprice_marketprice_raw_wh`" in caplog.text
@pytest.mark.asyncio
async def test_update_data_without_time_windows(self, provider, config_eos):
async def test_update_data_without_elecprice_marketprice_amt_kwh(self, caplog, provider, config_eos):
"""Test update_data fails without time windows."""
# Set empty time windows
empty_settings = ElecPriceFixedCommonSettings(time_windows=ValueTimeWindowSequence(windows=[]))
empty_settings = ElecPriceFixedCommonSettings(elecprice_marketprice_amt_kwh=ValueTimeWindowSequence(windows=[]))
config_eos.elecprice.elecpricefixed = empty_settings
with pytest.raises(ValueError, match="No time windows configured"):
with caplog.at_level("WARNING"):
await provider.update_data(force_enable=True, force_update=True)
assert "No time windows configured for `elecprice_marketprice_raw_wh`" in caplog.text
@pytest.mark.asyncio
async def test_key_to_array_resampling(self, provider, config_eos):
@@ -230,7 +236,7 @@ class TestElecPriceFixedIntegration:
ems_eos.set_start_datetime(start_dt)
# Configure with realistic German electricity prices (2024)
time_windows = ValueTimeWindowSequence(
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="00:00",
@@ -245,7 +251,7 @@ class TestElecPriceFixedIntegration:
]
)
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(time_windows=time_windows)
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(elecprice_marketprice_amt_kwh=elecprice_marketprice_amt_kwh)
config_eos.prediction.hours = 168 # 7 days
# Update data
@@ -257,13 +263,13 @@ class TestElecPriceFixedIntegration:
# Save configuration for documentation
config_data = {
"time_windows": [
"elecprice_marketprice_amt_kwh": [
{
"start_time": str(window.start_time),
"duration": str(window.duration),
"value": window.value
}
for window in config_eos.elecprice.elecpricefixed.time_windows.windows
for window in config_eos.elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows
]
}
+79
View File
@@ -0,0 +1,79 @@
# ruff: noqa: S101
import json
from unittest.mock import Mock, patch
import pytest
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARD
from akkudoktoreos.utils.datetimeutil import to_datetime
@pytest.fixture
def provider(config_eos):
"""Configure and return the direct SMARD singleton provider."""
config_eos.elecprice = ElecPriceCommonSettings(provider="ElecPriceSMARD")
get_ems().set_start_datetime(
to_datetime("2026-07-27 00:00:00", in_timezone="Europe/Berlin")
)
provider = ElecPriceSMARD()
provider.highest_orig_datetime = None
assert provider.enabled()
provider._db_reset_state()
return provider
def _response(payload):
response = Mock()
response.content = json.dumps(payload).encode()
response.raise_for_status.return_value = None
return response
@patch("akkudoktoreos.prediction.elecpricesmard.requests.get")
def test_request_forecast_fetches_index_and_overlapping_chunks(mock_get, provider):
"""SMARD index and weekly chunks are combined, sorted, and stripped of null values."""
chunk_start = 1785103200000
mock_get.side_effect = [
_response({"timestamps": [chunk_start]}),
_response(
{
"meta_data": {"version": 1, "created": 1785500527370},
"series": [
[1785103200000, 86.04],
[1785106800000, None],
[1785110400000, -1.25],
],
}
),
]
result = provider._request_forecast(
start_date="2026-07-27", force_update=True
)
assert result.unix_seconds == [1785103200, 1785110400]
assert result.price == [86.04, -1.25]
assert result.license_info == "CC BY 4.0 Bundesnetzagentur | SMARD.de"
assert mock_get.call_count == 2
assert mock_get.call_args_list[0].args[0].endswith("/4169/DE/index_quarterhour.json")
assert mock_get.call_args_list[1].args[0].endswith(
"/4169/DE/4169_DE_quarterhour_1785103200000.json"
)
def test_chunk_selection_includes_preceding_overlapping_chunk(provider):
"""A range beginning mid-week includes the chunk that started before it."""
index = provider._validate_index(
json.dumps({"timestamps": [1000, 2000, 3000]}).encode()
)
start = to_datetime(2.5, in_timezone="UTC")
end = to_datetime(3.5, in_timezone="UTC")
assert provider._chunk_timestamps(index, start, end) == [2000, 3000]
def test_smard_provider_is_enabled(provider):
assert provider.enabled()
+2 -2
View File
@@ -56,14 +56,14 @@ def _tibber_payload(
@pytest.fixture
def provider(config_eos):
"""Create a fresh Tibber electricity price provider."""
ElecPriceTibber.reset_instance()
config_eos.elecprice = ElecPriceCommonSettings(
provider="ElecPriceTibber",
tibber=ElecPriceTibberCommonSettings(access_token="token-123", home_id="home-1"),
)
config_eos.prediction.hours = 6
provider = ElecPriceTibber()
provider.records.clear()
assert provider.enabled()
provider._db_reset_state()
return provider
+191
View File
@@ -0,0 +1,191 @@
"""Tests for feed in tariff prediction abstract/base classes.
Shared `_apply_fees`/`_store_gross_series` plumbing (empty/short-series
validation, non-uniform-spacing fallback, missing-fee-row zero-fill, key
wiring, singleton mechanics) lives in `PricePredictionProviderBase` and is
exercised generically in `test_priceabc.py` - it is not re-tested here. This
module covers what's genuinely specific to `FeedInTariffProvider`: the data
record model, config-driven provider identity, and the feed-in-fee formula in
`_compute_gross`.
"""
from typing import Optional
from unittest.mock import AsyncMock
import pandas as pd
import pytest
from akkudoktoreos.prediction.feedintariffabc import (
FeedInTariffDataRecord,
FeedInTariffProvider,
)
from akkudoktoreos.utils.datetimeutil import to_datetime
class _FeedInTariffProviderForTest(FeedInTariffProvider):
"""Minimal concrete subclass to exercise the abstract FeedInTariffProvider base class."""
@classmethod
def provider_id(cls) -> str:
return "FeedInTariffProviderForTest"
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""No-op update.
Not exercised by the apply_fees() tests below - they build
raw_price_amt_wh directly and never call update_data() on this
provider - but FeedInTariffProvider declares _update_data as abstract,
so a concrete subclass must implement it to be instantiable at all.
"""
return None
class TestFeedInTariffDataRecord:
"""Tests for FeedInTariffDataRecord model."""
def test_tariff_kwh_computed_from_wh(self):
"""Test that the kWh tariff is the Wh tariff scaled by 1000."""
record = FeedInTariffDataRecord(feed_in_tariff_wh=0.0003)
assert record.feed_in_tariff_wh == 0.0003
assert record.feed_in_tariff_kwh is not None
assert abs(record.feed_in_tariff_kwh - 0.3) < 1e-9
def test_tariff_kwh_none_when_wh_none(self):
"""Test that the kWh tariff is None when the underlying Wh tariff is unset."""
record = FeedInTariffDataRecord()
assert record.feed_in_tariff_wh is None
assert record.feed_in_tariff_kwh is None
def test_tariff_kwh_zero_when_wh_zero(self):
"""Test that a genuine zero Wh tariff computes to a zero kWh tariff, not None."""
record = FeedInTariffDataRecord(feed_in_tariff_wh=0.0)
assert record.feed_in_tariff_kwh == 0.0
@pytest.fixture
def provider(monkeypatch, config_eos):
"""Fixture to create a concrete FeedInTariffProvider instance for testing apply_fees()."""
monkeypatch.setenv("EOS_FEEDINTARIFF__FEEDINTARIFF_PROVIDER", "FeedInTariffProviderForTest")
_FeedInTariffProviderForTest.reset_instance()
return _FeedInTariffProviderForTest()
def _patch_keys_to_dataframe(monkeypatch, provider, df_elecfee: pd.DataFrame) -> AsyncMock:
"""Monkeypatch Prediction.keys_to_dataframe to return fixed fee data.
apply_fees() requires a real fee provider to already be registered and
have generated data in the prediction registry for keys_to_dataframe to
return anything - which we sidestep here by mocking the call directly,
so apply_fees() can be tested in isolation.
provider.prediction is a pydantic model with validate_assignment enabled,
so assigning directly onto the *instance* (`provider.prediction.keys_to_dataframe
= mock`) is rejected by pydantic - keys_to_dataframe is a real method, not
a declared field. Patching the *class* method instead is plain attribute
replacement and bypasses pydantic's __setattr__ validation.
"""
mock = AsyncMock(return_value=df_elecfee)
monkeypatch.setattr(type(provider.prediction), "keys_to_dataframe", mock)
return mock
class TestFeedInTariffProvider:
"""Tests for the FeedInTariffProvider base class itself (via a minimal subclass).
Only config-driven behavior is tested here - `enabled()` wiring against
`config.feedintariff.provider` specifically. Provider-identity/singleton
mechanics themselves come from PredictionMixin and are covered generically
in test_priceabc.py.
"""
def test_provider_id(self, provider):
"""Test provider ID returns correct value."""
assert provider.provider_id() == "FeedInTariffProviderForTest"
def test_invalid_provider(self, provider, monkeypatch):
"""Test requesting an unsupported provider."""
monkeypatch.setenv("EOS_FEEDINTARIFF__FEEDINTARIFF_PROVIDER", "<invalid>")
provider.config.reset_settings()
assert not provider.enabled()
class TestFeedInTariffProviderApplyFees:
"""Tests for FeedInTariffProvider._compute_gross(), via _apply_fees(), with keys_to_dataframe() mocked.
Only the feed-in-fee formula is under test here. Input validation,
non-uniform-spacing handling, and missing-fee-row zero-fill are shared
`_apply_fees` plumbing, already covered generically in test_priceabc.py
against PricePredictionProviderBase directly.
"""
@pytest.mark.asyncio
async def test_apply_fees_combines_amt_and_percent(self, provider, monkeypatch):
"""Test combined tariff = raw * (100 - percent fee) / 100 - per-Wh fee."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0001, 0.0002, 0.0003, 0.0004], index=idx, name="raw_price")
df_elecfee = pd.DataFrame(
{
"elecfee_feedin_amt_wh": [0.000288, 0.000288, 0.00034, 0.00034],
"elecfee_feedin_percent_amt": [19.0, 19.0, 19.0, 19.0],
},
index=idx,
)
mock = _patch_keys_to_dataframe(monkeypatch, provider, df_elecfee)
result = await provider._apply_fees(raw_price_amt_wh)
assert mock.await_count == 1
assert mock.await_args
called_kwargs = mock.await_args.kwargs
# Verifies _fee_keys resolves to the real feed-in-fee key names, not
# just that *some* keys get passed through (already covered
# generically in test_priceabc.py).
assert set(called_kwargs["keys"]) == {
"elecfee_feedin_amt_wh",
"elecfee_feedin_percent_amt",
}
assert called_kwargs["start_datetime"] == start_dt
assert called_kwargs["boundary"] == "context"
assert called_kwargs["align_to_interval"] is True
assert result.name == "raw_price"
assert len(result) == 4
assert not result.isna().any()
expected = [
0.0001 * (100.0 - 19.0) / 100.0 - 0.000288,
0.0002 * (100.0 - 19.0) / 100.0 - 0.000288,
0.0003 * (100.0 - 19.0) / 100.0 - 0.00034,
0.0004 * (100.0 - 19.0) / 100.0 - 0.00034,
]
for i, exp in enumerate(expected):
assert abs(result.iloc[i] - exp) < 1e-9, (
f"interval {i}: expected {exp}, got {result.iloc[i]}"
)
@pytest.mark.asyncio
async def test_apply_fees_zero_percent_fee_subtracts_amt_fee_only(self, provider, monkeypatch):
"""Test that with a 0% deduction, the result is raw tariff minus the per-Wh fee only."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0002] * 4, index=idx)
df_elecfee = pd.DataFrame(
{
"elecfee_feedin_amt_wh": [0.0003] * 4,
"elecfee_feedin_percent_amt": [0.0] * 4,
},
index=idx,
)
_patch_keys_to_dataframe(monkeypatch, provider, df_elecfee)
result = await provider._apply_fees(raw_price_amt_wh)
expected = 0.0002 - 0.0003
for i in range(4):
assert abs(result.iloc[i] - expected) < 1e-9
+5 -6
View File
@@ -15,15 +15,14 @@ from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
def provider(config_eos):
config_eos.merge_settings_from_dict(
{
"elecprice": {"charges_kwh": 0.30},
"feedintariff": {"provider": "FeedInTariffAkkudoktor"},
}
)
value = FeedInTariffAkkudoktor()
value.highest_orig_datetime = None
value.records.clear()
assert value.enabled()
return value
provider = FeedInTariffAkkudoktor()
provider.highest_orig_datetime = None
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
+4 -1
View File
@@ -25,7 +25,10 @@ def provider(config_eos):
config_eos.merge_settings_from_dict(
{"feedintariff": {"provider": "FeedInTariffDvhubOnline"}}
)
return FeedInTariffDvhubOnline()
provider = FeedInTariffDvhubOnline()
assert provider.enabled()
provider._db_reset_state()
return provider
class TestFeedInTariffDvhubOnline:
+190 -10
View File
@@ -5,16 +5,17 @@ from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
import pytest
import requests
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecfeefixed import ElecFeeFixed
from akkudoktoreos.prediction.elecpriceenergycharts import (
ElecPriceEnergyCharts,
EnergyChartsElecPrice,
)
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.utils.datetimeutil import to_datetime
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
@@ -35,8 +36,25 @@ def provider(config_eos):
)
provider = FeedInTariffEnergyCharts()
provider.highest_orig_datetime = None
provider.records.clear()
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
def elecfee_provider(config_eos):
"""Fixture to create a ElecFeeFixed instance."""
config_eos.merge_settings_from_dict(
{
"elecfee": {
"provider": "ElecFeeFixed",
},
}
)
provider = ElecFeeFixed()
assert provider.enabled()
provider._db_reset_state()
return provider
@@ -94,7 +112,7 @@ class TestFeedInTariffEnergyCharts:
await provider._update_data(force_update=True)
result = await provider.key_to_raw_series(
key="feed_in_tariff_wh",
key="feed_in_tariff_raw_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
)
@@ -127,9 +145,9 @@ class TestFeedInTariffEnergyCharts:
with (
patch.object(provider, "_request_forecast", return_value=energy_charts_data) as request,
patch.object(ElecPriceEnergyCharts, "_predict_ets", side_effect=fake_ets),
patch.object(FeedInTariffEnergyCharts, "_predict_ets", side_effect=fake_ets),
patch.object(
ElecPriceEnergyCharts,
FeedInTariffEnergyCharts,
"_predict_median",
side_effect=AssertionError("median fallback must not be used"),
),
@@ -141,7 +159,7 @@ class TestFeedInTariffEnergyCharts:
# second update reuses the retained 35-day history without another request.
assert request.call_count == 1
assert len(ets_history_lengths) == 2
assert all(length > 800 * 4 for length, _ in ets_history_lengths)
assert all(length > 2 * 168 * 4 for length, _ in ets_history_lengths)
assert all(seasonal_periods == 168 * 4 for _, seasonal_periods in ets_history_lengths)
await provider.update_data(force_enable=True, force_update=True)
@@ -185,10 +203,10 @@ class TestFeedInTariffEnergyCharts:
deprecated=False,
)
def fake_predict(history, slots, slots_per_hour):
return np.full(slots, 0.00005)
def fake_predict(history, hours, slots_per_hour=1):
return np.full(hours, 0.00005)
with patch.object(provider, "_predict_prices", side_effect=fake_predict):
with patch.object(provider, "_predict", side_effect=fake_predict):
# First: successful update seeds history and highest_orig_datetime.
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider.update_data(force_enable=True, force_update=True)
@@ -218,3 +236,165 @@ class TestFeedInTariffEnergyCharts:
):
with pytest.raises(requests.exceptions.ReadTimeout):
await provider.update_data(force_enable=True, force_update=True)
@pytest.mark.asyncio
async def test_update_data_no_fees_configured_defaults_gross_to_raw(self, provider):
"""Without an ElecFee provider configured, feed_in_tariff_wh must equal the raw series.
_apply_fees() catches the KeyError from an absent ElecFee provider and
defaults both fee components to 0, so gross should be indistinguishable
from raw in that case.
"""
start = to_datetime("2025-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
raw_slots = provider.config.prediction.hours * 4
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[100.0] * raw_slots,
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider._update_data(force_update=True)
raw = await provider.key_to_series(
key="feed_in_tariff_raw_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
interval=to_duration("15 minutes"),
)
gross = await provider.key_to_series(
key="feed_in_tariff_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
interval=to_duration("15 minutes"),
)
pd.testing.assert_series_equal(gross, raw, check_names=False)
@pytest.mark.asyncio
async def test_update_data_applies_feedin_fees(self, provider, config_eos):
"""feed_in_tariff_wh must reflect the configured feed-in fee deduction.
Per _apply_fees(): gross = raw * (100 - percent_amt) / 100 - amt_wh,
i.e. fees are deducted from what the producer receives, the inverse
direction of the consumption-side markup.
"""
feedin_amt_kwh = 0.001 # flat fee/kWh deducted from the feed-in payout
feedin_percent_amt = 5.0 # percentage deducted from the feed-in payout
config_eos.merge_settings_from_dict(
{
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"feedin_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": feedin_amt_kwh},
],
},
"feedin_percent_amt": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": feedin_percent_amt},
],
},
},
},
}
)
start = to_datetime("2025-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
await ElecFeeFixed()._update_data(force_update=True)
raw_slots = provider.config.prediction.hours * 4
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[100.0] * raw_slots, # 100 EUR/MWh = 0.1 EUR/kWh
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider._update_data(force_update=True)
raw_result = await provider.key_to_series(
key="feed_in_tariff_raw_wh",
start_datetime=start,
end_datetime=start.add(minutes=15),
interval=to_duration("15 minutes"),
)
gross_result = await provider.key_to_series(
key="feed_in_tariff_wh",
start_datetime=start,
end_datetime=start.add(minutes=15),
interval=to_duration("15 minutes"),
)
raw_kwh = raw_result.iloc[0] * 1000
gross_kwh = gross_result.iloc[0] * 1000
assert raw_kwh == pytest.approx(0.1)
expected_gross_kwh = raw_kwh * (100.0 - feedin_percent_amt) / 100.0 - feedin_amt_kwh
assert gross_kwh == pytest.approx(expected_gross_kwh)
@pytest.mark.asyncio
async def test_update_data_covers_full_horizon_after_stale_fetch_outage(self, provider):
"""Regression test: needed_slots must include the gap when a fetch outage
leaves highest_orig_datetime behind the current ems_start_datetime.
Same bug/fix as ElecPriceEnergyCharts: `covered_slots` must be allowed
to go negative when highest_orig_datetime is older than
ems_start_datetime, rather than clamped to 0, or the predicted tail
ends before ems_start_datetime + prediction.hours whenever an outage
persists long enough for the two to diverge.
"""
provider.config.prediction.hours = 48
start = to_datetime("2026-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
raw_start = start.subtract(days=35)
raw_slots = int((start - raw_start).total_seconds() // 900) + 1
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(raw_start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[50.0 + float(i % 96) for i in range(raw_slots)],
unit="EUR/MWh",
deprecated=False,
)
def fake_ets(history, seasonal_periods, hours):
return np.full(hours, 0.00005)
with (
patch.object(provider, "_request_forecast", return_value=energy_charts_data),
patch.object(FeedInTariffEnergyCharts, "_predict_ets", side_effect=fake_ets),
):
await provider.update_data(force_enable=True, force_update=True)
last_good = provider.highest_orig_datetime
assert last_good is not None
# Advance ems_start_datetime well past the last known data point, as
# if a fetch outage has persisted for a while.
outage_gap_hours = 20
new_start = to_datetime(last_good).add(hours=outage_gap_hours)
get_ems().set_start_datetime(new_start)
with (
patch.object(
provider, "_request_forecast", side_effect=requests.exceptions.ReadTimeout("boom")
),
patch.object(FeedInTariffEnergyCharts, "_predict_ets", side_effect=fake_ets),
):
await provider.update_data(force_enable=True, force_update=True)
assert provider.highest_orig_datetime == last_good
horizon_end = new_start.add(hours=provider.config.prediction.hours)
raw_result = await provider.key_to_series(
key="feed_in_tariff_raw_wh",
start_datetime=horizon_end.subtract(minutes=15),
end_datetime=horizon_end,
interval=to_duration("15 minutes"),
)
assert len(raw_result) == 1
assert not raw_result.isna().any()
+7 -3
View File
@@ -12,19 +12,23 @@ DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
@pytest.fixture
def provider(config_eos):
"""Fixture to create a ElecPriceProvider instance."""
"""Fixture to create a FeedInTariffProvider instance."""
settings = {
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": 0.078,
"feed_in_tariff_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": 0.078},
],
},
},
}
}
config_eos.merge_settings_from_dict(settings)
assert config_eos.feedintariff.provider == "FeedInTariffFixed"
provider = FeedInTariffFixed()
assert provider.enabled()
provider._db_reset_state()
return provider
+41
View File
@@ -0,0 +1,41 @@
# ruff: noqa: S101
from unittest.mock import patch
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecpriceenergycharts import EnergyChartsElecPrice
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARD
from akkudoktoreos.prediction.feedintariffsmard import FeedInTariffSMARD
from akkudoktoreos.utils.datetimeutil import to_datetime
def test_feed_in_tariff_smard_reuses_raw_smard_market_prices(config_eos):
"""The feed-in provider delegates to SMARD and stores no import-price components."""
config_eos.merge_settings_from_dict(
{
"elecprice": {"provider": "ElecPriceSMARD"},
"feedintariff": {
"direct_marketing_enabled": True,
"provider": "FeedInTariffSMARD",
},
}
)
get_ems().set_start_datetime(
to_datetime("2026-08-01 00:00:00", in_timezone="Europe/Berlin")
)
provider = FeedInTariffSMARD()
data = EnergyChartsElecPrice(
license_info="CC BY 4.0 Bundesnetzagentur | SMARD.de",
unix_seconds=[1785535200],
price=[169.44],
unit="EUR/MWh",
deprecated=False,
)
with patch.object(ElecPriceSMARD, "_request_forecast", return_value=data) as request:
result = provider._request_forecast(start_date="2026-08-01", force_update=True)
assert provider.enabled()
assert result is data
assert provider._parse_data(result).iloc[0] == 169.44 / 1_000_000
request.assert_called_once_with(start_date="2026-08-01", force_update=True)
+4 -4
View File
@@ -45,7 +45,6 @@ def quarter_hour_points() -> list[dict]:
@pytest.fixture
def provider(config_eos):
"""Create a fresh Tibber feed-in tariff provider."""
FeedInTariffTibber.reset_instance()
config_eos.merge_settings_from_dict(
{
"elecprice": {"tibber": {"access_token": "token-123", "home_id": "home-1"}},
@@ -55,12 +54,13 @@ def provider(config_eos):
"prediction": {"hours": 2},
}
)
provider = FeedInTariffTibber()
provider.records.clear()
provider.highest_orig_datetime = None
get_ems().set_start_datetime(
to_datetime("2026-07-15T00:00:00+02:00", in_timezone="Europe/Berlin")
)
provider = FeedInTariffTibber()
provider.highest_orig_datetime = None
assert provider.enabled()
provider._db_reset_state()
return provider
+38 -22
View File
@@ -2,16 +2,20 @@ import pytest
from pydantic import ValidationError
from akkudoktoreos.core.coreabc import get_prediction
from akkudoktoreos.prediction.elecfeefixed import ElecFeeFixed
from akkudoktoreos.prediction.elecfeeimport import ElecFeeImport
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARD
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
from akkudoktoreos.prediction.feedintariffakkudoktor import FeedInTariffAkkudoktor
from akkudoktoreos.prediction.feedintariffdvhubonline import FeedInTariffDvhubOnline
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.feedintariffsmard import FeedInTariffSMARD
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber
from akkudoktoreos.prediction.loadakkudoktor import (
LoadAkkudoktor,
@@ -50,16 +54,20 @@ def forecast_providers():
WeatherClearOutside(),
WeatherImport(),
WeatherOpenMeteo(),
ElecFeeFixed(),
ElecFeeImport(),
ElecPriceAkkudoktor(),
ElecPriceEnergyCharts(),
ElecPriceFixed(),
ElecPriceImport(),
ElecPriceSMARD(),
ElecPriceTibber(),
FeedInTariffAkkudoktor(),
FeedInTariffDvhubOnline(),
FeedInTariffEnergyCharts(),
FeedInTariffFixed(),
FeedInTariffImport(),
FeedInTariffSMARD(),
FeedInTariffTibber(),
LoadAkkudoktor(),
LoadAkkudoktorAdjusted(),
@@ -108,28 +116,32 @@ def test_provider_sequence(prediction):
assert isinstance(prediction.providers[1], WeatherClearOutside)
assert isinstance(prediction.providers[2], WeatherImport)
assert isinstance(prediction.providers[3], WeatherOpenMeteo)
assert isinstance(prediction.providers[4], ElecPriceAkkudoktor)
assert isinstance(prediction.providers[5], ElecPriceEnergyCharts)
assert isinstance(prediction.providers[6], ElecPriceFixed)
assert isinstance(prediction.providers[7], ElecPriceImport)
assert isinstance(prediction.providers[8], ElecPriceTibber)
assert isinstance(prediction.providers[9], FeedInTariffAkkudoktor)
assert isinstance(prediction.providers[10], FeedInTariffDvhubOnline)
assert isinstance(prediction.providers[11], FeedInTariffEnergyCharts)
assert isinstance(prediction.providers[12], FeedInTariffFixed)
assert isinstance(prediction.providers[13], FeedInTariffImport)
assert isinstance(prediction.providers[14], FeedInTariffTibber)
assert isinstance(prediction.providers[15], LoadAkkudoktor)
assert isinstance(prediction.providers[16], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[17], LoadImport)
assert isinstance(prediction.providers[18], LoadVrm)
assert isinstance(prediction.providers[19], PVForecastAkkudoktor)
assert isinstance(prediction.providers[20], PVForecastForecastSolar)
assert isinstance(prediction.providers[21], PVForecastImport)
assert isinstance(prediction.providers[22], PVForecastPVLib)
assert isinstance(prediction.providers[23], PVForecastPVNode)
assert isinstance(prediction.providers[24], PVForecastSolcast)
assert isinstance(prediction.providers[25], PVForecastVrm)
assert isinstance(prediction.providers[4], ElecFeeFixed)
assert isinstance(prediction.providers[5], ElecFeeImport)
assert isinstance(prediction.providers[6], ElecPriceAkkudoktor)
assert isinstance(prediction.providers[7], ElecPriceEnergyCharts)
assert isinstance(prediction.providers[8], ElecPriceFixed)
assert isinstance(prediction.providers[9], ElecPriceImport)
assert isinstance(prediction.providers[10], ElecPriceSMARD)
assert isinstance(prediction.providers[11], ElecPriceTibber)
assert isinstance(prediction.providers[12], FeedInTariffAkkudoktor)
assert isinstance(prediction.providers[13], FeedInTariffDvhubOnline)
assert isinstance(prediction.providers[14], FeedInTariffEnergyCharts)
assert isinstance(prediction.providers[15], FeedInTariffFixed)
assert isinstance(prediction.providers[16], FeedInTariffImport)
assert isinstance(prediction.providers[17], FeedInTariffSMARD)
assert isinstance(prediction.providers[18], FeedInTariffTibber)
assert isinstance(prediction.providers[19], LoadAkkudoktor)
assert isinstance(prediction.providers[20], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[21], LoadImport)
assert isinstance(prediction.providers[22], LoadVrm)
assert isinstance(prediction.providers[23], PVForecastAkkudoktor)
assert isinstance(prediction.providers[24], PVForecastForecastSolar)
assert isinstance(prediction.providers[25], PVForecastImport)
assert isinstance(prediction.providers[26], PVForecastPVLib)
assert isinstance(prediction.providers[27], PVForecastPVNode)
assert isinstance(prediction.providers[28], PVForecastSolcast)
assert isinstance(prediction.providers[29], PVForecastVrm)
def test_provider_by_id(prediction, forecast_providers):
@@ -142,16 +154,20 @@ def test_prediction_repr(prediction):
"""Test that the Prediction instance's representation is correct."""
result = repr(prediction)
assert "Prediction([" in result
assert "ElecFeeFixed" in result
assert "ElecFeeImport" in result
assert "ElecPriceAkkudoktor" in result
assert "ElecPriceEnergyCharts" in result
assert "ElecPriceFixed" in result
assert "ElecPriceImport" in result
assert "ElecPriceSMARD" in result
assert "ElecPriceTibber" in result
assert "FeedInTariffAkkudoktor" in result
assert "FeedInTariffDvhubOnline" in result
assert "FeedInTariffEnergyCharts" in result
assert "FeedInTariffFixed" in result
assert "FeedInTariffImport" in result
assert "FeedInTariffSMARD" in result
assert "FeedInTariffTibber" in result
assert "LoadAkkudoktor" in result
assert "LoadAkkudoktorAdjusted" in result
+316
View File
@@ -0,0 +1,316 @@
"""Tests for the shared price prediction base class (PricePredictionProviderBase).
Covers the logic that lives in `priceabc.py` itself - the forecasting helpers,
`_apply_fees` plumbing (index normalization, fee fetch/fallback, zero-fill), and
`_store_gross_series` wiring via the `_raw_key`/`_gross_key`/`_fee_keys`/
`_compute_gross` hooks - independent of any concrete provider's fee formula.
Provider-specific tests (the actual `_compute_gross` formula for electricity
price vs. feed-in tariff, and end-to-end behavior with a real fee provider)
belong in `test_elecpriceabc.py` / `test_feedintariffabc.py` /
`test_elecpricenergycharts.py` instead.
"""
from typing import List, Optional
from unittest.mock import AsyncMock
import pandas as pd
import pytest
from pydantic import Field
from akkudoktoreos.prediction.predictionabc import PredictionRecord
from akkudoktoreos.prediction.priceabc import PricePredictionProviderBase
from akkudoktoreos.utils.datetimeutil import to_datetime
class _PriceProviderForTest(PricePredictionProviderBase):
"""Minimal concrete subclass to exercise PricePredictionProviderBase directly.
Implements `_compute_gross` with the same add-then-percent formula as
ElecPriceProvider, but that choice is incidental here - these tests target
the shared plumbing in `_apply_fees`/`_store_gross_series`, not the formula
itself, so any well-defined formula would do.
"""
records: List[PredictionRecord] = Field(
default_factory=list,
json_schema_extra={"description": "List of PredictionRecord records"},
)
@classmethod
def provider_id(cls) -> str:
return "PriceProviderForTest"
def enabled(self) -> bool:
return True
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""No-op update.
Not exercised by the tests below - they either build raw price series
directly or mock `key_to_raw_series`/`key_from_series` - but
`PredictionProvider` declares `_update_data` as abstract, so a concrete
subclass must implement it to be instantiable at all.
"""
return None
@property
def _raw_key(self) -> str:
return "test_price_raw_wh"
@property
def _gross_key(self) -> str:
return "test_price_wh"
@property
def _fee_keys(self) -> list[str]:
return ["test_fee_amt_wh", "test_fee_percent_amt"]
def _compute_gross(self, raw_amt_wh: pd.Series, df_fee: pd.DataFrame) -> pd.Series:
return (
(raw_amt_wh + df_fee["test_fee_amt_wh"])
* (100.0 + df_fee["test_fee_percent_amt"])
/ 100.0
)
@pytest.fixture
def provider(config_eos):
"""Fixture to create a concrete PricePredictionProviderBase instance for testing."""
_PriceProviderForTest.reset_instance()
return _PriceProviderForTest()
def _patch_keys_to_dataframe(monkeypatch, provider, df_fee: pd.DataFrame) -> AsyncMock:
"""Monkeypatch Prediction.keys_to_dataframe to return fixed fee data.
`_apply_fees` requires a real fee provider to already be registered and
have generated data in the prediction registry for keys_to_dataframe to
return anything - which we sidestep here by mocking the call directly,
so `_apply_fees` can be tested in isolation.
provider.prediction is a pydantic model with validate_assignment enabled,
so assigning directly onto the *instance* (`provider.prediction.keys_to_dataframe
= mock`) is rejected by pydantic - keys_to_dataframe is a real method, not
a declared field. Patching the *class* method instead is plain attribute
replacement and bypasses pydantic's __setattr__ validation.
"""
mock = AsyncMock(return_value=df_fee)
monkeypatch.setattr(type(provider.prediction), "keys_to_dataframe", mock)
return mock
class TestPricePredictionProviderBase:
"""Tests for the base class itself (via a minimal concrete subclass)."""
def test_provider_id(self, provider):
"""Test provider ID returns correct value."""
assert provider.provider_id() == "PriceProviderForTest"
def test_singleton_instance(self, provider):
"""Test that the concrete provider behaves as a singleton."""
another_instance = _PriceProviderForTest()
assert provider is another_instance
class TestPricePredictionProviderBaseApplyFeesValidation:
"""Tests for input validation in PricePredictionProviderBase._apply_fees()."""
@pytest.mark.asyncio
async def test_apply_fees_empty_series_raises(self, provider):
"""Test that an empty raw price series is rejected outright."""
empty_series = pd.Series([], dtype=float)
with pytest.raises(ValueError, match="must not be empty"):
await provider._apply_fees(empty_series)
@pytest.mark.asyncio
async def test_apply_fees_single_entry_series_raises(self, provider):
"""Test that a single-entry series has no interval to derive and is rejected."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
series = pd.Series([0.0003], index=pd.DatetimeIndex([start_dt]))
with pytest.raises(ValueError, match="at least two entries"):
await provider._apply_fees(series)
@pytest.mark.asyncio
async def test_apply_fees_non_uniform_interval_warns(self, caplog, provider):
"""Test that a series whose timestamps are not evenly spaced falls back to
a fixed 15-minute grid, with a warning, instead of raising."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex(
[start_dt, start_dt.add(minutes=15), start_dt.add(minutes=50)]
)
series = pd.Series([0.0003, 0.00031, 0.00032], index=idx)
with caplog.at_level("WARNING"):
await provider._apply_fees(series)
assert "raw_price_amt_wh has non uniform spacing" in caplog.text
class TestPricePredictionProviderBaseApplyFees:
"""Tests for PricePredictionProviderBase._apply_fees(), with keys_to_dataframe() mocked.
Uses the generic `_compute_gross` formula from `_PriceProviderForTest`
(structurally identical to ElecPriceProvider's), since the point here is to
verify the shared fetch/reindex/fill plumbing feeds `_compute_gross`
correctly - not to re-verify any one provider's formula.
"""
@pytest.mark.asyncio
async def test_apply_fees_calls_compute_gross_with_fetched_fees(self, provider, monkeypatch):
"""Test combined price = (raw + amt fee) * (100 + percent fee) / 100."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0001, 0.0002, 0.0003, 0.0004], index=idx, name="raw_price")
df_fee = pd.DataFrame(
{
"test_fee_amt_wh": [0.000288, 0.000288, 0.00034, 0.00034],
"test_fee_percent_amt": [19.0, 19.0, 19.0, 19.0],
},
index=idx,
)
mock = _patch_keys_to_dataframe(monkeypatch, provider, df_fee)
result = await provider._apply_fees(raw_price_amt_wh)
assert mock.await_count == 1
assert mock.await_args
called_kwargs = mock.await_args.kwargs
# The fee keys fetched must come from the `_fee_keys` hook, not be hardcoded.
assert set(called_kwargs["keys"]) == {"test_fee_amt_wh", "test_fee_percent_amt"}
assert called_kwargs["start_datetime"] == start_dt
assert called_kwargs["boundary"] == "context"
assert called_kwargs["align_to_interval"] is True
assert result.name == "raw_price"
assert len(result) == 4
assert not result.isna().any()
expected = [
(0.0001 + 0.000288) * (100.0 + 19.0) / 100.0,
(0.0002 + 0.000288) * (100.0 + 19.0) / 100.0,
(0.0003 + 0.00034) * (100.0 + 19.0) / 100.0,
(0.0004 + 0.00034) * (100.0 + 19.0) / 100.0,
]
for i, exp in enumerate(expected):
assert abs(result.iloc[i] - exp) < 1e-9, (
f"interval {i}: expected {exp}, got {result.iloc[i]}"
)
@pytest.mark.asyncio
async def test_apply_fees_missing_fee_provider_falls_back_to_zero(self, provider, monkeypatch):
"""Test that a KeyError from keys_to_dataframe (no fee provider configured)
is treated as zero fees rather than propagating."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0002] * 4, index=idx)
mock = AsyncMock(side_effect=KeyError("no fee provider configured"))
monkeypatch.setattr(type(provider.prediction), "keys_to_dataframe", mock)
result = await provider._apply_fees(raw_price_amt_wh)
# Zero amt fee, zero percent fee -> raw price passes through unchanged.
for i in range(4):
assert abs(result.iloc[i] - 0.0002) < 1e-9
@pytest.mark.asyncio
async def test_apply_fees_missing_fee_rows_filled_with_zero(self, provider, monkeypatch):
"""Test that timestamps not covered by the fee data get a zero fee, not NaN."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx_full = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
# Fee data only covers the first two of the four raw price timestamps.
idx_partial = idx_full[:2]
raw_price_amt_wh = pd.Series([0.0001, 0.0001, 0.0001, 0.0001], index=idx_full)
df_fee = pd.DataFrame(
{
"test_fee_amt_wh": [0.000288, 0.000288],
"test_fee_percent_amt": [19.0, 19.0],
},
index=idx_partial,
)
_patch_keys_to_dataframe(monkeypatch, provider, df_fee)
result = await provider._apply_fees(raw_price_amt_wh)
assert not result.isna().any()
# Covered timestamps: fee applied.
expected_covered = (0.0001 + 0.000288) * (100.0 + 19.0) / 100.0
assert abs(result.iloc[0] - expected_covered) < 1e-9
assert abs(result.iloc[1] - expected_covered) < 1e-9
# Uncovered timestamps: fee treated as zero, so the raw price passes through
# (raw + 0) * (100 + 0) / 100 == raw.
assert abs(result.iloc[2] - 0.0001) < 1e-9
assert abs(result.iloc[3] - 0.0001) < 1e-9
class TestPricePredictionProviderBaseStoreGrossSeries:
"""Tests for PricePredictionProviderBase._store_gross_series() wiring.
`key_to_raw_series`, `_apply_fees`, and `key_from_series` are mocked/spied
individually so these tests check the *wiring* - the right keys and bounds
flow through, in the right order - rather than the fee math (already
covered by TestPricePredictionProviderBaseApplyFees) or requiring a real
fee provider to be registered.
"""
@pytest.mark.asyncio
async def test_store_gross_series_uses_raw_and_gross_key_hooks(self, provider, monkeypatch):
"""Test that the raw series is read from `_raw_key` and the result is
written to `_gross_key`, both sourced from the subclass hooks rather
than hardcoded."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
end_dt = start_dt.add(hours=1)
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_series = pd.Series([0.0001, 0.0002, 0.0003, 0.0004], index=idx)
gross_series = raw_series * 1.19 # arbitrary stand-in for the fee-applied result
mock_key_to_raw_series = AsyncMock(return_value=raw_series)
mock_apply_fees = AsyncMock(return_value=gross_series)
mock_key_from_series = AsyncMock()
# Patch on the class, not the instance: these are real methods, not
# declared pydantic fields, and the model has validate_assignment
# enabled, so instance-level setattr is rejected (see
# _patch_keys_to_dataframe's docstring for the same issue).
monkeypatch.setattr(type(provider), "key_to_raw_series", mock_key_to_raw_series)
monkeypatch.setattr(type(provider), "_apply_fees", mock_apply_fees)
monkeypatch.setattr(type(provider), "key_from_series", mock_key_from_series)
await provider._store_gross_series(start_datetime=start_dt, end_datetime=end_dt)
mock_key_to_raw_series.assert_awaited_once_with(
key="test_price_raw_wh", start_datetime=start_dt, end_datetime=end_dt
)
mock_apply_fees.assert_awaited_once()
assert mock_apply_fees.await_args
(apply_fees_arg,) = mock_apply_fees.await_args.args
assert apply_fees_arg is raw_series
mock_key_from_series.assert_awaited_once_with("test_price_wh", gross_series)
@pytest.mark.asyncio
async def test_store_gross_series_without_bounds_defaults_to_none(self, provider, monkeypatch):
"""Test that omitting start_datetime/end_datetime forwards None, not an
implicit "full history" value computed here - bound selection is the
caller's responsibility, per `_store_gross_series`'s docstring."""
idx = pd.DatetimeIndex(
[to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")]
)
raw_series = pd.Series([0.0001], index=idx)
mock_key_to_raw_series = AsyncMock(return_value=raw_series)
mock_apply_fees = AsyncMock(return_value=raw_series)
mock_key_from_series = AsyncMock()
monkeypatch.setattr(type(provider), "key_to_raw_series", mock_key_to_raw_series)
monkeypatch.setattr(type(provider), "_apply_fees", mock_apply_fees)
monkeypatch.setattr(type(provider), "key_from_series", mock_key_from_series)
await provider._store_gross_series()
mock_key_to_raw_series.assert_awaited_once_with(
key="test_price_raw_wh", start_datetime=None, end_datetime=None
)
+1
View File
@@ -27,6 +27,7 @@ def provider(sample_import_1_json, config_eos):
}
config_eos.merge_settings_from_dict(settings)
provider = PVForecastImport()
provider._db_reset_state()
assert provider.enabled()
return provider
+4 -1
View File
@@ -33,7 +33,10 @@ def provider(config_eos):
},
}
config_eos.merge_settings_from_dict(settings)
return WeatherClearOutside()
provider = WeatherClearOutside()
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
+1
View File
@@ -27,6 +27,7 @@ def provider(sample_import_1_json, config_eos):
}
config_eos.merge_settings_from_dict(settings)
provider = WeatherImport()
provider._db_reset_state()
assert provider.enabled() == True
return provider
+1
View File
@@ -6,6 +6,7 @@
../_generated/configcache.md
../_generated/configdatabase.md
../_generated/configdevices.md
../_generated/configelecfee.md
../_generated/configelecprice.md
../_generated/configems.md
../_generated/configfeedintariff.md
+296
View File
@@ -0,0 +1,296 @@
## Electricity Price Prediction Configuration
<!-- pyml disable line-length -->
:::{table} elecfee
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| elecfeefixed | `EOS_ELECFEE__ELECFEEFIXED` | `ElecFeeFixedCommonSettings` | `rw` | `required` | Fixed electricity fees provider settings. |
| elecfeeimport | `EOS_ELECFEE__ELECFEEIMPORT` | `ElecFeeImportCommonSettings` | `rw` | `required` | Electricity fees import provider settings. |
| provider | `EOS_ELECFEE__PROVIDER` | `str | None` | `rw` | `None` | Electricity fee provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available electricity fee provider ids. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
},
"consumption_percent_amt": {
"windows": []
},
"feedin_amt_kwh": {
"windows": []
},
"feedin_percent_amt": {
"windows": []
}
},
"elecfeeimport": {
"import_file_path": null,
"import_json": null
}
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
},
"consumption_percent_amt": {
"windows": []
},
"feedin_amt_kwh": {
"windows": []
},
"feedin_percent_amt": {
"windows": []
}
},
"elecfeeimport": {
"import_file_path": null,
"import_json": null
},
"providers": [
"ElecFeeFixed",
"ElecFeeImport"
]
}
}
```
<!-- pyml enable line-length -->
### Common settings for elecfee data import from file or JSON String
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeeimport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `str | pathlib.Path | None` | `rw` | `None` | Path to the file to import elecfee data from. |
| import_json | `str | None` | `rw` | `None` | JSON string, dictionary of electricity fee forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeeimport": {
"import_file_path": null,
"import_json": "{\"elecfee_consumption_amt_wh\": [0.0003384, 0.0003318, 0.0003284]}"
}
}
}
```
<!-- pyml enable line-length -->
### Value applicable during a specific time window
This model extends `TimeWindow` by associating a value with the defined time interval.
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeefixed::consumption_amt_kwh::windows::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| date | `pydantic_extra_types.pendulum_dt.Date | None` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
| day_of_week | `int | str | None` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
| locale | `str | None` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
| value | `float | None` | `rw` | `None` | Value applicable during this time window. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.288
}
]
}
}
}
}
```
<!-- pyml enable line-length -->
### Sequence of value time windows
This model specializes `TimeWindowSequence` to ensure that all
contained windows are instances of `ValueTimeWindow`.
It provides the full set of sequence operations (containment checks,
availability, start time calculations) for value windows.
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeefixed::consumption_amt_kwh
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| windows | `list[akkudoktoreos.config.configabc.ValueTimeWindow]` | `rw` | `required` | Ordered list of value time windows. Each window defines a time interval and an associated value. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for fixed electricity fees
This model defines a fixed electricity fee schedule using a sequence
of time windows. Each window specifies a time interval and the electricity
fee applicable during that interval.
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeefixed
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| consumption_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the total fixed per-kWh electricty fee charged for consumed energy, accumulating all applicable fixed per-kWh charges (e.g. network charge, metering fee, concession fee) into a single amount [amount/kWh]. If not provided, no fixed per-kWh consumption fee is applied. |
| consumption_percent_amt | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the total fixed electricity surcharge applied as a percentage of the monetary amount already charged for consumed energy, accumulating all applicable percentage-based surcharges (e.g. VAT, electricity tax) into a single percentage [%]. This is a percentage of the fee amount, not a per-kWh rate. If not provided, no percentage-based consumption surcharge is applied. |
| feedin_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the total 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. If not provided, no fixed per-kWh feed-in fee is applied. |
| feedin_percent_amt | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the 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. If not provided, no percentage-based feed-in deduction is applied. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "8 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.00288
},
{
"start_time": "08:00:00.000000",
"duration": "16 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.0034
}
]
},
"consumption_percent_amt": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "1 day",
"day_of_week": null,
"date": null,
"locale": null,
"value": 19.0
}
]
},
"feedin_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "8 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.00288
},
{
"start_time": "08:00:00.000000",
"duration": "16 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.0034
}
]
},
"feedin_percent_amt": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "1 day",
"day_of_week": null,
"date": null,
"locale": null,
"value": 19.0
}
]
}
}
}
}
```
<!-- pyml enable line-length -->
+74 -93
View File
@@ -7,14 +7,14 @@
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| charges_kwh | `EOS_ELECPRICE__CHARGES_KWH` | `float | None` | `rw` | `None` | Electricity price charges [amount/kWh]. Will be added to variable market price. |
| akkudoktor | `EOS_ELECPRICE__AKKUDOKTOR` | `ElecPriceAkkudoktorCommonSettings` | `rw` | `required` | Akkudoktor electricity price provider settings. |
| elecpricefixed | `EOS_ELECPRICE__ELECPRICEFIXED` | `ElecPriceFixedCommonSettings` | `rw` | `required` | Fixed electricity price provider settings. |
| elecpriceimport | `EOS_ELECPRICE__ELECPRICEIMPORT` | `ElecPriceImportCommonSettings` | `rw` | `required` | Electricity price import provider settings. |
| energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. |
| provider | `EOS_ELECPRICE__PROVIDER` | `str | None` | `rw` | `None` | Electricity price provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available electricity price provider ids. |
| smard | `EOS_ELECPRICE__SMARD` | `ElecPriceSMARDCommonSettings` | `rw` | `required` | SMARD electricity price provider settings. |
| tibber | `EOS_ELECPRICE__TIBBER` | `ElecPriceTibberCommonSettings` | `rw` | `required` | Tibber electricity price provider settings. |
| vat_rate | `EOS_ELECPRICE__VAT_RATE` | `float | None` | `rw` | `1.19` | VAT rate factor applied to electricity price when charges are used. |
:::
<!-- pyml enable line-length -->
@@ -27,10 +27,9 @@
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"akkudoktor": {},
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": []
}
},
@@ -41,6 +40,10 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {
"filter_id": 4169,
"region": "DE"
},
"tibber": {
"access_token": null,
"home_id": null
@@ -59,10 +62,9 @@
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"akkudoktor": {},
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": []
}
},
@@ -73,6 +75,10 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {
"filter_id": 4169,
"region": "DE"
},
"tibber": {
"access_token": null,
"home_id": null
@@ -82,6 +88,7 @@
"ElecPriceEnergyCharts",
"ElecPriceFixed",
"ElecPriceImport",
"ElecPriceSMARD",
"ElecPriceTibber"
]
}
@@ -120,6 +127,37 @@
```
<!-- pyml enable line-length -->
### Common settings for the direct SMARD electricity-price provider
<!-- pyml disable line-length -->
:::{table} elecprice::smard
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| filter_id | `int` | `rw` | `4169` | SMARD filter id for the German/Luxembourg day-ahead price. |
| region | `str` | `rw` | `DE` | SMARD market region used in the chart-data endpoint. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"smard": {
"filter_id": 4169,
"region": "DE"
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for Energy Charts electricity price provider
<!-- pyml disable line-length -->
@@ -180,89 +218,6 @@
```
<!-- pyml enable line-length -->
### Value applicable during a specific time window
This model extends `TimeWindow` by associating a value with the defined time interval.
<!-- pyml disable line-length -->
:::{table} elecprice::elecpricefixed::time_windows::windows::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| date | `pydantic_extra_types.pendulum_dt.Date | None` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
| day_of_week | `int | str | None` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
| locale | `str | None` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
| value | `float | None` | `rw` | `None` | Value applicable during this time window. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.288
}
]
}
}
}
}
```
<!-- pyml enable line-length -->
### Sequence of value time windows
This model specializes `TimeWindowSequence` to ensure that all
contained windows are instances of `ValueTimeWindow`.
It provides the full set of sequence operations (containment checks,
availability, start time calculations) for value windows.
<!-- pyml disable line-length -->
:::{table} elecprice::elecpricefixed::time_windows
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| windows | `list[akkudoktoreos.config.configabc.ValueTimeWindow]` | `rw` | `required` | Ordered list of value time windows. Each window defines a time interval and an associated value. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"windows": []
}
}
}
}
```
<!-- pyml enable line-length -->
### Common configuration settings for fixed electricity pricing
This model defines a fixed electricity price schedule using a sequence
@@ -276,7 +231,7 @@ price applicable during that interval.
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| time_windows | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied. |
| elecprice_marketprice_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied. |
:::
<!-- pyml enable line-length -->
@@ -289,7 +244,7 @@ price applicable during that interval.
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
@@ -314,3 +269,29 @@ price applicable during that interval.
}
```
<!-- pyml enable line-length -->
### Common configuration settings for Akkodoktor electricity pricing
<!-- pyml disable line-length -->
:::{table} elecprice::akkudoktor
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"akkudoktor": {}
}
}
```
<!-- pyml enable line-length -->
+32 -5
View File
@@ -95,12 +95,32 @@
"home_appliances": [],
"max_home_appliances": 1
},
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
},
"consumption_percent_amt": {
"windows": []
},
"feedin_amt_kwh": {
"windows": []
},
"feedin_percent_amt": {
"windows": []
}
},
"elecfeeimport": {
"import_file_path": null,
"import_json": null
}
},
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"akkudoktor": {},
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": []
}
},
@@ -111,6 +131,10 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {
"filter_id": 4169,
"region": "DE"
},
"tibber": {
"access_token": null,
"home_id": null
@@ -124,7 +148,9 @@
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": null
"feed_in_tariff_amt_kwh": {
"windows": []
}
},
"feedintariffimport": {
"import_file_path": null,
@@ -136,7 +162,8 @@
},
"energycharts": {
"bidding_zone": "DE-LU"
}
},
"smard": {}
},
"general": {
"config_save_mode": "AUTOMATIC",
+58 -5
View File
@@ -13,6 +13,7 @@
| feedintariffimport | `EOS_FEEDINTARIFF__FEEDINTARIFFIMPORT` | `FeedInTariffImportCommonSettings` | `rw` | `required` | Feed in tarif import provider settings. |
| provider | `EOS_FEEDINTARIFF__PROVIDER` | `str | None` | `rw` | `None` | Feed in tariff provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available feed in tariff provider ids. |
| smard | `EOS_FEEDINTARIFF__SMARD` | `FeedInTariffSMARDCommonSettings` | `rw` | `required` | SMARD feed in tariff provider settings. |
:::
<!-- pyml enable line-length -->
@@ -26,7 +27,9 @@
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": null
"feed_in_tariff_amt_kwh": {
"windows": []
}
},
"feedintariffimport": {
"import_file_path": null,
@@ -38,7 +41,8 @@
},
"energycharts": {
"bidding_zone": "DE-LU"
}
},
"smard": {}
}
}
```
@@ -54,7 +58,9 @@
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": null
"feed_in_tariff_amt_kwh": {
"windows": []
}
},
"feedintariffimport": {
"import_file_path": null,
@@ -67,12 +73,14 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {},
"providers": [
"FeedInTariffAkkudoktor",
"FeedInTariffDvhubOnline",
"FeedInTariffEnergyCharts",
"FeedInTariffFixed",
"FeedInTariffImport",
"FeedInTariffSMARD",
"FeedInTariffTibber"
]
}
@@ -80,6 +88,32 @@
```
<!-- pyml enable line-length -->
### Settings for SMARD feed-in prices shared with ``elecprice.smard``
<!-- pyml disable line-length -->
:::{table} feedintariff::smard
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"feedintariff": {
"smard": {}
}
}
```
<!-- pyml enable line-length -->
### Common settings for feed in tariff data import from file or JSON string
<!-- pyml disable line-length -->
@@ -120,7 +154,7 @@
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| feed_in_tariff_kwh | `float | None` | `rw` | `None` | Electricity price feed in tariff [amount/kWh]. |
| feed_in_tariff_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the electricity feed in tariff [amount/kWh]. If not provided, no fixed feed in tariff is applied. |
:::
<!-- pyml enable line-length -->
@@ -133,7 +167,26 @@
{
"feedintariff": {
"feedintarifffixed": {
"feed_in_tariff_kwh": 0.078
"feed_in_tariff_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "8 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.028
},
{
"start_time": "08:00:00.000000",
"duration": "16 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.034
}
]
}
}
}
}
+1 -2
View File
@@ -43,8 +43,7 @@
}
},
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21
"provider": "ElecPriceAkkudoktor"
},
"load": {
"loadakkudoktor": {
+1 -2
View File
@@ -11,8 +11,7 @@
}
},
"elecprice": {
"provider": "ElecPriceImport",
"charges_kwh": 0.21
"provider": "ElecPriceImport"
},
"server": {
"host": "0.0.0.0",