Skip to content

Commit f0fe937

Browse files
fix(gateway,ohme): anchor a straddling New Year window correctly once now has itself rolled over
Copilot review on #5120: at 00:15 on 1 January, a published 23:30-01:30 window parsed its Dec 31 start with now.year (the new year), putting it a full year in the future rather than the previous Dec 31 it actually meant. Neither existing correction branch caught this - the start wasn't "far in the past" (it was wrongly far in the future), and the elif's end<start bump only made it worse, landing the window almost a year ahead and excluding now entirely. Identical bug, independently duplicated, in gateway.py's _refresh_ev_windows() and ohme.py's refresh_car_windows(). Replaced both call sites' bespoke heuristics with a single new utils.localize_yearless_window(): each of start/end is independently anchored to whichever of now.year-1/now.year/now.year+1 places it closest to now, which gets the New Year straddle right as a side effect (start and end are always only hours apart, well inside the ~6-month anchoring tolerance) without a separate correction branch - the old elif turned out to be dead code once anchoring is done this way, so it's gone too. Added a dedicated test_localize_yearless_window.py, new regression cases in test_gateway.py/test_ohme.py for the after-rollover scenario, and rewrote test_gateway.py's old "year_boundary" test, which had been exercising a 30-hours-old-but-same-year window that was never actually a year-boundary case under the old heuristic's own terms. All new cases verified to fail against the prior code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 47bba41 commit f0fe937

8 files changed

Lines changed: 172 additions & 49 deletions

File tree

‎.cspell/custom-dictionary-workspace.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,7 @@ xticks
719719
yaxis
720720
yaxistooltip
721721
yday
722+
yearless
722723
ylabel
723724
YOURSERIAL
724725
yuanzhi

‎apps/predbat/gateway.py‎

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import time
1616
import uuid
1717
import traceback
18-
from utils import calc_percent_limit, export_mode_of, export_target_of, export_power_of
18+
from utils import calc_percent_limit, export_mode_of, export_target_of, export_power_of, localize_yearless_window
1919
from const import EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE
2020
import pytz as _pytz
2121

