From 9d816acbd98f119e929937f9940a7b13b200d132 Mon Sep 17 00:00:00 2001 From: dr-dimitri <87113560+dr-dimitri@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:50:43 +0200 Subject: [PATCH] fix: honor Energy-Charts electricity price interval coverage (#1278) * fix: honor Energy-Charts electricity price interval coverage * test: make Energy-Charts coverage checks timezone-independent --- .../prediction/elecpriceenergycharts.py | 30 ++++-- tests/test_elecpriceenergycharts.py | 94 +++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/src/akkudoktoreos/prediction/elecpriceenergycharts.py b/src/akkudoktoreos/prediction/elecpriceenergycharts.py index b41fb070..12fd1c0d 100644 --- a/src/akkudoktoreos/prediction/elecpriceenergycharts.py +++ b/src/akkudoktoreos/prediction/elecpriceenergycharts.py @@ -98,6 +98,25 @@ class ElecPriceEnergyCharts(ElecPriceProvider): """Return the unique identifier for the Energy-Charts provider.""" return "ElecPriceEnergyCharts" + def _has_complete_published_horizon( + self, *, now: pd.Timestamp, resolution_seconds: int + ) -> bool: + """Return whether stored source data covers all currently published intervals. + + Energy-Charts timestamps identify interval starts. The actual coverage therefore ends one + source interval after ``highest_orig_datetime``. Before 14:00, prices through the end of + the current day are expected; from 14:00 onward, the following day is expected as well. + """ + if self.highest_orig_datetime is None: + return False + + published_days = 1 if now.hour < 14 else 2 + required_coverage_end = now.normalize() + pd.DateOffset(days=published_days) + coverage_end = pd.Timestamp(self.highest_orig_datetime) + pd.Timedelta( + seconds=resolution_seconds + ) + return coverage_end >= required_coverage_end + @classmethod def _validate_data(cls, json_str: Union[bytes, Any]) -> EnergyChartsElecPrice: """Validate Energy-Charts Electricity Price forecast data.""" @@ -211,11 +230,8 @@ class ElecPriceEnergyCharts(ElecPriceProvider): The final mapped and processed data is inserted into the sequence as `ElecPriceDataRecord`. """ - # New prices are available every day at 14:00 + # Tomorrow's prices are available every day at 14:00. now = pd.Timestamp.now(tz=self.config.general.timezone) - midnight = now.normalize() - hours_ahead = 23 if now.time() < pd.Timestamp("14:00").time() else 47 - end = midnight + pd.Timedelta(hours=hours_ahead) if not self.ems_start_datetime: raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}") @@ -254,8 +270,10 @@ class ElecPriceEnergyCharts(ElecPriceProvider): elif force_update: # Use default start date in case of forced update needs_update = True - elif end > self.highest_orig_datetime: - # We got enough history, but still not enough data to prediction end + elif not self._has_complete_published_horizon( + now=now, resolution_seconds=resolution_seconds + ): + # We have enough history, but not every expected source interval. start_datetime = gross_start_datetime needs_update = True else: diff --git a/tests/test_elecpriceenergycharts.py b/tests/test_elecpriceenergycharts.py index f537f728..8c73103c 100644 --- a/tests/test_elecpriceenergycharts.py +++ b/tests/test_elecpriceenergycharts.py @@ -1,5 +1,6 @@ import asyncio import json +from collections.abc import Callable from pathlib import Path from unittest.mock import Mock, patch @@ -156,6 +157,99 @@ class TestElecPriceEnergyCharts: ) assert len(np_price_array) == provider.total_hours + @pytest.mark.asyncio + @pytest.mark.parametrize("host_timezone", ["UTC", "Europe/Berlin"]) + @pytest.mark.parametrize( + ("now", "last_price", "interval_minutes", "needs_update"), + [ + ("2026-01-15 13:59:59", "2026-01-15 23:00", 15, True), + ("2026-01-15 13:59:59", "2026-01-15 23:45", 15, False), + ("2026-01-15 13:59:59", "2026-01-15 23:00", 60, False), + ("2026-01-15 14:00:00", "2026-01-16 23:00", 15, True), + ("2026-01-15 14:00:00", "2026-01-16 23:45", 15, False), + ("2026-01-15 14:00:00", "2026-01-16 23:00", 60, False), + ("2026-01-15 14:00:00", "2026-01-15 23:45", 15, True), + ("2026-03-28 14:00:00", "2026-03-29 23:45", 15, False), + ("2026-03-28 14:00:00", "2026-03-29 23:30", 15, True), + ("2026-10-24 14:00:00", "2026-10-25 23:45", 15, False), + ("2026-10-24 14:00:00", "2026-10-25 23:30", 15, True), + ], + ) + async def test_update_data_refreshes_incomplete_published_intervals( + self, + provider: ElecPriceEnergyCharts, + set_other_timezone: Callable[[str], str], + host_timezone: str, + now: str, + last_price: str, + interval_minutes: int, + needs_update: bool, + ) -> None: + """Fetch missing source intervals without refreshing an already complete day.""" + set_other_timezone(host_timezone) + provider.config.merge_settings_from_dict( + {"general": {"latitude": 52.52, "longitude": 13.405}} + ) + fixed_now = pd.Timestamp(now, tz="Europe/Berlin") + start = to_datetime(fixed_now, in_timezone="Europe/Berlin").start_of("day") + last_original = to_datetime( + pd.Timestamp(last_price, tz="Europe/Berlin"), in_timezone="Europe/Berlin" + ) + get_ems().set_start_datetime(start) + raw_index = pd.date_range( + start=start.subtract(days=35), + end=last_original, + freq=f"{interval_minutes}min", + ) + await provider.key_from_series( + "elecprice_marketprice_raw_wh", pd.Series(0.0001, index=raw_index) + ) + provider.highest_orig_datetime = last_original + + published_end = start.add(days=1 if fixed_now.hour < 14 else 2) + response_index = pd.date_range( + start=start, end=published_end, freq=f"{interval_minutes}min", inclusive="left" + ) + response = EnergyChartsElecPrice( + license_info="", + unix_seconds=[int(timestamp.timestamp()) for timestamp in response_index], + price=[200.0] * len(response_index), + unit="EUR/MWh", + deprecated=False, + ) + + def predict(history: np.ndarray, hours: int, slots_per_hour: int = 1) -> np.ndarray: + return np.full(hours, 0.00005) + + with ( + patch("akkudoktoreos.prediction.elecpriceenergycharts.pd", wraps=pd) as pandas, + patch.object(provider, "_request_forecast", return_value=response) as request, + patch.object(provider, "_predict", side_effect=predict), + ): + pandas.Timestamp.now.return_value = fixed_now + await provider._update_data(force_update=False) + + if needs_update: + # Request dates use the host timezone; the publication boundary uses Berlin. + request.assert_called_once_with( + start_date=start.in_timezone(host_timezone).format("YYYY-MM-DD"), + force_update=False, + ) + assert pd.Timestamp(provider.highest_orig_datetime) == response_index[-1] + fetched = await provider.key_to_raw_series( + key="elecprice_marketprice_raw_wh", + start_datetime=last_original, + end_datetime=published_end, + ) + expected_index = response_index[response_index >= pd.Timestamp(last_original)].tz_convert( + "UTC" + ) + assert fetched.index.equals(expected_index) + np.testing.assert_allclose(fetched.to_numpy(), 0.0002) + else: + request.assert_not_called() + assert provider.highest_orig_datetime == last_original + @pytest.mark.asyncio @patch("requests.get") async def test_update_data_with_incomplete_forecast(self, mock_get, caplog, provider):