diff --git a/docs/_generated/configelecfee.md b/docs/_generated/configelecfee.md index 7403e070..c9a8f14c 100644 --- a/docs/_generated/configelecfee.md +++ b/docs/_generated/configelecfee.md @@ -127,7 +127,7 @@ This model extends `TimeWindow` by associating a value with the defined time int | date | `Optional[pydantic_extra_types.pendulum_dt.Date]` | `rw` | `None` | Optional specific calendar date on which the time window must start. For overnight windows, this restriction applies to the start date, not the date of each datetime tested by contains(). | | day_of_week | `Union[int, str, NoneType]` | `rw` | `None` | Optional day of the week on which the time window must start. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. For overnight windows, the restriction applies to the start date. | | duration | `Duration` | `rw` | `0 microseconds` | Duration of the time window starting from `start_time`. | -| end_time | `Optional[akkudoktoreos.utils.datetimeutil.Time]` | `rw` | `None` | Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time is dalight saving time change save, whereas duration is always fixed. If None (the default), the window ends at start_time + duration. | +| end_time | `Optional[akkudoktoreos.utils.datetimeutil.Time]` | `rw` | `None` | Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time follows local wall-clock time across daylight saving time changes. If None (the default), the window ends at start_time + duration. | | locale | `Optional[str]` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. | | month | `Optional[int]` | `rw` | `None` | Optional calendar month restriction (1=January to 12=December). The window's start date must fall in this month, including for overnight windows that extend into the following month. | | start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). | diff --git a/docs/akkudoktoreos/configtimewindow.md b/docs/akkudoktoreos/configtimewindow.md index ab2fbf5f..0aaa3c13 100644 --- a/docs/akkudoktoreos/configtimewindow.md +++ b/docs/akkudoktoreos/configtimewindow.md @@ -45,7 +45,8 @@ The example defines a daily window from 09:00 to 11:00 without calendar restrict Each `TimeWindow` object supports the following fields: - **`start_time`** (required): Local time when the window begins. -- **`duration`** (required): Duration of the window, expressed as a time interval. +- **`duration`** (optional): Duration of the window, expressed as a time interval. Defaults to zero, + so it can be omitted when `end_time` defines the window. - **`end_time`** (optional): Explicit end time of the window. The effective end is the later of this time and the end calculated from `start_time` plus `duration`. - **`day_of_week`** (optional): Restricts the window's start date to a weekday. diff --git a/openapi.json b/openapi.json index a27ef907..b3c49551 100644 --- a/openapi.json +++ b/openapi.json @@ -16259,7 +16259,7 @@ } ], "title": "End Time", - "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time is dalight saving time change save, whereas duration is always fixed. If None (the default), the window ends at start_time + duration.", + "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time follows local wall-clock time across daylight saving time changes. If None (the default), the window ends at start_time + duration.", "examples": [ null, "12:00:00" @@ -16368,7 +16368,7 @@ } ], "title": "End Time", - "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time is dalight saving time change save, whereas duration is always fixed. If None (the default), the window ends at start_time + duration.", + "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time follows local wall-clock time across daylight saving time changes. If None (the default), the window ends at start_time + duration.", "examples": [ null, "12:00:00" @@ -16552,7 +16552,7 @@ } ], "title": "End Time", - "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time is dalight saving time change save, whereas duration is always fixed. If None (the default), the window ends at start_time + duration.", + "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time follows local wall-clock time across daylight saving time changes. If None (the default), the window ends at start_time + duration.", "examples": [ null, "12:00:00" @@ -16677,7 +16677,7 @@ } ], "title": "End Time", - "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time is dalight saving time change save, whereas duration is always fixed. If None (the default), the window ends at start_time + duration.", + "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time follows local wall-clock time across daylight saving time changes. If None (the default), the window ends at start_time + duration.", "examples": [ null, "12:00:00" diff --git a/src/akkudoktoreos/config/configabc.py b/src/akkudoktoreos/config/configabc.py index f44eb740..fdd6ca7e 100644 --- a/src/akkudoktoreos/config/configabc.py +++ b/src/akkudoktoreos/config/configabc.py @@ -1,6 +1,7 @@ """Abstract and base classes for configuration.""" import calendar +import math import os import sys from enum import StrEnum @@ -126,7 +127,7 @@ class TimeWindow(SettingsBaseModel): "description": ( "Optional exclusive end time of the window. " "The effective end is the later of end_time and start_time + duration. " - "End time is dalight saving time change save, whereas duration is always fixed. " + "End time follows local wall-clock time across daylight saving time changes. " "If None (the default), the window ends at start_time + duration." ), "examples": [None, "12:00:00"], @@ -279,7 +280,7 @@ class TimeWindow(SettingsBaseModel): @field_serializer("duration") def serialize_duration(self, value: Duration) -> str: """Serialize duration to string.""" - return str(value) + return "PT0S" if value.total_seconds() == 0 else str(value) def _window_start_end(self, reference_date: DateTime) -> tuple[DateTime, DateTime]: """Return the effective start and exclusive end of the time window. @@ -312,13 +313,6 @@ class TimeWindow(SettingsBaseModel): if self.end_time is None: return start, duration_end - explicit_end = start_day.replace( - hour=self.end_time.hour, - minute=self.end_time.minute, - second=self.end_time.second, - microsecond=self.end_time.microsecond, - ) - start_clock = ( self.start_time.hour, self.start_time.minute, @@ -332,9 +326,15 @@ class TimeWindow(SettingsBaseModel): self.end_time.microsecond, ) - # An end time at or before the start time belongs to the next day. - if end_clock <= start_clock: - explicit_end = explicit_end.add(days=1) + # Choose the date before setting the time: a nonexistent clock time on + # the start day must not be normalized and carried into the following day. + end_day = start_day.add(days=1) if end_clock <= start_clock else start_day + explicit_end = end_day.replace( + hour=self.end_time.hour, + minute=self.end_time.minute, + second=self.end_time.second, + microsecond=self.end_time.microsecond, + ) return start, max(duration_end, explicit_end) @@ -384,12 +384,11 @@ class TimeWindow(SettingsBaseModel): """ current_day = date_time.start_of("day") - # Check both today's window and a window that began yesterday. - # The previous-day candidate is necessary for overnight windows. - for start_day in ( - current_day, - current_day.subtract(days=1), - ): + # Include earlier starts for overnight and multi-day durations. One + # extra calendar day covers shorter days at daylight saving transitions. + days_back = max(1, math.ceil(self.duration.total_seconds() / 86400) + 1) + for offset in range(days_back + 1): + start_day = current_day.subtract(days=offset) if not self._matches_start_date(start_day): continue @@ -1187,61 +1186,22 @@ class CycleTimeWindowSequence(ValueTimeWindowSequence): ``1.0`` at step ``t`` means step ``t`` falls inside a window belonging to ``cycle``; ``0.0`` otherwise. """ - import math + if interval.total_seconds() <= 0: + return np.zeros(0, dtype=np.float64) - interval_s = interval.total_seconds() - - if align_to_interval and interval_s > 0: - # Floor purely in wall-clock seconds — identical to to_array's - # alignment so all three methods produce consistent grids. - wall_s = ( - start_datetime.hour * 3600 - + start_datetime.minute * 60 - + start_datetime.second - + start_datetime.microsecond / 1_000_000 - ) - remainder_s = wall_s % interval_s - if remainder_s: - start_datetime = start_datetime.subtract(seconds=remainder_s) - - # Use ceil so a partially-covered final step is included, matching the - # while-loop semantics of to_array. - steps = ( - math.ceil((end_datetime - start_datetime).total_seconds() / interval_s) - if interval_s > 0 - else 0 + windows = TimeWindowSequence[ValueTimeWindow]( + windows=[ + window + for window in self.windows + if window.value is not None and int(window.value) == cycle + ] + ) + return windows.to_array( + start_datetime, + end_datetime, + interval, + align_to_interval=align_to_interval, ) - result = np.zeros(steps, dtype=np.float64) - - # Anchor window start times to the calendar day of the (aligned) grid start. - base_day = start_datetime.start_of("day") - - for window in self.windows: - if window.value is None: - continue - if int(window.value) != cycle: - continue - - t = window.start_time - win_start = base_day.replace( - hour=t.hour, - minute=t.minute, - second=t.second, - microsecond=t.microsecond, - ) - win_end = win_start + window.duration - - # Convert to step indices relative to the aligned grid start. - idx_start = int((win_start - start_datetime).total_seconds() / interval_s) - idx_end = math.ceil((win_end - start_datetime).total_seconds() / interval_s) - - idx_start = max(idx_start, 0) - idx_end = min(idx_end, steps) - - if idx_start < idx_end: - result[idx_start:idx_end] = 1.0 - - return result def cycles_to_matrix( self, @@ -1275,8 +1235,6 @@ class CycleTimeWindowSequence(ValueTimeWindowSequence): ``matrix[k, t] == 1.0`` iff step ``t`` falls inside a window belonging to ``cycle_indices[k]``; ``0.0`` otherwise. """ - import math - interval_s = interval.total_seconds() if interval_s > 0: @@ -1299,34 +1257,13 @@ class CycleTimeWindowSequence(ValueTimeWindowSequence): ) matrix = np.zeros((len(cycles), steps), dtype=np.float64) - cycle_index = {c: i for i, c in enumerate(cycles)} - - # Anchor window start times to the calendar day of the aligned grid start. - base_day = start_datetime.start_of("day") - - for window in self.windows: - if window.value is None: - continue - - c = int(window.value) - row = cycle_index[c] - - t = window.start_time - win_start = base_day.replace( - hour=t.hour, - minute=t.minute, - second=t.second, - microsecond=t.microsecond, + for row, cycle in enumerate(cycles): + matrix[row] = self.cycle_to_array( + cycle, + start_datetime, + end_datetime, + interval, + align_to_interval=False, ) - win_end = win_start + window.duration - - idx_start = int((win_start - start_datetime).total_seconds() / interval_s) - idx_end = math.ceil((win_end - start_datetime).total_seconds() / interval_s) - - idx_start = max(idx_start, 0) - idx_end = min(idx_end, steps) - - if idx_start < idx_end: - matrix[row, idx_start:idx_end] = 1.0 return cycles, matrix diff --git a/src/akkudoktoreos/server/dash/components.py b/src/akkudoktoreos/server/dash/components.py index eb323652..6f86ad56 100644 --- a/src/akkudoktoreos/server/dash/components.py +++ b/src/akkudoktoreos/server/dash/components.py @@ -823,10 +823,10 @@ def make_config_update_time_windows_windows_form( const monthRaw = document.querySelector("[name='{wid}_month']").value; const date = document.querySelector("[name='{wid}_date']").value.trim(); const locale = document.querySelector("[name='{wid}_locale']").value.trim(); - if (!start || !dur || {val_js_guard}) return {json.dumps(json.dumps(current_windows))}; + if (!start || {val_js_guard}) return {json.dumps(json.dumps(current_windows))}; const edited = {{ start_time: start, - duration: dur, + duration: dur !== "" ? dur : "PT0S", end_time: end !== "" ? end : null, {val_js_field} day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null, @@ -917,7 +917,7 @@ def make_config_update_time_windows_windows_form( header_cols = [ P("start time *", cls="text-xs text-muted-foreground font-semibold"), - P("duration *", cls="text-xs text-muted-foreground font-semibold"), + P("duration", cls="text-xs text-muted-foreground font-semibold"), P("end time", cls="text-xs text-muted-foreground font-semibold"), ] add_input_cols = [ @@ -1001,10 +1001,10 @@ def make_config_update_time_windows_windows_form( const monthRaw = document.querySelector("[name='{add_wid}_month']").value; const date = document.querySelector("[name='{add_wid}_date']").value.trim(); const locale = document.querySelector("[name='{add_wid}_locale']").value.trim(); - if (!start || !dur || {add_val_js_guard}) return {current_json}; + if (!start || {add_val_js_guard}) return {current_json}; const newWin = {{ start_time: start, - duration: dur, + duration: dur !== "" ? dur : "PT0S", end_time: end !== "" ? end : null, {add_val_js_field} day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null, diff --git a/tests/test_timewindow_boundaries.py b/tests/test_timewindow_boundaries.py new file mode 100644 index 00000000..2d9b5912 --- /dev/null +++ b/tests/test_timewindow_boundaries.py @@ -0,0 +1,154 @@ +"""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])