mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-09-10 18:06:37 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user