mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-13 05:18:12 +00:00
feat: add Tibber price provider and PV forecast providers
This commit is contained in:
@@ -606,7 +606,7 @@ def server_base(
|
||||
# ensure process is running and return its logfile
|
||||
pid, logfile = xprocess.ensure("eos", Starter)
|
||||
logger.info(f"Started EOS ({pid}). This may take very long (up to {server_timeout} seconds).")
|
||||
logger.info(f"EOS_DIR: {Starter.env["EOS_DIR"]}, EOS_CONFIG_DIR: {Starter.env["EOS_CONFIG_DIR"]}")
|
||||
logger.info(f"EOS_DIR: {Starter.env['EOS_DIR']}, EOS_CONFIG_DIR: {Starter.env['EOS_CONFIG_DIR']}")
|
||||
logger.info(f"View xprocess logfile at: {logfile}")
|
||||
|
||||
yield {
|
||||
|
||||
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
|
||||
@@ -6,6 +6,7 @@ from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
||||
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
|
||||
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
|
||||
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
||||
from akkudoktoreos.prediction.loadakkudoktor import (
|
||||
@@ -19,7 +20,10 @@ from akkudoktoreos.prediction.prediction import (
|
||||
PredictionCommonSettings,
|
||||
)
|
||||
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
||||
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
|
||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
||||
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
|
||||
from akkudoktoreos.prediction.pvforecastsolcast import PVForecastSolcast
|
||||
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
|
||||
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
|
||||
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
||||
@@ -39,6 +43,7 @@ def forecast_providers():
|
||||
return [
|
||||
ElecPriceAkkudoktor(),
|
||||
ElecPriceEnergyCharts(),
|
||||
ElecPriceTibber(),
|
||||
ElecPriceFixed(),
|
||||
ElecPriceImport(),
|
||||
FeedInTariffFixed(),
|
||||
@@ -49,6 +54,9 @@ def forecast_providers():
|
||||
LoadImport(),
|
||||
PVForecastAkkudoktor(),
|
||||
PVForecastVrm(),
|
||||
PVForecastPVNode(),
|
||||
PVForecastForecastSolar(),
|
||||
PVForecastSolcast(),
|
||||
PVForecastImport(),
|
||||
WeatherBrightSky(),
|
||||
WeatherClearOutside(),
|
||||
@@ -88,21 +96,25 @@ def test_provider_sequence(prediction):
|
||||
"""Test the provider sequence is maintained in the Prediction instance."""
|
||||
assert isinstance(prediction.providers[0], ElecPriceAkkudoktor)
|
||||
assert isinstance(prediction.providers[1], ElecPriceEnergyCharts)
|
||||
assert isinstance(prediction.providers[2], ElecPriceFixed)
|
||||
assert isinstance(prediction.providers[3], ElecPriceImport)
|
||||
assert isinstance(prediction.providers[4], FeedInTariffFixed)
|
||||
assert isinstance(prediction.providers[5], FeedInTariffImport)
|
||||
assert isinstance(prediction.providers[6], LoadAkkudoktor)
|
||||
assert isinstance(prediction.providers[7], LoadAkkudoktorAdjusted)
|
||||
assert isinstance(prediction.providers[8], LoadVrm)
|
||||
assert isinstance(prediction.providers[9], LoadImport)
|
||||
assert isinstance(prediction.providers[10], PVForecastAkkudoktor)
|
||||
assert isinstance(prediction.providers[11], PVForecastVrm)
|
||||
assert isinstance(prediction.providers[12], PVForecastImport)
|
||||
assert isinstance(prediction.providers[13], WeatherBrightSky)
|
||||
assert isinstance(prediction.providers[14], WeatherClearOutside)
|
||||
assert isinstance(prediction.providers[15], WeatherOpenMeteo)
|
||||
assert isinstance(prediction.providers[16], WeatherImport)
|
||||
assert isinstance(prediction.providers[2], ElecPriceTibber)
|
||||
assert isinstance(prediction.providers[3], ElecPriceFixed)
|
||||
assert isinstance(prediction.providers[4], ElecPriceImport)
|
||||
assert isinstance(prediction.providers[5], FeedInTariffFixed)
|
||||
assert isinstance(prediction.providers[6], FeedInTariffImport)
|
||||
assert isinstance(prediction.providers[7], LoadAkkudoktor)
|
||||
assert isinstance(prediction.providers[8], LoadAkkudoktorAdjusted)
|
||||
assert isinstance(prediction.providers[9], LoadVrm)
|
||||
assert isinstance(prediction.providers[10], LoadImport)
|
||||
assert isinstance(prediction.providers[11], PVForecastAkkudoktor)
|
||||
assert isinstance(prediction.providers[12], PVForecastVrm)
|
||||
assert isinstance(prediction.providers[13], PVForecastPVNode)
|
||||
assert isinstance(prediction.providers[14], PVForecastForecastSolar)
|
||||
assert isinstance(prediction.providers[15], PVForecastSolcast)
|
||||
assert isinstance(prediction.providers[16], PVForecastImport)
|
||||
assert isinstance(prediction.providers[17], WeatherBrightSky)
|
||||
assert isinstance(prediction.providers[18], WeatherClearOutside)
|
||||
assert isinstance(prediction.providers[19], WeatherOpenMeteo)
|
||||
assert isinstance(prediction.providers[20], WeatherImport)
|
||||
|
||||
|
||||
def test_provider_by_id(prediction, forecast_providers):
|
||||
@@ -117,6 +129,7 @@ def test_prediction_repr(prediction):
|
||||
assert "Prediction([" in result
|
||||
assert "ElecPriceAkkudoktor" in result
|
||||
assert "ElecPriceEnergyCharts" in result
|
||||
assert "ElecPriceTibber" in result
|
||||
assert "ElecPriceFixed" in result
|
||||
assert "ElecPriceImport" in result
|
||||
assert "FeedInTariffFixed" in result
|
||||
|
||||
129
tests/test_pvforecastforecastsolar.py
Normal file
129
tests/test_pvforecastforecastsolar.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pendulum
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
|
||||
|
||||
|
||||
def _config(config_eos, planes=None, api_key=None):
|
||||
settings = {
|
||||
"general": {"latitude": 52.5, "longitude": 13.4},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastForecastSolar",
|
||||
"planes": planes
|
||||
if planes is not None
|
||||
else [{"surface_tilt": 30.0, "surface_azimuth": 180.0, "peakpower": 5.0}],
|
||||
"provider_settings": {"PVForecastForecastSolar": {"api_key": api_key}},
|
||||
},
|
||||
}
|
||||
config_eos.merge_settings_from_dict(settings)
|
||||
return config_eos
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pvforecast_instance(config_eos):
|
||||
_config(config_eos)
|
||||
start_dt = pendulum.datetime(2025, 1, 1, tz="Europe/Berlin")
|
||||
return PVForecastForecastSolar(config=config_eos.load, start_datetime=start_dt)
|
||||
|
||||
|
||||
def _http(watts, timezone="Europe/Berlin"):
|
||||
return type(
|
||||
"R",
|
||||
(),
|
||||
{
|
||||
"raise_for_status": lambda self: None,
|
||||
"json": lambda self: {
|
||||
"result": {"watts": watts},
|
||||
"message": {"info": {"timezone": timezone}},
|
||||
},
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
def test_provider_id(pvforecast_instance):
|
||||
assert PVForecastForecastSolar.provider_id() == "PVForecastForecastSolar"
|
||||
assert pvforecast_instance.enabled() is True
|
||||
|
||||
|
||||
def test_update_data_resolves_tz_and_sets_power(pvforecast_instance):
|
||||
body = {
|
||||
"timezone": "Europe/Berlin",
|
||||
"watts": {"2025-01-01 12:00:00": 1200.0, "2025-01-01 13:00:00": 1500.0},
|
||||
}
|
||||
with patch.object(pvforecast_instance, "_request_forecast", return_value=body), \
|
||||
patch.object(PVForecastForecastSolar, "update_value") as mock_update:
|
||||
pvforecast_instance._update_data()
|
||||
|
||||
assert mock_update.call_count == 2
|
||||
expected = [
|
||||
call(
|
||||
pendulum.datetime(2025, 1, 1, 12, 0, tz="Europe/Berlin"),
|
||||
{"pvforecast_ac_power": 1200.0, "pvforecast_dc_power": 1200.0},
|
||||
),
|
||||
call(
|
||||
pendulum.datetime(2025, 1, 1, 13, 0, tz="Europe/Berlin"),
|
||||
{"pvforecast_ac_power": 1500.0, "pvforecast_dc_power": 1500.0},
|
||||
),
|
||||
]
|
||||
mock_update.assert_has_calls(expected, any_order=False)
|
||||
# 12:00 Europe/Berlin (CET) is 11:00 UTC.
|
||||
assert mock_update.call_args_list[0][0][0].in_timezone("UTC").hour == 11
|
||||
|
||||
|
||||
def test_plane_url_converts_azimuth(config_eos):
|
||||
"""EOS azimuth 270 (west) -> Forecast.Solar 90; the key + plane geometry land in the URL."""
|
||||
_config(
|
||||
config_eos,
|
||||
planes=[{"surface_tilt": 25.0, "surface_azimuth": 270.0, "peakpower": 7.5}],
|
||||
api_key="secret",
|
||||
)
|
||||
pv = PVForecastForecastSolar(
|
||||
config=config_eos.load, start_datetime=pendulum.datetime(2025, 1, 1, tz="UTC")
|
||||
)
|
||||
with patch("requests.get", return_value=_http({})) as mock_get:
|
||||
# force_update is consumed by the cache_in_file decorator at runtime
|
||||
# (same call convention as pvforecastakkudoktor.py).
|
||||
pv._request_forecast(force_update=True) # type: ignore
|
||||
url = mock_get.call_args[0][0]
|
||||
assert url == "https://api.forecast.solar/secret/estimate/52.5/13.4/25.0/90.0/7.5"
|
||||
|
||||
|
||||
def test_request_forecast_sums_planes(config_eos):
|
||||
"""Two planes -> two requests; instantaneous powers are summed per timestamp."""
|
||||
_config(
|
||||
config_eos,
|
||||
planes=[
|
||||
{"surface_tilt": 30.0, "surface_azimuth": 90.0, "peakpower": 3.0},
|
||||
{"surface_tilt": 30.0, "surface_azimuth": 270.0, "peakpower": 3.0},
|
||||
],
|
||||
)
|
||||
pv = PVForecastForecastSolar(
|
||||
config=config_eos.load, start_datetime=pendulum.datetime(2025, 1, 1, tz="UTC")
|
||||
)
|
||||
responses = [
|
||||
_http({"2025-01-01 12:00:00": 1000.0}),
|
||||
_http({"2025-01-01 12:00:00": 800.0}),
|
||||
]
|
||||
with patch("requests.get", side_effect=responses) as mock_get:
|
||||
body = pv._request_forecast(force_update=True) # type: ignore
|
||||
assert mock_get.call_count == 2
|
||||
assert body["watts"]["2025-01-01 12:00:00"] == 1800.0
|
||||
|
||||
|
||||
def test_update_data_skips_when_disabled(pvforecast_instance, config_eos):
|
||||
config_eos.merge_settings_from_dict({"pvforecast": {"provider": "PVForecastAkkudoktor"}})
|
||||
with patch.object(pvforecast_instance, "_request_forecast") as mock_req, \
|
||||
patch.object(PVForecastForecastSolar, "update_value") as mock_update:
|
||||
pvforecast_instance._update_data()
|
||||
mock_req.assert_not_called()
|
||||
mock_update.assert_not_called()
|
||||
|
||||
|
||||
def test_request_forecast_raises_on_http_error(pvforecast_instance):
|
||||
with patch("requests.get", side_effect=requests.Timeout("timed out")):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
pvforecast_instance._request_forecast(force_update=True)
|
||||
assert "Failed to fetch pvforecast from Forecast.Solar" in str(exc_info.value)
|
||||
145
tests/test_pvforecastpvnode.py
Normal file
145
tests/test_pvforecastpvnode.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pendulum
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pvforecast_instance(config_eos):
|
||||
settings = {
|
||||
"general": {"latitude": 52.5, "longitude": 13.4},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastPVNode",
|
||||
"provider_settings": {
|
||||
"PVForecastPVNode": {
|
||||
"api_key": "dummy-key",
|
||||
"site_id": "test-site-123",
|
||||
"forecast_days": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
config_eos.merge_settings_from_dict(settings)
|
||||
start_dt = pendulum.datetime(2025, 1, 1, tz="Europe/Berlin")
|
||||
return PVForecastPVNode(config=config_eos.load, start_datetime=start_dt)
|
||||
|
||||
|
||||
def mock_v2_body():
|
||||
"""A canonical pvnode V2 response: site-local wall-clock + IANA timezone.
|
||||
|
||||
The second slot carries a null ``pv_power`` (night) which must become 0 W.
|
||||
"""
|
||||
return {
|
||||
"timezone": "Europe/Berlin",
|
||||
"values": [
|
||||
{"timestamp": "2025-01-01T12:00:00", "pv_power": 1200.0},
|
||||
{"timestamp": "2025-01-01T12:15:00", "pv_power": None},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_provider_id(pvforecast_instance):
|
||||
assert PVForecastPVNode.provider_id() == "PVForecastPVNode"
|
||||
assert pvforecast_instance.enabled() is True
|
||||
|
||||
|
||||
def test_extract_values_resolves_local_wall_clock_to_instant(pvforecast_instance):
|
||||
"""V2 timestamps are local wall-clock; 12:00 Europe/Berlin (CET) == 11:00 UTC."""
|
||||
rows = pvforecast_instance._extract_values(mock_v2_body())
|
||||
|
||||
assert len(rows) == 2
|
||||
# Same instant, regardless of representation.
|
||||
assert rows[0][0] == pendulum.datetime(2025, 1, 1, 12, 0, tz="Europe/Berlin")
|
||||
assert rows[0][0].in_timezone("UTC").hour == 11
|
||||
assert rows[0][1] == 1200.0
|
||||
# Null pv_power -> 0 W (not a gap, to avoid phantom night interpolation).
|
||||
assert rows[1][1] == 0.0
|
||||
|
||||
|
||||
def test_extract_values_trusts_explicit_offset(pvforecast_instance):
|
||||
"""A timestamp already carrying an offset is trusted as-is (no double shift)."""
|
||||
body = {
|
||||
"timezone": "Europe/Berlin",
|
||||
"values": [{"timestamp": "2025-01-01T12:00:00+00:00", "pv_power": 500.0}],
|
||||
}
|
||||
rows = pvforecast_instance._extract_values(body)
|
||||
assert rows[0][0].in_timezone("UTC").hour == 12
|
||||
|
||||
|
||||
def test_update_data_sets_ac_and_dc_power(pvforecast_instance):
|
||||
with patch.object(pvforecast_instance, "_request_forecast", return_value=mock_v2_body()), \
|
||||
patch.object(PVForecastPVNode, "update_value") as mock_update:
|
||||
pvforecast_instance._update_data()
|
||||
|
||||
assert mock_update.call_count == 2
|
||||
expected = [
|
||||
call(
|
||||
pendulum.datetime(2025, 1, 1, 12, 0, tz="Europe/Berlin"),
|
||||
{"pvforecast_ac_power": 1200.0, "pvforecast_dc_power": 1200.0},
|
||||
),
|
||||
call(
|
||||
pendulum.datetime(2025, 1, 1, 12, 15, tz="Europe/Berlin"),
|
||||
{"pvforecast_ac_power": 0.0, "pvforecast_dc_power": 0.0},
|
||||
),
|
||||
]
|
||||
mock_update.assert_has_calls(expected, any_order=False)
|
||||
|
||||
|
||||
def test_update_data_skips_when_disabled(pvforecast_instance, config_eos):
|
||||
config_eos.merge_settings_from_dict({"pvforecast": {"provider": "PVForecastAkkudoktor"}})
|
||||
with patch.object(pvforecast_instance, "_request_forecast") as mock_req, \
|
||||
patch.object(PVForecastPVNode, "update_value") as mock_update:
|
||||
pvforecast_instance._update_data()
|
||||
mock_req.assert_not_called()
|
||||
mock_update.assert_not_called()
|
||||
|
||||
|
||||
def test_update_data_skips_on_empty_forecast(pvforecast_instance):
|
||||
with patch.object(pvforecast_instance, "_request_forecast", return_value={"values": []}), \
|
||||
patch.object(PVForecastPVNode, "update_value") as mock_update:
|
||||
pvforecast_instance._update_data()
|
||||
mock_update.assert_not_called()
|
||||
|
||||
|
||||
def test_request_forecast_uses_saved_site_get(pvforecast_instance):
|
||||
"""site_id set -> GET /v2/forecast/{site_id} with Bearer auth."""
|
||||
fake = type("R", (), {"raise_for_status": lambda self: None, "json": lambda self: {"values": []}})()
|
||||
with patch("requests.get", return_value=fake) as mock_get:
|
||||
pvforecast_instance._request_forecast(force_update=True)
|
||||
url = mock_get.call_args[0][0]
|
||||
assert url.endswith("/v2/forecast/test-site-123")
|
||||
assert mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer dummy-key"
|
||||
|
||||
|
||||
def test_request_forecast_inline_post_when_no_site(config_eos):
|
||||
"""No site_id -> POST /v2/forecast/inline with planes geometry."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"general": {"latitude": 52.5, "longitude": 13.4},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastPVNode",
|
||||
"planes": [{"surface_tilt": 30.0, "surface_azimuth": 180.0, "peakpower": 5.0}],
|
||||
"provider_settings": {"PVForecastPVNode": {"api_key": "k", "site_id": None}},
|
||||
},
|
||||
}
|
||||
)
|
||||
pv = PVForecastPVNode(config=config_eos.load, start_datetime=pendulum.datetime(2025, 1, 1, tz="UTC"))
|
||||
fake = type("R", (), {"raise_for_status": lambda self: None, "json": lambda self: {"values": []}})()
|
||||
with patch("requests.post", return_value=fake) as mock_post:
|
||||
# force_update is consumed by the cache_in_file decorator at runtime
|
||||
# (same call convention as pvforecastakkudoktor.py).
|
||||
pv._request_forecast(force_update=True) # type: ignore
|
||||
url = mock_post.call_args[0][0]
|
||||
assert url.endswith("/v2/forecast/inline")
|
||||
body = mock_post.call_args.kwargs["json"]
|
||||
assert body["strings"][0] == {"slope": 30.0, "orientation": 180.0, "power_kw": 5.0}
|
||||
|
||||
|
||||
def test_request_forecast_raises_on_http_error(pvforecast_instance):
|
||||
with patch("requests.get", side_effect=requests.Timeout("timed out")):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
pvforecast_instance._request_forecast(force_update=True)
|
||||
assert "Failed to fetch pvforecast from pvnode" in str(exc_info.value)
|
||||
97
tests/test_pvforecastsolcast.py
Normal file
97
tests/test_pvforecastsolcast.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pendulum
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from akkudoktoreos.prediction.pvforecastsolcast import PVForecastSolcast
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pvforecast_instance(config_eos):
|
||||
settings = {
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastSolcast",
|
||||
"provider_settings": {
|
||||
"PVForecastSolcast": {"api_key": "dummy-key", "site_id": "site-abc"},
|
||||
},
|
||||
},
|
||||
}
|
||||
config_eos.merge_settings_from_dict(settings)
|
||||
start_dt = pendulum.datetime(2025, 1, 1, tz="Europe/Berlin")
|
||||
return PVForecastSolcast(config=config_eos.load, start_datetime=start_dt)
|
||||
|
||||
|
||||
def _http(forecasts):
|
||||
return type(
|
||||
"R",
|
||||
(),
|
||||
{
|
||||
"raise_for_status": lambda self: None,
|
||||
"json": lambda self: {"forecasts": forecasts},
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
def test_provider_id(pvforecast_instance):
|
||||
assert PVForecastSolcast.provider_id() == "PVForecastSolcast"
|
||||
assert pvforecast_instance.enabled() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"period,minutes",
|
||||
[("PT30M", 30), ("PT15M", 15), ("PT5M", 5), ("PT1H", 60), ("PT1H30M", 90), ("", 0), (None, 0)],
|
||||
)
|
||||
def test_period_minutes(period, minutes):
|
||||
assert PVForecastSolcast._period_minutes(period) == minutes
|
||||
|
||||
|
||||
def test_update_data_normalises_to_period_start_and_converts_kw(pvforecast_instance):
|
||||
"""pv_estimate (kW) -> W; timestamp = period_end - period; period_end is UTC."""
|
||||
body = {
|
||||
"forecasts": [
|
||||
{"pv_estimate": 1.2, "period_end": "2025-01-01T12:30:00.0000000Z", "period": "PT30M"},
|
||||
{"pv_estimate": 0.0, "period_end": "2025-01-01T13:00:00.0000000Z", "period": "PT30M"},
|
||||
]
|
||||
}
|
||||
with patch.object(pvforecast_instance, "_request_forecast", return_value=body), \
|
||||
patch.object(PVForecastSolcast, "update_value") as mock_update:
|
||||
pvforecast_instance._update_data()
|
||||
|
||||
assert mock_update.call_count == 2
|
||||
expected = [
|
||||
call(
|
||||
pendulum.datetime(2025, 1, 1, 12, 0, tz="UTC"),
|
||||
{"pvforecast_ac_power": 1200.0, "pvforecast_dc_power": 1200.0},
|
||||
),
|
||||
call(
|
||||
pendulum.datetime(2025, 1, 1, 12, 30, tz="UTC"),
|
||||
{"pvforecast_ac_power": 0.0, "pvforecast_dc_power": 0.0},
|
||||
),
|
||||
]
|
||||
mock_update.assert_has_calls(expected, any_order=False)
|
||||
|
||||
|
||||
def test_request_forecast_uses_site_and_bearer(pvforecast_instance):
|
||||
with patch("requests.get", return_value=_http([])) as mock_get:
|
||||
pvforecast_instance._request_forecast(force_update=True)
|
||||
url = mock_get.call_args[0][0]
|
||||
assert url.endswith("/rooftop_sites/site-abc/forecasts")
|
||||
assert mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer dummy-key"
|
||||
assert mock_get.call_args.kwargs["params"]["format"] == "json"
|
||||
|
||||
|
||||
def test_update_data_skips_when_disabled(pvforecast_instance, config_eos):
|
||||
config_eos.merge_settings_from_dict({"pvforecast": {"provider": "PVForecastAkkudoktor"}})
|
||||
with patch.object(pvforecast_instance, "_request_forecast") as mock_req, \
|
||||
patch.object(PVForecastSolcast, "update_value") as mock_update:
|
||||
pvforecast_instance._update_data()
|
||||
mock_req.assert_not_called()
|
||||
mock_update.assert_not_called()
|
||||
|
||||
|
||||
def test_request_forecast_raises_on_http_error(pvforecast_instance):
|
||||
with patch("requests.get", side_effect=requests.Timeout("timed out")):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
pvforecast_instance._request_forecast(force_update=True)
|
||||
assert "Failed to fetch pvforecast from Solcast" in str(exc_info.value)
|
||||
Reference in New Issue
Block a user