Skip to content

fix(car): stop the car charge start time walking forward (#269) - #5120

Merged
springfall2008 merged 2 commits into
mainfrom
fix/car-charge-start-drift-269
Oct 8, 2026
Merged

springfall2008 merged 2 commits into
mainfrom
fix/car-charge-start-drift-269

Conversation

@chalfontchubby

@chalfontchubby chalfontchubby commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

This PR was written by Claude, working with me.

Refs #269, the oldest outstanding issue in the tracker, from November 2023. (Changed from "Fixes" at merge so the issue stays open: the start still moves at each low-rate window boundary, see the approval review.)

The problem

Once a car charging window is underway, predbat.car_charging_start advances by five minutes on every cycle, so the displayed "charging starts at" never settles - it just tracks the clock. The actual charge session is unaffected, which is why it went unnoticed for so long.

Root cause

plan_car_charging() clamps a window's start to minutes_now:

start = max(window["start"], self.minutes_now)

This is correct, and load-bearing. Everything below it treats start→end as deliverable charging time - length = end - start, then kwh = rate * hours. The plan is rebuilt from scratch every cycle against the car's current SoC, with no memory that a window already began, so without the clamp a 23:30–05:30 window still on the books at 03:00 would be credited with six hours of charging when only 2.5 remain.

The bug is that publish_car_plan() then reused that planning-internal value as the user-facing timestamp.

Fix

plan_car_charging() records the window's real start as start_orig; publish_car_plan() displays that for both car_charging_start and the slot sensor's planned attribute. All kWh and cost arithmetic still uses the clamped start, so planning behaviour is unchanged. A window that is already underway now shows when it began (e.g. 23:30 when read at 02:00), while its kWh and cost cover only the part still to come.

Octopus Intelligent slots never go through plan_car_charging(), so they carry no start_orig and show their own start. That start is not fixed either: the Octopus component trims an in-progress dispatch's start to now on every poll, so an Intelligent dispatch already underway still shows a start that moves. This PR fixes Predbat's own planned windows only; doing the same for dispatches would mean carrying the original start through the Octopus component's trim.

Effect on charger control

planned also drives real chargers: Ohme, myenergi, GivEnergy EVC, Wallbox and the gateway EVC read it back and check start <= now < end. The published start can now legitimately be hours - or, for a long combined window, more than a day - in the past. Ohme and the gateway had their own year repair that treated any start more than 23 hours old as a New Year rollover and pushed it a year forward, which would have stopped the charger mid-window.

Both now use utils.parse_car_plan_windows(), the helper myenergi and GivEnergy EVC already share. It rebuilds the plan's missing year symmetrically around now with a 180-day margin, so a long window is never mistaken for a rollover and 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 a year assumes 1900, which has no 29 February, so every charger dropped a leap-day window.

Testing

  • car_charging_start_stable_issue269 steps minutes_now forward a cycle at a time inside one planned window and asserts the published start does not move. Reverting only the publisher prints ['12:00:00', '12:05:00', '12:10:00'] - the drift from the issue. It asserts the first window is at least 15 minutes long so it cannot go vacuous, and restores the dashboard state it publishes.
  • car_plan_windows tests parse_car_plan_windows() directly: New Year read before and after midnight, a long active window, a midnight crossing, leap day, and malformed or stale entries. The leap-day cases fail on main.
  • Ohme (control_midnight_after) and gateway (test_refresh_ev_windows_year_boundary, test_refresh_ev_windows_long_active_not_shifted) cover the same cases through the chargers, on pinned dates.
  • ./run_all --quick and pre-commit run --all-files pass (exit 0).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two critical findings affecting charger control remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This pull request fixes drifting car-charge start timestamps while preserving clamped planning calculations.

Changes:

  • Stores original charging-window starts.
  • Publishes stable starts and planned windows.
  • Adds regression coverage for timestamp stability.
File summaries
File Summary
apps/predbat/tests/test_car_charging_smart.py Adds regression coverage for stable timestamps.
apps/predbat/plan.py Stores original starts. Critical finding (1 vote): long combined windows may trigger incorrect year handling in chargers.
apps/predbat/output.py Publishes original starts. Critical finding (2 votes): starts older than 23 hours may be shifted into the next year by charger parsers.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/output.py
Comment thread apps/predbat/plan.py
chalfontchubby added a commit that referenced this pull request Sep 18, 2026
…ollover

Both parsers bumped a window's year forward whenever its parsed start was
more than 23h in the past, to handle a plan built just before New Year
carrying Jan 1 windows. But a genuinely active long/flat-rate window that
started well over 23h ago (its end still ahead of now) hit the same
heuristic and got shifted a year forward, dropping out of
should_charge_now()/_should_ev_charge_now() and stopping the charger
mid-window (#269, Copilot review on #5120).

Only bump the year when the window has also already finished (end < now)
- the signature of a genuine rollover artefact, not an active window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Posted by Rik; working with Claude.)

Both Copilot findings on this PR are fixed in 47bba41:

Ohme and GatewayMQTT could mistake a still-active window for a New Year rollover. Both parsers bump a parsed window's year forward whenever its start is more than 23 hours in the past, to handle a plan built just before midnight on Dec 31 carrying Jan 1 windows. But a genuinely active long/flat-rate window that legitimately started over 23 hours ago (its end still ahead of now) hit the same heuristic and got shifted a year forward - dropping it out of should_charge_now()/_should_ev_charge_now() and telling the charger to stop mid-window (#269).

The fix: only treat it as a rollover when the window has also already finished (end < now) - that's the actual signature of a stale rollover artefact, not just an old start.

Added regression tests for a >23h-old but still-active window in both test_ohme.py (control_long_active) and test_gateway.py (test_refresh_ev_windows_long_active_not_shifted), alongside the existing rollover coverage which confirms the genuine Dec 31→Jan 1 case still works.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Critical New Year rollover defects remain in Ohme and Gateway, along with a moderate test-isolation issue.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 High severity

Open (2)
Resolved since last review (2)

Comment thread apps/predbat/gateway.py Outdated
Comment thread apps/predbat/ohme.py Outdated
chalfontchubby added a commit that referenced this pull request Sep 23, 2026
… 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>
@chalfontchubby
chalfontchubby force-pushed the fix/car-charge-start-drift-269 branch from f0fe937 to 61b67a0 Compare September 26, 2026 20:47
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Written by Claude, posted by @chalfontchubby.)

