diff --git a/src/akkudoktoreos/config/configmigrate.py b/src/akkudoktoreos/config/configmigrate.py index 30291e70..f86ee3ed 100644 --- a/src/akkudoktoreos/config/configmigrate.py +++ b/src/akkudoktoreos/config/configmigrate.py @@ -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("/")) diff --git a/src/akkudoktoreos/devices/devices.py b/src/akkudoktoreos/devices/devices.py index 1575bc5b..1c2682b6 100644 --- a/src/akkudoktoreos/devices/devices.py +++ b/src/akkudoktoreos/devices/devices.py @@ -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 diff --git a/src/akkudoktoreos/devices/settings/homeappliancesettings.py b/src/akkudoktoreos/devices/settings/homeappliancesettings.py index 236ad375..81a6e448 100644 --- a/src/akkudoktoreos/devices/settings/homeappliancesettings.py +++ b/src/akkudoktoreos/devices/settings/homeappliancesettings.py @@ -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, ) diff --git a/src/akkudoktoreos/devices/settings/invertersettings.py b/src/akkudoktoreos/devices/settings/invertersettings.py index b5ca760b..b142b641 100644 --- a/src/akkudoktoreos/devices/settings/invertersettings.py +++ b/src/akkudoktoreos/devices/settings/invertersettings.py @@ -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, diff --git a/tests/test_configabc.py b/tests/test_configabc.py index 09ae8e04..2ffeac50 100644 --- a/tests/test_configabc.py +++ b/tests/test_configabc.py @@ -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) diff --git a/tests/test_consolidation_config.py b/tests/test_consolidation_config.py index 6de18949..fa204565 100644 --- a/tests/test_consolidation_config.py +++ b/tests/test_consolidation_config.py @@ -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 diff --git a/tests/test_genetichomeappliance.py b/tests/test_genetichomeappliance.py index 15cb9561..bc93233c 100644 --- a/tests/test_genetichomeappliance.py +++ b/tests/test_genetichomeappliance.py @@ -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,