Skip to content

Commit 4cf0338

Browse files
chalfontchubbyclaude
authored andcommitted
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 <noreply@anthropic.com>
1 parent ff584d3 commit 4cf0338

10 files changed

Lines changed: 307 additions & 79 deletions

File tree

‎apps/predbat/gateway.py‎

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import time
1717
import uuid
1818
import traceback
19-
from utils import calc_percent_limit, export_mode_of, export_target_of, export_power_of
19+
from utils import calc_percent_limit, export_mode_of, export_target_of, export_power_of, parse_car_plan_windows, in_car_plan_window
2020
from const import EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, MINUTE_WATT
2121
import pytz as _pytz
2222

@@ -505,41 +505,17 @@ def _refresh_ev_windows(self):
505505
with datetime strings in ``"%m-%d %H:%M:%S"`` format (produced by output.py).
506506
507507
Datetimes are localized using ``self.local_tz`` so comparisons respect the component
508-
timezone and DST transitions. Year boundaries are handled: if a parsed start is more
509-
than 23 hours in the past (i.e. the plan was built on Dec 31 and contains Jan 1
510-
windows), the year is bumped forward. Similarly, if end falls before start after
511-
localization the end year is incremented to handle windows that straddle midnight
512-
on New Year's Eve.
508+
timezone and DST transitions. The plan carries no year; ``utils.parse_car_plan_windows()``
509+
(shared with myenergi and GivEnergy EVC) rebuilds it around now, so a window that straddles
510+
New Year is anchored correctly whichever side of midnight it is read (#269).
513511
"""
514512
planned = self.get_state_wrapper(f"binary_sensor.{self.prefix}_car_charging_slot", attribute="planned") or []
515513
now = datetime.datetime.now(self.local_tz)
516-
current_year = now.year
517-
windows = []
518-
for w in planned:
519-
try:
520-
start_naive = datetime.datetime.strptime(w["start"], "%m-%d %H:%M:%S").replace(year=current_year)
521-
end_naive = datetime.datetime.strptime(w["end"], "%m-%d %H:%M:%S").replace(year=current_year)
522-
start_dt = self.local_tz.localize(start_naive)
523-
end_dt = self.local_tz.localize(end_naive)
524-
# If start is far in the past the plan crossed a year boundary (Dec 31 → Jan 1)
525-
if start_dt < now - datetime.timedelta(hours=23):
526-
start_dt = start_dt.replace(year=start_dt.year + 1)
527-
end_dt = end_dt.replace(year=end_dt.year + 1)
528-
elif end_dt < start_dt:
529-
# end crossed into the new year but start did not (e.g. 23:30 → 00:30)
530-
end_dt = end_dt.replace(year=end_dt.year + 1)
531-
windows.append((start_dt, end_dt))
532-
except (KeyError, ValueError):
533-
continue
534-
self._ev_windows = windows
514+
self._ev_windows = parse_car_plan_windows(planned, now, self.local_tz)
535515

536516
def _should_ev_charge_now(self):
537517
"""Return True if the current local time falls inside any planned charge window."""
538-
now = datetime.datetime.now(self.local_tz)
539-
for start_dt, end_dt in self._ev_windows:
540-
if start_dt <= now < end_dt:
541-
return True
542-
return False
518+
return in_car_plan_window(self._ev_windows, datetime.datetime.now(self.local_tz))
543519

544520
async def _apply_ev_charging_state(self):
545521
"""Start or stop EVC charging when the window state transitions.

‎apps/predbat/ohme.py‎

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

3233
GOOGLE_API_KEY = "AIzaSyC8ZeZngm33tpOXLpbXeKfwtyZ1WrkbdBY" # cspell:disable-line
3334
VERSION = "1.5.1"
@@ -95,9 +96,6 @@
9596
"octopus_charge_limit": {"entity_id": "number.predbat_ohme_target_percent", "domain": "number", "access": "rw"},
9697
}
9798

98-
# Format Predbat writes its planned car charging windows in - see PredBat.time_abs_str()
99-
PLAN_TIME_FORMAT = "%m-%d %H:%M:%S"
100-
10199
# Longest gap between power readings we will still integrate over. The charge session is polled
102100
# every 120 seconds, so a longer gap means Predbat stalled or was restarted and we have no evidence
103101
# of what the charger did meanwhile - under-counting is safe, inventing energy is not
@@ -391,30 +389,16 @@ def refresh_car_windows(self):
391389
if planned is None:
392390
return False
393391

