From ed61918fe0fcac931a6bbf54778c9382e8284192 Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Thu, 23 Jul 2026 15:48:29 +0200 Subject: [PATCH] feat: add EnergyCharts feed-in tariff provider (#1165) 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 Signed-off-by: Bobby Noelte --- .env | 2 +- config.yaml | 2 +- docs/_generated/configexample.md | 3 + docs/_generated/configfeedintariff.md | 37 +++ docs/_generated/openapi.md | 2 +- docs/akkudoktoreos/prediction.md | 36 +++ openapi.json | 25 +- src/akkudoktoreos/prediction/feedintariff.py | 14 +- .../prediction/feedintariffenergycharts.py | 292 ++++++++++++++++++ src/akkudoktoreos/prediction/prediction.py | 6 + tests/test_feedintariffenergycharts.py | 220 +++++++++++++ tests/test_prediction.py | 36 ++- .../testdata/docs/_generated/configexample.md | 3 + .../docs/_generated/configfeedintariff.md | 37 +++ uv.lock | 6 +- 15 files changed, 697 insertions(+), 24 deletions(-) create mode 100644 src/akkudoktoreos/prediction/feedintariffenergycharts.py create mode 100644 tests/test_feedintariffenergycharts.py diff --git a/.env b/.env index cb704ac7..7ef0458e 100644 --- a/.env +++ b/.env @@ -11,7 +11,7 @@ DOCKER_COMPOSE_DATA_DIR=${HOME}/.local/share/net.akkudoktor.eos # ----------------------------------------------------------------------------- # Image / build # ----------------------------------------------------------------------------- -VERSION=0.3.0.dev2607231185985809 +VERSION=0.3.0.dev2607231331447302 PYTHON_VERSION=3.13.9 # ----------------------------------------------------------------------------- diff --git a/config.yaml b/config.yaml index 611752ff..494c5480 100644 --- a/config.yaml +++ b/config.yaml @@ -6,7 +6,7 @@ # the root directory (no add-on folder as usual). name: "Akkudoktor-EOS" -version: "0.3.0.dev2607231185985809" +version: "0.3.0.dev2607231331447302" slug: "eos" description: "Akkudoktor-EOS add-on" url: "https://github.com/Akkudoktor-EOS/EOS" diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index 904891db..521182f4 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -129,6 +129,9 @@ "feedintariffimport": { "import_file_path": null, "import_json": null + }, + "energycharts": { + "bidding_zone": "DE-LU" } }, "general": { diff --git a/docs/_generated/configfeedintariff.md b/docs/_generated/configfeedintariff.md index 217b41ac..6ced93db 100644 --- a/docs/_generated/configfeedintariff.md +++ b/docs/_generated/configfeedintariff.md @@ -7,6 +7,7 @@ | 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. | | 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. | @@ -29,6 +30,9 @@ "feedintariffimport": { "import_file_path": null, "import_json": null + }, + "energycharts": { + "bidding_zone": "DE-LU" } } } @@ -51,7 +55,11 @@ "import_file_path": null, "import_json": null }, + "energycharts": { + "bidding_zone": "DE-LU" + }, "providers": [ + "FeedInTariffEnergyCharts", "FeedInTariffFixed", "FeedInTariffImport" ] @@ -119,3 +127,32 @@ } ``` + +### Common settings for Energy-Charts feed-in tariff provider + + +:::{table} feedintariff::energycharts +:widths: 10 10 5 5 30 +:align: left + +| Name | Type | Read-Only | Default | Description | +| ---- | ---- | --------- | ------- | ----------- | +| bidding_zone | `` | `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' | +::: + + + +**Example Input/Output** + + + +```json + { + "feedintariff": { + "energycharts": { + "bidding_zone": "DE-LU" + } + } + } +``` + diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index 89349ccd..50dbfe5b 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -1,6 +1,6 @@ # Akkudoktor-EOS -**Version**: `v0.3.0.dev2607231185985809` +**Version**: `v0.3.0.dev2607231331447302` **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. diff --git a/docs/akkudoktoreos/prediction.md b/docs/akkudoktoreos/prediction.md index d1f3251e..1ff9a9a4 100644 --- a/docs/akkudoktoreos/prediction.md +++ b/docs/akkudoktoreos/prediction.md @@ -217,13 +217,49 @@ Configuration options: - `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. - `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). - `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. +### 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 The `FeedInTariffImport` provider is designed to import feed in tariff prices from: diff --git a/openapi.json b/openapi.json index a8b57f6c..9bf805ce 100644 --- a/openapi.json +++ b/openapi.json @@ -8,7 +8,7 @@ "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - "version": "v0.3.0.dev2607231185985809" + "version": "v0.3.0.dev2607231331447302" }, "paths": { "/v1/admin/cache/clear": { @@ -4400,6 +4400,10 @@ "feedintariffimport": { "$ref": "#/components/schemas/FeedInTariffImportCommonSettings", "description": "Feed in tarif import provider settings." + }, + "energycharts": { + "$ref": "#/components/schemas/FeedInTariffEnergyChartsCommonSettings", + "description": "EnergyCharts feed in tariff provider settings." } }, "type": "object", @@ -4432,6 +4436,10 @@ "$ref": "#/components/schemas/FeedInTariffImportCommonSettings", "description": "Feed in tarif import provider settings." }, + "energycharts": { + "$ref": "#/components/schemas/FeedInTariffEnergyChartsCommonSettings", + "description": "EnergyCharts feed in tariff provider settings." + }, "providers": { "items": { "type": "string" @@ -4449,6 +4457,21 @@ "title": "FeedInTariffCommonSettings", "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": { "properties": { "feed_in_tariff_kwh": { diff --git a/src/akkudoktoreos/prediction/feedintariff.py b/src/akkudoktoreos/prediction/feedintariff.py index 7e1ade4e..1c715ee8 100644 --- a/src/akkudoktoreos/prediction/feedintariff.py +++ b/src/akkudoktoreos/prediction/feedintariff.py @@ -5,6 +5,9 @@ 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.feedintariffenergycharts import ( + FeedInTariffEnergyChartsCommonSettings, +) from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings @@ -16,7 +19,11 @@ def feedintariff_provider_ids() -> list[str]: except Exception: # Prediction may not be initialized # Return at least provider used in example - return ["FeedInTariffFixed", "FeedInTarifImport"] + return [ + "FeedInTariffEnergyCharts", + "FeedInTariffFixed", + "FeedInTarifImport", + ] return [ provider.provider_id() @@ -46,6 +53,11 @@ class FeedInTariffCommonSettings(SettingsBaseModel): 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] @property def providers(self) -> list[str]: diff --git a/src/akkudoktoreos/prediction/feedintariffenergycharts.py b/src/akkudoktoreos/prediction/feedintariffenergycharts.py new file mode 100644 index 00000000..2ab9392e --- /dev/null +++ b/src/akkudoktoreos/prediction/feedintariffenergycharts.py @@ -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) diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index c350ebe8..f9d8dc66 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -36,6 +36,7 @@ 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.feedintariffenergycharts import FeedInTariffEnergyCharts from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport from akkudoktoreos.prediction.loadakkudoktor import ( @@ -81,6 +82,7 @@ elecprice_energy_charts = ElecPriceEnergyCharts() elecprice_fixed = ElecPriceFixed() elecprice_import = ElecPriceImport() elecprice_tibber = ElecPriceTibber() +feedintariff_energy_charts = FeedInTariffEnergyCharts() feedintariff_fixed = FeedInTariffFixed() feedintariff_import = FeedInTariffImport() loadforecast_akkudoktor = LoadAkkudoktor() @@ -106,6 +108,7 @@ def prediction_providers() -> list[ ElecPriceFixed, ElecPriceImport, ElecPriceTibber, + FeedInTariffEnergyCharts, FeedInTariffFixed, FeedInTariffImport, LoadAkkudoktor, @@ -134,6 +137,7 @@ def prediction_providers() -> list[ elecprice_fixed, \ elecprice_import, \ elecprice_tibber, \ + feedintariff_energy_charts, \ feedintariff_fixed, \ feedintariff_import, \ loadforecast_akkudoktor, \ @@ -158,6 +162,7 @@ def prediction_providers() -> list[ elecprice_fixed, elecprice_import, elecprice_tibber, + feedintariff_energy_charts, feedintariff_fixed, feedintariff_import, loadforecast_akkudoktor, @@ -187,6 +192,7 @@ class Prediction(PredictionContainer): ElecPriceFixed, ElecPriceImport, ElecPriceTibber, + FeedInTariffEnergyCharts, FeedInTariffFixed, FeedInTariffImport, LoadAkkudoktor, diff --git a/tests/test_feedintariffenergycharts.py b/tests/test_feedintariffenergycharts.py new file mode 100644 index 00000000..00fa7f25 --- /dev/null +++ b/tests/test_feedintariffenergycharts.py @@ -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) diff --git a/tests/test_prediction.py b/tests/test_prediction.py index c203a2de..3619daee 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -7,6 +7,7 @@ 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.feedintariffenergycharts import FeedInTariffEnergyCharts from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport from akkudoktoreos.prediction.loadakkudoktor import ( @@ -46,6 +47,7 @@ def forecast_providers(): ElecPriceFixed(), ElecPriceImport(), ElecPriceTibber(), + FeedInTariffEnergyCharts(), FeedInTariffFixed(), FeedInTariffImport(), LoadAkkudoktor(), @@ -99,22 +101,23 @@ def test_provider_sequence(prediction): assert isinstance(prediction.providers[2], ElecPriceFixed) assert isinstance(prediction.providers[3], ElecPriceImport) assert isinstance(prediction.providers[4], ElecPriceTibber) - assert isinstance(prediction.providers[5], FeedInTariffFixed) - assert isinstance(prediction.providers[6], FeedInTariffImport) - assert isinstance(prediction.providers[7], LoadAkkudoktor) - assert isinstance(prediction.providers[8], LoadAkkudoktorAdjusted) - assert isinstance(prediction.providers[9], LoadVrm) - assert isinstance(prediction.providers[10], LoadImport) - assert isinstance(prediction.providers[11], PVForecastAkkudoktor) - assert isinstance(prediction.providers[12], PVForecastVrm) - assert isinstance(prediction.providers[13], PVForecastPVNode) - assert isinstance(prediction.providers[14], PVForecastForecastSolar) - assert isinstance(prediction.providers[15], PVForecastSolcast) - assert isinstance(prediction.providers[16], PVForecastImport) - assert isinstance(prediction.providers[17], WeatherBrightSky) - assert isinstance(prediction.providers[18], WeatherClearOutside) - assert isinstance(prediction.providers[19], WeatherOpenMeteo) - assert isinstance(prediction.providers[20], WeatherImport) + 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) def test_provider_by_id(prediction, forecast_providers): @@ -132,6 +135,7 @@ def test_prediction_repr(prediction): assert "ElecPriceFixed" in result assert "ElecPriceImport" in result assert "ElecPriceTibber" in result + assert "FeedInTariffEnergyCharts" in result assert "FeedInTariffFixed" in result assert "FeedInTariffImport" in result assert "LoadAkkudoktor" in result diff --git a/tests/testdata/docs/_generated/configexample.md b/tests/testdata/docs/_generated/configexample.md index 904891db..521182f4 100644 --- a/tests/testdata/docs/_generated/configexample.md +++ b/tests/testdata/docs/_generated/configexample.md @@ -129,6 +129,9 @@ "feedintariffimport": { "import_file_path": null, "import_json": null + }, + "energycharts": { + "bidding_zone": "DE-LU" } }, "general": { diff --git a/tests/testdata/docs/_generated/configfeedintariff.md b/tests/testdata/docs/_generated/configfeedintariff.md index 217b41ac..6ced93db 100644 --- a/tests/testdata/docs/_generated/configfeedintariff.md +++ b/tests/testdata/docs/_generated/configfeedintariff.md @@ -7,6 +7,7 @@ | 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. | | 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. | @@ -29,6 +30,9 @@ "feedintariffimport": { "import_file_path": null, "import_json": null + }, + "energycharts": { + "bidding_zone": "DE-LU" } } } @@ -51,7 +55,11 @@ "import_file_path": null, "import_json": null }, + "energycharts": { + "bidding_zone": "DE-LU" + }, "providers": [ + "FeedInTariffEnergyCharts", "FeedInTariffFixed", "FeedInTariffImport" ] @@ -119,3 +127,32 @@ } ``` + +### Common settings for Energy-Charts feed-in tariff provider + + +:::{table} feedintariff::energycharts +:widths: 10 10 5 5 30 +:align: left + +| Name | Type | Read-Only | Default | Description | +| ---- | ---- | --------- | ------- | ----------- | +| bidding_zone | `` | `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' | +::: + + + +**Example Input/Output** + + + +```json + { + "feedintariff": { + "energycharts": { + "bidding_zone": "DE-LU" + } + } + } +``` + diff --git a/uv.lock b/uv.lock index aacd13dd..3d49162b 100644 --- a/uv.lock +++ b/uv.lock @@ -1010,11 +1010,11 @@ standard-no-fastapi-cloud-cli = [ [[package]] name = "fastcore" -version = "2.1.4" +version = "2.1.5" 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 = [ - { 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]]