mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-21 01:08:12 +00:00
feat: add cloud PV forecast providers: pvnode.com, forecast-solar and solcast (#1150)
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
This PR adds three native PV power forecast providers, giving operators more
cloud forecast sources to choose from via pvforecast.provider, alongside the
existing PVForecastAkkudoktor, PVForecastVrm and PVForecastImport:
PVForecastPVNode — native 15-minute forecasts from the pvnode.com
V2 API. Saved-site mode (GET /v2/forecast/{site_id}) where the operator enters their API key +
site id, or inline mode (POST /v2/forecast/inline) using the configured planes.
PVForecastForecastSolar — the free Forecast.Solar API (no key
required for the public endpoint). Multi-plane systems issue one request per plane and the
instantaneous powers are summed per timestamp.
PVForecastSolcast — the Solcast rooftop-site API (API key + resource id).
Implementation notes
All three populate the existing pvforecast_ac_power (and mirror pvforecast_dc_power)
prediction keys, so they slot into the optimizer unchanged.
Timezone handling: pvnode and Forecast.Solar return site-local wall-clock timestamps with an
IANA timezone field, which are resolved to absolute instants before resampling; Solcast returns
UTC period_end and is normalised to the period start.
Each provider follows the existing provider pattern (provider_id(), _request_forecast() with
@cache_in_file, _update_data()), is registered in pvforecast.py and prediction.py, and is
upstream-neutral.
Validation
27 unit tests (timezone resolution, null/zero handling, kW→W and period-start conversion,
azimuth conversion, multi-plane summation, request URL/auth, HTTP-error handling).
ruff check (F/D/S/bandit) and ruff format clean.
Each provider was additionally validated against its live API with a real plant, confirming
the response shapes (pvnode: 288 native 15-min slots; Forecast.Solar: instantaneous watts;
Solcast: kW estimates with period_end/PT30M).
Documentation
Provider descriptions and configuration examples added to docs/akkudoktoreos/prediction.md.
CHANGELOG.md entry under Unreleased.
Regenerated docs/_generated/configpvforecast.md and configexample.md.
Notes for reviewers
Forecast.Solar's free endpoint is rate-limited (12 req/hour) and Solcast's free tier limits daily
calls; both providers rely on the standard 1-hour cache_in_file TTL to stay within budget.
Authors:
The code is created by Christin. Only minor adaptions by Bobby.
Signed-off-by: Christin <info@bikinibottom.capital>
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
Co-authored-by: Christin <info@bikinibottom.capital>
This commit is contained in:
@@ -19,7 +19,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
|
||||
@@ -49,6 +52,9 @@ def forecast_providers():
|
||||
LoadImport(),
|
||||
PVForecastAkkudoktor(),
|
||||
PVForecastVrm(),
|
||||
PVForecastPVNode(),
|
||||
PVForecastForecastSolar(),
|
||||
PVForecastSolcast(),
|
||||
PVForecastImport(),
|
||||
WeatherBrightSky(),
|
||||
WeatherClearOutside(),
|
||||
@@ -98,11 +104,14 @@ def test_provider_sequence(prediction):
|
||||
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[12], PVForecastPVNode)
|
||||
assert isinstance(prediction.providers[13], PVForecastForecastSolar)
|
||||
assert isinstance(prediction.providers[14], PVForecastSolcast)
|
||||
assert isinstance(prediction.providers[15], PVForecastImport)
|
||||
assert isinstance(prediction.providers[16], WeatherBrightSky)
|
||||
assert isinstance(prediction.providers[17], WeatherClearOutside)
|
||||
assert isinstance(prediction.providers[18], WeatherOpenMeteo)
|
||||
assert isinstance(prediction.providers[19], WeatherImport)
|
||||
|
||||
|
||||
def test_provider_by_id(prediction, forecast_providers):
|
||||
|
||||
130
tests/test_pvforecastforecastsolar.py
Normal file
130
tests/test_pvforecastforecastsolar.py
Normal file
@@ -0,0 +1,130 @@
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async 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:
|
||||
await 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)
|
||||
146
tests/test_pvforecastpvnode.py
Normal file
146
tests/test_pvforecastpvnode.py
Normal file
@@ -0,0 +1,146 @@
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async 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:
|
||||
await 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)
|
||||
98
tests/test_pvforecastsolcast.py
Normal file
98
tests/test_pvforecastsolcast.py
Normal file
@@ -0,0 +1,98 @@
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async 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:
|
||||
await 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)
|
||||
@@ -187,7 +187,10 @@
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"provider_settings": {
|
||||
"PVForecastImport": null,
|
||||
"PVForecastVrm": null
|
||||
"PVForecastVrm": null,
|
||||
"PVForecastPVNode": null,
|
||||
"PVForecastForecastSolar": null,
|
||||
"PVForecastSolcast": null
|
||||
},
|
||||
"planes": [
|
||||
{
|
||||
|
||||
120
tests/testdata/docs/_generated/configpvforecast.md
vendored
120
tests/testdata/docs/_generated/configpvforecast.md
vendored
@@ -31,7 +31,10 @@
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"provider_settings": {
|
||||
"PVForecastImport": null,
|
||||
"PVForecastVrm": null
|
||||
"PVForecastVrm": null,
|
||||
"PVForecastPVNode": null,
|
||||
"PVForecastForecastSolar": null,
|
||||
"PVForecastSolcast": null
|
||||
},
|
||||
"planes": [
|
||||
{
|
||||
@@ -96,7 +99,10 @@
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"provider_settings": {
|
||||
"PVForecastImport": null,
|
||||
"PVForecastVrm": null
|
||||
"PVForecastVrm": null,
|
||||
"PVForecastPVNode": null,
|
||||
"PVForecastForecastSolar": null,
|
||||
"PVForecastSolcast": null
|
||||
},
|
||||
"planes": [
|
||||
{
|
||||
@@ -148,6 +154,9 @@
|
||||
"providers": [
|
||||
"PVForecastAkkudoktor",
|
||||
"PVForecastVrm",
|
||||
"PVForecastPVNode",
|
||||
"PVForecastForecastSolar",
|
||||
"PVForecastSolcast",
|
||||
"PVForecastImport"
|
||||
],
|
||||
"planes_peakpower": [
|
||||
@@ -183,6 +192,105 @@
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Common settings for the Solcast PV forecast provider
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} pvforecast::provider_settings::PVForecastSolcast
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| api_key | `str` | `rw` | `` | Solcast API key (Bearer auth). Required. |
|
||||
| site_id | `str` | `rw` | `` | Solcast rooftop site (resource) id. Required. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"pvforecast": {
|
||||
"provider_settings": {
|
||||
"PVForecastSolcast": {
|
||||
"api_key": "your-solcast-key",
|
||||
"site_id": "abcd-1234-efgh-5678"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Common settings for the Forecast.Solar PV forecast provider
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} pvforecast::provider_settings::PVForecastForecastSolar
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| api_key | `Optional[str]` | `rw` | `None` | Forecast.Solar API key. Optional — the public endpoint works without a key (lower rate limit). |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"pvforecast": {
|
||||
"provider_settings": {
|
||||
"PVForecastForecastSolar": {
|
||||
"api_key": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Common settings for the pvnode.com PV forecast provider
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} pvforecast::provider_settings::PVForecastPVNode
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| api_key | `str` | `rw` | `` | pvnode.com API key (Bearer auth). Required. |
|
||||
| forecast_days | `int` | `rw` | `2` | Forecast horizon in days (1-7, capped by the pvnode plan). |
|
||||
| site_id | `Optional[str]` | `rw` | `None` | pvnode.com site id of the saved plant ('Anlagen-ID'). When set, the saved (possibly calibrated) site is used. Leave empty to send the configured pvforecast.planes inline instead. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"pvforecast": {
|
||||
"provider_settings": {
|
||||
"PVForecastPVNode": {
|
||||
"api_key": "pvn_live_xxxxxxxxxxxxxxxx",
|
||||
"site_id": "abcd-1234",
|
||||
"forecast_days": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Common settings for PV forecast VRM API
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
@@ -258,7 +366,10 @@
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| PVForecastForecastSolar | `Optional[akkudoktoreos.prediction.pvforecastforecastsolar.PVForecastForecastSolarCommonSettings]` | `rw` | `None` | PVForecastForecastSolar settings |
|
||||
| PVForecastImport | `Optional[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings]` | `rw` | `None` | PVForecastImport settings |
|
||||
| PVForecastPVNode | `Optional[akkudoktoreos.prediction.pvforecastpvnode.PVForecastPVNodeCommonSettings]` | `rw` | `None` | PVForecastPVNode settings |
|
||||
| PVForecastSolcast | `Optional[akkudoktoreos.prediction.pvforecastsolcast.PVForecastSolcastCommonSettings]` | `rw` | `None` | PVForecastSolcast settings |
|
||||
| PVForecastVrm | `Optional[akkudoktoreos.prediction.pvforecastvrm.PVForecastVrmCommonSettings]` | `rw` | `None` | PVForecastVrm settings |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
@@ -273,7 +384,10 @@
|
||||
"pvforecast": {
|
||||
"provider_settings": {
|
||||
"PVForecastImport": null,
|
||||
"PVForecastVrm": null
|
||||
"PVForecastVrm": null,
|
||||
"PVForecastPVNode": null,
|
||||
"PVForecastForecastSolar": null,
|
||||
"PVForecastSolcast": null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user