mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 07:56:40 +00:00
fix(config): satisfy typed device conversion and migration contracts
This commit is contained in:
@@ -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("/"))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user