From 314e45cfaae04183981bdbf8f327fb41fcbb9da9 Mon Sep 17 00:00:00 2001 From: Christin Date: Sun, 28 Jun 2026 02:52:47 +0000 Subject: [PATCH 1/6] feat(prediction): add native pvnode.com PV forecast provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PVForecastPVNode, a native 15-minute PV forecast provider for the pvnode.com V2 API, giving operators another forecast source to choose from alongside Akkudoktor, VRM and Import. Two request modes, selected by configuration: * site_id set -> GET /v2/forecast/{site_id} (a saved, possibly calibrated site managed on pvnode.com — the operator enters site id + API key) * site_id empty -> POST /v2/forecast/inline (geometry sent inline from the configured pvforecast.planes; no web-app setup required) V2 response timestamps are site-local wall-clock accompanied by an IANA timezone; they are resolved to absolute instants before EOS resamples them. Nullable pv_power (e.g. at night) is treated as 0 W so the optimizer's linear resampling does not interpolate phantom production across the night. Registered in pvforecast.py (provider settings + id list) and prediction.py (singleton, factory list, container union). Adds tests covering timezone resolution, null handling, both request modes and HTTP-error propagation. --- src/akkudoktoreos/prediction/prediction.py | 6 + src/akkudoktoreos/prediction/pvforecast.py | 7 +- .../prediction/pvforecastpvnode.py | 241 ++++++++++++++++++ tests/test_pvforecastpvnode.py | 143 +++++++++++ 4 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/akkudoktoreos/prediction/pvforecastpvnode.py create mode 100644 tests/test_pvforecastpvnode.py diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index 9fe6053..85ee870 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -46,6 +46,7 @@ from akkudoktoreos.prediction.loadvrm import LoadVrm from akkudoktoreos.prediction.predictionabc import PredictionContainer from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor from akkudoktoreos.prediction.pvforecastimport import PVForecastImport +from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside @@ -84,6 +85,7 @@ loadforecast_vrm = LoadVrm() loadforecast_import = LoadImport() pvforecast_akkudoktor = PVForecastAkkudoktor() pvforecast_vrm = PVForecastVrm() +pvforecast_pvnode = PVForecastPVNode() pvforecast_import = PVForecastImport() weather_brightsky = WeatherBrightSky() weather_clearoutside = WeatherClearOutside() @@ -105,6 +107,7 @@ def prediction_providers() -> list[ LoadImport, PVForecastAkkudoktor, PVForecastVrm, + PVForecastPVNode, PVForecastImport, WeatherBrightSky, WeatherClearOutside, @@ -129,6 +132,7 @@ def prediction_providers() -> list[ loadforecast_import, \ pvforecast_akkudoktor, \ pvforecast_vrm, \ + pvforecast_pvnode, \ pvforecast_import, \ weather_brightsky, \ weather_clearoutside, \ @@ -149,6 +153,7 @@ def prediction_providers() -> list[ loadforecast_import, pvforecast_akkudoktor, pvforecast_vrm, + pvforecast_pvnode, pvforecast_import, weather_brightsky, weather_clearoutside, @@ -174,6 +179,7 @@ class Prediction(PredictionContainer): LoadImport, PVForecastAkkudoktor, PVForecastVrm, + PVForecastPVNode, PVForecastImport, WeatherBrightSky, WeatherClearOutside, diff --git a/src/akkudoktoreos/prediction/pvforecast.py b/src/akkudoktoreos/prediction/pvforecast.py index f0ddb42..288c79e 100644 --- a/src/akkudoktoreos/prediction/pvforecast.py +++ b/src/akkudoktoreos/prediction/pvforecast.py @@ -8,6 +8,7 @@ from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings +from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNodeCommonSettings from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrmCommonSettings @@ -18,7 +19,7 @@ def pvforecast_provider_ids() -> list[str]: except: # Prediction may not be initialized # Return at least provider used in example - return ["PVForecastAkkudoktor", "PVForecastImport", "PVForecastVrm"] + return ["PVForecastAkkudoktor", "PVForecastImport", "PVForecastVrm", "PVForecastPVNode"] return [ provider.provider_id() @@ -179,6 +180,10 @@ class PVForecastCommonProviderSettings(SettingsBaseModel): default=None, json_schema_extra={"description": "PVForecastVrm settings", "examples": [None]}, ) + PVForecastPVNode: Optional[PVForecastPVNodeCommonSettings] = Field( + default=None, + json_schema_extra={"description": "PVForecastPVNode settings", "examples": [None]}, + ) class PVForecastCommonSettings(SettingsBaseModel): diff --git a/src/akkudoktoreos/prediction/pvforecastpvnode.py b/src/akkudoktoreos/prediction/pvforecastpvnode.py new file mode 100644 index 0000000..4e3bd03 --- /dev/null +++ b/src/akkudoktoreos/prediction/pvforecastpvnode.py @@ -0,0 +1,241 @@ +"""Retrieves PV forecast data from the pvnode.com V2 API. + +pvnode.com delivers native 15-minute PV power forecasts. Two request modes, +decided by configuration: + +* ``site_id`` set -> ``GET /v2/forecast/{site_id}`` — a saved (and possibly + calibrated) site managed in the pvnode web app. This is the operator's primary + path: register the plant once on pvnode.com, then enter the site id + API key. +* ``site_id`` empty -> ``POST /v2/forecast/inline`` — geometry is sent inline from + the configured ``pvforecast.planes`` (works without any web-app setup). + +V2 response timestamps are SITE-LOCAL wall-clock (no offset) accompanied by an +IANA ``timezone`` field. We resolve them to absolute instants here so the rest of +EOS keeps working in its own timezone. ``pv_power`` is nullable (e.g. at night) — +null is treated as 0 W so the optimizer's linear resampling does not interpolate +phantom production across the night. + +Notes: + - Requires ``pvforecast.provider_settings.PVForecastPVNode.api_key`` (Bearer auth). + - API: https://api.pvnode.com/v2 (15-minute resolution). +""" + +import re +from typing import Any, Optional + +import pendulum +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 + +PVNODE_BASE = "https://api.pvnode.com/v2" + +_TZ_SUFFIX = re.compile(r"([zZ]|[+-]\d\d:?\d\d)$") + + +class PVForecastPVNodeCommonSettings(SettingsBaseModel): + """Common settings for the pvnode.com PV forecast provider.""" + + api_key: str = Field( + default="", + json_schema_extra={ + "description": "pvnode.com API key (Bearer auth). Required.", + "examples": ["pvn_live_xxxxxxxxxxxxxxxx"], + }, + ) + site_id: Optional[str] = Field( + default=None, + json_schema_extra={ + "description": ( + "pvnode.com site id of the saved plant ('Anlagen-ID'). When set, the " + "saved (possibly calibrated) site is used. Leave empty to send the " + "configured pvforecast.planes inline instead." + ), + "examples": ["abcd-1234"], + }, + ) + forecast_days: int = Field( + default=2, + ge=1, + le=7, + json_schema_extra={ + "description": "Forecast horizon in days (1-7, capped by the pvnode plan).", + "examples": [2], + }, + ) + + +class PVForecastPVNode(PVForecastProvider): + """Fetch and process PV forecast data from the pvnode.com V2 API.""" + + @classmethod + def provider_id(cls) -> str: + """Return the unique identifier for the PV-Forecast-Provider.""" + return "PVForecastPVNode" + + @property + def _settings(self) -> PVForecastPVNodeCommonSettings: + settings = self.config.pvforecast.provider_settings.PVForecastPVNode + if settings is None: + settings = PVForecastPVNodeCommonSettings() + return settings + + def _to_utc_datetime(self, local_ts: Any, iana_tz: Optional[str]) -> Any: + """Resolve a pvnode V2 wall-clock timestamp to a timezone-aware datetime. + + V2 timestamps are local wall-clock without offset (e.g. "2026-06-22T14:00:00") + plus a response-level IANA ``timezone``. If the string already carries an + explicit offset or 'Z' it is trusted as-is. + """ + s = str(local_ts).strip() + if _TZ_SUFFIX.search(s): + # Already absolute (offset or Z present) — parse as-is. + return to_datetime(s) + tz = iana_tz or str(self.config.general.timezone) + # Interpret the naive wall-clock string AS local time in tz, then resolve. + dt = pendulum.parse(s, tz=tz) + return to_datetime(dt.isoformat()) + + def _extract_values(self, body: Any) -> list[tuple[Any, float]]: + """Extract (datetime, power_w) rows from a pvnode V2 response body. + + Canonical shape: ``{"timezone": ..., "values": [{"timestamp", "pv_power"}, ...]}``. + Tolerant of edge/legacy shapes (mirrors the production DVhub client). + """ + tz: Optional[str] = None + arr: Any = None + if isinstance(body, list): + arr = body + elif isinstance(body, dict): + tz = body.get("timezone") if isinstance(body.get("timezone"), str) else None + for key in ("values", "forecasts", "data", "forecast"): + if isinstance(body.get(key), list): + arr = body[key] + break + if not isinstance(arr, list): + return [] + + rows: list[tuple[Any, float]] = [] + for entry in arr: + if not isinstance(entry, dict): + continue + ts = ( + entry.get("timestamp") + or entry.get("time") + or entry.get("ts") + or entry.get("ts_utc") + or entry.get("datetime") + ) + if ts is None: + continue + # pv_power is nullable (night) -> treat missing as 0 W, not a gap, so the + # optimizer's linear resampling does not interpolate across the night. + raw_power = entry.get("pv_power") + if raw_power is None: + raw_power = entry.get("power_w") + if raw_power is None: + raw_power = entry.get("power") + if raw_power is None: + raw_power = entry.get("watts") + power = 0.0 if raw_power is None else float(raw_power) + try: + date = self._to_utc_datetime(ts, tz) + except Exception as e: # noqa: BLE001 - skip unparseable rows + logger.warning(f"pvnode: skipping unparseable timestamp {ts!r}: {e}") + continue + rows.append((date, round(power, 1))) + return rows + + @cache_in_file(with_ttl="1 hour") + def _request_forecast(self) -> Any: + """Fetch the PV forecast from pvnode.com (saved site or inline planes).""" + settings = self._settings + api_key = settings.api_key + if not api_key: + raise ValueError("PVForecastPVNode requires pvforecast...PVForecastPVNode.api_key") + + headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json"} + params = {"forecast_days": str(settings.forecast_days)} + site_id = (settings.site_id or "").strip() + + try: + if site_id: + url = f"{PVNODE_BASE}/forecast/{requests.utils.quote(site_id, safe='')}" + response = requests.get(url, headers=headers, params=params, timeout=30) + else: + body = self._inline_body() + url = f"{PVNODE_BASE}/forecast/inline" + headers["Content-Type"] = "application/json" + response = requests.post(url, headers=headers, params=params, json=body, timeout=30) + logger.debug(f"Requesting pvnode forecast: {url}") + response.raise_for_status() + except requests.RequestException as e: + logger.error(f"Failed to fetch pvforecast from pvnode: {e}") + raise RuntimeError("Failed to fetch pvforecast from pvnode API") from e + + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) + return response.json() + + def _inline_body(self) -> dict: + """Build the inline-mode request body from latitude/longitude + planes.""" + latitude = self.config.general.latitude + longitude = self.config.general.longitude + if latitude is None or longitude is None: + raise ValueError( + "PVForecastPVNode inline mode needs general.latitude/longitude " + "(or set pvforecast...PVForecastPVNode.site_id)" + ) + planes = self.config.pvforecast.planes or [] + strings = [] + for plane in planes: + tilt = getattr(plane, "surface_tilt", None) + azimuth = getattr(plane, "surface_azimuth", None) + peakpower = getattr(plane, "peakpower", None) + if peakpower is None or tilt is None or azimuth is None: + continue + strings.append( + { + "slope": float(tilt), + # pvnode V2 azimuth convention (0=N, 90=E, 180=S, 270=W) matches + # EOS surface_azimuth, so it is forwarded unchanged. + "orientation": float(azimuth), + "power_kw": float(peakpower), + } + ) + if not strings: + raise ValueError( + "PVForecastPVNode inline mode needs at least one pvforecast.planes " + "entry with peakpower, surface_tilt and surface_azimuth" + ) + return {"latitude": float(latitude), "longitude": float(longitude), "strings": strings} + + def _update_data(self, force_update: Optional[bool] = False) -> None: + """Update forecast data in the PVForecastDataRecord format.""" + if not self.enabled(): + logger.info("PVForecastPVNode is disabled, skipping update.") + return + + body = self._request_forecast(force_update=force_update) # type: ignore[call-arg] + rows = self._extract_values(body) + + for date, power_w in rows: + # pvnode returns the plant's expected output power; feed it as AC power + # (the key the optimizer reads) and mirror it to DC for reporting. + self.update_value( + date, + {"pvforecast_ac_power": power_w, "pvforecast_dc_power": power_w}, + ) + + logger.debug(f"Updated pvforecast from pvnode with {len(rows)} entries.") + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) + + +# Example usage +if __name__ == "__main__": + pv = PVForecastPVNode() + pv._update_data() diff --git a/tests/test_pvforecastpvnode.py b/tests/test_pvforecastpvnode.py new file mode 100644 index 0000000..351fd61 --- /dev/null +++ b/tests/test_pvforecastpvnode.py @@ -0,0 +1,143 @@ +from unittest.mock import call, patch + +import pendulum +import pytest +import requests + +from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode + + +@pytest.fixture +def pvforecast_instance(config_eos): + settings = { + "general": {"latitude": 52.5, "longitude": 13.4}, + "pvforecast": { + "provider": "PVForecastPVNode", + "provider_settings": { + "PVForecastPVNode": { + "api_key": "dummy-key", + "site_id": "test-site-123", + "forecast_days": 2, + }, + }, + }, + } + config_eos.merge_settings_from_dict(settings) + start_dt = pendulum.datetime(2025, 1, 1, tz="Europe/Berlin") + return PVForecastPVNode(config=config_eos.load, start_datetime=start_dt) + + +def mock_v2_body(): + """A canonical pvnode V2 response: site-local wall-clock + IANA timezone. + + The second slot carries a null ``pv_power`` (night) which must become 0 W. + """ + return { + "timezone": "Europe/Berlin", + "values": [ + {"timestamp": "2025-01-01T12:00:00", "pv_power": 1200.0}, + {"timestamp": "2025-01-01T12:15:00", "pv_power": None}, + ], + } + + +def test_provider_id(pvforecast_instance): + assert PVForecastPVNode.provider_id() == "PVForecastPVNode" + assert pvforecast_instance.enabled() is True + + +def test_extract_values_resolves_local_wall_clock_to_instant(pvforecast_instance): + """V2 timestamps are local wall-clock; 12:00 Europe/Berlin (CET) == 11:00 UTC.""" + rows = pvforecast_instance._extract_values(mock_v2_body()) + + assert len(rows) == 2 + # Same instant, regardless of representation. + assert rows[0][0] == pendulum.datetime(2025, 1, 1, 12, 0, tz="Europe/Berlin") + assert rows[0][0].in_timezone("UTC").hour == 11 + assert rows[0][1] == 1200.0 + # Null pv_power -> 0 W (not a gap, to avoid phantom night interpolation). + assert rows[1][1] == 0.0 + + +def test_extract_values_trusts_explicit_offset(pvforecast_instance): + """A timestamp already carrying an offset is trusted as-is (no double shift).""" + body = { + "timezone": "Europe/Berlin", + "values": [{"timestamp": "2025-01-01T12:00:00+00:00", "pv_power": 500.0}], + } + rows = pvforecast_instance._extract_values(body) + assert rows[0][0].in_timezone("UTC").hour == 12 + + +def test_update_data_sets_ac_and_dc_power(pvforecast_instance): + with patch.object(pvforecast_instance, "_request_forecast", return_value=mock_v2_body()), \ + patch.object(PVForecastPVNode, "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="Europe/Berlin"), + {"pvforecast_ac_power": 1200.0, "pvforecast_dc_power": 1200.0}, + ), + call( + pendulum.datetime(2025, 1, 1, 12, 15, tz="Europe/Berlin"), + {"pvforecast_ac_power": 0.0, "pvforecast_dc_power": 0.0}, + ), + ] + mock_update.assert_has_calls(expected, any_order=False) + + +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(PVForecastPVNode, "update_value") as mock_update: + pvforecast_instance._update_data() + mock_req.assert_not_called() + mock_update.assert_not_called() + + +def test_update_data_skips_on_empty_forecast(pvforecast_instance): + with patch.object(pvforecast_instance, "_request_forecast", return_value={"values": []}), \ + patch.object(PVForecastPVNode, "update_value") as mock_update: + pvforecast_instance._update_data() + mock_update.assert_not_called() + + +def test_request_forecast_uses_saved_site_get(pvforecast_instance): + """site_id set -> GET /v2/forecast/{site_id} with Bearer auth.""" + fake = type("R", (), {"raise_for_status": lambda self: None, "json": lambda self: {"values": []}})() + with patch("requests.get", return_value=fake) as mock_get: + pvforecast_instance._request_forecast(force_update=True) + url = mock_get.call_args[0][0] + assert url.endswith("/v2/forecast/test-site-123") + assert mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer dummy-key" + + +def test_request_forecast_inline_post_when_no_site(config_eos): + """No site_id -> POST /v2/forecast/inline with planes geometry.""" + config_eos.merge_settings_from_dict( + { + "general": {"latitude": 52.5, "longitude": 13.4}, + "pvforecast": { + "provider": "PVForecastPVNode", + "planes": [{"surface_tilt": 30.0, "surface_azimuth": 180.0, "peakpower": 5.0}], + "provider_settings": {"PVForecastPVNode": {"api_key": "k", "site_id": None}}, + }, + } + ) + pv = PVForecastPVNode(config=config_eos.load, start_datetime=pendulum.datetime(2025, 1, 1, tz="UTC")) + fake = type("R", (), {"raise_for_status": lambda self: None, "json": lambda self: {"values": []}})() + with patch("requests.post", return_value=fake) as mock_post: + pv._request_forecast(force_update=True) + url = mock_post.call_args[0][0] + assert url.endswith("/v2/forecast/inline") + body = mock_post.call_args.kwargs["json"] + assert body["strings"][0] == {"slope": 30.0, "orientation": 180.0, "power_kw": 5.0} + + +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 pvnode" in str(exc_info.value) From a1e210020612bddd2c96238b86ca0e305bbd11ac Mon Sep 17 00:00:00 2001 From: Christin Date: Sun, 28 Jun 2026 03:03:12 +0000 Subject: [PATCH 2/6] feat(prediction): add Forecast.Solar PV forecast provider Add PVForecastForecastSolar, a PV forecast provider for the free Forecast.Solar API (https://forecast.solar), giving operators a no-account forecast source in addition to Akkudoktor, VRM, Import and pvnode. An optional API key raises the rate limit. result.watts is the instantaneous AC power per timestamp, fed directly as pvforecast_ac_power. Plants with several roof planes issue one request per plane (Forecast.Solar is single-plane) and the powers are summed per timestamp. Forecast.Solar azimuth (-180=N..0=S..90=W) is converted from EOS surface_azimuth (north=0..south=180); local wall-clock timestamps are resolved via the response timezone before resampling. Registered in pvforecast.py and prediction.py. Adds tests for timezone resolution, azimuth conversion, multi-plane summation and HTTP-error handling. --- src/akkudoktoreos/prediction/prediction.py | 6 + src/akkudoktoreos/prediction/pvforecast.py | 15 +- .../prediction/pvforecastforecastsolar.py | 161 ++++++++++++++++++ tests/test_pvforecastforecastsolar.py | 127 ++++++++++++++ 4 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 src/akkudoktoreos/prediction/pvforecastforecastsolar.py create mode 100644 tests/test_pvforecastforecastsolar.py diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index 85ee870..75ea60a 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -45,6 +45,7 @@ from akkudoktoreos.prediction.loadimport import LoadImport from akkudoktoreos.prediction.loadvrm import LoadVrm from akkudoktoreos.prediction.predictionabc import PredictionContainer 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.pvforecastvrm import PVForecastVrm @@ -86,6 +87,7 @@ loadforecast_import = LoadImport() pvforecast_akkudoktor = PVForecastAkkudoktor() pvforecast_vrm = PVForecastVrm() pvforecast_pvnode = PVForecastPVNode() +pvforecast_forecastsolar = PVForecastForecastSolar() pvforecast_import = PVForecastImport() weather_brightsky = WeatherBrightSky() weather_clearoutside = WeatherClearOutside() @@ -108,6 +110,7 @@ def prediction_providers() -> list[ PVForecastAkkudoktor, PVForecastVrm, PVForecastPVNode, + PVForecastForecastSolar, PVForecastImport, WeatherBrightSky, WeatherClearOutside, @@ -133,6 +136,7 @@ def prediction_providers() -> list[ pvforecast_akkudoktor, \ pvforecast_vrm, \ pvforecast_pvnode, \ + pvforecast_forecastsolar, \ pvforecast_import, \ weather_brightsky, \ weather_clearoutside, \ @@ -154,6 +158,7 @@ def prediction_providers() -> list[ pvforecast_akkudoktor, pvforecast_vrm, pvforecast_pvnode, + pvforecast_forecastsolar, pvforecast_import, weather_brightsky, weather_clearoutside, @@ -180,6 +185,7 @@ class Prediction(PredictionContainer): PVForecastAkkudoktor, PVForecastVrm, PVForecastPVNode, + PVForecastForecastSolar, PVForecastImport, WeatherBrightSky, WeatherClearOutside, diff --git a/src/akkudoktoreos/prediction/pvforecast.py b/src/akkudoktoreos/prediction/pvforecast.py index 288c79e..1b95a5c 100644 --- a/src/akkudoktoreos/prediction/pvforecast.py +++ b/src/akkudoktoreos/prediction/pvforecast.py @@ -7,6 +7,9 @@ from pydantic import Field, computed_field, field_validator, model_validator from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider +from akkudoktoreos.prediction.pvforecastforecastsolar import ( + PVForecastForecastSolarCommonSettings, +) from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNodeCommonSettings from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrmCommonSettings @@ -19,7 +22,13 @@ def pvforecast_provider_ids() -> list[str]: except: # Prediction may not be initialized # Return at least provider used in example - return ["PVForecastAkkudoktor", "PVForecastImport", "PVForecastVrm", "PVForecastPVNode"] + return [ + "PVForecastAkkudoktor", + "PVForecastImport", + "PVForecastVrm", + "PVForecastPVNode", + "PVForecastForecastSolar", + ] return [ provider.provider_id() @@ -184,6 +193,10 @@ class PVForecastCommonProviderSettings(SettingsBaseModel): default=None, json_schema_extra={"description": "PVForecastPVNode settings", "examples": [None]}, ) + PVForecastForecastSolar: Optional[PVForecastForecastSolarCommonSettings] = Field( + default=None, + json_schema_extra={"description": "PVForecastForecastSolar settings", "examples": [None]}, + ) class PVForecastCommonSettings(SettingsBaseModel): diff --git a/src/akkudoktoreos/prediction/pvforecastforecastsolar.py b/src/akkudoktoreos/prediction/pvforecastforecastsolar.py new file mode 100644 index 0000000..cfd2a33 --- /dev/null +++ b/src/akkudoktoreos/prediction/pvforecastforecastsolar.py @@ -0,0 +1,161 @@ +"""Retrieves PV forecast data from the Forecast.Solar API. + +Forecast.Solar (https://forecast.solar) is a free public PV forecast service +(no API key required; an optional key raises the rate/feature limits). Each +request covers a single plane: + + GET https://api.forecast.solar[/{api_key}]/estimate/{lat}/{lon}/{dec}/{az}/{kwp} + +``result.watts`` is the instantaneous AC power per timestamp — exactly what the +optimizer consumes as ``pvforecast_ac_power``. EOS plants with several roof +planes issue one request per plane and the instantaneous powers are summed per +timestamp. + +Note on conventions: + - Forecast.Solar azimuth is -180=N, -90=E, 0=S, 90=W, whereas EOS + ``surface_azimuth`` is north=0, east=90, south=180, west=270. The provider + converts via ``az = surface_azimuth - 180``. + - Response timestamps are local wall-clock; ``message.info.timezone`` is used + to resolve them to absolute instants before EOS resamples them. +""" + +import re +from typing import Any, Optional + +import pendulum +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 + +FORECAST_SOLAR_BASE = "https://api.forecast.solar" + +_TZ_SUFFIX = re.compile(r"([zZ]|[+-]\d\d:?\d\d)$") + + +class PVForecastForecastSolarCommonSettings(SettingsBaseModel): + """Common settings for the Forecast.Solar PV forecast provider.""" + + api_key: Optional[str] = Field( + default=None, + json_schema_extra={ + "description": ( + "Forecast.Solar API key. Optional — the public endpoint works " + "without a key (lower rate limit)." + ), + "examples": [None, "your-forecast-solar-key"], + }, + ) + + +class PVForecastForecastSolar(PVForecastProvider): + """Fetch and process PV forecast data from the Forecast.Solar API.""" + + @classmethod + def provider_id(cls) -> str: + """Return the unique identifier for the PV-Forecast-Provider.""" + return "PVForecastForecastSolar" + + @property + def _api_key(self) -> Optional[str]: + settings = self.config.pvforecast.provider_settings.PVForecastForecastSolar + return settings.api_key if settings is not None else None + + def _to_utc_datetime(self, local_ts: Any, iana_tz: Optional[str]) -> Any: + """Resolve a Forecast.Solar wall-clock timestamp to a timezone-aware datetime.""" + s = str(local_ts).strip() + if _TZ_SUFFIX.search(s): + return to_datetime(s) + tz = iana_tz or str(self.config.general.timezone) + dt = pendulum.parse(s, tz=tz) + return to_datetime(dt.isoformat()) + + def _plane_url(self, plane: Any) -> str: + """Build the single-plane estimate URL for the given plane configuration.""" + latitude = self.config.general.latitude + longitude = self.config.general.longitude + if latitude is None or longitude is None: + raise ValueError("PVForecastForecastSolar needs general.latitude/longitude") + tilt = getattr(plane, "surface_tilt", None) + azimuth = getattr(plane, "surface_azimuth", None) + peakpower = getattr(plane, "peakpower", None) + if tilt is None or azimuth is None or peakpower is None: + raise ValueError( + "PVForecastForecastSolar needs surface_tilt, surface_azimuth and " + "peakpower on each pvforecast.planes entry" + ) + # EOS azimuth (north=0..south=180) -> Forecast.Solar (north=-180..south=0). + fs_az = float(azimuth) - 180.0 + base = FORECAST_SOLAR_BASE + api_key = self._api_key + if api_key: + base = f"{base}/{api_key}" + return f"{base}/estimate/{latitude}/{longitude}/{float(tilt)}/{fs_az}/{float(peakpower)}" + + @cache_in_file(with_ttl="1 hour") + def _request_forecast(self) -> dict: + """Fetch and aggregate the Forecast.Solar estimate across all configured planes.""" + planes = self.config.pvforecast.planes or [] + if not planes: + raise ValueError("PVForecastForecastSolar needs at least one pvforecast.planes entry") + + summed: dict[str, float] = {} + timezone: Optional[str] = None + for plane in planes: + url = self._plane_url(plane) + logger.debug(f"Requesting Forecast.Solar estimate: {url}") + try: + response = requests.get(url, headers={"Accept": "application/json"}, timeout=30) + response.raise_for_status() + except requests.RequestException as e: + logger.error(f"Failed to fetch pvforecast from Forecast.Solar: {e}") + raise RuntimeError("Failed to fetch pvforecast from Forecast.Solar API") from e + data = response.json() + if timezone is None: + timezone = (data.get("message", {}).get("info", {}) or {}).get("timezone") + watts = (data.get("result", {}) or {}).get("watts", {}) or {} + for ts, power in watts.items(): + try: + summed[ts] = summed.get(ts, 0.0) + float(power) + except (TypeError, ValueError): + continue + + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) + return {"timezone": timezone, "watts": summed} + + def _update_data(self, force_update: Optional[bool] = False) -> None: + """Update forecast data in the PVForecastDataRecord format.""" + if not self.enabled(): + logger.info("PVForecastForecastSolar is disabled, skipping update.") + return + + body = self._request_forecast(force_update=force_update) # type: ignore[call-arg] + timezone = body.get("timezone") + watts = body.get("watts", {}) + + count = 0 + for ts, power_w in sorted(watts.items()): + try: + date = self._to_utc_datetime(ts, timezone) + except Exception as e: # noqa: BLE001 - skip unparseable rows + logger.warning(f"Forecast.Solar: skipping unparseable timestamp {ts!r}: {e}") + continue + value = round(float(power_w), 1) + self.update_value( + date, + {"pvforecast_ac_power": value, "pvforecast_dc_power": value}, + ) + count += 1 + + logger.debug(f"Updated pvforecast from Forecast.Solar with {count} entries.") + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) + + +# Example usage +if __name__ == "__main__": + pv = PVForecastForecastSolar() + pv._update_data() diff --git a/tests/test_pvforecastforecastsolar.py b/tests/test_pvforecastforecastsolar.py new file mode 100644 index 0000000..1f3b4a0 --- /dev/null +++ b/tests/test_pvforecastforecastsolar.py @@ -0,0 +1,127 @@ +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 + + +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: + 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: + pv._request_forecast(force_update=True) + 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) + 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) From cec9e35aa90be08f6260174ecc468cdc86313869 Mon Sep 17 00:00:00 2001 From: Christin Date: Sun, 28 Jun 2026 03:06:55 +0000 Subject: [PATCH 3/6] 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) From 2f09263a4acb62c38fc134d33e5cd1a87e8c0501 Mon Sep 17 00:00:00 2001 From: Christin Date: Sun, 28 Jun 2026 03:25:27 +0000 Subject: [PATCH 4/6] docs(prediction): document pvnode, Forecast.Solar and Solcast providers Add provider descriptions and configuration examples for the three new PV forecast providers to the prediction guide, a CHANGELOG entry, and regenerate the affected auto-generated config docs. --- CHANGELOG.md | 10 +++ docs/_generated/configexample.md | 5 +- docs/_generated/configpvforecast.md | 120 +++++++++++++++++++++++++++- docs/akkudoktoreos/prediction.md | 87 ++++++++++++++++++++ 4 files changed, 218 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03db594..d812d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to the akkudoktoreos project will be documented in this file The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## Unreleased + +### Added + +- New PV forecast providers giving operators more cloud forecast sources to choose from in + addition to Akkudoktor, VRM and Import: + - `PVForecastPVNode` — native 15-minute forecasts from the pvnode.com API. + - `PVForecastForecastSolar` — forecasts from the free Forecast.Solar API. + - `PVForecastSolcast` — forecasts from the Solcast rooftop-site API. + ## 0.3.0 (2026-03-17) Akkudoktor-EOS can now be run as Home Assistant add-on and standalone. diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index dbc8187..aaad1b7 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -187,7 +187,10 @@ "provider": "PVForecastAkkudoktor", "provider_settings": { "PVForecastImport": null, - "PVForecastVrm": null + "PVForecastVrm": null, + "PVForecastPVNode": null, + "PVForecastForecastSolar": null, + "PVForecastSolcast": null }, "planes": [ { diff --git a/docs/_generated/configpvforecast.md b/docs/_generated/configpvforecast.md index ea78dbd..a5776e0 100644 --- a/docs/_generated/configpvforecast.md +++ b/docs/_generated/configpvforecast.md @@ -31,7 +31,10 @@ "provider": "PVForecastAkkudoktor", "provider_settings": { "PVForecastImport": null, - "PVForecastVrm": null + "PVForecastVrm": null, + "PVForecastPVNode": null, + "PVForecastForecastSolar": null, + "PVForecastSolcast": null }, "planes": [ { @@ -96,7 +99,10 @@ "provider": "PVForecastAkkudoktor", "provider_settings": { "PVForecastImport": null, - "PVForecastVrm": null + "PVForecastVrm": null, + "PVForecastPVNode": null, + "PVForecastForecastSolar": null, + "PVForecastSolcast": null }, "planes": [ { @@ -148,6 +154,9 @@ "providers": [ "PVForecastAkkudoktor", "PVForecastVrm", + "PVForecastPVNode", + "PVForecastForecastSolar", + "PVForecastSolcast", "PVForecastImport" ], "planes_peakpower": [ @@ -183,6 +192,105 @@ ``` +### Common settings for the Solcast PV forecast provider + + +:::{table} pvforecast::provider_settings::PVForecastSolcast +:widths: 10 10 5 5 30 +:align: left + +| Name | Type | Read-Only | Default | Description | +| ---- | ---- | --------- | ------- | ----------- | +| api_key | `str` | `rw` | `` | Solcast API key (Bearer auth). Required. | +| site_id | `str` | `rw` | `` | Solcast rooftop site (resource) id. Required. | +::: + + + +**Example Input/Output** + + + +```json + { + "pvforecast": { + "provider_settings": { + "PVForecastSolcast": { + "api_key": "your-solcast-key", + "site_id": "abcd-1234-efgh-5678" + } + } + } + } +``` + + +### Common settings for the Forecast.Solar PV forecast provider + + +:::{table} pvforecast::provider_settings::PVForecastForecastSolar +:widths: 10 10 5 5 30 +:align: left + +| Name | Type | Read-Only | Default | Description | +| ---- | ---- | --------- | ------- | ----------- | +| api_key | `Optional[str]` | `rw` | `None` | Forecast.Solar API key. Optional — the public endpoint works without a key (lower rate limit). | +::: + + + +**Example Input/Output** + + + +```json + { + "pvforecast": { + "provider_settings": { + "PVForecastForecastSolar": { + "api_key": null + } + } + } + } +``` + + +### Common settings for the pvnode.com PV forecast provider + + +:::{table} pvforecast::provider_settings::PVForecastPVNode +:widths: 10 10 5 5 30 +:align: left + +| Name | Type | Read-Only | Default | Description | +| ---- | ---- | --------- | ------- | ----------- | +| api_key | `str` | `rw` | `` | pvnode.com API key (Bearer auth). Required. | +| forecast_days | `int` | `rw` | `2` | Forecast horizon in days (1-7, capped by the pvnode plan). | +| site_id | `Optional[str]` | `rw` | `None` | pvnode.com site id of the saved plant ('Anlagen-ID'). When set, the saved (possibly calibrated) site is used. Leave empty to send the configured pvforecast.planes inline instead. | +::: + + + +**Example Input/Output** + + + +```json + { + "pvforecast": { + "provider_settings": { + "PVForecastPVNode": { + "api_key": "pvn_live_xxxxxxxxxxxxxxxx", + "site_id": "abcd-1234", + "forecast_days": 2 + } + } + } + } +``` + + ### Common settings for PV forecast VRM API @@ -258,7 +366,10 @@ | Name | Type | Read-Only | Default | Description | | ---- | ---- | --------- | ------- | ----------- | +| PVForecastForecastSolar | `Optional[akkudoktoreos.prediction.pvforecastforecastsolar.PVForecastForecastSolarCommonSettings]` | `rw` | `None` | PVForecastForecastSolar settings | | PVForecastImport | `Optional[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings]` | `rw` | `None` | PVForecastImport settings | +| PVForecastPVNode | `Optional[akkudoktoreos.prediction.pvforecastpvnode.PVForecastPVNodeCommonSettings]` | `rw` | `None` | PVForecastPVNode settings | +| PVForecastSolcast | `Optional[akkudoktoreos.prediction.pvforecastsolcast.PVForecastSolcastCommonSettings]` | `rw` | `None` | PVForecastSolcast settings | | PVForecastVrm | `Optional[akkudoktoreos.prediction.pvforecastvrm.PVForecastVrmCommonSettings]` | `rw` | `None` | PVForecastVrm settings | ::: @@ -273,7 +384,10 @@ "pvforecast": { "provider_settings": { "PVForecastImport": null, - "PVForecastVrm": null + "PVForecastVrm": null, + "PVForecastPVNode": null, + "PVForecastForecastSolar": null, + "PVForecastSolcast": null } } } diff --git a/docs/akkudoktoreos/prediction.md b/docs/akkudoktoreos/prediction.md index c53c93d..d1a0e7c 100644 --- a/docs/akkudoktoreos/prediction.md +++ b/docs/akkudoktoreos/prediction.md @@ -373,6 +373,9 @@ Configuration options: - `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net. - `PVForecastVrm`: Retrieves data from the VRM API by Victron Energy. + - `PVForecastPVNode`: Retrieves native 15-minute forecasts from the pvnode.com API. + - `PVForecastForecastSolar`: Retrieves forecasts from the free Forecast.Solar API. + - `PVForecastSolcast`: Retrieves forecasts from the Solcast rooftop-site API. - `PVForecastImport`: Imports from a file or JSON string or by endpoint data provision. - `planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking. @@ -401,6 +404,12 @@ Configuration options: - `planes[].strings_per_inverter`: Number of the strings of the inverter of this plane. - `provider_settings.import_file_path`: Path to the file to import PV forecast data from. - `provider_settings.import_json`: JSON string, dictionary of PV forecast value lists. + - `provider_settings.PVForecastPVNode.api_key`: pvnode.com API key. + - `provider_settings.PVForecastPVNode.site_id`: pvnode.com saved-site id. Leave empty for inline mode. + - `provider_settings.PVForecastPVNode.forecast_days`: Forecast horizon in days (1-7). + - `provider_settings.PVForecastForecastSolar.api_key`: Forecast.Solar API key (optional). + - `provider_settings.PVForecastSolcast.api_key`: Solcast API key. + - `provider_settings.PVForecastSolcast.site_id`: Solcast rooftop resource (site) id. --- @@ -604,6 +613,84 @@ The PV forecast data must be provided in one of the formats described in The data may additionally or solely be provided by the **PUT** `/v1/prediction/import/PVForecastImport` endpoint. +### PVForecastPVNode Provider + +The `PVForecastPVNode` provider retrieves native 15-minute PV power forecasts from the +[pvnode.com](https://pvnode.com) V2 API. Register a site in the pvnode web app and store the API +key together with the site id in the EOS configuration (saved-site mode). Alternatively, leave the +site id empty to send the configured `planes` geometry inline. + +```python + { + "pvforecast": { + "provider": "PVForecastPVNode", + "provider_settings": { + "PVForecastPVNode": { + "api_key": "your-pvnode-key", + "site_id": "your-site-id", + "forecast_days": 2 + } + } + } + } +``` + +The prediction keys for the PV forecast data are: + +- `pvforecast_ac_power`: Total AC power (W). +- `pvforecast_dc_power`: Total DC power (W). + +### PVForecastForecastSolar Provider + +The `PVForecastForecastSolar` provider retrieves PV power forecasts from the free +[Forecast.Solar](https://forecast.solar) API. No API key is required for the public endpoint; an +optional key raises the rate limit. The location is taken from `general.latitude`/`longitude` and +the system geometry from the configured `planes` (one request per plane, summed per timestamp). + +```python + { + "pvforecast": { + "provider": "PVForecastForecastSolar", + "provider_settings": { + "PVForecastForecastSolar": { + "api_key": null + } + } + } + } +``` + +The prediction keys for the PV forecast data are: + +- `pvforecast_ac_power`: Total AC power (W). +- `pvforecast_dc_power`: Total DC power (W). + +### PVForecastSolcast Provider + +The `PVForecastSolcast` provider retrieves PV power forecasts from the +[Solcast](https://solcast.com) rooftop-site API. Register a rooftop site in the Solcast web app and +store the API key together with the resource (site) id in the EOS configuration. Note that the free +tier limits the number of API calls per day. + +```python + { + "pvforecast": { + "provider": "PVForecastSolcast", + "provider_settings": { + "PVForecastSolcast": { + "api_key": "your-solcast-key", + "site_id": "your-resource-id" + } + } + } + } +``` + +The prediction keys for the PV forecast data are: + +- `pvforecast_ac_power`: Total AC power (W). +- `pvforecast_dc_power`: Total DC power (W). + ## Weather Prediction Prediction keys: From d4dc9fa6624b8d940fc112780dc4109cfa2ab914 Mon Sep 17 00:00:00 2001 From: Christin Date: Sat, 4 Jul 2026 09:23:57 +0000 Subject: [PATCH 5/6] fix(prediction): green up CI for the new PV providers (mypy + provider sequence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use urllib.parse.quote instead of requests.utils.quote in the pvnode and Solcast providers: the runtime re-export exists, but the requests type stubs do not declare it, so the pre-commit mypy hook failed with 'Module has no attribute "quote"'. - Mark the force_update keyword in the new provider tests with `# type: ignore` — it is consumed by the cache_in_file decorator at runtime; same call convention and ignore style as pvforecastakkudoktor.py. - Add PVForecastPVNode, PVForecastForecastSolar and PVForecastSolcast to the expected provider sequence in tests/test_prediction.py (fixture + index assertions) — the two sequence tests failed because the new providers were registered in prediction.py but missing from the hardcoded expectations. --- .../prediction/pvforecastpvnode.py | 3 ++- .../prediction/pvforecastsolcast.py | 3 ++- tests/test_prediction.py | 19 ++++++++++++++----- tests/test_pvforecastforecastsolar.py | 6 ++++-- tests/test_pvforecastpvnode.py | 4 +++- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/akkudoktoreos/prediction/pvforecastpvnode.py b/src/akkudoktoreos/prediction/pvforecastpvnode.py index 4e3bd03..4b08eac 100644 --- a/src/akkudoktoreos/prediction/pvforecastpvnode.py +++ b/src/akkudoktoreos/prediction/pvforecastpvnode.py @@ -22,6 +22,7 @@ Notes: import re from typing import Any, Optional +from urllib.parse import quote import pendulum import requests @@ -165,7 +166,7 @@ class PVForecastPVNode(PVForecastProvider): try: if site_id: - url = f"{PVNODE_BASE}/forecast/{requests.utils.quote(site_id, safe='')}" + url = f"{PVNODE_BASE}/forecast/{quote(site_id, safe='')}" response = requests.get(url, headers=headers, params=params, timeout=30) else: body = self._inline_body() diff --git a/src/akkudoktoreos/prediction/pvforecastsolcast.py b/src/akkudoktoreos/prediction/pvforecastsolcast.py index 01a2f99..004050c 100644 --- a/src/akkudoktoreos/prediction/pvforecastsolcast.py +++ b/src/akkudoktoreos/prediction/pvforecastsolcast.py @@ -18,6 +18,7 @@ Notes: import re from typing import Any, Optional +from urllib.parse import quote import requests from loguru import logger @@ -84,7 +85,7 @@ class PVForecastSolcast(PVForecastProvider): 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" + url = f"{SOLCAST_BASE}/{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}") diff --git a/tests/test_prediction.py b/tests/test_prediction.py index b973017..2b7241d 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -19,7 +19,10 @@ from akkudoktoreos.prediction.prediction import ( PredictionCommonSettings, ) 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 @@ -49,6 +52,9 @@ def forecast_providers(): LoadImport(), PVForecastAkkudoktor(), PVForecastVrm(), + PVForecastPVNode(), + PVForecastForecastSolar(), + PVForecastSolcast(), PVForecastImport(), WeatherBrightSky(), WeatherClearOutside(), @@ -98,11 +104,14 @@ def test_provider_sequence(prediction): assert isinstance(prediction.providers[9], LoadImport) assert isinstance(prediction.providers[10], PVForecastAkkudoktor) assert isinstance(prediction.providers[11], PVForecastVrm) - assert isinstance(prediction.providers[12], PVForecastImport) - assert isinstance(prediction.providers[13], WeatherBrightSky) - assert isinstance(prediction.providers[14], WeatherClearOutside) - assert isinstance(prediction.providers[15], WeatherOpenMeteo) - assert isinstance(prediction.providers[16], WeatherImport) + assert isinstance(prediction.providers[12], PVForecastPVNode) + assert isinstance(prediction.providers[13], PVForecastForecastSolar) + assert isinstance(prediction.providers[14], PVForecastSolcast) + assert isinstance(prediction.providers[15], PVForecastImport) + assert isinstance(prediction.providers[16], WeatherBrightSky) + assert isinstance(prediction.providers[17], WeatherClearOutside) + assert isinstance(prediction.providers[18], WeatherOpenMeteo) + assert isinstance(prediction.providers[19], WeatherImport) def test_provider_by_id(prediction, forecast_providers): diff --git a/tests/test_pvforecastforecastsolar.py b/tests/test_pvforecastforecastsolar.py index 1f3b4a0..c0b6daa 100644 --- a/tests/test_pvforecastforecastsolar.py +++ b/tests/test_pvforecastforecastsolar.py @@ -84,7 +84,9 @@ def test_plane_url_converts_azimuth(config_eos): config=config_eos.load, start_datetime=pendulum.datetime(2025, 1, 1, tz="UTC") ) with patch("requests.get", return_value=_http({})) as mock_get: - pv._request_forecast(force_update=True) + # 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" @@ -106,7 +108,7 @@ def test_request_forecast_sums_planes(config_eos): _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) + 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 diff --git a/tests/test_pvforecastpvnode.py b/tests/test_pvforecastpvnode.py index 351fd61..ce463b6 100644 --- a/tests/test_pvforecastpvnode.py +++ b/tests/test_pvforecastpvnode.py @@ -129,7 +129,9 @@ def test_request_forecast_inline_post_when_no_site(config_eos): pv = PVForecastPVNode(config=config_eos.load, start_datetime=pendulum.datetime(2025, 1, 1, tz="UTC")) fake = type("R", (), {"raise_for_status": lambda self: None, "json": lambda self: {"values": []}})() with patch("requests.post", return_value=fake) as mock_post: - pv._request_forecast(force_update=True) + # 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_post.call_args[0][0] assert url.endswith("/v2/forecast/inline") body = mock_post.call_args.kwargs["json"] From 32e3eb5c6e52405559ac78a35c2b9c293d1f1719 Mon Sep 17 00:00:00 2001 From: Christin Date: Tue, 7 Jul 2026 06:27:01 +0000 Subject: [PATCH 6/6] fix(prediction): drop module-level `from urllib.parse import quote` tests/test_docstringrst.py scans every class/function member of each module via inspect.getmembers() with no __module__ filter, so `from urllib.parse import quote` pulled the stdlib quote() into the pvnode and Solcast provider namespaces and its non-reST docstring failed the docstring-compliance check. Import `urllib.parse` as a module and call `urllib.parse.quote(...)` instead: a module member is skipped by the isfunction/isclass scan, and the fully-qualified call keeps mypy happy (the requests stubs lack quote, which is why urllib.parse was chosen over requests.utils in the first place). test_all_docstrings_rst_compliant now passes; isort/ruff/ruff-format/mypy pre-commit hooks all green. --- src/akkudoktoreos/prediction/pvforecastpvnode.py | 4 ++-- src/akkudoktoreos/prediction/pvforecastsolcast.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/akkudoktoreos/prediction/pvforecastpvnode.py b/src/akkudoktoreos/prediction/pvforecastpvnode.py index 4b08eac..afe6985 100644 --- a/src/akkudoktoreos/prediction/pvforecastpvnode.py +++ b/src/akkudoktoreos/prediction/pvforecastpvnode.py @@ -21,8 +21,8 @@ Notes: """ import re +import urllib.parse from typing import Any, Optional -from urllib.parse import quote import pendulum import requests @@ -166,7 +166,7 @@ class PVForecastPVNode(PVForecastProvider): try: if site_id: - url = f"{PVNODE_BASE}/forecast/{quote(site_id, safe='')}" + url = f"{PVNODE_BASE}/forecast/{urllib.parse.quote(site_id, safe='')}" response = requests.get(url, headers=headers, params=params, timeout=30) else: body = self._inline_body() diff --git a/src/akkudoktoreos/prediction/pvforecastsolcast.py b/src/akkudoktoreos/prediction/pvforecastsolcast.py index 004050c..60a022a 100644 --- a/src/akkudoktoreos/prediction/pvforecastsolcast.py +++ b/src/akkudoktoreos/prediction/pvforecastsolcast.py @@ -17,8 +17,8 @@ Notes: """ import re +import urllib.parse from typing import Any, Optional -from urllib.parse import quote import requests from loguru import logger @@ -85,7 +85,7 @@ class PVForecastSolcast(PVForecastProvider): if not settings.api_key or not settings.site_id: raise ValueError("PVForecastSolcast requires api_key and site_id") - url = f"{SOLCAST_BASE}/{quote(settings.site_id, safe='')}/forecasts" + url = f"{SOLCAST_BASE}/{urllib.parse.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}")