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:
Normann
2026-10-28 18:59:43 +01:00
committed by Bobby Noelte
parent 7ca66c7dee
commit 63e7e7e729
6 changed files with 345 additions and 262 deletions
+2 -1
View File
@@ -431,7 +431,8 @@ class TimeWindow(SettingsBaseModel):
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 window_start
@@ -565,12 +565,16 @@ class HomeAppliance:
) -> bool:
"""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
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_seconds = (run_end - start).total_seconds()
for window in windows:
days_back = math.ceil(window.duration.total_seconds() / 86400) + 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:
continue
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
if merge and opening < run_end and closing > start:
intervals.append((opening, closing))
covered_until = start
for opening, closing in sorted(intervals):
if opening > covered_until:
if merge and opening_offset < run_seconds and closing_offset > 0:
intervals.append((opening_offset, closing_offset))
covered_until = 0.0
for opening_offset, closing_offset in sorted(intervals):
if opening_offset > covered_until:
break
covered_until = max(covered_until, closing)
if covered_until >= run_end:
covered_until = max(covered_until, closing_offset)
if covered_until >= run_seconds:
return True
return False