mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-12 21:08:13 +00:00
feat: add Tibber price provider and PV forecast providers
This commit is contained in:
194
src/akkudoktoreos/prediction/elecpricetibber.py
Normal file
194
src/akkudoktoreos/prediction/elecpricetibber.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Electricity price provider for Tibber."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
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
|
||||
|
||||
TIBBER_GRAPHQL_URL = "https://api.tibber.com/v1-beta/gql"
|
||||
TIBBER_PRICE_QUERY = """
|
||||
query TibberPriceInfo {
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
today {
|
||||
startsAt
|
||||
total
|
||||
energy
|
||||
tax
|
||||
}
|
||||
tomorrow {
|
||||
startsAt
|
||||
total
|
||||
energy
|
||||
tax
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
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": "Tibber home id to read prices from.",
|
||||
"examples": ["00000000-0000-0000-0000-000000000000"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TibberPricePoint(PydanticBaseModel):
|
||||
"""Single Tibber price point."""
|
||||
|
||||
startsAt: datetime
|
||||
total: float
|
||||
energy: Optional[float] = None
|
||||
tax: Optional[float] = None
|
||||
|
||||
|
||||
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: TibberPriceInfo
|
||||
|
||||
|
||||
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 TibberGraphQLResponse(PydanticBaseModel):
|
||||
"""Tibber GraphQL response payload."""
|
||||
|
||||
data: Optional[TibberData] = None
|
||||
errors: Optional[list[dict[str, Any]]] = None
|
||||
|
||||
|
||||
class ElecPriceTibber(ElecPriceProvider):
|
||||
"""Fetch and store Tibber electricity import prices."""
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the Tibber provider."""
|
||||
return "ElecPriceTibber"
|
||||
|
||||
@cache_in_file(with_ttl="5 minutes")
|
||||
def _request_forecast(self, force_update: Optional[bool] = False) -> 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")
|
||||
|
||||
response = requests.post(
|
||||
TIBBER_GRAPHQL_URL,
|
||||
json={"query": TIBBER_PRICE_QUERY},
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
try:
|
||||
return TibberGraphQLResponse.model_validate_json(response.content)
|
||||
except ValidationError as exc:
|
||||
logger.error("Tibber schema validation failed: {}", exc)
|
||||
raise ValueError(f"Tibber schema validation failed: {exc}") from exc
|
||||
|
||||
def _select_home(self, response: TibberGraphQLResponse) -> TibberHome:
|
||||
"""Select the configured Tibber home from a GraphQL response."""
|
||||
home_id = self.config.elecprice.tibber.home_id
|
||||
if not home_id:
|
||||
raise ValueError("Tibber home_id is required")
|
||||
|
||||
if response.errors:
|
||||
raise ValueError(f"Tibber GraphQL error: {response.errors}")
|
||||
|
||||
if response.data is None:
|
||||
raise ValueError("Tibber response does not contain data")
|
||||
|
||||
for home in response.data.viewer.homes:
|
||||
if home.id == home_id:
|
||||
return home
|
||||
|
||||
raise ValueError("Tibber home_id not found")
|
||||
|
||||
def _parse_data(self, response: TibberGraphQLResponse) -> pd.Series:
|
||||
"""Parse Tibber prices into EOS market prices in EUR/Wh."""
|
||||
home = self._select_home(response)
|
||||
|
||||
if home.currentSubscription is None:
|
||||
raise ValueError("Tibber home has no current subscription")
|
||||
|
||||
price_info = home.currentSubscription.priceInfo
|
||||
points = list(price_info.today) + list(price_info.tomorrow)
|
||||
|
||||
if not price_info.tomorrow:
|
||||
logger.warning("Tibber tomorrow prices not available yet")
|
||||
|
||||
if not points:
|
||||
raise ValueError("Tibber response contains no price points")
|
||||
|
||||
values: dict[datetime, float] = {}
|
||||
|
||||
for point in points:
|
||||
dt = to_datetime(point.startsAt, in_timezone=self.config.general.timezone)
|
||||
values[dt] = point.total / 1000.0
|
||||
|
||||
return pd.Series(values, dtype=float).sort_index()
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
"""Update EOS electricity prices from Tibber price data."""
|
||||
response = self._request_forecast(force_update=force_update)
|
||||
series_data = self._parse_data(response)
|
||||
self.key_from_series("elecprice_marketprice_wh", series_data)
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
|
||||
logger.info("Updated ElecPriceTibber with {} price points", len(series_data))
|
||||
Reference in New Issue
Block a user