Files
EOS/tests/test_config.py
T
Bobby NoelteandClaude Opus 5.5 e255f987bf feat(devices): add battery profiles
A battery profile describes a battery model: the charge and discharge limit
maps over temperature and SoC, the charge and discharge efficiency curves
over C-rate, the technical SoC bounds, the hard temperature cutoffs and,
for a model, its nominal capacity and power limits. EOS packages the
generic profile generic-lfp; users add their own profiles.

A battery or electric vehicle selects its profile by battery_profile and
describes how the installation differs from it in profile_adjustment
(capacity, power limits of the installation, periodic full charge). The
effective values are resolved from both at the measured operating point
(temperature, SoC, power) and shown as effective_* fields.

The GENETIC simulation uses the limits and efficiencies of the profile over
SoC, with precomputed tables as a fast path; GENETIC0 gets the values at the
operating point. Optimization parameters are always valid: a battery whose
profile is missing, unreadable or invalid uses the packaged generic-lfp
profile, and the former defaults only if that is not usable either; each is
logged as error, and an optimization run is never cancelled because of a
battery.

Additional changes
- Add a generic device profile store: user and packaged profiles, atomic
  saves, compiled profiles cached per file and checked again at most once a
  second, plain file names as profile names ('schema' is reserved). On
  Windows, a save or read that collides with the other is retried briefly.
- Add the REST API /v1/profile to list the profile kinds and to list, read
  (document and JSON schema), save and delete profiles.
- Check the hard temperature cutoffs on the exact temperature.
- Resolve inhibited charging or discharging to a power of 0, e.g. for a cold
  battery; give GENETIC0 its own discharge limit for that.
- Limit generic profiles without own power limits to the former 5000 W.
- Accept only the implemented units; current limits need the nominal
  voltage.
- Migrate legacy battery settings into the profile adjustment, keep the
  former charge power limit as discharge limit (also for a Home Assistant
  entity on it) and report dropped efficiencies as errors.
- Limit GENETIC battery discharge by the discharge power.
- Allow battery profile lookups while the configuration is created.
- Resolve a battery once per configuration dump.
- Package the bundled profiles, document battery profiles and regenerate
  the configuration and OpenAPI documentation.
- Ignore the report PDFs written by the visualization tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-by: Claude Code (claude-opus-5-5)
2026-11-11 22:08:18 +01:00

1031 lines
41 KiB
Python

import json
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import Mock, patch
import numpy as np
import pytest
from pydantic import ValidationError
from akkudoktoreos.config.config import ConfigEOS, GeneralSettings
from akkudoktoreos.devices.devices import DevicesCommonSettings
def assert_values_equal(actual, expected):
"""Compare values, handling lists and numpy arrays."""
if isinstance(actual, (list, np.ndarray)) or isinstance(expected, (list, np.ndarray)):
# Convert both to numpy arrays for comparison
actual_arr = np.array(actual)
expected_arr = np.array(expected)
assert np.array_equal(actual_arr, expected_arr), f"Expected {expected_arr}, but got {actual_arr}"
else:
assert actual == expected, f"Expected {expected}, but got {actual}"
# ------------------------------------
# Runtime settings priority (issue #1303)
# ------------------------------------
@pytest.fixture
def config_eos_file(config_eos_factory) -> ConfigEOS:
"""ConfigEOS with the EOS configuration file as an active settings source."""
return config_eos_factory(
init={
"with_init_settings": True,
"with_env_settings": True,
"with_dotenv_settings": False,
"with_file_settings": True,
"with_file_secret_settings": False,
}
)
def write_config_file(config_eos: ConfigEOS, settings: dict[str, Any]) -> None:
"""Write settings to the EOS configuration file and load them."""
settings = {"general": {"version": config_eos.general.version}, **settings}
config_file_path = config_eos.general.config_file_path
assert config_file_path is not None
config_file_path.write_text(json.dumps(settings), encoding="utf-8")
config_eos.reset_settings()
class TestConfigEOS:
"""Configuration loading and runtime overrides without the ConfigMixin patch."""
@pytest.fixture(autouse=True)
def config_mixin(self):
"""Exercise real configuration lookup within this class."""
pass
def test_fixture_new_config_file(self, config_default_dirs):
"""Assure fixture stash_config_file is working."""
config_default_dir_user, config_default_dir_cwd, _, _ = config_default_dirs
config_file_user = config_default_dir_user.joinpath(ConfigEOS.CONFIG_FILE_NAME)
config_file_cwd = config_default_dir_cwd.joinpath(ConfigEOS.CONFIG_FILE_NAME)
assert not config_file_user.exists()
assert not config_file_cwd.exists()
def test_config_constants(self, config_eos):
"""Test config constants are the way expected by the tests."""
assert config_eos.APP_NAME == "net.akkudoktor.eos"
assert config_eos.APP_AUTHOR == "akkudoktor"
assert config_eos.EOS_DIR == "EOS_DIR"
assert config_eos.ENCODING == "UTF-8"
assert config_eos.CONFIG_FILE_NAME == "EOS.config.json"
def test_computed_paths(self, config_eos, monkeypatch):
"""Test computed paths for output and cache."""
# Ensure we do not have the environment patched by conftest.py
monkeypatch.delenv("EOS_GENERAL__DATA_FOLDER_PATH", raising=False)
monkeypatch.delenv("EOS_GENERAL__DATA_CACHE_SUBPATH", raising=False)
monkeypatch.delenv("EOS_GENERAL__DATA_OUTPUT_SUBPATH", raising=False)
# Don't actually try to create the data folder
with patch("pathlib.Path.mkdir"), \
patch("pathlib.Path.is_dir", return_value=True), \
patch("pathlib.Path.exists", return_value=True):
config_eos.merge_settings_from_dict(
{
"general": {
"data_folder_path": "/base/data",
"data_output_subpath": "extra/output",
},
"cache": {
"subpath": "somewhere/cache",
},
}
)
assert config_eos.general.data_folder_path == Path("/base/data")
assert config_eos.general.data_output_path == Path("/base/data/extra/output")
assert config_eos.cache.path() == Path("/base/data/somewhere/cache")
# Check non configurable pathes
assert config_eos.package_root_path == Path(__file__).parent.parent.resolve().joinpath(
"src/akkudoktoreos"
)
# reset settings so the config_eos fixture can verify the default paths
config_eos.reset_settings()
def test_config_from_env(self, monkeypatch, config_eos):
"""Test configuration from env."""
assert config_eos.server.port == 8503
assert config_eos.server.eosdash_port == 8504
monkeypatch.setenv("EOS_SERVER__PORT", "8553")
monkeypatch.setenv("EOS_SERVER__EOSDASH_PORT", "8555")
config_eos.reset_settings()
assert config_eos.server.port == 8553
assert config_eos.server.eosdash_port == 8555
def test_config_ipaddress(self, monkeypatch, config_eos):
"""Validate configured IPs and hostnames without external DNS."""
resolver = Mock(side_effect={
"mail.akkudoktor.net": "192.0.2.1",
"localhost": "127.0.0.1",
}.__getitem__)
monkeypatch.setattr("akkudoktoreos.server.server.socket.gethostbyname", resolver)
assert config_eos.server.host == "127.0.0.1"
monkeypatch.setenv("EOS_SERVER__HOST", "0.0.0.0")
config_eos.reset_settings()
assert config_eos.server.host == "0.0.0.0"
resolver.assert_not_called()
monkeypatch.setenv("EOS_SERVER__HOST", "mail.akkudoktor.net")
config_eos.reset_settings()
assert config_eos.server.host == "mail.akkudoktor.net"
resolver.assert_called_with("mail.akkudoktor.net")
# keep last
monkeypatch.setenv("EOS_SERVER__HOST", "localhost")
config_eos.reset_settings()
assert config_eos.server.host == "localhost"
resolver.assert_called_with("localhost")
def test_singleton_behavior(self, config_eos, config_default_dirs, monkeypatch):
"""Test that ConfigEOS behaves as a singleton."""
config_eos.reset_instance()
monkeypatch.setenv("EOS_CONFIG_DIR", str(config_default_dirs[0]))
instance1 = ConfigEOS()
instance2 = ConfigEOS()
assert instance1 is not config_eos
assert instance1 is instance2
assert instance1._config_file_path == instance2._config_file_path
def test_config_file_priority(self, config_default_dirs):
"""Test config file priority.
Priority is:
1. environment variable directory
2. user configuration directory
3. current working directory
"""
config_default_dir_user, config_default_dir_cwd, _, _ = config_default_dirs
config_file_cwd = Path(config_default_dir_cwd) / ConfigEOS.CONFIG_FILE_NAME
config_file_user = Path(config_default_dir_user) / ConfigEOS.CONFIG_FILE_NAME
assert not config_file_cwd.exists()
assert not config_file_user.exists()
# current working directory (prio 3)
config_file_cwd.write_text("{}")
config_eos = ConfigEOS()
config_eos.update()
assert config_eos.general.config_file_path == config_file_cwd
# user configuration directory (prio 2)
config_file_user.parent.mkdir()
config_file_user.write_text("{}")
config_eos.update()
assert config_eos.general.config_file_path == config_file_user
@patch("akkudoktoreos.config.config.user_config_dir")
def test_get_config_file_path(self, user_config_dir_patch, config_eos, config_default_dirs, monkeypatch):
"""Test that _get_config_file_path identifies the correct config file."""
config_default_dir_user, _, _, _ = config_default_dirs
user_config_dir_patch.return_value = str(config_default_dir_user)
def cfg_file(dir: Path) -> Path:
# Match production directory normalization, including macOS /var aliases.
return dir.resolve().joinpath(ConfigEOS.CONFIG_FILE_NAME)
# Config newly created from fixture with fresh user config directory
assert config_eos._get_config_file_path() == (cfg_file(config_default_dir_user), True)
cfg_file(config_default_dir_user).unlink()
with tempfile.TemporaryDirectory() as temp_dir:
temp_dir_path = Path(temp_dir)
monkeypatch.setenv("EOS_DIR", str(temp_dir_path))
monkeypatch.delenv("EOS_CONFIG_DIR", raising=False)
assert config_eos._get_config_file_path() == (cfg_file(temp_dir_path), False)
monkeypatch.setenv("EOS_CONFIG_DIR", "config")
config_dir = temp_dir_path / "config"
config_dir.mkdir(exist_ok=True)
assert config_eos._get_config_file_path() == (
cfg_file(config_dir),
False,
)
monkeypatch.setenv("EOS_CONFIG_DIR", str(temp_dir_path / "config2"))
config_dir = temp_dir_path / "config2"
config_dir.mkdir(exist_ok=True)
assert config_eos._get_config_file_path() == (
cfg_file(config_dir),
False,
)
monkeypatch.delenv("EOS_DIR")
monkeypatch.setenv("EOS_CONFIG_DIR", "config3")
assert config_eos._get_config_file_path() == (cfg_file(config_default_dir_user), False)
monkeypatch.setenv("EOS_CONFIG_DIR", str(temp_dir_path / "config3"))
config_dir = temp_dir_path / "config3"
config_dir.mkdir(exist_ok=True)
assert config_eos._get_config_file_path() == (
cfg_file(config_dir),
False,
)
def test_config_copy(self, config_eos, monkeypatch):
"""Test if the config is copied to the provided path."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_folder_path = Path(temp_dir)
temp_config_file_path = temp_folder_path.joinpath(config_eos.CONFIG_FILE_NAME).resolve()
monkeypatch.setenv("EOS_CONFIG_DIR", str(temp_folder_path))
assert not temp_config_file_path.exists()
with patch("akkudoktoreos.config.config.user_config_dir", return_value=temp_dir):
assert config_eos._get_config_file_path() == (temp_config_file_path, False)
config_eos.update()
assert temp_config_file_path.exists()
@pytest.mark.parametrize(
"latitude, longitude, expected_timezone",
[
(40.7128, -74.0060, "America/New_York"), # Valid latitude/longitude
(None, None, None), # No location
(51.5074, -0.1278, "Europe/London"), # Another valid location
],
)
def test_config_common_settings_valid(self, latitude, longitude, expected_timezone):
"""Test valid settings for GeneralSettings."""
general_settings = GeneralSettings(
latitude=latitude,
longitude=longitude,
)
assert general_settings.latitude == latitude
assert general_settings.longitude == longitude
assert general_settings.timezone == expected_timezone
@pytest.mark.parametrize(
"field_name, invalid_value, expected_error",
[
("latitude", -91.0, "Input should be greater than or equal to -90"),
("latitude", 91.0, "Input should be less than or equal to 90"),
("longitude", -181.0, "Input should be greater than or equal to -180"),
("longitude", 181.0, "Input should be less than or equal to 180"),
],
)
def test_config_common_settings_invalid(self, field_name, invalid_value, expected_error):
"""Test invalid settings for PredictionCommonSettings."""
valid_data = {
"latitude": 40.7128,
"longitude": -74.0060,
}
assert GeneralSettings.model_validate(valid_data) is not None
valid_data[field_name] = invalid_value
with pytest.raises(ValidationError, match=expected_error):
GeneralSettings.model_validate(valid_data)
def test_config_common_settings_no_location(self):
"""Test that timezone is None when latitude and longitude are not provided."""
settings = GeneralSettings(latitude=None, longitude=None)
assert settings.timezone is None
def test_config_common_settings_with_location(self):
"""Test that timezone is correctly computed when latitude and longitude are provided."""
settings = GeneralSettings(latitude=34.0522, longitude=-118.2437)
assert settings.timezone == "America/Los_Angeles"
def test_config_common_settings_timezone_none_when_coordinates_missing(self):
"""Test that timezone is None when latitude or longitude is missing."""
config_no_latitude = GeneralSettings(latitude=None, longitude=-74.0060)
config_no_longitude = GeneralSettings(latitude=40.7128, longitude=None)
config_no_coords = GeneralSettings(latitude=None, longitude=None)
assert config_no_latitude.timezone is None
assert config_no_longitude.timezone is None
assert config_no_coords.timezone is None
@pytest.mark.parametrize(
"path, value, expected, exception",
[
# Correct value assignment
(
"general/latitude",
42.0,
[("general.latitude", 42.0), ("general.longitude", 13.405)],
None,
),
# Correct value assignment (trailing /)
(
"general/latitude/",
41,
[("general.latitude", 41.0), ("general.longitude", 13.405)],
None,
),
# Correct value assignment (cast)
(
"general/latitude",
"43.0",
[("general.latitude", 43.0), ("general.longitude", 13.405)],
None,
),
# Invalid value assignment (constraint)
(
"general/latitude",
91.0,
[("general.latitude", 52.52), ("general.longitude", 13.405)],
ValueError,
),
# Invalid value assignment (type)
(
"general/latitude",
"test",
[("general.latitude", 52.52), ("general.longitude", 13.405)],
ValueError,
),
# Invalid path
(
"general/latitude/test",
"",
[("general.latitude", 52.52), ("general.longitude", 13.405)],
KeyError,
),
# Correct value nested assignment
(
"general",
{"latitude": 22},
[("general.latitude", 22.0), ("general.longitude", 13.405)],
None,
),
# Invalid value nested assignment
(
"general",
{"latitude": "test"},
[("general.latitude", 52.52), ("general.longitude", 13.405)],
ValueError,
),
# Correct value assignment - preparation for list
(
"devices/max_home_appliances",
1,
[("devices.max_home_appliances", 1), ],
None,
),
# Correct value to generate a windows list
(
"devices/home_appliances",
{
"dishwasher1": {
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3,
"cycle_time_windows": {
"windows": [
{
"start_time": "08:00",
"duration": "5 hours",
"value": 0,
},
{
"start_time": "15:00",
"duration": "3 hours",
"value": 0,
},
],
},
},
},
[
("devices.home_appliances['dishwasher1'].device_id", "dishwasher1"),
("devices.home_appliances['dishwasher1'].consumption_wh", 2000),
("devices.home_appliances['dishwasher1'].duration_h", 3),
],
None,
),
# Invalid value for list
(
"devices/home_appliances/dishwasher1/cycle_time_windows/windows",
"invalid",
[
(
"devices.home_appliances['dishwasher1'].cycle_time_windows",
[
{
"start_time": "08:00",
"duration": "5 hours",
"value": 0,
},
{
"start_time": "15:00",
"duration": "3 hours",
"value": 0,
},
],
)
],
ValueError,
),
# Invalid index (out of bound)
(
"devices/home_appliances/dishwasher1/cycle_time_windows/windows/10",
{
"start_time": "17:00",
"duration": "1 hour",
"value": 0,
},
[
(
"devices.home_appliances['dishwasher1'].cycle_time_windows.windows[10]",
{
"start_time": "17:00",
"duration": "1 hour",
"value": 0,
},
)
],
TypeError,
),
# Invalid index (no number)
(
"devices/home_appliances/dishwasher1/cycle_time_windows/windows/test",
{
"start_time": "17:00",
"duration": "1 hour",
"value": 0,
},
[
(
"devices.home_appliances['dishwasher1'].cycle_time_windows.windows[0]",
{
"start_time": "08:00",
"duration": "5 hours",
"value": 0,
},
)
],
IndexError,
),
# Unset value (set None)
(
"devices/home_appliances/dishwasher1/cycle_time_windows/windows/0",
None,
[
(
"devices.home_appliances['dishwasher1'].cycle_time_windows.windows[0]",
{
"start_time": "08:00",
"duration": "5 hours",
"value": 0,
},
)
],
TypeError,
),
],
)
def test_set_nested_key(self, path, value, expected, exception, config_eos):
if not exception:
config_eos.set_nested_value(path, value)
for expected_path, expected_value in expected:
actual_value = eval(f"config_eos.{expected_path}")
assert_values_equal(actual_value, expected_value)
else:
try:
config_eos.set_nested_value(path, value)
for expected_path, expected_value in expected:
actual_value = eval(f"config_eos.{expected_path}")
assert_values_equal(actual_value, expected_value)
pytest.fail(
f"Expected exception {exception} but none was raised. Set '{expected_path}' to '{actual_value}'"
)
except Exception as e:
assert isinstance(e, exception), (
f"Expected exception {exception}, but got {type(e)}: {e}"
)
def test_set_nested_key_read_only_computed_field(self, config_eos):
"""Setting a computed field is reported as read-only, not as missing."""
path = "adapter/homeassistant/eos_device_instruction_entity_ids"
with pytest.raises(TypeError, match="read-only computed field"):
config_eos.set_nested_value(path, ["sensor.eos_battery1"])
def test_set_nested_key_unknown_field_still_reports_missing(self, config_eos):
"""A field that does not exist keeps the original 'does not exist' message."""
with pytest.raises(TypeError, match="does not exist in model"):
config_eos.set_nested_value("adapter/homeassistant/no_such_field", 1)
@pytest.mark.parametrize(
"path, expected_value, exception",
[
("general/latitude", 52.52, None),
("general/latitude/", 52.52, None),
("general/latitude/test", None, KeyError),
],
)
def test_get_nested_key(self, path, expected_value, exception, config_eos):
if not exception:
assert config_eos.get_nested_value(path) == expected_value
else:
with pytest.raises(exception):
config_eos.get_nested_value(path)
def test_merge_settings_from_dict_invalid(self, config_eos):
"""Test merging invalid data."""
invalid_settings = {
"general": {
"latitude": "invalid_latitude" # Should be a float
},
}
with pytest.raises(Exception): # Pydantic ValidationError expected
config_eos.merge_settings_from_dict(invalid_settings)
def test_merge_settings_partial(self, config_eos):
"""Test merging only a subset of settings."""
partial_settings: dict[str, Any] = {
"general": {
"latitude": 51.1657 # Only latitude is updated
},
}
config_eos.merge_settings_from_dict(partial_settings)
assert config_eos.general.latitude == 51.1657
assert config_eos.general.longitude == 13.405 # Should remain unchanged
#-----------------
partial_settings = {
"weather": {
"provider": "BrightSky",
},
}
config_eos.merge_settings_from_dict(partial_settings)
assert config_eos.weather.provider == "BrightSky"
#-----------------
partial_settings = {
"general": {
"latitude": None,
},
"weather": {
"provider": "ClearOutside",
},
}
config_eos.merge_settings_from_dict(partial_settings)
assert config_eos.general.latitude is None
assert config_eos.weather.provider == "ClearOutside"
# Assure update keeps same values
config_eos.update()
assert config_eos.general.latitude is None
assert config_eos.weather.provider == "ClearOutside"
#-----------------
partial_settings = {
"devices": {
"max_electric_vehicles": 1,
"electric_vehicles": {
"ev1": {"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],}
},
}
}
config_eos.merge_settings_from_dict(partial_settings)
assert config_eos.devices.max_electric_vehicles == 1
assert len(config_eos.devices.electric_vehicles) == 1
assert_values_equal(config_eos.devices.electric_vehicles["ev1"].charge_rates, [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0])
# Assure re-apply generates the same config
config_eos.merge_settings_from_dict(partial_settings)
assert config_eos.devices.max_electric_vehicles == 1
assert len(config_eos.devices.electric_vehicles) == 1
assert_values_equal(config_eos.devices.electric_vehicles["ev1"].charge_rates, [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0])
# Assure update keeps same values
config_eos.update()
assert config_eos.devices.max_electric_vehicles == 1
assert len(config_eos.devices.electric_vehicles) == 1
assert_values_equal(config_eos.devices.electric_vehicles["ev1"].charge_rates, [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0])
def test_merge_settings_empty(self, config_eos):
"""Test merging an empty dictionary does not change settings."""
original_latitude = config_eos.general.latitude
config_eos.merge_settings_from_dict({}) # No changes
assert config_eos.general.latitude == original_latitude # Should remain unchanged
def test_merge_settings_overrides_config_file(self, config_eos_file):
"""Runtime settings take precedence over the EOS configuration file."""
write_config_file(
config_eos_file,
{
"optimization": {"genetic": {"individuals": 200}},
"pvforecast": {
"planes": [
{"surface_tilt": 30.0, "surface_azimuth": azimuth, "peakpower": 5.0}
for azimuth in (0.0, 90.0, 180.0, 270.0)
]
},
},
)
assert config_eos_file.optimization.genetic.individuals == 200
assert len(config_eos_file.pvforecast.planes) == 4
config_eos_file.merge_settings_from_dict(
{
"optimization": {"genetic": {"individuals": 300}},
"pvforecast": {
"planes": [{"surface_tilt": 30.0, "surface_azimuth": 180.0, "peakpower": 5.0}]
},
}
)
assert config_eos_file.optimization.genetic.individuals == 300
assert len(config_eos_file.pvforecast.planes) == 1
def test_merge_settings_overrides_env(self, config_eos_file, monkeypatch):
"""Runtime settings take precedence over environment variables."""
monkeypatch.setenv("EOS_OPTIMIZATION__GENETIC__INDIVIDUALS", "150")
config_eos_file.reset_settings()
assert config_eos_file.optimization.genetic.individuals == 150
config_eos_file.merge_settings_from_dict({"optimization": {"genetic": {"individuals": 300}}})
assert config_eos_file.optimization.genetic.individuals == 300
def test_env_overrides_config_file_after_merge(self, config_eos_file, monkeypatch):
"""Environment variables keep precedence over the config file for untouched keys."""
write_config_file(config_eos_file, {"server": {"port": 9000}})
monkeypatch.setenv("EOS_SERVER__PORT", "9500")
config_eos_file.reset_settings()
assert config_eos_file.server.port == 9500
# A runtime update of an unrelated key must not freeze the env value
config_eos_file.merge_settings_from_dict({"general": {"latitude": 51.1657}})
assert config_eos_file.general.latitude == 51.1657
assert config_eos_file.server.port == 9500
monkeypatch.setenv("EOS_SERVER__PORT", "9600")
config_eos_file.reset_settings()
assert config_eos_file.server.port == 9600
def test_reset_settings_drops_runtime_settings(self, config_eos_file):
"""Reset drops runtime settings and falls back to the config file."""
write_config_file(config_eos_file, {"optimization": {"genetic": {"individuals": 200}}})
config_eos_file.merge_settings_from_dict({"optimization": {"genetic": {"individuals": 300}}})
assert config_eos_file.optimization.genetic.individuals == 300
config_eos_file.reset_settings()
assert config_eos_file.optimization.genetic.individuals == 200
def test_set_nested_value_survives_merge(self, config_eos_file):
"""Granular updates are not lost by a later bulk update."""
write_config_file(
config_eos_file,
{
"general": {"latitude": 48.0},
"optimization": {"genetic": {"individuals": 200}},
"pvforecast": {
"planes": [
{"surface_tilt": 30.0, "surface_azimuth": azimuth, "peakpower": 5.0}
for azimuth in (0.0, 90.0)
]
},
},
)
config_eos_file.set_nested_value("optimization/genetic/individuals", 400)
# A list index can not be expressed by the settings dictionary
config_eos_file.set_nested_value("pvforecast/planes/1/peakpower", 9.9)
# Clearing a value must not be reverted by the config file either
config_eos_file.set_nested_value("general/latitude", None)
config_eos_file.merge_settings_from_dict({"server": {"port": 8600}})
assert config_eos_file.server.port == 8600
assert config_eos_file.optimization.genetic.individuals == 400
assert config_eos_file.pvforecast.planes[1].peakpower == 9.9
assert config_eos_file.general.latitude is None
def test_revert_settings_restores_backup(self, config_eos_file):
"""Revert restores the backup values even if the config file differs."""
write_config_file(config_eos_file, {"optimization": {"genetic": {"individuals": 200}}})
config_file_path = config_eos_file.general.config_file_path
assert config_file_path is not None
backup_path = config_file_path.with_suffix(".backup")
backup_path.write_text(
json.dumps(
{
"general": {"version": config_eos_file.general.version},
"optimization": {"genetic": {"individuals": 500}},
}
),
encoding="utf-8",
)
config_eos_file.revert_settings("backup")
assert config_eos_file.optimization.genetic.individuals == 500
def test_config_from_env_on_first_init(self, config_eos, config_default_dirs, monkeypatch):
"""Environment variables are applied on the first configuration build."""
config_eos.reset_instance()
monkeypatch.setenv("EOS_CONFIG_DIR", str(config_default_dirs[0]))
monkeypatch.setenv("EOS_SERVER__PORT", "8553")
assert ConfigEOS().server.port == 8553
class TestConfigEOSToConfigFile:
def test_to_config_file_writes_file(self, config_eos):
config_path = config_eos.general.config_file_path
# Remove file to test writing
config_path.unlink(missing_ok=True)
config_eos.to_config_file()
assert config_path.exists()
assert config_path.read_text().strip().startswith("{")
def test_to_config_file_excludes_computed_fields(self, config_eos):
config_path = config_eos.general.config_file_path
config_eos.to_config_file()
data = json.loads(config_path.read_text())
assert "timezone" not in data["general"]
assert "data_output_path" not in data["general"]
assert "config_folder_path" not in data["general"]
assert "config_file_path" not in data["general"]
def test_to_config_file_excludes_defaults(self, config_eos):
"""Ensure fields with default values are excluded when saving config."""
# Pick fields that have defaults
default_latitude = GeneralSettings.model_fields["latitude"].default
default_longitude = GeneralSettings.model_fields["longitude"].default
# Ensure fields are at default values
config_eos.general.latitude = default_latitude
config_eos.general.longitude = default_longitude
# Save the config using the correct path managed by config_eos
config_eos.to_config_file()
# Read back JSON from the correct path
config_file_path = config_eos.general.config_file_path
content = json.loads(config_file_path.read_text(encoding="utf-8"))
# Default fields should not appear
assert "latitude" not in content["general"]
assert "longitude" not in content["general"]
# Non-default value should appear
config_eos.general.latitude = 48.0
config_eos.to_config_file()
content = json.loads(config_file_path.read_text(encoding="utf-8"))
assert content["general"]["latitude"] == 48.0
def test_to_config_file_excludes_none_fields(self, config_eos):
config_eos.general.latitude = None
config_path = config_eos.general.config_file_path
config_eos.to_config_file()
data = json.loads(config_path.read_text())
assert "latitude" not in data["general"]
def test_to_config_file_includes_version(tmp_path, config_eos):
"""Ensure general.version is always included."""
# Save config
config_eos.to_config_file()
# Read back JSON
config_file_path = config_eos.general.config_file_path
content = json.loads(config_file_path.read_text(encoding="utf-8"))
# Assert 'version' is included even if default
assert content["general"]["version"] == config_eos.general.version
def test_to_config_file_roundtrip(self, config_eos):
config_eos.merge_settings_from_dict(
{
"general": {"latitude": 48.0},
"server": {"port": 9000},
}
)
config_path = config_eos.general.config_file_path
config_eos.to_config_file()
raw_data = json.loads(config_path.read_text())
reloaded = ConfigEOS.model_validate(raw_data)
assert reloaded.general.latitude == 48.0
assert reloaded.server.port == 9000
class TestDeviceSettings:
def test_device_map_supplies_stable_identity(self):
raw = {"batteries": {"house": {"profile_adjustment": {"nominal_capacity_wh": 12000}}}}
a = DevicesCommonSettings.model_validate(raw)
b = DevicesCommonSettings.model_validate(raw)
assert a.batteries is not None and b.batteries is not None
assert a.batteries["house"].device_id == b.batteries["house"].device_id == "house"
assert "house-soc-factor" in a.measurement_keys
def test_device_map_rejects_conflicting_identity(self):
with pytest.raises(ValidationError, match="device_id"):
DevicesCommonSettings.model_validate({"batteries": {"house": {"device_id": "other"}}})
def test_check_battery_profiles_names_unresolvable_devices(self, config_eos):
# generic-lfp provides no capacity: the house battery lacks it, the car has it.
devices = DevicesCommonSettings.model_validate(
{
"batteries": {"house": {}},
"electric_vehicles": {"car": {"profile_adjustment": {"nominal_capacity_wh": 60000}}},
}
)
errors, warnings = devices.check_battery_profiles()
assert len(errors) == 1
assert "Battery 'house'" in errors[0]
assert warnings == []
def test_config_logs_battery_profile_problems_once(self, config_eos, monkeypatch):
from loguru import logger
monkeypatch.setattr(ConfigEOS, "_battery_profile_problems", ((), ()))
messages: list[str] = []
sink_id = logger.add(
lambda message: messages.append(message.record["message"]), level="WARNING"
)
try:
config_eos.merge_settings_from_dict({"devices": {"batteries": {"house": {}}}})
config_eos.merge_settings_from_dict({"devices": {"max_batteries": 1}})
unresolvable = [m for m in messages if "Battery 'house'" in m]
# Logged on the change only, not again on the unrelated update.
assert len(unresolvable) == 1
messages.clear()
config_eos.set_nested_value(
"devices/batteries/house/profile_adjustment/nominal_capacity_wh", 10000
)
assert not [m for m in messages if "Battery 'house'" in m]
assert ConfigEOS._battery_profile_problems == ((), ())
finally:
logger.remove(sink_id)
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"]
class TestDocumentationMode:
"""Documentation mode is enabled explicitly, never by an imported Sphinx."""
def test_imported_sphinx_does_not_enable_it(self, monkeypatch):
import sphinx # noqa: F401 # the module is in sys.modules now
monkeypatch.delattr("sys._called_from_sphinx", raising=False)
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", False)
assert not ConfigEOS.documentation_mode()
def test_sphinx_configuration_enables_it(self, monkeypatch):
monkeypatch.setattr("sys._called_from_sphinx", True, raising=False)
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", False)
assert ConfigEOS.documentation_mode()
def test_documentation_generators_enable_it(self, monkeypatch):
monkeypatch.delattr("sys._called_from_sphinx", raising=False)
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", True)
assert ConfigEOS.documentation_mode()
def test_optimization_solution_keys_are_left_out(self, monkeypatch):
"""Keys of a solution of a previous run do not reach the documentation."""
from akkudoktoreos.optimization import optimization
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
solution = Mock()
solution.solution.to_dataframe.return_value.columns.tolist.return_value = ["b", "a"]
ems = Mock()
ems.optimization_solution.return_value = solution
monkeypatch.setattr(optimization, "get_ems", lambda: ems)
monkeypatch.delattr("sys._called_from_sphinx", raising=False)
settings = OptimizationCommonSettings()
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", False)
assert settings.keys == ["a", "b"]
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", True)
assert settings.keys == []
class TestConfigSetup:
"""The configuration can be created when its computed fields need it.
A battery's computed fields resolve the battery profile, and the profile
store reads its directories from the configuration. Rendering the
configuration while ``ConfigEOS()`` is still being created must not fail.
"""
@pytest.fixture
def uncreated_config_with_battery(self, uncreated_config: Path) -> None:
"""Write a config file with a battery; get_config(init=True) creates the configuration."""
uncreated_config.write_text(
json.dumps(
{
"devices": {
"batteries": {
"battery1": {
"device_id": "battery1",
"profile_adjustment": {"nominal_capacity_wh": 10240},
}
}
}
}
)
)
def test_config_with_battery_is_created(self, uncreated_config_with_battery):
from akkudoktoreos.core.coreabc import get_config
config = get_config(init=True)
assert config.devices.batteries is not None
assert config.devices.batteries["battery1"].effective_nominal_capacity_wh == 10240
def test_config_with_battery_is_created_with_trace_logging(
self, uncreated_config_with_battery
):
"""With trace logging the configuration is rendered during its creation."""
from loguru import logger
from akkudoktoreos.core.coreabc import get_config
messages: list[str] = []
sink_id = logger.add(
lambda message: messages.append(message.record["message"]), level="TRACE"
)
try:
config = get_config(init=True)
finally:
logger.remove(sink_id)
assert config.devices.batteries is not None
assert "battery1" in config.devices.batteries
setup_messages = [m for m in messages if m.startswith("Config setup:")]
assert len(setup_messages) == 1
assert "effective_nominal_capacity_wh=10240" in setup_messages[0]
def test_access_before_settings_are_loaded_raises(self, uncreated_config, monkeypatch):
"""During creation the configuration is usable only once its settings are loaded."""
from akkudoktoreos.core import coreabc
monkeypatch.setattr(coreabc, "_config_eos_setup_running", True)
with pytest.raises(RuntimeError, match="before its settings are loaded"):
coreabc.get_config(init=True)