feat: add Tibber price provider and PV forecast providers

This commit is contained in:
Andreas
2026-07-08 16:36:59 +02:00
22 changed files with 1942 additions and 26 deletions

View File

@@ -0,0 +1,286 @@
"""Tests for the Tibber electricity price provider."""
import json
from unittest.mock import Mock, patch
import pytest
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.elecpricetibber import (
ElecPriceTibber,
ElecPriceTibberCommonSettings,
TibberGraphQLResponse,
)
from akkudoktoreos.utils.datetimeutil import to_datetime
@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"),
)
return ElecPriceTibber()
@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 {
"data": {
"viewer": {
"homes": [
{
"id": "other-home",
"currentSubscription": {
"priceInfo": {
"today": [
{
"startsAt": "2026-07-07T00:00:00.000+02:00",
"total": 0.999,
"energy": 0.111,
"tax": 0.888,
}
],
"tomorrow": [],
}
},
},
{
"id": "home-1",
"currentSubscription": {
"priceInfo": {
"today": [
{
"startsAt": "2026-07-07T01:00:00.000+02:00",
"total": 0.2970716,
"energy": 0.10922,
"tax": 0.1878516,
},
{
"startsAt": "2026-07-07T00:00:00.000+02:00",
"total": 0.3109662,
"energy": 0.12098,
"tax": 0.1899862,
},
],
"tomorrow": [
{
"startsAt": "2026-07-08T00:00:00.000+02:00",
"total": 0.30468,
"energy": 0.1162,
"tax": 0.18848,
}
],
}
},
},
]
}
}
}
@pytest.fixture
def tibber_response(tibber_response_dict):
"""Validated sample Tibber GraphQL response."""
return TibberGraphQLResponse.model_validate(tibber_response_dict)
def test_provider_id(provider):
"""Provider ID is stable."""
assert provider.provider_id() == "ElecPriceTibber"
def test_enabled_only_for_configured_provider(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():
"""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(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_missing_home_id_raises(provider, config_eos, tibber_response):
"""A Tibber home id is required for selecting prices."""
config_eos.elecprice.tibber.home_id = None
with pytest.raises(ValueError, match="Tibber home_id is required"):
provider._select_home(tibber_response)
def test_graphql_errors_raise(provider):
"""GraphQL errors are surfaced as ValueError."""
response = TibberGraphQLResponse.model_validate(
{"errors": [{"message": "Authentication failed"}]}
)
with pytest.raises(ValueError, match="Tibber GraphQL error"):
provider._select_home(response)
def test_unknown_home_id_raises(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(provider, tibber_response):
"""Today and tomorrow prices are sorted and converted from EUR/kWh 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_update_data_stores_elecprice_marketprice_wh(provider, tibber_response):
"""Parsed Tibber totals are stored in EOS records."""
with patch.object(provider, "_request_forecast", return_value=tibber_response):
provider.update_data(force_enable=True, force_update=True)
series = provider.key_to_series("elecprice_marketprice_wh")
assert len(series) == 3
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_total_conversion_exact_example(provider):
"""Tibber total 0.311 EUR/kWh is stored as 0.000311 EUR/Wh."""
response = TibberGraphQLResponse.model_validate(
{
"data": {
"viewer": {
"homes": [
{
"id": "home-1",
"currentSubscription": {
"priceInfo": {
"today": [
{
"startsAt": "2026-07-07T00:00:00.000+02:00",
"total": 0.311,
}
],
"tomorrow": [],
}
},
}
]
}
}
}
)
series = provider._parse_data(response)
assert series.iloc[0] == pytest.approx(0.000311)
def test_empty_tomorrow_stores_only_today_and_warns(provider):
"""An empty tomorrow list does not create fake values."""
response = TibberGraphQLResponse.model_validate(
{
"data": {
"viewer": {
"homes": [
{
"id": "home-1",
"currentSubscription": {
"priceInfo": {
"today": [
{
"startsAt": "2026-07-07T00:00:00.000+02:00",
"total": 0.3109662,
},
{
"startsAt": "2026-07-07T01:00:00.000+02:00",
"total": 0.2970716,
},
],
"tomorrow": [],
}
},
}
]
}
}
}
)
with patch("akkudoktoreos.prediction.elecpricetibber.logger.warning") as mock_warning:
series = provider._parse_data(response)
assert len(series) == 2
mock_warning.assert_called_once_with("Tibber tomorrow prices not available yet")
@patch("requests.post")
def test_request_forecast_uses_tibber_graphql_api(
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 "total" in kwargs["json"]["query"]
assert kwargs["timeout"] == 30