From 78f6dfeb84e4468d71534b1d5d64bd3221d2f61d Mon Sep 17 00:00:00 2001 From: Andreas Date: Wed, 9 Sep 2026 14:15:05 +0200 Subject: [PATCH] fix(elecprice): do not shorten the forecast by the source's own lag The price series went flat towards the end of the horizon: a constant value repeated for the last hours, exactly as long as the day-ahead source was behind. The ETS extrapolation is appended after the last known price, but its length was computed as `prediction.hours * slots_per_hour - covered_slots`, and covered_slots is zero once the last known price lies before the run start. The forecast therefore spanned prediction.hours measured from the last known price rather than from now, and ended that much too early. Callers reading past that point got the last record held constant. With SMARD published up to 2026-09-08 23:45 and a run at 2026-09-09 13:00, the forecast covered 09-09 00:00 to 09-12 00:00 while the horizon needed 09-12 13:00: 52 quarter-hour slots of flat price, right inside the trailing window the terminal value curve is derived from. The length is now measured from the last known value through to `ems_start + prediction.hours`, which reduces to the previous formula whenever the source is current. Both the electricity price and the feed-in tariff provider had the same calculation. --- CHANGELOG.md | 7 ++++ .../prediction/elecpriceenergycharts.py | 20 ++++++------ .../prediction/feedintariffenergycharts.py | 32 ++++++++----------- tests/test_elecpriceenergycharts.py | 29 +++++++++++++++++ 4 files changed, 60 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d899e426..cf13e8cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -241,6 +241,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `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. +- A day-ahead source that lags no longer shortens the price forecast by its own lag. The ETS + extrapolation is appended after the last known price, but its length was measured from the run + start, so a source that had not published the current day yet left exactly that lag uncovered at + the end of the horizon. Callers reading `elecprice_marketprice_wh` or `feed_in_tariff_wh` past + that point saw the last value held constant - a flat price in precisely the trailing window the + terminal value curve is derived from. The length is now measured from the last known value + through to `ems_start + prediction.hours`. - A weather-API outage no longer takes the whole prediction update with it. `PVForecastAkkudoktorLocal` raised on the first failed Open-Meteo request, and `PredictionContainer.update_data` re-raises whatever an enabled provider raises, so every diff --git a/src/akkudoktoreos/prediction/elecpriceenergycharts.py b/src/akkudoktoreos/prediction/elecpriceenergycharts.py index a5db55e7..ff0417b2 100644 --- a/src/akkudoktoreos/prediction/elecpriceenergycharts.py +++ b/src/akkudoktoreos/prediction/elecpriceenergycharts.py @@ -356,16 +356,16 @@ class ElecPriceEnergyCharts(ElecPriceProvider): ) # some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours - covered_slots = 0 - if self.highest_orig_datetime >= self.ems_start_datetime: - covered_slots = ( - int( - (self.highest_orig_datetime - self.ems_start_datetime).total_seconds() - // resolution_seconds - ) - + 1 - ) - needed_slots = self.config.prediction.hours * slots_per_hour - covered_slots + # The forecast is appended after the last known value, so its length has + # to be measured from there - not from now. When the source lags behind + # (a day-ahead auction that has not been published yet), measuring from + # now leaves exactly that lag uncovered at the end of the horizon, where + # callers then see the last value held constant. + horizon_end = self.ems_start_datetime + to_duration(f"{self.config.prediction.hours} hours") + needed_slots = ( + int((horizon_end - self.highest_orig_datetime).total_seconds() // resolution_seconds) + - 1 + ) if needed_slots <= 0: logger.warning( diff --git a/src/akkudoktoreos/prediction/feedintariffenergycharts.py b/src/akkudoktoreos/prediction/feedintariffenergycharts.py index 5c6d2e59..43a621e7 100644 --- a/src/akkudoktoreos/prediction/feedintariffenergycharts.py +++ b/src/akkudoktoreos/prediction/feedintariffenergycharts.py @@ -116,8 +116,7 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): energycharts = ElecPriceEnergyCharts() if len(history) > 800 * slots_per_hour: logger.info( - "Using weekly seasonal ETS forecast for {} " - "with {} historical values.", + "Using weekly seasonal ETS forecast for {} " "with {} historical values.", self.provider_id(), len(history), ) @@ -126,8 +125,7 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): ) if len(history) > 168 * slots_per_hour: logger.info( - "Using daily seasonal ETS forecast for {} " - "with {} historical values.", + "Using daily seasonal ETS forecast for {} " "with {} historical values.", self.provider_id(), len(history), ) @@ -136,8 +134,7 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): ) if len(history) > 0: logger.warning( - "Using constant median fallback for {} " - "with only {} historical values.", + "Using constant median fallback for {} " "with only {} historical values.", self.provider_id(), len(history), ) @@ -184,8 +181,7 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): if needs_update: logger.info( - "Update {} is needed, last in history: {}, " - "force_update={}, history_refresh={}", + "Update {} is needed, last in history: {}, " "force_update={}, history_refresh={}", self.provider_id(), self.highest_orig_datetime, bool(force_update), @@ -247,16 +243,16 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): fill_method="linear", ) - covered_slots = 0 - if self.highest_orig_datetime >= self.ems_start_datetime: - covered_slots = ( - int( - (self.highest_orig_datetime - self.ems_start_datetime).total_seconds() - // resolution_seconds - ) - + 1 - ) - needed_slots = self.config.prediction.hours * slots_per_hour - covered_slots + # The forecast is appended after the last known value, so its length has + # to be measured from there - not from now. When the source lags behind + # (a day-ahead auction that has not been published yet), measuring from + # now leaves exactly that lag uncovered at the end of the horizon, where + # callers then see the last value held constant. + horizon_end = self.ems_start_datetime + to_duration(f"{self.config.prediction.hours} hours") + needed_slots = ( + int((horizon_end - self.highest_orig_datetime).total_seconds() // resolution_seconds) + - 1 + ) if needed_slots <= 0: logger.warning( diff --git a/tests/test_elecpriceenergycharts.py b/tests/test_elecpriceenergycharts.py index a97e1de1..eb63f169 100644 --- a/tests/test_elecpriceenergycharts.py +++ b/tests/test_elecpriceenergycharts.py @@ -435,3 +435,32 @@ def test_energycharts_development_forecast_data(provider): "w", encoding="utf-8", newline="\n" ) as f_out: json.dump(energy_charts_data, f_out, indent=4) + + +@patch("requests.get") +def test_forecast_covers_the_horizon_when_the_source_lags( + mock_get, provider, sample_energycharts_json, cache_store +): + """A lagging source must not shorten the forecast by its own lag. + + The extrapolation is appended after the last known price, so measuring its + length from now leaves exactly the lag uncovered at the end of the horizon - + where callers then see the last value held constant. + """ + 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) + + # The sample ends at 2024-12-11 23:00; start the run more than a day later. + start = to_datetime("2024-12-12 13:00:00", in_timezone="Europe/Berlin") + get_ems().set_start_datetime(start) + provider.highest_orig_datetime = None + provider.update_data(force_enable=True, force_update=True) + + assert provider.highest_orig_datetime < start + + horizon_end = start.add(hours=provider.config.prediction.hours) + series = provider.key_to_series(key="elecprice_marketprice_wh") + assert series.index.max() >= horizon_end.subtract(hours=1)