feat: add cloud PV forecast providers: pvnode.com, forecast-solar and solcast (#1150)
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>
2026-07-17 16:51:00 +02:00
|
|
|
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",
|
2026-07-20 14:37:03 +02:00
|
|
|
"solcast": {"api_key": "dummy-key", "site_id": "site-abc"},
|
feat: add cloud PV forecast providers: pvnode.com, forecast-solar and solcast (#1150)
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>
2026-07-17 16:51:00 +02:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
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)
|