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
+31
View File
@@ -560,6 +560,37 @@ class TestCacheFileDecorators:
cache_file.seek(0) # Move to the start of the file
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):
"""Test that the cache_in_file decorator reuses cached file on subsequent calls."""
# 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 test_update_data_with_incomplete_forecast(mock_get, provider):
"""Test `_update_data` with incomplete or missing forecast data."""
def _mock_empty_forecast(mock_get) -> None:
"""Let the API answer correctly but without any price rows."""
incomplete_data: dict = {
"license_info": "",
"unix_seconds": [],
@@ -281,11 +280,52 @@ def test_update_data_with_incomplete_forecast(mock_get, provider):
mock_response.status_code = 200
mock_response.content = json.dumps(incomplete_data)
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.")
with pytest.raises(ValueError):
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(
"status_code, exception",
[(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):
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)