Files
EOS/src/akkudoktoreos/prediction/elecpricesmard.py
T

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,
)