mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-24 18:58:12 +00:00
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:
@@ -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 -->
|
||||||
|
|||||||
@@ -110,6 +110,10 @@
|
|||||||
},
|
},
|
||||||
"energycharts": {
|
"energycharts": {
|
||||||
"bidding_zone": "DE-LU"
|
"bidding_zone": "DE-LU"
|
||||||
|
},
|
||||||
|
"tibber": {
|
||||||
|
"access_token": null,
|
||||||
|
"home_id": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ems": {
|
"ems": {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
47
openapi.json
47
openapi.json
@@ -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": {
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
472
src/akkudoktoreos/prediction/elecpricetibber.py
Normal file
472
src/akkudoktoreos/prediction/elecpricetibber.py
Normal 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)
|
||||||
@@ -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,
|
||||||
|
|||||||
403
tests/test_elecpricetibber.py
Normal file
403
tests/test_elecpricetibber.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
3
tests/testdata/eos_config_unstripped.json
vendored
3
tests/testdata/eos_config_unstripped.json
vendored
@@ -280,7 +280,8 @@
|
|||||||
"providers": [
|
"providers": [
|
||||||
"ElecPriceAkkudoktor",
|
"ElecPriceAkkudoktor",
|
||||||
"ElecPriceEnergyCharts",
|
"ElecPriceEnergyCharts",
|
||||||
"ElecPriceImport"
|
"ElecPriceImport",
|
||||||
|
"ElecPriceTibber"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"feedintariff": {
|
"feedintariff": {
|
||||||
|
|||||||
32
uv.lock
generated
32
uv.lock
generated
@@ -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]]
|
||||||
|
|||||||
Reference in New Issue
Block a user