mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
Once Tibber publishes prices across a DST change, the series holds timestamps with two UTC offsets and pd.to_datetime() raises "Mixed timezones detected", so every optimization run is canceled. Parse via UTC and convert back to the configured timezone. Fixes #1446
442 lines
16 KiB
Python
442 lines
16 KiB
Python
"""Retrieves and processes electricity price forecast data from Tibber."""
|
|
|
|
from typing import Any, List, Optional, Union
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import requests
|
|
from loguru import logger
|
|
from pydantic import Field, ValidationError
|
|
|
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
|
from akkudoktoreos.core.cache import cache_in_file
|
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
|
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 {
|
|
homes {
|
|
id
|
|
currentSubscription {
|
|
priceInfo {
|
|
today {
|
|
startsAt
|
|
total
|
|
energy
|
|
}
|
|
tomorrow {
|
|
startsAt
|
|
total
|
|
energy
|
|
}
|
|
}
|
|
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
|
|
nodes {
|
|
startsAt
|
|
total
|
|
energy
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
# Same query, but requesting priceInfo (and therefore its today/tomorrow
|
|
# fields) at quarter-hourly resolution. Tibber defines ``resolution`` on
|
|
# Subscription.priceInfo, not on the nested PriceInfo.today/tomorrow fields.
|
|
# Tried first; on a GraphQL schema error from an older API the provider falls
|
|
# back to TIBBER_PRICE_QUERY.
|
|
TIBBER_PRICE_QUERY_QUARTER_HOURLY = """
|
|
query TibberPriceInfo {
|
|
viewer {
|
|
homes {
|
|
id
|
|
currentSubscription {
|
|
priceInfo(resolution: QUARTER_HOURLY) {
|
|
today {
|
|
startsAt
|
|
total
|
|
energy
|
|
}
|
|
tomorrow {
|
|
startsAt
|
|
total
|
|
energy
|
|
}
|
|
}
|
|
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
|
|
nodes {
|
|
startsAt
|
|
total
|
|
energy
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
class ElecPriceTibberCommonSettings(SettingsBaseModel):
|
|
"""Common settings for the Tibber electricity price provider."""
|
|
|
|
access_token: Optional[str] = Field(
|
|
default=None,
|
|
json_schema_extra={
|
|
"description": "Tibber API access token.",
|
|
"examples": ["tibber_pat_..."],
|
|
},
|
|
)
|
|
|
|
home_id: Optional[str] = Field(
|
|
default=None,
|
|
json_schema_extra={
|
|
"description": (
|
|
"Optional Tibber home id. If omitted, the first home with a subscription is used."
|
|
),
|
|
"examples": ["00000000-0000-0000-0000-000000000000"],
|
|
},
|
|
)
|
|
|
|
|
|
class TibberPricePoint(PydanticBaseModel):
|
|
"""Single Tibber price point."""
|
|
|
|
startsAt: str
|
|
total: float
|
|
energy: Optional[float] = None
|
|
|
|
|
|
class TibberPriceConnection(PydanticBaseModel):
|
|
"""Tibber connection for historical price nodes."""
|
|
|
|
nodes: List[TibberPricePoint] = Field(default_factory=list)
|
|
|
|
|
|
class TibberPriceInfo(PydanticBaseModel):
|
|
"""Tibber price info for today and tomorrow."""
|
|
|
|
today: List[TibberPricePoint] = Field(default_factory=list)
|
|
tomorrow: List[TibberPricePoint] = Field(default_factory=list)
|
|
|
|
|
|
class TibberSubscription(PydanticBaseModel):
|
|
"""Tibber subscription data."""
|
|
|
|
priceInfo: Optional[TibberPriceInfo] = None
|
|
priceInfoRange: Optional[TibberPriceConnection] = None
|
|
|
|
|
|
class TibberHome(PydanticBaseModel):
|
|
"""Tibber home data."""
|
|
|
|
id: str
|
|
currentSubscription: Optional[TibberSubscription] = None
|
|
|
|
|
|
class TibberViewer(PydanticBaseModel):
|
|
"""Tibber viewer data."""
|
|
|
|
homes: List[TibberHome] = Field(default_factory=list)
|
|
|
|
|
|
class TibberData(PydanticBaseModel):
|
|
"""Tibber GraphQL data payload."""
|
|
|
|
viewer: TibberViewer
|
|
|
|
|
|
class TibberGraphQLError(PydanticBaseModel):
|
|
"""Tibber GraphQL error item."""
|
|
|
|
message: str
|
|
|
|
|
|
class TibberGraphQLResponse(PydanticBaseModel):
|
|
"""Tibber GraphQL response payload."""
|
|
|
|
data: Optional[TibberData] = None
|
|
errors: Optional[List[TibberGraphQLError]] = None
|
|
|
|
|
|
class ElecPriceTibber(ElecPriceProvider):
|
|
"""Fetch and process electricity price forecast data from Tibber."""
|
|
|
|
@classmethod
|
|
def provider_id(cls) -> str:
|
|
"""Return the unique identifier for the Tibber provider."""
|
|
return "ElecPriceTibber"
|
|
|
|
def historic_hours_min(self) -> int:
|
|
"""Keep enough history for weekly seasonal price extrapolation."""
|
|
return 24 * 35
|
|
|
|
@classmethod
|
|
def _validate_data(cls, json_str: Union[bytes, Any]) -> TibberGraphQLResponse:
|
|
"""Validate Tibber GraphQL response data."""
|
|
try:
|
|
tibber_data = TibberGraphQLResponse.model_validate_json(json_str)
|
|
except ValidationError as e:
|
|
error_msg = ""
|
|
for error in e.errors():
|
|
field = " -> ".join(str(x) for x in error["loc"])
|
|
message = error["msg"]
|
|
error_type = error["type"]
|
|
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
|
|
logger.error(f"Tibber schema change: {error_msg}")
|
|
raise ValueError(error_msg)
|
|
if tibber_data.errors:
|
|
error_msg = "; ".join(error.message for error in tibber_data.errors)
|
|
error_msg = f"Tibber GraphQL error: {error_msg}"
|
|
logger.error(error_msg)
|
|
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."""
|
|
access_token = self.config.elecprice.tibber.access_token
|
|
if not access_token:
|
|
raise ValueError("Tibber access_token is required")
|
|
|
|
# Prefer quarter-hourly today/tomorrow prices; fall back to the hourly
|
|
# query when the Tibber API rejects the resolution argument. Tibber
|
|
# signals schema errors either as HTTP 400 or as HTTP 200 with an
|
|
# "errors" array, so both must route to the fallback (raise_for_status
|
|
# must NOT run before the fallback check).
|
|
response = None
|
|
queries = (TIBBER_PRICE_QUERY_QUARTER_HOURLY, TIBBER_PRICE_QUERY)
|
|
for attempt, query in enumerate(queries, start=1):
|
|
response = requests.post(
|
|
TIBBER_GRAPHQL_URL,
|
|
json={"query": query},
|
|
headers={
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
timeout=30,
|
|
)
|
|
logger.debug(f"Response from Tibber GraphQL API: {response}")
|
|
if response.ok and b'"errors"' not in response.content:
|
|
break
|
|
if attempt < len(queries):
|
|
logger.info(
|
|
"Tibber rejected the quarter-hourly priceInfo query "
|
|
"(HTTP {}): {} - falling back to hourly today/tomorrow prices.",
|
|
response.status_code,
|
|
response.text[:300],
|
|
)
|
|
else:
|
|
# Final (hourly) attempt failed for real - surface the error.
|
|
response.raise_for_status()
|
|
if response is None: # pragma: no cover - the query tuple is never empty
|
|
raise RuntimeError("No Tibber GraphQL query was attempted")
|
|
tibber_data = self._validate_data(response.content)
|
|
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
|
return tibber_data
|
|
|
|
def _select_home(self, response: TibberGraphQLResponse) -> TibberHome:
|
|
"""Select the configured Tibber home from a GraphQL response."""
|
|
if response.data is None:
|
|
raise ValueError("Tibber response does not contain data")
|
|
|
|
home_id = self.config.elecprice.tibber.home_id
|
|
if home_id:
|
|
for home in response.data.viewer.homes:
|
|
if home.id == home_id:
|
|
if home.currentSubscription is None:
|
|
raise ValueError(f"Tibber home '{home_id}' has no current subscription")
|
|
return home
|
|
raise ValueError("Tibber home_id not found")
|
|
|
|
for home in response.data.viewer.homes:
|
|
if home.currentSubscription is not None:
|
|
return home
|
|
raise ValueError("No Tibber home with a current subscription found")
|
|
|
|
def _parse_data(self, response: TibberGraphQLResponse) -> pd.Series:
|
|
"""Parse Tibber prices into EOS market prices in EUR/Wh."""
|
|
home = self._select_home(response)
|
|
subscription = home.currentSubscription
|
|
if subscription is None:
|
|
raise ValueError("Tibber home has no current subscription")
|
|
|
|
points: list[TibberPricePoint] = []
|
|
if subscription.priceInfoRange is not None:
|
|
points.extend(subscription.priceInfoRange.nodes)
|
|
if subscription.priceInfo is not None:
|
|
points.extend(subscription.priceInfo.today)
|
|
points.extend(subscription.priceInfo.tomorrow)
|
|
if not subscription.priceInfo.tomorrow:
|
|
logger.warning("Tibber tomorrow prices not available yet")
|
|
|
|
if not points:
|
|
raise ValueError("Tibber response contains no price points")
|
|
|
|
series_data = pd.Series(dtype=float)
|
|
for point in points:
|
|
orig_datetime = to_datetime(point.startsAt, in_timezone=self.config.general.timezone)
|
|
series_data.at[orig_datetime] = point.total / 1000.0
|
|
|
|
return series_data.sort_index()
|
|
|
|
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()
|
|
# Parse via UTC: around a DST change the series mixes UTC offsets
|
|
# (e.g. +02:00 and +01:00), which pd.to_datetime() refuses to combine.
|
|
index = pd.to_datetime([to_datetime(index).isoformat() for index in series.index], utc=True)
|
|
timezone = self.config.general.timezone
|
|
series.index = index.tz_convert(timezone) if timezone else index
|
|
series = series.groupby(level=0).mean().sort_index()
|
|
return series.dropna()
|
|
|
|
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_slots = len(numeric_history)
|
|
|
|
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 values.",
|
|
history_slots,
|
|
)
|
|
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 values.",
|
|
history_slots,
|
|
)
|
|
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 values are available.",
|
|
history_slots,
|
|
)
|
|
return self._predict_median(numeric_history, hours=slots)
|
|
|
|
logger.error("No data available for prediction")
|
|
raise ValueError("No data available")
|
|
|
|
async 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._normalize_series(self._parse_data(tibber_data))
|
|
if series_data.empty:
|
|
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())
|
|
await self.key_from_series("elecprice_marketprice_wh", series_data)
|
|
|
|
history = await self.key_to_array(
|
|
key="elecprice_marketprice_wh",
|
|
end_datetime=highest_orig_datetime,
|
|
interval=to_duration(f"{resolution_seconds} seconds"),
|
|
fill_method="linear",
|
|
)
|
|
|
|
if not highest_orig_datetime:
|
|
error_msg = f"Highest original datetime not available: {highest_orig_datetime}"
|
|
logger.error(error_msg)
|
|
raise ValueError(error_msg)
|
|
|
|
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_slots <= 0:
|
|
logger.warning(
|
|
"No prediction needed. "
|
|
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={}, 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_slots,
|
|
)
|
|
prediction = self._predict_missing_prices(
|
|
history, slots=needed_slots, slots_per_hour=slots_per_hour
|
|
)
|
|
|
|
prediction_series = pd.Series(
|
|
data=prediction,
|
|
index=[
|
|
highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds")
|
|
for i in range(len(prediction))
|
|
],
|
|
)
|
|
await self.key_from_series("elecprice_marketprice_wh", prediction_series)
|