394-
now = self.now_utc_exact
395-
windows = []
396-
for window in planned:
397-
try:
398-
start = self.local_tz.localize(datetime.datetime.strptime(window["start"], PLAN_TIME_FORMAT).replace(year=now.year))
399-
end = self.local_tz.localize(datetime.datetime.strptime(window["end"], PLAN_TIME_FORMAT).replace(year=now.year))
400-
except (KeyError, TypeError, ValueError):
401-
continue
402-
# The plan carries no year, so rebuild it around now for windows that cross New Year
403-
if start < now - timedelta(hours=23):
404-
start = start.replace(year=start.year + 1)
405-
end = end.replace(year=end.year + 1)
406-
elif end < start:
407-
end = end.replace(year=end.year + 1)
408-
windows.append((start, end))
409-
self.control_windows = windows
392+
# The plan carries no year - parse_car_plan_windows() (shared with myenergi and GivEnergy
393+
# EVC) rebuilds it around now, whichever side of New Year it is read (#269)
394+
self.control_windows = parse_car_plan_windows(planned, self.now_utc_exact, self.local_tz)
410395
return True
411396

412397
def should_charge_now(self):
413398
"""
414399
Is now inside one of Predbat's planned charging windows.
415400
"""
416-
now = self.now_utc_exact
417-
return any(start <= now < end for start, end in self.control_windows)
401+
return in_car_plan_window(self.control_windows, self.now_utc_exact)
418402

