diff --git a/src/akkudoktoreos/prediction/elecpricetibber.py b/src/akkudoktoreos/prediction/elecpricetibber.py index b2b810b..aade5f5 100644 --- a/src/akkudoktoreos/prediction/elecpricetibber.py +++ b/src/akkudoktoreos/prediction/elecpricetibber.py @@ -16,6 +16,8 @@ from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration TIBBER_GRAPHQL_URL = "https://api.tibber.com/v1-beta/gql" +TIBBER_DAILY_SEASONAL_HOURS = 24 * 7 +TIBBER_WEEKLY_SEASONAL_HOURS = 24 * 35 TIBBER_PRICE_QUERY = """ query TibberPriceInfo { viewer { @@ -159,6 +161,23 @@ class ElecPriceTibber(ElecPriceProvider): raise ValueError(error_msg) return tibber_data + def _api_price_counts(self, response: TibberGraphQLResponse) -> tuple[int, int, int]: + """Return Tibber API price counts for history, today, and tomorrow.""" + home = self._select_home(response) + subscription = home.currentSubscription + if subscription is None: + raise ValueError("Tibber home has no current subscription") + + history_count = 0 + today_count = 0 + tomorrow_count = 0 + if subscription.priceInfoRange is not None: + history_count = len(subscription.priceInfoRange.nodes) + if subscription.priceInfo is not None: + today_count = len(subscription.priceInfo.today) + tomorrow_count = len(subscription.priceInfo.tomorrow) + return history_count, today_count, tomorrow_count + @cache_in_file(with_ttl="1 hour") def _request_forecast(self) -> TibberGraphQLResponse: """Fetch electricity price data from the Tibber GraphQL API.""" @@ -253,12 +272,44 @@ 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.""" + numeric_history = np.asarray(history, dtype=float) + numeric_history = numeric_history[np.isfinite(numeric_history)] + history_hours = len(numeric_history) + + if history_hours > TIBBER_WEEKLY_SEASONAL_HOURS: + logger.info( + "Using weekly seasonal ETS forecast for Tibber electricity prices " + "with {} historical hourly values.", + history_hours, + ) + return self._predict_ets(numeric_history, seasonal_periods=168, hours=hours) + if history_hours > TIBBER_DAILY_SEASONAL_HOURS: + logger.info( + "Using daily seasonal ETS forecast for Tibber electricity prices " + "with {} historical hourly values.", + history_hours, + ) + return self._predict_ets(numeric_history, seasonal_periods=24, hours=hours) + if history_hours > 0: + logger.warning( + "Using median fallback for Tibber electricity prices because only {} " + "historical hourly values are available.", + history_hours, + ) + return self._predict_median(numeric_history, hours=hours) + + logger.error("No data available for prediction") + raise ValueError("No data available") + def _update_data(self, force_update: Optional[bool] = False) -> None: """Update Tibber price data and extrapolate missing future prices.""" tibber_data = self._request_forecast(force_update=force_update) # type: ignore if not self.ems_start_datetime: 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)) if series_data.empty: raise ValueError("Tibber response contains no usable hourly price points") @@ -272,7 +323,6 @@ class ElecPriceTibber(ElecPriceProvider): fill_method="linear", ) - amount_datasets = len(self.records) if not highest_orig_datetime: error_msg = f"Highest original datetime not available: {highest_orig_datetime}" logger.error(error_msg) @@ -293,15 +343,16 @@ class ElecPriceTibber(ElecPriceProvider): ) return - if amount_datasets > 800: - prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours) - elif amount_datasets > 168: - prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours) - elif amount_datasets > 0: - prediction = self._predict_median(history, hours=needed_hours) - else: - logger.error("No data available for prediction") - raise ValueError("No data available") + logger.info( + "Tibber electricity price input: api_history_hours={}, api_today_hours={}, " + "api_tomorrow_hours={}, combined_history_hours={}, needed_forecast_hours={}.", + api_history_count, + api_today_count, + api_tomorrow_count, + len(history), + needed_hours, + ) + prediction = self._predict_missing_prices(history, hours=needed_hours) prediction_series = pd.Series( data=prediction, diff --git a/tests/test_elecpricetibber.py b/tests/test_elecpricetibber.py index 556d985..5fc2353 100644 --- a/tests/test_elecpricetibber.py +++ b/tests/test_elecpricetibber.py @@ -30,6 +30,7 @@ def _tibber_payload( *, home_id: str = "home-1", include_other_home: bool = False, + include_history_range: bool = True, ) -> dict[str, object]: homes: list[dict[str, object]] = [] if include_other_home: @@ -42,15 +43,11 @@ def _tibber_payload( } ) - homes.append( - { - "id": home_id, - "currentSubscription": { - "priceInfo": {"today": prices[:2], "tomorrow": prices[2:]}, - "priceInfoRange": {"nodes": prices}, - }, - } - ) + subscription: dict[str, object] = {"priceInfo": {"today": prices[:2], "tomorrow": prices[2:]}} + if include_history_range: + subscription["priceInfoRange"] = {"nodes": prices} + + homes.append({"id": home_id, "currentSubscription": subscription}) return {"data": {"viewer": {"homes": homes}}} @@ -266,3 +263,39 @@ def test_tibber_update_extrapolates_missing_hours_with_seasonal_history( ) assert prices.tolist() == pytest.approx([0.0003, 0.00042, 0.00036, 0.0005, 0.0005, 0.0005]) + + +def test_tibber_update_uses_eos_storage_history_when_api_history_is_missing( + tibber_provider, monkeypatch +): + """Stored EOS price history can provide enough data for weekly seasonal ETS.""" + data = TibberGraphQLResponse.model_validate( + _tibber_payload( + [ + _price("2026-07-09T00:00:00+00:00", 0.30), + _price("2026-07-09T01:00:00+00:00", 0.42), + _price("2026-07-09T02:00: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_hours"] = len(history) + return np.full(hours, 0.0007) + + monkeypatch.setattr(tibber_provider, "_predict_ets", fake_predict_ets) + + stored_history = pd.Series( + data=np.linspace(0.0002, 0.0004, 900), + index=pd.date_range("2026-06-01T00:00:00+00:00", periods=900, freq="1h"), + ) + tibber_provider.key_from_series("elecprice_marketprice_wh", stored_history) + + tibber_provider._update_data(force_update=True) + + assert forecast_call["seasonal_periods"] == 168 + assert forecast_call["history_hours"] > 840