feat: add Tibber price provider and PV forecast providers

This commit is contained in:
Andreas
2026-07-08 16:36:59 +02:00
22 changed files with 1942 additions and 26 deletions

View File

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

View File

@@ -13,6 +13,7 @@
| energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. |
| provider | `EOS_ELECPRICE__PROVIDER` | `Optional[str]` | `rw` | `None` | Electricity price provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available electricity price provider ids. |
| tibber | `EOS_ELECPRICE__TIBBER` | `ElecPriceTibberCommonSettings` | `rw` | `required` | Tibber electricity price provider settings. |
| vat_rate | `EOS_ELECPRICE__VAT_RATE` | `Optional[float]` | `rw` | `1.19` | VAT rate factor applied to electricity price when charges are used. |
:::
<!-- pyml enable line-length -->
@@ -39,6 +40,10 @@
},
"energycharts": {
"bidding_zone": "DE-LU"
},
"tibber": {
"access_token": null,
"home_id": null
}
}
}
@@ -68,9 +73,14 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"tibber": {
"access_token": null,
"home_id": null
},
"providers": [
"ElecPriceAkkudoktor",
"ElecPriceEnergyCharts",
"ElecPriceTibber",
"ElecPriceFixed",
"ElecPriceImport"
]
@@ -79,6 +89,37 @@
```
<!-- pyml enable line-length -->
### Common settings for the Tibber electricity price provider
<!-- pyml disable line-length -->
:::{table} elecprice::tibber
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| access_token | `Optional[str]` | `rw` | `None` | Tibber API access token. |
| home_id | `Optional[str]` | `rw` | `None` | Tibber home id to read prices from. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"tibber": {
"access_token": "tibber_pat_...",
"home_id": "00000000-0000-0000-0000-000000000000"
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for Energy Charts electricity price provider
<!-- pyml disable line-length -->

View File

@@ -110,6 +110,10 @@
},
"energycharts": {
"bidding_zone": "DE-LU"
},
"tibber": {
"access_token": null,
"home_id": null
}
},
"ems": {
@@ -187,7 +191,10 @@
"provider": "PVForecastAkkudoktor",
"provider_settings": {
"PVForecastImport": null,
"PVForecastVrm": null
"PVForecastVrm": null,
"PVForecastPVNode": null,
"PVForecastForecastSolar": null,
"PVForecastSolcast": null
},
"planes": [
{

View File

@@ -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 @@
```
<!-- pyml enable line-length -->
### Common settings for the Solcast PV forecast provider
<!-- pyml disable line-length -->
:::{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. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider_settings": {
"PVForecastSolcast": {
"api_key": "your-solcast-key",
"site_id": "abcd-1234-efgh-5678"
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for the Forecast.Solar PV forecast provider
<!-- pyml disable line-length -->
:::{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). |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider_settings": {
"PVForecastForecastSolar": {
"api_key": null
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for the pvnode.com PV forecast provider
<!-- pyml disable line-length -->
:::{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. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider_settings": {
"PVForecastPVNode": {
"api_key": "pvn_live_xxxxxxxxxxxxxxxx",
"site_id": "abcd-1234",
"forecast_days": 2
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for PV forecast VRM API
<!-- pyml disable line-length -->
@@ -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 |
:::
<!-- pyml enable line-length -->
@@ -273,7 +384,10 @@
"pvforecast": {
"provider_settings": {
"PVForecastImport": null,
"PVForecastVrm": null
"PVForecastVrm": null,
"PVForecastPVNode": null,
"PVForecastForecastSolar": null,
"PVForecastSolcast": null
}
}
}

View File

@@ -1,6 +1,6 @@
# Akkudoktor-EOS
**Version**: `v0.3.0.dev2604141105859917`
**Version**: `v0.3.0.dev2607071966322885`
<!-- pyml disable line-length -->
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.

View File

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

View File

@@ -8,7 +8,7 @@
"name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "v0.3.0.dev2604141105859917"
"version": "v0.3.0.dev2607071966322885"
},
"paths": {
"/v1/admin/cache/clear": {
@@ -3390,6 +3390,10 @@
"energycharts": {
"$ref": "#/components/schemas/ElecPriceEnergyChartsCommonSettings",
"description": "Energy Charts provider settings."
},
"tibber": {
"$ref": "#/components/schemas/ElecPriceTibberCommonSettings",
"description": "Tibber electricity price provider settings."
}
},
"type": "object",
@@ -3458,6 +3462,10 @@
"$ref": "#/components/schemas/ElecPriceEnergyChartsCommonSettings",
"description": "Energy Charts provider settings."
},
"tibber": {
"$ref": "#/components/schemas/ElecPriceTibberCommonSettings",
"description": "Tibber electricity price provider settings."
},
"providers": {
"items": {
"type": "string"
@@ -3586,6 +3594,43 @@
"title": "ElecPriceImportCommonSettings",
"description": "Common settings for elecprice data import from file or JSON String."
},
"ElecPriceTibberCommonSettings": {
"properties": {
"access_token": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Access Token",
"description": "Tibber API access token.",
"examples": [
"tibber_pat_..."
]
},
"home_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Home Id",
"description": "Tibber home id to read prices from.",
"examples": [
"00000000-0000-0000-0000-000000000000"
]
}
},
"type": "object",
"title": "ElecPriceTibberCommonSettings",
"description": "Common settings for the Tibber electricity price provider."
},
"ElectricVehicleParameters": {
"properties": {
"device_id": {
@@ -7389,6 +7434,48 @@
"examples": [
null
]
},
"PVForecastPVNode": {
"anyOf": [
{
"$ref": "#/components/schemas/PVForecastPVNodeCommonSettings"
},
{
"type": "null"
}
],
"description": "PVForecastPVNode settings",
"examples": [
null
]
},
"PVForecastForecastSolar": {
"anyOf": [
{
"$ref": "#/components/schemas/PVForecastForecastSolarCommonSettings"
},
{
"type": "null"
}
],
"description": "PVForecastForecastSolar settings",
"examples": [
null
]
},
"PVForecastSolcast": {
"anyOf": [
{
"$ref": "#/components/schemas/PVForecastSolcastCommonSettings"
},
{
"type": "null"
}
],
"description": "PVForecastSolcast settings",
"examples": [
null
]
}
},
"type": "object",
@@ -7657,6 +7744,29 @@
"title": "PVForecastCommonSettings",
"description": "PV Forecast Configuration."
},
"PVForecastForecastSolarCommonSettings": {
"properties": {
"api_key": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Api Key",
"description": "Forecast.Solar API key. Optional \u2014 the public endpoint works without a key (lower rate limit).",
"examples": [
null,
"your-forecast-solar-key"
]
}
},
"type": "object",
"title": "PVForecastForecastSolarCommonSettings",
"description": "Common settings for the Forecast.Solar PV forecast provider."
},
"PVForecastImportCommonSettings": {
"properties": {
"import_file_path": {
@@ -7699,6 +7809,48 @@
"title": "PVForecastImportCommonSettings",
"description": "Common settings for pvforecast data import from file or JSON string."
},
"PVForecastPVNodeCommonSettings": {
"properties": {
"api_key": {
"type": "string",
"title": "Api Key",
"description": "pvnode.com API key (Bearer auth). Required.",
"default": "",
"examples": [
"pvn_live_xxxxxxxxxxxxxxxx"
]
},
"site_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id",
"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": {
"type": "integer",
"maximum": 7.0,
"minimum": 1.0,
"title": "Forecast Days",
"description": "Forecast horizon in days (1-7, capped by the pvnode plan).",
"default": 2,
"examples": [
2
]
}
},
"type": "object",
"title": "PVForecastPVNodeCommonSettings",
"description": "Common settings for the pvnode.com PV forecast provider."
},
"PVForecastPlaneSetting": {
"properties": {
"surface_tilt": {
@@ -7971,6 +8123,31 @@
"title": "PVForecastPlaneSetting",
"description": "PV Forecast Plane Configuration."
},
"PVForecastSolcastCommonSettings": {
"properties": {
"api_key": {
"type": "string",
"title": "Api Key",
"description": "Solcast API key (Bearer auth). Required.",
"default": "",
"examples": [
"your-solcast-key"
]
},
"site_id": {
"type": "string",
"title": "Site Id",
"description": "Solcast rooftop site (resource) id. Required.",
"default": "",
"examples": [
"abcd-1234-efgh-5678"
]
}
},
"type": "object",
"title": "PVForecastSolcastCommonSettings",
"description": "Common settings for the Solcast PV forecast provider."
},
"PVForecastVrmCommonSettings": {
"properties": {
"pvforecast_vrm_token": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -606,7 +606,7 @@ def server_base(
# ensure process is running and return its logfile
pid, logfile = xprocess.ensure("eos", Starter)
logger.info(f"Started EOS ({pid}). This may take very long (up to {server_timeout} seconds).")
logger.info(f"EOS_DIR: {Starter.env["EOS_DIR"]}, EOS_CONFIG_DIR: {Starter.env["EOS_CONFIG_DIR"]}")
logger.info(f"EOS_DIR: {Starter.env['EOS_DIR']}, EOS_CONFIG_DIR: {Starter.env['EOS_CONFIG_DIR']}")
logger.info(f"View xprocess logfile at: {logfile}")
yield {

View File

@@ -0,0 +1,286 @@
"""Tests for the Tibber electricity price provider."""
import json
from unittest.mock import Mock, patch
import pytest
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.elecpricetibber import (
ElecPriceTibber,
ElecPriceTibberCommonSettings,
TibberGraphQLResponse,
)
from akkudoktoreos.utils.datetimeutil import to_datetime
@pytest.fixture
def provider(config_eos):
"""Create a fresh Tibber electricity price provider."""
ElecPriceTibber.reset_instance()
config_eos.elecprice = ElecPriceCommonSettings(
provider="ElecPriceTibber",
tibber=ElecPriceTibberCommonSettings(access_token="token-123", home_id="home-1"),
)
return ElecPriceTibber()
@pytest.fixture
def cache_store():
"""Create a cache store for tests that touch cached methods."""
return CacheFileStore()
@pytest.fixture
def tibber_response_dict():
"""Sample Tibber GraphQL response."""
return {
"data": {
"viewer": {
"homes": [
{
"id": "other-home",
"currentSubscription": {
"priceInfo": {
"today": [
{
"startsAt": "2026-07-07T00:00:00.000+02:00",
"total": 0.999,
"energy": 0.111,
"tax": 0.888,
}
],
"tomorrow": [],
}
},
},
{
"id": "home-1",
"currentSubscription": {
"priceInfo": {
"today": [
{
"startsAt": "2026-07-07T01:00:00.000+02:00",
"total": 0.2970716,
"energy": 0.10922,
"tax": 0.1878516,
},
{
"startsAt": "2026-07-07T00:00:00.000+02:00",
"total": 0.3109662,
"energy": 0.12098,
"tax": 0.1899862,
},
],
"tomorrow": [
{
"startsAt": "2026-07-08T00:00:00.000+02:00",
"total": 0.30468,
"energy": 0.1162,
"tax": 0.18848,
}
],
}
},
},
]
}
}
}
@pytest.fixture
def tibber_response(tibber_response_dict):
"""Validated sample Tibber GraphQL response."""
return TibberGraphQLResponse.model_validate(tibber_response_dict)
def test_provider_id(provider):
"""Provider ID is stable."""
assert provider.provider_id() == "ElecPriceTibber"
def test_enabled_only_for_configured_provider(provider, config_eos):
"""Provider is enabled only when configured as active elecprice provider."""
assert provider.enabled()
config_eos.elecprice.provider = "ElecPriceFixed"
assert not provider.enabled()
def test_config_structure_accepts_tibber_settings():
"""The requested nested Tibber config structure is accepted."""
settings = ElecPriceCommonSettings.model_validate(
{
"provider": "ElecPriceTibber",
"tibber": {
"access_token": "token-123",
"home_id": "home-1",
},
}
)
assert settings.provider == "ElecPriceTibber"
assert settings.tibber.access_token == "token-123"
assert settings.tibber.home_id == "home-1"
def test_missing_access_token_raises(provider, config_eos):
"""A Tibber access token is required before making requests."""
config_eos.elecprice.tibber.access_token = None
with pytest.raises(ValueError, match="Tibber access_token is required"):
provider._request_forecast(force_update=True)
def test_missing_home_id_raises(provider, config_eos, tibber_response):
"""A Tibber home id is required for selecting prices."""
config_eos.elecprice.tibber.home_id = None
with pytest.raises(ValueError, match="Tibber home_id is required"):
provider._select_home(tibber_response)
def test_graphql_errors_raise(provider):
"""GraphQL errors are surfaced as ValueError."""
response = TibberGraphQLResponse.model_validate(
{"errors": [{"message": "Authentication failed"}]}
)
with pytest.raises(ValueError, match="Tibber GraphQL error"):
provider._select_home(response)
def test_unknown_home_id_raises(provider, config_eos, tibber_response):
"""Configured home id must exist in the Tibber response."""
config_eos.elecprice.tibber.home_id = "missing-home"
with pytest.raises(ValueError, match="Tibber home_id not found"):
provider._select_home(tibber_response)
def test_parse_data_combines_sorts_and_converts_total(provider, tibber_response):
"""Today and tomorrow prices are sorted and converted from EUR/kWh to EUR/Wh."""
series = provider._parse_data(tibber_response)
assert list(series.index) == [
to_datetime("2026-07-07T00:00:00.000+02:00", in_timezone="Europe/Berlin"),
to_datetime("2026-07-07T01:00:00.000+02:00", in_timezone="Europe/Berlin"),
to_datetime("2026-07-08T00:00:00.000+02:00", in_timezone="Europe/Berlin"),
]
assert series.iloc[0] == pytest.approx(0.0003109662)
assert series.iloc[1] == pytest.approx(0.0002970716)
assert series.iloc[2] == pytest.approx(0.00030468)
def test_update_data_stores_elecprice_marketprice_wh(provider, tibber_response):
"""Parsed Tibber totals are stored in EOS records."""
with patch.object(provider, "_request_forecast", return_value=tibber_response):
provider.update_data(force_enable=True, force_update=True)
series = provider.key_to_series("elecprice_marketprice_wh")
assert len(series) == 3
assert series.iloc[0] == pytest.approx(0.0003109662)
assert series.iloc[1] == pytest.approx(0.0002970716)
assert series.iloc[2] == pytest.approx(0.00030468)
def test_total_conversion_exact_example(provider):
"""Tibber total 0.311 EUR/kWh is stored as 0.000311 EUR/Wh."""
response = TibberGraphQLResponse.model_validate(
{
"data": {
"viewer": {
"homes": [
{
"id": "home-1",
"currentSubscription": {
"priceInfo": {
"today": [
{
"startsAt": "2026-07-07T00:00:00.000+02:00",
"total": 0.311,
}
],
"tomorrow": [],
}
},
}
]
}
}
}
)
series = provider._parse_data(response)
assert series.iloc[0] == pytest.approx(0.000311)
def test_empty_tomorrow_stores_only_today_and_warns(provider):
"""An empty tomorrow list does not create fake values."""
response = TibberGraphQLResponse.model_validate(
{
"data": {
"viewer": {
"homes": [
{
"id": "home-1",
"currentSubscription": {
"priceInfo": {
"today": [
{
"startsAt": "2026-07-07T00:00:00.000+02:00",
"total": 0.3109662,
},
{
"startsAt": "2026-07-07T01:00:00.000+02:00",
"total": 0.2970716,
},
],
"tomorrow": [],
}
},
}
]
}
}
}
)
with patch("akkudoktoreos.prediction.elecpricetibber.logger.warning") as mock_warning:
series = provider._parse_data(response)
assert len(series) == 2
mock_warning.assert_called_once_with("Tibber tomorrow prices not available yet")
@patch("requests.post")
def test_request_forecast_uses_tibber_graphql_api(
mock_post,
provider,
tibber_response_dict,
cache_store,
):
"""Request uses Tibber URL, bearer token, and GraphQL query body."""
cache_store.clear(clear_all=True)
mock_response = Mock()
mock_response.content = json.dumps(tibber_response_dict).encode()
mock_post.return_value = mock_response
response = provider._request_forecast(force_update=True)
assert isinstance(response, TibberGraphQLResponse)
mock_post.assert_called_once()
_, kwargs = mock_post.call_args
assert mock_post.call_args.args[0] == "https://api.tibber.com/v1-beta/gql"
assert kwargs["headers"]["Authorization"] == "Bearer token-123"
assert kwargs["headers"]["Content-Type"] == "application/json"
assert "query" in kwargs["json"]
assert "TibberPriceInfo" in kwargs["json"]["query"]
assert "total" in kwargs["json"]["query"]
assert kwargs["timeout"] == 30

View File

@@ -6,6 +6,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 (
@@ -19,7 +20,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
@@ -39,6 +43,7 @@ def forecast_providers():
return [
ElecPriceAkkudoktor(),
ElecPriceEnergyCharts(),
ElecPriceTibber(),
ElecPriceFixed(),
ElecPriceImport(),
FeedInTariffFixed(),
@@ -49,6 +54,9 @@ def forecast_providers():
LoadImport(),
PVForecastAkkudoktor(),
PVForecastVrm(),
PVForecastPVNode(),
PVForecastForecastSolar(),
PVForecastSolcast(),
PVForecastImport(),
WeatherBrightSky(),
WeatherClearOutside(),
@@ -88,21 +96,25 @@ def test_provider_sequence(prediction):
"""Test the provider sequence is maintained in the Prediction instance."""
assert isinstance(prediction.providers[0], ElecPriceAkkudoktor)
assert isinstance(prediction.providers[1], ElecPriceEnergyCharts)
assert isinstance(prediction.providers[2], ElecPriceFixed)
assert isinstance(prediction.providers[3], ElecPriceImport)
assert isinstance(prediction.providers[4], FeedInTariffFixed)
assert isinstance(prediction.providers[5], FeedInTariffImport)
assert isinstance(prediction.providers[6], LoadAkkudoktor)
assert isinstance(prediction.providers[7], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[8], LoadVrm)
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[2], ElecPriceTibber)
assert isinstance(prediction.providers[3], ElecPriceFixed)
assert isinstance(prediction.providers[4], ElecPriceImport)
assert isinstance(prediction.providers[5], FeedInTariffFixed)
assert isinstance(prediction.providers[6], FeedInTariffImport)
assert isinstance(prediction.providers[7], LoadAkkudoktor)
assert isinstance(prediction.providers[8], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[9], LoadVrm)
assert isinstance(prediction.providers[10], LoadImport)
assert isinstance(prediction.providers[11], PVForecastAkkudoktor)
assert isinstance(prediction.providers[12], PVForecastVrm)
assert isinstance(prediction.providers[13], PVForecastPVNode)
assert isinstance(prediction.providers[14], PVForecastForecastSolar)
assert isinstance(prediction.providers[15], PVForecastSolcast)
assert isinstance(prediction.providers[16], PVForecastImport)
assert isinstance(prediction.providers[17], WeatherBrightSky)
assert isinstance(prediction.providers[18], WeatherClearOutside)
assert isinstance(prediction.providers[19], WeatherOpenMeteo)
assert isinstance(prediction.providers[20], WeatherImport)
def test_provider_by_id(prediction, forecast_providers):
@@ -117,6 +129,7 @@ def test_prediction_repr(prediction):
assert "Prediction([" in result
assert "ElecPriceAkkudoktor" in result
assert "ElecPriceEnergyCharts" in result
assert "ElecPriceTibber" in result
assert "ElecPriceFixed" in result
assert "ElecPriceImport" in result
assert "FeedInTariffFixed" in result

View File

@@ -0,0 +1,129 @@
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:
# 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"
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) # type: ignore
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)

View File

@@ -0,0 +1,145 @@
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:
# 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"]
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)

View File

@@ -0,0 +1,97 @@
from unittest.mock import call, patch
import pendulum
import pytest
import requests
from akkudoktoreos.prediction.pvforecastsolcast import PVForecastSolcast
@pytest.fixture
def pvforecast_instance(config_eos):
settings = {
"pvforecast": {
"provider": "PVForecastSolcast",
"provider_settings": {
"PVForecastSolcast": {"api_key": "dummy-key", "site_id": "site-abc"},
},
},
}
config_eos.merge_settings_from_dict(settings)
start_dt = pendulum.datetime(2025, 1, 1, tz="Europe/Berlin")
return PVForecastSolcast(config=config_eos.load, start_datetime=start_dt)
def _http(forecasts):
return type(
"R",
(),
{
"raise_for_status": lambda self: None,
"json": lambda self: {"forecasts": forecasts},
},
)()
def test_provider_id(pvforecast_instance):
assert PVForecastSolcast.provider_id() == "PVForecastSolcast"
assert pvforecast_instance.enabled() is True
@pytest.mark.parametrize(
"period,minutes",
[("PT30M", 30), ("PT15M", 15), ("PT5M", 5), ("PT1H", 60), ("PT1H30M", 90), ("", 0), (None, 0)],
)
def test_period_minutes(period, minutes):
assert PVForecastSolcast._period_minutes(period) == minutes
def test_update_data_normalises_to_period_start_and_converts_kw(pvforecast_instance):
"""pv_estimate (kW) -> W; timestamp = period_end - period; period_end is UTC."""
body = {
"forecasts": [
{"pv_estimate": 1.2, "period_end": "2025-01-01T12:30:00.0000000Z", "period": "PT30M"},
{"pv_estimate": 0.0, "period_end": "2025-01-01T13:00:00.0000000Z", "period": "PT30M"},
]
}
with patch.object(pvforecast_instance, "_request_forecast", return_value=body), \
patch.object(PVForecastSolcast, "update_value") as mock_update:
pvforecast_instance._update_data()
assert mock_update.call_count == 2
expected = [
call(
pendulum.datetime(2025, 1, 1, 12, 0, tz="UTC"),
{"pvforecast_ac_power": 1200.0, "pvforecast_dc_power": 1200.0},
),
call(
pendulum.datetime(2025, 1, 1, 12, 30, tz="UTC"),
{"pvforecast_ac_power": 0.0, "pvforecast_dc_power": 0.0},
),
]
mock_update.assert_has_calls(expected, any_order=False)
def test_request_forecast_uses_site_and_bearer(pvforecast_instance):
with patch("requests.get", return_value=_http([])) as mock_get:
pvforecast_instance._request_forecast(force_update=True)
url = mock_get.call_args[0][0]
assert url.endswith("/rooftop_sites/site-abc/forecasts")
assert mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer dummy-key"
assert mock_get.call_args.kwargs["params"]["format"] == "json"
def test_update_data_skips_when_disabled(pvforecast_instance, config_eos):
config_eos.merge_settings_from_dict({"pvforecast": {"provider": "PVForecastAkkudoktor"}})
with patch.object(pvforecast_instance, "_request_forecast") as mock_req, \
patch.object(PVForecastSolcast, "update_value") as mock_update:
pvforecast_instance._update_data()
mock_req.assert_not_called()
mock_update.assert_not_called()
def test_request_forecast_raises_on_http_error(pvforecast_instance):
with patch("requests.get", side_effect=requests.Timeout("timed out")):
with pytest.raises(RuntimeError) as exc_info:
pvforecast_instance._request_forecast(force_update=True)
assert "Failed to fetch pvforecast from Solcast" in str(exc_info.value)