mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 12:46:38 +00:00
feat: add Tibber price provider and PV forecast providers
This commit is contained in:
@@ -276,7 +276,7 @@ def _version_date_hash() -> tuple[datetime, str]:
|
||||
Returns:
|
||||
lattest commit date and SHA256 hash of the project files
|
||||
"""
|
||||
if not str(DIR_PACKAGE_ROOT).endswith("src/akkudoktoreos"):
|
||||
if DIR_PACKAGE_ROOT.parts[-2:] != ("src", "akkudoktoreos"):
|
||||
error_msg = f"DIR_PACKAGE_ROOT does not end with src/akkudoktoreos: {DIR_PACKAGE_ROOT}"
|
||||
raise ValueError(error_msg)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import (
|
||||
)
|
||||
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixedCommonSettings
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
|
||||
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibberCommonSettings
|
||||
|
||||
|
||||
def elecprice_provider_ids() -> list[str]:
|
||||
@@ -72,6 +73,11 @@ class ElecPriceCommonSettings(SettingsBaseModel):
|
||||
json_schema_extra={"description": "Energy Charts provider settings."},
|
||||
)
|
||||
|
||||
tibber: ElecPriceTibberCommonSettings = Field(
|
||||
default_factory=ElecPriceTibberCommonSettings,
|
||||
json_schema_extra={"description": "Tibber electricity price provider settings."},
|
||||
)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def providers(self) -> list[str]:
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Electricity price provider for Tibber."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pydantic import Field, ValidationError
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
TIBBER_GRAPHQL_URL = "https://api.tibber.com/v1-beta/gql"
|
||||
TIBBER_PRICE_QUERY = """
|
||||
query TibberPriceInfo {
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
today {
|
||||
startsAt
|
||||
total
|
||||
energy
|
||||
tax
|
||||
}
|
||||
tomorrow {
|
||||
startsAt
|
||||
total
|
||||
energy
|
||||
tax
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class ElecPriceTibberCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for the Tibber electricity price provider."""
|
||||
|
||||
access_token: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Tibber API access token.",
|
||||
"examples": ["tibber_pat_..."],
|
||||
},
|
||||
)
|
||||
|
||||
home_id: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Tibber home id to read prices from.",
|
||||
"examples": ["00000000-0000-0000-0000-000000000000"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TibberPricePoint(PydanticBaseModel):
|
||||
"""Single Tibber price point."""
|
||||
|
||||
startsAt: datetime
|
||||
total: float
|
||||
energy: Optional[float] = None
|
||||
tax: Optional[float] = None
|
||||
|
||||
|
||||
class TibberPriceInfo(PydanticBaseModel):
|
||||
"""Tibber price info for today and tomorrow."""
|
||||
|
||||
today: List[TibberPricePoint] = Field(default_factory=list)
|
||||
tomorrow: List[TibberPricePoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TibberSubscription(PydanticBaseModel):
|
||||
"""Tibber subscription data."""
|
||||
|
||||
priceInfo: TibberPriceInfo
|
||||
|
||||
|
||||
class TibberHome(PydanticBaseModel):
|
||||
"""Tibber home data."""
|
||||
|
||||
id: str
|
||||
currentSubscription: Optional[TibberSubscription] = None
|
||||
|
||||
|
||||
class TibberViewer(PydanticBaseModel):
|
||||
"""Tibber viewer data."""
|
||||
|
||||
homes: List[TibberHome] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TibberData(PydanticBaseModel):
|
||||
"""Tibber GraphQL data payload."""
|
||||
|
||||
viewer: TibberViewer
|
||||
|
||||
|
||||
class TibberGraphQLResponse(PydanticBaseModel):
|
||||
"""Tibber GraphQL response payload."""
|
||||
|
||||
data: Optional[TibberData] = None
|
||||
errors: Optional[list[dict[str, Any]]] = None
|
||||
|
||||
|
||||
class ElecPriceTibber(ElecPriceProvider):
|
||||
"""Fetch and store Tibber electricity import prices."""
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the Tibber provider."""
|
||||
return "ElecPriceTibber"
|
||||
|
||||
@cache_in_file(with_ttl="5 minutes")
|
||||
def _request_forecast(self, force_update: Optional[bool] = False) -> TibberGraphQLResponse:
|
||||
"""Fetch electricity price data from the Tibber GraphQL API."""
|
||||
access_token = self.config.elecprice.tibber.access_token
|
||||
if not access_token:
|
||||
raise ValueError("Tibber access_token is required")
|
||||
|
||||
response = requests.post(
|
||||
TIBBER_GRAPHQL_URL,
|
||||
json={"query": TIBBER_PRICE_QUERY},
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
try:
|
||||
return TibberGraphQLResponse.model_validate_json(response.content)
|
||||
except ValidationError as exc:
|
||||
logger.error("Tibber schema validation failed: {}", exc)
|
||||
raise ValueError(f"Tibber schema validation failed: {exc}") from exc
|
||||
|
||||
def _select_home(self, response: TibberGraphQLResponse) -> TibberHome:
|
||||
"""Select the configured Tibber home from a GraphQL response."""
|
||||
home_id = self.config.elecprice.tibber.home_id
|
||||
if not home_id:
|
||||
raise ValueError("Tibber home_id is required")
|
||||
|
||||
if response.errors:
|
||||
raise ValueError(f"Tibber GraphQL error: {response.errors}")
|
||||
|
||||
if response.data is None:
|
||||
raise ValueError("Tibber response does not contain data")
|
||||
|
||||
for home in response.data.viewer.homes:
|
||||
if home.id == home_id:
|
||||
return home
|
||||
|
||||
raise ValueError("Tibber home_id not found")
|
||||
|
||||
def _parse_data(self, response: TibberGraphQLResponse) -> pd.Series:
|
||||
"""Parse Tibber prices into EOS market prices in EUR/Wh."""
|
||||
home = self._select_home(response)
|
||||
|
||||
if home.currentSubscription is None:
|
||||
raise ValueError("Tibber home has no current subscription")
|
||||
|
||||
price_info = home.currentSubscription.priceInfo
|
||||
points = list(price_info.today) + list(price_info.tomorrow)
|
||||
|
||||
if not price_info.tomorrow:
|
||||
logger.warning("Tibber tomorrow prices not available yet")
|
||||
|
||||
if not points:
|
||||
raise ValueError("Tibber response contains no price points")
|
||||
|
||||
values: dict[datetime, float] = {}
|
||||
|
||||
for point in points:
|
||||
dt = to_datetime(point.startsAt, in_timezone=self.config.general.timezone)
|
||||
values[dt] = point.total / 1000.0
|
||||
|
||||
return pd.Series(values, dtype=float).sort_index()
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
"""Update EOS electricity prices from Tibber price data."""
|
||||
response = self._request_forecast(force_update=force_update)
|
||||
series_data = self._parse_data(response)
|
||||
self.key_from_series("elecprice_marketprice_wh", series_data)
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
|
||||
logger.info("Updated ElecPriceTibber with {} price points", len(series_data))
|
||||
@@ -35,6 +35,7 @@ from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
||||
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
|
||||
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
|
||||
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
||||
from akkudoktoreos.prediction.loadakkudoktor import (
|
||||
@@ -45,7 +46,10 @@ 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.pvforecastsolcast import PVForecastSolcast
|
||||
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
|
||||
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
|
||||
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
||||
@@ -74,6 +78,7 @@ class PredictionCommonSettings(SettingsBaseModel):
|
||||
# Initialize forecast providers, all are singletons.
|
||||
elecprice_akkudoktor = ElecPriceAkkudoktor()
|
||||
elecprice_energy_charts = ElecPriceEnergyCharts()
|
||||
elecprice_tibber = ElecPriceTibber()
|
||||
elecprice_fixed = ElecPriceFixed()
|
||||
elecprice_import = ElecPriceImport()
|
||||
feedintariff_fixed = FeedInTariffFixed()
|
||||
@@ -84,6 +89,9 @@ loadforecast_vrm = LoadVrm()
|
||||
loadforecast_import = LoadImport()
|
||||
pvforecast_akkudoktor = PVForecastAkkudoktor()
|
||||
pvforecast_vrm = PVForecastVrm()
|
||||
pvforecast_pvnode = PVForecastPVNode()
|
||||
pvforecast_forecastsolar = PVForecastForecastSolar()
|
||||
pvforecast_solcast = PVForecastSolcast()
|
||||
pvforecast_import = PVForecastImport()
|
||||
weather_brightsky = WeatherBrightSky()
|
||||
weather_clearoutside = WeatherClearOutside()
|
||||
@@ -95,6 +103,7 @@ def prediction_providers() -> list[
|
||||
Union[
|
||||
ElecPriceAkkudoktor,
|
||||
ElecPriceEnergyCharts,
|
||||
ElecPriceTibber,
|
||||
ElecPriceFixed,
|
||||
ElecPriceImport,
|
||||
FeedInTariffFixed,
|
||||
@@ -105,6 +114,9 @@ def prediction_providers() -> list[
|
||||
LoadImport,
|
||||
PVForecastAkkudoktor,
|
||||
PVForecastVrm,
|
||||
PVForecastPVNode,
|
||||
PVForecastForecastSolar,
|
||||
PVForecastSolcast,
|
||||
PVForecastImport,
|
||||
WeatherBrightSky,
|
||||
WeatherClearOutside,
|
||||
@@ -119,6 +131,7 @@ def prediction_providers() -> list[
|
||||
global \
|
||||
elecprice_akkudoktor, \
|
||||
elecprice_energy_charts, \
|
||||
elecprice_tibber, \
|
||||
elecprice_fixed, \
|
||||
elecprice_import, \
|
||||
feedintariff_fixed, \
|
||||
@@ -129,6 +142,9 @@ def prediction_providers() -> list[
|
||||
loadforecast_import, \
|
||||
pvforecast_akkudoktor, \
|
||||
pvforecast_vrm, \
|
||||
pvforecast_pvnode, \
|
||||
pvforecast_forecastsolar, \
|
||||
pvforecast_solcast, \
|
||||
pvforecast_import, \
|
||||
weather_brightsky, \
|
||||
weather_clearoutside, \
|
||||
@@ -139,6 +155,7 @@ def prediction_providers() -> list[
|
||||
return [
|
||||
elecprice_akkudoktor,
|
||||
elecprice_energy_charts,
|
||||
elecprice_tibber,
|
||||
elecprice_fixed,
|
||||
elecprice_import,
|
||||
feedintariff_fixed,
|
||||
@@ -149,6 +166,9 @@ def prediction_providers() -> list[
|
||||
loadforecast_import,
|
||||
pvforecast_akkudoktor,
|
||||
pvforecast_vrm,
|
||||
pvforecast_pvnode,
|
||||
pvforecast_forecastsolar,
|
||||
pvforecast_solcast,
|
||||
pvforecast_import,
|
||||
weather_brightsky,
|
||||
weather_clearoutside,
|
||||
@@ -164,6 +184,7 @@ class Prediction(PredictionContainer):
|
||||
Union[
|
||||
ElecPriceAkkudoktor,
|
||||
ElecPriceEnergyCharts,
|
||||
ElecPriceTibber,
|
||||
ElecPriceFixed,
|
||||
ElecPriceImport,
|
||||
FeedInTariffFixed,
|
||||
@@ -174,6 +195,9 @@ class Prediction(PredictionContainer):
|
||||
LoadImport,
|
||||
PVForecastAkkudoktor,
|
||||
PVForecastVrm,
|
||||
PVForecastPVNode,
|
||||
PVForecastForecastSolar,
|
||||
PVForecastSolcast,
|
||||
PVForecastImport,
|
||||
WeatherBrightSky,
|
||||
WeatherClearOutside,
|
||||
|
||||
@@ -7,7 +7,12 @@ 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.pvforecastsolcast import PVForecastSolcastCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrmCommonSettings
|
||||
|
||||
|
||||
@@ -18,7 +23,14 @@ 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",
|
||||
"PVForecastForecastSolar",
|
||||
"PVForecastSolcast",
|
||||
]
|
||||
|
||||
return [
|
||||
provider.provider_id()
|
||||
@@ -179,6 +191,18 @@ 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]},
|
||||
)
|
||||
PVForecastForecastSolar: Optional[PVForecastForecastSolarCommonSettings] = Field(
|
||||
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):
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,242 @@
|
||||
"""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
|
||||
import urllib.parse
|
||||
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/{urllib.parse.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()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""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
|
||||
import urllib.parse
|
||||
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}/{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}")
|
||||
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()
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Server Module."""
|
||||
|
||||
import grp
|
||||
import ipaddress
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
@@ -16,6 +14,13 @@ from pydantic import Field, field_validator
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.coreabc import get_config
|
||||
|
||||
try:
|
||||
import grp
|
||||
import pwd
|
||||
except ModuleNotFoundError:
|
||||
grp = None
|
||||
pwd = None
|
||||
|
||||
|
||||
def get_default_host() -> str:
|
||||
"""Default host for EOS."""
|
||||
@@ -180,6 +185,12 @@ def drop_root_privileges(run_as_user: Optional[str] = None) -> bool:
|
||||
- The target user must exist inside the container (valid entry in
|
||||
``/etc/passwd`` and ``/etc/group``).
|
||||
"""
|
||||
if pwd is None or grp is None or not hasattr(os, "geteuid"):
|
||||
if run_as_user is not None:
|
||||
logger.error("Privilege switching is not supported on this platform.")
|
||||
return False
|
||||
return True
|
||||
|
||||
# Determine current user
|
||||
current_user = pwd.getpwuid(os.geteuid()).pw_name
|
||||
|
||||
@@ -261,6 +272,10 @@ def fix_data_directories_permissions(run_as_user: Optional[str] = None) -> None:
|
||||
"""
|
||||
config_eos = get_config()
|
||||
|
||||
if pwd is None or not hasattr(os, "geteuid") or not hasattr(os, "chown"):
|
||||
logger.debug("Skipping data directory ownership fix on this platform.")
|
||||
return
|
||||
|
||||
base_dirs = [
|
||||
config_eos.general.data_folder_path,
|
||||
config_eos.general.data_output_path,
|
||||
@@ -415,6 +430,8 @@ class ServerCommonSettings(SettingsBaseModel):
|
||||
@field_validator("run_as_user")
|
||||
def validate_user(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is not None:
|
||||
if pwd is None:
|
||||
raise ValueError("User privilege switching is not supported on this platform.")
|
||||
# Resolve target user info
|
||||
try:
|
||||
pw_record = pwd.getpwnam(value)
|
||||
|
||||
Reference in New Issue
Block a user