fix(config): satisfy typed device conversion and migration contracts

This commit is contained in:
Andreas
2026-09-16 19:04:09 +02:00
parent c968e4ccd1
commit 55e205c602
7 changed files with 66 additions and 36 deletions
+14 -7
View File
@@ -55,12 +55,18 @@ def _list_to_device_dict(
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
key = (
(item.get("device_id") or f"{prefix}{index}") if isinstance(value, list) else index
)
if not isinstance(key, str):
raise ValueError("Device identifiers must be strings")
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"))
item.setdefault(
"levelized_cost_of_storage_amt_kwh", item.pop("levelized_cost_of_storage_kwh")
)
result[key] = item
return result
@@ -294,14 +300,15 @@ def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults":
if transform:
old_value = transform(old_value)
if old_path == "optimization/ev_available_charge_rates_percent":
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
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))
vehicles = {"ev0": BatteriesCommonSettings(device_id="ev0")}
new_config.devices.electric_vehicles = vehicles
device_id = next(iter(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("/"))
+6 -2
View File
@@ -13,6 +13,8 @@ from akkudoktoreos.core.emplan import ResourceStatus
from akkudoktoreos.core.pydantic import ConfigDict, PydanticBaseModel
from akkudoktoreos.devices.settings.batterysettings import (
BATTERY_DEFAULT_CHARGE_RATES as BATTERY_DEFAULT_CHARGE_RATES,
)
from akkudoktoreos.devices.settings.batterysettings import (
BatteriesCommonSettings,
)
from akkudoktoreos.devices.settings.homeappliancesettings import (
@@ -116,7 +118,9 @@ class DevicesCommonSettings(SettingsBaseModel):
},
)
@field_validator("batteries", "electric_vehicles", "inverters", "home_appliances", mode="before")
@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."""
@@ -147,7 +151,7 @@ class DevicesCommonSettings(SettingsBaseModel):
self.home_appliances,
]:
for device in (device_dict or {}).values():
keys.extend(device.measurement_keys)
keys.extend(device.measurement_keys or [])
return keys
@@ -245,7 +245,7 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
return Genetic0HomeApplianceParameters(
device_id=self.device_id,
consumption_wh=float(self.consumption_wh),
consumption_wh=self.consumption_wh,
duration_h=self.duration_h,
time_windows=self.cycle_time_windows,
)
@@ -352,6 +352,8 @@ class InverterCommonSettings(DevicesBaseSettings):
"""Return InverterParameters for the GENETIC optimizer."""
from akkudoktoreos.devices.genetic.inverter import InverterParameters
if self.max_power_w is None:
raise ValueError("Inverter max_power_w is required for optimization")
return InverterParameters(
device_id=self.device_id,
max_power_wh=self.max_power_w,
@@ -371,6 +373,8 @@ class InverterCommonSettings(DevicesBaseSettings):
Genetic0InverterParameters,
)
if self.max_power_w is None:
raise ValueError("Inverter max_power_w is required for optimization")
return Genetic0InverterParameters(
device_id=self.device_id,
max_power_wh=self.max_power_w,
+25 -25
View File
@@ -1525,9 +1525,9 @@ class TestCycleTimeWindowSequence:
def setup_method(self, method):
self.seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.0),
ValueTimeWindow(start_time="14:00:00", duration="4 hours", value=1.0),
ValueTimeWindow(start_time="20:00:00", duration="2 hours", value=2.0),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.0)),
ValueTimeWindow.model_validate(dict(start_time="14:00:00", duration="4 hours", value=1.0)),
ValueTimeWindow.model_validate(dict(start_time="20:00:00", duration="2 hours", value=2.0)),
]
)
@@ -1541,8 +1541,8 @@ class TestCycleTimeWindowSequence:
def test_num_cycles_ignores_none(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
ValueTimeWindow(start_time="10:00:00", duration="2 hours", value=1.0),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
ValueTimeWindow.model_validate(dict(start_time="10:00:00", duration="2 hours", value=1.0)),
]
)
assert seq.num_cycles() == 1
@@ -1550,8 +1550,8 @@ class TestCycleTimeWindowSequence:
def test_num_cycles_non_contiguous(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=2.0),
ValueTimeWindow(start_time="10:00:00", duration="2 hours", value=5.0),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=2.0)),
ValueTimeWindow.model_validate(dict(start_time="10:00:00", duration="2 hours", value=5.0)),
]
)
assert seq.num_cycles() == 2
@@ -1647,8 +1647,8 @@ class TestCycleTimeWindowSequence:
def test_cycle_array_dropna_false(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
ValueTimeWindow(start_time="10:00:00", duration="2 hours", value=1.0),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
ValueTimeWindow.model_validate(dict(start_time="10:00:00", duration="2 hours", value=1.0)),
]
)
@@ -1694,9 +1694,9 @@ class TestCyclesToMatrix:
def setup_method(self, method):
self.seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.0),
ValueTimeWindow(start_time="14:00:00", duration="4 hours", value=1.0),
ValueTimeWindow(start_time="20:00:00", duration="2 hours", value=2.0),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.0)),
ValueTimeWindow.model_validate(dict(start_time="14:00:00", duration="4 hours", value=1.0)),
ValueTimeWindow.model_validate(dict(start_time="20:00:00", duration="2 hours", value=2.0)),
]
)
self.start = naive_dt(2024, 6, 15, 0)
@@ -1775,8 +1775,8 @@ class TestCyclesToMatrix:
def test_none_value_windows_skipped(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
ValueTimeWindow(start_time="10:00:00", duration="2 hours", value=1.0),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
ValueTimeWindow.model_validate(dict(start_time="10:00:00", duration="2 hours", value=1.0)),
]
)
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval)
@@ -1790,7 +1790,7 @@ class TestCyclesToMatrix:
def test_all_none_returns_empty(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
]
)
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval)
@@ -1804,9 +1804,9 @@ class TestCyclesToMatrix:
def test_row_order_independent_of_window_order(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="20:00:00", duration="2 hours", value=2.0),
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.0),
ValueTimeWindow(start_time="14:00:00", duration="4 hours", value=1.0),
ValueTimeWindow.model_validate(dict(start_time="20:00:00", duration="2 hours", value=2.0)),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.0)),
ValueTimeWindow.model_validate(dict(start_time="14:00:00", duration="4 hours", value=1.0)),
]
)
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval)
@@ -1822,8 +1822,8 @@ class TestCyclesToMatrix:
def test_non_contiguous_cycle_indices(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="06:00:00", duration="2 hours", value=3.0),
ValueTimeWindow(start_time="16:00:00", duration="2 hours", value=7.0),
ValueTimeWindow.model_validate(dict(start_time="06:00:00", duration="2 hours", value=3.0)),
ValueTimeWindow.model_validate(dict(start_time="16:00:00", duration="2 hours", value=7.0)),
]
)
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval)
@@ -1842,8 +1842,8 @@ class TestCyclesToMatrix:
# Cycle 0 appears twice: 06:00–08:00 and 20:00–22:00
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="06:00:00", duration="2 hours", value=0.0),
ValueTimeWindow(start_time="20:00:00", duration="2 hours", value=0.0),
ValueTimeWindow.model_validate(dict(start_time="06:00:00", duration="2 hours", value=0.0)),
ValueTimeWindow.model_validate(dict(start_time="20:00:00", duration="2 hours", value=0.0)),
]
)
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval)
@@ -1862,7 +1862,7 @@ class TestCyclesToMatrix:
# Cycle 0: 08:00–12:00 → 8 half-hour steps starting at step 16 (08:00 / 0.5h)
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.0),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.0)),
]
)
start = naive_dt(2024, 6, 15, 0)
@@ -1938,7 +1938,7 @@ class TestCyclesToMatrix:
# (10:00 < 10:10, so it is included by ceil)
# Window 08:00–12:00 → all three steps are inside → all 1.0
seq = CycleTimeWindowSequence(
windows=[ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.0)]
windows=[ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.0))]
)
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
@@ -1950,7 +1950,7 @@ class TestCyclesToMatrix:
# start=08:15, interval=30min → floor to 08:00
# Window 08:00–10:00 → steps 08:00(1), 08:30(1), 09:00(1), 09:30(1)
seq = CycleTimeWindowSequence(
windows=[ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.0)]
windows=[ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=0.0))]
)
start = naive_dt(2024, 6, 15, 8, 15)
end = naive_dt(2024, 6, 15, 10, 15)
+14
View File
@@ -1,15 +1,19 @@
"""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
from akkudoktoreos.devices.settings.invertersettings import InverterCommonSettings
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 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
@@ -26,5 +30,15 @@ def test_migration_preserves_lcos_and_input(as_list):
raw = {"devices": {"batteries": [battery] if as_list else {"house": battery}}}
original = copy.deepcopy(raw)
migrated = migrate_config_data(raw)
assert migrated.devices.batteries is not None
assert migrated.devices.batteries["house"].levelized_cost_of_storage_amt_kwh == 0.123
assert raw == original
@pytest.mark.parametrize("converter", ["to_genetic_param", "to_genetic0_param"])
def test_inverter_conversion_requires_output_limit(converter):
settings = InverterCommonSettings(device_id="inverter")
with pytest.raises(ValueError, match="max_power_w"):
getattr(settings, converter)()
settings.max_power_w = 4200
assert getattr(settings, converter)().max_power_wh == 4200
+2 -1
View File
@@ -3,6 +3,7 @@
TODO: fix this import to match wherever HomeApplianceParameters / HomeAppliance
actually live in the repo.
"""
from typing import Any
from unittest.mock import Mock
import numpy as np
@@ -20,7 +21,7 @@ from akkudoktoreos.utils.datetimeutil import to_duration, to_time
# ---------------------------------------------------------------------------
def make_params(**overrides) -> HomeApplianceParameters:
defaults = dict(
defaults: dict[str, Any] = dict(
device_id="dishwasher",
consumption_wh=2000,
duration_h=2,