fix: use stored history for Tibber price forecast

This commit is contained in:
Andreas
2026-07-09 11:20:46 +02:00
parent 15ba84b39c
commit cc583600d8
2 changed files with 103 additions and 19 deletions

View File

@@ -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,

View File

@@ -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