From 63e7e7e7295a3d37223f10259a1ff3e2dc7e0352 Mon Sep 17 00:00:00 2001 From: Normann Date: Mon, 26 Oct 2026 23:04:32 +0100 Subject: [PATCH] fix: honor DST boundaries in appliance scheduling Compare elapsed offsets when checking and merging appliance windows so both occurrences of the autumn hour obey the configured boundaries. Preserve calendar durations when finding the earliest valid start. Add optimizer and calendar-day regressions, and consolidate the existing time-window regression files into test_configabc classes. --- src/akkudoktoreos/config/configabc.py | 3 +- .../devices/genetic/homeappliance.py | 26 +- tests/test_configabc.py | 249 ++++++++++++++++++ tests/test_genetichomeappliance.py | 78 ++++++ tests/test_timewindow_boundaries.py | 154 ----------- tests/test_timewindow_dst.py | 97 ------- 6 files changed, 345 insertions(+), 262 deletions(-) delete mode 100644 tests/test_timewindow_boundaries.py delete mode 100644 tests/test_timewindow_dst.py diff --git a/src/akkudoktoreos/config/configabc.py b/src/akkudoktoreos/config/configabc.py index 09c8eee7..112f4176 100644 --- a/src/akkudoktoreos/config/configabc.py +++ b/src/akkudoktoreos/config/configabc.py @@ -431,7 +431,8 @@ class TimeWindow(SettingsBaseModel): window_start, window_end = self._window_start_end(reference_date) - if duration > window_end - window_start: + # Add first so calendar durations retain their meaning across DST. + if (window_start + duration - window_end).total_seconds() > 0: return None return window_start diff --git a/src/akkudoktoreos/devices/genetic/homeappliance.py b/src/akkudoktoreos/devices/genetic/homeappliance.py index 5781687b..b9c16117 100644 --- a/src/akkudoktoreos/devices/genetic/homeappliance.py +++ b/src/akkudoktoreos/devices/genetic/homeappliance.py @@ -565,12 +565,16 @@ class HomeAppliance: ) -> bool: """Check full coverage including windows anchored on previous local dates. - Date and weekday restrictions belong to the window's opening day. + Date, weekday, and month restrictions belong to the window's opening day. Per-cycle windows retain their existing union semantics; shared windows require one complete containing occurrence. """ - intervals: list[tuple[DateTime, DateTime]] = [] + # Compare elapsed offsets from the run start, including when sorting + # and merging intervals. Wall-clock ordering cannot distinguish the + # two occurrences of an hour repeated during a DST transition. + intervals: list[tuple[float, float]] = [] run_end = start + duration + run_seconds = (run_end - start).total_seconds() for window in windows: days_back = math.ceil(window.duration.total_seconds() / 86400) + 1 for offset in range(days_back + 1): @@ -582,16 +586,18 @@ class HomeAppliance: if window.month is not None and anchor.month != window.month: continue opening, closing = window._window_start_end(anchor) - if opening <= start and run_end <= closing: + opening_offset = (opening - start).total_seconds() + closing_offset = (closing - start).total_seconds() + if opening_offset <= 0 and run_seconds <= closing_offset: return True - if merge and opening < run_end and closing > start: - intervals.append((opening, closing)) - covered_until = start - for opening, closing in sorted(intervals): - if opening > covered_until: + if merge and opening_offset < run_seconds and closing_offset > 0: + intervals.append((opening_offset, closing_offset)) + covered_until = 0.0 + for opening_offset, closing_offset in sorted(intervals): + if opening_offset > covered_until: break - covered_until = max(covered_until, closing) - if covered_until >= run_end: + covered_until = max(covered_until, closing_offset) + if covered_until >= run_seconds: return True return False diff --git a/tests/test_configabc.py b/tests/test_configabc.py index 2163cc58..3dd7d4d9 100644 --- a/tests/test_configabc.py +++ b/tests/test_configabc.py @@ -15,6 +15,7 @@ Timezone contract under test: """ import datetime +import json import os import sys @@ -38,6 +39,10 @@ from akkudoktoreos.config.configabc import ( # noqa — ensure Time is importab ValueTimeWindow, ValueTimeWindowSequence, ) +from akkudoktoreos.devices.genetic.homeappliance import ( + HomeAppliance, + HomeApplianceParameters, +) from akkudoktoreos.utils.datetimeutil import Time, to_datetime, to_duration # =========================================================================== @@ -571,6 +576,29 @@ class TestStartTimes: assert result is not None assert result.hour == 8 + @pytest.mark.parametrize( + ("date", "duration", "expected"), + [ + ("2026-03-29", "P1D", "2026-03-29T00:00:00+01:00"), + ("2026-03-29", "PT24H", None), + ("2026-03-28", "P1D", "2026-03-28T00:00:00+01:00"), + ("2026-03-28", "PT24H", "2026-03-28T00:00:00+01:00"), + ("2026-10-25", "P1D", "2026-10-25T00:00:00+02:00"), + ("2026-10-25", "PT24H", "2026-10-25T00:00:00+02:00"), + ], + ) + def test_earliest_calendar_day_across_dst(self, date, duration, expected): + """A calendar day fits a local day even when it has fewer than 24 hours.""" + window = make_tw(start_time="00:00", end_time="00:00") + reference = pendulum.from_format(date, "YYYY-MM-DD", tz="Europe/Berlin") + run_duration = to_duration(duration) + + earliest = window.earliest_start_time(run_duration, reference) + + assert (earliest.isoformat() if earliest is not None else None) == expected + assert window.can_fit_duration(run_duration, reference) is (expected is not None) + assert window.contains(reference, run_duration) is (expected is not None) + def test_latest_naive(self): ref = naive_dt(2024, 6, 15) result = self.w.latest_start_time(pendulum.duration(hours=2), reference_date=ref) @@ -2276,3 +2304,224 @@ class TestCyclesToMatrix: expected, err_msg=f"Row {k} (cycle {cycle_idx}) differs from cycle_to_array (misaligned)", ) + + +class TestTimeWindowBoundaries: + """Explicit window ends, serialization, and cycle scheduling.""" + + @pytest.mark.parametrize("model", [TimeWindow, ValueTimeWindow]) + def test_end_only_window_json_round_trip(self, model): + window = model.model_validate({"start_time": "08:00", "end_time": "12:00"}) + + serialized = window.model_dump_json() + + assert json.loads(serialized)["duration"] == "PT0S" + restored = model.model_validate_json(serialized) + assert restored.model_dump() == window.model_dump() + assert restored.contains(pendulum.datetime(2026, 10, 26, 9)) + + @pytest.mark.parametrize( + ("start_date", "start_time", "end_time", "expected_end"), + [ + # The missing 02:30 on the opening date must not shift Monday's end. + ("2026-03-29", "22:00", "02:30", "2026-03-30T02:30:00+02:00"), + # The explicit end lies after the spring transition. + ("2026-03-28", "22:00", "06:00", "2026-03-29T06:00:00+02:00"), + # The explicit end lies after the autumn transition. + ("2026-10-24", "22:00", "06:00", "2026-10-25T06:00:00+01:00"), + ], + ) + def test_overnight_end_uses_clock_time_on_end_date( + self, start_date, start_time, end_time, expected_end + ): + window = TimeWindow.model_validate({"start_time": start_time, "end_time": end_time}) + reference = pendulum.from_format(start_date, "YYYY-MM-DD", tz="Europe/Berlin") + + _, end = window._window_start_end(reference) + + assert end.isoformat() == expected_end + assert not window.contains(end) + assert window.contains(end.subtract(minutes=1)) + + @pytest.mark.parametrize( + ("config", "start", "hours", "active_steps"), + [ + ({"start_time": "08:00", "end_time": "12:00"}, "2026-10-26", 24, [8, 9, 10, 11]), + # Duration may extend the explicit end. + ( + {"start_time": "08:00", "end_time": "10:00", "duration": "4 hours"}, + "2026-10-26", + 24, + [8, 9, 10, 11], + ), + ({"start_time": "08:00", "end_time": "12:00", "month": 11}, "2026-10-26", 24, []), + ({"start_time": "08:00", "end_time": "12:00", "day_of_week": 1}, "2026-10-26", 24, []), + ({"start_time": "08:00", "end_time": "12:00", "date": "2026-10-27"}, "2026-10-26", 24, []), + # Include the tail of Friday's window on Saturday. + ( + {"start_time": "22:00", "end_time": "06:00", "day_of_week": 4}, + "2026-10-24", + 24, + list(range(6)), + ), + # Month restrictions belong to the opening date. + ( + {"start_time": "22:00", "end_time": "06:00", "month": 10}, + "2026-11-01", + 24, + list(range(6)), + ), + # Long durations can begin more than one calendar day earlier. + ( + {"start_time": "08:00", "duration": "3 days", "date": "2026-10-24"}, + "2026-10-26", + 24, + list(range(24)), + ), + # Recurring windows must also appear on later horizon days. + ({"start_time": "08:00", "end_time": "10:00"}, "2026-10-26", 48, [8, 9, 32, 33]), + ], + ) + def test_cycle_arrays_use_effective_windows(self, config, start, hours, active_steps): + window = ValueTimeWindow.model_validate({**config, "value": 0}) + sequence = CycleTimeWindowSequence(windows=[window]) + reference = pendulum.from_format(start, "YYYY-MM-DD", tz="UTC") + end = reference.add(hours=hours) + interval = pendulum.duration(hours=1) + expected = np.zeros(hours) + expected[active_steps] = 1 + + actual = sequence.cycle_to_array(0, reference, end, interval) + indices, matrix = sequence.cycles_to_matrix(reference, end, interval) + + assert indices == [0] + np.testing.assert_array_equal(actual, expected) + np.testing.assert_array_equal(matrix[0], expected) + + @pytest.mark.parametrize(("date", "hours"), [("2026-03-29", 23), ("2026-10-25", 25)]) + def test_cycle_arrays_across_dst(self, date, hours): + window = ValueTimeWindow.model_validate( + {"start_time": "00:00", "end_time": "06:00", "value": 0} + ) + sequence = CycleTimeWindowSequence(windows=[window]) + start = pendulum.from_format(date, "YYYY-MM-DD", tz="Europe/Berlin") + end = start.add(days=1) + expected = np.zeros(hours) + expected[: hours - 18] = 1 # Five elapsed hours in spring; seven in autumn. + + actual = sequence.cycle_to_array(0, start, end, pendulum.duration(hours=1)) + _, matrix = sequence.cycles_to_matrix(start, end, pendulum.duration(hours=1)) + + np.testing.assert_array_equal(actual, expected) + np.testing.assert_array_equal(matrix[0], expected) + + def test_end_only_cycle_window_allows_appliance_starts(self): + sequence = CycleTimeWindowSequence.model_validate( + {"windows": [{"start_time": "08:00", "end_time": "12:00", "value": 0}]} + ) + # Exercise the serialized settings used by the dashboard/API as well. + sequence = CycleTimeWindowSequence.model_validate_json(sequence.model_dump_json()) + appliance = HomeAppliance( + parameters=HomeApplianceParameters( + device_id="dishwasher", + duration_h=2, + consumption_wh=2000, + num_cycles=1, + time_windows=sequence, + ), + prediction_hours=24, + optimization_hours=24, + ) + start = pendulum.datetime(2026, 10, 26) + appliance.start_allowed = [] + appliance.start_earliest = [] + appliance.start_latest = [] + + appliance._build_cycle_start_allowed( + sequence, start, start.add(days=1), pendulum.duration(hours=1) + ) + + np.testing.assert_array_equal(np.flatnonzero(appliance.start_allowed[0]), [8, 9, 10]) + + +class TestTimeWindowDST: + """Window boundaries and grids distinguish the repeated DST hour.""" + + @staticmethod + def autumn_time(hour, minute=0, fold=1): + return pendulum.datetime(2026, 10, 25, hour, minute, tz="Europe/Berlin", fold=fold) + + @pytest.mark.parametrize( + ("duration", "expected"), + [ + ("PT1H45M", "2026-10-25T02:30:00+01:00"), + ("PT2H45M", "2026-10-25T02:45:00+01:00"), + ], + ) + @pytest.mark.parametrize("fold", [0, 1]) + def test_effective_end_uses_later_instant(self, duration, expected, fold): + window = TimeWindow.model_validate( + {"start_time": "01:00", "end_time": "02:30", "duration": duration} + ) + + _, end = window._window_start_end(self.autumn_time(2, fold=fold)) + + assert end.isoformat() == expected + + @pytest.mark.parametrize( + ("hour", "minute", "fold", "duration", "expected"), + [ + (2, 45, 0, None, True), + (2, 30, 0, None, True), + (2, 30, 1, None, False), + (2, 45, 1, None, False), + (2, 45, 0, 45, True), + (2, 45, 0, 46, False), + ], + ) + def test_membership_uses_elapsed_time(self, hour, minute, fold, duration, expected): + window = TimeWindow.model_validate({"start_time": "01:00", "end_time": "02:30"}) + run_duration = pendulum.duration(minutes=duration) if duration is not None else None + + assert window.contains(self.autumn_time(hour, minute, fold), run_duration) is expected + + def test_membership_rejects_first_fold_before_second_fold_start(self): + window = TimeWindow.model_validate({"start_time": "02:30", "end_time": "04:00"}) + + assert not window.contains(self.autumn_time(2, 45, fold=0)) + assert window.contains(self.autumn_time(2, 45, fold=1)) + + @pytest.mark.parametrize("minute", [0, 30]) + @pytest.mark.parametrize("start_hour", [0, 2]) + def test_cycle_grid_ends_in_second_fold(self, start_hour, minute): + sequence = CycleTimeWindowSequence.model_validate( + {"windows": [{"start_time": "00:00", "end_time": "06:00", "value": 0}]} + ) + start = self.autumn_time(start_hour, fold=0) + end = self.autumn_time(2, minute, fold=1) + interval = pendulum.duration(minutes=30) + expected = np.ones(int((end - start).total_seconds() / interval.total_seconds())) + + actual = sequence.cycle_to_array(0, start, end, interval) + indices, matrix = sequence.cycles_to_matrix(start, end, interval) + + assert indices == [0] + np.testing.assert_array_equal(actual, expected) + np.testing.assert_array_equal(matrix, expected[np.newaxis, :]) + + @pytest.mark.parametrize("sequence_type", [TimeWindowSequence, ValueTimeWindowSequence]) + def test_array_and_series_keep_both_folds(self, sequence_type): + window = ValueTimeWindow.model_validate( + {"start_time": "00:00", "end_time": "06:00", "value": 1} + ) + sequence = sequence_type(windows=[window]) + start = self.autumn_time(0) + end = self.autumn_time(2, fold=1) + interval = pendulum.duration(hours=1) + + np.testing.assert_array_equal(sequence.to_array(start, end, interval), np.ones(3)) + series = sequence.to_series(start, end, interval) + np.testing.assert_array_equal(series.to_numpy(), np.ones(3)) + assert [stamp.timestamp() for stamp in series.index] == [ + start.add(hours=i).timestamp() for i in range(3) + ] diff --git a/tests/test_genetichomeappliance.py b/tests/test_genetichomeappliance.py index bc93233c..a0fbee98 100644 --- a/tests/test_genetichomeappliance.py +++ b/tests/test_genetichomeappliance.py @@ -7,6 +7,7 @@ from typing import Any from unittest.mock import Mock import numpy as np +import pendulum import pytest from akkudoktoreos.config.configabc import CycleTimeWindowSequence, ValueTimeWindow @@ -182,6 +183,83 @@ class TestExplicitCycleWindow: assert appliance.start_latest[0] == 24 - 3 +class TestDSTAllowedStartSlots: + """The optimizer must distinguish both occurrences of the autumn hour.""" + + @staticmethod + def _appliance(window_field, windows, run_minutes=15): + if window_field == "time_windows": + windows = [{**window, "value": 0} for window in windows] + return HomeAppliance( + parameters=HomeApplianceParameters.model_validate( + { + "device_id": "dishwasher", + "load_profile_power_w": [1000], + "load_profile_interval_seconds": run_minutes * 60, + window_field: {"windows": windows}, + } + ), + optimization_hours=25, + prediction_hours=100, + slot_duration_h=0.25, + ) + + @pytest.mark.parametrize("window_field", ["shared_time_windows", "time_windows"]) + def test_first_fold_starts_before_second_fold_opening_are_rejected(self, window_field): + appliance = self._appliance( + window_field, [{"start_time": "02:30", "end_time": "04:00"}] + ) + zero = pendulum.datetime(2026, 10, 25, tz="Europe/Berlin") + + allowed = appliance.allowed_start_slots( + slot0_datetime=zero, earliest_slot=0, horizon_end_slot=24, cycle_index=0 + ) + + # The second 02:30 is slot 14. The first 02:30/02:45 are slots 10/11 + # and precede the configured opening despite their matching clock times. + assert allowed == list(range(14, 20)) + + @pytest.mark.parametrize("window_field", ["shared_time_windows", "time_windows"]) + def test_first_fold_starts_before_second_fold_closing_are_retained(self, window_field): + appliance = self._appliance( + window_field, [{"start_time": "01:00", "end_time": "02:30"}] + ) + zero = pendulum.datetime(2026, 10, 25, tz="Europe/Berlin") + + allowed = appliance.allowed_start_slots( + slot0_datetime=zero, earliest_slot=0, horizon_end_slot=24, cycle_index=0 + ) + + # Includes a run from the first 02:30 to the first 02:45 (slot 10), + # which finishes before the second 02:30 closing boundary. + assert allowed == list(range(4, 14)) + + @pytest.mark.parametrize( + ("first_duration", "expected"), [("1 hour", []), ("2 hours", [9])] + ) + def test_cycle_window_union_distinguishes_touching_instants_from_clock_times( + self, first_duration, expected + ): + appliance = self._appliance( + "time_windows", + [ + {"start_time": "01:30", "duration": first_duration}, + {"start_time": "02:30", "end_time": "03:30"}, + ], + run_minutes=90, + ) + zero = pendulum.datetime(2026, 10, 25, tz="Europe/Berlin") + + allowed = appliance.allowed_start_slots( + slot0_datetime=zero, earliest_slot=9, horizon_end_slot=15, cycle_index=0 + ) + + # The only candidate runs from the first 02:15 to the second 02:45. + # A one-hour first window ends at the first 02:30, leaving a real + # one-hour gap; a two-hour first window touches the second opening. + assert allowed == expected + + # --------------------------------------------------------------------------- # Allowed-start computation: mocked per-cycle windows (CycleTimeWindowSequence) # --------------------------------------------------------------------------- diff --git a/tests/test_timewindow_boundaries.py b/tests/test_timewindow_boundaries.py deleted file mode 100644 index 2d9b5912..00000000 --- a/tests/test_timewindow_boundaries.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Regression tests for explicit window ends and cycle scheduling.""" - -import json - -import numpy as np -import pendulum -import pytest - -from akkudoktoreos.config.configabc import ( - CycleTimeWindowSequence, - TimeWindow, - ValueTimeWindow, -) -from akkudoktoreos.devices.genetic.homeappliance import ( - HomeAppliance, - HomeApplianceParameters, -) - - -@pytest.mark.parametrize("model", [TimeWindow, ValueTimeWindow]) -def test_end_only_window_json_round_trip(model): - window = model.model_validate({"start_time": "08:00", "end_time": "12:00"}) - - serialized = window.model_dump_json() - - assert json.loads(serialized)["duration"] == "PT0S" - restored = model.model_validate_json(serialized) - assert restored.model_dump() == window.model_dump() - assert restored.contains(pendulum.datetime(2026, 10, 26, 9)) - - -@pytest.mark.parametrize( - ("start_date", "start_time", "end_time", "expected_end"), - [ - # The missing 02:30 on the opening date must not shift Monday's end. - ("2026-03-29", "22:00", "02:30", "2026-03-30T02:30:00+02:00"), - # The explicit end lies after the spring transition. - ("2026-03-28", "22:00", "06:00", "2026-03-29T06:00:00+02:00"), - # The explicit end lies after the autumn transition. - ("2026-10-24", "22:00", "06:00", "2026-10-25T06:00:00+01:00"), - ], -) -def test_overnight_end_uses_clock_time_on_end_date(start_date, start_time, end_time, expected_end): - window = TimeWindow.model_validate({"start_time": start_time, "end_time": end_time}) - reference = pendulum.from_format(start_date, "YYYY-MM-DD", tz="Europe/Berlin") - - _, end = window._window_start_end(reference) - - assert end.isoformat() == expected_end - assert not window.contains(end) - assert window.contains(end.subtract(minutes=1)) - - -@pytest.mark.parametrize( - ("config", "start", "hours", "active_steps"), - [ - ({"start_time": "08:00", "end_time": "12:00"}, "2026-10-26", 24, [8, 9, 10, 11]), - # Duration may extend the explicit end. - ( - {"start_time": "08:00", "end_time": "10:00", "duration": "4 hours"}, - "2026-10-26", - 24, - [8, 9, 10, 11], - ), - ({"start_time": "08:00", "end_time": "12:00", "month": 11}, "2026-10-26", 24, []), - ({"start_time": "08:00", "end_time": "12:00", "day_of_week": 1}, "2026-10-26", 24, []), - ({"start_time": "08:00", "end_time": "12:00", "date": "2026-10-27"}, "2026-10-26", 24, []), - # Include the tail of Friday's window on Saturday. - ( - {"start_time": "22:00", "end_time": "06:00", "day_of_week": 4}, - "2026-10-24", - 24, - list(range(6)), - ), - # Month restrictions belong to the opening date. - ( - {"start_time": "22:00", "end_time": "06:00", "month": 10}, - "2026-11-01", - 24, - list(range(6)), - ), - # Long durations can begin more than one calendar day earlier. - ( - {"start_time": "08:00", "duration": "3 days", "date": "2026-10-24"}, - "2026-10-26", - 24, - list(range(24)), - ), - # Recurring windows must also appear on later horizon days. - ({"start_time": "08:00", "end_time": "10:00"}, "2026-10-26", 48, [8, 9, 32, 33]), - ], -) -def test_cycle_arrays_use_effective_windows(config, start, hours, active_steps): - window = ValueTimeWindow.model_validate({**config, "value": 0}) - sequence = CycleTimeWindowSequence(windows=[window]) - reference = pendulum.from_format(start, "YYYY-MM-DD", tz="UTC") - end = reference.add(hours=hours) - interval = pendulum.duration(hours=1) - expected = np.zeros(hours) - expected[active_steps] = 1 - - actual = sequence.cycle_to_array(0, reference, end, interval) - indices, matrix = sequence.cycles_to_matrix(reference, end, interval) - - assert indices == [0] - np.testing.assert_array_equal(actual, expected) - np.testing.assert_array_equal(matrix[0], expected) - - -@pytest.mark.parametrize(("date", "hours"), [("2026-03-29", 23), ("2026-10-25", 25)]) -def test_cycle_arrays_across_dst(date, hours): - window = ValueTimeWindow.model_validate( - {"start_time": "00:00", "end_time": "06:00", "value": 0} - ) - sequence = CycleTimeWindowSequence(windows=[window]) - start = pendulum.from_format(date, "YYYY-MM-DD", tz="Europe/Berlin") - end = start.add(days=1) - expected = np.zeros(hours) - expected[: hours - 18] = 1 # Five elapsed hours in spring; seven in autumn. - - actual = sequence.cycle_to_array(0, start, end, pendulum.duration(hours=1)) - _, matrix = sequence.cycles_to_matrix(start, end, pendulum.duration(hours=1)) - - np.testing.assert_array_equal(actual, expected) - np.testing.assert_array_equal(matrix[0], expected) - - -def test_end_only_cycle_window_allows_appliance_starts(): - sequence = CycleTimeWindowSequence.model_validate( - {"windows": [{"start_time": "08:00", "end_time": "12:00", "value": 0}]} - ) - # Exercise the serialized settings used by the dashboard/API as well. - sequence = CycleTimeWindowSequence.model_validate_json(sequence.model_dump_json()) - appliance = HomeAppliance( - parameters=HomeApplianceParameters( - device_id="dishwasher", - duration_h=2, - consumption_wh=2000, - num_cycles=1, - time_windows=sequence, - ), - prediction_hours=24, - optimization_hours=24, - ) - start = pendulum.datetime(2026, 10, 26) - appliance.start_allowed = [] - appliance.start_earliest = [] - appliance.start_latest = [] - - appliance._build_cycle_start_allowed( - sequence, start, start.add(days=1), pendulum.duration(hours=1) - ) - - np.testing.assert_array_equal(np.flatnonzero(appliance.start_allowed[0]), [8, 9, 10]) diff --git a/tests/test_timewindow_dst.py b/tests/test_timewindow_dst.py deleted file mode 100644 index 3af11506..00000000 --- a/tests/test_timewindow_dst.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Time window boundaries and grids must distinguish the repeated DST hour.""" - -import numpy as np -import pendulum -import pytest - -from akkudoktoreos.config.configabc import ( - CycleTimeWindowSequence, - TimeWindow, - TimeWindowSequence, - ValueTimeWindow, - ValueTimeWindowSequence, -) - - -def autumn_time(hour, minute=0, fold=1): - return pendulum.datetime(2026, 10, 25, hour, minute, tz="Europe/Berlin", fold=fold) - - -@pytest.mark.parametrize( - ("duration", "expected"), - [ - ("PT1H45M", "2026-10-25T02:30:00+01:00"), - ("PT2H45M", "2026-10-25T02:45:00+01:00"), - ], -) -@pytest.mark.parametrize("fold", [0, 1]) -def test_effective_end_uses_later_instant(duration, expected, fold): - window = TimeWindow.model_validate( - {"start_time": "01:00", "end_time": "02:30", "duration": duration} - ) - - _, end = window._window_start_end(autumn_time(2, fold=fold)) - - assert end.isoformat() == expected - - -@pytest.mark.parametrize( - ("hour", "minute", "fold", "duration", "expected"), - [ - (2, 45, 0, None, True), - (2, 30, 0, None, True), - (2, 30, 1, None, False), - (2, 45, 1, None, False), - (2, 45, 0, 45, True), - (2, 45, 0, 46, False), - ], -) -def test_membership_uses_elapsed_time(hour, minute, fold, duration, expected): - window = TimeWindow.model_validate({"start_time": "01:00", "end_time": "02:30"}) - run_duration = pendulum.duration(minutes=duration) if duration is not None else None - - assert window.contains(autumn_time(hour, minute, fold), run_duration) is expected - - -def test_membership_rejects_first_fold_before_second_fold_start(): - window = TimeWindow.model_validate({"start_time": "02:30", "end_time": "04:00"}) - - assert not window.contains(autumn_time(2, 45, fold=0)) - assert window.contains(autumn_time(2, 45, fold=1)) - - -@pytest.mark.parametrize("minute", [0, 30]) -@pytest.mark.parametrize("start_hour", [0, 2]) -def test_cycle_grid_ends_in_second_fold(start_hour, minute): - sequence = CycleTimeWindowSequence.model_validate( - {"windows": [{"start_time": "00:00", "end_time": "06:00", "value": 0}]} - ) - start = autumn_time(start_hour, fold=0) - end = autumn_time(2, minute, fold=1) - interval = pendulum.duration(minutes=30) - expected = np.ones(int((end - start).total_seconds() / interval.total_seconds())) - - actual = sequence.cycle_to_array(0, start, end, interval) - indices, matrix = sequence.cycles_to_matrix(start, end, interval) - - assert indices == [0] - np.testing.assert_array_equal(actual, expected) - np.testing.assert_array_equal(matrix, expected[np.newaxis, :]) - - -@pytest.mark.parametrize("sequence_type", [TimeWindowSequence, ValueTimeWindowSequence]) -def test_array_and_series_keep_both_folds(sequence_type): - window = ValueTimeWindow.model_validate( - {"start_time": "00:00", "end_time": "06:00", "value": 1} - ) - sequence = sequence_type(windows=[window]) - start = autumn_time(0) - end = autumn_time(2, fold=1) - interval = pendulum.duration(hours=1) - - np.testing.assert_array_equal(sequence.to_array(start, end, interval), np.ones(3)) - series = sequence.to_series(start, end, interval) - np.testing.assert_array_equal(series.to_numpy(), np.ones(3)) - assert [stamp.timestamp() for stamp in series.index] == [ - start.add(hours=i).timestamp() for i in range(3) - ]