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(). |
|
| 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(). |
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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