Add resilient market feed-in tariff providers

This commit is contained in:
Andreas
2026-07-15 16:10:34 +02:00
parent 67cf6f7d8a
commit d4056af0f6
15 changed files with 928 additions and 45 deletions
+18
View File
@@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
### Added
- Add `FeedInTariffAkkudoktor`, using raw hourly Akkudoktor/aWATTar day-ahead market prices as
feed-in tariff data without import charges or VAT. Quarter-hour optimization holds each hourly
value constant for four slots.
- Add `FeedInTariffTibber`, using Tibber's native `QUARTER_HOURLY` spot-price component as a
strict 15-minute feed-in tariff. Hourly API responses are rejected instead of expanded.
- Flexible consumers (home appliances): schedule any number of consumers via
`devices.home_appliances`, each with a unique `device_id`. Every consumer defines its
load **either** as an explicit power profile (`load_profile_power_w` at
@@ -67,6 +72,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
hourly appliance) and `result.Home_appliance_wh_per_hour` (aggregate over all appliances)
are deprecated in favour of `appliance_starts` and `result.home_appliance_energy_wh`.
### Fixed
- FeedInTariffEnergyCharts no longer aborts the whole prediction/optimization when the
Energy-Charts API is briefly unreachable: transient timeouts/connection errors are
retried (with a (connect, read) timeout of (5, 60) s), and if a fetch still fails while
historical data exists, the existing history is kept and the remaining slots are
extrapolated via ETS instead of failing. A genuine cold start (no data at all) still
fails.
- The deprecated `/gesamtlast` endpoint no longer forces a full provider refresh on every
call. Forcing bypassed the provider caches and hammered external APIs, so a single flaky
provider could 404 the whole load prediction. It now defaults to a cache-aware update and
accepts an optional `force_update` flag in the request body for callers that still want
to force.
## 0.3.0 (2026-03-17)
Akkudoktor-EOS can now be run as Home Assistant add-on and standalone.
+43
View File
@@ -218,9 +218,12 @@ Configuration options:
- `provider`: Feed in tariff provider id of provider to be used.
- `FeedInTariffFixed`: Provides fixed feed in tariff values.
- `FeedInTariffAkkudoktor`: Retrieves raw day-ahead market prices from the public
Akkudoktor API without import charges or VAT.
- `FeedInTariffEnergyCharts`: Retrieves Energy-Charts day-ahead market prices and extends
them to the configured prediction horizon when necessary.
- `FeedInTariffImport`: Imports from a file or JSON string or by endpoint data provision.
- `FeedInTariffTibber`: Retrieves Tibber's native quarter-hour energy-price component.
- `provider_settings.FeedInTariffFixed.feed_in_tariff_kwh`: Fixed feed-in tariff (€/kWh).
- `provider_settings.FeedInTariffEnergyCharts.bidding_zone`: Energy-Charts bidding zone.
@@ -229,6 +232,46 @@ Configuration options:
- `provider_settings.FeedInTariffImport.import_json`: JSON string containing feed-in tariff
prediction data.
### FeedInTariffAkkudoktor Provider
The `FeedInTariffAkkudoktor` provider uses raw day-ahead market prices from
`https://api.akkudoktor.net/prices` as `feed_in_tariff_wh`. It does not add electricity import
charges or VAT. Published prices are extended to the configured prediction horizon with the same
seasonal ETS or median fallback used by the Akkudoktor electricity-price provider.
The Akkudoktor endpoint currently forwards hourly market prices from aWATTar. With a 15-minute
optimization interval, EOS holds each hourly price constant for its four quarter-hour slots. This
keeps the slot grid consistent but does not create genuine quarter-hour market prices.
```json
{
"feedintariff": {
"direct_marketing_enabled": true,
"provider": "FeedInTariffAkkudoktor"
}
}
```
### FeedInTariffTibber Provider
The `FeedInTariffTibber` provider requests `priceInfo` and `priceInfoRange` with
`resolution: QUARTER_HOURLY` and preserves the native 15-minute timestamps. It uses Tibber's
`energy` spot-price component without the `tax` part or EOS electricity-price charges. The
end-customer `total` component is deliberately ignored.
The provider deliberately rejects hourly API responses instead of silently repeating them. It
reuses `elecprice.tibber.access_token` and `elecprice.tibber.home_id`, so no duplicate credentials
are needed.
```json
{
"feedintariff": {
"direct_marketing_enabled": true,
"provider": "FeedInTariffTibber"
}
}
```
### FeedInTariffEnergyCharts Provider
The `FeedInTariffEnergyCharts` provider uses the raw Energy-Charts day-ahead market price as the
@@ -29,6 +29,10 @@ from akkudoktoreos.optimization.genetic.geneticdevices import (
)
from akkudoktoreos.utils.datetimeutil import to_duration
MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS = frozenset(
{"FeedInTariffAkkudoktor", "FeedInTariffEnergyCharts", "FeedInTariffTibber"}
)
# Do not import directly from akkudoktoreos.core.coreabc
# EnergyManagementSystemMixin - Creates circular dependency with ems.py
# StartMixin - Creates circular dependency with ems.py
@@ -161,8 +165,7 @@ class GeneticOptimizationParameters(
dishwasher = self.__dict__.get("dishwasher")
if dishwasher is not None and self.home_appliances is not None:
raise ValueError(
"Provide either 'home_appliances' or the deprecated 'dishwasher', "
"not both."
"Provide either 'home_appliances' or the deprecated 'dishwasher', " "not both."
)
appliances = self.home_appliances or []
device_ids = [appliance.device_id for appliance in appliances]
@@ -405,7 +408,7 @@ class GeneticOptimizationParameters(
# Retry
continue
if cls.config.feedintariff.direct_marketing_enabled:
if cls.config.feedintariff.provider == "FeedInTariffEnergyCharts":
if cls.config.feedintariff.provider in MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS:
try:
feed_in_tariff_wh = cls.prediction.key_to_array(
key="feed_in_tariff_wh",
@@ -28,16 +28,19 @@ query TibberPriceInfo {
today {
startsAt
total
energy
}
tomorrow {
startsAt
total
energy
}
}
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
nodes {
startsAt
total
energy
}
}
}
@@ -61,16 +64,19 @@ query TibberPriceInfo {
today {
startsAt
total
energy
}
tomorrow {
startsAt
total
energy
}
}
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
nodes {
startsAt
total
energy
}
}
}
@@ -107,6 +113,7 @@ class TibberPricePoint(PydanticBaseModel):
startsAt: str
total: float
energy: Optional[float] = None
class TibberPriceConnection(PydanticBaseModel):
+23 -1
View File
@@ -5,11 +5,15 @@ from pydantic import Field, computed_field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_prediction
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
from akkudoktoreos.prediction.feedintariffakkudoktor import (
FeedInTariffAkkudoktorCommonSettings,
)
from akkudoktoreos.prediction.feedintariffenergycharts import (
FeedInTariffEnergyChartsCommonSettings,
)
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibberCommonSettings
def elecprice_provider_ids() -> list[str]:
@@ -19,7 +23,13 @@ def elecprice_provider_ids() -> list[str]:
except:
# Prediction may not be initialized
# Return at least provider used in example
return ["FeedInTariffFixed", "FeedInTariffEnergyCharts", "FeedInTariffImport"]
return [
"FeedInTariffAkkudoktor",
"FeedInTariffFixed",
"FeedInTariffEnergyCharts",
"FeedInTariffImport",
"FeedInTariffTibber",
]
return [
provider.provider_id()
@@ -31,6 +41,10 @@ def elecprice_provider_ids() -> list[str]:
class FeedInTariffCommonProviderSettings(SettingsBaseModel):
"""Feed In Tariff Prediction Provider Configuration."""
FeedInTariffAkkudoktor: Optional[FeedInTariffAkkudoktorCommonSettings] = Field(
default=None,
json_schema_extra={"description": "FeedInTariffAkkudoktor settings", "examples": [None]},
)
FeedInTariffFixed: Optional[FeedInTariffFixedCommonSettings] = Field(
default=None,
json_schema_extra={"description": "FeedInTariffFixed settings", "examples": [None]},
@@ -43,6 +57,10 @@ class FeedInTariffCommonProviderSettings(SettingsBaseModel):
default=None,
json_schema_extra={"description": "FeedInTariffImport settings", "examples": [None]},
)
FeedInTariffTibber: Optional[FeedInTariffTibberCommonSettings] = Field(
default=None,
json_schema_extra={"description": "FeedInTariffTibber settings", "examples": [None]},
)
class FeedInTariffCommonSettings(SettingsBaseModel):
@@ -61,9 +79,11 @@ class FeedInTariffCommonSettings(SettingsBaseModel):
json_schema_extra={
"description": "Feed in tariff provider id of provider to be used.",
"examples": [
"FeedInTariffAkkudoktor",
"FeedInTariffFixed",
"FeedInTariffEnergyCharts",
"FeedInTariffImport",
"FeedInTariffTibber",
],
},
)
@@ -75,9 +95,11 @@ class FeedInTariffCommonSettings(SettingsBaseModel):
"examples": [
# Example 1: Empty/default settings (all providers None)
{
"FeedInTariffAkkudoktor": None,
"FeedInTariffFixed": None,
"FeedInTariffEnergyCharts": None,
"FeedInTariffImport": None,
"FeedInTariffTibber": None,
},
],
},
@@ -0,0 +1,163 @@
"""Provide feed-in tariff data from Akkudoktor market prices."""
import time
from datetime import datetime
from typing import Optional
import numpy as np
import pandas as pd
import requests
from loguru import logger
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.prediction.elecpriceakkudoktor import (
AkkudoktorElecPrice,
ElecPriceAkkudoktor,
)
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
class FeedInTariffAkkudoktorCommonSettings(SettingsBaseModel):
"""Settings for the Akkudoktor feed-in tariff provider.
The public Akkudoktor price endpoint only needs the timezone already
configured in ``general.timezone``, so no provider-specific values are
currently required.
"""
class FeedInTariffAkkudoktor(FeedInTariffProvider):
"""Use raw Akkudoktor day-ahead market prices as feed-in tariff data.
The upstream aWATTar endpoint currently supplies hourly values. EOS stores
those source values unchanged; consumers requesting a shorter interval can
forward-fill them onto the optimization grid.
Electricity import charges and VAT are intentionally not added. Prices
returned in EUR/MWh are converted to EUR/Wh and stored under
``feed_in_tariff_wh``.
"""
highest_orig_datetime: Optional[datetime] = None
def historic_hours_min(self) -> int:
"""Keep enough history for weekly seasonal price extrapolation."""
return 24 * 35
@classmethod
def provider_id(cls) -> str:
"""Return the unique provider identifier."""
return "FeedInTariffAkkudoktor"
@cache_in_file(with_ttl="1 hour")
def _request_forecast(self) -> AkkudoktorElecPrice:
"""Fetch market prices from the public Akkudoktor API."""
if not self.ems_start_datetime:
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
start_date = to_datetime(
self.ems_start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD"
)
end_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
timezone = self.config.general.timezone
url = (
"https://api.akkudoktor.net/prices" f"?start={start_date}&end={end_date}&tz={timezone}"
)
max_attempts = 3
last_exc: Optional[Exception] = None
for attempt in range(1, max_attempts + 1):
try:
response = requests.get(url, timeout=(5, 20))
logger.debug("Response from {}: {}", url, response)
response.raise_for_status()
data = ElecPriceAkkudoktor._validate_data(response.content)
self.update_datetime = to_datetime(in_timezone=timezone)
return data
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
last_exc = exc
logger.warning(
"Akkudoktor feed-in tariff request attempt {}/{} failed: {}",
attempt,
max_attempts,
exc,
)
if attempt < max_attempts:
time.sleep(2 * attempt)
raise last_exc # type: ignore[misc]
def _parse_data(self, data: AkkudoktorElecPrice) -> pd.Series:
"""Convert raw EUR/MWh values to a timezone-aware EUR/Wh series."""
series = pd.Series(dtype=float)
for value in data.values:
timestamp = to_datetime(value.start, in_timezone=self.config.general.timezone)
series.at[timestamp] = value.marketprice / 1_000_000
return series
def _predict_prices(self, history: np.ndarray, hours: int) -> np.ndarray:
"""Extend published prices to the configured prediction horizon."""
predictor = ElecPriceAkkudoktor()
if len(history) > 800:
return predictor._predict_ets(history, seasonal_periods=168, hours=hours)
if len(history) > 168:
return predictor._predict_ets(history, seasonal_periods=24, hours=hours)
if len(history) > 0:
logger.warning(
"Using median fallback for Akkudoktor feed-in tariff with only {} values.",
len(history),
)
return predictor._predict_median(history, hours=hours)
raise ValueError("No Akkudoktor feed-in tariff data available")
def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update raw prices and extrapolate any missing horizon values."""
if not self.ems_start_datetime:
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
try:
data = self._request_forecast(force_update=force_update) # type: ignore[call-arg]
series = self._parse_data(data)
if series.empty:
raise ValueError("No Akkudoktor feed-in tariff data available")
self.highest_orig_datetime = to_datetime(
series.index.max(), in_timezone=self.config.general.timezone
)
self.key_from_series("feed_in_tariff_wh", series)
except Exception as exc:
if self.highest_orig_datetime is None:
raise
logger.warning(
"Akkudoktor feed-in tariff update failed ({}); retaining existing data.",
exc,
)
if self.highest_orig_datetime is None:
raise ValueError("Highest original datetime not available")
history = np.asarray(
self.key_to_array(
key="feed_in_tariff_wh",
end_datetime=self.highest_orig_datetime,
fill_method="linear",
),
dtype=float,
)
covered_hours = (
int((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600) + 1
)
needed_hours = self.config.prediction.hours - max(covered_hours, 0)
if needed_hours <= 0:
return
prediction = self._predict_prices(history, needed_hours)
prediction_series = pd.Series(
data=prediction,
index=[
self.highest_orig_datetime + to_duration(f"{i + 1} hours")
for i in range(len(prediction))
],
)
self.key_from_series("feed_in_tariff_wh", prediction_series)
@@ -1,5 +1,6 @@
"""Provides feed-in tariff data from Energy-Charts market prices."""
import time
from datetime import datetime
from typing import Optional
@@ -44,6 +45,10 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
highest_orig_datetime: Optional[datetime] = None
def historic_hours_min(self) -> int:
"""Keep enough history for weekly seasonal price extrapolation."""
return 24 * 35
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the Energy-Charts feed-in tariff provider."""
@@ -69,12 +74,34 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
url = f"{source}/price?bzn={self._bidding_zone()}&start={start_date}&end={last_date}"
response = requests.get(url, timeout=30)
# Retry transient network problems (timeouts / connection resets) a few
# times with a short backoff. Uses a (connect, read) timeout tuple so a
# slow-to-respond API does not block forever but also is not aborted
# after a too-short single read window.
max_attempts = 3
last_exc: Optional[Exception] = None
for attempt in range(1, max_attempts + 1):
try:
response = requests.get(url, timeout=(5, 60))
logger.debug(f"Response from {url}: {response}")
response.raise_for_status()
energy_charts_data = ElecPriceEnergyCharts._validate_data(response.content)
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return energy_charts_data
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
last_exc = exc
logger.warning(
"Energy-Charts request attempt {}/{} failed: {}",
attempt,
max_attempts,
exc,
)
if attempt < max_attempts:
time.sleep(2 * attempt)
# All attempts exhausted - re-raise the last transient error so the
# caller (_update_data) can decide whether to fall back to history.
raise last_exc # type: ignore[misc]
def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series:
series_data = pd.Series(dtype=float)
@@ -88,14 +115,29 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
def _predict_prices(self, history, slots: int, slots_per_hour: int):
energycharts = ElecPriceEnergyCharts()
if len(history) > 800 * slots_per_hour:
logger.info(
"Using weekly seasonal ETS forecast for Energy-Charts feed-in tariff "
"with {} historical values.",
len(history),
)
return energycharts._predict_ets(
history, seasonal_periods=168 * slots_per_hour, hours=slots
)
if len(history) > 168 * slots_per_hour:
logger.info(
"Using daily seasonal ETS forecast for Energy-Charts feed-in tariff "
"with {} historical values.",
len(history),
)
return energycharts._predict_ets(
history, seasonal_periods=24 * slots_per_hour, hours=slots
)
if len(history) > 0:
logger.warning(
"Using constant median fallback for Energy-Charts feed-in tariff "
"with only {} historical values.",
len(history),
)
return energycharts._predict_median(history, hours=slots)
logger.error("No feed-in tariff data available for Energy-Charts prediction")
raise ValueError("No data available")
@@ -111,25 +153,45 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
past_days = 35
needs_history_refresh = False
if self.highest_orig_datetime:
history_series = self.key_to_series(
key="feed_in_tariff_wh", start_datetime=self.ems_start_datetime
raw_history = self.key_to_series(
key="feed_in_tariff_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
if not history_series.empty and history_series.index.min() <= self.ems_start_datetime:
# A later update must not mistake the current forecast window for
# sufficient ETS history. Require the same amount of data that the
# weekly prediction branch below needs; otherwise fetch 35 days
# again and repair an already-truncated in-memory history.
if not raw_history.empty:
resolution_seconds = ElecPriceEnergyCharts._resolution_seconds(raw_history)
slots_per_hour = 3600 // resolution_seconds
needs_history_refresh = len(raw_history) <= 800 * slots_per_hour
else:
needs_history_refresh = True
if not needs_history_refresh and not force_update:
past_days = 0
needs_update = end > self.highest_orig_datetime
needs_update = (
bool(force_update) or end > self.highest_orig_datetime or needs_history_refresh
)
else:
needs_update = True
if needs_update:
logger.info(
"Update FeedInTariffEnergyCharts is needed, last in history: {}",
"Update FeedInTariffEnergyCharts is needed, last in history: {}, "
"force_update={}, history_refresh={}",
self.highest_orig_datetime,
bool(force_update),
needs_history_refresh,
)
start_date = to_datetime(
self.ems_start_datetime - to_duration(f"{past_days} days"),
as_string="YYYY-MM-DD",
)
try:
energy_charts_data = self._request_forecast(
start_date=start_date, force_update=force_update
)
@@ -138,6 +200,23 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
raise ValueError("No Energy-Charts feed-in tariff data available")
self.highest_orig_datetime = series_data.index.max()
self.key_from_series("feed_in_tariff_wh", series_data)
except Exception as exc:
if self.highest_orig_datetime is None:
# Cold start: no cached/historical data to fall back to, so a
# failed fetch is fatal.
raise
# Transient API outage with existing history available: do not
# abort the whole prediction update. Keep the existing history
# and let the ETS/median branch below extrapolate the remaining
# slots, so downstream (e.g. /gesamtlast, optimization) still
# gets a usable feed-in tariff series.
logger.warning(
"Energy-Charts feed-in tariff update failed ({}); keeping "
"existing history until {} and extrapolating the remaining "
"slots via ETS.",
exc,
self.highest_orig_datetime,
)
else:
logger.info(
"No update FeedInTariffEnergyCharts is needed, last in history: {}",
@@ -0,0 +1,169 @@
"""Provide native quarter-hour feed-in prices from the Tibber API."""
from datetime import datetime
from typing import Optional
import numpy as np
import pandas as pd
import requests
from loguru import logger
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.prediction.elecpricetibber import (
TIBBER_GRAPHQL_URL,
TIBBER_PRICE_QUERY_QUARTER_HOURLY,
ElecPriceTibber,
TibberGraphQLResponse,
TibberPricePoint,
)
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
class FeedInTariffTibberCommonSettings(SettingsBaseModel):
"""Settings for the Tibber feed-in tariff provider.
Authentication is shared with ``elecprice.tibber`` so the access token and
home id do not have to be configured twice.
"""
class FeedInTariffTibber(FeedInTariffProvider):
"""Use Tibber's native quarter-hour energy component as feed-in price.
Tibber documents ``energy`` as the spot-price component. Unlike the
end-customer ``total`` component it excludes taxes.
"""
highest_orig_datetime: Optional[datetime] = None
@classmethod
def provider_id(cls) -> str:
"""Return the unique provider identifier."""
return "FeedInTariffTibber"
def historic_hours_min(self) -> int:
"""Keep enough history for seasonal price extrapolation."""
return 24 * 35
@cache_in_file(with_ttl="1 hour")
def _request_forecast(self) -> TibberGraphQLResponse:
"""Request strictly quarter-hourly Tibber prices.
Unlike the electricity-price provider, this provider deliberately has
no hourly fallback because it promises a native 15-minute signal.
"""
access_token = self.config.elecprice.tibber.access_token
if not access_token:
raise ValueError("Tibber access_token is required")
response = requests.post(
TIBBER_GRAPHQL_URL,
json={"query": TIBBER_PRICE_QUERY_QUARTER_HOURLY},
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
timeout=30,
)
logger.debug("Response from Tibber GraphQL API for feed-in tariff: {}", response)
response.raise_for_status()
tibber_data = ElecPriceTibber._validate_data(response.content)
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return tibber_data
def _price_points(self, response: TibberGraphQLResponse) -> list[TibberPricePoint]:
"""Collect historical, today, and tomorrow price points."""
tibber = ElecPriceTibber()
home = tibber._select_home(response)
subscription = home.currentSubscription
if subscription is None:
raise ValueError("Tibber home has no current subscription")
points: list[TibberPricePoint] = []
if subscription.priceInfoRange is not None:
points.extend(subscription.priceInfoRange.nodes)
if subscription.priceInfo is not None:
points.extend(subscription.priceInfo.today)
points.extend(subscription.priceInfo.tomorrow)
if not subscription.priceInfo.tomorrow:
logger.warning("Tibber tomorrow prices not available yet")
return points
def _parse_data(self, response: TibberGraphQLResponse) -> pd.Series:
"""Convert Tibber's EUR/kWh spot-price component to EUR/Wh."""
series = pd.Series(dtype=float)
for point in self._price_points(response):
if point.energy is None:
raise ValueError("Tibber response does not contain the energy price component")
timestamp = to_datetime(point.startsAt, in_timezone=self.config.general.timezone)
series.at[timestamp] = point.energy / 1000.0
if series.empty:
raise ValueError("Tibber response contains no feed-in price points")
return ElecPriceTibber()._normalize_series(series)
def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Store native 15-minute values and forecast missing horizon slots."""
if not self.ems_start_datetime:
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
try:
data = self._request_forecast(force_update=force_update) # type: ignore[call-arg]
series = self._parse_data(data)
resolution_seconds = ElecPriceTibber()._resolution_seconds(series)
if resolution_seconds != 900:
raise ValueError(
"FeedInTariffTibber requires native 15-minute prices; "
f"received {resolution_seconds}-second intervals"
)
self.highest_orig_datetime = to_datetime(
series.index.max(), in_timezone=self.config.general.timezone
)
self.key_from_series("feed_in_tariff_wh", series)
except Exception as exc:
if self.highest_orig_datetime is None:
raise
logger.warning(
"Tibber feed-in tariff update failed ({}); retaining existing 15-minute data.",
exc,
)
if self.highest_orig_datetime is None:
raise ValueError("Highest original datetime not available")
interval_seconds = 900
history = np.asarray(
self.key_to_array(
key="feed_in_tariff_wh",
end_datetime=self.highest_orig_datetime,
interval=to_duration(f"{interval_seconds} seconds"),
fill_method="linear",
),
dtype=float,
)
covered_slots = 0
if self.highest_orig_datetime >= self.ems_start_datetime:
covered_slots = (
int(
(self.highest_orig_datetime - self.ems_start_datetime).total_seconds()
// interval_seconds
)
+ 1
)
needed_slots = self.config.prediction.hours * 4 - covered_slots
if needed_slots <= 0:
return
prediction = ElecPriceTibber()._predict_missing_prices(
history, slots=needed_slots, slots_per_hour=4
)
prediction_series = pd.Series(
data=prediction,
index=[
self.highest_orig_datetime + to_duration(f"{(i + 1) * interval_seconds} seconds")
for i in range(len(prediction))
],
)
self.key_from_series("feed_in_tariff_wh", prediction_series)
@@ -36,9 +36,11 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
from akkudoktoreos.prediction.feedintariffakkudoktor import FeedInTariffAkkudoktor
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber
from akkudoktoreos.prediction.loadakkudoktor import (
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
@@ -83,8 +85,10 @@ elecprice_tibber = ElecPriceTibber()
elecprice_fixed = ElecPriceFixed()
elecprice_import = ElecPriceImport()
feedintariff_energy_charts = FeedInTariffEnergyCharts()
feedintariff_akkudoktor = FeedInTariffAkkudoktor()
feedintariff_fixed = FeedInTariffFixed()
feedintariff_import = FeedInTariffImport()
feedintariff_tibber = FeedInTariffTibber()
loadforecast_akkudoktor = LoadAkkudoktor()
loadforecast_akkudoktor_adjusted = LoadAkkudoktorAdjusted()
loadforecast_vrm = LoadVrm()
@@ -110,8 +114,10 @@ def prediction_providers() -> (
ElecPriceFixed,
ElecPriceImport,
FeedInTariffEnergyCharts,
FeedInTariffAkkudoktor,
FeedInTariffFixed,
FeedInTariffImport,
FeedInTariffTibber,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
LoadVrm,
@@ -140,8 +146,10 @@ def prediction_providers() -> (
elecprice_fixed, \
elecprice_import, \
feedintariff_energy_charts, \
feedintariff_akkudoktor, \
feedintariff_fixed, \
feedintariff_import, \
feedintariff_tibber, \
loadforecast_akkudoktor, \
loadforecast_akkudoktor_adjusted, \
loadforecast_vrm, \
@@ -165,8 +173,10 @@ def prediction_providers() -> (
elecprice_fixed,
elecprice_import,
feedintariff_energy_charts,
feedintariff_akkudoktor,
feedintariff_fixed,
feedintariff_import,
feedintariff_tibber,
loadforecast_akkudoktor,
loadforecast_akkudoktor_adjusted,
loadforecast_vrm,
@@ -195,8 +205,10 @@ class Prediction(PredictionContainer):
ElecPriceFixed,
ElecPriceImport,
FeedInTariffEnergyCharts,
FeedInTariffAkkudoktor,
FeedInTariffFixed,
FeedInTariffImport,
FeedInTariffTibber,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
LoadVrm,
+7 -2
View File
@@ -1162,6 +1162,7 @@ class GesamtlastRequest(PydanticBaseModel):
year_energy: float
measured_data: List[Dict[str, Any]]
hours: int
force_update: bool = False
@app.post("/gesamtlast", tags=["prediction"])
@@ -1230,11 +1231,15 @@ async def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
energy_mr_values.append(energy_mr)
get_measurement().key_from_lists(measurement_key, energy_mr_dates, energy_mr_values)
# Ensure there is only one optimization/ energy management run at a time
# Ensure there is only one optimization/ energy management run at a time.
# Do not force a full provider refresh by default (see request.force_update):
# forcing bypasses the provider caches and hammers external APIs on every
# call, which made a single flaky provider (e.g. Energy-Charts) abort the
# whole load prediction.
try:
await get_ems().run(
mode=EnergyManagementMode.PREDICTION,
force_update=True,
force_update=request.force_update,
)
except Exception as e:
raise HTTPException(
+2 -3
View File
@@ -253,6 +253,7 @@ def test_request_forecast_uses_tibber_graphql_api(
assert "priceInfoRange" in kwargs["json"]["query"]
assert "QUARTER_HOURLY" in kwargs["json"]["query"]
assert "total" in kwargs["json"]["query"]
assert "energy" in kwargs["json"]["query"]
assert kwargs["timeout"] == 30
@@ -340,9 +341,7 @@ def test_tibber_update_uses_eos_storage_history_when_api_history_is_missing(
assert forecast_call["history_hours"] > 840
def test_tibber_update_preserves_quarter_hour_resolution_and_slots(
tibber_provider, monkeypatch
):
def test_tibber_update_preserves_quarter_hour_resolution_and_slots(tibber_provider, monkeypatch):
"""15-minute Tibber prices are stored natively and extrapolated on the slot grid.
Proves the resolution-agnostic path: (a) the native 15-min resolution survives
+102
View File
@@ -0,0 +1,102 @@
import json
from unittest.mock import Mock, patch
import pytest
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.optimization.genetic.geneticparams import (
MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS,
)
from akkudoktoreos.prediction.elecpriceakkudoktor import AkkudoktorElecPrice
from akkudoktoreos.prediction.feedintariffakkudoktor import FeedInTariffAkkudoktor
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
@pytest.fixture
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
@pytest.fixture
def response_data():
return {
"meta": {
"start_timestamp": "1733871600",
"end_timestamp": "1733958000",
"start": "2024-12-11T00:00:00+01:00",
"end": "2024-12-12T00:00:00+01:00",
},
"values": [
{
"start_timestamp": 1733871600,
"end_timestamp": 1733875200,
"start": "2024-12-11T00:00:00+01:00",
"end": "2024-12-11T01:00:00+01:00",
"marketprice": 100.0,
"unit": "Eur/MWh",
"marketpriceEurocentPerKWh": 10.0,
},
{
"start_timestamp": 1733875200,
"end_timestamp": 1733878800,
"start": "2024-12-11T01:00:00+01:00",
"end": "2024-12-11T02:00:00+01:00",
"marketprice": 200.0,
"unit": "Eur/MWh",
"marketpriceEurocentPerKWh": 20.0,
},
],
}
def test_provider_is_available(config_eos):
assert "FeedInTariffAkkudoktor" in config_eos.feedintariff.providers
assert "FeedInTariffAkkudoktor" in MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS
def test_parse_data_uses_raw_market_price_without_import_charges(provider, response_data):
data = AkkudoktorElecPrice.model_validate(response_data)
series = provider._parse_data(data)
assert series.iloc[0] == pytest.approx(0.0001)
def test_hourly_prices_are_held_constant_on_quarter_hour_grid(provider, response_data):
data = AkkudoktorElecPrice.model_validate(response_data)
provider.key_from_series("feed_in_tariff_wh", provider._parse_data(data))
start = to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin")
values = provider.key_to_array(
key="feed_in_tariff_wh",
start_datetime=start,
end_datetime=start + to_duration("2 hours"),
interval=to_duration("15 minutes"),
fill_method="ffill",
)
assert values.tolist() == pytest.approx([0.0001] * 4 + [0.0002] * 4)
@patch("requests.get")
def test_request_uses_akkudoktor_prices_endpoint(mock_get, provider, response_data):
response = Mock()
response.content = json.dumps(response_data)
response.raise_for_status = Mock()
mock_get.return_value = response
get_ems().set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin"))
provider._request_forecast(force_update=True)
url = mock_get.call_args[0][0]
assert url.startswith("https://api.akkudoktor.net/prices?")
assert "tz=Europe/Berlin" in url
assert mock_get.call_args.kwargs["timeout"] == (5, 20)
+124 -1
View File
@@ -4,10 +4,15 @@ import json
from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pytest
import requests
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecpriceenergycharts import EnergyChartsElecPrice
from akkudoktoreos.prediction.elecpriceenergycharts import (
ElecPriceEnergyCharts,
EnergyChartsElecPrice,
)
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.utils.datetimeutil import to_datetime
@@ -99,3 +104,121 @@ def test_update_data_keeps_quarter_hour_resolution(provider):
)
assert len(result) == provider.config.prediction.hours * 4
assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
def test_repeated_updates_keep_ets_history_and_honor_force_update(provider):
"""A later update must retain ETS history and a forced update must fetch again."""
start = to_datetime(in_timezone="Europe/Berlin").start_of("day")
get_ems().set_start_datetime(start)
provider.config.prediction.hours = 72
raw_start = start.subtract(days=35)
raw_end = start.add(days=2)
raw_slots = int((raw_end - 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,
)
ets_history_lengths = []
def fake_ets(history, seasonal_periods, hours):
ets_history_lengths.append((len(history), seasonal_periods))
return np.full(hours, 0.00005)
with (
patch.object(provider, "_request_forecast", return_value=energy_charts_data) as request,
patch.object(ElecPriceEnergyCharts, "_predict_ets", side_effect=fake_ets),
patch.object(
ElecPriceEnergyCharts,
"_predict_median",
side_effect=AssertionError("median fallback must not be used"),
),
):
provider.update_data(force_enable=True, force_update=True)
provider.update_data(force_enable=True, force_update=False)
# Raw prices already cover the Energy-Charts publication window, so the
# 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(seasonal_periods == 168 * 4 for _, seasonal_periods in ets_history_lengths)
provider.update_data(force_enable=True, force_update=True)
# force_update must bypass the provider's own "no update needed" decision.
assert request.call_count == 2
assert provider.historic_hours_min() == 24 * 35
def test_request_forecast_retries_transient_errors(provider, sample_energycharts_json):
"""A transient timeout is retried; a later success is returned (Fix D)."""
get_ems().set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin"))
ok_response = Mock()
ok_response.status_code = 200
ok_response.content = json.dumps(sample_energycharts_json)
ok_response.raise_for_status = Mock()
with (
patch("requests.get", side_effect=[requests.exceptions.ReadTimeout("t1"), ok_response]) as get_mock,
patch("akkudoktoreos.prediction.feedintariffenergycharts.time.sleep", return_value=None),
):
provider._request_forecast(start_date="2024-12-10", force_update=True)
assert get_mock.call_count == 2
def test_update_data_falls_back_to_history_on_fetch_error(provider):
"""A transient fetch error must not abort the update when history exists (Fix A)."""
start = to_datetime(in_timezone="Europe/Berlin").start_of("day")
get_ems().set_start_datetime(start)
provider.config.prediction.hours = 48
raw_start = start.subtract(days=35)
raw_slots = int((start.add(days=2) - 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_predict(history, slots, slots_per_hour):
return np.full(slots, 0.00005)
with patch.object(provider, "_predict_prices", side_effect=fake_predict):
# First: successful update seeds history and highest_orig_datetime.
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
provider.update_data(force_enable=True, force_update=True)
assert provider.highest_orig_datetime is not None
last_good = provider.highest_orig_datetime
# Second: API times out. With existing history the update must NOT raise
# and the retained history must be kept.
with patch.object(
provider, "_request_forecast", side_effect=requests.exceptions.ReadTimeout("boom")
):
provider.update_data(force_enable=True, force_update=True)
# Fix A: the update did not abort (we got here) and the retained history is
# unchanged, so downstream consumers still receive a feed-in tariff series.
assert provider.highest_orig_datetime == last_good
def test_update_data_cold_start_fetch_error_raises(provider):
"""Without any history a fetch error stays fatal (cold start)."""
start = to_datetime(in_timezone="Europe/Berlin").start_of("day")
get_ems().set_start_datetime(start)
assert provider.highest_orig_datetime is None
with patch.object(
provider, "_request_forecast", side_effect=requests.exceptions.ReadTimeout("boom")
):
with pytest.raises(requests.exceptions.ReadTimeout):
provider.update_data(force_enable=True, force_update=True)
+130
View File
@@ -0,0 +1,130 @@
"""Tests for the native quarter-hour Tibber feed-in tariff provider."""
import json
from unittest.mock import Mock, patch
import pytest
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.optimization.genetic.geneticparams import (
MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS,
)
from akkudoktoreos.prediction.elecpricetibber import TibberGraphQLResponse
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
def _point(starts_at: str, energy: float, total: float = 0.40) -> dict[str, object]:
return {"startsAt": starts_at, "energy": energy, "total": total}
def _payload(points: list[dict[str, object]]) -> dict[str, object]:
return {
"data": {
"viewer": {
"homes": [
{
"id": "home-1",
"currentSubscription": {
"priceInfo": {"today": points[:4], "tomorrow": points[4:]},
"priceInfoRange": {"nodes": points},
},
}
]
}
}
}
@pytest.fixture
def quarter_hour_points():
return [
_point(f"2026-07-15T0{index // 4}:{(index % 4) * 15:02d}:00+02:00", 0.10 + index / 100)
for index in range(8)
]
@pytest.fixture
def provider(config_eos):
FeedInTariffTibber.reset_instance()
config_eos.merge_settings_from_dict(
{
"elecprice": {"tibber": {"access_token": "token-123", "home_id": "home-1"}},
"feedintariff": {
"direct_marketing_enabled": True,
"provider": "FeedInTariffTibber",
},
"prediction": {"hours": 2},
}
)
value = FeedInTariffTibber()
value.highest_orig_datetime = None
value.records.clear()
get_ems().set_start_datetime(
to_datetime("2026-07-15T00:00:00+02:00", in_timezone="Europe/Berlin")
)
return value
def test_provider_is_registered_and_used_for_direct_marketing(provider, config_eos):
assert provider.enabled()
assert "FeedInTariffTibber" in config_eos.feedintariff.providers
assert "FeedInTariffTibber" in MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS
def test_parse_uses_energy_component_at_native_quarter_hour_resolution(
provider, quarter_hour_points
):
response = TibberGraphQLResponse.model_validate(_payload(quarter_hour_points))
series = provider._parse_data(response)
assert series.tolist() == pytest.approx([(0.10 + index / 100) / 1000 for index in range(8)])
assert series.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
@patch("requests.post")
def test_request_is_strictly_quarter_hourly_and_requests_energy(
mock_post, provider, quarter_hour_points
):
response = Mock()
response.content = json.dumps(_payload(quarter_hour_points)).encode()
response.raise_for_status = Mock()
mock_post.return_value = response
provider._request_forecast(force_update=True)
query = mock_post.call_args.kwargs["json"]["query"]
assert "priceInfo(resolution: QUARTER_HOURLY)" in " ".join(query.split())
assert "priceInfoRange(resolution: QUARTER_HOURLY" in " ".join(query.split())
assert "energy" in query
assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer token-123"
def test_update_keeps_four_distinct_prices_per_hour(provider, quarter_hour_points, monkeypatch):
response = TibberGraphQLResponse.model_validate(_payload(quarter_hour_points))
monkeypatch.setattr(provider, "_request_forecast", lambda **_: response)
provider._update_data(force_update=True)
start = to_datetime("2026-07-15T00:00:00+02:00", in_timezone="Europe/Berlin")
prices = provider.key_to_array(
key="feed_in_tariff_wh",
start_datetime=start,
end_datetime=start + to_duration("2 hours"),
interval=to_duration("15 minutes"),
fill_method="ffill",
)
assert prices.tolist() == pytest.approx([(0.10 + index / 100) / 1000 for index in range(8)])
def test_update_rejects_hourly_tibber_data(provider, monkeypatch):
hourly = [
_point("2026-07-15T00:00:00+02:00", 0.10),
_point("2026-07-15T01:00:00+02:00", 0.11),
]
response = TibberGraphQLResponse.model_validate(_payload(hourly))
monkeypatch.setattr(provider, "_request_forecast", lambda **_: response)
with pytest.raises(ValueError, match="requires native 15-minute prices"):
provider._update_data(force_update=True)
+24 -16
View File
@@ -7,9 +7,11 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
from akkudoktoreos.prediction.feedintariffakkudoktor import FeedInTariffAkkudoktor
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber
from akkudoktoreos.prediction.loadakkudoktor import (
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
@@ -48,8 +50,10 @@ def forecast_providers():
ElecPriceFixed(),
ElecPriceImport(),
FeedInTariffEnergyCharts(),
FeedInTariffAkkudoktor(),
FeedInTariffFixed(),
FeedInTariffImport(),
FeedInTariffTibber(),
LoadAkkudoktor(),
LoadAkkudoktorAdjusted(),
LoadVrm(),
@@ -102,22 +106,24 @@ def test_provider_sequence(prediction):
assert isinstance(prediction.providers[3], ElecPriceFixed)
assert isinstance(prediction.providers[4], ElecPriceImport)
assert isinstance(prediction.providers[5], FeedInTariffEnergyCharts)
assert isinstance(prediction.providers[6], FeedInTariffFixed)
assert isinstance(prediction.providers[7], FeedInTariffImport)
assert isinstance(prediction.providers[8], LoadAkkudoktor)
assert isinstance(prediction.providers[9], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[10], LoadVrm)
assert isinstance(prediction.providers[11], LoadImport)
assert isinstance(prediction.providers[12], PVForecastAkkudoktor)
assert isinstance(prediction.providers[13], PVForecastVrm)
assert isinstance(prediction.providers[14], PVForecastPVNode)
assert isinstance(prediction.providers[15], PVForecastForecastSolar)
assert isinstance(prediction.providers[16], PVForecastSolcast)
assert isinstance(prediction.providers[17], PVForecastImport)
assert isinstance(prediction.providers[18], WeatherBrightSky)
assert isinstance(prediction.providers[19], WeatherClearOutside)
assert isinstance(prediction.providers[20], WeatherOpenMeteo)
assert isinstance(prediction.providers[21], WeatherImport)
assert isinstance(prediction.providers[6], FeedInTariffAkkudoktor)
assert isinstance(prediction.providers[7], FeedInTariffFixed)
assert isinstance(prediction.providers[8], FeedInTariffImport)
assert isinstance(prediction.providers[9], FeedInTariffTibber)
assert isinstance(prediction.providers[10], LoadAkkudoktor)
assert isinstance(prediction.providers[11], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[12], LoadVrm)
assert isinstance(prediction.providers[13], LoadImport)
assert isinstance(prediction.providers[14], PVForecastAkkudoktor)
assert isinstance(prediction.providers[15], PVForecastVrm)
assert isinstance(prediction.providers[16], PVForecastPVNode)
assert isinstance(prediction.providers[17], PVForecastForecastSolar)
assert isinstance(prediction.providers[18], PVForecastSolcast)
assert isinstance(prediction.providers[19], PVForecastImport)
assert isinstance(prediction.providers[20], WeatherBrightSky)
assert isinstance(prediction.providers[21], WeatherClearOutside)
assert isinstance(prediction.providers[22], WeatherOpenMeteo)
assert isinstance(prediction.providers[23], WeatherImport)
def test_provider_by_id(prediction, forecast_providers):
@@ -139,7 +145,9 @@ def test_prediction_repr(prediction):
assert "ElecPriceFixed" in result
assert "ElecPriceImport" in result
assert "FeedInTariffFixed" in result
assert "FeedInTariffAkkudoktor" in result
assert "FeedInTariffImport" in result
assert "FeedInTariffTibber" in result
assert "LoadAkkudoktor" in result
assert "LoadVrm" in result
assert "LoadImport" in result