mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-16 02:51:56 +00:00
186 lines
7.2 KiB
Python
186 lines
7.2 KiB
Python
"""Retrieve German day-ahead electricity prices directly from SMARD."""
|
|
|
|
import time
|
|
from datetime import datetime
|
|
from typing import List, Optional
|
|
|
|
import requests
|
|
from loguru import logger
|
|
from pydantic import Field, ValidationError
|
|
|
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
|
from akkudoktoreos.core.cache import cache_in_file
|
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
|
from akkudoktoreos.prediction.elecpriceenergycharts import (
|
|
ElecPriceEnergyCharts,
|
|
EnergyChartsElecPrice,
|
|
)
|
|
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
|
|
|
SMARD_BASE_URL = "https://www.smard.de/app/chart_data"
|
|
|
|
|
|
class SmardIndex(PydanticBaseModel):
|
|
"""Available SMARD data-chunk timestamps."""
|
|
|
|
timestamps: List[int]
|
|
|
|
|
|
class SmardChunkMetadata(PydanticBaseModel):
|
|
"""Metadata included in a SMARD data chunk."""
|
|
|
|
version: int
|
|
created: int
|
|
|
|
|
|
class SmardChunk(PydanticBaseModel):
|
|
"""SMARD data chunk with millisecond timestamps and EUR/MWh values."""
|
|
|
|
meta_data: SmardChunkMetadata
|
|
series: List[tuple[int, Optional[float]]]
|
|
|
|
|
|
class ElecPriceSMARDCommonSettings(SettingsBaseModel):
|
|
"""Common settings for the direct SMARD electricity-price provider."""
|
|
|
|
filter_id: int = Field(
|
|
default=4169,
|
|
gt=0,
|
|
json_schema_extra={
|
|
"description": "SMARD filter id for the German/Luxembourg day-ahead price.",
|
|
"examples": [4169],
|
|
},
|
|
)
|
|
region: str = Field(
|
|
default="DE",
|
|
min_length=2,
|
|
json_schema_extra={
|
|
"description": "SMARD market region used in the chart-data endpoint.",
|
|
"examples": ["DE"],
|
|
},
|
|
)
|
|
|
|
|
|
class ElecPriceSMARD(ElecPriceEnergyCharts):
|
|
"""Fetch SMARD day-ahead prices and extend them with the seasonal EOS forecast.
|
|
|
|
The provider uses the public SMARD chart-data endpoint directly. It reuses the
|
|
Energy-Charts parsing and ETS pipeline after normalizing the response because both
|
|
sources expose the same EUR/MWh day-ahead market-price concept.
|
|
"""
|
|
|
|
@classmethod
|
|
def provider_id(cls) -> str:
|
|
"""Return the unique identifier for the direct SMARD provider."""
|
|
return "ElecPriceSMARD"
|
|
|
|
@classmethod
|
|
def _validate_index(cls, json_data: bytes) -> SmardIndex:
|
|
"""Validate a SMARD chunk index response."""
|
|
try:
|
|
return SmardIndex.model_validate_json(json_data)
|
|
except ValidationError as exc:
|
|
logger.error("SMARD index schema change: {}", exc)
|
|
raise ValueError(f"SMARD index schema change: {exc}") from exc
|
|
|
|
@classmethod
|
|
def _validate_chunk(cls, json_data: bytes) -> SmardChunk:
|
|
"""Validate a SMARD price chunk response."""
|
|
try:
|
|
return SmardChunk.model_validate_json(json_data)
|
|
except ValidationError as exc:
|
|
logger.error("SMARD price schema change: {}", exc)
|
|
raise ValueError(f"SMARD price schema change: {exc}") from exc
|
|
|
|
@staticmethod
|
|
def _get(url: str) -> bytes:
|
|
"""Request a SMARD JSON resource with bounded retries."""
|
|
last_exc: Optional[Exception] = None
|
|
for attempt in range(1, 4):
|
|
try:
|
|
response = requests.get(
|
|
url,
|
|
headers={"User-Agent": "Akkudoktor-EOS/SMARD price provider"},
|
|
timeout=(5, 30),
|
|
)
|
|
response.raise_for_status()
|
|
return response.content
|
|
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
|
|
last_exc = exc
|
|
logger.warning("SMARD request attempt {}/3 failed for {}: {}", attempt, url, exc)
|
|
if attempt < 3:
|
|
time.sleep(2 * attempt)
|
|
if last_exc is not None:
|
|
raise last_exc
|
|
raise RuntimeError(f"SMARD request failed without an exception: {url}")
|
|
|
|
def _chunk_timestamps(
|
|
self, index: SmardIndex, start_datetime: datetime, end_datetime: datetime
|
|
) -> list[int]:
|
|
"""Select all weekly chunks overlapping the requested datetime range."""
|
|
start_ms = int(to_datetime(start_datetime).timestamp() * 1000)
|
|
end_ms = int(to_datetime(end_datetime).timestamp() * 1000)
|
|
timestamps = sorted(set(index.timestamps))
|
|
selected: list[int] = []
|
|
for position, chunk_start in enumerate(timestamps):
|
|
next_start = timestamps[position + 1] if position + 1 < len(timestamps) else None
|
|
overlaps_start = next_start is None or next_start > start_ms
|
|
if chunk_start <= end_ms and overlaps_start:
|
|
selected.append(chunk_start)
|
|
return selected
|
|
|
|
@cache_in_file(with_ttl="1 hour")
|
|
def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice:
|
|
"""Fetch and normalize quarter-hourly German/Luxembourg day-ahead prices from SMARD."""
|
|
if not self.ems_start_datetime:
|
|
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
|
|
if start_date is None:
|
|
start_datetime = self.ems_start_datetime - to_duration("35 days")
|
|
else:
|
|
start_datetime = to_datetime(
|
|
start_date, in_timezone=self.config.general.timezone
|
|
).start_of("day")
|
|
end_datetime = to_datetime(self.end_datetime).end_of("day")
|
|
|
|
settings = self.config.elecprice.smard
|
|
filter_id = settings.filter_id
|
|
region = settings.region
|
|
resolution = "quarterhour"
|
|
index_url = f"{SMARD_BASE_URL}/{filter_id}/{region}/index_{resolution}.json"
|
|
index = self._validate_index(self._get(index_url))
|
|
chunk_timestamps = self._chunk_timestamps(index, start_datetime, end_datetime)
|
|
if not chunk_timestamps:
|
|
raise ValueError("SMARD index contains no price chunks for the requested period")
|
|
|
|
values_by_timestamp: dict[int, float] = {}
|
|
latest_created = 0
|
|
for chunk_timestamp in chunk_timestamps:
|
|
chunk_url = (
|
|
f"{SMARD_BASE_URL}/{filter_id}/{region}/"
|
|
f"{filter_id}_{region}_{resolution}_{chunk_timestamp}.json"
|
|
)
|
|
chunk = self._validate_chunk(self._get(chunk_url))
|
|
latest_created = max(latest_created, chunk.meta_data.created)
|
|
for timestamp_ms, price_eur_mwh in chunk.series:
|
|
if price_eur_mwh is None:
|
|
continue
|
|
if int(start_datetime.timestamp() * 1000) <= timestamp_ms <= int(
|
|
end_datetime.timestamp() * 1000
|
|
):
|
|
values_by_timestamp[timestamp_ms] = price_eur_mwh
|
|
|
|
if not values_by_timestamp:
|
|
raise ValueError("SMARD response contains no usable day-ahead prices")
|
|
|
|
ordered_values = sorted(values_by_timestamp.items())
|
|
self.update_datetime = to_datetime(
|
|
latest_created / 1000, in_timezone=self.config.general.timezone
|
|
)
|
|
return EnergyChartsElecPrice(
|
|
license_info="CC BY 4.0 Bundesnetzagentur | SMARD.de",
|
|
unix_seconds=[timestamp_ms // 1000 for timestamp_ms, _ in ordered_values],
|
|
price=[price for _, price in ordered_values],
|
|
unit="EUR/MWh",
|
|
deprecated=False,
|
|
)
|