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

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:
redmoon2711
2025-07-19 08:55:16 +02:00
committed by GitHub
parent e5500b6857
commit 8e69caba73
11 changed files with 5458 additions and 4829 deletions

116
tests/test_loadvrm.py Normal file
View 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()

View File

@@ -6,6 +6,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
from akkudoktoreos.prediction.loadimport import LoadImport
from akkudoktoreos.prediction.loadvrm import LoadVrm
from akkudoktoreos.prediction.prediction import (
Prediction,
PredictionCommonSettings,
@@ -13,6 +14,7 @@ from akkudoktoreos.prediction.prediction import (
)
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.prediction.weatherimport import WeatherImport
@@ -32,8 +34,10 @@ def forecast_providers():
ElecPriceEnergyCharts(),
ElecPriceImport(),
LoadAkkudoktor(),
LoadVrm(),
LoadImport(),
PVForecastAkkudoktor(),
PVForecastVrm(),
PVForecastImport(),
WeatherBrightSky(),
WeatherClearOutside(),
@@ -73,12 +77,14 @@ def test_provider_sequence(prediction):
assert isinstance(prediction.providers[1], ElecPriceEnergyCharts)
assert isinstance(prediction.providers[2], ElecPriceImport)
assert isinstance(prediction.providers[3], LoadAkkudoktor)
assert isinstance(prediction.providers[4], LoadImport)
assert isinstance(prediction.providers[5], PVForecastAkkudoktor)
assert isinstance(prediction.providers[6], PVForecastImport)
assert isinstance(prediction.providers[7], WeatherBrightSky)
assert isinstance(prediction.providers[8], WeatherClearOutside)
assert isinstance(prediction.providers[9], WeatherImport)
assert isinstance(prediction.providers[4], LoadVrm)
assert isinstance(prediction.providers[5], LoadImport)
assert isinstance(prediction.providers[6], PVForecastAkkudoktor)
assert isinstance(prediction.providers[7], PVForecastVrm)
assert isinstance(prediction.providers[8], PVForecastImport)
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):
@@ -95,8 +101,10 @@ def test_prediction_repr(prediction):
assert "ElecPriceEnergyCharts" in result
assert "ElecPriceImport" in result
assert "LoadAkkudoktor" in result
assert "LoadVrm" in result
assert "LoadImport" in result
assert "PVForecastAkkudoktor" in result
assert "PVForecastVrm" in result
assert "PVForecastImport" in result
assert "WeatherBrightSky" in result
assert "WeatherClearOutside" in result

116
tests/test_pvforecastvrm.py Normal file
View 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()