mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 16:06:40 +00:00
fix(config): preserve device identities and storage costs during migration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -19,8 +19,8 @@
|
||||
}
|
||||
},
|
||||
"electric_vehicles": {
|
||||
"0": {
|
||||
"device_id": "__ANY__",
|
||||
"ev0": {
|
||||
"device_id": "ev0",
|
||||
"charge_rates": [
|
||||
0.0,
|
||||
0.375,
|
||||
|
||||
Reference in New Issue
Block a user