"""Regression test suite for the repaired HomeAppliance module. 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 import pytest from akkudoktoreos.config.configabc import CycleTimeWindowSequence, ValueTimeWindow from akkudoktoreos.devices.genetic.homeappliance import ( HomeAppliance, HomeApplianceParameters, ) from akkudoktoreos.utils.datetimeutil import to_duration, to_time # --------------------------------------------------------------------------- # Fixtures / factories # --------------------------------------------------------------------------- def make_params(**overrides) -> HomeApplianceParameters: defaults: dict[str, Any] = dict( device_id="dishwasher", consumption_wh=2000, duration_h=2, num_cycles=1, min_cycle_gap_h=0, time_windows=None, ) defaults.update(overrides) return HomeApplianceParameters(**defaults) def make_appliance( prediction_hours: int = 24, optimization_hours: int = 24, **param_overrides, ) -> HomeAppliance: params = make_params(**param_overrides) return HomeAppliance( parameters=params, optimization_hours=optimization_hours, prediction_hours=prediction_hours, ) def cycle_window(cycle: int, start: str, duration: str) -> CycleTimeWindowSequence: """Build a CycleTimeWindowSequence with a single window for one cycle.""" return CycleTimeWindowSequence( windows=[ ValueTimeWindow( start_time=to_time(start), duration=to_duration(duration), value=float(cycle), ) ] ) def mock_cycle_windows(cycle_matrix: dict[int, np.ndarray]) -> Mock: """Build a Mock standing in for CycleTimeWindowSequence. ``Mock(spec=CycleTimeWindowSequence)`` satisfies both the pydantic field-type check on ``HomeApplianceParameters.time_windows`` and any isinstance check in the module, without needing real windows/pendulum datetimes -- useful for isolating _build_duration_feasibility and the scheduling/repair logic from the real cycles_to_matrix() implementation. """ cycle_indices = list(cycle_matrix.keys()) matrix = np.array([cycle_matrix[c] for c in cycle_indices]) mock = Mock(spec=CycleTimeWindowSequence) mock.cycles_to_matrix = Mock(return_value=(cycle_indices, matrix)) return mock # --------------------------------------------------------------------------- # Setup / defaults # --------------------------------------------------------------------------- class TestSetup: def test_default_time_windows_created_when_none_given(self): appliance = make_appliance(time_windows=None, num_cycles=1) assert appliance.parameters.time_windows is not None assert isinstance(appliance.parameters.time_windows, CycleTimeWindowSequence) def test_default_time_window_value_left_unset(self): # A None-valued window is invisible to cycles_to_matrix() and so # never masquerades as a real per-cycle window; every remaining # cycle instead falls through to the "unconstrained" fallback. appliance = make_appliance(time_windows=None, num_cycles=3) assert appliance.parameters.time_windows is not None windows = appliance.parameters.time_windows.windows assert len(windows) == 1 assert windows[0].value is None def test_default_time_window_serializes_without_a_cycle_value(self): appliance = make_appliance(time_windows=None, num_cycles=1) assert appliance.parameters.time_windows is not None dumped = appliance.parameters.time_windows.model_dump() assert dumped["windows"][0]["value"] is None def test_num_remaining_cycles_initial(self): appliance = make_appliance(num_cycles=3) assert appliance.num_remaining_cycles == 3 def test_num_remaining_cycles_never_negative(self): appliance = make_appliance(num_cycles=2) appliance.completed_cycles = 5 assert appliance.num_remaining_cycles == 0 # --------------------------------------------------------------------------- # Allowed-start computation: default (unconstrained) per-cycle windows # --------------------------------------------------------------------------- class TestDefaultStartAllowed: def test_starts_allowed_up_to_horizon_minus_duration(self): appliance = make_appliance(prediction_hours=10, duration_h=3) max_start = 10 - 3 allowed = appliance.start_allowed[0] assert allowed[: max_start + 1].all() def test_starts_beyond_horizon_minus_duration_forbidden(self): appliance = make_appliance(prediction_hours=10, duration_h=3) max_start = 10 - 3 allowed = appliance.start_allowed[0] assert not allowed[max_start + 1 :].any() def test_start_earliest_and_latest(self): appliance = make_appliance(prediction_hours=10, duration_h=3) assert appliance.start_earliest[0] == 0 assert appliance.start_latest[0] == 10 - 3 def test_each_cycle_gets_its_own_unconstrained_mask(self): appliance = make_appliance(prediction_hours=10, duration_h=2, num_cycles=2) max_start = 10 - 2 assert appliance.start_allowed[0][: max_start + 1].all() assert appliance.start_allowed[1][: max_start + 1].all() # --------------------------------------------------------------------------- # Allowed-start computation: explicit single-cycle window # --------------------------------------------------------------------------- class TestExplicitCycleWindow: def test_only_hours_inside_window_allowed(self): appliance = make_appliance( prediction_hours=24, duration_h=2, num_cycles=1, time_windows=cycle_window(0, "10:00", "3 hours"), ) allowed = appliance.start_allowed[0] # Window is 10:00-13:00, appliance needs 2h -> valid starts 10, 11. assert allowed[10] and allowed[11] assert not allowed[9] assert not allowed[12] def test_earliest_latest_reflect_window(self): appliance = make_appliance( prediction_hours=24, duration_h=2, num_cycles=1, time_windows=cycle_window(0, "10:00", "3 hours"), ) assert appliance.start_earliest[0] == 10 assert appliance.start_latest[0] == 11 def test_window_with_no_valid_start_falls_back(self): # Window shorter than the appliance's own duration -> nothing fits. appliance = make_appliance( prediction_hours=24, duration_h=3, num_cycles=1, time_windows=cycle_window(0, "10:00", "1 hour"), ) allowed = appliance.start_allowed[0] assert not allowed.any() assert appliance.start_earliest[0] == 0 assert appliance.start_latest[0] == 24 - 3 # --------------------------------------------------------------------------- # Allowed-start computation: mocked per-cycle windows (CycleTimeWindowSequence) # --------------------------------------------------------------------------- class TestCycleStartAllowed: def test_missing_cycle_row_is_unconstrained(self): # num_cycles=2 but the mock only provides a window for cycle 0. prediction_hours = 10 duration_h = 2 steps = np.zeros(prediction_hours) steps[2:6] = 1.0 windows = mock_cycle_windows({0: steps}) appliance = make_appliance( prediction_hours=prediction_hours, duration_h=duration_h, num_cycles=2, time_windows=windows, ) max_start = prediction_hours - duration_h # Cycle 1 has no matrix row -> should be allowed everywhere it fits. assert appliance.start_allowed[1][: max_start + 1].all() def test_duration_feasibility_uses_correct_window(self): # Steps 2,3,4,5 are inside the window (1.0); everything else is 0. # duration_h=2 -> valid starts are 2, 3, 4 (each 2h block fully inside). prediction_hours = 10 duration_h = 2 steps = np.zeros(prediction_hours) steps[2:6] = 1.0 windows = mock_cycle_windows({0: steps}) appliance = make_appliance( prediction_hours=prediction_hours, duration_h=duration_h, num_cycles=1, time_windows=windows, ) allowed = appliance.start_allowed[0] expected = np.zeros(prediction_hours, dtype=bool) expected[2:5] = True # starts 2, 3, 4 np.testing.assert_array_equal(allowed, expected) # --------------------------------------------------------------------------- # set_completed_cycles # --------------------------------------------------------------------------- class TestSetCompletedCycles: def test_resets_start_hours_and_load_curve(self): appliance = make_appliance(num_cycles=2) appliance.start_hours = [1, 5] appliance.load_curve[0] = 999 appliance.set_completed_cycles(1) assert appliance.start_hours == [] assert (appliance.load_curve == 0).all() def test_remaining_cycle_indices_updated(self): appliance = make_appliance(num_cycles=4) appliance.set_completed_cycles(2) assert appliance.remaining_cycle_indices == [2, 3] assert appliance.num_remaining_cycles == 2 def test_clamped_to_valid_range(self): appliance = make_appliance(num_cycles=3) appliance.set_completed_cycles(-5) assert appliance.completed_cycles == 0 appliance.set_completed_cycles(99) assert appliance.completed_cycles == 3 # --------------------------------------------------------------------------- # set_starting_times -- the core scheduling / repair logic # --------------------------------------------------------------------------- class TestSetStartingTimes: def test_single_cycle_schedule_returns_requested_start(self): appliance = make_appliance(prediction_hours=24, duration_h=2, num_cycles=1) result = appliance.set_starting_times([5]) assert result == [5] def test_two_cycles_enforce_minimum_gap(self): appliance = make_appliance( prediction_hours=24, duration_h=2, num_cycles=2, min_cycle_gap_h=1 ) # Requested starts overlap; cycle 1 must be pushed to start >= 0+2+1=3. result = appliance.set_starting_times([0, 1]) assert result[0] == 0 assert result[1] >= 3 def test_three_cycles_are_all_gap_repaired(self): appliance = make_appliance( prediction_hours=24, duration_h=1, num_cycles=3, min_cycle_gap_h=0 ) result = appliance.set_starting_times([0, 0, 0]) # Each cycle is 1h with no gap -> expect 0, 1, 2. assert result == [0, 1, 2] def test_load_curve_reflects_final_start_hours(self): appliance = make_appliance( prediction_hours=10, duration_h=2, consumption_wh=2000, num_cycles=1 ) appliance.set_starting_times([3]) expected = np.zeros(10) expected[3:5] = 1000 # 2000 Wh over 2h np.testing.assert_array_equal(appliance.get_load_curve(), expected) def test_sorting_preserves_per_cycle_window_alignment(self): prediction_hours = 24 duration_h = 1 # Cycle 0 only allowed late (hour 20), cycle 1 only allowed early (hour 2). steps0 = np.zeros(prediction_hours) steps0[20] = 1.0 steps1 = np.zeros(prediction_hours) steps1[2] = 1.0 windows = mock_cycle_windows({0: steps0, 1: steps1}) appliance = make_appliance( prediction_hours=prediction_hours, duration_h=duration_h, num_cycles=2, time_windows=windows, ) result = appliance.set_starting_times([20, 2]) # Cycle 0 must land at 20 (its only allowed hour), cycle 1 at 2, # regardless of chronological sorting during repair. assert 20 in result assert 2 in result def test_raises_on_wrong_number_of_start_times(self): appliance = make_appliance(prediction_hours=24, duration_h=2, num_cycles=2) with pytest.raises(ValueError): appliance.set_starting_times([5]) def test_no_remaining_cycles_returns_empty_list(self): appliance = make_appliance(prediction_hours=24, duration_h=2, num_cycles=1) appliance.completed_cycles = 1 result = appliance.set_starting_times([]) assert result == [] assert (appliance.load_curve == 0).all() # --------------------------------------------------------------------------- # Backwards-compatible single-cycle interface # --------------------------------------------------------------------------- class TestSetStartingTimeBackCompat: def test_single_cycle_wrapper_returns_int(self): appliance = make_appliance(prediction_hours=24, duration_h=2, num_cycles=1) result = appliance.set_starting_time(5) assert isinstance(result, int) assert result == 5 def test_no_remaining_cycles_returns_input_unchanged(self): appliance = make_appliance(prediction_hours=24, duration_h=2, num_cycles=1) appliance.completed_cycles = 1 # nothing left to schedule result = appliance.set_starting_time(7) assert result == 7 assert (appliance.load_curve == 0).all() # --------------------------------------------------------------------------- # Load curve utilities # --------------------------------------------------------------------------- class TestLoadCurve: def test_reset_load_curve_zeros_array(self): appliance = make_appliance(prediction_hours=6) appliance.load_curve[:] = 42 appliance.reset_load_curve() assert (appliance.load_curve == 0).all() assert len(appliance.load_curve) == 6 def test_get_load_for_hour_valid(self): appliance = make_appliance(prediction_hours=6) appliance.load_curve[2] = 123.0 assert appliance.get_load_for_hour(2) == 123.0 @pytest.mark.parametrize("hour", [-1, 6, 100]) def test_get_load_for_hour_out_of_range_raises(self, hour): appliance = make_appliance(prediction_hours=6) with pytest.raises(ValueError): appliance.get_load_for_hour(hour)