From 940aa1021fdcd10f9fc3a36295a08ca8b21c4155 Mon Sep 17 00:00:00 2001 From: dr-dimitri <87113560+dr-dimitri@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:05:25 +0200 Subject: [PATCH] fix: honor Energy-Charts feed-in interval coverage (#1275) --- .../prediction/feedintariffenergycharts.py | 30 +++++++++++++++---- tests/test_feedintariffenergycharts.py | 28 +++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/akkudoktoreos/prediction/feedintariffenergycharts.py b/src/akkudoktoreos/prediction/feedintariffenergycharts.py index ef723e2c..6526c738 100644 --- a/src/akkudoktoreos/prediction/feedintariffenergycharts.py +++ b/src/akkudoktoreos/prediction/feedintariffenergycharts.py @@ -55,6 +55,25 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): """Return the unique identifier for the Energy-Charts feed-in tariff provider.""" return "FeedInTariffEnergyCharts" + 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 + def _bidding_zone(self) -> str: settings = self.config.feedintariff.energycharts if settings is None: @@ -150,11 +169,8 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): The final mapped and processed data is inserted into the sequence as `FeedInTariffDataRecord`. """ - # 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}") @@ -193,8 +209,10 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): 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_feedintariffenergycharts.py b/tests/test_feedintariffenergycharts.py index e45da091..dbeb4888 100644 --- a/tests/test_feedintariffenergycharts.py +++ b/tests/test_feedintariffenergycharts.py @@ -79,6 +79,34 @@ class TestFeedInTariffEnergyCharts: assert series.iloc[0] == pytest.approx(sample_energycharts_json["price"][0] / 1_000_000) + @pytest.mark.parametrize( + ("now", "highest_orig_datetime", "resolution_seconds", "expected"), + [ + ("2026-07-23T10:00:00+02:00", "2026-07-23T23:00:00+02:00", 15 * 60, False), + ("2026-07-23T10:00:00+02:00", "2026-07-23T23:45:00+02:00", 15 * 60, True), + ("2026-07-23T10:00:00+02:00", "2026-07-23T23:00:00+02:00", 60 * 60, True), + ("2026-07-23T15:00:00+02:00", "2026-07-24T23:00:00+02:00", 15 * 60, False), + ("2026-07-23T15:00:00+02:00", "2026-07-24T23:45:00+02:00", 15 * 60, True), + ( + pd.Timestamp("2026-03-28 15:00:00", tz="Europe/Berlin"), + "2026-03-29T23:45:00+02:00", + 15 * 60, + True, + ), + ], + ) + def test_published_horizon_requires_complete_last_interval( + self, provider, now, highest_orig_datetime, resolution_seconds, expected + ): + provider.highest_orig_datetime = to_datetime(highest_orig_datetime) + + assert ( + provider._has_complete_published_horizon( + now=pd.Timestamp(now), resolution_seconds=resolution_seconds + ) + is expected + ) + @patch("requests.get") def test_request_forecast_uses_feedintariff_bidding_zone(