mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-17 03:21:56 +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:
|
def _transform(value: Any) -> Any:
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, (list, dict)):
|
||||||
return value # already a dict or something unexpected – leave as-is
|
return value
|
||||||
result: Dict[str, Any] = {}
|
result: Dict[str, Any] = {}
|
||||||
for i, item in enumerate(value):
|
entries = enumerate(value) if isinstance(value, list) else value.items()
|
||||||
if not isinstance(item, dict):
|
for index, original in entries:
|
||||||
continue
|
if not isinstance(original, dict):
|
||||||
key = item.get("device_id") or f"{prefix}{i}"
|
raise ValueError("Device settings must be an object")
|
||||||
# Ensure device_id is stored inside the dict so Pydantic can validate it
|
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)
|
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
|
result[key] = item
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -190,7 +195,7 @@ MIGRATION_MAP: Dict[
|
|||||||
"optimization/interval": None,
|
"optimization/interval": None,
|
||||||
"optimization/horizon_hours": "optimization/genetic0/horizon_hours",
|
"optimization/horizon_hours": "optimization/genetic0/horizon_hours",
|
||||||
"optimization/ev_available_charge_rates_percent": (
|
"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],
|
lambda v: [x / 100 for x in v],
|
||||||
),
|
),
|
||||||
"optimization/hours": "optimization/genetic0/horizon_hours",
|
"optimization/hours": "optimization/genetic0/horizon_hours",
|
||||||
@@ -288,6 +293,16 @@ def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults":
|
|||||||
try:
|
try:
|
||||||
if transform:
|
if transform:
|
||||||
old_value = transform(old_value)
|
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)
|
new_config.set_nested_value(new_path, old_value)
|
||||||
migrated_source_paths.add(old_path.strip("/"))
|
migrated_source_paths.add(old_path.strip("/"))
|
||||||
mapped_count += 1
|
mapped_count += 1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
from typing import Any, Optional, TextIO, cast
|
from typing import Any, Optional, TextIO, cast
|
||||||
|
|
||||||
from loguru import logger
|
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.config.configabc import ConfigScope, SettingsBaseModel
|
||||||
from akkudoktoreos.core.cache import CacheFileStore
|
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]
|
@computed_field # type: ignore[prop-decorator]
|
||||||
@property
|
@property
|
||||||
def measurement_keys(self) -> list[str]:
|
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": {
|
"electric_vehicles": {
|
||||||
"0": {
|
"ev0": {
|
||||||
"device_id": "__ANY__",
|
"device_id": "ev0",
|
||||||
"charge_rates": [
|
"charge_rates": [
|
||||||
0.0,
|
0.0,
|
||||||
0.375,
|
0.375,
|
||||||
|
|||||||
Reference in New Issue
Block a user