diff --git a/src/akkudoktoreos/config/configmigrate.py b/src/akkudoktoreos/config/configmigrate.py index 431d8181..30291e70 100644 --- a/src/akkudoktoreos/config/configmigrate.py +++ b/src/akkudoktoreos/config/configmigrate.py @@ -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 diff --git a/src/akkudoktoreos/devices/devices.py b/src/akkudoktoreos/devices/devices.py index d5c8fa19..71efb4f3 100644 --- a/src/akkudoktoreos/devices/devices.py +++ b/src/akkudoktoreos/devices/devices.py @@ -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]: diff --git a/tests/test_consolidation_config.py b/tests/test_consolidation_config.py new file mode 100644 index 00000000..6de18949 --- /dev/null +++ b/tests/test_consolidation_config.py @@ -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 diff --git a/tests/testdata/eos_config_andreas_now.json b/tests/testdata/eos_config_andreas_now.json index 23e80bab..97303bfa 100644 --- a/tests/testdata/eos_config_andreas_now.json +++ b/tests/testdata/eos_config_andreas_now.json @@ -19,8 +19,8 @@ } }, "electric_vehicles": { - "0": { - "device_id": "__ANY__", + "ev0": { + "device_id": "ev0", "charge_rates": [ 0.0, 0.375,