@@ -433,36 +433,17 @@ def _refresh_ev_windows(self):
433433
with datetime strings in ``"%m-%d %H:%M:%S"`` format (produced by output.py).
434434
435435
Datetimes are localized using ``self.local_tz`` so comparisons respect the component
436-
timezone and DST transitions. Year boundaries are handled: if a parsed start is more
437-
than 23 hours in the past *and the window has already ended* (i.e. the plan was built on
438-
Dec 31 and contains Jan 1 windows), the year is bumped forward - a still-active long
439-
window is left alone even if its start is over 23 hours old (#269). Similarly, if end
440-
falls before start after localization the end year is incremented to handle windows that
441-
straddle midnight
442-
on New Year's Eve.
436+
timezone and DST transitions. Year boundaries are handled by
437+
``utils.localize_yearless_window()`` - see its docstring for how a window that straddles
438+
New Year (#269), or is simply read back after ``now`` has itself rolled over into a new
439+
year, is anchored correctly either way.
443440
"""
444441
planned = self.get_state_wrapper(f"binary_sensor.{self.prefix}_car_charging_slot", attribute="planned") or []
445442
now = datetime.datetime.now(self.local_tz)
446-
current_year = now.year
447443
windows = []
448444
for w in planned:
449445
try:
450-
start_naive = datetime.datetime.strptime(w["start"], "%m-%d %H:%M:%S").replace(year=current_year)
451-
end_naive = datetime.datetime.strptime(w["end"], "%m-%d %H:%M:%S").replace(year=current_year)
452-
start_dt = self.local_tz.localize(start_naive)
453-
end_dt = self.local_tz.localize(end_naive)
454-
# If start is far in the past the plan crossed a year boundary (Dec 31 → Jan 1).
455-
# Only treat it that way if the window has also finished - a still-active long
456-
# window (end still ahead of now) legitimately started that long ago and must not
457-
# be shifted a year forward, or _should_ev_charge_now() stops seeing it as current
458-
# and the charger is told to stop mid-window (#269).
459-
if start_dt < now - datetime.timedelta(hours=23) and end_dt < now:
460-
start_dt = start_dt.replace(year=start_dt.year + 1)
461-
end_dt = end_dt.replace(year=end_dt.year + 1)
462-
elif end_dt < start_dt:
463-
# end crossed into the new year but start did not (e.g. 23:30 → 00:30)
464-
end_dt = end_dt.replace(year=end_dt.year + 1)
465-
windows.append((start_dt, end_dt))
446+
windows.append(localize_yearless_window(w["start"], w["end"], "%m-%d %H:%M:%S", now, self.local_tz))
466447
except (KeyError, ValueError):
467448
continue
468449
self._ev_windows = windows

‎apps/predbat/ohme.py‎

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from const import TIME_FORMAT_HA
2929
from component_base import ComponentBase
3030
from predbat_metrics import record_api_call
31+
from utils import localize_yearless_window
3132

3233
GOOGLE_API_KEY = "AIzaSyC8ZeZngm33tpOXLpbXeKfwtyZ1WrkbdBY" # cspell:disable-line
3334
VERSION = "1.5.1"
@@ -335,21 +336,11 @@ def refresh_car_windows(self):
335336
windows = []
336337
for window in planned:
337338
try:
338-
start = self.local_tz.localize(datetime.datetime.strptime(window["start"], PLAN_TIME_FORMAT).replace(year=now.year))
339-
end = self.local_tz.localize(datetime.datetime.strptime(window["end"], PLAN_TIME_FORMAT).replace(year=now.year))
339+
# The plan carries no year - utils.localize_yearless_window() rebuilds it around
340+
# now, correctly handling a window that straddles New Year (#269) either direction.
341+
windows.append(localize_yearless_window(window["start"], window["end"], PLAN_TIME_FORMAT, now, self.local_tz))
340342
except (KeyError, TypeError, ValueError):
341343
continue
342-
# The plan carries no year, so rebuild it around now for windows that cross New Year.
343-
# A start more than 23 hours in the past is only a rollover artifact if the window has
344-
# also finished - a still-active long window (its end still ahead of now) legitimately
345-
# started that long ago and must not be shifted a year forward, or should_charge_now()
346-
# stops seeing it as current and the charger is told to stop mid-window (#269).
347-
if start < now - timedelta(hours=23) and end < now:
348-
start = start.replace(year=start.year + 1)
349-
end = end.replace(year=end.year + 1)
350-
elif end < start:
351-
end = end.replace(year=end.year + 1)
352-
windows.append((start, end))
353344
self.control_windows = windows
354345
return True
355346

‎apps/predbat/tests/test_gateway.py‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4400,25 +4400,28 @@ def test_should_charge_now_no_windows(self):
44004400
assert gw._should_ev_charge_now() is False
44014401

44024402
def test_refresh_ev_windows_year_boundary(self):
4403-
"""Windows whose parsed start would be >23 h in the past get their year bumped."""
4403+
"""A window genuinely dated the prior calendar year (Dec 31) is anchored to it, not now.year."""
44044404
import datetime as dt_mod
4405+
from unittest.mock import patch
44054406

44064407
gw = self._make_gateway()
4407-
now = dt_mod.datetime.now(gw.local_tz)
4408-
# Simulate a Jan 1 window parsed with current_year when now is Dec 31
4409-
# by injecting a planned entry whose start, parsed with the current year, is 30 h in the past
4410-
stale = now - dt_mod.timedelta(hours=30)
4411-
future_end = stale + dt_mod.timedelta(hours=2)
4412-
# Format as MM-DD HH:MM:SS — these will be parsed with current year and end up in the past
4413-
planned = [{"start": stale.strftime("%m-%d %H:%M:%S"), "end": future_end.strftime("%m-%d %H:%M:%S"), "kwh": 5.0, "average": 20.0, "cost": 1.0}]
4408+
# A plan built Dec 31 that ran a window into the small hours of Jan 1, read back just after
4409+
# midnight on Jan 1 - now.year already sees the new year, so the Dec 31 half of the window
4410+
# must anchor to the *previous* year, not now.year (Copilot review on #5120: naively using
4411+
# now.year for both ends previously put a Dec 31 start a year in the future here).
4412+
fixed_now = dt_mod.datetime(2027, 1, 1, 0, 15, 0, tzinfo=gw.local_tz)
4413+
planned = [{"start": "12-31 23:30:00", "end": "01-01 01:30:00", "kwh": 5.0, "average": 20.0, "cost": 1.0}]
44144414
gw.get_state_wrapper = lambda entity, attribute=None: planned if attribute == "planned" else "on"
44154415

4416-
gw._refresh_ev_windows()
4416+
with patch("gateway.datetime") as mock_dt:
4417+
mock_dt.datetime.now.return_value = fixed_now
4418+
gw._refresh_ev_windows()
44174419

44184420
assert len(gw._ev_windows) == 1
44194421
start_dt, end_dt = gw._ev_windows[0]
4420-
# After year bump, start should be in the future (next year)
4421-
assert start_dt > now
4422+
assert start_dt.year == 2026, f"Dec 31 start should anchor to the previous year, got {start_dt}"
4423+
assert end_dt.year == 2027, f"Jan 1 end should anchor to the current year, got {end_dt}"
4424+
assert start_dt <= fixed_now < end_dt, "now (00:15 Jan 1) should fall inside the window"
44224425

44234426
def test_refresh_ev_windows_long_active_not_shifted(self):
44244427
"""A still-active window whose start is over 23h old is not mistaken for a year rollover (#269)."""
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# -----------------------------------------------------------------------------
2+
# Predbat Home Battery System
3+
# Copyright Trefor Southwell 2026 - All Rights Reserved
4+
# This application maybe used for personal use only and not for commercial use
5+
# -----------------------------------------------------------------------------
6+
# fmt off
7+
# pylint: disable=consider-using-f-string
8+
# pylint: disable=line-too-long
9+
# pylint: disable=attribute-defined-outside-init
10+
11+
from datetime import datetime
12+
import pytz
13+
from utils import localize_yearless_window
14+
15+
TIME_FORMAT = "%m-%d %H:%M:%S"
16+
LONDON = pytz.timezone("Europe/London")
17+
18+
19+
def test_localize_yearless_window(my_predbat=None):
20+
"""
21+
Test utils.localize_yearless_window() - anchoring a "%m-%d %H:%M:%S" start/end pair (as
22+
output.py's car_charging_slot "planned" attribute publishes them, with no year) onto a real
23+
year close to `now`, including the New Year boundary case that motivated it (#269, and the
24+
Copilot review on #5120 that found the original gateway.py/ohme.py heuristic could anchor a
25+
still-current window a year in the future once `now` had itself rolled into January).
26+
"""
27+
failed = False
28+
print("**** Testing localize_yearless_window ****")
29+
30+
# Test 1: an ordinary same-day window, nowhere near a year boundary
31+
now = LONDON.localize(datetime(2026, 6, 15, 14, 0, 0))
32+
start, end = localize_yearless_window("06-15 13:00:00", "06-15 15:00:00", TIME_FORMAT, now, LONDON)
33+
if (start.year, end.year) != (2026, 2026) or not (start <= now < end):
34+
print(f"ERROR: Test 1 (ordinary window) failed - got start={start} end={end}")
35+
failed = True
36+
37+
# Test 2: a window straddling New Year's Eve into New Year's Day, read while it's still
38+
# underway on Dec 31 - both ends belong to the same current year
39+
now = LONDON.localize(datetime(2026, 12, 31, 23, 45, 0))
40+
start, end = localize_yearless_window("12-31 23:30:00", "01-01 01:30:00", TIME_FORMAT, now, LONDON)
41+
if start.year != 2026 or end.year != 2027 or not (start <= now < end):
42+
print(f"ERROR: Test 2 (NYE window, read before midnight) failed - got start={start} end={end}")
43+
failed = True
44+
45+
# Test 3: the same window, but read back once `now` has itself rolled over into January - the
46+
# Dec 31 half must anchor to the *previous* year, not now.year, or the window is pushed a full
47+
# year into the future and stops covering `now` at all (Copilot review on #5120)
48+
now = LONDON.localize(datetime(2027, 1, 1, 0, 15, 0))
49+
start, end = localize_yearless_window("12-31 23:30:00", "01-01 01:30:00", TIME_FORMAT, now, LONDON)
50+
if start.year != 2026 or end.year != 2027 or not (start <= now < end):
51+
print(f"ERROR: Test 3 (NYE window, read after midnight rollover) failed - got start={start} end={end}")
52+
failed = True
53+
54+
# Test 4: a still-active long window whose start is genuinely a couple of days old, nowhere
55+
# near a year boundary - must not be shifted a year in either direction (#269)
56+
now = LONDON.localize(datetime(2026, 6, 16, 23, 5, 0))
57+
start, end = localize_yearless_window("06-14 00:10:00", "06-17 02:00:00", TIME_FORMAT, now, LONDON)
58+
if start.year != 2026 or end.year != 2026 or not (start <= now < end):
59+
print(f"ERROR: Test 4 (long active window) failed - got start={start} end={end}")
60+
failed = True
61+
62+
# Test 5: a midnight-crossing window with no year-boundary involvement at all (e.g. 23:00-00:30
63+
# in mid-June) - end must land on the following calendar day, same year, without needing the
64+
# New Year handling to fire
65+
now = LONDON.localize(datetime(2026, 6, 15, 23, 30, 0))
66+
start, end = localize_yearless_window("06-15 23:00:00", "06-16 00:30:00", TIME_FORMAT, now, LONDON)
67+
if (start.year, end.year) != (2026, 2026) or end.day != 16 or not (start <= now < end):
68+
print(f"ERROR: Test 5 (ordinary midnight-crossing window) failed - got start={start} end={end}")
69+
failed = True
70+
71+
# Test 6: an unparseable time string raises ValueError, same as the underlying strptime - the
72+
# gateway.py/ohme.py callers rely on this to skip a malformed planned entry rather than crash
73+
try:
74+
localize_yearless_window("not-a-date", "06-15 15:00:00", TIME_FORMAT, now, LONDON)
75+
print("ERROR: Test 6 (unparseable string) should have raised ValueError")
76+
failed = True
77+
except ValueError:
78+
pass
79+
80+
if not failed:
81+
print("**** All localize_yearless_window tests PASSED ****")
82+
else:
83+
print("**** Some localize_yearless_window tests FAILED ****")
84+
85+
return failed

‎apps/predbat/tests/test_ohme.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ def test_ohme(my_predbat=None):
283283
("control_enable", _test_ohme_control_enable_rules, "ohme_control enable rules"),
284284
("control_windows", _test_ohme_control_window_parsing, "control window parsing"),
285285
("control_midnight", _test_ohme_control_window_year_rollover, "control windows across new year"),
286+
("control_midnight_after", _test_ohme_control_window_year_rollover_after_midnight, "control window across new year, read back after midnight has rolled over"),
286287
("control_long_active", _test_ohme_control_window_long_active_not_shifted, "long active window not mistaken for a rollover"),
287288
("control_startup", _test_ohme_control_waits_for_plan, "control waits for a published plan"),
288289
("control_edges", _test_ohme_control_edge_triggered, "control only acts on transitions"),
@@ -1758,6 +1759,29 @@ def _test_ohme_control_window_year_rollover(my_predbat=None):
17581759
return 0
17591760

17601761

1762+
def _test_ohme_control_window_year_rollover_after_midnight(my_predbat=None):
1763+
"""A Dec 31 -> Jan 1 window read back once now has itself rolled into January (Copilot review on #5120)"""
1764+
print("**** Running test_ohme_control_window_year_rollover_after_midnight ****")
1765+
1766+
tz = pytz.timezone("Europe/London")
1767+
# Same window as the new year's eve test above, but now is read a little after midnight, once
1768+
# the clock has already ticked into January. Naively anchoring both start and end to now.year
1769+
# previously put the Dec 31 start a full year in the future (next Dec 31) rather than the
1770+
# actual previous one, so should_charge_now() stopped seeing the still-active window at all.
1771+
now = tz.localize(datetime.datetime(2027, 1, 1, 0, 15, 0))
1772+
window = {"start": "12-31 23:30:00", "end": "01-01 01:30:00", "kwh": 7.0}
1773+
api = _ohme_control_api(windows=[window], now=now)
1774+
1775+
api.refresh_car_windows()
1776+
start, end = api.control_windows[0]
1777+
assert start.year == 2026, f"Dec 31 start should anchor to the previous year, got {start}"
1778+
assert end.year == 2027, f"Jan 1 end should anchor to the current year, got {end}"
1779+
assert api.should_charge_now() is True, "Expected to still be charging at 00:15 on new year's day"
1780+
1781+
print("PASS: new year window handled after midnight rollover")
1782+
return 0
1783+
1784+
17611785
def _test_ohme_control_window_long_active_not_shifted(my_predbat=None):
17621786
"""Test a still-active window whose start is over 23 hours old is not mistaken for a New Year rollover (#269)"""
17631787
print("**** Running test_ohme_control_window_long_active_not_shifted ****")

‎apps/predbat/unit_test.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from tests.test_fetch_config_options import test_fetch_config_options
4747
from tests.test_multi_inverter import run_inverter_multi_tests
4848
from tests.test_window2minutes import test_window2minutes
49+
from tests.test_localize_yearless_window import test_localize_yearless_window
4950
from tests.test_hass_watcher import test_hass_watcher
5051
from tests.test_new_install_detection import test_new_install_detection
5152
from tests.test_history_attribute import test_history_attribute
@@ -446,6 +447,7 @@ def main():
446447
("rate_export_max_forward_calc", test_rate_export_max_forward_calc, "Rate export max forward calc tests", False),
447448
("window_sort", run_window_sort_tests, "Window sort tests", False),
448449
("window2minutes", test_window2minutes, "Window to minutes tests", False),
450+
("localize_yearless_window", test_localize_yearless_window, "Year-anchoring for %m-%d planned window strings, including New Year boundary", False),
449451
("hass_watcher", test_hass_watcher, "Standalone-mode file watcher tests (#4397/#4396)", False),
450452
("new_install_detection", test_new_install_detection, "New-install misdetection tests (Bug B, #4397/#4396, #3259, #3306)", False),
451453
("compute_metric", run_compute_metric_tests, "Compute metric tests", False),

‎apps/predbat/utils.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1611,6 +1611,42 @@ def window2minutes(start, end, minutes_now):
16111611
return compute_window_minutes(start, end, minutes_now)
16121612

16131613

1614+
def localize_yearless_window(start_str, end_str, time_format, now, local_tz):
1615+
"""
1616+
Parse a "%m-%d %H:%M:%S"-style start/end pair (no year, as output.py's car_charging_slot
1617+
"planned" attribute publishes them) into a year-anchored, tz-aware (start, end) pair close to
1618+
`now`, correctly handling a window that straddles New Year.
1619+
1620+
Naively assuming `now.year` for both breaks in two ways right around New Year: a still-active
1621+
long window whose start is genuinely >23h in the past (e.g. it began on Dec 31 and now is Jan 1)
1622+
must not be shifted a year forward just because it's old, or callers stop seeing it as current
1623+
mid-window (#269); and a window recorded around Dec 31 but read back once `now` has itself
1624+
rolled over to the following January would otherwise anchor its start a full year in the future
1625+
rather than the previous Dec 31 it actually meant, throwing the elapsed-time comparison off
1626+
entirely rather than merely off by a year in the expected direction.
1627+
1628+
Handles both directions the same way instead of a directional heuristic: each of start/end is
1629+
independently anchored to whichever of now.year-1/now.year/now.year+1 places it closest to
1630+
`now`, so a >6-month gap in either direction is corrected before start/end are ever compared to
1631+
each other or to `now`. This also gets a window that straddles New Year right without any
1632+
further correction: anchoring start (Dec 31) and end (Jan 1) independently to whichever side of
1633+
the boundary is closest to `now` always leaves end on the later calendar date of the pair,
1634+
since the two are only ever a few hours apart while the anchoring tolerance is ~6 months.
1635+
1636+
Raises ValueError via strptime if either string doesn't match `time_format` - callers already
1637+
wrap this in a try/except for that (KeyError/TypeError/ValueError), as they did before this was
1638+
extracted from gateway.py/ohme.py duplicating the same logic.
1639+
"""
1640+
1641+
def closest_year(naive):
1642+
candidates = (naive.replace(year=now.year + delta) for delta in (-1, 0, 1))
1643+
return local_tz.localize(min(candidates, key=lambda dt: abs(local_tz.localize(dt) - now)))
1644+
1645+
start_dt = closest_year(datetime.strptime(start_str, time_format))
1646+
end_dt = closest_year(datetime.strptime(end_str, time_format))
1647+
return start_dt, end_dt
1648+
1649+
16141650
def minutes_since_midnight(now, midnight):
16151651
"""
16161652
Compute minutes from midnight to now, floored to a PREDICT_STEP boundary

0 commit comments

Comments
 (0)