diff --git a/CHANGELOG.md b/CHANGELOG.md index e912019c..ab4448f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). default 3600 s interval keeps the previous hourly behaviour. The new sub-hourly PV providers (pvnode, Forecast.Solar, Solcast) feed their native resolution straight into the quarter-hour grid. + - The Tibber electricity price provider now requests native 15-minute exchange prices + (`priceInfoRange(resolution: QUARTER_HOURLY)`) and stores them at their native + resolution, so both the hourly and the 15-minute optimizer are fed the correct + grid. The seasonal price extrapolation is resolution-agnostic and stays identical + at the default hourly resolution. ## 0.3.0 (2026-03-17) diff --git a/src/akkudoktoreos/prediction/elecpricetibber.py b/src/akkudoktoreos/prediction/elecpricetibber.py index aade5f5c..3489822b 100644 --- a/src/akkudoktoreos/prediction/elecpricetibber.py +++ b/src/akkudoktoreos/prediction/elecpricetibber.py @@ -34,7 +34,7 @@ query TibberPriceInfo { total } } - priceInfoRange(resolution: HOURLY, last: 840) { + priceInfoRange(resolution: QUARTER_HOURLY, last: 960) { nodes { startsAt total @@ -245,13 +245,36 @@ class ElecPriceTibber(ElecPriceProvider): return series_data.sort_index() - def _hourly_series(self, series: pd.Series) -> pd.Series: - """Normalize Tibber prices to hourly values for EOS optimization.""" + def _normalize_series(self, series: pd.Series) -> pd.Series: + """Normalize Tibber prices while preserving their native resolution. + + The Tibber API delivers either hourly or quarter-hourly prices. EOS resamples + the stored records onto the optimization grid on demand (``key_to_array``), so + the provider must keep the native step size (e.g. 15 minutes) instead of + pre-aggregating to hourly values. Duplicate timestamps are collapsed (mean) and + the series is sorted, but the resolution is left untouched. + """ if series.empty: return series series = series.sort_index() series.index = pd.to_datetime([to_datetime(index).isoformat() for index in series.index]) - return series.resample("1h").mean().dropna() + series = series.groupby(level=0).mean().sort_index() + return series.dropna() + + def _resolution_seconds(self, series: pd.Series) -> int: + """Infer the native slot size in seconds from the series timestamps. + + Uses the median of the timestamp differences so that a single outlier gap does + not distort the result. Falls back to hourly (3600 s) when fewer than two + timestamps are available. + """ + if len(series) < 2: + return 3600 + deltas = pd.DatetimeIndex(series.index).to_series().diff().dropna() + if deltas.empty: + return 3600 + resolution = int(round(deltas.dt.total_seconds().median())) + return resolution if resolution > 0 else 3600 def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray: mean = data.mean() @@ -272,33 +295,48 @@ class ElecPriceTibber(ElecPriceProvider): clean_history = self._cap_outliers(history) return np.full(hours, np.median(clean_history)) - def _predict_missing_prices(self, history: np.ndarray, hours: int) -> np.ndarray: - """Forecast missing future prices from the available hourly history.""" + def _predict_missing_prices( + self, history: np.ndarray, slots: int, slots_per_hour: int + ) -> np.ndarray: + """Forecast missing future prices from the available history. + + Works on the native resolution of the series: ``slots_per_hour`` scales the + hour-based seasonal windows into slot counts, so the seasonal periods and + history thresholds stay correct at both hourly (``slots_per_hour == 1``) and + quarter-hourly (``slots_per_hour == 4``) resolution. + """ numeric_history = np.asarray(history, dtype=float) numeric_history = numeric_history[np.isfinite(numeric_history)] - history_hours = len(numeric_history) + history_slots = len(numeric_history) - if history_hours > TIBBER_WEEKLY_SEASONAL_HOURS: + weekly_seasonal_slots = TIBBER_WEEKLY_SEASONAL_HOURS * slots_per_hour + daily_seasonal_slots = TIBBER_DAILY_SEASONAL_HOURS * slots_per_hour + + if history_slots > weekly_seasonal_slots: logger.info( "Using weekly seasonal ETS forecast for Tibber electricity prices " - "with {} historical hourly values.", - history_hours, + "with {} historical values.", + history_slots, ) - return self._predict_ets(numeric_history, seasonal_periods=168, hours=hours) - if history_hours > TIBBER_DAILY_SEASONAL_HOURS: + return self._predict_ets( + numeric_history, seasonal_periods=168 * slots_per_hour, hours=slots + ) + if history_slots > daily_seasonal_slots: logger.info( "Using daily seasonal ETS forecast for Tibber electricity prices " - "with {} historical hourly values.", - history_hours, + "with {} historical values.", + history_slots, ) - return self._predict_ets(numeric_history, seasonal_periods=24, hours=hours) - if history_hours > 0: + return self._predict_ets( + numeric_history, seasonal_periods=24 * slots_per_hour, hours=slots + ) + if history_slots > 0: logger.warning( "Using median fallback for Tibber electricity prices because only {} " - "historical hourly values are available.", - history_hours, + "historical values are available.", + history_slots, ) - return self._predict_median(numeric_history, hours=hours) + return self._predict_median(numeric_history, hours=slots) logger.error("No data available for prediction") raise ValueError("No data available") @@ -310,9 +348,12 @@ class ElecPriceTibber(ElecPriceProvider): raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}") api_history_count, api_today_count, api_tomorrow_count = self._api_price_counts(tibber_data) - series_data = self._hourly_series(self._parse_data(tibber_data)) + series_data = self._normalize_series(self._parse_data(tibber_data)) if series_data.empty: - raise ValueError("Tibber response contains no usable hourly price points") + raise ValueError("Tibber response contains no usable price points") + + resolution_seconds = self._resolution_seconds(series_data) + slots_per_hour = round(3600 / resolution_seconds) highest_orig_datetime = to_datetime(series_data.index.max()) self.key_from_series("elecprice_marketprice_wh", series_data) @@ -320,6 +361,7 @@ class ElecPriceTibber(ElecPriceProvider): history = self.key_to_array( key="elecprice_marketprice_wh", end_datetime=highest_orig_datetime, + interval=to_duration(f"{resolution_seconds} seconds"), fill_method="linear", ) @@ -328,36 +370,41 @@ class ElecPriceTibber(ElecPriceProvider): logger.error(error_msg) raise ValueError(error_msg) - needed_hours = int( - self.config.prediction.hours - - ((highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600) - ) + covered_slots = ( + highest_orig_datetime - self.ems_start_datetime + ).total_seconds() // resolution_seconds + needed_slots = int(self.config.prediction.hours * slots_per_hour - covered_slots) - if needed_hours <= 0: + if needed_slots <= 0: logger.warning( "No prediction needed. " - f"needed_hours={needed_hours}, " + f"needed_slots={needed_slots}, " f"hours={self.config.prediction.hours}, " + f"slots_per_hour={slots_per_hour}, " f"highest_orig_datetime={highest_orig_datetime}, " f"start_datetime={self.ems_start_datetime}" ) return logger.info( - "Tibber electricity price input: api_history_hours={}, api_today_hours={}, " - "api_tomorrow_hours={}, combined_history_hours={}, needed_forecast_hours={}.", + "Tibber electricity price input: api_history={}, api_today={}, " + "api_tomorrow={}, resolution_seconds={}, combined_history_slots={}, " + "needed_forecast_slots={}.", api_history_count, api_today_count, api_tomorrow_count, + resolution_seconds, len(history), - needed_hours, + needed_slots, + ) + prediction = self._predict_missing_prices( + history, slots=needed_slots, slots_per_hour=slots_per_hour ) - prediction = self._predict_missing_prices(history, hours=needed_hours) prediction_series = pd.Series( data=prediction, index=[ - highest_orig_datetime + to_duration(f"{i + 1} hours") + highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds") for i in range(len(prediction)) ], ) diff --git a/tests/test_elecpricetibber.py b/tests/test_elecpricetibber.py index 5fc23536..7297071d 100644 --- a/tests/test_elecpricetibber.py +++ b/tests/test_elecpricetibber.py @@ -175,14 +175,41 @@ def test_parse_data_combines_sorts_and_converts_total(provider, tibber_response) assert series.iloc[2] == pytest.approx(0.00030468) -def test_tibber_hourly_series_averages_quarter_hour_prices(provider): - """Quarter-hour Tibber prices are averaged to hourly EOS prices.""" +def test_tibber_normalize_series_preserves_quarter_hour_resolution(provider): + """Quarter-hour Tibber prices keep their native 15-min resolution (no averaging). + + EOS resamples onto the optimization grid on demand, so the provider must store the + native step size instead of pre-aggregating quarter-hour prices to hourly values. + """ index = pd.date_range("2026-07-09T00:00:00+00:00", periods=8, freq="15min") - series = pd.Series([0.10, 0.30, 0.50, 0.70, 1.0, 1.4, 1.8, 2.2], index=index) + values = [0.10, 0.30, 0.50, 0.70, 1.0, 1.4, 1.8, 2.2] + series = pd.Series(values, index=index) - hourly = provider._hourly_series(series) + normalized = provider._normalize_series(series) - assert hourly.tolist() == pytest.approx([0.40, 1.60]) + # Every 15-min point survives, values untouched, still on a 15-min grid. + assert normalized.tolist() == pytest.approx(values) + deltas = normalized.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() + assert deltas == [900.0] + assert provider._resolution_seconds(normalized) == 900 + + +def test_tibber_normalize_series_deduplicates_timestamps(provider): + """Duplicate timestamps are collapsed (mean) without changing the resolution.""" + index = pd.DatetimeIndex( + [ + "2026-07-09T00:00:00+00:00", + "2026-07-09T00:00:00+00:00", + "2026-07-09T01:00:00+00:00", + ] + ) + series = pd.Series([0.10, 0.30, 0.50], index=index) + + normalized = provider._normalize_series(series) + + assert len(normalized) == 2 + assert normalized.iloc[0] == pytest.approx(0.20) + assert normalized.iloc[1] == pytest.approx(0.50) def test_empty_tomorrow_stores_only_today_and_warns(provider): @@ -223,6 +250,7 @@ def test_request_forecast_uses_tibber_graphql_api( assert "query" in kwargs["json"] assert "TibberPriceInfo" in kwargs["json"]["query"] assert "priceInfoRange" in kwargs["json"]["query"] + assert "QUARTER_HOURLY" in kwargs["json"]["query"] assert "total" in kwargs["json"]["query"] assert kwargs["timeout"] == 30 @@ -299,3 +327,62 @@ def test_tibber_update_uses_eos_storage_history_when_api_history_is_missing( assert forecast_call["seasonal_periods"] == 168 assert forecast_call["history_hours"] > 840 + + +def test_tibber_update_preserves_quarter_hour_resolution_and_slots( + tibber_provider, monkeypatch +): + """15-minute Tibber prices are stored natively and extrapolated on the slot grid. + + Proves the resolution-agnostic path: (a) the native 15-min resolution survives + storage, (b) the ETS extrapolation scales the seasonal window into slots + (daily-only history -> 24*4 = 96 seasonal periods), and (c) the forecast index is + spaced at 15-minute steps. + """ + data = TibberGraphQLResponse.model_validate( + _tibber_payload( + [ + _price("2026-07-09T00:00:00+00:00", 0.30), + _price("2026-07-09T00:15:00+00:00", 0.42), + _price("2026-07-09T00:30:00+00:00", 0.36), + ], + include_history_range=False, + ) + ) + monkeypatch.setattr(tibber_provider, "_request_forecast", lambda **_: data) + forecast_call = {} + + def fake_predict_ets(history, seasonal_periods, hours): + forecast_call["seasonal_periods"] = seasonal_periods + forecast_call["history_slots"] = len(history) + forecast_call["forecast_slots"] = hours + return np.full(hours, 0.0009) + + monkeypatch.setattr(tibber_provider, "_predict_ets", fake_predict_ets) + + # A bit more than one week of quarter-hour history: enough for the daily seasonal + # window (> 24*7*4 = 672 slots) but below the weekly one (<= 24*35*4 = 3360 slots). + stored_history = pd.Series( + data=np.linspace(0.0002, 0.0004, 800), + index=pd.date_range("2026-07-01T00:00:00+00:00", periods=800, freq="15min"), + ) + tibber_provider.key_from_series("elecprice_marketprice_wh", stored_history) + + tibber_provider._update_data(force_update=True) + + # (b) Daily seasonal window scaled into 15-min slots. + assert forecast_call["seasonal_periods"] == 96 + assert 672 < forecast_call["history_slots"] <= 3360 + # prediction.hours (6) * slots_per_hour (4) - covered slots (2 -> 00:00..00:30) = 22 + assert forecast_call["forecast_slots"] == 22 + + # (a)+(c) Stored records keep the native 15-min grid across today and the forecast. + stored = tibber_provider.key_to_series( + "elecprice_marketprice_wh", + start_datetime=to_datetime("2026-07-09T00:00:00+00:00"), + end_datetime=to_datetime("2026-07-09T06:15:00+00:00"), + ) + steps = stored.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() + assert steps == [900.0] + # 00:00..06:00 inclusive at 15-min steps = 25 points (3 API + 22 forecast). + assert len(stored) == 25