mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-29 17:21:57 +00:00
fix: honor DST boundaries in appliance scheduling
Compare elapsed offsets when checking and merging appliance windows so both occurrences of the autumn hour obey the configured boundaries. Preserve calendar durations when finding the earliest valid start. Add optimizer and calendar-day regressions, and consolidate the existing time-window regression files into test_configabc classes.
This commit is contained in:
@@ -431,7 +431,8 @@ class TimeWindow(SettingsBaseModel):
|
|||||||
|
|
||||||
window_start, window_end = self._window_start_end(reference_date)
|
window_start, window_end = self._window_start_end(reference_date)
|
||||||
|
|
||||||
if duration > window_end - window_start:
|
# Add first so calendar durations retain their meaning across DST.
|
||||||
|
if (window_start + duration - window_end).total_seconds() > 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return window_start
|
return window_start
|
||||||
|
|||||||
@@ -565,12 +565,16 @@ class HomeAppliance:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check full coverage including windows anchored on previous local dates.
|
"""Check full coverage including windows anchored on previous local dates.
|
||||||
|
|
||||||
Date and weekday restrictions belong to the window's opening day.
|
Date, weekday, and month restrictions belong to the window's opening day.
|
||||||
Per-cycle windows retain their existing union semantics; shared windows
|
Per-cycle windows retain their existing union semantics; shared windows
|
||||||
require one complete containing occurrence.
|
require one complete containing occurrence.
|
||||||
"""
|
"""
|
||||||
intervals: list[tuple[DateTime, DateTime]] = []
|
# Compare elapsed offsets from the run start, including when sorting
|
||||||
|
# and merging intervals. Wall-clock ordering cannot distinguish the
|
||||||
|
# two occurrences of an hour repeated during a DST transition.
|
||||||
|
intervals: list[tuple[float, float]] = []
|
||||||
run_end = start + duration
|
run_end = start + duration
|
||||||
|
run_seconds = (run_end - start).total_seconds()
|
||||||
for window in windows:
|
for window in windows:
|
||||||
days_back = math.ceil(window.duration.total_seconds() / 86400) + 1
|
days_back = math.ceil(window.duration.total_seconds() / 86400) + 1
|
||||||
for offset in range(days_back + 1):
|
for offset in range(days_back + 1):
|
||||||
@@ -582,16 +586,18 @@ class HomeAppliance:
|
|||||||
if window.month is not None and anchor.month != window.month:
|
if window.month is not None and anchor.month != window.month:
|
||||||
continue
|
continue
|
||||||
opening, closing = window._window_start_end(anchor)
|
opening, closing = window._window_start_end(anchor)
|
||||||
if opening <= start and run_end <= closing:
|
opening_offset = (opening - start).total_seconds()
|
||||||
|
closing_offset = (closing - start).total_seconds()
|
||||||
|
if opening_offset <= 0 and run_seconds <= closing_offset:
|
||||||
return True
|
return True
|
||||||
if merge and opening < run_end and closing > start:
|
if merge and opening_offset < run_seconds and closing_offset > 0:
|
||||||
intervals.append((opening, closing))
|
intervals.append((opening_offset, closing_offset))
|
||||||
covered_until = start
|
covered_until = 0.0
|
||||||
for opening, closing in sorted(intervals):
|
for opening_offset, closing_offset in sorted(intervals):
|
||||||
if opening > covered_until:
|
if opening_offset > covered_until:
|
||||||
break
|
break
|
||||||
covered_until = max(covered_until, closing)
|
covered_until = max(covered_until, closing_offset)
|
||||||
if covered_until >= run_end:
|
if covered_until >= run_seconds:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Timezone contract under test:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -38,6 +39,10 @@ from akkudoktoreos.config.configabc import ( # noqa — ensure Time is importab
|
|||||||
ValueTimeWindow,
|
ValueTimeWindow,
|
||||||
ValueTimeWindowSequence,
|
ValueTimeWindowSequence,
|
||||||
)
|
)
|
||||||
|
from akkudoktoreos.devices.genetic.homeappliance import (
|
||||||
|
HomeAppliance,
|
||||||
|
HomeApplianceParameters,
|
||||||
|
)
|
||||||
from akkudoktoreos.utils.datetimeutil import Time, to_datetime, to_duration
|
from akkudoktoreos.utils.datetimeutil import Time, to_datetime, to_duration
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -571,6 +576,29 @@ class TestStartTimes:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.hour == 8
|
assert result.hour == 8
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("date", "duration", "expected"),
|
||||||
|
[
|
||||||
|
("2026-03-29", "P1D", "2026-03-29T00:00:00+01:00"),
|
||||||
|
("2026-03-29", "PT24H", None),
|
||||||
|
("2026-03-28", "P1D", "2026-03-28T00:00:00+01:00"),
|
||||||
|
("2026-03-28", "PT24H", "2026-03-28T00:00:00+01:00"),
|
||||||
|
("2026-10-25", "P1D", "2026-10-25T00:00:00+02:00"),
|
||||||
|
("2026-10-25", "PT24H", "2026-10-25T00:00:00+02:00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_earliest_calendar_day_across_dst(self, date, duration, expected):
|
||||||
|
"""A calendar day fits a local day even when it has fewer than 24 hours."""
|
||||||
|
window = make_tw(start_time="00:00", end_time="00:00")
|
||||||
|
reference = pendulum.from_format(date, "YYYY-MM-DD", tz="Europe/Berlin")
|
||||||
|
run_duration = to_duration(duration)
|
||||||
|
|
||||||
|
earliest = window.earliest_start_time(run_duration, reference)
|
||||||
|
|
||||||
|
assert (earliest.isoformat() if earliest is not None else None) == expected
|
||||||
|
assert window.can_fit_duration(run_duration, reference) is (expected is not None)
|
||||||
|
assert window.contains(reference, run_duration) is (expected is not None)
|
||||||
|
|
||||||
def test_latest_naive(self):
|
def test_latest_naive(self):
|
||||||
ref = naive_dt(2024, 6, 15)
|
ref = naive_dt(2024, 6, 15)
|
||||||
result = self.w.latest_start_time(pendulum.duration(hours=2), reference_date=ref)
|
result = self.w.latest_start_time(pendulum.duration(hours=2), reference_date=ref)
|
||||||
@@ -2276,3 +2304,224 @@ class TestCyclesToMatrix:
|
|||||||
expected,
|
expected,
|
||||||
err_msg=f"Row {k} (cycle {cycle_idx}) differs from cycle_to_array (misaligned)",
|
err_msg=f"Row {k} (cycle {cycle_idx}) differs from cycle_to_array (misaligned)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimeWindowBoundaries:
|
||||||
|
"""Explicit window ends, serialization, and cycle scheduling."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model", [TimeWindow, ValueTimeWindow])
|
||||||
|
def test_end_only_window_json_round_trip(self, 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(
|
||||||
|
self, 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(self, 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(self, 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(self):
|
||||||
|
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])
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimeWindowDST:
|
||||||
|
"""Window boundaries and grids distinguish the repeated DST hour."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def autumn_time(hour, minute=0, fold=1):
|
||||||
|
return pendulum.datetime(2026, 10, 25, hour, minute, tz="Europe/Berlin", fold=fold)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("duration", "expected"),
|
||||||
|
[
|
||||||
|
("PT1H45M", "2026-10-25T02:30:00+01:00"),
|
||||||
|
("PT2H45M", "2026-10-25T02:45:00+01:00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("fold", [0, 1])
|
||||||
|
def test_effective_end_uses_later_instant(self, duration, expected, fold):
|
||||||
|
window = TimeWindow.model_validate(
|
||||||
|
{"start_time": "01:00", "end_time": "02:30", "duration": duration}
|
||||||
|
)
|
||||||
|
|
||||||
|
_, end = window._window_start_end(self.autumn_time(2, fold=fold))
|
||||||
|
|
||||||
|
assert end.isoformat() == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("hour", "minute", "fold", "duration", "expected"),
|
||||||
|
[
|
||||||
|
(2, 45, 0, None, True),
|
||||||
|
(2, 30, 0, None, True),
|
||||||
|
(2, 30, 1, None, False),
|
||||||
|
(2, 45, 1, None, False),
|
||||||
|
(2, 45, 0, 45, True),
|
||||||
|
(2, 45, 0, 46, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_membership_uses_elapsed_time(self, hour, minute, fold, duration, expected):
|
||||||
|
window = TimeWindow.model_validate({"start_time": "01:00", "end_time": "02:30"})
|
||||||
|
run_duration = pendulum.duration(minutes=duration) if duration is not None else None
|
||||||
|
|
||||||
|
assert window.contains(self.autumn_time(hour, minute, fold), run_duration) is expected
|
||||||
|
|
||||||
|
def test_membership_rejects_first_fold_before_second_fold_start(self):
|
||||||
|
window = TimeWindow.model_validate({"start_time": "02:30", "end_time": "04:00"})
|
||||||
|
|
||||||
|
assert not window.contains(self.autumn_time(2, 45, fold=0))
|
||||||
|
assert window.contains(self.autumn_time(2, 45, fold=1))
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("minute", [0, 30])
|
||||||
|
@pytest.mark.parametrize("start_hour", [0, 2])
|
||||||
|
def test_cycle_grid_ends_in_second_fold(self, start_hour, minute):
|
||||||
|
sequence = CycleTimeWindowSequence.model_validate(
|
||||||
|
{"windows": [{"start_time": "00:00", "end_time": "06:00", "value": 0}]}
|
||||||
|
)
|
||||||
|
start = self.autumn_time(start_hour, fold=0)
|
||||||
|
end = self.autumn_time(2, minute, fold=1)
|
||||||
|
interval = pendulum.duration(minutes=30)
|
||||||
|
expected = np.ones(int((end - start).total_seconds() / interval.total_seconds()))
|
||||||
|
|
||||||
|
actual = sequence.cycle_to_array(0, start, end, interval)
|
||||||
|
indices, matrix = sequence.cycles_to_matrix(start, end, interval)
|
||||||
|
|
||||||
|
assert indices == [0]
|
||||||
|
np.testing.assert_array_equal(actual, expected)
|
||||||
|
np.testing.assert_array_equal(matrix, expected[np.newaxis, :])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sequence_type", [TimeWindowSequence, ValueTimeWindowSequence])
|
||||||
|
def test_array_and_series_keep_both_folds(self, sequence_type):
|
||||||
|
window = ValueTimeWindow.model_validate(
|
||||||
|
{"start_time": "00:00", "end_time": "06:00", "value": 1}
|
||||||
|
)
|
||||||
|
sequence = sequence_type(windows=[window])
|
||||||
|
start = self.autumn_time(0)
|
||||||
|
end = self.autumn_time(2, fold=1)
|
||||||
|
interval = pendulum.duration(hours=1)
|
||||||
|
|
||||||
|
np.testing.assert_array_equal(sequence.to_array(start, end, interval), np.ones(3))
|
||||||
|
series = sequence.to_series(start, end, interval)
|
||||||
|
np.testing.assert_array_equal(series.to_numpy(), np.ones(3))
|
||||||
|
assert [stamp.timestamp() for stamp in series.index] == [
|
||||||
|
start.add(hours=i).timestamp() for i in range(3)
|
||||||
|
]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pendulum
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import CycleTimeWindowSequence, ValueTimeWindow
|
from akkudoktoreos.config.configabc import CycleTimeWindowSequence, ValueTimeWindow
|
||||||
@@ -182,6 +183,83 @@ class TestExplicitCycleWindow:
|
|||||||
assert appliance.start_latest[0] == 24 - 3
|
assert appliance.start_latest[0] == 24 - 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestDSTAllowedStartSlots:
|
||||||
|
"""The optimizer must distinguish both occurrences of the autumn hour."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _appliance(window_field, windows, run_minutes=15):
|
||||||
|
if window_field == "time_windows":
|
||||||
|
windows = [{**window, "value": 0} for window in windows]
|
||||||
|
return HomeAppliance(
|
||||||
|
parameters=HomeApplianceParameters.model_validate(
|
||||||
|
{
|
||||||
|
"device_id": "dishwasher",
|
||||||
|
"load_profile_power_w": [1000],
|
||||||
|
"load_profile_interval_seconds": run_minutes * 60,
|
||||||
|
window_field: {"windows": windows},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
optimization_hours=25,
|
||||||
|
prediction_hours=100,
|
||||||
|
slot_duration_h=0.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("window_field", ["shared_time_windows", "time_windows"])
|
||||||
|
def test_first_fold_starts_before_second_fold_opening_are_rejected(self, window_field):
|
||||||
|
appliance = self._appliance(
|
||||||
|
window_field, [{"start_time": "02:30", "end_time": "04:00"}]
|
||||||
|
)
|
||||||
|
zero = pendulum.datetime(2026, 10, 25, tz="Europe/Berlin")
|
||||||
|
|
||||||
|
allowed = appliance.allowed_start_slots(
|
||||||
|
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=24, cycle_index=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# The second 02:30 is slot 14. The first 02:30/02:45 are slots 10/11
|
||||||
|
# and precede the configured opening despite their matching clock times.
|
||||||
|
assert allowed == list(range(14, 20))
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("window_field", ["shared_time_windows", "time_windows"])
|
||||||
|
def test_first_fold_starts_before_second_fold_closing_are_retained(self, window_field):
|
||||||
|
appliance = self._appliance(
|
||||||
|
window_field, [{"start_time": "01:00", "end_time": "02:30"}]
|
||||||
|
)
|
||||||
|
zero = pendulum.datetime(2026, 10, 25, tz="Europe/Berlin")
|
||||||
|
|
||||||
|
allowed = appliance.allowed_start_slots(
|
||||||
|
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=24, cycle_index=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Includes a run from the first 02:30 to the first 02:45 (slot 10),
|
||||||
|
# which finishes before the second 02:30 closing boundary.
|
||||||
|
assert allowed == list(range(4, 14))
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("first_duration", "expected"), [("1 hour", []), ("2 hours", [9])]
|
||||||
|
)
|
||||||
|
def test_cycle_window_union_distinguishes_touching_instants_from_clock_times(
|
||||||
|
self, first_duration, expected
|
||||||
|
):
|
||||||
|
appliance = self._appliance(
|
||||||
|
"time_windows",
|
||||||
|
[
|
||||||
|
{"start_time": "01:30", "duration": first_duration},
|
||||||
|
{"start_time": "02:30", "end_time": "03:30"},
|
||||||
|
],
|
||||||
|
run_minutes=90,
|
||||||
|
)
|
||||||
|
zero = pendulum.datetime(2026, 10, 25, tz="Europe/Berlin")
|
||||||
|
|
||||||
|
allowed = appliance.allowed_start_slots(
|
||||||
|
slot0_datetime=zero, earliest_slot=9, horizon_end_slot=15, cycle_index=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# The only candidate runs from the first 02:15 to the second 02:45.
|
||||||
|
# A one-hour first window ends at the first 02:30, leaving a real
|
||||||
|
# one-hour gap; a two-hour first window touches the second opening.
|
||||||
|
assert allowed == expected
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Allowed-start computation: mocked per-cycle windows (CycleTimeWindowSequence)
|
# Allowed-start computation: mocked per-cycle windows (CycleTimeWindowSequence)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
"""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])
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
"""Time window boundaries and grids must distinguish the repeated DST hour."""
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pendulum
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import (
|
|
||||||
CycleTimeWindowSequence,
|
|
||||||
TimeWindow,
|
|
||||||
TimeWindowSequence,
|
|
||||||
ValueTimeWindow,
|
|
||||||
ValueTimeWindowSequence,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def autumn_time(hour, minute=0, fold=1):
|
|
||||||
return pendulum.datetime(2026, 10, 25, hour, minute, tz="Europe/Berlin", fold=fold)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("duration", "expected"),
|
|
||||||
[
|
|
||||||
("PT1H45M", "2026-10-25T02:30:00+01:00"),
|
|
||||||
("PT2H45M", "2026-10-25T02:45:00+01:00"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize("fold", [0, 1])
|
|
||||||
def test_effective_end_uses_later_instant(duration, expected, fold):
|
|
||||||
window = TimeWindow.model_validate(
|
|
||||||
{"start_time": "01:00", "end_time": "02:30", "duration": duration}
|
|
||||||
)
|
|
||||||
|
|
||||||
_, end = window._window_start_end(autumn_time(2, fold=fold))
|
|
||||||
|
|
||||||
assert end.isoformat() == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("hour", "minute", "fold", "duration", "expected"),
|
|
||||||
[
|
|
||||||
(2, 45, 0, None, True),
|
|
||||||
(2, 30, 0, None, True),
|
|
||||||
(2, 30, 1, None, False),
|
|
||||||
(2, 45, 1, None, False),
|
|
||||||
(2, 45, 0, 45, True),
|
|
||||||
(2, 45, 0, 46, False),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_membership_uses_elapsed_time(hour, minute, fold, duration, expected):
|
|
||||||
window = TimeWindow.model_validate({"start_time": "01:00", "end_time": "02:30"})
|
|
||||||
run_duration = pendulum.duration(minutes=duration) if duration is not None else None
|
|
||||||
|
|
||||||
assert window.contains(autumn_time(hour, minute, fold), run_duration) is expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_membership_rejects_first_fold_before_second_fold_start():
|
|
||||||
window = TimeWindow.model_validate({"start_time": "02:30", "end_time": "04:00"})
|
|
||||||
|
|
||||||
assert not window.contains(autumn_time(2, 45, fold=0))
|
|
||||||
assert window.contains(autumn_time(2, 45, fold=1))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("minute", [0, 30])
|
|
||||||
@pytest.mark.parametrize("start_hour", [0, 2])
|
|
||||||
def test_cycle_grid_ends_in_second_fold(start_hour, minute):
|
|
||||||
sequence = CycleTimeWindowSequence.model_validate(
|
|
||||||
{"windows": [{"start_time": "00:00", "end_time": "06:00", "value": 0}]}
|
|
||||||
)
|
|
||||||
start = autumn_time(start_hour, fold=0)
|
|
||||||
end = autumn_time(2, minute, fold=1)
|
|
||||||
interval = pendulum.duration(minutes=30)
|
|
||||||
expected = np.ones(int((end - start).total_seconds() / interval.total_seconds()))
|
|
||||||
|
|
||||||
actual = sequence.cycle_to_array(0, start, end, interval)
|
|
||||||
indices, matrix = sequence.cycles_to_matrix(start, end, interval)
|
|
||||||
|
|
||||||
assert indices == [0]
|
|
||||||
np.testing.assert_array_equal(actual, expected)
|
|
||||||
np.testing.assert_array_equal(matrix, expected[np.newaxis, :])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sequence_type", [TimeWindowSequence, ValueTimeWindowSequence])
|
|
||||||
def test_array_and_series_keep_both_folds(sequence_type):
|
|
||||||
window = ValueTimeWindow.model_validate(
|
|
||||||
{"start_time": "00:00", "end_time": "06:00", "value": 1}
|
|
||||||
)
|
|
||||||
sequence = sequence_type(windows=[window])
|
|
||||||
start = autumn_time(0)
|
|
||||||
end = autumn_time(2, fold=1)
|
|
||||||
interval = pendulum.duration(hours=1)
|
|
||||||
|
|
||||||
np.testing.assert_array_equal(sequence.to_array(start, end, interval), np.ones(3))
|
|
||||||
series = sequence.to_series(start, end, interval)
|
|
||||||
np.testing.assert_array_equal(series.to_numpy(), np.ones(3))
|
|
||||||
assert [stamp.timestamp() for stamp in series.index] == [
|
|
||||||
start.add(hours=i).timestamp() for i in range(3)
|
|
||||||
]
|
|
||||||
Reference in New Issue
Block a user