Skip to content

fix(overrides): decode manual overrides against an unfakeable clock (#4900) - #4906

Merged
springfall2008 merged 1 commit into
mainfrom
fix/manual-override-day-shift-4900
Sep 3, 2026
Merged

fix(overrides): decode manual overrides against an unfakeable clock (#4900)#4906
springfall2008 merged 1 commit into
mainfrom
fix/manual-override-day-shift-4900

Conversation

@chalfontchubby

@chalfontchubby chalfontchubby commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

(Investigated and written by Claude Code on my behalf.)

Fixes #4900.
Fixes #3078.

The bug

A manual override set on the plan card is not dropped by the optimiser — it is silently moved forward exactly 24 hours and then honoured on the wrong day. In the reporter's log the seven slots pinned for 05:30–09:00 reappear five minutes later against tomorrow:

02:00:17  Export windows filtered [ 02-09 05:30-06:00 @ 15.72p 86.0%, 02-09 06:00-06:30 @ 76.0%, ... ]
02:05:17  Export windows filtered [ 02-09 07:55-08:00 @ 94.0%, ...,
                                    03-09 05:30-06:00 @ 15.72p 0.0%, 03-09 06:00-06:30 @ 0.0%, ... ]

Those 03-09 windows sit beyond end_record, where optimise_charge_windows_reset() parks everything at EXPORT_LIMIT_IDLE. The only thing that writes an unclipped 0.0 there is optimise_charge_windows_manual() — so the pins were still being applied, just to the wrong day.

Why

The override is stored as a list of relative "%a %H:%M" labels on its select entity — there is no other copy. manual_times() and manual_rates() decode those labels to absolute minutes, re-render them, and write the result back with expose_config(..., force=True) on every call. The decode read self.midnight_utc; the render read self.midnight. Same instant normally, so the round trip is a no-op and the mismatch stayed latent.

calculate_yesterday() makes them differ. To re-simulate yesterday it winds midnight_utc back a day and zeroes minutes_now on the shared instance, restoring them at the end. Measured across 36 runs in the reporter's logs that window is 0.67–0.97s, mean 0.78s, and it runs once an hour (gated on savings_last_updated), not per replan.

Four callers can reach these functions off the main thread and land in it:

Trigger Needs the web UI?
Plan page polling /api/plan_data every 5s page open
Plan card click yes
HA select changed from a dashboard, automation or script → select_event (websocket thread) no
agent_tools / MCP no

For an open plan page that is a 0.78s window against a 5s poll — ~16% per hourly run, so ~88% over a night. Not a rare race, which is why it was reported. fetch_config_options() can never hit it, being on the same thread as calculate_yesterday().

The same borrowed clock also broke the 48-hour horizon check: a selection made during the window measured every stored slot a day late, so anything beyond 24 hours fell past the limit and was deleted:

stored before : +Wed 05:30,Thu 18:00
(click "Wed 06:00" during the window)
stored during : +Wed 05:30,Wed 06:00     <- Thu 18:00 silently gone

The fix

New manual_time_origin() derives midnight and minutes-now from now_utc, which nothing fakes. Both functions use it for the decode, the re-render and the horizon check, so interpreting a stored selection no longer depends on what the shared clock is doing or which thread is asking. That closes the read path, the click path and the horizon check together.

Both functions also gain update=False, used by every web read path, so rendering the plan can no longer write user config at all. That is belt-and-braces now rather than the thing making it correct — a GET simply should not be able to rewrite a user's overrides.

Tests

Three regression tests in tests/test_manual_times.py, each verified to fail on main:

  • T11 — a stored slot survives the clock shift and still resolves to the right day
  • T12 — the same for manual rates, and update=False touches neither the value nor the options
  • T13 — a selection made mid-shift keeps a slot more than a day out

./run_all --quick passes; pre-commit clean.

Not addressed

calculate_yesterday() fakes around twenty attributes plus self.prediction on the shared instance while other threads read it. manual_times/manual_rates were the only exposure that persisted anything — the rest of web.py reads published HA entities, not live base state — but a page rendered inside the window can still briefly hide the iBoost and carbon columns, since iboost_enable and carbon_enable are blanked too. That is cosmetic and self-heals. The wider shared-state faking is worth its own issue; it is the same function as the forecast_minutes bug in #4418.

🤖 Generated with Claude Code

Manual charge/export/freeze/demand slots and the manual rate overrides are
stored as relative "%a %H:%M" labels on their select entity, and every read
decodes them to absolute minutes, re-renders them and writes the result back.
The two halves of that round trip used different clocks - the decode read
midnight_utc, the render read midnight - which is a no-op while the two agree.

calculate_yesterday() makes them disagree. It winds midnight_utc back a day and
zeroes minutes_now on the shared instance for about 0.8s while it re-simulates
yesterday, once an hour. Any caller reaching manual_times() or manual_rates()
off the main thread during that window - the plan page polling /api/plan_data
every 5s, a plan card click, an HA select change arriving on the websocket
thread, or agent_tools - decoded the stored labels a day late and persisted the
result. The user's override then ran against tomorrow and never came back.

The same borrowed clock also broke the 48 hour horizon check, so a selection
made during the window silently dropped any slot more than 24 hours out.

Both functions now take midnight and minutes-now from manual_time_origin(),
derived from now_utc, which nothing fakes - so the decode no longer depends on
what the shared clock is doing or which thread is asking. The web read paths
also gain update=False so rendering the plan cannot write user config at all;
that is belt-and-braces rather than the correctness fix.

Fixes #4900

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

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.

🟢 Approval recommended

The fix directly addresses the described race, avoids side-effecting GET paths, and includes targeted regression tests for the reported failure modes.

Pull request overview

This PR fixes a race where manual override selections could be decoded against a temporarily “borrowed” clock during calculate_yesterday(), causing overrides to be shifted by 24 hours or pruned by the 48‑hour horizon check. It centralizes manual-override decoding on an unfaked time origin and prevents web read paths from writing back to user configuration.

Changes:

  • Add manual_time_origin() and update manual_times() / manual_rates() to decode and re-render using the same (unfaked) origin, plus an update=False read-only mode.
  • Update web plan endpoints (and rate-clear flows) to call manual override decoders with update=False to avoid persisting state during GETs.
  • Add regression tests covering clock-shift survivability, read-only decoding behavior, and far-future slot retention.
File summaries
File Description
apps/predbat/web.py Makes web/GET-driven reads of overrides read-only (update=False) to prevent persisting mid-recompute decodes.
apps/predbat/userinterface.py Introduces a stable manual-override time origin and adds update gating to prevent unwanted config writes on read paths.
apps/predbat/tests/test_manual_times.py Adds regression tests for the clock-shift race and ensures read-only decode doesn’t mutate stored values/options.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 0
  • Review effort level: Lite

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

@springfall2008
springfall2008 merged commit 60ffb79 into main Sep 3, 2026
3 checks passed
@springfall2008
springfall2008 deleted the fix/manual-override-day-shift-4900 branch September 3, 2026 17:21
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.

Forced Export gets erased after few hours Sometimes manual override is partially ignored

3 participants