From cec9e35aa90be08f6260174ecc468cdc86313869 Mon Sep 17 00:00:00 2001 From: Christin Date: Sun, 28 Jun 2026 03:06:55 +0000 Subject: [PATCH] feat(prediction): add Solcast PV forecast provider Add PVForecastSolcast for the Solcast rooftop-site API. The operator registers a site in the Solcast web app and enters the API key + resource (site) id: GET /rooftop_sites/{site_id}/forecasts. pv_estimate (kW) is converted to watts and fed as pvforecast_ac_power; the timestamp is normalised to the period start (period_end - period) so it aligns with the resample axis. period_end is UTC. Completes the set of selectable cloud PV forecast providers (Akkudoktor, VRM, Import, pvnode, Forecast.Solar, Solcast). Adds tests for the kW->W conversion, period-start normalisation, ISO-8601 period parsing, the request URL/auth and HTTP-error handling. --- src/akkudoktoreos/prediction/prediction.py | 6 + src/akkudoktoreos/prediction/pvforecast.py | 6 + .../prediction/pvforecastsolcast.py | 141 ++++++++++++++++++ tests/test_pvforecastsolcast.py | 97 ++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 src/akkudoktoreos/prediction/pvforecastsolcast.py create mode 100644 tests/test_pvforecastsolcast.py diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index 75ea60a..2b26552 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -48,6 +48,7 @@ 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 @@ -88,6 +89,7 @@ pvforecast_akkudoktor = PVForecastAkkudoktor() pvforecast_vrm = PVForecastVrm() pvforecast_pvnode = PVForecastPVNode() pvforecast_forecastsolar = PVForecastForecastSolar() +pvforecast_solcast = PVForecastSolcast() pvforecast_import = PVForecastImport() weather_brightsky = WeatherBrightSky() weather_clearoutside = WeatherClearOutside() @@ -111,6 +113,7 @@ def prediction_providers() -> list[ PVForecastVrm, PVForecastPVNode, PVForecastForecastSolar, + PVForecastSolcast, PVForecastImport, WeatherBrightSky, WeatherClearOutside, @@ -137,6 +140,7 @@ def prediction_providers() -> list[ pvforecast_vrm, \ pvforecast_pvnode, \ pvforecast_forecastsolar, \ + pvforecast_solcast, \ pvforecast_import, \ weather_brightsky, \ weather_clearoutside, \ @@ -159,6 +163,7 @@ def prediction_providers() -> list[ pvforecast_vrm, pvforecast_pvnode, pvforecast_forecastsolar, + pvforecast_solcast, pvforecast_import, weather_brightsky, weather_clearoutside, @@ -186,6 +191,7 @@ class Prediction(PredictionContainer): PVForecastVrm, PVForecastPVNode, PVForecastForecastSolar, + PVForecastSolcast, PVForecastImport, WeatherBrightSky, WeatherClearOutside, diff --git a/src/akkudoktoreos/prediction/pvforecast.py b/src/akkudoktoreos/prediction/pvforecast.py index 1b95a5c..61381e7 100644 --- a/src/akkudoktoreos/prediction/pvforecast.py +++ b/src/akkudoktoreos/prediction/pvforecast.py @@ -12,6 +12,7 @@ from akkudoktoreos.prediction.pvforecastforecastsolar import ( ) from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNodeCommonSettings +from akkudoktoreos.prediction.pvforecastsolcast import PVForecastSolcastCommonSettings from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrmCommonSettings @@ -28,6 +29,7 @@ def pvforecast_provider_ids() -> list[str]: "PVForecastVrm", "PVForecastPVNode", "PVForecastForecastSolar", + "PVForecastSolcast", ] return [ @@ -197,6 +199,10 @@ class PVForecastCommonProviderSettings(SettingsBaseModel): default=None, json_schema_extra={"description": "PVForecastForecastSolar settings", "examples": [None]}, ) + PVForecastSolcast: Optional[PVForecastSolcastCommonSettings] = Field( + default=None, + json_schema_extra={"description": "PVForecastSolcast settings", "examples": [None]}, + ) class PVForecastCommonSettings(SettingsBaseModel): diff --git a/src/akkudoktoreos/prediction/pvforecastsolcast.py b/src/akkudoktoreos/prediction/pvforecastsolcast.py new file mode 100644 index 0000000..01a2f99 --- /dev/null +++ b/src/akkudoktoreos/prediction/pvforecastsolcast.py @@ -0,0 +1,141 @@ +"""Retrieves PV forecast data from the Solcast API. + +Solcast (https://solcast.com) provides high-accuracy PV forecasts for a rooftop +site that the operator registers in the Solcast web app. The operator enters the +API key + the rooftop resource id (site id): + + GET https://api.solcast.com.au/rooftop_sites/{site_id}/forecasts?format=json&hours=72 + +Each forecast row carries ``pv_estimate`` (in kW) and ``period_end`` (UTC) plus +an ISO-8601 ``period`` duration. The estimate is converted to watts and the +timestamp is normalised to the period START (``period_end - period``) so it sits +on the same axis EOS resamples onto. + +Notes: + - Solcast's free tier limits the number of calls per day; the response is + cached (1 hour TTL) to stay within budget. +""" + +import re +from typing import Any, Optional + +import requests +from loguru import logger +from pydantic import Field + +from akkudoktoreos.config.configabc import SettingsBaseModel +from akkudoktoreos.core.cache import cache_in_file +from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider +from akkudoktoreos.utils.datetimeutil import to_datetime + +SOLCAST_BASE = "https://api.solcast.com.au/rooftop_sites" + +_PERIOD_RE = re.compile(r"^PT(?:(\d+)H)?(?:(\d+)M)?$") + + +class PVForecastSolcastCommonSettings(SettingsBaseModel): + """Common settings for the Solcast PV forecast provider.""" + + api_key: str = Field( + default="", + json_schema_extra={ + "description": "Solcast API key (Bearer auth). Required.", + "examples": ["your-solcast-key"], + }, + ) + site_id: str = Field( + default="", + json_schema_extra={ + "description": "Solcast rooftop site (resource) id. Required.", + "examples": ["abcd-1234-efgh-5678"], + }, + ) + + +class PVForecastSolcast(PVForecastProvider): + """Fetch and process PV forecast data from the Solcast API.""" + + @classmethod + def provider_id(cls) -> str: + """Return the unique identifier for the PV-Forecast-Provider.""" + return "PVForecastSolcast" + + @property + def _settings(self) -> PVForecastSolcastCommonSettings: + settings = self.config.pvforecast.provider_settings.PVForecastSolcast + if settings is None: + settings = PVForecastSolcastCommonSettings() + return settings + + @staticmethod + def _period_minutes(period: Optional[str]) -> int: + """Parse an ISO-8601 period like 'PT30M' or 'PT1H' into minutes (0 if unknown).""" + match = _PERIOD_RE.match(str(period or "")) + if not match: + return 0 + hours = int(match.group(1) or 0) + minutes = int(match.group(2) or 0) + return hours * 60 + minutes + + @cache_in_file(with_ttl="1 hour") + def _request_forecast(self) -> Any: + """Fetch the rooftop-site forecast from Solcast.""" + settings = self._settings + if not settings.api_key or not settings.site_id: + raise ValueError("PVForecastSolcast requires api_key and site_id") + + url = f"{SOLCAST_BASE}/{requests.utils.quote(settings.site_id, safe='')}/forecasts" + params = {"format": "json", "hours": "72"} + headers = {"Authorization": f"Bearer {settings.api_key}", "Accept": "application/json"} + logger.debug(f"Requesting Solcast forecast: {url}") + try: + response = requests.get(url, headers=headers, params=params, timeout=30) + response.raise_for_status() + except requests.RequestException as e: + logger.error(f"Failed to fetch pvforecast from Solcast: {e}") + raise RuntimeError("Failed to fetch pvforecast from Solcast API") from e + + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) + return response.json() + + def _update_data(self, force_update: Optional[bool] = False) -> None: + """Update forecast data in the PVForecastDataRecord format.""" + if not self.enabled(): + logger.info("PVForecastSolcast is disabled, skipping update.") + return + + body = self._request_forecast(force_update=force_update) # type: ignore[call-arg] + forecasts = body.get("forecasts", []) if isinstance(body, dict) else [] + + count = 0 + for entry in forecasts: + if not isinstance(entry, dict): + continue + estimate_kw = entry.get("pv_estimate") + if estimate_kw is None: + estimate_kw = entry.get("pv_estimate_period") + period_end = entry.get("period_end") + if estimate_kw is None or period_end is None: + continue + try: + end = to_datetime(period_end) + except Exception as e: # noqa: BLE001 - skip unparseable rows + logger.warning(f"Solcast: skipping unparseable period_end {period_end!r}: {e}") + continue + minutes = self._period_minutes(entry.get("period")) + date = end.subtract(minutes=minutes) if minutes else end + power_w = round(float(estimate_kw) * 1000.0, 1) + self.update_value( + date, + {"pvforecast_ac_power": power_w, "pvforecast_dc_power": power_w}, + ) + count += 1 + + logger.debug(f"Updated pvforecast from Solcast with {count} entries.") + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) + + +# Example usage +if __name__ == "__main__": + pv = PVForecastSolcast() + pv._update_data() diff --git a/tests/test_pvforecastsolcast.py b/tests/test_pvforecastsolcast.py new file mode 100644 index 0000000..eedfc69 --- /dev/null +++ b/tests/test_pvforecastsolcast.py @@ -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)