mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-12 21:08:13 +00:00
feat: add Tibber price provider and PV forecast providers
This commit is contained in:
286
tests/test_elecpricetibber.py
Normal file
286
tests/test_elecpricetibber.py
Normal 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
|
||||
Reference in New Issue
Block a user