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(). |
| 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(). |
+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:
- **`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.
+4 -4
View File
@@ -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"
+37 -100
View File
@@ -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
windows = TimeWindowSequence[ValueTimeWindow](
windows=[
window
for window in self.windows
if window.value is not None and int(window.value) == cycle
]
)
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
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
+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 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,
+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])