mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-08-02 03:42:26 +00:00
feat(VRM forecast): add load and pv forecast by VRM API (#611)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Add support for fetching forecasts from the VRM API by Victron Energy. Retrieve forecasts for PV generation and total load of a Victron system from the internet. Tests for the new modules have been added, and the documentation has been updated accordingly. Signed-off-by: redmoon2711 <redmoon2711@gmx.de>
This commit is contained in:
parent
e5500b6857
commit
8e69caba73
@ -477,7 +477,7 @@ Validators:
|
|||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| provider | `EOS_LOAD__PROVIDER` | `Optional[str]` | `rw` | `None` | Load provider id of provider to be used. |
|
| provider | `EOS_LOAD__PROVIDER` | `Optional[str]` | `rw` | `None` | Load provider id of provider to be used. |
|
||||||
| provider_settings | `EOS_LOAD__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktorCommonSettings, akkudoktoreos.prediction.loadimport.LoadImportCommonSettings, NoneType]` | `rw` | `None` | Provider settings |
|
| provider_settings | `EOS_LOAD__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktorCommonSettings, akkudoktoreos.prediction.loadvrm.LoadVrmCommonSettings, akkudoktoreos.prediction.loadimport.LoadImportCommonSettings, NoneType]` | `rw` | `None` | Provider settings |
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Example Input/Output
|
### Example Input/Output
|
||||||
@ -520,6 +520,33 @@ Validators:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Common settings for VRM API
|
||||||
|
|
||||||
|
:::{table} load::provider_settings
|
||||||
|
:widths: 10 10 5 5 30
|
||||||
|
:align: left
|
||||||
|
|
||||||
|
| Name | Type | Read-Only | Default | Description |
|
||||||
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
|
| load_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API |
|
||||||
|
| load_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID |
|
||||||
|
:::
|
||||||
|
|
||||||
|
#### Example Input/Output
|
||||||
|
|
||||||
|
```{eval-rst}
|
||||||
|
.. code-block:: json
|
||||||
|
|
||||||
|
{
|
||||||
|
"load": {
|
||||||
|
"provider_settings": {
|
||||||
|
"load_vrm_token": "your-token",
|
||||||
|
"load_vrm_idsite": 12345
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Common settings for load data import from file
|
### Common settings for load data import from file
|
||||||
|
|
||||||
:::{table} load::provider_settings
|
:::{table} load::provider_settings
|
||||||
@ -554,7 +581,7 @@ Validators:
|
|||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| provider | `EOS_PVFORECAST__PROVIDER` | `Optional[str]` | `rw` | `None` | PVForecast provider id of provider to be used. |
|
| provider | `EOS_PVFORECAST__PROVIDER` | `Optional[str]` | `rw` | `None` | PVForecast provider id of provider to be used. |
|
||||||
| provider_settings | `EOS_PVFORECAST__PROVIDER_SETTINGS` | `Optional[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings]` | `rw` | `None` | Provider settings |
|
| provider_settings | `EOS_PVFORECAST__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings, akkudoktoreos.prediction.pvforecastvrm.PVforecastVrmCommonSettings, NoneType]` | `rw` | `None` | Provider settings |
|
||||||
| planes | `EOS_PVFORECAST__PLANES` | `Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]` | `rw` | `None` | Plane configuration. |
|
| planes | `EOS_PVFORECAST__PLANES` | `Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]` | `rw` | `None` | Plane configuration. |
|
||||||
| max_planes | `EOS_PVFORECAST__MAX_PLANES` | `Optional[int]` | `rw` | `0` | Maximum number of planes that can be set |
|
| max_planes | `EOS_PVFORECAST__MAX_PLANES` | `Optional[int]` | `rw` | `0` | Maximum number of planes that can be set |
|
||||||
| planes_peakpower | | `List[float]` | `ro` | `N/A` | Compute a list of the peak power per active planes. |
|
| planes_peakpower | | `List[float]` | `ro` | `N/A` | Compute a list of the peak power per active planes. |
|
||||||
@ -795,6 +822,33 @@ Validators:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Common settings for VRM API
|
||||||
|
|
||||||
|
:::{table} pvforecast::provider_settings
|
||||||
|
:widths: 10 10 5 5 30
|
||||||
|
:align: left
|
||||||
|
|
||||||
|
| Name | Type | Read-Only | Default | Description |
|
||||||
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
|
| pvforecast_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API |
|
||||||
|
| pvforecast_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID |
|
||||||
|
:::
|
||||||
|
|
||||||
|
#### Example Input/Output
|
||||||
|
|
||||||
|
```{eval-rst}
|
||||||
|
.. code-block:: json
|
||||||
|
|
||||||
|
{
|
||||||
|
"pvforecast": {
|
||||||
|
"provider_settings": {
|
||||||
|
"pvforecast_vrm_token": "your-token",
|
||||||
|
"pvforecast_vrm_idsite": 12345
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Common settings for pvforecast data import from file or JSON string
|
### Common settings for pvforecast data import from file or JSON string
|
||||||
|
|
||||||
:::{table} pvforecast::provider_settings
|
:::{table} pvforecast::provider_settings
|
||||||
|
@ -184,6 +184,7 @@ Configuration options:
|
|||||||
- `provider`: Load provider id of provider to be used.
|
- `provider`: Load provider id of provider to be used.
|
||||||
|
|
||||||
- `LoadAkkudoktor`: Retrieves from local database.
|
- `LoadAkkudoktor`: Retrieves from local database.
|
||||||
|
- `LoadVrm`: Retrieves data from the VRM API by Victron Energy.
|
||||||
- `LoadImport`: Imports from a file or JSON string.
|
- `LoadImport`: Imports from a file or JSON string.
|
||||||
|
|
||||||
- `provider_settings.loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
|
- `provider_settings.loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
|
||||||
@ -196,6 +197,27 @@ The `LoadAkkudoktor` provider retrieves generic load data from a local database
|
|||||||
align with the annual energy consumption specified in the `loadakkudoktor_year_energy` configuration
|
align with the annual energy consumption specified in the `loadakkudoktor_year_energy` configuration
|
||||||
option.
|
option.
|
||||||
|
|
||||||
|
### LoadVrm Provider
|
||||||
|
|
||||||
|
The `LoadVrm` provider retrieves load forecast data from the VRM API by Victron Energy.
|
||||||
|
To receive forecasts, the system data must be configured under Dynamic ESS in the VRM portal.
|
||||||
|
To query the forecasts, an API token is required, which can also be created in the VRM portal under Preferences.
|
||||||
|
This token must be stored in the EOS configuration along with the VRM-Installations-ID.
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"load": {
|
||||||
|
"provider": "LoadVrm",
|
||||||
|
"provider_settings": {
|
||||||
|
"load_vrm_token": "dummy-token",
|
||||||
|
"load_vrm_idsite": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The prediction keys for the load forecast data are:
|
||||||
|
|
||||||
|
- `load_mean`: Predicted load mean value (W).
|
||||||
|
|
||||||
### LoadImport Provider
|
### LoadImport Provider
|
||||||
|
|
||||||
The `LoadImport` provider is designed to import load forecast data from a file or a JSON
|
The `LoadImport` provider is designed to import load forecast data from a file or a JSON
|
||||||
@ -234,6 +256,7 @@ Configuration options:
|
|||||||
- `provider`: PVForecast provider id of provider to be used.
|
- `provider`: PVForecast provider id of provider to be used.
|
||||||
|
|
||||||
- `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net.
|
- `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net.
|
||||||
|
- `PVForecastVrm`: Retrieves data from the VRM API by Victron Energy.
|
||||||
- `PVForecastImport`: Imports from a file or JSON string.
|
- `PVForecastImport`: Imports from a file or JSON string.
|
||||||
|
|
||||||
- `planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
- `planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||||
@ -422,6 +445,28 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### PVForecastVrm Provider
|
||||||
|
|
||||||
|
The `PVForecastVrm` provider retrieves pv power forecast data from the VRM API by Victron Energy.
|
||||||
|
To receive forecasts, the system data must be configured under Dynamic ESS in the VRM portal.
|
||||||
|
To query the forecasts, an API token is required, which can also be created in the VRM portal under Preferences.
|
||||||
|
This token must be stored in the EOS configuration along with the VRM-Installations-ID.
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"pvforecast": {
|
||||||
|
"provider": "PVForecastVrm",
|
||||||
|
"provider_settings": {
|
||||||
|
"pvforecast_vrm_token": "dummy-token",
|
||||||
|
"pvforecast_vrm_idsite": 12345
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The prediction keys for the PV forecast data are:
|
||||||
|
|
||||||
|
- `pvforecast_dc_power`: Total DC power (W).
|
||||||
|
|
||||||
### PVForecastImport Provider
|
### PVForecastImport Provider
|
||||||
|
|
||||||
The `PVForecastImport` provider is designed to import PV forecast data from a file or a JSON
|
The `PVForecastImport` provider is designed to import PV forecast data from a file or a JSON
|
||||||
@ -430,8 +475,8 @@ becomes available.
|
|||||||
|
|
||||||
The prediction keys for the PV forecast data are:
|
The prediction keys for the PV forecast data are:
|
||||||
|
|
||||||
- `pvforecast_ac_power`: Total DC power (W).
|
- `pvforecast_ac_power`: Total AC power (W).
|
||||||
- `pvforecast_dc_power`: Total AC power (W).
|
- `pvforecast_dc_power`: Total DC power (W).
|
||||||
|
|
||||||
The PV forecast data must be provided in one of the formats described in
|
The PV forecast data must be provided in one of the formats described in
|
||||||
<project:#prediction-import-providers>. The data source can be given in the
|
<project:#prediction-import-providers>. The data source can be given in the
|
||||||
|
9685
openapi.json
9685
openapi.json
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ from akkudoktoreos.config.configabc import SettingsBaseModel
|
|||||||
from akkudoktoreos.prediction.loadabc import LoadProvider
|
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||||
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
||||||
|
from akkudoktoreos.prediction.loadvrm import LoadVrmCommonSettings
|
||||||
from akkudoktoreos.prediction.prediction import get_prediction
|
from akkudoktoreos.prediction.prediction import get_prediction
|
||||||
|
|
||||||
prediction_eos = get_prediction()
|
prediction_eos = get_prediction()
|
||||||
@ -29,9 +30,9 @@ class LoadCommonSettings(SettingsBaseModel):
|
|||||||
examples=["LoadAkkudoktor"],
|
examples=["LoadAkkudoktor"],
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
|
provider_settings: Optional[
|
||||||
Field(default=None, description="Provider settings", examples=[None])
|
Union[LoadAkkudoktorCommonSettings, LoadVrmCommonSettings, LoadImportCommonSettings]
|
||||||
)
|
] = Field(default=None, description="Provider settings", examples=[None])
|
||||||
|
|
||||||
# Validators
|
# Validators
|
||||||
@field_validator("provider", mode="after")
|
@field_validator("provider", mode="after")
|
||||||
|
109
src/akkudoktoreos/prediction/loadvrm.py
Normal file
109
src/akkudoktoreos/prediction/loadvrm.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"""Retrieves load forecast data from VRM API."""
|
||||||
|
|
||||||
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from loguru import logger
|
||||||
|
from pendulum import DateTime
|
||||||
|
from pydantic import Field, ValidationError
|
||||||
|
|
||||||
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||||
|
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||||
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||||
|
|
||||||
|
|
||||||
|
class VrmForecastRecords(PydanticBaseModel):
|
||||||
|
vrm_consumption_fc: list[tuple[int, float]]
|
||||||
|
solar_yield_forecast: list[tuple[int, float]]
|
||||||
|
|
||||||
|
|
||||||
|
class VrmForecastResponse(PydanticBaseModel):
|
||||||
|
success: bool
|
||||||
|
records: VrmForecastRecords
|
||||||
|
totals: dict
|
||||||
|
|
||||||
|
|
||||||
|
class LoadVrmCommonSettings(SettingsBaseModel):
|
||||||
|
"""Common settings for VRM API."""
|
||||||
|
|
||||||
|
load_vrm_token: str = Field(
|
||||||
|
default="your-token", description="Token for Connecting VRM API", examples=["your-token"]
|
||||||
|
)
|
||||||
|
load_vrm_idsite: int = Field(default=12345, description="VRM-Installation-ID", examples=[12345])
|
||||||
|
|
||||||
|
|
||||||
|
class LoadVrm(LoadProvider):
|
||||||
|
"""Fetch Load forecast data from VRM API."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def provider_id(cls) -> str:
|
||||||
|
return "LoadVrm"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse:
|
||||||
|
"""Validate the VRM API load forecast response."""
|
||||||
|
try:
|
||||||
|
return VrmForecastResponse.model_validate_json(json_str)
|
||||||
|
except ValidationError as e:
|
||||||
|
error_msg = "\n".join(
|
||||||
|
f"Field: {' -> '.join(str(x) for x in err['loc'])}\n"
|
||||||
|
f"Error: {err['msg']}\nType: {err['type']}"
|
||||||
|
for err in e.errors()
|
||||||
|
)
|
||||||
|
logger.error(f"VRM-API schema validation failed:\n{error_msg}")
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
|
def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse:
|
||||||
|
"""Fetch forecast data from Victron VRM API."""
|
||||||
|
base_url = "https://vrmapi.victronenergy.com/v2/installations"
|
||||||
|
installation_id = self.config.load.provider_settings.load_vrm_idsite
|
||||||
|
api_token = self.config.load.provider_settings.load_vrm_token
|
||||||
|
|
||||||
|
url = f"{base_url}/{installation_id}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours"
|
||||||
|
headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
logger.debug(f"Requesting VRM load forecast: {url}")
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Error during VRM API request: {e}")
|
||||||
|
raise RuntimeError("Failed to fetch load forecast from VRM API") from e
|
||||||
|
|
||||||
|
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||||
|
return self._validate_data(response.content)
|
||||||
|
|
||||||
|
def _ts_to_datetime(self, timestamp: int) -> DateTime:
|
||||||
|
"""Convert UNIX ms timestamp to timezone-aware datetime."""
|
||||||
|
return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone)
|
||||||
|
|
||||||
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
|
"""Fetch and store VRM load forecast as load_mean and related values."""
|
||||||
|
start_date = self.start_datetime.start_of("day")
|
||||||
|
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
|
||||||
|
start_ts = int(start_date.timestamp())
|
||||||
|
end_ts = int(end_date.timestamp())
|
||||||
|
|
||||||
|
logger.info(f"Updating Load forecast from VRM: {start_date} to {end_date}")
|
||||||
|
vrm_forecast_data = self._request_forecast(start_ts, end_ts)
|
||||||
|
|
||||||
|
load_mean_data = []
|
||||||
|
for timestamp, value in vrm_forecast_data.records.vrm_consumption_fc:
|
||||||
|
date = self._ts_to_datetime(timestamp)
|
||||||
|
rounded_value = round(value, 2)
|
||||||
|
|
||||||
|
self.update_value(
|
||||||
|
date,
|
||||||
|
{"load_mean": rounded_value, "load_std": 0.0, "load_mean_adjusted": rounded_value},
|
||||||
|
)
|
||||||
|
|
||||||
|
load_mean_data.append((date, rounded_value))
|
||||||
|
|
||||||
|
logger.debug(f"Updated load_mean with {len(load_mean_data)} entries.")
|
||||||
|
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
lv = LoadVrm()
|
||||||
|
lv._update_data()
|
@ -36,9 +36,11 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
|||||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
|
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
|
||||||
from akkudoktoreos.prediction.loadimport import LoadImport
|
from akkudoktoreos.prediction.loadimport import LoadImport
|
||||||
|
from akkudoktoreos.prediction.loadvrm import LoadVrm
|
||||||
from akkudoktoreos.prediction.predictionabc import PredictionContainer
|
from akkudoktoreos.prediction.predictionabc import PredictionContainer
|
||||||
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
||||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
||||||
|
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
|
||||||
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
|
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
|
||||||
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
||||||
from akkudoktoreos.prediction.weatherimport import WeatherImport
|
from akkudoktoreos.prediction.weatherimport import WeatherImport
|
||||||
@ -87,8 +89,10 @@ class Prediction(PredictionContainer):
|
|||||||
ElecPriceEnergyCharts,
|
ElecPriceEnergyCharts,
|
||||||
ElecPriceImport,
|
ElecPriceImport,
|
||||||
LoadAkkudoktor,
|
LoadAkkudoktor,
|
||||||
|
LoadVrm,
|
||||||
LoadImport,
|
LoadImport,
|
||||||
PVForecastAkkudoktor,
|
PVForecastAkkudoktor,
|
||||||
|
PVForecastVrm,
|
||||||
PVForecastImport,
|
PVForecastImport,
|
||||||
WeatherBrightSky,
|
WeatherBrightSky,
|
||||||
WeatherClearOutside,
|
WeatherClearOutside,
|
||||||
@ -102,8 +106,10 @@ elecprice_akkudoktor = ElecPriceAkkudoktor()
|
|||||||
elecprice_energy_charts = ElecPriceEnergyCharts()
|
elecprice_energy_charts = ElecPriceEnergyCharts()
|
||||||
elecprice_import = ElecPriceImport()
|
elecprice_import = ElecPriceImport()
|
||||||
load_akkudoktor = LoadAkkudoktor()
|
load_akkudoktor = LoadAkkudoktor()
|
||||||
|
load_vrm = LoadVrm()
|
||||||
load_import = LoadImport()
|
load_import = LoadImport()
|
||||||
pvforecast_akkudoktor = PVForecastAkkudoktor()
|
pvforecast_akkudoktor = PVForecastAkkudoktor()
|
||||||
|
pvforecast_vrm = PVForecastVrm()
|
||||||
pvforecast_import = PVForecastImport()
|
pvforecast_import = PVForecastImport()
|
||||||
weather_brightsky = WeatherBrightSky()
|
weather_brightsky = WeatherBrightSky()
|
||||||
weather_clearoutside = WeatherClearOutside()
|
weather_clearoutside = WeatherClearOutside()
|
||||||
@ -120,8 +126,10 @@ def get_prediction() -> Prediction:
|
|||||||
elecprice_energy_charts,
|
elecprice_energy_charts,
|
||||||
elecprice_import,
|
elecprice_import,
|
||||||
load_akkudoktor,
|
load_akkudoktor,
|
||||||
|
load_vrm,
|
||||||
load_import,
|
load_import,
|
||||||
pvforecast_akkudoktor,
|
pvforecast_akkudoktor,
|
||||||
|
pvforecast_vrm,
|
||||||
pvforecast_import,
|
pvforecast_import,
|
||||||
weather_brightsky,
|
weather_brightsky,
|
||||||
weather_clearoutside,
|
weather_clearoutside,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
"""PV forecast module for PV power predictions."""
|
"""PV forecast module for PV power predictions."""
|
||||||
|
|
||||||
from typing import Any, List, Optional, Self
|
from typing import Any, List, Optional, Self, Union
|
||||||
|
|
||||||
from pydantic import Field, computed_field, field_validator, model_validator
|
from pydantic import Field, computed_field, field_validator, model_validator
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ from akkudoktoreos.config.configabc import SettingsBaseModel
|
|||||||
from akkudoktoreos.prediction.prediction import get_prediction
|
from akkudoktoreos.prediction.prediction import get_prediction
|
||||||
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
||||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
||||||
|
from akkudoktoreos.prediction.pvforecastvrm import PVforecastVrmCommonSettings
|
||||||
from akkudoktoreos.utils.docs import get_model_structure_from_examples
|
from akkudoktoreos.utils.docs import get_model_structure_from_examples
|
||||||
|
|
||||||
prediction_eos = get_prediction()
|
prediction_eos = get_prediction()
|
||||||
@ -134,9 +135,9 @@ class PVForecastCommonSettings(SettingsBaseModel):
|
|||||||
examples=["PVForecastAkkudoktor"],
|
examples=["PVForecastAkkudoktor"],
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_settings: Optional[PVForecastImportCommonSettings] = Field(
|
provider_settings: Optional[
|
||||||
default=None, description="Provider settings", examples=[None]
|
Union[PVForecastImportCommonSettings, PVforecastVrmCommonSettings]
|
||||||
)
|
] = Field(default=None, description="Provider settings", examples=[None])
|
||||||
|
|
||||||
planes: Optional[list[PVForecastPlaneSetting]] = Field(
|
planes: Optional[list[PVForecastPlaneSetting]] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
110
src/akkudoktoreos/prediction/pvforecastvrm.py
Normal file
110
src/akkudoktoreos/prediction/pvforecastvrm.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"""Retrieves pvforecast data from VRM API."""
|
||||||
|
|
||||||
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from loguru import logger
|
||||||
|
from pendulum import DateTime
|
||||||
|
from pydantic import Field, ValidationError
|
||||||
|
|
||||||
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||||
|
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
||||||
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||||
|
|
||||||
|
|
||||||
|
class VrmForecastRecords(PydanticBaseModel):
|
||||||
|
vrm_consumption_fc: list[tuple[int, float]]
|
||||||
|
solar_yield_forecast: list[tuple[int, float]]
|
||||||
|
|
||||||
|
|
||||||
|
class VrmForecastResponse(PydanticBaseModel):
|
||||||
|
success: bool
|
||||||
|
records: VrmForecastRecords
|
||||||
|
totals: dict
|
||||||
|
|
||||||
|
|
||||||
|
class PVforecastVrmCommonSettings(SettingsBaseModel):
|
||||||
|
"""Common settings for VRM API."""
|
||||||
|
|
||||||
|
pvforecast_vrm_token: str = Field(
|
||||||
|
default="your-token", description="Token for Connecting VRM API", examples=["your-token"]
|
||||||
|
)
|
||||||
|
pvforecast_vrm_idsite: int = Field(
|
||||||
|
default=12345, description="VRM-Installation-ID", examples=[12345]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PVForecastVrm(PVForecastProvider):
|
||||||
|
"""Fetch and process PV forecast data from VRM API."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def provider_id(cls) -> str:
|
||||||
|
"""Return the unique identifier for the PV-Forecast-Provider."""
|
||||||
|
return "PVForecastVrm"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse:
|
||||||
|
"""Validate the VRM forecast response data against the expected schema."""
|
||||||
|
try:
|
||||||
|
return VrmForecastResponse.model_validate_json(json_str)
|
||||||
|
except ValidationError as e:
|
||||||
|
error_msg = "\n".join(
|
||||||
|
f"Field: {' -> '.join(str(x) for x in err['loc'])}\n"
|
||||||
|
f"Error: {err['msg']}\nType: {err['type']}"
|
||||||
|
for err in e.errors()
|
||||||
|
)
|
||||||
|
logger.error(f"VRM-API schema change:\n{error_msg}")
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
|
def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse:
|
||||||
|
"""Fetch forecast data from Victron VRM API."""
|
||||||
|
source = "https://vrmapi.victronenergy.com/v2/installations"
|
||||||
|
id_site = self.config.pvforecast.provider_settings.pvforecast_vrm_idsite
|
||||||
|
api_token = self.config.pvforecast.provider_settings.pvforecast_vrm_token
|
||||||
|
headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"}
|
||||||
|
url = f"{source}/{id_site}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours"
|
||||||
|
logger.debug(f"Requesting VRM forecast: {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Failed to fetch pvforecast: {e}")
|
||||||
|
raise RuntimeError("Failed to fetch pvforecast from VRM API") from e
|
||||||
|
|
||||||
|
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||||
|
return self._validate_data(response.content)
|
||||||
|
|
||||||
|
def _ts_to_datetime(self, timestamp: int) -> DateTime:
|
||||||
|
"""Convert UNIX ms timestamp to timezone-aware datetime."""
|
||||||
|
return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone)
|
||||||
|
|
||||||
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
|
"""Update forecast data in the PVForecastDataRecord format."""
|
||||||
|
start_date = self.start_datetime.start_of("day")
|
||||||
|
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
|
||||||
|
start_ts = int(start_date.timestamp())
|
||||||
|
end_ts = int(end_date.timestamp())
|
||||||
|
|
||||||
|
logger.info(f"Updating PV forecast from VRM: {start_date} to {end_date}")
|
||||||
|
vrm_forecast_data = self._request_forecast(start_ts, end_ts)
|
||||||
|
|
||||||
|
pv_forecast = []
|
||||||
|
for timestamp, value in vrm_forecast_data.records.solar_yield_forecast:
|
||||||
|
date = self._ts_to_datetime(timestamp)
|
||||||
|
dc_power = round(value, 2)
|
||||||
|
ac_power = round(dc_power * 0.96, 2)
|
||||||
|
self.update_value(
|
||||||
|
date, {"pvforecast_dc_power": dc_power, "pvforecast_ac_power": ac_power}
|
||||||
|
)
|
||||||
|
pv_forecast.append((date, dc_power))
|
||||||
|
|
||||||
|
logger.debug(f"Updated pvforecast_dc_power with {len(pv_forecast)} entries.")
|
||||||
|
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||||
|
|
||||||
|
|
||||||
|
# Example usage
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pv = PVForecastVrm()
|
||||||
|
pv._update_data()
|
116
tests/test_loadvrm.py
Normal file
116
tests/test_loadvrm.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import json
|
||||||
|
from unittest.mock import call, patch
|
||||||
|
|
||||||
|
import pendulum
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from akkudoktoreos.prediction.loadvrm import (
|
||||||
|
LoadVrm,
|
||||||
|
VrmForecastRecords,
|
||||||
|
VrmForecastResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def load_vrm_instance(config_eos):
|
||||||
|
# Settings für LoadVrm
|
||||||
|
settings = {
|
||||||
|
"load": {
|
||||||
|
"provider": "LoadVrm",
|
||||||
|
"provider_settings": {
|
||||||
|
"load_vrm_token": "dummy-token",
|
||||||
|
"load_vrm_idsite": 12345
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config_eos.merge_settings_from_dict(settings)
|
||||||
|
# start_datetime initialize
|
||||||
|
start_dt = pendulum.datetime(2025, 1, 1, tz='Europe/Berlin')
|
||||||
|
|
||||||
|
# create LoadVrm-instance with config and start_datetime
|
||||||
|
lv = LoadVrm(config=config_eos.load, start_datetime=start_dt)
|
||||||
|
|
||||||
|
return lv
|
||||||
|
|
||||||
|
|
||||||
|
def mock_forecast_response():
|
||||||
|
"""Return a fake VrmForecastResponse with sample data."""
|
||||||
|
return VrmForecastResponse(
|
||||||
|
success=True,
|
||||||
|
records=VrmForecastRecords(
|
||||||
|
vrm_consumption_fc=[
|
||||||
|
(pendulum.datetime(2025, 1, 1, 0, 0, tz='Europe/Berlin').int_timestamp * 1000, 100.5),
|
||||||
|
(pendulum.datetime(2025, 1, 1, 1, 0, tz='Europe/Berlin').int_timestamp * 1000, 101.2)
|
||||||
|
],
|
||||||
|
solar_yield_forecast=[]
|
||||||
|
),
|
||||||
|
totals={}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_data_calls_update_value(load_vrm_instance):
|
||||||
|
with patch.object(load_vrm_instance, "_request_forecast", return_value=mock_forecast_response()), \
|
||||||
|
patch.object(LoadVrm, "update_value") as mock_update:
|
||||||
|
|
||||||
|
load_vrm_instance._update_data()
|
||||||
|
|
||||||
|
assert mock_update.call_count == 2
|
||||||
|
|
||||||
|
expected_calls = [
|
||||||
|
call(
|
||||||
|
pendulum.datetime(2025, 1, 1, 0, 0, 0, tz='Europe/Berlin'),
|
||||||
|
{"load_mean": 100.5, "load_std": 0.0, "load_mean_adjusted": 100.5}
|
||||||
|
),
|
||||||
|
call(
|
||||||
|
pendulum.datetime(2025, 1, 1, 1, 0, 0, tz='Europe/Berlin'),
|
||||||
|
{"load_mean": 101.2, "load_std": 0.0, "load_mean_adjusted": 101.2}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_update.assert_has_calls(expected_calls, any_order=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_data_accepts_valid_json():
|
||||||
|
"""Test that _validate_data doesn't raise with valid input."""
|
||||||
|
response = mock_forecast_response()
|
||||||
|
json_data = response.model_dump_json()
|
||||||
|
|
||||||
|
validated = LoadVrm._validate_data(json_data)
|
||||||
|
assert validated.success
|
||||||
|
assert len(validated.records.vrm_consumption_fc) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_data_raises_on_invalid_json():
|
||||||
|
"""_validate_data should raise ValueError on schema mismatch."""
|
||||||
|
invalid_json = json.dumps({"success": True}) # missing 'records'
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
LoadVrm._validate_data(invalid_json)
|
||||||
|
|
||||||
|
assert "Field:" in str(exc_info.value)
|
||||||
|
assert "records" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_forecast_raises_on_http_error(load_vrm_instance):
|
||||||
|
with patch("requests.get", side_effect=requests.Timeout("Request timed out")) as mock_get:
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
load_vrm_instance._request_forecast(0, 1)
|
||||||
|
|
||||||
|
assert "Failed to fetch load forecast" in str(exc_info.value)
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_data_does_nothing_on_empty_forecast(load_vrm_instance):
|
||||||
|
empty_response = VrmForecastResponse(
|
||||||
|
success=True,
|
||||||
|
records=VrmForecastRecords(vrm_consumption_fc=[], solar_yield_forecast=[]),
|
||||||
|
totals={}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(load_vrm_instance, "_request_forecast", return_value=empty_response), \
|
||||||
|
patch.object(LoadVrm, "update_value") as mock_update:
|
||||||
|
|
||||||
|
load_vrm_instance._update_data()
|
||||||
|
|
||||||
|
mock_update.assert_not_called()
|
@ -6,6 +6,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
|||||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
|
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
|
||||||
from akkudoktoreos.prediction.loadimport import LoadImport
|
from akkudoktoreos.prediction.loadimport import LoadImport
|
||||||
|
from akkudoktoreos.prediction.loadvrm import LoadVrm
|
||||||
from akkudoktoreos.prediction.prediction import (
|
from akkudoktoreos.prediction.prediction import (
|
||||||
Prediction,
|
Prediction,
|
||||||
PredictionCommonSettings,
|
PredictionCommonSettings,
|
||||||
@ -13,6 +14,7 @@ from akkudoktoreos.prediction.prediction import (
|
|||||||
)
|
)
|
||||||
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
||||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
||||||
|
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
|
||||||
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
|
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
|
||||||
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
||||||
from akkudoktoreos.prediction.weatherimport import WeatherImport
|
from akkudoktoreos.prediction.weatherimport import WeatherImport
|
||||||
@ -32,8 +34,10 @@ def forecast_providers():
|
|||||||
ElecPriceEnergyCharts(),
|
ElecPriceEnergyCharts(),
|
||||||
ElecPriceImport(),
|
ElecPriceImport(),
|
||||||
LoadAkkudoktor(),
|
LoadAkkudoktor(),
|
||||||
|
LoadVrm(),
|
||||||
LoadImport(),
|
LoadImport(),
|
||||||
PVForecastAkkudoktor(),
|
PVForecastAkkudoktor(),
|
||||||
|
PVForecastVrm(),
|
||||||
PVForecastImport(),
|
PVForecastImport(),
|
||||||
WeatherBrightSky(),
|
WeatherBrightSky(),
|
||||||
WeatherClearOutside(),
|
WeatherClearOutside(),
|
||||||
@ -73,12 +77,14 @@ def test_provider_sequence(prediction):
|
|||||||
assert isinstance(prediction.providers[1], ElecPriceEnergyCharts)
|
assert isinstance(prediction.providers[1], ElecPriceEnergyCharts)
|
||||||
assert isinstance(prediction.providers[2], ElecPriceImport)
|
assert isinstance(prediction.providers[2], ElecPriceImport)
|
||||||
assert isinstance(prediction.providers[3], LoadAkkudoktor)
|
assert isinstance(prediction.providers[3], LoadAkkudoktor)
|
||||||
assert isinstance(prediction.providers[4], LoadImport)
|
assert isinstance(prediction.providers[4], LoadVrm)
|
||||||
assert isinstance(prediction.providers[5], PVForecastAkkudoktor)
|
assert isinstance(prediction.providers[5], LoadImport)
|
||||||
assert isinstance(prediction.providers[6], PVForecastImport)
|
assert isinstance(prediction.providers[6], PVForecastAkkudoktor)
|
||||||
assert isinstance(prediction.providers[7], WeatherBrightSky)
|
assert isinstance(prediction.providers[7], PVForecastVrm)
|
||||||
assert isinstance(prediction.providers[8], WeatherClearOutside)
|
assert isinstance(prediction.providers[8], PVForecastImport)
|
||||||
assert isinstance(prediction.providers[9], WeatherImport)
|
assert isinstance(prediction.providers[9], WeatherBrightSky)
|
||||||
|
assert isinstance(prediction.providers[10], WeatherClearOutside)
|
||||||
|
assert isinstance(prediction.providers[11], WeatherImport)
|
||||||
|
|
||||||
|
|
||||||
def test_provider_by_id(prediction, forecast_providers):
|
def test_provider_by_id(prediction, forecast_providers):
|
||||||
@ -95,8 +101,10 @@ def test_prediction_repr(prediction):
|
|||||||
assert "ElecPriceEnergyCharts" in result
|
assert "ElecPriceEnergyCharts" in result
|
||||||
assert "ElecPriceImport" in result
|
assert "ElecPriceImport" in result
|
||||||
assert "LoadAkkudoktor" in result
|
assert "LoadAkkudoktor" in result
|
||||||
|
assert "LoadVrm" in result
|
||||||
assert "LoadImport" in result
|
assert "LoadImport" in result
|
||||||
assert "PVForecastAkkudoktor" in result
|
assert "PVForecastAkkudoktor" in result
|
||||||
|
assert "PVForecastVrm" in result
|
||||||
assert "PVForecastImport" in result
|
assert "PVForecastImport" in result
|
||||||
assert "WeatherBrightSky" in result
|
assert "WeatherBrightSky" in result
|
||||||
assert "WeatherClearOutside" in result
|
assert "WeatherClearOutside" in result
|
||||||
|
116
tests/test_pvforecastvrm.py
Normal file
116
tests/test_pvforecastvrm.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import json
|
||||||
|
from unittest.mock import call, patch
|
||||||
|
|
||||||
|
import pendulum
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from akkudoktoreos.prediction.pvforecastvrm import (
|
||||||
|
PVForecastVrm,
|
||||||
|
VrmForecastRecords,
|
||||||
|
VrmForecastResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pvforecast_instance(config_eos):
|
||||||
|
# Settings for PVForecastVrm
|
||||||
|
settings = {
|
||||||
|
"pvforecast": {
|
||||||
|
"provider": "PVForecastVrm",
|
||||||
|
"provider_settings": {
|
||||||
|
"pvforecast_vrm_token": "dummy-token",
|
||||||
|
"pvforecast_vrm_idsite": 12345
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config_eos.merge_settings_from_dict(settings)
|
||||||
|
# start_datetime initialize
|
||||||
|
start_dt = pendulum.datetime(2025, 1, 1, tz='Europe/Berlin')
|
||||||
|
|
||||||
|
# create PVForecastVrm-instance with config and start_datetime
|
||||||
|
pv = PVForecastVrm(config=config_eos.load, start_datetime=start_dt)
|
||||||
|
|
||||||
|
return pv
|
||||||
|
|
||||||
|
|
||||||
|
def mock_forecast_response():
|
||||||
|
"""Return a fake VrmForecastResponse with sample data."""
|
||||||
|
return VrmForecastResponse(
|
||||||
|
success=True,
|
||||||
|
records=VrmForecastRecords(
|
||||||
|
vrm_consumption_fc=[],
|
||||||
|
solar_yield_forecast=[
|
||||||
|
(pendulum.datetime(2025, 1, 1, 0, 0, tz='Europe/Berlin').int_timestamp * 1000, 120.0),
|
||||||
|
(pendulum.datetime(2025, 1, 1, 1, 0, tz='Europe/Berlin').int_timestamp * 1000, 130.0)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
totals={}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_data_updates_dc_and_ac_power(pvforecast_instance):
|
||||||
|
with patch.object(pvforecast_instance, "_request_forecast", return_value=mock_forecast_response()), \
|
||||||
|
patch.object(PVForecastVrm, "update_value") as mock_update:
|
||||||
|
|
||||||
|
pvforecast_instance._update_data()
|
||||||
|
|
||||||
|
# Check that update_value was called correctly
|
||||||
|
assert mock_update.call_count == 2
|
||||||
|
|
||||||
|
expected_calls = [
|
||||||
|
call(
|
||||||
|
pendulum.datetime(2025, 1, 1, 0, 0, tz='Europe/Berlin'),
|
||||||
|
{"pvforecast_dc_power": 120.0, "pvforecast_ac_power": 115.2}
|
||||||
|
),
|
||||||
|
call(
|
||||||
|
pendulum.datetime(2025, 1, 1, 1, 0, tz='Europe/Berlin'),
|
||||||
|
{"pvforecast_dc_power": 130.0, "pvforecast_ac_power": 124.8}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_update.assert_has_calls(expected_calls, any_order=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_data_accepts_valid_json():
|
||||||
|
"""Test that _validate_data doesn't raise with valid input."""
|
||||||
|
response = mock_forecast_response()
|
||||||
|
json_data = response.model_dump_json()
|
||||||
|
|
||||||
|
validated = PVForecastVrm._validate_data(json_data)
|
||||||
|
assert validated.success
|
||||||
|
assert len(validated.records.solar_yield_forecast) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_data_invalid_json_raises():
|
||||||
|
"""Test that _validate_data raises with invalid input."""
|
||||||
|
invalid_json = json.dumps({"success": True}) # missing 'records'
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
PVForecastVrm._validate_data(invalid_json)
|
||||||
|
assert "Field:" in str(exc_info.value)
|
||||||
|
assert "records" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_forecast_raises_on_http_error(pvforecast_instance):
|
||||||
|
"""Ensure _request_forecast raises RuntimeError on HTTP failure."""
|
||||||
|
with patch("requests.get", side_effect=requests.Timeout("Request timed out")) as mock_get:
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
pvforecast_instance._request_forecast(0, 1)
|
||||||
|
|
||||||
|
assert "Failed to fetch pvforecast" in str(exc_info.value)
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_data_skips_on_empty_forecast(pvforecast_instance):
|
||||||
|
"""Ensure no update_value calls are made if no forecast data is present."""
|
||||||
|
empty_response = VrmForecastResponse(
|
||||||
|
success=True,
|
||||||
|
records=VrmForecastRecords(vrm_consumption_fc=[], solar_yield_forecast=[]),
|
||||||
|
totals={}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(pvforecast_instance, "_request_forecast", return_value=empty_response), \
|
||||||
|
patch.object(PVForecastVrm, "update_value") as mock_update:
|
||||||
|
|
||||||
|
pvforecast_instance._update_data()
|
||||||
|
mock_update.assert_not_called()
|
Loading…
x
Reference in New Issue
Block a user