feat: add EnergyCharts feed-in tariff provider (#1165)
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled

The `FeedInTariffEnergyCharts` provider uses the raw Energy-Charts day-ahead market price as the
feed-in tariff. It stores prices in `feed_in_tariff_wh` without adding electricity import charges
or VAT. The data is loaded from the Energy-Charts `/price` endpoint for the configured bidding
zone. The native Energy-Charts resolution, including quarter-hour data, is retained.

Energy-Charts usually supplies prices only for the published day-ahead period. If that data does
not cover the complete configured prediction horizon, the provider extends it as follows:

- With more than 800 hours of history, an ETS (Holt-Winters exponential smoothing) forecast with
  weekly seasonality is used.
- With more than 168 hours of history, an ETS forecast with daily seasonality is used.
- With less history, the median of the available values is used as a constant fallback.

The seasonal periods are adjusted to the source resolution. For example, quarter-hour data uses
four values per hour. Values already supplied by Energy-Charts are kept unchanged; only missing
future slots after the last published price are forecast. Consequently, a 15-minute optimization
uses four forecast values per hour without converting them to hourly averages.

Signed-off-by: Andreas Schmitz <akkudoktor.net>
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2026-07-23 15:48:29 +02:00
committed by GitHub
parent 6093d8d348
commit ed61918fe0
15 changed files with 697 additions and 24 deletions

2
.env
View File

@@ -11,7 +11,7 @@ DOCKER_COMPOSE_DATA_DIR=${HOME}/.local/share/net.akkudoktor.eos
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Image / build # Image / build
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
VERSION=0.3.0.dev2607231185985809 VERSION=0.3.0.dev2607231331447302
PYTHON_VERSION=3.13.9 PYTHON_VERSION=3.13.9
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -6,7 +6,7 @@
# the root directory (no add-on folder as usual). # the root directory (no add-on folder as usual).
name: "Akkudoktor-EOS" name: "Akkudoktor-EOS"
version: "0.3.0.dev2607231185985809" version: "0.3.0.dev2607231331447302"
slug: "eos" slug: "eos"
description: "Akkudoktor-EOS add-on" description: "Akkudoktor-EOS add-on"
url: "https://github.com/Akkudoktor-EOS/EOS" url: "https://github.com/Akkudoktor-EOS/EOS"

View File

@@ -129,6 +129,9 @@
"feedintariffimport": { "feedintariffimport": {
"import_file_path": null, "import_file_path": null,
"import_json": null "import_json": null
},
"energycharts": {
"bidding_zone": "DE-LU"
} }
}, },
"general": { "general": {

View File

@@ -7,6 +7,7 @@
| Name | Environment Variable | Type | Read-Only | Default | Description | | Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- | | ---- | -------------------- | ---- | --------- | ------- | ----------- |
| energycharts | `EOS_FEEDINTARIFF__ENERGYCHARTS` | `FeedInTariffEnergyChartsCommonSettings` | `rw` | `required` | EnergyCharts feed in tariff provider settings. |
| feedintarifffixed | `EOS_FEEDINTARIFF__FEEDINTARIFFFIXED` | `FeedInTariffFixedCommonSettings` | `rw` | `required` | Fixed feed in tariff provider settings. | | feedintarifffixed | `EOS_FEEDINTARIFF__FEEDINTARIFFFIXED` | `FeedInTariffFixedCommonSettings` | `rw` | `required` | Fixed feed in tariff provider settings. |
| feedintariffimport | `EOS_FEEDINTARIFF__FEEDINTARIFFIMPORT` | `FeedInTariffImportCommonSettings` | `rw` | `required` | Feed in tarif import provider settings. | | feedintariffimport | `EOS_FEEDINTARIFF__FEEDINTARIFFIMPORT` | `FeedInTariffImportCommonSettings` | `rw` | `required` | Feed in tarif import provider settings. |
| provider | `EOS_FEEDINTARIFF__PROVIDER` | `Optional[str]` | `rw` | `None` | Feed in tariff provider id of provider to be used. | | provider | `EOS_FEEDINTARIFF__PROVIDER` | `Optional[str]` | `rw` | `None` | Feed in tariff provider id of provider to be used. |
@@ -29,6 +30,9 @@
"feedintariffimport": { "feedintariffimport": {
"import_file_path": null, "import_file_path": null,
"import_json": null "import_json": null
},
"energycharts": {
"bidding_zone": "DE-LU"
} }
} }
} }
@@ -51,7 +55,11 @@
"import_file_path": null, "import_file_path": null,
"import_json": null "import_json": null
}, },
"energycharts": {
"bidding_zone": "DE-LU"
},
"providers": [ "providers": [
"FeedInTariffEnergyCharts",
"FeedInTariffFixed", "FeedInTariffFixed",
"FeedInTariffImport" "FeedInTariffImport"
] ]
@@ -119,3 +127,32 @@
} }
``` ```
<!-- pyml enable line-length --> <!-- pyml enable line-length -->
### Common settings for Energy-Charts feed-in tariff provider
<!-- pyml disable line-length -->
:::{table} feedintariff::energycharts
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| bidding_zone | `<enum 'EnergyChartsBiddingZones'>` | `rw` | `DE-LU` | Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', 'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI' |
:::
<!-- 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": {
"energycharts": {
"bidding_zone": "DE-LU"
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,6 +1,6 @@
# Akkudoktor-EOS # Akkudoktor-EOS
**Version**: `v0.3.0.dev2607231185985809` **Version**: `v0.3.0.dev2607231331447302`
<!-- pyml disable line-length --> <!-- pyml disable line-length -->
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period. **Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.

View File

@@ -217,13 +217,49 @@ Configuration options:
- `provider`: Feed in tariff provider id of provider to be used. - `provider`: Feed in tariff provider id of provider to be used.
- `FeedInTariffEnergyCharts`: Retrieves Energy-Charts day-ahead market prices and extends
them to the configured prediction horizon when necessary.
- `FeedInTariffFixed`: Provides fixed feed in tariff values. - `FeedInTariffFixed`: Provides fixed feed in tariff values.
- `FeedInTariffImport`: Imports from a file or JSON string or by endpoint data provision. - `FeedInTariffImport`: Imports from a file or JSON string or by endpoint data provision.
- `energycharts.bidding_zone`: Bidding zone Energy Charts shall provide feed-in tariff for.
- `feedintarifffixed.feed_in_tariff_kwh`: Fixed feed in tariff (€/kWh). - `feedintarifffixed.feed_in_tariff_kwh`: Fixed feed in tariff (€/kWh).
- `feedintariffimport.import_file_path`: Path to the file to import feed in tariff forecast data from. - `feedintariffimport.import_file_path`: Path to the file to import feed in tariff forecast data from.
- `feedintariffimport.import_json`: JSON string, dictionary of feed in tariff value lists. - `feedintariffimport.import_json`: JSON string, dictionary of feed in tariff value lists.
### FeedInTariffEnergyCharts Provider
The `FeedInTariffEnergyCharts` provider uses the raw Energy-Charts day-ahead market price as the
feed-in tariff. It stores prices in `feed_in_tariff_wh` without adding electricity import charges
or VAT. The data is loaded from the Energy-Charts `/price` endpoint for the configured bidding
zone. The native Energy-Charts resolution, including quarter-hour data, is retained.
Energy-Charts usually supplies prices only for the published day-ahead period. If that data does
not cover the complete configured prediction horizon, the provider extends it as follows:
- With more than 800 hours of history, an ETS (Holt-Winters exponential smoothing) forecast with
weekly seasonality is used.
- With more than 168 hours of history, an ETS forecast with daily seasonality is used.
- With less history, the median of the available values is used as a constant fallback.
The seasonal periods are adjusted to the source resolution. For example, quarter-hour data uses
four values per hour. Values already supplied by Energy-Charts are kept unchanged; only missing
future slots after the last published price are forecast. Consequently, a 15-minute optimization
uses four forecast values per hour without converting them to hourly averages.
Example configuration:
```json
{
"feedintariff": {
"provider": "FeedInTariffEnergyCharts",
"energycharts": {
"bidding_zone": "DE-LU"
}
}
}
```
### FeedInTariffImport Provider ### FeedInTariffImport Provider
The `FeedInTariffImport` provider is designed to import feed in tariff prices from: The `FeedInTariffImport` provider is designed to import feed in tariff prices from:

View File

@@ -8,7 +8,7 @@
"name": "Apache 2.0", "name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html" "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
}, },
"version": "v0.3.0.dev2607231185985809" "version": "v0.3.0.dev2607231331447302"
}, },
"paths": { "paths": {
"/v1/admin/cache/clear": { "/v1/admin/cache/clear": {
@@ -4400,6 +4400,10 @@
"feedintariffimport": { "feedintariffimport": {
"$ref": "#/components/schemas/FeedInTariffImportCommonSettings", "$ref": "#/components/schemas/FeedInTariffImportCommonSettings",
"description": "Feed in tarif import provider settings." "description": "Feed in tarif import provider settings."
},
"energycharts": {
"$ref": "#/components/schemas/FeedInTariffEnergyChartsCommonSettings",
"description": "EnergyCharts feed in tariff provider settings."
} }
}, },
"type": "object", "type": "object",
@@ -4432,6 +4436,10 @@
"$ref": "#/components/schemas/FeedInTariffImportCommonSettings", "$ref": "#/components/schemas/FeedInTariffImportCommonSettings",
"description": "Feed in tarif import provider settings." "description": "Feed in tarif import provider settings."
}, },
"energycharts": {
"$ref": "#/components/schemas/FeedInTariffEnergyChartsCommonSettings",
"description": "EnergyCharts feed in tariff provider settings."
},
"providers": { "providers": {
"items": { "items": {
"type": "string" "type": "string"
@@ -4449,6 +4457,21 @@
"title": "FeedInTariffCommonSettings", "title": "FeedInTariffCommonSettings",
"description": "Feed In Tariff Prediction Configuration." "description": "Feed In Tariff Prediction Configuration."
}, },
"FeedInTariffEnergyChartsCommonSettings": {
"properties": {
"bidding_zone": {
"$ref": "#/components/schemas/EnergyChartsBiddingZones",
"description": "Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', 'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI'",
"default": "DE-LU",
"examples": [
"DE-LU"
]
}
},
"type": "object",
"title": "FeedInTariffEnergyChartsCommonSettings",
"description": "Common settings for Energy-Charts feed-in tariff provider."
},
"FeedInTariffFixedCommonSettings": { "FeedInTariffFixedCommonSettings": {
"properties": { "properties": {
"feed_in_tariff_kwh": { "feed_in_tariff_kwh": {

View File

@@ -5,6 +5,9 @@ from pydantic import Field, computed_field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.core.coreabc import get_prediction
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
from akkudoktoreos.prediction.feedintariffenergycharts import (
FeedInTariffEnergyChartsCommonSettings,
)
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings
@@ -16,7 +19,11 @@ def feedintariff_provider_ids() -> list[str]:
except Exception: except Exception:
# Prediction may not be initialized # Prediction may not be initialized
# Return at least provider used in example # Return at least provider used in example
return ["FeedInTariffFixed", "FeedInTarifImport"] return [
"FeedInTariffEnergyCharts",
"FeedInTariffFixed",
"FeedInTarifImport",
]
return [ return [
provider.provider_id() provider.provider_id()
@@ -46,6 +53,11 @@ class FeedInTariffCommonSettings(SettingsBaseModel):
json_schema_extra={"description": "Feed in tarif import provider settings."}, json_schema_extra={"description": "Feed in tarif import provider settings."},
) )
energycharts: FeedInTariffEnergyChartsCommonSettings = Field(
default_factory=FeedInTariffEnergyChartsCommonSettings,
json_schema_extra={"description": "EnergyCharts feed in tariff provider settings."},
)
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def providers(self) -> list[str]: def providers(self) -> list[str]:

View File

@@ -0,0 +1,292 @@
"""Provides feed-in tariff data from Energy-Charts 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 pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.prediction.elecpriceenergycharts import (
ElecPriceEnergyCharts,
EnergyChartsBiddingZones,
EnergyChartsElecPrice,
)
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
class FeedInTariffEnergyChartsCommonSettings(SettingsBaseModel):
"""Common settings for Energy-Charts feed-in tariff provider."""
bidding_zone: EnergyChartsBiddingZones = Field(
default=EnergyChartsBiddingZones.DE_LU,
json_schema_extra={
"description": (
"Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', "
"'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI'"
),
"examples": ["DE-LU"],
},
)
class FeedInTariffEnergyCharts(FeedInTariffProvider):
"""Fetch Energy-Charts market prices as feed-in tariff data.
This provider stores the raw Energy-Charts day-ahead market price as
``feed_in_tariff_wh``. Unlike ``ElecPriceEnergyCharts`` it intentionally
does not add electricity import charges or VAT.
"""
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."""
return "FeedInTariffEnergyCharts"
def _bidding_zone(self) -> str:
settings = self.config.feedintariff.energycharts
if settings is None:
return EnergyChartsBiddingZones.DE_LU.value
bidding_zone = settings.bidding_zone
if isinstance(bidding_zone, EnergyChartsBiddingZones):
return bidding_zone.value
return str(bidding_zone)
@cache_in_file(with_ttl="1 hour")
def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice:
"""Fetch market price forecast data from Energy-Charts."""
source = "https://api.energy-charts.info"
if start_date is None:
start_date = to_datetime(
self.ems_start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD"
)
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}"
# 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)
for unix_sec, price_eur_per_mwh in zip(
energy_charts_data.unix_seconds, energy_charts_data.price, strict=False
):
orig_datetime = to_datetime(unix_sec, in_timezone=self.config.general.timezone)
series_data.at[orig_datetime] = price_eur_per_mwh / 1_000_000
return series_data
def _resolution_seconds(self, series: pd.Series) -> int:
"""Infer the current native market interval from recent timestamps."""
if len(series) < 2:
return 3600
index = pd.DatetimeIndex(series.sort_index().index).drop_duplicates()
deltas = index.to_series().diff().dropna().dt.total_seconds()
deltas = deltas[deltas > 0].tail(96)
if deltas.empty:
return 3600
resolution = int(round(float(deltas.median())))
return resolution if resolution > 0 and 3600 % resolution == 0 else 3600
def _predict_prices(self, history: np.ndarray, slots: int, slots_per_hour: int) -> np.ndarray:
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")
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update feed-in tariff forecast data from Energy-Charts."""
# New prices are available every day at 14:00
now = pd.Timestamp.now(tz=self.config.general.timezone)
midnight = now.normalize()
hours_ahead = 23 if now.time() < pd.Timestamp("14:00").time() else 47
end = midnight + pd.Timedelta(hours=hours_ahead)
if not self.ems_start_datetime:
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
# Determine if update is needed and how many days
past_days = 35
needs_history_refresh = False
if self.highest_orig_datetime:
raw_history = await self.key_to_series(
key="feed_in_tariff_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
# 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 = self._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 = (
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: {}, "
"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
) # type: ignore
series_data = self._parse_data(energy_charts_data)
if series_data.empty:
raise ValueError("No Energy-Charts feed-in tariff data available")
self.highest_orig_datetime = series_data.index.max()
await 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: {}",
self.highest_orig_datetime,
)
if not self.highest_orig_datetime:
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
raw_series = await self.key_to_series(
key="feed_in_tariff_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
resolution_seconds = self._resolution_seconds(raw_series)
slots_per_hour = 3600 // resolution_seconds
history = await self.key_to_array(
key="feed_in_tariff_wh",
end_datetime=self.highest_orig_datetime,
interval=to_duration(f"{resolution_seconds} seconds"),
fill_method="linear",
)
# some of our data is already in the future, so we need to predict less.
# If we got less data we increase the prediction hours
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()
// resolution_seconds
)
+ 1
)
needed_slots = self.config.prediction.hours * slots_per_hour - covered_slots
if needed_slots <= 0:
logger.warning(
"No feed-in tariff prediction needed. needed_slots={}, hours={}, "
"resolution_seconds={}, highest_orig_datetime={}, start_datetime={}",
needed_slots,
self.config.prediction.hours,
resolution_seconds,
self.highest_orig_datetime,
self.ems_start_datetime,
)
return
prediction = self._predict_prices(history, needed_slots, slots_per_hour)
prediction_series = pd.Series(
data=prediction,
index=[
self.highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds")
for i in range(len(prediction))
],
)
await self.key_from_series("feed_in_tariff_wh", prediction_series)

View File

@@ -36,6 +36,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.loadakkudoktor import ( from akkudoktoreos.prediction.loadakkudoktor import (
@@ -81,6 +82,7 @@ elecprice_energy_charts = ElecPriceEnergyCharts()
elecprice_fixed = ElecPriceFixed() elecprice_fixed = ElecPriceFixed()
elecprice_import = ElecPriceImport() elecprice_import = ElecPriceImport()
elecprice_tibber = ElecPriceTibber() elecprice_tibber = ElecPriceTibber()
feedintariff_energy_charts = FeedInTariffEnergyCharts()
feedintariff_fixed = FeedInTariffFixed() feedintariff_fixed = FeedInTariffFixed()
feedintariff_import = FeedInTariffImport() feedintariff_import = FeedInTariffImport()
loadforecast_akkudoktor = LoadAkkudoktor() loadforecast_akkudoktor = LoadAkkudoktor()
@@ -106,6 +108,7 @@ def prediction_providers() -> list[
ElecPriceFixed, ElecPriceFixed,
ElecPriceImport, ElecPriceImport,
ElecPriceTibber, ElecPriceTibber,
FeedInTariffEnergyCharts,
FeedInTariffFixed, FeedInTariffFixed,
FeedInTariffImport, FeedInTariffImport,
LoadAkkudoktor, LoadAkkudoktor,
@@ -134,6 +137,7 @@ def prediction_providers() -> list[
elecprice_fixed, \ elecprice_fixed, \
elecprice_import, \ elecprice_import, \
elecprice_tibber, \ elecprice_tibber, \
feedintariff_energy_charts, \
feedintariff_fixed, \ feedintariff_fixed, \
feedintariff_import, \ feedintariff_import, \
loadforecast_akkudoktor, \ loadforecast_akkudoktor, \
@@ -158,6 +162,7 @@ def prediction_providers() -> list[
elecprice_fixed, elecprice_fixed,
elecprice_import, elecprice_import,
elecprice_tibber, elecprice_tibber,
feedintariff_energy_charts,
feedintariff_fixed, feedintariff_fixed,
feedintariff_import, feedintariff_import,
loadforecast_akkudoktor, loadforecast_akkudoktor,
@@ -187,6 +192,7 @@ class Prediction(PredictionContainer):
ElecPriceFixed, ElecPriceFixed,
ElecPriceImport, ElecPriceImport,
ElecPriceTibber, ElecPriceTibber,
FeedInTariffEnergyCharts,
FeedInTariffFixed, FeedInTariffFixed,
FeedInTariffImport, FeedInTariffImport,
LoadAkkudoktor, LoadAkkudoktor,

View File

@@ -0,0 +1,220 @@
# ruff: noqa: S101
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 (
ElecPriceEnergyCharts,
EnergyChartsElecPrice,
)
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.utils.datetimeutil import to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON = DIR_TESTDATA.joinpath(
"elecpriceforecast_energycharts.json"
)
@pytest.fixture
def provider(config_eos):
config_eos.merge_settings_from_dict(
{
"feedintariff": {
"provider": "FeedInTariffEnergyCharts",
"energycharts": {"bidding_zone": "AT"},
},
}
)
provider = FeedInTariffEnergyCharts()
provider.highest_orig_datetime = None
provider.records.clear()
assert provider.enabled()
return provider
@pytest.fixture
def sample_energycharts_json():
with FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON.open(
"r", encoding="utf-8", newline=None
) as f_res:
return json.load(f_res)
class TestFeedInTariffEnergyCharts:
def test_provider_is_available(self, config_eos):
assert "FeedInTariffEnergyCharts" in config_eos.feedintariff.providers
def test_parse_data_uses_raw_market_price(self, provider, sample_energycharts_json):
energy_charts_data = EnergyChartsElecPrice.model_validate(sample_energycharts_json)
series = provider._parse_data(energy_charts_data)
assert series.iloc[0] == pytest.approx(sample_energycharts_json["price"][0] / 1_000_000)
@patch("requests.get")
def test_request_forecast_uses_feedintariff_bidding_zone(
self, mock_get, provider, sample_energycharts_json
):
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_energycharts_json)
mock_get.return_value = mock_response
get_ems().set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin"))
provider._request_forecast(start_date="2024-12-10", force_update=True)
actual_url = mock_get.call_args[0][0]
assert "bzn=AT" in actual_url
@pytest.mark.asyncio
async def test_update_data_keeps_quarter_hour_resolution(self, provider):
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 * 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="feed_in_tariff_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
)
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_repeated_updates_keep_ets_history_and_honor_force_update(self, 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"),
),
):
await provider.update_data(force_enable=True, force_update=True)
await 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)
await 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(self, 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
@pytest.mark.asyncio
async def test_update_data_falls_back_to_history_on_fetch_error(self, 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):
await 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")
):
await 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
@pytest.mark.asyncio
async def test_update_data_cold_start_fetch_error_raises(self, 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):
await provider.update_data(force_enable=True, force_update=True)

View File

@@ -7,6 +7,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.loadakkudoktor import ( from akkudoktoreos.prediction.loadakkudoktor import (
@@ -46,6 +47,7 @@ def forecast_providers():
ElecPriceFixed(), ElecPriceFixed(),
ElecPriceImport(), ElecPriceImport(),
ElecPriceTibber(), ElecPriceTibber(),
FeedInTariffEnergyCharts(),
FeedInTariffFixed(), FeedInTariffFixed(),
FeedInTariffImport(), FeedInTariffImport(),
LoadAkkudoktor(), LoadAkkudoktor(),
@@ -99,22 +101,23 @@ def test_provider_sequence(prediction):
assert isinstance(prediction.providers[2], ElecPriceFixed) assert isinstance(prediction.providers[2], ElecPriceFixed)
assert isinstance(prediction.providers[3], ElecPriceImport) assert isinstance(prediction.providers[3], ElecPriceImport)
assert isinstance(prediction.providers[4], ElecPriceTibber) assert isinstance(prediction.providers[4], ElecPriceTibber)
assert isinstance(prediction.providers[5], FeedInTariffFixed) assert isinstance(prediction.providers[5], FeedInTariffEnergyCharts)
assert isinstance(prediction.providers[6], FeedInTariffImport) assert isinstance(prediction.providers[6], FeedInTariffFixed)
assert isinstance(prediction.providers[7], LoadAkkudoktor) assert isinstance(prediction.providers[7], FeedInTariffImport)
assert isinstance(prediction.providers[8], LoadAkkudoktorAdjusted) assert isinstance(prediction.providers[8], LoadAkkudoktor)
assert isinstance(prediction.providers[9], LoadVrm) assert isinstance(prediction.providers[9], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[10], LoadImport) assert isinstance(prediction.providers[10], LoadVrm)
assert isinstance(prediction.providers[11], PVForecastAkkudoktor) assert isinstance(prediction.providers[11], LoadImport)
assert isinstance(prediction.providers[12], PVForecastVrm) assert isinstance(prediction.providers[12], PVForecastAkkudoktor)
assert isinstance(prediction.providers[13], PVForecastPVNode) assert isinstance(prediction.providers[13], PVForecastVrm)
assert isinstance(prediction.providers[14], PVForecastForecastSolar) assert isinstance(prediction.providers[14], PVForecastPVNode)
assert isinstance(prediction.providers[15], PVForecastSolcast) assert isinstance(prediction.providers[15], PVForecastForecastSolar)
assert isinstance(prediction.providers[16], PVForecastImport) assert isinstance(prediction.providers[16], PVForecastSolcast)
assert isinstance(prediction.providers[17], WeatherBrightSky) assert isinstance(prediction.providers[17], PVForecastImport)
assert isinstance(prediction.providers[18], WeatherClearOutside) assert isinstance(prediction.providers[18], WeatherBrightSky)
assert isinstance(prediction.providers[19], WeatherOpenMeteo) assert isinstance(prediction.providers[19], WeatherClearOutside)
assert isinstance(prediction.providers[20], WeatherImport) assert isinstance(prediction.providers[20], WeatherOpenMeteo)
assert isinstance(prediction.providers[21], WeatherImport)
def test_provider_by_id(prediction, forecast_providers): def test_provider_by_id(prediction, forecast_providers):
@@ -132,6 +135,7 @@ def test_prediction_repr(prediction):
assert "ElecPriceFixed" in result assert "ElecPriceFixed" in result
assert "ElecPriceImport" in result assert "ElecPriceImport" in result
assert "ElecPriceTibber" in result assert "ElecPriceTibber" in result
assert "FeedInTariffEnergyCharts" in result
assert "FeedInTariffFixed" in result assert "FeedInTariffFixed" in result
assert "FeedInTariffImport" in result assert "FeedInTariffImport" in result
assert "LoadAkkudoktor" in result assert "LoadAkkudoktor" in result

View File

@@ -129,6 +129,9 @@
"feedintariffimport": { "feedintariffimport": {
"import_file_path": null, "import_file_path": null,
"import_json": null "import_json": null
},
"energycharts": {
"bidding_zone": "DE-LU"
} }
}, },
"general": { "general": {

View File

@@ -7,6 +7,7 @@
| Name | Environment Variable | Type | Read-Only | Default | Description | | Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- | | ---- | -------------------- | ---- | --------- | ------- | ----------- |
| energycharts | `EOS_FEEDINTARIFF__ENERGYCHARTS` | `FeedInTariffEnergyChartsCommonSettings` | `rw` | `required` | EnergyCharts feed in tariff provider settings. |
| feedintarifffixed | `EOS_FEEDINTARIFF__FEEDINTARIFFFIXED` | `FeedInTariffFixedCommonSettings` | `rw` | `required` | Fixed feed in tariff provider settings. | | feedintarifffixed | `EOS_FEEDINTARIFF__FEEDINTARIFFFIXED` | `FeedInTariffFixedCommonSettings` | `rw` | `required` | Fixed feed in tariff provider settings. |
| feedintariffimport | `EOS_FEEDINTARIFF__FEEDINTARIFFIMPORT` | `FeedInTariffImportCommonSettings` | `rw` | `required` | Feed in tarif import provider settings. | | feedintariffimport | `EOS_FEEDINTARIFF__FEEDINTARIFFIMPORT` | `FeedInTariffImportCommonSettings` | `rw` | `required` | Feed in tarif import provider settings. |
| provider | `EOS_FEEDINTARIFF__PROVIDER` | `Optional[str]` | `rw` | `None` | Feed in tariff provider id of provider to be used. | | provider | `EOS_FEEDINTARIFF__PROVIDER` | `Optional[str]` | `rw` | `None` | Feed in tariff provider id of provider to be used. |
@@ -29,6 +30,9 @@
"feedintariffimport": { "feedintariffimport": {
"import_file_path": null, "import_file_path": null,
"import_json": null "import_json": null
},
"energycharts": {
"bidding_zone": "DE-LU"
} }
} }
} }
@@ -51,7 +55,11 @@
"import_file_path": null, "import_file_path": null,
"import_json": null "import_json": null
}, },
"energycharts": {
"bidding_zone": "DE-LU"
},
"providers": [ "providers": [
"FeedInTariffEnergyCharts",
"FeedInTariffFixed", "FeedInTariffFixed",
"FeedInTariffImport" "FeedInTariffImport"
] ]
@@ -119,3 +127,32 @@
} }
``` ```
<!-- pyml enable line-length --> <!-- pyml enable line-length -->
### Common settings for Energy-Charts feed-in tariff provider
<!-- pyml disable line-length -->
:::{table} feedintariff::energycharts
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| bidding_zone | `<enum 'EnergyChartsBiddingZones'>` | `rw` | `DE-LU` | Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', 'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI' |
:::
<!-- 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": {
"energycharts": {
"bidding_zone": "DE-LU"
}
}
}
```
<!-- pyml enable line-length -->

6
uv.lock generated
View File

@@ -1010,11 +1010,11 @@ standard-no-fastapi-cloud-cli = [
[[package]] [[package]]
name = "fastcore" name = "fastcore"
version = "2.1.4" version = "2.1.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/cd/1c7fe06bd4b42d199b6c70b82f09f177eb22b8b520fd4861d64514c4dc70/fastcore-2.1.4.tar.gz", hash = "sha256:2c6ef14b4f986a1e1792906c0f4b8d9ada85e014b110ce4ffb46ca18eed7cb5b", size = 113686, upload-time = "2026-07-20T13:29:22.152Z" } sdist = { url = "https://files.pythonhosted.org/packages/2c/83/81349cd1cb156b1737431ef6f3fc3065b4c2f94729bf2e10620c29c36699/fastcore-2.1.5.tar.gz", hash = "sha256:e029a2231e826c38bc02be4a179ab85737d8eeabe9c54d4f54a7ee77e721f1eb", size = 113991, upload-time = "2026-07-22T05:42:32.075Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/55/1d/56a84d96e89505ddcb4f3017f60e47192bf74164f38c0ae2bb54279bf048/fastcore-2.1.4-py3-none-any.whl", hash = "sha256:25bd4b9e941454619b2eaa5726bb1e6a3fd531e5ea7ab7e1e7f1d9dcf12b9705", size = 118789, upload-time = "2026-07-20T13:29:20.535Z" }, { url = "https://files.pythonhosted.org/packages/1d/d6/a660226b0770c5d4a39d5964a5e00745dc5ca25fa9d9e7948e911c30c3c3/fastcore-2.1.5-py3-none-any.whl", hash = "sha256:612a1084f039a4f65695713f62d411dfa5b9f5f4f8d767ff999b7f6c65a29bd7", size = 119006, upload-time = "2026-07-22T05:42:30.619Z" },
] ]
[[package]] [[package]]