2026-07-29 22:21:17 +02:00
|
|
|
"""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.
|
2026-10-11 08:39:01 +02:00
|
|
|
Only published slots are stored. Automatic optimization requires real
|
|
|
|
|
tariff coverage for the GENETIC or GENETIC0 control horizon.
|
2026-07-29 22:21:17 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
2026-10-11 08:39:01 +02:00
|
|
|
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")
|
|
|
|
|
await self.key_from_series("feed_in_tariff_wh", series_data)
|
|
|
|
|
self.highest_orig_datetime = series_data.index.max()
|