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): if not isinstance(original, dict):
raise ValueError("Device settings must be an object") raise ValueError("Device settings must be an object")
item = dict(original) 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: if key in result:
raise ValueError(f"Duplicate device_id: {key!r}") 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: 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 result[key] = item
return result return result
@@ -294,14 +300,15 @@ def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults":
if transform: if transform:
old_value = transform(old_value) old_value = transform(old_value)
if old_path == "optimization/ev_available_charge_rates_percent": 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 vehicles = new_config.devices.electric_vehicles
if not vehicles: if not vehicles:
new_config.devices.electric_vehicles = { vehicles = {"ev0": BatteriesCommonSettings(device_id="ev0")}
"ev0": BatteriesCommonSettings(device_id="ev0") new_config.devices.electric_vehicles = vehicles
} device_id = next(iter(vehicles))
device_id = next(iter(new_config.devices.electric_vehicles))
new_path = f"devices/electric_vehicles/{device_id}/charge_rates" 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("/"))
+6 -2
View File
@@ -13,6 +13,8 @@ from akkudoktoreos.core.emplan import ResourceStatus
from akkudoktoreos.core.pydantic import ConfigDict, PydanticBaseModel from akkudoktoreos.core.pydantic import ConfigDict, PydanticBaseModel
from akkudoktoreos.devices.settings.batterysettings import ( from akkudoktoreos.devices.settings.batterysettings import (
BATTERY_DEFAULT_CHARGE_RATES as BATTERY_DEFAULT_CHARGE_RATES, BATTERY_DEFAULT_CHARGE_RATES as BATTERY_DEFAULT_CHARGE_RATES,
)
from akkudoktoreos.devices.settings.batterysettings import (
BatteriesCommonSettings, BatteriesCommonSettings,
) )
from akkudoktoreos.devices.settings.homeappliancesettings import ( 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 @classmethod
def validate_device_ids(cls, value: Any) -> Any: def validate_device_ids(cls, value: Any) -> Any:
"""Keep map keys and device identities consistent without mutating callers.""" """Keep map keys and device identities consistent without mutating callers."""
@@ -147,7 +151,7 @@ class DevicesCommonSettings(SettingsBaseModel):
self.home_appliances, self.home_appliances,
]: ]:
for device in (device_dict or {}).values(): for device in (device_dict or {}).values():
keys.extend(device.measurement_keys) keys.extend(device.measurement_keys or [])
return keys return keys
@@ -245,7 +245,7 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
return Genetic0HomeApplianceParameters( return Genetic0HomeApplianceParameters(
device_id=self.device_id, device_id=self.device_id,
consumption_wh=float(self.consumption_wh), consumption_wh=self.consumption_wh,
duration_h=self.duration_h, duration_h=self.duration_h,
time_windows=self.cycle_time_windows, time_windows=self.cycle_time_windows,
) )
@@ -352,6 +352,8 @@ class InverterCommonSettings(DevicesBaseSettings):
"""Return InverterParameters for the GENETIC optimizer.""" """Return InverterParameters for the GENETIC optimizer."""
from akkudoktoreos.devices.genetic.inverter import InverterParameters 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( return InverterParameters(
device_id=self.device_id, device_id=self.device_id,
max_power_wh=self.max_power_w, max_power_wh=self.max_power_w,
@@ -371,6 +373,8 @@ class InverterCommonSettings(DevicesBaseSettings):
Genetic0InverterParameters, Genetic0InverterParameters,
) )
if self.max_power_w is None:
raise ValueError("Inverter max_power_w is required for optimization")
return Genetic0InverterParameters( return Genetic0InverterParameters(
device_id=self.device_id, device_id=self.device_id,
max_power_wh=self.max_power_w, max_power_wh=self.max_power_w,
+25 -25
View File
@@ -1525,9 +1525,9 @@ class TestCycleTimeWindowSequence:
def setup_method(self, method): def setup_method(self, method):
self.seq = CycleTimeWindowSequence( self.seq = CycleTimeWindowSequence(
windows=[ 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)),
ValueTimeWindow(start_time="14:00:00", duration="4 hours", value=1.0), ValueTimeWindow.model_validate(dict(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="20:00:00", duration="2 hours", value=2.0)),
] ]
) )
@@ -1541,8 +1541,8 @@ class TestCycleTimeWindowSequence:
def test_num_cycles_ignores_none(self): def test_num_cycles_ignores_none(self):
seq = CycleTimeWindowSequence( seq = CycleTimeWindowSequence(
windows=[ 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)),
ValueTimeWindow(start_time="10:00:00", duration="2 hours", value=1.0), ValueTimeWindow.model_validate(dict(start_time="10:00:00", duration="2 hours", value=1.0)),
] ]
) )
assert seq.num_cycles() == 1 assert seq.num_cycles() == 1
@@ -1550,8 +1550,8 @@ class TestCycleTimeWindowSequence:
def test_num_cycles_non_contiguous(self): def test_num_cycles_non_contiguous(self):
seq = CycleTimeWindowSequence( seq = CycleTimeWindowSequence(
windows=[ windows=[
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=2.0), ValueTimeWindow.model_validate(dict(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="10:00:00", duration="2 hours", value=5.0)),
] ]
) )
assert seq.num_cycles() == 2 assert seq.num_cycles() == 2
@@ -1647,8 +1647,8 @@ class TestCycleTimeWindowSequence:
def test_cycle_array_dropna_false(self): def test_cycle_array_dropna_false(self):
seq = CycleTimeWindowSequence( seq = CycleTimeWindowSequence(
windows=[ 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)),
ValueTimeWindow(start_time="10:00:00", duration="2 hours", value=1.0), 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): def setup_method(self, method):
self.seq = CycleTimeWindowSequence( self.seq = CycleTimeWindowSequence(
windows=[ 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)),
ValueTimeWindow(start_time="14:00:00", duration="4 hours", value=1.0), ValueTimeWindow.model_validate(dict(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="20:00:00", duration="2 hours", value=2.0)),
] ]
) )
self.start = naive_dt(2024, 6, 15, 0) self.start = naive_dt(2024, 6, 15, 0)
@@ -1775,8 +1775,8 @@ class TestCyclesToMatrix:
def test_none_value_windows_skipped(self): def test_none_value_windows_skipped(self):
seq = CycleTimeWindowSequence( seq = CycleTimeWindowSequence(
windows=[ 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)),
ValueTimeWindow(start_time="10:00:00", duration="2 hours", value=1.0), 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) 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): def test_all_none_returns_empty(self):
seq = CycleTimeWindowSequence( seq = CycleTimeWindowSequence(
windows=[ 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) 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): def test_row_order_independent_of_window_order(self):
seq = CycleTimeWindowSequence( seq = CycleTimeWindowSequence(
windows=[ windows=[
ValueTimeWindow(start_time="20:00:00", duration="2 hours", value=2.0), ValueTimeWindow.model_validate(dict(start_time="20:00:00", duration="2 hours", value=2.0)),
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)),
ValueTimeWindow(start_time="14:00:00", duration="4 hours", value=1.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) 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): def test_non_contiguous_cycle_indices(self):
seq = CycleTimeWindowSequence( seq = CycleTimeWindowSequence(
windows=[ windows=[
ValueTimeWindow(start_time="06:00:00", duration="2 hours", value=3.0), ValueTimeWindow.model_validate(dict(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="16:00:00", duration="2 hours", value=7.0)),
] ]
) )
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval) 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 # Cycle 0 appears twice: 06:00–08:00 and 20:00–22:00
seq = CycleTimeWindowSequence( seq = CycleTimeWindowSequence(
windows=[ windows=[
ValueTimeWindow(start_time="06:00:00", duration="2 hours", value=0.0), ValueTimeWindow.model_validate(dict(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="20:00:00", duration="2 hours", value=0.0)),
] ]
) )
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval) 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) # Cycle 0: 08:00–12:00 → 8 half-hour steps starting at step 16 (08:00 / 0.5h)
seq = CycleTimeWindowSequence( seq = CycleTimeWindowSequence(
windows=[ 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) start = naive_dt(2024, 6, 15, 0)
@@ -1938,7 +1938,7 @@ class TestCyclesToMatrix:
# (10:00 < 10:10, so it is included by ceil) # (10:00 < 10:10, so it is included by ceil)
# Window 08:00–12:00 → all three steps are inside → all 1.0 # Window 08:00–12:00 → all three steps are inside → all 1.0
seq = CycleTimeWindowSequence( 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) start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10) end = naive_dt(2024, 6, 15, 10, 10)
@@ -1950,7 +1950,7 @@ class TestCyclesToMatrix:
# start=08:15, interval=30min → floor to 08:00 # 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) # Window 08:00–10:00 → steps 08:00(1), 08:30(1), 09:00(1), 09:30(1)
seq = CycleTimeWindowSequence( 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) start = naive_dt(2024, 6, 15, 8, 15)
end = naive_dt(2024, 6, 15, 10, 15) end = naive_dt(2024, 6, 15, 10, 15)
+14
View File
@@ -1,15 +1,19 @@
"""Contracts required by the combined EOS configuration.""" """Contracts required by the combined EOS configuration."""
import copy import copy
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from akkudoktoreos.config.configmigrate import migrate_config_data from akkudoktoreos.config.configmigrate import migrate_config_data
from akkudoktoreos.devices.devices import DevicesCommonSettings from akkudoktoreos.devices.devices import DevicesCommonSettings
from akkudoktoreos.devices.settings.invertersettings import InverterCommonSettings
def test_device_map_supplies_stable_identity(): def test_device_map_supplies_stable_identity():
raw = {"batteries": {"house": {"capacity_wh": 12000}}} raw = {"batteries": {"house": {"capacity_wh": 12000}}}
a = DevicesCommonSettings.model_validate(raw) a = DevicesCommonSettings.model_validate(raw)
b = 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 a.batteries["house"].device_id == b.batteries["house"].device_id == "house"
assert "house-soc-factor" in a.measurement_keys 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}}} raw = {"devices": {"batteries": [battery] if as_list else {"house": battery}}}
original = copy.deepcopy(raw) original = copy.deepcopy(raw)
migrated = migrate_config_data(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 migrated.devices.batteries["house"].levelized_cost_of_storage_amt_kwh == 0.123
assert raw == original 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 TODO: fix this import to match wherever HomeApplianceParameters / HomeAppliance
actually live in the repo. actually live in the repo.
""" """
from typing import Any
from unittest.mock import Mock from unittest.mock import Mock
import numpy as np import numpy as np
@@ -20,7 +21,7 @@ from akkudoktoreos.utils.datetimeutil import to_duration, to_time
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def make_params(**overrides) -> HomeApplianceParameters: def make_params(**overrides) -> HomeApplianceParameters:
defaults = dict( defaults: dict[str, Any] = dict(
device_id="dishwasher", device_id="dishwasher",
consumption_wh=2000, consumption_wh=2000,
duration_h=2, duration_h=2,