fix(elecprice): survive a day-ahead source that has not published yet

Every morning before the day-ahead auction is published, /v1/prediction/update
answered 400 and no prediction was produced at all.

The provider asks for prices starting at the run day, because an existing
history sets past_days to 0. The optimization horizon always reaches past the
last published price, so an update is always considered necessary - and SMARD
publishes the next day around midday. Between midnight and publication the
requested window therefore contains nothing, and ElecPriceSMARD raised
"SMARD response contains no usable day-ahead prices", which failed the whole
prediction update rather than only that provider.

ElecPriceEnergyCharts and its SMARD subclass now keep their existing history and
let the ETS/median branch extrapolate the remaining slots, the same fallback
FeedInTariffEnergyCharts already had. A cold start without any history stays
fatal. ElecPriceSMARD also separates the two cases it used to conflate: a period
the source has not published yet now reports the latest value it does have, and
only a response without a single price still reads as unusable.

Fixes the cache noise this produced as well. cache_in_file claimed its cache
entry before calling the wrapped function, so a raising function left an empty
file behind and every later call within the TTL logged "Read failed: Ran out of
input" before refetching. The entry is now created only after the call returns.
This commit is contained in:
Andreas
2026-09-09 12:57:39 +02:00
parent 280ee761e6
commit 983a23f0af
7 changed files with 183 additions and 20 deletions
+13
View File
@@ -231,6 +231,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
historical data exists, the existing history is kept and the remaining slots are historical data exists, the existing history is kept and the remaining slots are
extrapolated via ETS instead of failing. A genuine cold start (no data at all) still extrapolated via ETS instead of failing. A genuine cold start (no data at all) still
fails. fails.
- A day-ahead price source that has not published the next day yet no longer fails the whole
prediction update. `ElecPriceEnergyCharts` and its `ElecPriceSMARD` subclass ask for prices
starting at the run day, while the optimization horizon always reaches past the last published
price, so every morning before the auction is published the request came back empty and the
provider raised - answering `/v1/prediction/update` with 400 until the source caught up. The
provider now keeps its existing history and extrapolates the remaining slots via ETS, the same
way `FeedInTariffEnergyCharts` already did. A cold start with no history at all still fails.
- `ElecPriceSMARD` now distinguishes a lagging publication from a broken response. A window the
source cannot serve yet reports the latest value it does have, instead of claiming the response
contained no usable prices.
- `cache_in_file` no longer leaves an empty cache entry behind when the wrapped function raises.
The entry was claimed before the call, so every later call within the TTL first failed to read
it ("Ran out of input") before refetching. The entry is now created only after the call returns.
- The deprecated `/gesamtlast` endpoint no longer forces a full provider refresh on every - The deprecated `/gesamtlast` endpoint no longer forces a full provider refresh on every
call. Forcing bypassed the provider caches and hammered external APIs, so a single flaky call. Forcing bypassed the provider caches and hammered external APIs, so a single flaky
provider could 404 the whole load prediction. It now defaults to a cache-aware update and provider could 404 the whole load prediction. It now defaults to a cache-aware update and
+5 -1
View File
@@ -1016,6 +1016,11 @@ def cache_in_file(
force_update = True force_update = True
if force_update or cache_file is None: if force_update or cache_file is None:
# Otherwise, call the function and save its result to the cache # Otherwise, call the function and save its result to the cache
# Run first and only then claim a cache entry. Creating the entry
# up front left an empty file behind whenever the function raised,
# and every later call within the TTL then failed to read it
# ("Ran out of input") before refetching anyway.
result = func(*args, **kwargs)
logger.debug("Created cache file for function: " + func.__name__) logger.debug("Created cache file for function: " + func.__name__)
cache_file = CacheFileStore().create( cache_file = CacheFileStore().create(
key, key,
@@ -1026,7 +1031,6 @@ def cache_in_file(
until_date=until_date, until_date=until_date,
with_ttl=with_ttl, with_ttl=with_ttl,
) )
result = func(*args, **kwargs)
try: try:
# Assure we have an empty file # Assure we have an empty file
cache_file.truncate(0) cache_file.truncate(0)
@@ -166,9 +166,7 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
highest_orig_datetime = orig_datetime highest_orig_datetime = orig_datetime
# Convert EUR/MWh to EUR/Wh and add the configured retail price components. # Convert EUR/MWh to EUR/Wh and add the configured retail price components.
price_wh = self._price_with_charges( price_wh = self._price_with_charges(price_eur_per_mwh / 1_000_000, orig_datetime)
price_eur_per_mwh / 1_000_000, orig_datetime
)
# Store in series # Store in series
series_data.at[orig_datetime] = price_wh series_data.at[orig_datetime] = price_wh
@@ -279,8 +277,7 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
if needs_update: if needs_update:
logger.info( logger.info(
"Update {} is needed, last in history: {}, " "Update {} is needed, last in history: {}, " "force_update={}, history_refresh={}",
"force_update={}, history_refresh={}",
self.provider_id(), self.provider_id(),
self.highest_orig_datetime, self.highest_orig_datetime,
bool(force_update), bool(force_update),
@@ -290,15 +287,36 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
start_date = to_datetime( start_date = to_datetime(
self.ems_start_datetime - to_duration(f"{past_days} days"), as_string="YYYY-MM-DD" self.ems_start_datetime - to_duration(f"{past_days} days"), as_string="YYYY-MM-DD"
) )
# Get Energy-Charts electricity price data try:
energy_charts_data = self._request_forecast( # Get Energy-Charts electricity price data
start_date=start_date, force_update=force_update energy_charts_data = self._request_forecast(
) # type: ignore start_date=start_date, force_update=force_update
) # type: ignore
# Parse and store data # Parse and store data
series_data = self._parse_data(energy_charts_data) series_data = self._parse_data(energy_charts_data)
self.highest_orig_datetime = series_data.index.max() if series_data.empty:
self.key_from_series("elecprice_marketprice_wh", series_data) raise ValueError("No electricity price data available")
self.highest_orig_datetime = series_data.index.max()
self.key_from_series("elecprice_marketprice_wh", series_data)
except Exception as exc:
if self.highest_orig_datetime is None:
# Cold start: there is no history to fall back to, so a failed
# fetch is fatal.
raise
# The horizon reaches past the last published price on every run,
# so an upstream that has not published the next day yet is the
# normal case, not an outage - and neither is a transient API
# failure a reason to fail the whole prediction update. Keep the
# history and let the ETS/median branch below extrapolate the
# remaining slots.
logger.warning(
"{} update failed ({}); keeping existing history until {} and "
"extrapolating the remaining slots.",
self.provider_id(),
exc,
self.highest_orig_datetime,
)
else: else:
logger.info( logger.info(
"No update {} is needed, last in history: {}", "No update {} is needed, last in history: {}",
+17 -3
View File
@@ -154,6 +154,9 @@ class ElecPriceSMARD(ElecPriceEnergyCharts):
values_by_timestamp: dict[int, float] = {} values_by_timestamp: dict[int, float] = {}
latest_created = 0 latest_created = 0
latest_published_ms: Optional[int] = None
start_ms = int(start_datetime.timestamp() * 1000)
end_ms = int(end_datetime.timestamp() * 1000)
for chunk_timestamp in chunk_timestamps: for chunk_timestamp in chunk_timestamps:
chunk_url = ( chunk_url = (
f"{SMARD_BASE_URL}/{filter_id}/{region}/" f"{SMARD_BASE_URL}/{filter_id}/{region}/"
@@ -164,12 +167,23 @@ class ElecPriceSMARD(ElecPriceEnergyCharts):
for timestamp_ms, price_eur_mwh in chunk.series: for timestamp_ms, price_eur_mwh in chunk.series:
if price_eur_mwh is None: if price_eur_mwh is None:
continue continue
if int(start_datetime.timestamp() * 1000) <= timestamp_ms <= int( if latest_published_ms is None or timestamp_ms > latest_published_ms:
end_datetime.timestamp() * 1000 latest_published_ms = timestamp_ms
): if start_ms <= timestamp_ms <= end_ms:
values_by_timestamp[timestamp_ms] = price_eur_mwh values_by_timestamp[timestamp_ms] = price_eur_mwh
if not values_by_timestamp: if not values_by_timestamp:
# SMARD answered correctly; it simply has not published the requested
# period yet. Say so, because the caller keeps its history and
# extrapolates in that case instead of treating it as a broken API.
if latest_published_ms is not None:
latest_published = to_datetime(
latest_published_ms / 1000, in_timezone=self.config.general.timezone
)
raise ValueError(
f"SMARD has not published day-ahead prices for the requested period yet "
f"(from {start_datetime}); latest published value is {latest_published}"
)
raise ValueError("SMARD response contains no usable day-ahead prices") raise ValueError("SMARD response contains no usable day-ahead prices")
ordered_values = sorted(values_by_timestamp.items()) ordered_values = sorted(values_by_timestamp.items())
+31
View File
@@ -560,6 +560,37 @@ class TestCacheFileDecorators:
cache_file.seek(0) # Move to the start of the file cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == "Some expensive computation result" assert cache_file.read() == "Some expensive computation result"
def test_cache_in_file_decorator_discards_the_entry_when_the_call_raises(
self, cache_file_store
):
"""A failing call must not leave an empty cache file behind.
The entry is created before the wrapped function runs, so a raising
function used to leave an empty file that every later call within the TTL
failed to unpickle ("Ran out of input") before falling back to a refetch.
"""
cache_file_store.clear(clear_all=True)
assert len(cache_file_store._store) == 0
calls = []
@cache_in_file(mode="w+")
def failing_function(until_date=None):
calls.append(1)
raise ValueError("upstream has nothing to offer yet")
until = datetime.now() + timedelta(days=1)
with pytest.raises(ValueError, match="upstream has nothing"):
failing_function(until_date=until)
assert len(cache_file_store._store) == 0
# The next call runs the function again and reports the same failure
# rather than a confusing unpickling error from an empty file.
with pytest.raises(ValueError, match="upstream has nothing"):
failing_function(until_date=until)
assert len(calls) == 2
def test_cache_in_file_decorator_uses_cache(self, cache_file_store): def test_cache_in_file_decorator_uses_cache(self, cache_file_store):
"""Test that the cache_in_file decorator reuses cached file on subsequent calls.""" """Test that the cache_in_file decorator reuses cached file on subsequent calls."""
# Clear store to assure it is empty # Clear store to assure it is empty
+43 -3
View File
@@ -267,9 +267,8 @@ def test_market_price_charge_round_trip(provider):
) )
@patch("requests.get") def _mock_empty_forecast(mock_get) -> None:
def test_update_data_with_incomplete_forecast(mock_get, provider): """Let the API answer correctly but without any price rows."""
"""Test `_update_data` with incomplete or missing forecast data."""
incomplete_data: dict = { incomplete_data: dict = {
"license_info": "", "license_info": "",
"unix_seconds": [], "unix_seconds": [],
@@ -281,11 +280,52 @@ def test_update_data_with_incomplete_forecast(mock_get, provider):
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.content = json.dumps(incomplete_data) mock_response.content = json.dumps(incomplete_data)
mock_get.return_value = mock_response mock_get.return_value = mock_response
@patch("requests.get")
def test_update_data_with_incomplete_forecast_is_fatal_on_cold_start(mock_get, provider):
"""Without any history there is nothing to fall back to."""
_mock_empty_forecast(mock_get)
provider.highest_orig_datetime = None
logger.info("The following errors are intentional and part of the test.") logger.info("The following errors are intentional and part of the test.")
with pytest.raises(ValueError): with pytest.raises(ValueError):
provider._update_data(force_update=True) provider._update_data(force_update=True)
@patch("requests.get")
def test_update_data_with_incomplete_forecast_keeps_existing_history(
mock_get, provider, sample_energycharts_json, cache_store
):
"""An upstream without new prices must not fail the whole prediction update.
The horizon always reaches past the last published price, so a day-ahead
source that has not published the next day yet is the normal case. The
provider keeps its history and extrapolates the remaining slots.
"""
# Establish a history first.
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_energycharts_json)
mock_get.return_value = mock_response
cache_store.clear(clear_all=True)
get_ems().set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin"))
provider.highest_orig_datetime = None
provider.update_data(force_enable=True, force_update=True)
before = provider.highest_orig_datetime
assert before is not None
records_before = len(provider)
# The next refresh finds nothing new upstream.
_mock_empty_forecast(mock_get)
cache_store.clear(clear_all=True)
logger.info("The following errors are intentional and part of the test.")
provider._update_data(force_update=True)
assert provider.highest_orig_datetime == before
assert len(provider) == records_before
@pytest.mark.parametrize( @pytest.mark.parametrize(
"status_code, exception", "status_code, exception",
[(400, requests.exceptions.HTTPError), (500, requests.exceptions.HTTPError), (200, None)], [(400, requests.exceptions.HTTPError), (500, requests.exceptions.HTTPError), (200, None)],
+43
View File
@@ -75,3 +75,46 @@ def test_chunk_selection_includes_preceding_overlapping_chunk(provider):
def test_smard_provider_is_enabled(provider): def test_smard_provider_is_enabled(provider):
assert provider.enabled() assert provider.enabled()
@patch("akkudoktoreos.prediction.elecpricesmard.requests.get")
def test_unpublished_period_names_the_latest_published_value(mock_get, provider):
"""A day-ahead source that lags is not a broken response.
SMARD publishes the next day around midday. Until then a run whose horizon
already reaches into that day asks for a window SMARD cannot serve yet. The
error has to say that, because the caller keeps its history and extrapolates
in that case rather than treating the API as broken.
"""
chunk_start = 1785103200000
# The chunk holds prices, but all of them end before the requested window.
mock_get.side_effect = [
_response({"timestamps": [chunk_start]}),
_response(
{
"meta_data": {"version": 1, "created": 1785500527370},
"series": [[chunk_start, 86.04], [chunk_start + 900000, 84.5]],
}
),
]
with pytest.raises(ValueError, match="has not published day-ahead prices"):
provider._request_forecast(start_date="2026-07-28", force_update=True)
@patch("akkudoktoreos.prediction.elecpricesmard.requests.get")
def test_empty_series_still_reports_an_unusable_response(mock_get, provider):
"""A chunk without a single price is a different problem and says so."""
chunk_start = 1785103200000
mock_get.side_effect = [
_response({"timestamps": [chunk_start]}),
_response(
{
"meta_data": {"version": 1, "created": 1785500527370},
"series": [[chunk_start, None]],
}
),
]
with pytest.raises(ValueError, match="no usable day-ahead prices"):
provider._request_forecast(start_date="2026-07-27", force_update=True)