feat: add tibber electricity price provider (#1160)

Signed-off-by: Andreas Schmitz <akkudoktor.net>
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2026-07-20 13:43:43 +02:00
committed by GitHub
parent 2ed04d5573
commit cc8193216a
11 changed files with 1027 additions and 40 deletions

View File

@@ -13,6 +13,7 @@
| energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. | | energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. |
| provider | `EOS_ELECPRICE__PROVIDER` | `Optional[str]` | `rw` | `None` | Electricity price provider id of provider to be used. | | provider | `EOS_ELECPRICE__PROVIDER` | `Optional[str]` | `rw` | `None` | Electricity price provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available electricity price provider ids. | | providers | | `list[str]` | `ro` | `N/A` | Available electricity price provider ids. |
| tibber | `EOS_ELECPRICE__TIBBER` | `ElecPriceTibberCommonSettings` | `rw` | `required` | Tibber electricity price provider settings. |
| vat_rate | `EOS_ELECPRICE__VAT_RATE` | `Optional[float]` | `rw` | `1.19` | VAT rate factor applied to electricity price when charges are used. | | vat_rate | `EOS_ELECPRICE__VAT_RATE` | `Optional[float]` | `rw` | `1.19` | VAT rate factor applied to electricity price when charges are used. |
::: :::
<!-- pyml enable line-length --> <!-- pyml enable line-length -->
@@ -39,6 +40,10 @@
}, },
"energycharts": { "energycharts": {
"bidding_zone": "DE-LU" "bidding_zone": "DE-LU"
},
"tibber": {
"access_token": null,
"home_id": null
} }
} }
} }
@@ -68,17 +73,53 @@
"energycharts": { "energycharts": {
"bidding_zone": "DE-LU" "bidding_zone": "DE-LU"
}, },
"tibber": {
"access_token": null,
"home_id": null
},
"providers": [ "providers": [
"ElecPriceAkkudoktor", "ElecPriceAkkudoktor",
"ElecPriceEnergyCharts", "ElecPriceEnergyCharts",
"ElecPriceFixed", "ElecPriceFixed",
"ElecPriceImport" "ElecPriceImport",
"ElecPriceTibber"
] ]
} }
} }
``` ```
<!-- pyml enable line-length --> <!-- pyml enable line-length -->
### Common settings for the Tibber electricity price provider
<!-- pyml disable line-length -->
:::{table} elecprice::tibber
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| access_token | `Optional[str]` | `rw` | `None` | Tibber API access token. |
| home_id | `Optional[str]` | `rw` | `None` | Optional Tibber home id. If omitted, the first home with a subscription is used. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"tibber": {
"access_token": "tibber_pat_...",
"home_id": "00000000-0000-0000-0000-000000000000"
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for Energy Charts electricity price provider ### Common settings for Energy Charts electricity price provider
<!-- pyml disable line-length --> <!-- pyml disable line-length -->

View File

@@ -110,6 +110,10 @@
}, },
"energycharts": { "energycharts": {
"bidding_zone": "DE-LU" "bidding_zone": "DE-LU"
},
"tibber": {
"access_token": null,
"home_id": null
} }
}, },
"ems": { "ems": {

View File

@@ -1,6 +1,6 @@
# Akkudoktor-EOS # Akkudoktor-EOS
**Version**: `v0.3.0.dev2607200446185246` **Version**: `v0.3.0.dev2607201124501528`
<!-- pyml disable line-length --> <!-- pyml disable line-length -->
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period. **Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.

View File

@@ -8,7 +8,7 @@
"name": "Apache 2.0", "name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html" "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
}, },
"version": "v0.3.0.dev2607200446185246" "version": "v0.3.0.dev2607201124501528"
}, },
"paths": { "paths": {
"/v1/admin/cache/clear": { "/v1/admin/cache/clear": {
@@ -3495,6 +3495,10 @@
"energycharts": { "energycharts": {
"$ref": "#/components/schemas/ElecPriceEnergyChartsCommonSettings", "$ref": "#/components/schemas/ElecPriceEnergyChartsCommonSettings",
"description": "Energy Charts provider settings." "description": "Energy Charts provider settings."
},
"tibber": {
"$ref": "#/components/schemas/ElecPriceTibberCommonSettings",
"description": "Tibber electricity price provider settings."
} }
}, },
"type": "object", "type": "object",
@@ -3563,6 +3567,10 @@
"$ref": "#/components/schemas/ElecPriceEnergyChartsCommonSettings", "$ref": "#/components/schemas/ElecPriceEnergyChartsCommonSettings",
"description": "Energy Charts provider settings." "description": "Energy Charts provider settings."
}, },
"tibber": {
"$ref": "#/components/schemas/ElecPriceTibberCommonSettings",
"description": "Tibber electricity price provider settings."
},
"providers": { "providers": {
"items": { "items": {
"type": "string" "type": "string"
@@ -3691,6 +3699,43 @@
"title": "ElecPriceImportCommonSettings", "title": "ElecPriceImportCommonSettings",
"description": "Common settings for elecprice data import from file or JSON String." "description": "Common settings for elecprice data import from file or JSON String."
}, },
"ElecPriceTibberCommonSettings": {
"properties": {
"access_token": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Access Token",
"description": "Tibber API access token.",
"examples": [
"tibber_pat_..."
]
},
"home_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Home Id",
"description": "Optional Tibber home id. If omitted, the first home with a subscription is used.",
"examples": [
"00000000-0000-0000-0000-000000000000"
]
}
},
"type": "object",
"title": "ElecPriceTibberCommonSettings",
"description": "Common settings for the Tibber electricity price provider."
},
"ElectricVehicleParameters": { "ElectricVehicleParameters": {
"properties": { "properties": {
"device_id": { "device_id": {

View File

@@ -10,6 +10,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import (
) )
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixedCommonSettings from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixedCommonSettings
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibberCommonSettings
def elecprice_provider_ids() -> list[str]: def elecprice_provider_ids() -> list[str]:
@@ -17,9 +18,14 @@ def elecprice_provider_ids() -> list[str]:
try: try:
prediction_eos = get_prediction() prediction_eos = get_prediction()
except Exception: except Exception:
# Prediction may not be initialized # Prediction may not be initialized. Return static built-in provider ids.
# Return at least provider used in example return [
return ["ElecPriceAkkudoktor"] "ElecPriceAkkudoktor",
"ElecPriceEnergyCharts",
"ElecPriceFixed",
"ElecPriceImport",
"ElecPriceTibber",
]
return [ return [
provider.provider_id() provider.provider_id()
@@ -72,6 +78,11 @@ class ElecPriceCommonSettings(SettingsBaseModel):
json_schema_extra={"description": "Energy Charts provider settings."}, json_schema_extra={"description": "Energy Charts provider settings."},
) )
tibber: ElecPriceTibberCommonSettings = Field(
default_factory=ElecPriceTibberCommonSettings,
json_schema_extra={"description": "Tibber electricity price provider settings."},
)
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def providers(self) -> list[str]: def providers(self) -> list[str]:

View File

@@ -0,0 +1,472 @@
"""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 statsmodels.tsa.holtwinters import ExponentialSmoothing
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()
series.index = pd.to_datetime([to_datetime(index).isoformat() for index in series.index])
series = series.groupby(level=0).mean().sort_index()
return series.dropna()
def _resolution_seconds(self, series: pd.Series) -> int:
"""Infer the native slot size in seconds from the series timestamps.
Uses the median of the timestamp differences so that a single outlier gap does
not distort the result. Falls back to hourly (3600 s) when fewer than two
timestamps are available.
"""
if len(series) < 2:
return 3600
deltas = pd.DatetimeIndex(series.index).to_series().diff().dropna()
if deltas.empty:
return 3600
resolution = int(round(deltas.dt.total_seconds().median()))
return resolution if resolution > 0 else 3600
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
mean = data.mean()
std = data.std()
lower_bound = mean - sigma * std
upper_bound = mean + sigma * std
capped_data = data.clip(min=lower_bound, max=upper_bound)
return capped_data
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
model = ExponentialSmoothing(
clean_history, seasonal="add", seasonal_periods=seasonal_periods
).fit()
return model.forecast(hours)
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
return np.full(hours, np.median(clean_history))
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)

View File

@@ -35,6 +35,7 @@ from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.loadakkudoktor import ( from akkudoktoreos.prediction.loadakkudoktor import (
@@ -79,6 +80,7 @@ elecprice_akkudoktor = ElecPriceAkkudoktor()
elecprice_energy_charts = ElecPriceEnergyCharts() elecprice_energy_charts = ElecPriceEnergyCharts()
elecprice_fixed = ElecPriceFixed() elecprice_fixed = ElecPriceFixed()
elecprice_import = ElecPriceImport() elecprice_import = ElecPriceImport()
elecprice_tibber = ElecPriceTibber()
feedintariff_fixed = FeedInTariffFixed() feedintariff_fixed = FeedInTariffFixed()
feedintariff_import = FeedInTariffImport() feedintariff_import = FeedInTariffImport()
loadforecast_akkudoktor = LoadAkkudoktor() loadforecast_akkudoktor = LoadAkkudoktor()
@@ -103,6 +105,7 @@ def prediction_providers() -> list[
ElecPriceEnergyCharts, ElecPriceEnergyCharts,
ElecPriceFixed, ElecPriceFixed,
ElecPriceImport, ElecPriceImport,
ElecPriceTibber,
FeedInTariffFixed, FeedInTariffFixed,
FeedInTariffImport, FeedInTariffImport,
LoadAkkudoktor, LoadAkkudoktor,
@@ -130,6 +133,7 @@ def prediction_providers() -> list[
elecprice_energy_charts, \ elecprice_energy_charts, \
elecprice_fixed, \ elecprice_fixed, \
elecprice_import, \ elecprice_import, \
elecprice_tibber, \
feedintariff_fixed, \ feedintariff_fixed, \
feedintariff_import, \ feedintariff_import, \
loadforecast_akkudoktor, \ loadforecast_akkudoktor, \
@@ -153,6 +157,7 @@ def prediction_providers() -> list[
elecprice_energy_charts, elecprice_energy_charts,
elecprice_fixed, elecprice_fixed,
elecprice_import, elecprice_import,
elecprice_tibber,
feedintariff_fixed, feedintariff_fixed,
feedintariff_import, feedintariff_import,
loadforecast_akkudoktor, loadforecast_akkudoktor,
@@ -181,6 +186,7 @@ class Prediction(PredictionContainer):
ElecPriceEnergyCharts, ElecPriceEnergyCharts,
ElecPriceFixed, ElecPriceFixed,
ElecPriceImport, ElecPriceImport,
ElecPriceTibber,
FeedInTariffFixed, FeedInTariffFixed,
FeedInTariffImport, FeedInTariffImport,
LoadAkkudoktor, LoadAkkudoktor,

View File

@@ -0,0 +1,403 @@
"""Tests for the Tibber electricity price provider."""
import json
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
import pytest
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.elecpricetibber import (
TIBBER_PRICE_QUERY_QUARTER_HOURLY,
ElecPriceTibber,
ElecPriceTibberCommonSettings,
TibberGraphQLResponse,
)
from akkudoktoreos.utils.datetimeutil import to_datetime
class _FakeEms:
start_datetime = to_datetime("2026-07-09T00:00:00+00:00")
def _price(starts_at: str, total: float) -> dict[str, object]:
return {"startsAt": starts_at, "total": total}
def _tibber_payload(
prices: list[dict[str, object]],
*,
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:
homes.append(
{
"id": "other-home",
"currentSubscription": {
"priceInfo": {"today": [_price("2026-07-09T00:00:00+00:00", 0.999)]}
},
}
)
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}}}
@pytest.fixture
def provider(config_eos):
"""Create a fresh Tibber electricity price provider."""
ElecPriceTibber.reset_instance()
config_eos.elecprice = ElecPriceCommonSettings(
provider="ElecPriceTibber",
tibber=ElecPriceTibberCommonSettings(access_token="token-123", home_id="home-1"),
)
config_eos.prediction.hours = 6
provider = ElecPriceTibber()
provider.records.clear()
return provider
@pytest.fixture
def tibber_provider(provider, monkeypatch):
"""Create a Tibber provider with a deterministic EMS start time."""
monkeypatch.setattr("akkudoktoreos.core.coreabc.get_ems", lambda: _FakeEms())
return provider
@pytest.fixture
def cache_store():
"""Create a cache store for tests that touch cached methods."""
return CacheFileStore()
@pytest.fixture
def tibber_response_dict():
"""Sample Tibber GraphQL response."""
return _tibber_payload(
[
_price("2026-07-07T01:00:00.000+02:00", 0.2970716),
_price("2026-07-07T00:00:00.000+02:00", 0.3109662),
_price("2026-07-08T00:00:00.000+02:00", 0.30468),
],
include_other_home=True,
)
@pytest.fixture
def tibber_response(tibber_response_dict):
"""Validated sample Tibber GraphQL response."""
return TibberGraphQLResponse.model_validate(tibber_response_dict)
class TestElecPriceTibber:
"""Tests for ElecPriceTibber provider."""
def test_provider_id(self, provider):
"""Provider ID is stable."""
assert provider.provider_id() == "ElecPriceTibber"
def test_enabled_only_for_configured_provider(self, provider, config_eos):
"""Provider is enabled only when configured as active elecprice provider."""
assert provider.enabled()
config_eos.elecprice.provider = "ElecPriceFixed"
assert not provider.enabled()
def test_config_structure_accepts_tibber_settings(self):
"""The requested nested Tibber config structure is accepted."""
settings = ElecPriceCommonSettings.model_validate(
{
"provider": "ElecPriceTibber",
"tibber": {
"access_token": "token-123",
"home_id": "home-1",
},
}
)
assert settings.provider == "ElecPriceTibber"
assert settings.tibber.access_token == "token-123"
assert settings.tibber.home_id == "home-1"
def test_missing_access_token_raises(self, provider, config_eos):
"""A Tibber access token is required before making requests."""
config_eos.elecprice.tibber.access_token = None
with pytest.raises(ValueError, match="Tibber access_token is required"):
provider._request_forecast(force_update=True)
def test_select_home_uses_first_subscription_when_home_id_is_omitted(
self, provider, config_eos, tibber_response
):
"""If no home id is configured, the first subscribed Tibber home is used."""
config_eos.elecprice.tibber.home_id = None
home = provider._select_home(tibber_response)
assert home.id == "other-home"
def test_graphql_errors_raise(self, provider):
"""GraphQL errors are surfaced as ValueError."""
with pytest.raises(ValueError, match="Tibber GraphQL error"):
provider._validate_data(json.dumps({"errors": [{"message": "Authentication failed"}]}))
def test_unknown_home_id_raises(self, provider, config_eos, tibber_response):
"""Configured home id must exist in the Tibber response."""
config_eos.elecprice.tibber.home_id = "missing-home"
with pytest.raises(ValueError, match="Tibber home_id not found"):
provider._select_home(tibber_response)
def test_parse_data_combines_sorts_and_converts_total(self, provider, tibber_response):
"""Today, tomorrow, and history prices are sorted and converted to EUR/Wh."""
series = provider._parse_data(tibber_response)
assert list(series.index) == [
to_datetime("2026-07-07T00:00:00.000+02:00", in_timezone="Europe/Berlin"),
to_datetime("2026-07-07T01:00:00.000+02:00", in_timezone="Europe/Berlin"),
to_datetime("2026-07-08T00:00:00.000+02:00", in_timezone="Europe/Berlin"),
]
assert series.iloc[0] == pytest.approx(0.0003109662)
assert series.iloc[1] == pytest.approx(0.0002970716)
assert series.iloc[2] == pytest.approx(0.00030468)
def test_tibber_normalize_series_preserves_quarter_hour_resolution(self, provider):
"""Quarter-hour Tibber prices keep their native 15-min resolution (no averaging).
EOS resamples onto the optimization grid on demand, so the provider must store the
native step size instead of pre-aggregating quarter-hour prices to hourly values.
"""
index = pd.date_range("2026-07-09T00:00:00+00:00", periods=8, freq="15min")
values = [0.10, 0.30, 0.50, 0.70, 1.0, 1.4, 1.8, 2.2]
series = pd.Series(values, index=index)
normalized = provider._normalize_series(series)
# Every 15-min point survives, values untouched, still on a 15-min grid.
assert normalized.tolist() == pytest.approx(values)
deltas = normalized.index.to_series().diff().dropna().dt.total_seconds().unique().tolist()
assert deltas == [900.0]
assert provider._resolution_seconds(normalized) == 900
def test_tibber_normalize_series_deduplicates_timestamps(self, provider):
"""Duplicate timestamps are collapsed (mean) without changing the resolution."""
index = pd.DatetimeIndex(
[
"2026-07-09T00:00:00+00:00",
"2026-07-09T00:00:00+00:00",
"2026-07-09T01:00:00+00:00",
]
)
series = pd.Series([0.10, 0.30, 0.50], index=index)
normalized = provider._normalize_series(series)
assert len(normalized) == 2
assert normalized.iloc[0] == pytest.approx(0.20)
assert normalized.iloc[1] == pytest.approx(0.50)
def test_empty_tomorrow_stores_only_today_and_warns(self, provider):
"""An empty tomorrow list does not create fake values before forecasting."""
response = TibberGraphQLResponse.model_validate(
_tibber_payload([_price("2026-07-07T00:00:00.000+02:00", 0.3109662)])
)
with patch("akkudoktoreos.prediction.elecpricetibber.logger.warning") as mock_warning:
series = provider._parse_data(response)
assert len(series) == 1
mock_warning.assert_called_once_with("Tibber tomorrow prices not available yet")
@patch("requests.post")
def test_request_forecast_uses_tibber_graphql_api(
self,
mock_post,
provider,
tibber_response_dict,
cache_store,
):
"""Request uses Tibber URL, bearer token, and GraphQL query body."""
cache_store.clear(clear_all=True)
mock_response = Mock()
mock_response.content = json.dumps(tibber_response_dict).encode()
mock_post.return_value = mock_response
response = provider._request_forecast(force_update=True)
assert isinstance(response, TibberGraphQLResponse)
mock_post.assert_called_once()
_, kwargs = mock_post.call_args
assert mock_post.call_args.args[0] == "https://api.tibber.com/v1-beta/gql"
assert kwargs["headers"]["Authorization"] == "Bearer token-123"
assert kwargs["headers"]["Content-Type"] == "application/json"
assert "query" in kwargs["json"]
assert "TibberPriceInfo" in kwargs["json"]["query"]
assert "priceInfoRange" in kwargs["json"]["query"]
assert "QUARTER_HOURLY" in kwargs["json"]["query"]
assert "total" in kwargs["json"]["query"]
assert "energy" in kwargs["json"]["query"]
assert kwargs["timeout"] == 30
def test_quarter_hour_query_sets_resolution_on_price_info(self):
"""Tibber defines resolution on priceInfo, not on today or tomorrow."""
compact_query = " ".join(TIBBER_PRICE_QUERY_QUARTER_HOURLY.split())
assert "priceInfo(resolution: QUARTER_HOURLY)" in compact_query
assert "today(resolution:" not in compact_query
assert "tomorrow(resolution:" not in compact_query
assert "priceInfoRange(resolution: QUARTER_HOURLY, last: 672)" in compact_query
@pytest.mark.asyncio
async def test_tibber_update_extrapolates_missing_hours_with_seasonal_history(
self, tibber_provider, monkeypatch
):
"""Missing Tibber future hours are forecast from seasonal price history."""
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),
]
)
)
monkeypatch.setattr(tibber_provider, "_request_forecast", lambda **_: data)
monkeypatch.setattr(
tibber_provider,
"_predict_ets",
lambda history, seasonal_periods, hours: np.full(hours, 0.0005),
)
history = pd.Series(
data=np.linspace(0.0002, 0.0004, 169),
index=pd.date_range("2026-07-01T23:00:00+00:00", periods=169, freq="1h"),
)
await tibber_provider.key_from_series("elecprice_marketprice_wh", history)
await tibber_provider._update_data(force_update=True)
prices = await tibber_provider.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=to_datetime("2026-07-09T00:00:00+00:00"),
end_datetime=to_datetime("2026-07-09T06:00:00+00:00"),
fill_method="ffill",
)
assert prices.tolist() == pytest.approx([0.0003, 0.00042, 0.00036, 0.0005, 0.0005, 0.0005])
@pytest.mark.asyncio
async def test_tibber_update_uses_eos_storage_history_when_api_history_is_missing(
self, 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"),
)
await tibber_provider.key_from_series("elecprice_marketprice_wh", stored_history)
await tibber_provider._update_data(force_update=True)
assert forecast_call["seasonal_periods"] == 168
assert forecast_call["history_hours"] > 840
@pytest.mark.asyncio
async def test_tibber_update_preserves_quarter_hour_resolution_and_slots(self, tibber_provider, monkeypatch):
"""15-minute Tibber prices are stored natively and extrapolated on the slot grid.
Proves the resolution-agnostic path: (a) the native 15-min resolution survives
storage, (b) the ETS extrapolation scales the seasonal window into slots
(daily-only history -> 24*4 = 96 seasonal periods), and (c) the forecast index is
spaced at 15-minute steps.
"""
data = TibberGraphQLResponse.model_validate(
_tibber_payload(
[
_price("2026-07-09T00:00:00+00:00", 0.30),
_price("2026-07-09T00:15:00+00:00", 0.42),
_price("2026-07-09T00:30: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_slots"] = len(history)
forecast_call["forecast_slots"] = hours
return np.full(hours, 0.0009)
monkeypatch.setattr(tibber_provider, "_predict_ets", fake_predict_ets)
# A bit more than one week of quarter-hour history: enough for the daily seasonal
# window (> 24*7*4 = 672 slots) but below the weekly one (<= 24*35*4 = 3360 slots).
stored_history = pd.Series(
data=np.linspace(0.0002, 0.0004, 800),
index=pd.date_range("2026-07-01T00:00:00+00:00", periods=800, freq="15min"),
)
await tibber_provider.key_from_series("elecprice_marketprice_wh", stored_history)
await tibber_provider._update_data(force_update=True)
# (b) Daily seasonal window scaled into 15-min slots.
assert forecast_call["seasonal_periods"] == 96
assert 672 < forecast_call["history_slots"] <= 3360
# prediction.hours (6) * slots_per_hour (4) - covered slots (2 -> 00:00..00:30) = 22
assert forecast_call["forecast_slots"] == 22
# (a)+(c) Stored records keep the native 15-min grid across today and the forecast.
stored = await tibber_provider.key_to_series(
"elecprice_marketprice_wh",
start_datetime=to_datetime("2026-07-09T00:00:00+00:00"),
end_datetime=to_datetime("2026-07-09T06:15:00+00:00"),
)
steps = stored.index.to_series().diff().dropna().dt.total_seconds().unique().tolist()
assert steps == [900.0]
# 00:00..06:00 inclusive at 15-min steps = 25 points (3 API + 22 forecast).
assert len(stored) == 25

View File

@@ -6,6 +6,7 @@ from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.loadakkudoktor import ( from akkudoktoreos.prediction.loadakkudoktor import (
@@ -44,6 +45,7 @@ def forecast_providers():
ElecPriceEnergyCharts(), ElecPriceEnergyCharts(),
ElecPriceFixed(), ElecPriceFixed(),
ElecPriceImport(), ElecPriceImport(),
ElecPriceTibber(),
FeedInTariffFixed(), FeedInTariffFixed(),
FeedInTariffImport(), FeedInTariffImport(),
LoadAkkudoktor(), LoadAkkudoktor(),
@@ -96,22 +98,23 @@ def test_provider_sequence(prediction):
assert isinstance(prediction.providers[1], ElecPriceEnergyCharts) assert isinstance(prediction.providers[1], ElecPriceEnergyCharts)
assert isinstance(prediction.providers[2], ElecPriceFixed) assert isinstance(prediction.providers[2], ElecPriceFixed)
assert isinstance(prediction.providers[3], ElecPriceImport) assert isinstance(prediction.providers[3], ElecPriceImport)
assert isinstance(prediction.providers[4], FeedInTariffFixed) assert isinstance(prediction.providers[4], ElecPriceTibber)
assert isinstance(prediction.providers[5], FeedInTariffImport) assert isinstance(prediction.providers[5], FeedInTariffFixed)
assert isinstance(prediction.providers[6], LoadAkkudoktor) assert isinstance(prediction.providers[6], FeedInTariffImport)
assert isinstance(prediction.providers[7], LoadAkkudoktorAdjusted) assert isinstance(prediction.providers[7], LoadAkkudoktor)
assert isinstance(prediction.providers[8], LoadVrm) assert isinstance(prediction.providers[8], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[9], LoadImport) assert isinstance(prediction.providers[9], LoadVrm)
assert isinstance(prediction.providers[10], PVForecastAkkudoktor) assert isinstance(prediction.providers[10], LoadImport)
assert isinstance(prediction.providers[11], PVForecastVrm) assert isinstance(prediction.providers[11], PVForecastAkkudoktor)
assert isinstance(prediction.providers[12], PVForecastPVNode) assert isinstance(prediction.providers[12], PVForecastVrm)
assert isinstance(prediction.providers[13], PVForecastForecastSolar) assert isinstance(prediction.providers[13], PVForecastPVNode)
assert isinstance(prediction.providers[14], PVForecastSolcast) assert isinstance(prediction.providers[14], PVForecastForecastSolar)
assert isinstance(prediction.providers[15], PVForecastImport) assert isinstance(prediction.providers[15], PVForecastSolcast)
assert isinstance(prediction.providers[16], WeatherBrightSky) assert isinstance(prediction.providers[16], PVForecastImport)
assert isinstance(prediction.providers[17], WeatherClearOutside) assert isinstance(prediction.providers[17], WeatherBrightSky)
assert isinstance(prediction.providers[18], WeatherOpenMeteo) assert isinstance(prediction.providers[18], WeatherClearOutside)
assert isinstance(prediction.providers[19], WeatherImport) assert isinstance(prediction.providers[19], WeatherOpenMeteo)
assert isinstance(prediction.providers[20], WeatherImport)
def test_provider_by_id(prediction, forecast_providers): def test_provider_by_id(prediction, forecast_providers):
@@ -128,6 +131,7 @@ def test_prediction_repr(prediction):
assert "ElecPriceEnergyCharts" in result assert "ElecPriceEnergyCharts" in result
assert "ElecPriceFixed" in result assert "ElecPriceFixed" in result
assert "ElecPriceImport" in result assert "ElecPriceImport" in result
assert "ElecPriceTibber" in result
assert "FeedInTariffFixed" in result assert "FeedInTariffFixed" in result
assert "FeedInTariffImport" in result assert "FeedInTariffImport" in result
assert "LoadAkkudoktor" in result assert "LoadAkkudoktor" in result

View File

@@ -280,7 +280,8 @@
"providers": [ "providers": [
"ElecPriceAkkudoktor", "ElecPriceAkkudoktor",
"ElecPriceEnergyCharts", "ElecPriceEnergyCharts",
"ElecPriceImport" "ElecPriceImport",
"ElecPriceTibber"
] ]
}, },
"feedintariff": { "feedintariff": {
@@ -593,4 +594,4 @@
"NodeRED" "NodeRED"
] ]
} }
} }

32
uv.lock generated
View File

@@ -82,15 +82,15 @@ requires-dist = [
{ name = "beautifulsoup4", specifier = "==4.15.0" }, { name = "beautifulsoup4", specifier = "==4.15.0" },
{ name = "bokeh", specifier = "==3.9.1" }, { name = "bokeh", specifier = "==3.9.1" },
{ name = "cachebox", specifier = "==6.1.2" }, { name = "cachebox", specifier = "==6.1.2" },
{ name = "commitizen", marker = "extra == 'dev'", specifier = "==4.16.4" }, { name = "commitizen", marker = "extra == 'dev'", specifier = "==4.16.5" },
{ name = "contourpy", specifier = "==1.3.3" }, { name = "contourpy", specifier = "==1.3.3" },
{ name = "coverage", marker = "extra == 'dev'", specifier = "==7.15.1" }, { name = "coverage", marker = "extra == 'dev'", specifier = "==7.15.1" },
{ name = "deap", specifier = "==1.4.4" }, { name = "deap", specifier = "==1.4.4" },
{ name = "deprecated", marker = "extra == 'dev'", specifier = "==1.3.1" }, { name = "deprecated", marker = "extra == 'dev'", specifier = "==1.3.1" },
{ name = "docutils", marker = "extra == 'dev'", specifier = "==0.21.2" }, { name = "docutils", marker = "extra == 'dev'", specifier = "==0.21.2" },
{ name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = "==0.139.0" }, { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = "==0.139.2" },
{ name = "fastapi-cli", specifier = "==0.0.29" }, { name = "fastapi-cli", specifier = "==0.0.29" },
{ name = "gitpython", marker = "extra == 'dev'", specifier = "==3.1.51" }, { name = "gitpython", marker = "extra == 'dev'", specifier = "==3.1.52" },
{ name = "linkify-it-py", specifier = "==2.1.0" }, { name = "linkify-it-py", specifier = "==2.1.0" },
{ name = "lmdb", specifier = "==2.3.0" }, { name = "lmdb", specifier = "==2.3.0" },
{ name = "loguru", specifier = "==0.7.3" }, { name = "loguru", specifier = "==0.7.3" },
@@ -123,7 +123,7 @@ requires-dist = [
{ name = "sphinx", marker = "extra == 'dev'", specifier = "==9.0.4" }, { name = "sphinx", marker = "extra == 'dev'", specifier = "==9.0.4" },
{ name = "sphinx-rtd-theme", marker = "extra == 'dev'", specifier = "==3.1.0" }, { name = "sphinx-rtd-theme", marker = "extra == 'dev'", specifier = "==3.1.0" },
{ name = "sphinx-tabs", marker = "extra == 'dev'", specifier = "==3.5.0" }, { name = "sphinx-tabs", marker = "extra == 'dev'", specifier = "==3.5.0" },
{ name = "sphinxcontrib-mermaid", marker = "extra == 'dev'", specifier = "==2.0.3" }, { name = "sphinxcontrib-mermaid", marker = "extra == 'dev'", specifier = "==2.1.0" },
{ name = "statsmodels", specifier = "==0.14.6" }, { name = "statsmodels", specifier = "==0.14.6" },
{ name = "tokenize-rt", marker = "extra == 'dev'", specifier = "==6.2.0" }, { name = "tokenize-rt", marker = "extra == 'dev'", specifier = "==6.2.0" },
{ name = "types-docutils", marker = "extra == 'dev'", specifier = "==0.22.3.20260712" }, { name = "types-docutils", marker = "extra == 'dev'", specifier = "==0.22.3.20260712" },
@@ -686,7 +686,7 @@ wheels = [
[[package]] [[package]]
name = "commitizen" name = "commitizen"
version = "4.16.4" version = "4.16.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "argcomplete" }, { name = "argcomplete" },
@@ -702,9 +702,9 @@ dependencies = [
{ name = "termcolor" }, { name = "termcolor" },
{ name = "tomlkit" }, { name = "tomlkit" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/50/8a/4fccfa29c95536ac6dc98dc09a676cc2bce60d72f68b8e280278c6674669/commitizen-4.16.4.tar.gz", hash = "sha256:bb2fda50da381979e308d4a443d349598de2487d9e40d2e4f55a1f115ca38a8c", size = 66771, upload-time = "2026-06-22T06:32:50.06Z" } sdist = { url = "https://files.pythonhosted.org/packages/8e/de/b7ad6b48415760b3bb2e702218b9fc89ea376509ec88d04a281c33196f42/commitizen-4.16.5.tar.gz", hash = "sha256:a953394a8625ab6e3619d7e97ffc032cac8a13c5854a12010d0a9d415cdb80ca", size = 66779, upload-time = "2026-07-16T02:58:50.386Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/8f/496168ab853b325f62075e78ceb611d975ffc58116e4fabcca17725ac53c/commitizen-4.16.4-py3-none-any.whl", hash = "sha256:054e957491e3c897226a631e5bbeca5f5db772ce23412a5d588a4d7b9bb0bbc2", size = 88933, upload-time = "2026-06-22T06:32:48.615Z" }, { url = "https://files.pythonhosted.org/packages/d2/e2/b412a27022502f5b96638f379c821e25668ae231d5501088a2773262ef4a/commitizen-4.16.5-py3-none-any.whl", hash = "sha256:cd80978a61bb3e0b18137da9e10d03edba0b9c184b9bc964dba8a31c42a7630c", size = 88932, upload-time = "2026-07-16T02:58:48.658Z" },
] ]
[[package]] [[package]]
@@ -963,7 +963,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.139.0" version = "0.139.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -972,9 +972,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -1102,14 +1102,14 @@ wheels = [
[[package]] [[package]]
name = "gitpython" name = "gitpython"
version = "3.1.51" version = "3.1.52"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "gitdb" }, { name = "gitdb" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/59/30/a8a0c15f9480dc91b5b7f11ebd26105e5f80898d7ff02da197fef35d8395/gitpython-3.1.51.tar.gz", hash = "sha256:22c9c94bb6b0b9f3c7157c684fece45a414cea204586b600beae6cd4570dcd6d", size = 223519, upload-time = "2026-07-12T13:40:07.922Z" } sdist = { url = "https://files.pythonhosted.org/packages/e5/fd/df0bafa4eb5ea2f51e1adee9f7a94c8e62c5d180e65117045dfca3439c8a/gitpython-3.1.52.tar.gz", hash = "sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e", size = 223726, upload-time = "2026-07-16T03:15:59.599Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/11/1232bb1ba52a230f20ff08e1b3df7cd9d43a71b61cc0a1c37941064fe4c7/gitpython-3.1.51-py3-none-any.whl", hash = "sha256:d5b29685708f3c80a56db040b665bfd69492c8e150b5848532af8013b686c5a4", size = 215246, upload-time = "2026-07-12T13:40:06.43Z" }, { url = "https://files.pythonhosted.org/packages/8d/90/04dff7c1e176bb1c3011ef1647393d368790da710d8dde1cdcfad301f45a/gitpython-3.1.52-py3-none-any.whl", hash = "sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a", size = 215366, upload-time = "2026-07-16T03:15:58.239Z" },
] ]
[[package]] [[package]]
@@ -3074,16 +3074,16 @@ wheels = [
[[package]] [[package]]
name = "sphinxcontrib-mermaid" name = "sphinxcontrib-mermaid"
version = "2.0.3" version = "2.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jinja2" }, { name = "jinja2" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "sphinx" }, { name = "sphinx" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/51/29/54cf1f7e03630ca4859caba25bc192698954d7c630377c62d1e264715c37/sphinxcontrib_mermaid-2.0.3.tar.gz", hash = "sha256:a6865ef6b65b225c5403a3170de63a04a07227cada11a4a71a6b87b4f9ed185a", size = 20764, upload-time = "2026-07-08T00:30:44.216Z" } sdist = { url = "https://files.pythonhosted.org/packages/07/9d/bf3a48a657682c7e0445c71d916bca7ab8194454276cc22b16e7f974e502/sphinxcontrib_mermaid-2.1.0.tar.gz", hash = "sha256:13c5f9ac395cb6abf403eca34e228dc9fb3a30c9d960dbf3e40e9a8cef969549", size = 21695, upload-time = "2026-07-18T23:08:11.265Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/57/b39c7a69b70a3ce999732bdb57fcaac78fb3cc8e2843a3daf1c1f821bc9d/sphinxcontrib_mermaid-2.0.3-py3-none-any.whl", hash = "sha256:f001ed36a55c108f6221a2d656a441c487ee30651b54db72b7c752a20c7a66e8", size = 15401, upload-time = "2026-07-08T00:30:42.877Z" }, { url = "https://files.pythonhosted.org/packages/f3/bd/d3348d62296e73a91420f0edf671450c9003ecd8704da40b1e3d9b868459/sphinxcontrib_mermaid-2.1.0-py3-none-any.whl", hash = "sha256:417cd144ec4b28852f46ba653f02ce8e538881c812111671a4c30344e87f2112", size = 16190, upload-time = "2026-07-18T23:08:10.098Z" },
] ]
[[package]] [[package]]