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

@@ -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 {

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

View File

@@ -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

View 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)

View 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)

View 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)