mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 12:46:38 +00:00
feat: add dvhubonline feed-in tariff provider (#1193)
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
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
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
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Fetches GET /api/prices?start&end&zone (15-min slots, EUR/MWh) and stores the raw market price as feed_in_tariff_wh (EUR/Wh) — no import charges/VAT. Slots beyond the day-ahead horizon are left to the consumer's forward-fill (FeedInTariffImport behaviour). 5 unit tests + opt-in live smoke (EOS_DVHUB_ONLINE_LIVE=1, passing). Signed-off-by: Christin <info@bikinibottom.capital> Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
@@ -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.feedintariffdvhubonline import (
|
||||
FeedInTariffDvhubOnlineCommonSettings,
|
||||
)
|
||||
from akkudoktoreos.prediction.feedintariffenergycharts import (
|
||||
FeedInTariffEnergyChartsCommonSettings,
|
||||
)
|
||||
@@ -20,6 +23,7 @@ def feedintariff_provider_ids() -> list[str]:
|
||||
# Prediction may not be initialized. Return static built-in provider ids.
|
||||
return [
|
||||
"FeedInTariffAkkudoktor",
|
||||
"FeedInTariffDvhubOnline",
|
||||
"FeedInTariffEnergyCharts",
|
||||
"FeedInTariffFixed",
|
||||
"FeedInTariffImport",
|
||||
@@ -54,6 +58,11 @@ class FeedInTariffCommonSettings(SettingsBaseModel):
|
||||
json_schema_extra={"description": "Feed in tarif import provider settings."},
|
||||
)
|
||||
|
||||
dvhubonline: FeedInTariffDvhubOnlineCommonSettings = Field(
|
||||
default_factory=FeedInTariffDvhubOnlineCommonSettings,
|
||||
json_schema_extra={"description": "DvhubOnline feed in tariff provider settings."},
|
||||
)
|
||||
|
||||
energycharts: FeedInTariffEnergyChartsCommonSettings = Field(
|
||||
default_factory=FeedInTariffEnergyChartsCommonSettings,
|
||||
json_schema_extra={"description": "EnergyCharts feed in tariff provider settings."},
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Provides feed-in tariff data from the dvhub.online price API."""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
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.feedintariffabc import FeedInTariffProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
|
||||
class FeedInTariffDvhubOnlineCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for the dvhub.online feed-in tariff provider."""
|
||||
|
||||
base_url: str = Field(
|
||||
default="https://dvhub.online",
|
||||
json_schema_extra={
|
||||
"description": "Base URL of the dvhub.online price API.",
|
||||
"examples": ["https://dvhub.online"],
|
||||
},
|
||||
)
|
||||
zone: str = Field(
|
||||
default="DE-LU",
|
||||
json_schema_extra={
|
||||
"description": "Bidding zone passed to the dvhub.online price API.",
|
||||
"examples": ["DE-LU"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FeedInTariffDvhubOnline(FeedInTariffProvider):
|
||||
"""Fetch dvhub.online day-ahead market prices as feed-in tariff data.
|
||||
|
||||
dvhub.online serves EPEX day-ahead prices (15-minute slots, EUR/MWh) via
|
||||
``GET /api/prices?start=YYYY-MM-DD&end=YYYY-MM-DD&zone=DE-LU``. The raw
|
||||
market price is stored as ``feed_in_tariff_wh`` (EUR/Wh) — like
|
||||
``FeedInTariffEnergyCharts`` this intentionally adds no import charges or
|
||||
VAT, so the series is the direct-marketing revenue the optimizer needs.
|
||||
Slots beyond the published day-ahead horizon are left to the consumer's
|
||||
forward-fill (same behaviour as ``FeedInTariffImport``).
|
||||
"""
|
||||
|
||||
highest_orig_datetime: Optional[datetime] = None
|
||||
|
||||
def historic_hours_min(self) -> int:
|
||||
"""Day-ahead source without seasonal extrapolation — keep two days."""
|
||||
return 48
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the dvhub.online feed-in tariff provider."""
|
||||
return "FeedInTariffDvhubOnline"
|
||||
|
||||
def _provider_settings(self) -> FeedInTariffDvhubOnlineCommonSettings:
|
||||
settings = self.config.feedintariff.dvhubonline
|
||||
if settings is None:
|
||||
return FeedInTariffDvhubOnlineCommonSettings()
|
||||
return settings
|
||||
|
||||
@cache_in_file(with_ttl="1 hour")
|
||||
def _request_forecast(self, start_date: str, end_date: str) -> dict[str, Any]:
|
||||
"""Fetch market prices from the dvhub.online price API."""
|
||||
settings = self._provider_settings()
|
||||
url = (
|
||||
f"{settings.base_url}/api/prices?start={start_date}&end={end_date}&zone={settings.zone}"
|
||||
)
|
||||
# Retry transient network problems with a short backoff (same pattern
|
||||
# as FeedInTariffEnergyCharts): (connect, read) timeout tuple.
|
||||
max_attempts = 3
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
response = requests.get(
|
||||
url, headers={"accept": "application/json"}, timeout=(5, 60)
|
||||
)
|
||||
logger.debug(f"Response from {url}: {response}")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not isinstance(data, dict) or not isinstance(data.get("data"), list):
|
||||
raise ValueError(f"Unexpected dvhub.online price API response shape from {url}")
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return data
|
||||
except (
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.ConnectionError,
|
||||
) as exc:
|
||||
last_exc = exc
|
||||
logger.warning(
|
||||
"dvhub.online price 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, dvhub_data: dict[str, Any]) -> pd.Series:
|
||||
"""dvhub.online entries {ts, price[EUR/MWh]} -> Series of EUR/Wh."""
|
||||
series_data = pd.Series(dtype=float)
|
||||
for entry in dvhub_data.get("data", []):
|
||||
try:
|
||||
orig_datetime = to_datetime(entry["ts"], in_timezone=self.config.general.timezone)
|
||||
price_eur_per_mwh = float(entry["price"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
logger.warning("Skipping malformed dvhub.online price entry {}: {}", entry, exc)
|
||||
continue
|
||||
series_data.at[orig_datetime] = price_eur_per_mwh / 1_000_000
|
||||
return series_data
|
||||
|
||||
async def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
"""Update feed-in tariff forecast data from dvhub.online."""
|
||||
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("2 days"), as_string="YYYY-MM-DD"
|
||||
)
|
||||
end_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||
|
||||
try:
|
||||
dvhub_data = self._request_forecast(
|
||||
start_date=start_date, end_date=end_date, force_update=force_update
|
||||
) # type: ignore[call-arg]
|
||||
series_data = self._parse_data(dvhub_data)
|
||||
if series_data.empty:
|
||||
raise ValueError("No dvhub.online 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: nothing to fall back to — a failed fetch is fatal.
|
||||
raise
|
||||
# Transient outage with existing history: keep the history and let
|
||||
# downstream forward-fill cover the remaining slots.
|
||||
logger.warning(
|
||||
"dvhub.online feed-in tariff update failed ({}); keeping existing "
|
||||
"history until {}.",
|
||||
exc,
|
||||
self.highest_orig_datetime,
|
||||
)
|
||||
@@ -37,6 +37,7 @@ 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.feedintariffdvhubonline import FeedInTariffDvhubOnline
|
||||
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
|
||||
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
|
||||
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
||||
@@ -85,6 +86,7 @@ elecprice_fixed = ElecPriceFixed()
|
||||
elecprice_import = ElecPriceImport()
|
||||
elecprice_tibber = ElecPriceTibber()
|
||||
feedintariff_akkudoktor = FeedInTariffAkkudoktor()
|
||||
feedintariff_dvhubonline = FeedInTariffDvhubOnline()
|
||||
feedintariff_energy_charts = FeedInTariffEnergyCharts()
|
||||
feedintariff_fixed = FeedInTariffFixed()
|
||||
feedintariff_import = FeedInTariffImport()
|
||||
@@ -113,6 +115,7 @@ def prediction_providers() -> list[
|
||||
ElecPriceImport,
|
||||
ElecPriceTibber,
|
||||
FeedInTariffAkkudoktor,
|
||||
FeedInTariffDvhubOnline,
|
||||
FeedInTariffEnergyCharts,
|
||||
FeedInTariffFixed,
|
||||
FeedInTariffImport,
|
||||
@@ -144,6 +147,7 @@ def prediction_providers() -> list[
|
||||
elecprice_import, \
|
||||
elecprice_tibber, \
|
||||
feedintariff_akkudoktor, \
|
||||
feedintariff_dvhubonline, \
|
||||
feedintariff_energy_charts, \
|
||||
feedintariff_fixed, \
|
||||
feedintariff_import, \
|
||||
@@ -171,6 +175,7 @@ def prediction_providers() -> list[
|
||||
elecprice_import,
|
||||
elecprice_tibber,
|
||||
feedintariff_akkudoktor,
|
||||
feedintariff_dvhubonline,
|
||||
feedintariff_energy_charts,
|
||||
feedintariff_fixed,
|
||||
feedintariff_import,
|
||||
@@ -203,6 +208,7 @@ class Prediction(PredictionContainer):
|
||||
ElecPriceImport,
|
||||
ElecPriceTibber,
|
||||
FeedInTariffAkkudoktor,
|
||||
FeedInTariffDvhubOnline,
|
||||
FeedInTariffEnergyCharts,
|
||||
FeedInTariffFixed,
|
||||
FeedInTariffImport,
|
||||
|
||||
Reference in New Issue
Block a user