Files
EOS/tests/test_pvforecastforecastsolar.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

131 lines
4.9 KiB
Python

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)