feat(prediction): add native pvnode.com PV forecast provider

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.
This commit is contained in:
Christin
2026-06-28 02:52:47 +00:00
parent 441a4639c3
commit 314e45cfaa
4 changed files with 396 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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