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
This commit is contained in:
dr-dimitri
2026-09-05 15:50:43 +02:00
committed by GitHub
parent 940aa1021f
commit 9d816acbd9
2 changed files with 118 additions and 6 deletions
@@ -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:
+94
View File
@@ -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):