From 4cf03389edeb07b1e51ef430fa993b610a23d8d4 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 26 Sep 2026 21:47:31 +0100 Subject: [PATCH 1/2] fix(car): stop the car charge start time walking forward (#269) plan_car_charging() clamps an in-progress window's start to minutes_now so the kWh and cost maths only count time the car can still charge for. That clamped value was also published as predbat.car_charging_start and in the car_charging_slot sensor's planned attribute, so the displayed start advanced by five minutes every cycle while the car charged. plan_car_charging() now records the window's real start as start_orig and publish_car_plan() shows that; all planning arithmetic still uses the clamped start. Octopus Intelligent slots carry no start_orig and fall back unchanged. The published start can now be hours, or for a long combined window more than a day, in the past. Ohme and the gateway EVC had their own year repair that treated any start over 23 hours old as a New Year rollover and pushed it a year forward, stopping the charger mid-window. Both now use utils.parse_car_plan_windows() / in_car_plan_window(), which myenergi and GivEnergy EVC already share: it rebuilds the missing year symmetrically around now with a 180-day margin, so a New Year window is anchored correctly whichever side of midnight it is read. It now also parses with the year in the string - strptime without one assumes 1900, which has no 29 February, so every charger dropped a leap-day window - and keeps the year shifts inside the try, as a stale 29 February entry has no counterpart in the neighbouring year. Tests: car_charging_start_stable_issue269 steps through one window and asserts the published start holds (restoring the dashboard state it publishes); car_plan_windows covers the helper directly, including New Year both ways, a January window read on 31 December, leap day and malformed entries; Ohme and gateway cover the same cases through the chargers on pinned dates. Fixes #269 Co-Authored-By: Claude Opus 5.5 --- apps/predbat/gateway.py | 36 +----- apps/predbat/ohme.py | 26 +---- apps/predbat/output.py | 11 +- apps/predbat/plan.py | 5 + apps/predbat/tests/test_car_charging_smart.py | 105 ++++++++++++++++++ apps/predbat/tests/test_car_plan_windows.py | 72 ++++++++++++ apps/predbat/tests/test_gateway.py | 51 +++++++-- apps/predbat/tests/test_ohme.py | 47 ++++++++ apps/predbat/unit_test.py | 2 + apps/predbat/utils.py | 31 +++--- 10 files changed, 307 insertions(+), 79 deletions(-) create mode 100644 apps/predbat/tests/test_car_plan_windows.py diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index f20395b3f..6d9bd71c1 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -16,7 +16,7 @@ import time import uuid import traceback -from utils import calc_percent_limit, export_mode_of, export_target_of, export_power_of +from utils import calc_percent_limit, export_mode_of, export_target_of, export_power_of, parse_car_plan_windows, in_car_plan_window from const import EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, MINUTE_WATT import pytz as _pytz @@ -505,41 +505,17 @@ def _refresh_ev_windows(self): with datetime strings in ``"%m-%d %H:%M:%S"`` format (produced by output.py). Datetimes are localized using ``self.local_tz`` so comparisons respect the component - timezone and DST transitions. Year boundaries are handled: if a parsed start is more - than 23 hours in the past (i.e. the plan was built on Dec 31 and contains Jan 1 - windows), the year is bumped forward. Similarly, if end falls before start after - localization the end year is incremented to handle windows that straddle midnight - on New Year's Eve. + timezone and DST transitions. The plan carries no year; ``utils.parse_car_plan_windows()`` + (shared with myenergi and GivEnergy EVC) rebuilds it around now, so a window that straddles + New Year is anchored correctly whichever side of midnight it is read (#269). """ planned = self.get_state_wrapper(f"binary_sensor.{self.prefix}_car_charging_slot", attribute="planned") or [] now = datetime.datetime.now(self.local_tz) - current_year = now.year - windows = [] - for w in planned: - try: - start_naive = datetime.datetime.strptime(w["start"], "%m-%d %H:%M:%S").replace(year=current_year) - end_naive = datetime.datetime.strptime(w["end"], "%m-%d %H:%M:%S").replace(year=current_year) - start_dt = self.local_tz.localize(start_naive) - end_dt = self.local_tz.localize(end_naive) - # If start is far in the past the plan crossed a year boundary (Dec 31 → Jan 1) - if start_dt < now - datetime.timedelta(hours=23): - start_dt = start_dt.replace(year=start_dt.year + 1) - end_dt = end_dt.replace(year=end_dt.year + 1) - elif end_dt < start_dt: - # end crossed into the new year but start did not (e.g. 23:30 → 00:30) - end_dt = end_dt.replace(year=end_dt.year + 1) - windows.append((start_dt, end_dt)) - except (KeyError, ValueError): - continue - self._ev_windows = windows + self._ev_windows = parse_car_plan_windows(planned, now, self.local_tz) def _should_ev_charge_now(self): """Return True if the current local time falls inside any planned charge window.""" - now = datetime.datetime.now(self.local_tz) - for start_dt, end_dt in self._ev_windows: - if start_dt <= now < end_dt: - return True - return False + return in_car_plan_window(self._ev_windows, datetime.datetime.now(self.local_tz)) async def _apply_ev_charging_state(self): """Start or stop EVC charging when the window state transitions. diff --git a/apps/predbat/ohme.py b/apps/predbat/ohme.py index 782688d36..2070cdb99 100644 --- a/apps/predbat/ohme.py +++ b/apps/predbat/ohme.py @@ -28,6 +28,7 @@ from const import TIME_FORMAT_HA, DISPATCH_SOURCE_CHARGER_SCHEDULE from component_base import ComponentBase from predbat_metrics import record_api_call +from utils import parse_car_plan_windows, in_car_plan_window GOOGLE_API_KEY = "AIzaSyC8ZeZngm33tpOXLpbXeKfwtyZ1WrkbdBY" # cspell:disable-line VERSION = "1.5.1" @@ -95,9 +96,6 @@ "octopus_charge_limit": {"entity_id": "number.predbat_ohme_target_percent", "domain": "number", "access": "rw"}, } -# Format Predbat writes its planned car charging windows in - see PredBat.time_abs_str() -PLAN_TIME_FORMAT = "%m-%d %H:%M:%S" - # Longest gap between power readings we will still integrate over. The charge session is polled # every 120 seconds, so a longer gap means Predbat stalled or was restarted and we have no evidence # of what the charger did meanwhile - under-counting is safe, inventing energy is not @@ -391,30 +389,16 @@ def refresh_car_windows(self): if planned is None: return False - now = self.now_utc_exact - windows = [] - for window in planned: - try: - start = self.local_tz.localize(datetime.datetime.strptime(window["start"], PLAN_TIME_FORMAT).replace(year=now.year)) - end = self.local_tz.localize(datetime.datetime.strptime(window["end"], PLAN_TIME_FORMAT).replace(year=now.year)) - except (KeyError, TypeError, ValueError): - continue - # The plan carries no year, so rebuild it around now for windows that cross New Year - if start < now - timedelta(hours=23): - start = start.replace(year=start.year + 1) - end = end.replace(year=end.year + 1) - elif end < start: - end = end.replace(year=end.year + 1) - windows.append((start, end)) - self.control_windows = windows + # The plan carries no year - parse_car_plan_windows() (shared with myenergi and GivEnergy + # EVC) rebuilds it around now, whichever side of New Year it is read (#269) + self.control_windows = parse_car_plan_windows(planned, self.now_utc_exact, self.local_tz) return True def should_charge_now(self): """ Is now inside one of Predbat's planned charging windows. """ - now = self.now_utc_exact - return any(start <= now < end for start, end in self.control_windows) + return in_car_plan_window(self.control_windows, self.now_utc_exact) def control_drifted(self, should_charge): """ diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 581000e08..3007c35d7 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -163,10 +163,15 @@ def publish_car_plan(self): else: slot = False + # Show when the window really began, not the planner's clamped start, so the + # displayed start time stops walking forward once charging is underway (#269). + # Octopus slots carry no start_orig, so they keep their own start unchanged. + window_start = window.get("start_orig", window["start"]) + time_format_time = "%H:%M:%S" - car_startt = self.midnight_utc + timedelta(minutes=window["start"]) + car_startt = self.midnight_utc + timedelta(minutes=window_start) car_start_time_str = car_startt.strftime(time_format_time) - minutes_to = max(window["start"] - self.minutes_now, 0) + minutes_to = max(window_start - self.minutes_now, 0) self.dashboard_item( self.prefix + ".car_charging_start" + postfix, state=car_start_time_str, @@ -184,7 +189,7 @@ def publish_car_plan(self): total_kwh = 0 total_cost = 0 for window in self.car_charging_slots[car_n]: - start = self.time_abs_str(window["start"]) + start = self.time_abs_str(window.get("start_orig", window["start"])) end = self.time_abs_str(window["end"]) kwh = dp2(window["kwh"]) average = dp2(window["average"]) diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 2a1e25a38..bde0d303b 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -5978,6 +5978,11 @@ def plan_car_charging(self, car_n, low_rates): new_slot = {} new_slot["start"] = start new_slot["end"] = end + # start is clamped to minutes_now above so the kWh maths only counts time the car + # can still charge for, but that makes an in-progress window's start walk forward + # by plan_interval_minutes every cycle. Keep the window's real start for display + # and for anything driving a charger off the published plan (issue #269). + new_slot["start_orig"] = window["start"] new_slot["kwh"] = dp3(kwh) new_slot["average"] = window["average"] new_slot["cost"] = dp2(new_slot["average"] * kwh) diff --git a/apps/predbat/tests/test_car_charging_smart.py b/apps/predbat/tests/test_car_charging_smart.py index f49ac08cd..71310c6c1 100644 --- a/apps/predbat/tests/test_car_charging_smart.py +++ b/apps/predbat/tests/test_car_charging_smart.py @@ -7,6 +7,7 @@ # pylint: disable=consider-using-f-string # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init +import copy from tests.test_infra import reset_rates2, reset_inverter from config import CONFIG_ITEMS @@ -157,4 +158,108 @@ def run_car_charging_smart_tests(my_predbat): failed |= run_car_charging_slot_integer_test("smart_int2_issue3911", my_predbat, battery_size=11.9, soc=0.0, limit=11.9, rate=7.4, loss=1.0, plan_time="07:00:00") failed |= run_car_charging_slot_integer_test("smart_int3_issue3911", my_predbat, battery_size=77.0, soc=74.3, limit=77.0, rate=11.0, loss=0.9, plan_time="07:00:00") + failed |= run_car_charging_start_stable_test(my_predbat) + + return failed + + +def run_car_charging_start_stable_test(my_predbat): + """ + The published car charge start time must not walk forward once a slot is underway. + + Regression test for issue #269: plan_car_charging() clamps an in-progress window's start + to minutes_now so the kWh maths only counts time the car can still charge for. That + clamped value was also published as predbat.car_charging_start, so the displayed start + time advanced by plan_interval_minutes on every cycle while the car was charging. + """ + failed = False + print("**** Running Test: car_charging_start_stable_issue269 ****") + + saved_battery_size = my_predbat.car_charging_battery_size + saved_limit = my_predbat.car_charging_limit + saved_soc = my_predbat.car_charging_soc + saved_soc_next = my_predbat.car_charging_soc_next + saved_rate = my_predbat.car_charging_rate + saved_loss = my_predbat.car_charging_loss + saved_max_price = my_predbat.car_charging_plan_max_price + saved_smart = my_predbat.car_charging_plan_smart + saved_plan_time = my_predbat.car_charging_plan_time + saved_num_cars = my_predbat.num_cars + saved_minutes_now = my_predbat.minutes_now + saved_slots = my_predbat.car_charging_slots + + # publish_car_plan() writes the car sensors into the shared dashboard state + saved_dashboard = copy.deepcopy(my_predbat.dashboard_values) + + try: + my_predbat.car_charging_battery_size = [100.0] + my_predbat.car_charging_limit = [100.0] + my_predbat.car_charging_soc = [0] + my_predbat.car_charging_soc_next = [None] + my_predbat.car_charging_rate = [1.0] + my_predbat.car_charging_loss = 1.0 + my_predbat.car_charging_plan_max_price = [99] + my_predbat.car_charging_plan_smart = [False] + my_predbat.car_charging_plan_time = ["23:00:00"] + my_predbat.num_cars = 1 + my_predbat.car_charging_slots = [[]] + + # Plan once, then replan from part way through the first window as the real 5 minute + # cycle would, and check the published start time did not move. + start_times = [] + plan_starts = [] + first_window_start = None + for step in range(3): + if step == 0: + my_predbat.minutes_now = saved_minutes_now + else: + # Step forward a cycle at a time while staying inside the first planned window, + # which is what a car charging through the window actually sees + my_predbat.minutes_now = first_window_start + step * 5 + + my_predbat.car_charging_slots[0] = my_predbat.plan_car_charging(0, my_predbat.low_rates) + if not my_predbat.car_charging_slots[0]: + print("ERROR: no car charging slots planned at step {}".format(step)) + failed = True + break + if first_window_start is None: + first_window_start = my_predbat.car_charging_slots[0][0]["start"] + first_window_end = my_predbat.car_charging_slots[0][0]["end"] + # The drift only shows while one window stays current, so fail loudly rather than + # silently passing if the fixture's windows ever get shorter than the steps below + if first_window_end - first_window_start < 3 * 5: + print("ERROR: test needs a first window at least 15 minutes long, got {}-{}".format(first_window_start, first_window_end)) + failed = True + break + + my_predbat.publish_car_plan() + start_times.append(my_predbat.dashboard_values["predbat.car_charging_start"]["state"]) + plan_starts.append(my_predbat.dashboard_values["binary_sensor.predbat_car_charging_slot"]["attributes"]["planned"][0]["start"]) + + if not failed: + if len(set(start_times)) != 1: + print("ERROR: car_charging_start moved while the slot was underway: {} (issue #269)".format(start_times)) + failed = True + else: + print("OK: car_charging_start held at {} across the slot (issue #269)".format(start_times[0])) + if len(set(plan_starts)) != 1: + print("ERROR: planned window start moved while the slot was underway: {} (issue #269)".format(plan_starts)) + failed = True + finally: + my_predbat.car_charging_battery_size = saved_battery_size + my_predbat.car_charging_limit = saved_limit + my_predbat.car_charging_soc = saved_soc + my_predbat.car_charging_soc_next = saved_soc_next + my_predbat.car_charging_rate = saved_rate + my_predbat.car_charging_loss = saved_loss + my_predbat.car_charging_plan_max_price = saved_max_price + my_predbat.car_charging_plan_smart = saved_smart + my_predbat.car_charging_plan_time = saved_plan_time + my_predbat.num_cars = saved_num_cars + my_predbat.minutes_now = saved_minutes_now + my_predbat.car_charging_slots = saved_slots + + my_predbat.dashboard_values.clear() + my_predbat.dashboard_values.update(saved_dashboard) + return failed diff --git a/apps/predbat/tests/test_car_plan_windows.py b/apps/predbat/tests/test_car_plan_windows.py new file mode 100644 index 000000000..388413b80 --- /dev/null +++ b/apps/predbat/tests/test_car_plan_windows.py @@ -0,0 +1,72 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +from datetime import datetime +import pytz +from utils import parse_car_plan_windows, in_car_plan_window + +LONDON = pytz.timezone("Europe/London") + + +def _window(start, end): + """A planned-attribute entry as output.py publishes it.""" + return {"start": start, "end": end, "kwh": 5.0, "average": 20.0, "cost": 1.0} + + +def test_car_plan_windows(my_predbat=None): + """ + utils.parse_car_plan_windows() rebuilds the year on the car charging plan (published as + "%m-%d %H:%M:%S") around now. Shared by every charger that follows the plan - myenergi, + GivEnergy EVC, Ohme and the gateway EVC (#269, #5120 review). + """ + failed = False + print("**** Testing parse_car_plan_windows ****") + + cases = [ + # (name, now, start, end, expected start year, expected end year, now inside) + ("ordinary window", datetime(2026, 6, 15, 14, 0), "06-15 13:00:00", "06-15 15:00:00", 2026, 2026, True), + ("New Year window read before midnight", datetime(2026, 12, 31, 23, 45), "12-31 23:30:00", "01-01 01:30:00", 2026, 2027, True), + # Once now itself is in January the Dec 31 start belongs to the previous year, or the window + # lands a year in the future and a charging car is stopped mid-window + ("New Year window read after midnight", datetime(2027, 1, 1, 0, 15), "12-31 23:30:00", "01-01 01:30:00", 2026, 2027, True), + # A window wholly in January, read on 31 December, parses into January of the year ending and + # has to move forward a year + ("January window read on 31 December", datetime(2026, 12, 31, 23, 30), "01-01 00:30:00", "01-01 02:00:00", 2027, 2027, False), + # A long window whose start is days old is still the current one, not a year rollover (#269) + ("long active window", datetime(2026, 6, 16, 23, 5), "06-14 00:10:00", "06-17 02:00:00", 2026, 2026, True), + ("midnight-crossing window", datetime(2026, 6, 15, 23, 30), "06-15 23:00:00", "06-16 00:30:00", 2026, 2026, True), + # strptime with no year assumes 1900, which has no 29 February - the window used to be dropped + ("leap day window", datetime(2028, 2, 29, 1, 0), "02-29 00:30:00", "02-29 05:00:00", 2028, 2028, True), + ("window starting on leap day, read on 1 March", datetime(2028, 3, 1, 0, 30), "02-29 23:30:00", "03-01 02:00:00", 2028, 2028, True), + ] + for name, now_naive, start, end, start_year, end_year, inside in cases: + now = LONDON.localize(now_naive) + windows = parse_car_plan_windows([_window(start, end)], now, LONDON) + if len(windows) != 1: + print("ERROR: {}: expected one window, got {}".format(name, windows)) + failed = True + continue + start_dt, end_dt = windows[0] + if (start_dt.year, end_dt.year) != (start_year, end_year) or in_car_plan_window(windows, now) != inside: + print("ERROR: {}: got start={} end={} (now {}, inside {})".format(name, start_dt, end_dt, now, in_car_plan_window(windows, now))) + failed = True + + # A malformed entry, or a stale 29 February one that has no counterpart in the neighbouring year, + # is skipped without costing the rest of the plan + now = LONDON.localize(datetime(2028, 9, 1, 12, 0)) + planned = [_window("not-a-date", "09-01 13:00:00"), {"start": None, "end": "09-01 13:00:00"}, {"end": "09-01 13:00:00"}, _window("02-29 01:00:00", "02-29 02:00:00"), _window("09-01 11:00:00", "09-01 13:00:00")] + windows = parse_car_plan_windows(planned, now, LONDON) + if len(windows) != 1 or not in_car_plan_window(windows, now): + print("ERROR: malformed entries should be skipped and the valid window kept, got {}".format(windows)) + failed = True + + if not failed: + print("**** All parse_car_plan_windows tests PASSED ****") + return failed diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index b3a5f2ab4..e207c81a7 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -5009,25 +5009,54 @@ def test_should_charge_now_no_windows(self): assert gw._should_ev_charge_now() is False def test_refresh_ev_windows_year_boundary(self): - """Windows whose parsed start would be >23 h in the past get their year bumped.""" + """A window genuinely dated the prior calendar year (Dec 31) is anchored to it, not now.year.""" import datetime as dt_mod + from unittest.mock import patch gw = self._make_gateway() - now = dt_mod.datetime.now(gw.local_tz) - # Simulate a Jan 1 window parsed with current_year when now is Dec 31 - # by injecting a planned entry whose start, parsed with the current year, is 30 h in the past - stale = now - dt_mod.timedelta(hours=30) - future_end = stale + dt_mod.timedelta(hours=2) - # Format as MM-DD HH:MM:SS — these will be parsed with current year and end up in the past - 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}] + # A plan built Dec 31 that ran a window into the small hours of Jan 1, read back just after + # midnight on Jan 1 - now.year already sees the new year, so the Dec 31 half of the window + # must anchor to the *previous* year, not now.year (Copilot review on #5120: naively using + # now.year for both ends previously put a Dec 31 start a year in the future here). + fixed_now = gw.local_tz.localize(dt_mod.datetime(2027, 1, 1, 0, 15, 0)) + planned = [{"start": "12-31 23:30:00", "end": "01-01 01:30:00", "kwh": 5.0, "average": 20.0, "cost": 1.0}] gw.get_state_wrapper = lambda entity, attribute=None: planned if attribute == "planned" else "on" - gw._refresh_ev_windows() + with patch("gateway.datetime") as mock_dt: + mock_dt.datetime.now.return_value = fixed_now + gw._refresh_ev_windows() + + assert len(gw._ev_windows) == 1 + start_dt, end_dt = gw._ev_windows[0] + assert start_dt.year == 2026, f"Dec 31 start should anchor to the previous year, got {start_dt}" + assert end_dt.year == 2027, f"Jan 1 end should anchor to the current year, got {end_dt}" + assert start_dt <= fixed_now < end_dt, "now (00:15 Jan 1) should fall inside the window" + + def test_refresh_ev_windows_long_active_not_shifted(self): + """A still-active window whose start is over 23h old is not mistaken for a year rollover (#269).""" + import datetime as dt_mod + from unittest.mock import patch + + gw = self._make_gateway() + # Pinned rather than the real clock, which made this fail from 1 to 2 January (the start fell + # in the previous year) and around 29 February + now = gw.local_tz.localize(dt_mod.datetime(2026, 6, 16, 23, 5, 0)) + # A long/flat-rate window that started well over 23h ago but has not finished yet - its + # end is still ahead of now, so this is a genuinely active window, not a stale one left + # over from a plan built before a year boundary. + planned = [{"start": "06-15 17:05:00", "end": "06-17 01:05:00", "kwh": 5.0, "average": 20.0, "cost": 1.0}] + gw.get_state_wrapper = lambda entity, attribute=None: planned if attribute == "planned" else "on" + + with patch("gateway.datetime") as mock_dt: + mock_dt.datetime.now.return_value = now + gw._refresh_ev_windows() + charging = gw._should_ev_charge_now() assert len(gw._ev_windows) == 1 start_dt, end_dt = gw._ev_windows[0] - # After year bump, start should be in the future (next year) - assert start_dt > now + # Must not be bumped a year forward, or the still-active window stops matching "now" + assert start_dt.year == 2026 and end_dt.year == 2026 + assert charging is True def test_apply_sends_start_on_transition(self): """_apply_ev_charging_state sends SetChargingProfile then RemoteStartTransaction when entering a window.""" diff --git a/apps/predbat/tests/test_ohme.py b/apps/predbat/tests/test_ohme.py index 6e974a48f..3bfe8161c 100644 --- a/apps/predbat/tests/test_ohme.py +++ b/apps/predbat/tests/test_ohme.py @@ -300,6 +300,8 @@ def test_ohme(my_predbat=None): ("control_enable", _test_ohme_control_enable_rules, "ohme_control enable rules"), ("control_windows", _test_ohme_control_window_parsing, "control window parsing"), ("control_midnight", _test_ohme_control_window_year_rollover, "control windows across new year"), + ("control_midnight_after", _test_ohme_control_window_year_rollover_after_midnight, "control window across new year, read back after midnight has rolled over"), + ("control_long_active", _test_ohme_control_window_long_active_not_shifted, "long active window not mistaken for a rollover"), ("control_startup", _test_ohme_control_waits_for_plan, "control waits for a published plan"), ("control_edges", _test_ohme_control_edge_triggered, "control only acts on transitions"), ("control_drift", _test_ohme_control_reapplies_on_drift, "control re-applies after app changes"), @@ -1805,6 +1807,51 @@ def _test_ohme_control_window_year_rollover(my_predbat=None): return 0 +def _test_ohme_control_window_year_rollover_after_midnight(my_predbat=None): + """A Dec 31 -> Jan 1 window read back once now has itself rolled into January (Copilot review on #5120)""" + print("**** Running test_ohme_control_window_year_rollover_after_midnight ****") + + tz = pytz.timezone("Europe/London") + # Same window as the new year's eve test above, but now is read a little after midnight, once + # the clock has already ticked into January. Naively anchoring both start and end to now.year + # previously put the Dec 31 start a full year in the future (next Dec 31) rather than the + # actual previous one, so should_charge_now() stopped seeing the still-active window at all. + now = tz.localize(datetime.datetime(2027, 1, 1, 0, 15, 0)) + window = {"start": "12-31 23:30:00", "end": "01-01 01:30:00", "kwh": 7.0} + api = _ohme_control_api(windows=[window], now=now) + + api.refresh_car_windows() + start, end = api.control_windows[0] + assert start.year == 2026, f"Dec 31 start should anchor to the previous year, got {start}" + assert end.year == 2027, f"Jan 1 end should anchor to the current year, got {end}" + assert api.should_charge_now() is True, "Expected to still be charging at 00:15 on new year's day" + + print("PASS: new year window handled after midnight rollover") + return 0 + + +def _test_ohme_control_window_long_active_not_shifted(my_predbat=None): + """Test a still-active window whose start is over 23 hours old is not mistaken for a New Year rollover (#269)""" + print("**** Running test_ohme_control_window_long_active_not_shifted ****") + + tz = pytz.timezone("Europe/London") + # A long/flat-rate window starting just after midnight yesterday and still running: at 23:05 + # the next day its start is nearly 47 hours old, well past the 23 hour rollover heuristic, but + # its end is still ahead of now, so it must be read as genuinely active rather than shifted a + # year forward and dropped out of should_charge_now(). + now = tz.localize(datetime.datetime(2026, 6, 15, 23, 5, 0)) + window = {"start": "06-14 00:10:00", "end": "06-16 02:00:00", "kwh": 40.0} + api = _ohme_control_api(windows=[window], now=now) + + api.refresh_car_windows() + start, end = api.control_windows[0] + assert start.year == now.year, f"Expected the still-active window's start left in the current year, got {start}" + assert api.should_charge_now() is True, "Expected to still be charging inside a long active window over 23 hours after its start" + + print("PASS: long active window left unshifted") + return 0 + + def _test_ohme_control_waits_for_plan(my_predbat=None): """Test control sends nothing until a plan has actually been published""" print("**** Running test_ohme_control_waits_for_plan ****") diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 89cfdaef7..120aba6cf 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -46,6 +46,7 @@ from tests.test_fetch_config_options import test_fetch_config_options from tests.test_multi_inverter import run_inverter_multi_tests from tests.test_window2minutes import test_window2minutes +from tests.test_car_plan_windows import test_car_plan_windows from tests.test_hass_watcher import test_hass_watcher from tests.test_new_install_detection import test_new_install_detection from tests.test_history_attribute import test_history_attribute @@ -463,6 +464,7 @@ def main(): ("rate_export_max_forward_calc", test_rate_export_max_forward_calc, "Rate export max forward calc tests", False), ("window_sort", run_window_sort_tests, "Window sort tests", False), ("window2minutes", test_window2minutes, "Window to minutes tests", False), + ("car_plan_windows", test_car_plan_windows, "parse_car_plan_windows year anchoring: New Year either side, long windows, leap day", False), ("hass_watcher", test_hass_watcher, "Standalone-mode file watcher tests (#4397/#4396)", False), ("new_install_detection", test_new_install_detection, "New-install misdetection tests (Bug B, #4397/#4396, #3259, #3306)", False), ("compute_metric", run_compute_metric_tests, "Compute metric tests", False), diff --git a/apps/predbat/utils.py b/apps/predbat/utils.py index 135b5d254..65bd090df 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -1767,8 +1767,8 @@ def format_time_ago(last_updated): def parse_car_plan_windows(planned, now, local_tz): """Turn one car's published charging plan into a list of localised (start, end) pairs. - Shared by the components that drive a charger from the plan (myenergi, GivEnergy EVC) - so the awkward parts stay in one place: the plan carries no year, so each window is + Shared by the components that drive a charger from the plan (myenergi, GivEnergy EVC, + Ohme, the gateway EVC) so the awkward parts stay in one place: the plan carries no year, so each window is rebuilt around now - without that, a plan read either side of New Year lands eleven months out - and a malformed entry is skipped rather than costing the rest of the plan. @@ -1787,20 +1787,23 @@ def parse_car_plan_windows(planned, now, local_tz): parsed = [] for window in planned or []: try: - start = local_tz.localize(datetime.strptime(window["start"], CAR_PLAN_TIME_FORMAT).replace(year=now.year)) - end = local_tz.localize(datetime.strptime(window["end"], CAR_PLAN_TIME_FORMAT).replace(year=now.year)) + # Parsed with now's year in the string, not stamped on afterwards: strptime without a + # year assumes 1900, which has no 29 February, so a leap-day window was dropped + start = local_tz.localize(datetime.strptime("{}-{}".format(now.year, window["start"]), "%Y-" + CAR_PLAN_TIME_FORMAT)) + end = local_tz.localize(datetime.strptime("{}-{}".format(now.year, window["end"]), "%Y-" + CAR_PLAN_TIME_FORMAT)) + # Shift both ends together so their spacing survives, then close a window whose + # end is in January while its start is still in December. Inside the try, as a stale + # 29 February entry has no counterpart in the neighbouring year. + if start > now + CAR_PLAN_YEAR_MARGIN: + start = start.replace(year=start.year - 1) + end = end.replace(year=end.year - 1) + elif start < now - CAR_PLAN_YEAR_MARGIN: + start = start.replace(year=start.year + 1) + end = end.replace(year=end.year + 1) + if end < start: + end = end.replace(year=end.year + 1) except (KeyError, TypeError, ValueError): continue - # Shift both ends together so their spacing survives, then close a window whose - # end is in January while its start is still in December - if start > now + CAR_PLAN_YEAR_MARGIN: - start = start.replace(year=start.year - 1) - end = end.replace(year=end.year - 1) - elif start < now - CAR_PLAN_YEAR_MARGIN: - start = start.replace(year=start.year + 1) - end = end.replace(year=end.year + 1) - if end < start: - end = end.replace(year=end.year + 1) parsed.append((start, end)) return parsed From be0ccf020015a50facdaaa90d88c69436982cac9 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 22:11:18 +0100 Subject: [PATCH 2/2] docs(car): say Octopus dispatches still show a moving start, and list Wallbox among the plan readers (#5120 review) An Intelligent dispatch already underway has its start trimmed to now by the Octopus component, so the comment's claim that Octopus slots keep their own start unchanged was wrong for those. The shared plan parser also has a new caller in Wallbox since the rebase. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/gateway.py | 2 +- apps/predbat/output.py | 3 ++- apps/predbat/utils.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index 6d9bd71c1..1a9dbd561 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -506,7 +506,7 @@ def _refresh_ev_windows(self): Datetimes are localized using ``self.local_tz`` so comparisons respect the component timezone and DST transitions. The plan carries no year; ``utils.parse_car_plan_windows()`` - (shared with myenergi and GivEnergy EVC) rebuilds it around now, so a window that straddles + (shared with the other charger components) rebuilds it around now, so a window that straddles New Year is anchored correctly whichever side of midnight it is read (#269). """ planned = self.get_state_wrapper(f"binary_sensor.{self.prefix}_car_charging_slot", attribute="planned") or [] diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 3007c35d7..72c265f30 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -165,7 +165,8 @@ def publish_car_plan(self): # Show when the window really began, not the planner's clamped start, so the # displayed start time stops walking forward once charging is underway (#269). - # Octopus slots carry no start_orig, so they keep their own start unchanged. + # Octopus slots carry no start_orig and show their own start - which the Octopus + # component trims to now once a dispatch is underway, so those still move. window_start = window.get("start_orig", window["start"]) time_format_time = "%H:%M:%S" diff --git a/apps/predbat/utils.py b/apps/predbat/utils.py index 65bd090df..35033d4f8 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -1768,7 +1768,7 @@ def parse_car_plan_windows(planned, now, local_tz): """Turn one car's published charging plan into a list of localised (start, end) pairs. Shared by the components that drive a charger from the plan (myenergi, GivEnergy EVC, - Ohme, the gateway EVC) so the awkward parts stay in one place: the plan carries no year, so each window is + Ohme, Wallbox, the gateway EVC) so the awkward parts stay in one place: the plan carries no year, so each window is rebuilt around now - without that, a plan read either side of New Year lands eleven months out - and a malformed entry is skipped rather than costing the rest of the plan.