fix: Improve provider update error handling and add VRM provider settings validation (#887)
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled

* fix: improve error handling for provider updates

Distinguishes failures of active providers from inactive ones.
Propagates errors only for enabled providers, allowing execution
to continue if a non-active provider fails, which avoids unnecessary
interruptions and improves robustness.

* fix: add provider settings validation for forecast requests

Prevents potential runtime errors by checking if provider settings are configured
before accessing forecast credentials.

Raises a clear error when settings are missing to help with debugging misconfigurations.

* refactor(load): move provider settings to top-level fields

Transitions load provider settings from a nested "provider_settings" object with provider-specific keys to dedicated top-level fields.\n\nRemoves the legacy "provider_settings" mapping and updates migration logic to ensure backward compatibility with existing configurations.

* docs: update version numbers and documantation

---------

Co-authored-by: Normann <github@koldrack.com>
This commit is contained in:
Christopher Nadler
2026-02-26 18:31:47 +01:00
committed by GitHub
parent 2ca9c930e5
commit 04420e66ab
20 changed files with 170 additions and 262 deletions

View File

@@ -43,16 +43,21 @@ MIGRATION_MAP: Dict[
# 0.2.0 -> 0.2.0+dev
"elecprice/provider_settings/ElecPriceImport/import_file_path": "elecprice/elecpriceimport/import_file_path",
"elecprice/provider_settings/ElecPriceImport/import_json": "elecprice/elecpriceimport/import_json",
"load/provider_settings/LoadAkkudoktor/loadakkudoktor_year_energy_kwh": "load/loadakkudoktor/loadakkudoktor_year_energy_kwh",
"load/provider_settings/LoadVrm/load_vrm_idsite": "load/loadvrm/load_vrm_idsite",
"load/provider_settings/LoadVrm/load_vrm_token": "load/loadvrm/load_vrm_token",
"load/provider_settings/LoadImport/import_file_path": "load/loadimport/import_file_path",
"load/provider_settings/LoadImport/import_json": "load/loadimport/import_json",
# 0.1.0 -> 0.2.0+dev
"devices/batteries/0/initial_soc_percentage": None,
"devices/electric_vehicles/0/initial_soc_percentage": None,
"elecprice/provider_settings/import_file_path": "elecprice/elecpriceimport/import_file_path",
"elecprice/provider_settings/import_json": "elecprice/elecpriceimport/import_json",
"load/provider_settings/import_file_path": "load/provider_settings/LoadImport/import_file_path",
"load/provider_settings/import_json": "load/provider_settings/LoadImport/import_json",
"load/provider_settings/loadakkudoktor_year_energy": "load/provider_settings/LoadAkkudoktor/loadakkudoktor_year_energy_kwh",
"load/provider_settings/load_vrm_idsite": "load/provider_settings/LoadVrm/load_vrm_idsite",
"load/provider_settings/load_vrm_token": "load/provider_settings/LoadVrm/load_vrm_token",
"load/provider_settings/import_file_path": "load/loadimport/import_file_path",
"load/provider_settings/import_json": "load/loadimport/import_json",
"load/provider_settings/loadakkudoktor_year_energy": "load/loadakkudoktor/loadakkudoktor_year_energy_kwh",
"load/provider_settings/load_vrm_idsite": "load/loadvrm/load_vrm_idsite",
"load/provider_settings/load_vrm_token": "load/loadvrm/load_vrm_token",
"logging/level": "logging/console_level",
"logging/root_level": None,
"measurement/load0_name": "measurement/load_emr_keys/0",

View File

@@ -1982,8 +1982,14 @@ class DataContainer(SingletonMixin, DataABC, MutableMapping):
provider.update_data(force_enable=force_enable, force_update=force_update)
except Exception as ex:
error = f"Provider {provider.provider_id()} fails on update - enabled={provider.enabled()}, force_enable={force_enable}, force_update={force_update}: {ex}"
logger.error(error)
raise RuntimeError(error)
if provider.enabled():
# The active provider failed — this is a real error worth propagating.
logger.error(error)
raise RuntimeError(error)
else:
# A non-active provider failed (e.g. missing config while force_enable=True).
# Log as warning and continue so the remaining providers still run.
logger.warning(error)
def key_to_series(
self,

View File

@@ -337,10 +337,8 @@ class GeneticOptimizationParameters(
{
"load": {
"provider": "LoadAkkudoktor",
"provider_settings": {
"LoadAkkudoktor": {
"loadakkudoktor_year_energy_kwh": "3000",
},
"loadakkudoktor": {
"loadakkudoktor_year_energy_kwh": "3000",
},
},
}

View File

@@ -28,21 +28,6 @@ def load_providers() -> list[str]:
]
class LoadCommonProviderSettings(SettingsBaseModel):
"""Load Prediction Provider Configuration."""
LoadAkkudoktor: Optional[LoadAkkudoktorCommonSettings] = Field(
default=None,
json_schema_extra={"description": "LoadAkkudoktor settings", "examples": [None]},
)
LoadVrm: Optional[LoadVrmCommonSettings] = Field(
default=None, json_schema_extra={"description": "LoadVrm settings", "examples": [None]}
)
LoadImport: Optional[LoadImportCommonSettings] = Field(
default=None, json_schema_extra={"description": "LoadImport settings", "examples": [None]}
)
class LoadCommonSettings(SettingsBaseModel):
"""Load Prediction Configuration."""
@@ -54,19 +39,19 @@ class LoadCommonSettings(SettingsBaseModel):
},
)
provider_settings: LoadCommonProviderSettings = Field(
default_factory=LoadCommonProviderSettings,
json_schema_extra={
"description": "Provider settings",
"examples": [
# Example 1: Empty/default settings (all providers None)
{
"LoadAkkudoktor": None,
"LoadVrm": None,
"LoadImport": None,
},
],
},
loadakkudoktor: LoadAkkudoktorCommonSettings = Field(
default_factory=LoadAkkudoktorCommonSettings,
json_schema_extra={"description": "LoadAkkudoktor provider settings."},
)
loadvrm: LoadVrmCommonSettings = Field(
default_factory=LoadVrmCommonSettings,
json_schema_extra={"description": "LoadVrm provider settings."},
)
loadimport: LoadImportCommonSettings = Field(
default_factory=LoadImportCommonSettings,
json_schema_extra={"description": "LoadImport provider settings."},
)
@computed_field # type: ignore[prop-decorator]

View File

@@ -56,9 +56,7 @@ class LoadAkkudoktor(LoadProvider):
)
# Calculate values in W by relative profile data and yearly consumption given in kWh
data_year_energy = (
profile_data
* self.config.load.provider_settings.LoadAkkudoktor.loadakkudoktor_year_energy_kwh
* 1000
profile_data * self.config.load.loadakkudoktor.loadakkudoktor_year_energy_kwh * 1000
)
except FileNotFoundError:
error_msg = f"Error: File {load_file} not found."

View File

@@ -9,7 +9,6 @@ format, enabling consistent access to forecasted and historical load attributes.
from pathlib import Path
from typing import Optional, Union
from loguru import logger
from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
@@ -64,14 +63,7 @@ class LoadImport(LoadProvider, PredictionImportProvider):
return "LoadImport"
def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.load.provider_settings.LoadImport is None:
logger.debug(f"{self.provider_id()} data update without provider settings.")
return
if self.config.load.provider_settings.LoadImport.import_file_path:
self.import_from_file(
self.config.provider_settings.LoadImport.import_file_path, key_prefix="load"
)
if self.config.load.provider_settings.LoadImport.import_json:
self.import_from_json(
self.config.load.provider_settings.LoadImport.import_json, key_prefix="load"
)
if self.config.load.loadimport.import_file_path:
self.import_from_file(self.config.load.loadimport.import_file_path, key_prefix="load")
if self.config.load.loadimport.import_json:
self.import_from_json(self.config.load.loadimport.import_json, key_prefix="load")

View File

@@ -62,8 +62,9 @@ class LoadVrm(LoadProvider):
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.LoadVrm.load_vrm_idsite
api_token = self.config.load.provider_settings.LoadVrm.load_vrm_token
vrm_settings = self.config.load.loadvrm
installation_id = vrm_settings.load_vrm_idsite
api_token = vrm_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"}
@@ -85,6 +86,9 @@ class LoadVrm(LoadProvider):
def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Fetch and store VRM load forecast as loadforecast_power_w and related values."""
if self.enabled is False:
logger.info("LoadVrm is disabled, skipping update.")
return
start_date = self.ems_start_datetime.start_of("day")
end_date = self.ems_start_datetime.add(hours=self.config.prediction.hours)
start_ts = int(start_date.timestamp())

View File

@@ -85,6 +85,9 @@ class PVForecastVrm(PVForecastProvider):
def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update forecast data in the PVForecastDataRecord format."""
if self.enabled is False:
logger.info("PVForecastVrm is disabled, skipping update.")
return
start_date = self.ems_start_datetime.start_of("day")
end_date = self.ems_start_datetime.add(hours=self.config.prediction.hours)
start_ts = int(start_date.timestamp())

View File

@@ -154,9 +154,7 @@ def LoadForecast(predictions: pd.DataFrame, config: dict, date_time_tz: str, dar
source = ColumnDataSource(predictions)
provider = config["load"]["provider"]
if provider == "LoadAkkudoktorAdjusted":
year_energy = config["load"]["provider_settings"]["LoadAkkudoktor"][
"loadakkudoktor_year_energy_kwh"
]
year_energy = config["load"]["loadakkudoktor"]["loadakkudoktor_year_energy_kwh"]
provider = f"{provider}, {year_energy} kWh"
plot = figure(

View File

@@ -54,7 +54,7 @@ from akkudoktoreos.optimization.genetic.geneticparams import (
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
from akkudoktoreos.optimization.optimization import OptimizationSolution
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.load import LoadCommonProviderSettings, LoadCommonSettings
from akkudoktoreos.prediction.load import LoadCommonSettings
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
from akkudoktoreos.server.rest.cli import cli_apply_args_to_config, cli_parse_args
@@ -1074,10 +1074,8 @@ async def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
},
"load": {
"provider": "LoadAkkudoktorAdjusted",
"provider_settings": {
"LoadAkkudoktor": {
"loadakkudoktor_year_energy_kwh": request.year_energy,
},
"loadakkudoktor": {
"loadakkudoktor_year_energy_kwh": request.year_energy,
},
},
"measurement": {
@@ -1174,10 +1172,8 @@ async def fastapi_gesamtlast_simple(year_energy: float) -> list[float]:
settings = SettingsEOS(
load=LoadCommonSettings(
provider="LoadAkkudoktor",
provider_settings=LoadCommonProviderSettings(
LoadAkkudoktor=LoadAkkudoktorCommonSettings(
loadakkudoktor_year_energy_kwh=year_energy / 1000, # Convert to kWh
),
loadakkudoktor=LoadAkkudoktorCommonSettings(
loadakkudoktor_year_energy_kwh=year_energy / 1000, # Convert to kWh
),
)
)