fix(overrides): decode manual overrides against an unfakeable clock (#4900) - #4906
Merged
Merged
Conversation
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>
Contributor
There was a problem hiding this comment.
🟢 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 updatemanual_times()/manual_rates()to decode and re-render using the same (unfaked) origin, plus anupdate=Falseread-only mode. - Update web plan endpoints (and rate-clear flows) to call manual override decoders with
update=Falseto 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
(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:
Those
03-09windows sit beyondend_record, whereoptimise_charge_windows_reset()parks everything atEXPORT_LIMIT_IDLE. The only thing that writes an unclipped0.0there isoptimise_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()andmanual_rates()decode those labels to absolute minutes, re-render them, and write the result back withexpose_config(..., force=True)on every call. The decode readself.midnight_utc; the render readself.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 windsmidnight_utcback a day and zeroesminutes_nowon 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 onsavings_last_updated), not per replan.Four callers can reach these functions off the main thread and land in it:
/api/plan_dataevery 5sselect_event(websocket thread)agent_tools/ MCPFor 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 ascalculate_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:
The fix
New
manual_time_origin()derives midnight and minutes-now fromnow_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 onmain:update=Falsetouches neither the value nor the options./run_all --quickpasses; pre-commit clean.Not addressed
calculate_yesterday()fakes around twenty attributes plusself.predictionon the shared instance while other threads read it.manual_times/manual_rateswere the only exposure that persisted anything — the rest ofweb.pyreads published HA entities, not live base state — but a page rendered inside the window can still briefly hide the iBoost and carbon columns, sinceiboost_enableandcarbon_enableare blanked too. That is cosmetic and self-heals. The wider shared-state faking is worth its own issue; it is the same function as theforecast_minutesbug in #4418.🤖 Generated with Claude Code