419403
def control_drifted(self, should_charge):
420404
"""

‎apps/predbat/output.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,15 @@ def publish_car_plan(self):
163163
else:
164164
slot = False
165165

166+
# Show when the window really began, not the planner's clamped start, so the
167+
# displayed start time stops walking forward once charging is underway (#269).
168+
# Octopus slots carry no start_orig, so they keep their own start unchanged.
169+
window_start = window.get("start_orig", window["start"])
170+
166171
time_format_time = "%H:%M:%S"
167-
car_startt = self.midnight_utc + timedelta(minutes=window["start"])
172+
car_startt = self.midnight_utc + timedelta(minutes=window_start)
168173
car_start_time_str = car_startt.strftime(time_format_time)
169-
minutes_to = max(window["start"] - self.minutes_now, 0)
174+
minutes_to = max(window_start - self.minutes_now, 0)
170175
self.dashboard_item(
171176
self.prefix + ".car_charging_start" + postfix,
172177
state=car_start_time_str,
@@ -184,7 +189,7 @@ def publish_car_plan(self):
184189
total_kwh = 0
185190
total_cost = 0
186191
for window in self.car_charging_slots[car_n]:
187-
start = self.time_abs_str(window["start"])
192+
start = self.time_abs_str(window.get("start_orig", window["start"]))
188193
end = self.time_abs_str(window["end"])
189194
kwh = dp2(window["kwh"])
190195
average = dp2(window["average"])

‎apps/predbat/plan.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5978,6 +5978,11 @@ def plan_car_charging(self, car_n, low_rates):
59785978
new_slot = {}
59795979
new_slot["start"] = start
59805980
new_slot["end"] = end
5981+
# start is clamped to minutes_now above so the kWh maths only counts time the car
5982+
# can still charge for, but that makes an in-progress window's start walk forward
5983+
# by plan_interval_minutes every cycle. Keep the window's real start for display
5984+
# and for anything driving a charger off the published plan (issue #269).
5985+
new_slot["start_orig"] = window["start"]
59815986
new_slot["kwh"] = dp3(kwh)
59825987
new_slot["average"] = window["average"]
59835988
new_slot["cost"] = dp2(new_slot["average"] * kwh)

‎apps/predbat/tests/test_car_charging_smart.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# pylint: disable=consider-using-f-string
88
# pylint: disable=line-too-long
99
# pylint: disable=attribute-defined-outside-init
10+
import copy
1011
from tests.test_infra import reset_rates2, reset_inverter
1112
from config import CONFIG_ITEMS
1213

@@ -157,4 +158,108 @@ def run_car_charging_smart_tests(my_predbat):
157158
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")
158159
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")
159160

161+
failed |= run_car_charging_start_stable_test(my_predbat)
162+
163+
return failed
164+
165+
166+
def run_car_charging_start_stable_test(my_predbat):
167+
"""
168+
The published car charge start time must not walk forward once a slot is underway.
169+
170+
Regression test for issue #269: plan_car_charging() clamps an in-progress window's start
171+
to minutes_now so the kWh maths only counts time the car can still charge for. That
172+
clamped value was also published as predbat.car_charging_start, so the displayed start
173+
time advanced by plan_interval_minutes on every cycle while the car was charging.
174+
"""
175+
failed = False
176+
print("**** Running Test: car_charging_start_stable_issue269 ****")
177+
178+
saved_battery_size = my_predbat.car_charging_battery_size
179+
saved_limit = my_predbat.car_charging_limit
180+
saved_soc = my_predbat.car_charging_soc
181+
saved_soc_next = my_predbat.car_charging_soc_next
182+
saved_rate = my_predbat.car_charging_rate
183+
saved_loss = my_predbat.car_charging_loss
184+
saved_max_price = my_predbat.car_charging_plan_max_price
185+
saved_smart = my_predbat.car_charging_plan_smart
186+
saved_plan_time = my_predbat.car_charging_plan_time
187+
saved_num_cars = my_predbat.num_cars
188+
saved_minutes_now = my_predbat.minutes_now
189+
saved_slots = my_predbat.car_charging_slots
190+
191+
# publish_car_plan() writes the car sensors into the shared dashboard state
192+
saved_dashboard = copy.deepcopy(my_predbat.dashboard_values)
193+
194+
try:
195+
my_predbat.car_charging_battery_size = [100.0]
196+
my_predbat.car_charging_limit = [100.0]
197+
my_predbat.car_charging_soc = [0]
198+
my_predbat.car_charging_soc_next = [None]
199+
my_predbat.car_charging_rate = [1.0]
200+
my_predbat.car_charging_loss = 1.0
201+
my_predbat.car_charging_plan_max_price = [99]
202+
my_predbat.car_charging_plan_smart = [False]
203+
my_predbat.car_charging_plan_time = ["23:00:00"]
204+
my_predbat.num_cars = 1
205+
my_predbat.car_charging_slots = [[]]
206+
207+
# Plan once, then replan from part way through the first window as the real 5 minute
208+
# cycle would, and check the published start time did not move.
209+
start_times = []
210+
plan_starts = []
211+
first_window_start = None
212+
for step in range(3):
213+
if step == 0:
214+
my_predbat.minutes_now = saved_minutes_now
215+
else:
216+
# Step forward a cycle at a time while staying inside the first planned window,
217+
# which is what a car charging through the window actually sees
218+
my_predbat.minutes_now = first_window_start + step * 5
219+
220+
my_predbat.car_charging_slots[0] = my_predbat.plan_car_charging(0, my_predbat.low_rates)
221+
if not my_predbat.car_charging_slots[0]:
222+
print("ERROR: no car charging slots planned at step {}".format(step))
223+
failed = True
224+
break
225+
if first_window_start is None:
226+
first_window_start = my_predbat.car_charging_slots[0][0]["start"]
227+
first_window_end = my_predbat.car_charging_slots[0][0]["end"]
228+
# The drift only shows while one window stays current, so fail loudly rather than
229+
# silently passing if the fixture's windows ever get shorter than the steps below
230+
if first_window_end - first_window_start < 3 * 5:
231+
print("ERROR: test needs a first window at least 15 minutes long, got {}-{}".format(first_window_start, first_window_end))
232+
failed = True
233+
break
234+
235+
my_predbat.publish_car_plan()
236+
start_times.append(my_predbat.dashboard_values["predbat.car_charging_start"]["state"])
237+
plan_starts.append(my_predbat.dashboard_values["binary_sensor.predbat_car_charging_slot"]["attributes"]["planned"][0]["start"])
238+
239+
if not failed:
240+
if len(set(start_times)) != 1:
241+
print("ERROR: car_charging_start moved while the slot was underway: {} (issue #269)".format(start_times))
242+
failed = True
243+
else:
244+
print("OK: car_charging_start held at {} across the slot (issue #269)".format(start_times[0]))
245+
if len(set(plan_starts)) != 1:
246+
print("ERROR: planned window start moved while the slot was underway: {} (issue #269)".format(plan_starts))
247+
failed = True
248+
finally:
249+
my_predbat.car_charging_battery_size = saved_battery_size
250+
my_predbat.car_charging_limit = saved_limit
251+
my_predbat.car_charging_soc = saved_soc
252+
my_predbat.car_charging_soc_next = saved_soc_next
253+
my_predbat.car_charging_rate = saved_rate
254+
my_predbat.car_charging_loss = saved_loss
255+
my_predbat.car_charging_plan_max_price = saved_max_price
256+
my_predbat.car_charging_plan_smart = saved_smart
257+
my_predbat.car_charging_plan_time = saved_plan_time
258+
my_predbat.num_cars = saved_num_cars
259+
my_predbat.minutes_now = saved_minutes_now
260+
my_predbat.car_charging_slots = saved_slots
261+
262+
my_predbat.dashboard_values.clear()
263+
my_predbat.dashboard_values.update(saved_dashboard)
264+
160265
return failed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 parse_car_plan_windows, in_car_plan_window
14+
15+
LONDON = pytz.timezone("Europe/London")
16+
17+
18+
def _window(start, end):
19+
"""A planned-attribute entry as output.py publishes it."""
20+
return {"start": start, "end": end, "kwh": 5.0, "average": 20.0, "cost": 1.0}
21+
22+
23+
def test_car_plan_windows(my_predbat=None):
24+
"""
25+
utils.parse_car_plan_windows() rebuilds the year on the car charging plan (published as
26+
"%m-%d %H:%M:%S") around now. Shared by every charger that follows the plan - myenergi,
27+
GivEnergy EVC, Ohme and the gateway EVC (#269, #5120 review).
28+
"""
29+
failed = False
30+
print("**** Testing parse_car_plan_windows ****")
31+
32+
cases = [
33+
# (name, now, start, end, expected start year, expected end year, now inside)
34+
("ordinary window", datetime(2026, 6, 15, 14, 0), "06-15 13:00:00", "06-15 15:00:00", 2026, 2026, True),
35+
("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),
36+
# Once now itself is in January the Dec 31 start belongs to the previous year, or the window
37+
# lands a year in the future and a charging car is stopped mid-window
38+
("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),
39+
# A window wholly in January, read on 31 December, parses into January of the year ending and
40+
# has to move forward a year
41+
("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),
42+
# A long window whose start is days old is still the current one, not a year rollover (#269)
43+
("long active window", datetime(2026, 6, 16, 23, 5), "06-14 00:10:00", "06-17 02:00:00", 2026, 2026, True),
44+
("midnight-crossing window", datetime(2026, 6, 15, 23, 30), "06-15 23:00:00", "06-16 00:30:00", 2026, 2026, True),
45+
# strptime with no year assumes 1900, which has no 29 February - the window used to be dropped
46+
("leap day window", datetime(2028, 2, 29, 1, 0), "02-29 00:30:00", "02-29 05:00:00", 2028, 2028, True),
47+
("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),
48+
]
49+
for name, now_naive, start, end, start_year, end_year, inside in cases:
50+
now = LONDON.localize(now_naive)
51+
windows = parse_car_plan_windows([_window(start, end)], now, LONDON)
52+
if len(windows) != 1:
53+
print("ERROR: {}: expected one window, got {}".format(name, windows))
54+
failed = True
55+
continue
56+
start_dt, end_dt = windows[0]
57+
if (start_dt.year, end_dt.year) != (start_year, end_year) or in_car_plan_window(windows, now) != inside:
58+
print("ERROR: {}: got start={} end={} (now {}, inside {})".format(name, start_dt, end_dt, now, in_car_plan_window(windows, now)))
59+
failed = True
60+
61+
# A malformed entry, or a stale 29 February one that has no counterpart in the neighbouring year,
62+
# is skipped without costing the rest of the plan
63+
now = LONDON.localize(datetime(2028, 9, 1, 12, 0))
64+
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")]
65+
windows = parse_car_plan_windows(planned, now, LONDON)
66+
if len(windows) != 1 or not in_car_plan_window(windows, now):
67+
print("ERROR: malformed entries should be skipped and the valid window kept, got {}".format(windows))
68+
failed = True
69+
70+
if not failed:
71+
print("**** All parse_car_plan_windows tests PASSED ****")
72+
return failed

0 commit comments

Comments
 (0)