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 the unique identifier for the Energy-Charts provider."""
|
||||||
return "ElecPriceEnergyCharts"
|
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
|
@classmethod
|
||||||
def _validate_data(cls, json_str: Union[bytes, Any]) -> EnergyChartsElecPrice:
|
def _validate_data(cls, json_str: Union[bytes, Any]) -> EnergyChartsElecPrice:
|
||||||
"""Validate Energy-Charts Electricity Price forecast data."""
|
"""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`.
|
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)
|
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:
|
if not self.ems_start_datetime:
|
||||||
raise ValueError(f"Start DateTime not set: {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:
|
elif force_update:
|
||||||
# Use default start date in case of forced update
|
# Use default start date in case of forced update
|
||||||
needs_update = True
|
needs_update = True
|
||||||
elif end > self.highest_orig_datetime:
|
elif not self._has_complete_published_horizon(
|
||||||
# We got enough history, but still not enough data to prediction end
|
now=now, resolution_seconds=resolution_seconds
|
||||||
|
):
|
||||||
|
# We have enough history, but not every expected source interval.
|
||||||
start_datetime = gross_start_datetime
|
start_datetime = gross_start_datetime
|
||||||
needs_update = True
|
needs_update = True
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
@@ -156,6 +157,99 @@ class TestElecPriceEnergyCharts:
|
|||||||
)
|
)
|
||||||
assert len(np_price_array) == provider.total_hours
|
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
|
@pytest.mark.asyncio
|
||||||
@patch("requests.get")
|
@patch("requests.get")
|
||||||
async def test_update_data_with_incomplete_forecast(self, mock_get, caplog, provider):
|
async def test_update_data_with_incomplete_forecast(self, mock_get, caplog, provider):
|
||||||
|
|||||||
Reference in New Issue
Block a user