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)