fix: honor effective time window boundaries in cycle scheduling

This commit is contained in:
Normann
2026-10-28 18:59:43 +01:00
committed by Bobby Noelte
parent c02d250639
commit d22628b312
6 changed files with 204 additions and 112 deletions
+1 -1
View File
@@ -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(). | | 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. | | 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`. | | 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. | | 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. | | 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(). | | 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(). |
+2 -1
View File
@@ -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: Each `TimeWindow` object supports the following fields:
- **`start_time`** (required): Local time when the window begins. - **`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 - **`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`. 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. - **`day_of_week`** (optional): Restricts the window's start date to a weekday.
+4 -4
View File
@@ -16259,7 +16259,7 @@
} }
], ],
"title": "End Time", "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": [ "examples": [
null, null,
"12:00:00" "12:00:00"
@@ -16368,7 +16368,7 @@
} }
], ],
"title": "End Time", "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": [ "examples": [
null, null,
"12:00:00" "12:00:00"
@@ -16552,7 +16552,7 @@
} }
], ],
"title": "End Time", "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": [ "examples": [
null, null,
"12:00:00" "12:00:00"
@@ -16677,7 +16677,7 @@
} }
], ],
"title": "End Time", "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": [ "examples": [
null, null,
"12:00:00" "12:00:00"
+38 -101
View File
@@ -1,6 +1,7 @@
"""Abstract and base classes for configuration.""" """Abstract and base classes for configuration."""
import calendar import calendar
import math
import os import os
import sys import sys
from enum import StrEnum from enum import StrEnum
@@ -126,7 +127,7 @@ class TimeWindow(SettingsBaseModel):
"description": ( "description": (
"Optional exclusive end time of the window. " "Optional exclusive end time of the window. "
"The effective end is the later of end_time and start_time + duration. " "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." "If None (the default), the window ends at start_time + duration."
), ),
"examples": [None, "12:00:00"], "examples": [None, "12:00:00"],
@@ -279,7 +280,7 @@ class TimeWindow(SettingsBaseModel):
@field_serializer("duration") @field_serializer("duration")
def serialize_duration(self, value: Duration) -> str: def serialize_duration(self, value: Duration) -> str:
"""Serialize duration to string.""" """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]: def _window_start_end(self, reference_date: DateTime) -> tuple[DateTime, DateTime]:
"""Return the effective start and exclusive end of the time window. """Return the effective start and exclusive end of the time window.
@@ -312,13 +313,6 @@ class TimeWindow(SettingsBaseModel):
if self.end_time is None: if self.end_time is None:
return start, duration_end 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 = ( start_clock = (
self.start_time.hour, self.start_time.hour,
self.start_time.minute, self.start_time.minute,
@@ -332,9 +326,15 @@ class TimeWindow(SettingsBaseModel):
self.end_time.microsecond, self.end_time.microsecond,
) )
# An end time at or before the start time belongs to the next day. # Choose the date before setting the time: a nonexistent clock time on
if end_clock <= start_clock: # the start day must not be normalized and carried into the following day.
explicit_end = explicit_end.add(days=1) 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) return start, max(duration_end, explicit_end)
@@ -384,12 +384,11 @@ class TimeWindow(SettingsBaseModel):
""" """
current_day = date_time.start_of("day") current_day = date_time.start_of("day")
# Check both today's window and a window that began yesterday. # Include earlier starts for overnight and multi-day durations. One
# The previous-day candidate is necessary for overnight windows. # extra calendar day covers shorter days at daylight saving transitions.
for start_day in ( days_back = max(1, math.ceil(self.duration.total_seconds() / 86400) + 1)
current_day, for offset in range(days_back + 1):
current_day.subtract(days=1), start_day = current_day.subtract(days=offset)
):
if not self._matches_start_date(start_day): if not self._matches_start_date(start_day):
continue continue
@@ -1187,61 +1186,22 @@ class CycleTimeWindowSequence(ValueTimeWindowSequence):
``1.0`` at step ``t`` means step ``t`` falls inside a window ``1.0`` at step ``t`` means step ``t`` falls inside a window
belonging to ``cycle``; ``0.0`` otherwise. 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() windows = TimeWindowSequence[ValueTimeWindow](
windows=[
if align_to_interval and interval_s > 0: window
# Floor purely in wall-clock seconds — identical to to_array's for window in self.windows
# alignment so all three methods produce consistent grids. if window.value is not None and int(window.value) == cycle
wall_s = ( ]
start_datetime.hour * 3600 )
+ start_datetime.minute * 60 return windows.to_array(
+ start_datetime.second start_datetime,
+ start_datetime.microsecond / 1_000_000 end_datetime,
) interval,
remainder_s = wall_s % interval_s align_to_interval=align_to_interval,
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
) )
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( def cycles_to_matrix(
self, self,
@@ -1275,8 +1235,6 @@ class CycleTimeWindowSequence(ValueTimeWindowSequence):
``matrix[k, t] == 1.0`` iff step ``t`` falls inside a window ``matrix[k, t] == 1.0`` iff step ``t`` falls inside a window
belonging to ``cycle_indices[k]``; ``0.0`` otherwise. belonging to ``cycle_indices[k]``; ``0.0`` otherwise.
""" """
import math
interval_s = interval.total_seconds() interval_s = interval.total_seconds()
if interval_s > 0: if interval_s > 0:
@@ -1299,34 +1257,13 @@ class CycleTimeWindowSequence(ValueTimeWindowSequence):
) )
matrix = np.zeros((len(cycles), steps), dtype=np.float64) matrix = np.zeros((len(cycles), steps), dtype=np.float64)
cycle_index = {c: i for i, c in enumerate(cycles)} for row, cycle in enumerate(cycles):
matrix[row] = self.cycle_to_array(
# Anchor window start times to the calendar day of the aligned grid start. cycle,
base_day = start_datetime.start_of("day") start_datetime,
end_datetime,
for window in self.windows: interval,
if window.value is None: align_to_interval=False,
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,
) )
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 return cycles, matrix
+5 -5
View File
@@ -823,10 +823,10 @@ def make_config_update_time_windows_windows_form(
const monthRaw = document.querySelector("[name='{wid}_month']").value; const monthRaw = document.querySelector("[name='{wid}_month']").value;
const date = document.querySelector("[name='{wid}_date']").value.trim(); const date = document.querySelector("[name='{wid}_date']").value.trim();
const locale = document.querySelector("[name='{wid}_locale']").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 = {{ const edited = {{
start_time: start, start_time: start,
duration: dur, duration: dur !== "" ? dur : "PT0S",
end_time: end !== "" ? end : null, end_time: end !== "" ? end : null,
{val_js_field} {val_js_field}
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null, day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
@@ -917,7 +917,7 @@ def make_config_update_time_windows_windows_form(
header_cols = [ header_cols = [
P("start time *", cls="text-xs text-muted-foreground font-semibold"), 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"), P("end time", cls="text-xs text-muted-foreground font-semibold"),
] ]
add_input_cols = [ 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 monthRaw = document.querySelector("[name='{add_wid}_month']").value;
const date = document.querySelector("[name='{add_wid}_date']").value.trim(); const date = document.querySelector("[name='{add_wid}_date']").value.trim();
const locale = document.querySelector("[name='{add_wid}_locale']").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 = {{ const newWin = {{
start_time: start, start_time: start,
duration: dur, duration: dur !== "" ? dur : "PT0S",
end_time: end !== "" ? end : null, end_time: end !== "" ? end : null,
{add_val_js_field} {add_val_js_field}
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null, day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
+154
View File
@@ -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])