mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-29 09:11:57 +00:00
fix: honor effective time window boundaries in cycle scheduling
This commit is contained in:
@@ -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(). |
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user