A correction to the two replies I posted earlier today on the Ohme and gateway New Year threads. They described localize_yearless_window() as "a new shared helper" and didn't answer the actual suggestion in both comments: use utils.parse_car_plan_windows(), which myenergi and GivEnergy EVC already use and which already handled both sides of New Year. Adding a second helper for the same job was the wrong call.

Now fixed in 61b67a0:

  • Ohme and the gateway EVC call parse_car_plan_windows() / in_car_plan_window(). The duplicate helper, its test module and registry entry are removed, so all four chargers parse the plan one way.
  • parse_car_plan_windows() now parses with the year in the string. Without one, strptime assumes 1900, which has no 29 February, so every charger dropped a leap-day window. That was already the case on main.
  • The gateway long-active test read the real clock and failed from 1 to 2 January and around 29 February. It is now pinned to a fixed date.

The branch is rebased onto current main and squashed into that one commit, so the commits cited in earlier replies (47bba41, f0fe937) no longer exist on it - the first commit on its own would also have reintroduced the 23-hour rollover bug for long windows. The PR description is rewritten to match.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No unresolved blocking issues were identified.

Review effort: Lite
Findings: None

Resolved since last review (2)

chalfontchubby added a commit that referenced this pull request Oct 6, 2026
… 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 <noreply@anthropic.com>
@chalfontchubby
chalfontchubby force-pushed the fix/car-charge-start-drift-269 branch from 61b67a0 to 281a74a Compare October 6, 2026 01:50
chalfontchubby and others added 2 commits October 8, 2026 13:31
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>
… 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 <noreply@anthropic.com>
@springfall2008
springfall2008 force-pushed the fix/car-charge-start-drift-269 branch from 281a74a to be0ccf0 Compare October 8, 2026 12:31
@springfall2008
springfall2008 self-requested a review October 8, 2026 12:31

@springfall2008 springfall2008 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Moving Ohme and the gateway charger onto the shared parse_car_plan_windows() and the leap-day fix are good, and publishing start_orig cannot make a charger start early: the planner never moves a slot later than its window start. The charger and car plan test modules passed on a local merge with main before the rebase.

One thing to follow up, not a blocker. This stops the 5-minute drift from #269 but the start can still move:

  • With combine_charge_slots off (the default), low-rate windows are split into plan-interval chunks (find_charge_window(), the charge_slot_split break), so start_orig is the start of the current 30-minute chunk and the displayed start steps forward at each chunk boundary.
  • The window scan starts at today's midnight, so a 23:30-05:30 window reads as starting at 00:00 after midnight, not 23:30 as the description's example says.
  • car_charging_start_stable_issue269 steps three 5-minute cycles inside one window, so it crosses neither.

(Review written by Claude on behalf of @springfall2008.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants