fix(config): preserve device identities and storage costs during migration

This commit is contained in:
Andreas
2026-09-16 12:09:32 +02:00
parent b43c8dfb4d
commit e3987b264e
4 changed files with 75 additions and 11 deletions
+23 -8
View File
@@ -47,15 +47,20 @@ def _list_to_device_dict(
"""
def _transform(value: Any) -> Any:
if not isinstance(value, list):
return value # already a dict or something unexpected – leave as-is
if not isinstance(value, (list, dict)):
return value
result: Dict[str, Any] = {}
for i, item in enumerate(value):
if not isinstance(item, dict):
continue
key = item.get("device_id") or f"{prefix}{i}"
# Ensure device_id is stored inside the dict so Pydantic can validate it
entries = enumerate(value) if isinstance(value, list) else value.items()
for index, original in entries:
if not isinstance(original, dict):
raise ValueError("Device settings must be an object")
item = dict(original)
key = (item.get("device_id") or f"{prefix}{index}") if isinstance(value, list) else index
if key in result:
raise ValueError(f"Duplicate device_id: {key!r}")
item.setdefault("device_id", key)
if "levelized_cost_of_storage_kwh" in item:
item.setdefault("levelized_cost_of_storage_amt_kwh", item.pop("levelized_cost_of_storage_kwh"))
result[key] = item
return result
@@ -190,7 +195,7 @@ MIGRATION_MAP: Dict[
"optimization/interval": None,
"optimization/horizon_hours": "optimization/genetic0/horizon_hours",
"optimization/ev_available_charge_rates_percent": (
"devices/electric_vehicles/0/charge_rates",
"devices/electric_vehicles/ev0/charge_rates",
lambda v: [x / 100 for x in v],
),
"optimization/hours": "optimization/genetic0/horizon_hours",
@@ -288,6 +293,16 @@ def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults":
try:
if transform:
old_value = transform(old_value)
if old_path == "optimization/ev_available_charge_rates_percent":
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
vehicles = new_config.devices.electric_vehicles
if not vehicles:
new_config.devices.electric_vehicles = {
"ev0": BatteriesCommonSettings(device_id="ev0")
}
device_id = next(iter(new_config.devices.electric_vehicles))
new_path = f"devices/electric_vehicles/{device_id}/charge_rates"
new_config.set_nested_value(new_path, old_value)
migrated_source_paths.add(old_path.strip("/"))
mapped_count += 1
+20 -1
View File
@@ -4,7 +4,7 @@ import json
from typing import Any, Optional, TextIO, cast
from loguru import logger
from pydantic import Field, computed_field, model_validator
from pydantic import Field, computed_field, field_validator, model_validator
from akkudoktoreos.config.configabc import ConfigScope, SettingsBaseModel
from akkudoktoreos.core.cache import CacheFileStore
@@ -113,6 +113,25 @@ class DevicesCommonSettings(SettingsBaseModel):
},
)
@field_validator("batteries", "electric_vehicles", "inverters", "home_appliances", mode="before")
@classmethod
def validate_device_ids(cls, value: Any) -> Any:
"""Keep map keys and device identities consistent without mutating callers."""
if not isinstance(value, dict):
return value
result = {}
for key, device in value.items():
if isinstance(device, dict):
device = dict(device)
device.setdefault("device_id", key)
device_id = device["device_id"]
else:
device_id = device.device_id
if device_id != key:
raise ValueError(f"device_id {device_id!r} must match map key {key!r}")
result[key] = device
return result
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> list[str]:
+30
View File
@@ -0,0 +1,30 @@
"""Contracts required by the combined EOS configuration."""
import copy
import pytest
from pydantic import ValidationError
from akkudoktoreos.config.configmigrate import migrate_config_data
from akkudoktoreos.devices.devices import DevicesCommonSettings
def test_device_map_supplies_stable_identity():
raw = {"batteries": {"house": {"capacity_wh": 12000}}}
a = DevicesCommonSettings.model_validate(raw)
b = DevicesCommonSettings.model_validate(raw)
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():
with pytest.raises(ValidationError, match="device_id"):
DevicesCommonSettings.model_validate({"batteries": {"house": {"device_id": "other"}}})
@pytest.mark.parametrize("as_list", [False, True])
def test_migration_preserves_lcos_and_input(as_list):
battery = {"device_id": "house", "capacity_wh": 12000,
"levelized_cost_of_storage_kwh": 0.123}
raw = {"devices": {"batteries": [battery] if as_list else {"house": battery}}}
original = copy.deepcopy(raw)
migrated = migrate_config_data(raw)
assert migrated.devices.batteries["house"].levelized_cost_of_storage_amt_kwh == 0.123
assert raw == original
+2 -2
View File
@@ -19,8 +19,8 @@
}
},
"electric_vehicles": {
"0": {
"device_id": "__ANY__",
"ev0": {
"device_id": "ev0",
"charge_rates": [
0.0,
0.375,