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.
This commit is contained in:
Christin
2026-06-28 03:06:55 +00:00
parent a1e2100206
commit cec9e35aa9
4 changed files with 250 additions and 0 deletions

View File

@@ -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,

View File

@@ -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):

View File

@@ -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()

View File

@@ -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)