mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-12 21:08:13 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
141
src/akkudoktoreos/prediction/pvforecastsolcast.py
Normal file
141
src/akkudoktoreos/prediction/pvforecastsolcast.py
Normal 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()
|
||||
97
tests/test_pvforecastsolcast.py
Normal file
97
tests/test_pvforecastsolcast.py
Normal 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)
|
||||
Reference in New Issue
Block a user