mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-12 21:08:13 +00:00
fix: use stored history for Tibber price forecast
This commit is contained in:
@@ -16,6 +16,8 @@ from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
|||||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||||
|
|
||||||
TIBBER_GRAPHQL_URL = "https://api.tibber.com/v1-beta/gql"
|
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 = """
|
TIBBER_PRICE_QUERY = """
|
||||||
query TibberPriceInfo {
|
query TibberPriceInfo {
|
||||||
viewer {
|
viewer {
|
||||||
@@ -159,6 +161,23 @@ class ElecPriceTibber(ElecPriceProvider):
|
|||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
return tibber_data
|
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")
|
@cache_in_file(with_ttl="1 hour")
|
||||||
def _request_forecast(self) -> TibberGraphQLResponse:
|
def _request_forecast(self) -> TibberGraphQLResponse:
|
||||||
"""Fetch electricity price data from the Tibber GraphQL API."""
|
"""Fetch electricity price data from the Tibber GraphQL API."""
|
||||||
@@ -253,12 +272,44 @@ class ElecPriceTibber(ElecPriceProvider):
|
|||||||
clean_history = self._cap_outliers(history)
|
clean_history = self._cap_outliers(history)
|
||||||
return np.full(hours, np.median(clean_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:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
"""Update Tibber price data and extrapolate missing future prices."""
|
"""Update Tibber price data and extrapolate missing future prices."""
|
||||||
tibber_data = self._request_forecast(force_update=force_update) # type: ignore
|
tibber_data = self._request_forecast(force_update=force_update) # type: ignore
|
||||||
if not self.ems_start_datetime:
|
if not self.ems_start_datetime:
|
||||||
raise ValueError(f"Start DateTime not set: {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))
|
series_data = self._hourly_series(self._parse_data(tibber_data))
|
||||||
if series_data.empty:
|
if series_data.empty:
|
||||||
raise ValueError("Tibber response contains no usable hourly price points")
|
raise ValueError("Tibber response contains no usable hourly price points")
|
||||||
@@ -272,7 +323,6 @@ class ElecPriceTibber(ElecPriceProvider):
|
|||||||
fill_method="linear",
|
fill_method="linear",
|
||||||
)
|
)
|
||||||
|
|
||||||
amount_datasets = len(self.records)
|
|
||||||
if not highest_orig_datetime:
|
if not highest_orig_datetime:
|
||||||
error_msg = f"Highest original datetime not available: {highest_orig_datetime}"
|
error_msg = f"Highest original datetime not available: {highest_orig_datetime}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
@@ -293,15 +343,16 @@ class ElecPriceTibber(ElecPriceProvider):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if amount_datasets > 800:
|
logger.info(
|
||||||
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
|
"Tibber electricity price input: api_history_hours={}, api_today_hours={}, "
|
||||||
elif amount_datasets > 168:
|
"api_tomorrow_hours={}, combined_history_hours={}, needed_forecast_hours={}.",
|
||||||
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
|
api_history_count,
|
||||||
elif amount_datasets > 0:
|
api_today_count,
|
||||||
prediction = self._predict_median(history, hours=needed_hours)
|
api_tomorrow_count,
|
||||||
else:
|
len(history),
|
||||||
logger.error("No data available for prediction")
|
needed_hours,
|
||||||
raise ValueError("No data available")
|
)
|
||||||
|
prediction = self._predict_missing_prices(history, hours=needed_hours)
|
||||||
|
|
||||||
prediction_series = pd.Series(
|
prediction_series = pd.Series(
|
||||||
data=prediction,
|
data=prediction,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def _tibber_payload(
|
|||||||
*,
|
*,
|
||||||
home_id: str = "home-1",
|
home_id: str = "home-1",
|
||||||
include_other_home: bool = False,
|
include_other_home: bool = False,
|
||||||
|
include_history_range: bool = True,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
homes: list[dict[str, object]] = []
|
homes: list[dict[str, object]] = []
|
||||||
if include_other_home:
|
if include_other_home:
|
||||||
@@ -42,15 +43,11 @@ def _tibber_payload(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
homes.append(
|
subscription: dict[str, object] = {"priceInfo": {"today": prices[:2], "tomorrow": prices[2:]}}
|
||||||
{
|
if include_history_range:
|
||||||
"id": home_id,
|
subscription["priceInfoRange"] = {"nodes": prices}
|
||||||
"currentSubscription": {
|
|
||||||
"priceInfo": {"today": prices[:2], "tomorrow": prices[2:]},
|
homes.append({"id": home_id, "currentSubscription": subscription})
|
||||||
"priceInfoRange": {"nodes": prices},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {"data": {"viewer": {"homes": homes}}}
|
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])
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user