Files
EOS/tests/test_pvforecastsolcast.py
Bobby Noelte 75548990e1
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
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

99 lines
3.6 KiB
Python

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)