diff --git a/src/akkudoktoreos/prediction/elecpriceenergycharts.py b/src/akkudoktoreos/prediction/elecpriceenergycharts.py index 7075267a..c847cbd7 100644 --- a/src/akkudoktoreos/prediction/elecpriceenergycharts.py +++ b/src/akkudoktoreos/prediction/elecpriceenergycharts.py @@ -91,6 +91,10 @@ class ElecPriceEnergyCharts(ElecPriceProvider): 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 def provider_id(cls) -> str: """Return the unique identifier for the Energy-Charts provider.""" @@ -246,25 +250,41 @@ class ElecPriceEnergyCharts(ElecPriceProvider): if not 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 + needs_history_refresh = False if self.highest_orig_datetime: - history_series = self.key_to_series( - key="elecprice_marketprice_wh", start_datetime=self.ems_start_datetime + raw_history = self.key_to_series( + 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 - needs_update = end > self.highest_orig_datetime + needs_update = ( + bool(force_update) or end > self.highest_orig_datetime or needs_history_refresh + ) else: needs_update = True if needs_update: logger.info( - "Update {} is needed, last in history: {}", + "Update {} is needed, last in history: {}, " + "force_update={}, history_refresh={}", self.provider_id(), self.highest_orig_datetime, + bool(force_update), + needs_history_refresh, ) # Set start_date try to take data from 5 weeks back for prediction start_date = to_datetime( diff --git a/tests/test_elecpriceenergycharts.py b/tests/test_elecpriceenergycharts.py index 772d0bfa..c5997dcf 100644 --- a/tests/test_elecpriceenergycharts.py +++ b/tests/test_elecpriceenergycharts.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import Mock, patch import numpy as np +import pandas as pd import pytest import requests from loguru import logger @@ -63,6 +64,11 @@ def test_singleton_instance(provider): 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): """Test requesting an unsupported provider.""" monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "") @@ -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] +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): """Build the gross retail price from market price and the matching Module 3 fee.""" provider.config.elecprice.charges_kwh = None