fix(config): validate prediction providers before the prediction exists

The provider settings (weather, PV forecast, load, electricity price, feed-in
tariff, electricity fee) validate their provider id against the providers of
the prediction instance. While the configuration is created, that instance
does not exist yet, and the validation falls back to fixed lists. These were
incomplete: weather knew only 'WeatherImport', load lacked
'LoadAkkudoktorAdjusted'. A configuration file with e.g. weather provider
'OpenMeteo' then fails to load:

  Provider 'OpenMeteo' is not a valid weather provider: ['WeatherImport']

This is hidden today because ConfigEOS.documentation_mode() is always true
(_force_documentation_mode is a pydantic private attribute, whose class
attribute is a truthy descriptor), and get_prediction() then creates the
prediction instance on access. Once documentation mode is enabled only
explicitly, the configuration does not load.

Derive the provider ids from the provider classes instead
(prediction_provider_ids). They no longer depend on whether the prediction
instance exists, so the validation is the same before and after
initialization.

Additional changes
- Add the test fixture uncreated_config, which lets get_config(init=True)
  create a new configuration from the config file and restores the old one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bobby Noelte
2026-11-09 12:40:15 +01:00
co-authored by Claude Opus 5.5
parent 4475774c06
commit a0e57a18b7
10 changed files with 132 additions and 98 deletions
+25
View File
@@ -462,6 +462,31 @@ def config_eos(config_eos_factory) -> ConfigEOS:
return config_eos
@pytest.fixture
def uncreated_config(config_eos: ConfigEOS, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Drop the created configuration, so get_config(init=True) creates a new one from the file.
Write the configuration to the returned config file path, then call
``get_config(init=True)``. The new configuration reads the config file, as
the server does. monkeypatch puts the fixture's configuration back into
both ``coreabc._config_eos`` and the ``ConfigEOS`` singleton registry, so
the two stay in sync after the test.
Returns:
Path of the config file the new configuration reads.
"""
from akkudoktoreos.core import coreabc
config_file_path = config_eos.general.config_file_path
if config_file_path is None:
raise RuntimeError("The test configuration has no config file path.")
singleton_instances: dict[type, object] = ConfigEOS._instances
monkeypatch.setattr(coreabc, "_config_eos", None)
monkeypatch.setattr(ConfigEOS, "_init_config_eos", {"with_file_settings": True})
monkeypatch.delitem(singleton_instances, ConfigEOS, raising=False)
return config_file_path
# ------------------------------------
# Provide pytest EOS server management
# ------------------------------------
+31
View File
@@ -854,3 +854,34 @@ class TestDeviceSettings:
def test_device_map_rejects_conflicting_identity(self):
with pytest.raises(ValidationError, match="device_id"):
DevicesCommonSettings.model_validate({"batteries": {"house": {"device_id": "other"}}})
class TestConfigProviderValidation:
"""Provider ids are validated while the configuration is created."""
def test_config_with_providers_is_created_before_the_prediction(
self, uncreated_config, monkeypatch
):
"""No prediction instance exists yet while the configuration is created."""
from akkudoktoreos.core import coreabc
from akkudoktoreos.core.version import __version__
providers = {
"weather": {"provider": "OpenMeteo"},
"load": {"provider": "LoadAkkudoktorAdjusted"},
"pvforecast": {"provider": "PVForecastPVLib"},
"elecprice": {"provider": "ElecPriceEnergyCharts"},
"feedintariff": {"provider": "FeedInTariffFixed"},
"elecfee": {"provider": "ElecFeeFixed"},
}
# A current version: the file is validated as is, without migration.
uncreated_config.write_text(json.dumps({"general": {"version": __version__}, **providers}))
monkeypatch.setattr(coreabc, "_prediction_eos", None)
# Outside of documentation mode get_prediction() does not create the
# prediction instance on access.
monkeypatch.setattr(ConfigEOS, "documentation_mode", classmethod(lambda cls: False))
config = coreabc.get_config(init=True)
for section, value in providers.items():
assert getattr(config, section).provider == value["provider"]
+31
View File
@@ -209,3 +209,34 @@ async def test_empty_providers(prediction, forecast_providers):
# Cleanup after Test
prediction.providers = providers_bkup
@pytest.mark.parametrize(
"provider_type",
[
"WeatherProvider",
"PVForecastProvider",
"LoadProvider",
"ElecPriceProvider",
"FeedInTariffProvider",
"ElecFeeProvider",
],
)
def test_provider_ids_without_prediction_instance(prediction, provider_type):
"""The ids from the provider classes match the providers of the prediction."""
import importlib
from akkudoktoreos.prediction.prediction import prediction_provider_ids
module_name = {
"WeatherProvider": "weatherabc",
"PVForecastProvider": "pvforecastabc",
"LoadProvider": "loadabc",
"ElecPriceProvider": "elecpriceabc",
"FeedInTariffProvider": "feedintariffabc",
"ElecFeeProvider": "elecfeeabc",
}[provider_type]
base = getattr(importlib.import_module(f"akkudoktoreos.prediction.{module_name}"), provider_type)
expected = {p.provider_id() for p in prediction.providers if isinstance(p, base)}
assert expected
assert set(prediction_provider_ids(base)) == expected