Fix seasonal SMARD retail price forecast

This commit is contained in:
Andreas
2026-08-01 13:05:37 +02:00
parent 69ef57d9c9
commit 57d2917c0c
2 changed files with 80 additions and 7 deletions
@@ -91,6 +91,10 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
highest_orig_datetime: Optional[datetime] = None highest_orig_datetime: Optional[datetime] = None
def historic_hours_min(self) -> int:
"""Keep enough market-price history for weekly seasonal extrapolation."""
return 24 * 35
@classmethod @classmethod
def provider_id(cls) -> str: def provider_id(cls) -> str:
"""Return the unique identifier for the Energy-Charts provider.""" """Return the unique identifier for the Energy-Charts provider."""
@@ -246,25 +250,41 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
if not self.ems_start_datetime: if not self.ems_start_datetime:
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}") raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
# Determine if update is needed and how many days # Determine if an update or history repair is needed and how many days to request.
past_days = 35 past_days = 35
needs_history_refresh = False
if self.highest_orig_datetime: if self.highest_orig_datetime:
history_series = self.key_to_series( raw_history = self.key_to_series(
key="elecprice_marketprice_wh", start_datetime=self.ems_start_datetime key="elecprice_marketprice_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
) )
# If history lower, then start_datetime
if history_series.index.min() <= self.ems_start_datetime: # Do not mistake the current forecast window for sufficient ETS history. This also
# repairs installations that retained only the previous 48-hour default 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 past_days = 0
needs_update = end > self.highest_orig_datetime needs_update = (
bool(force_update) or end > self.highest_orig_datetime or needs_history_refresh
)
else: else:
needs_update = True needs_update = True
if needs_update: if needs_update:
logger.info( logger.info(
"Update {} is needed, last in history: {}", "Update {} is needed, last in history: {}, "
"force_update={}, history_refresh={}",
self.provider_id(), self.provider_id(),
self.highest_orig_datetime, self.highest_orig_datetime,
bool(force_update),
needs_history_refresh,
) )
# Set start_date try to take data from 5 weeks back for prediction # Set start_date try to take data from 5 weeks back for prediction
start_date = to_datetime( start_date = to_datetime(
+53
View File
@@ -3,6 +3,7 @@ from pathlib import Path
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import numpy as np import numpy as np
import pandas as pd
import pytest import pytest
import requests import requests
from loguru import logger from loguru import logger
@@ -63,6 +64,11 @@ def test_singleton_instance(provider):
assert provider is another_instance assert provider is another_instance
def test_keeps_weekly_price_history(provider):
"""Retain enough native-resolution values for the weekly ETS forecast."""
assert provider.historic_hours_min() == 24 * 35
def test_invalid_provider(provider, monkeypatch): def test_invalid_provider(provider, monkeypatch):
"""Test requesting an unsupported provider.""" """Test requesting an unsupported provider."""
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "<invalid>") monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "<invalid>")
@@ -159,6 +165,53 @@ def test_update_data_keeps_quarter_hour_resolution(provider):
assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0] assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
def test_update_data_repairs_short_quarter_hour_history(provider):
"""A previously retained 48-hour series is replaced with the full ETS history."""
start = to_datetime("2026-08-01 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
provider.highest_orig_datetime = start.add(hours=24)
short_history = pd.Series(
0.0001,
index=pd.date_range(start=start.subtract(hours=48), periods=192, freq="15min"),
)
weekly_history = pd.Series(
0.0001,
index=pd.date_range(start=start.subtract(days=35), periods=3204, freq="15min"),
)
refreshed_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(provider.highest_orig_datetime.timestamp())],
price=[100.0],
unit="EUR/MWh",
deprecated=False,
)
predicted_slots = provider.config.prediction.hours * 4 - 97
with (
patch.object(
ElecPriceEnergyCharts,
"key_to_series",
side_effect=[short_history, weekly_history],
),
patch.object(
ElecPriceEnergyCharts, "key_to_array", return_value=weekly_history.to_numpy()
),
patch.object(ElecPriceEnergyCharts, "key_from_series"),
patch.object(
ElecPriceEnergyCharts, "_request_forecast", return_value=refreshed_data
) as request,
patch.object(
ElecPriceEnergyCharts,
"_predict_ets",
return_value=np.full(predicted_slots, 0.0001),
) as predict,
):
provider._update_data()
assert request.call_args.kwargs["start_date"] == "2026-06-27"
assert predict.call_args.kwargs["seasonal_periods"] == 168 * 4
def test_parse_data_adds_constant_charges_variable_network_fees_and_vat(provider): def test_parse_data_adds_constant_charges_variable_network_fees_and_vat(provider):
"""Build the gross retail price from market price and the matching Module 3 fee.""" """Build the gross retail price from market price and the matching Module 3 fee."""
provider.config.elecprice.charges_kwh = None provider.config.elecprice.charges_kwh = None