From 314e45cfaae04183981bdbf8f327fb41fcbb9da9 Mon Sep 17 00:00:00 2001 From: Christin Date: Sun, 28 Jun 2026 02:52:47 +0000 Subject: [PATCH] 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)