Skip to content

feat(debug): rolling debug-history snapshot buffer with automation hooks - #4438

Open
chalfontchubby wants to merge 15 commits into
mainfrom
feat/rolling-debug-history
Open

feat(debug): rolling debug-history snapshot buffer with automation hooks#4438
chalfontchubby wants to merge 15 commits into
mainfrom
feat/rolling-debug-history

Conversation

@chalfontchubby

@chalfontchubby chalfontchubby commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #4417.

create_debug_yaml() (what bug reporters attach so a maintainer can replay their exact plan computation via unit_test.py --debug_file --redo) only ever wrote when switch.predbat_debug_enable was already on, on every ~5-minute cycle with no pruning at all - unbounded disk growth if left on, and no history to look back at for anyone who didn't already have it on before something went wrong (originally raised in #1745, never actually built).

Adds a rolling buffer of debug snapshots, independent of that switch, so there's always some recent history to look back at:

  • debug_history.py (new) - capture/list/load/prune snapshots, mirroring the existing annual_store.py ring-buffer pattern and going through the Storage component rather than the filesystem directly. Snapshots are saved with format="text" (the payload is already-rendered YAML text from create_debug_yaml(write_file=False), not a Python object).
  • Four new config items: switch.predbat_debug_history_enable (default on) turns the rolling capture off entirely; debug_history_count (default 15, minimum 1) and debug_history_interval (default 3h - 15x3h matches the History tab's worst-case "yesterday + today so far" coverage) size the buffer; debug_history_force_capture is a self-resetting switch so an automation that notices something worth investigating can trigger an immediate, precisely-timed capture rather than waiting on the routine interval - still honoured even when the rolling capture is disabled.
  • predbat.py: _capture_debug_history(), throttled in-memory, only advances its throttle clock or resets the force-capture switch on a genuine successful capture - a failed attempt (or Storage being unavailable) retries next cycle instead of silently deferring a full interval.
  • switch.predbat_debug_enable (the existing raw per-cycle debug.yaml writer) now auto-disables itself after DEBUG_ENABLE_MAX_HOURS (2h) if left on, bounding the unbounded-disk-growth problem for that path too without removing it outright - it's still useful at finer resolution than the rolling buffer's hour-plus floor for someone actively watching a live issue.
  • web.py: /debug_history_list and /debug_history_download routes (reusing the existing html_file() attachment-serving helper), plus a single Download all (.tgz) link on the dashboard's Debug panel that bundles every retained snapshot.
  • web_helper.py: a Debug column on the plan's History/Yesterday view only (its rows have a real past timestamp to match a snapshot against - the live Plan view is mostly future predictions with nothing to match). A link appears when a row lands on the exact floored capture slot for a snapshot; fetched and matched client-side by wall-clock time.
  • Docs - customisation.md ("Debug history" subsection, plus a note on debug_enable's new auto-disable), web-interface.md (the download link), predbat-plan-card.md (the Debug column).

Doesn't touch car charging forecasting or historical cost reporting - this is purely a debugging/diagnostic aid, no effect on planning behaviour.

Test plan

  • test_debug_history.py - capture/list/load round-trip, pruning to a configured count on both Storage eviction paths, same-minute dedup, max_age pruning, corrupt-index handling, latest/falsy-id resolution, and a real-StorageLocalFiles round-trip of genuine YAML-syntax text (guards the format="text" vs "yaml" trap directly)
  • test_debug_history_capture.py - interval throttling, the enable switch disabling routine capture, force-capture bypassing both the count and interval, the switch resetting itself only on success, graceful handling with no storage component available
  • test_debug_enable_auto_scope.py - the 2-hour auto-disable behaviour
  • test_web_debug_history_routes.py - all three new routes against a real StorageLocalFiles backend, including atomic latest-snapshot resolve+load and reflected-id escaping
  • test_debug_history_client_js.py - structural coverage for the client-side JS (findNearestDebugSnapshot/loadDebugHistoryData/Debug column rendering), following test_plan_why_reason.py's precedent for testing embedded JS
  • ./run_all --quick - full suite passes
  • ./run_pre_commit - clean

🤖 Generated with Claude Code

@chalfontchubby
chalfontchubby force-pushed the feat/rolling-debug-history branch from 30b85ff to 5625a57 Compare August 6, 2026 13:29
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author
Screenshot 2026-08-06 at 17 34 35

Adds the ability to download debug files for a defined subset of points in the history without the ever expanding set of debug files enabled with "debug". This screenshot covers a period with multiple restarts, so the snapshot files are not at the expected regular intervals.

A follow on branch adds a new chart showing how these "historical plans" evolve over time (the planned soc overlaid for each historical snapshot) so it is possible to find out "what caused that weird thing I see in the history" and "why did the plan suddenly do something different"

@springfall2008

Copy link
Copy Markdown
Owner

Review

Nice feature — went through the diff in detail. A few correctness issues worth fixing before merge, plus some lower-priority cleanup.

Correctness bugs

  1. predbat.py _capture_debug_history()self.debug_history_last_capture = self.now_utc runs unconditionally after the try/except. A failed capture (exception caught and logged as a "Warning") still resets the throttle clock, so the next attempt is silently deferred for a full debug_history_interval (default 3h) instead of retrying next cycle.
  2. Same function, storage-unavailable pathif not storage: return exits before that line, so the throttle timestamp never advances at all. If Storage is ever down, every ~5-minute cycle re-enters and re-logs the warning indefinitely.
  3. Force-capture switch resets before the outcome is knownexpose_config("debug_history_force_capture", False) fires immediately on trigger, before storage is checked or the capture is attempted. docs/customisation.md says "Predbat resets the switch back off itself once the snapshot has been taken" — the code resets it on trigger, not completion. An automation polling the switch for success has no reliable signal if the capture actually failed.
  4. web.py html_debug_history_download, id=latest caseload_snapshot(storage, "latest") resolves "latest" internally and discards the resolved id; the route then calls list_snapshots() a second, independent time just to rebuild the filename. A capture landing between the two calls can serve one snapshot's bytes under another snapshot's filename.
  5. debug_history.py capture_snapshot() max_age pruning — the try/except (KeyError, ValueError, TypeError) only wraps the fromisoformat parse, not the later existing_time < cutoff comparison. A naive (non-tz-aware) stored timestamp would raise an uncaught TypeError, aborting the index write and orphaning the just-captured snapshot in storage. Low likelihood today since now_utc is always tz-aware, but the guard doesn't cover what its comment claims it does.
  6. web.py"Snapshot {} not found".format(snapshot_id) reflects the raw ?id= query param unescaped into a text/html response. Reflected-XSS shape on a newly-added, unauthenticated route (this mirrors an existing pattern in html_file(), but it's new attack surface worth escaping while we're here).
  7. Same-slot force-captures silently overwrite each other — the snapshot id is floored to the plan-slot grid, so firing debug_history_force_capture twice within one slot produces the same id and the second storage.save() silently clobbers the first — no log, no dedup/discard path.

Docs no longer match shipped behavior

  1. docs/web-interface.md and docs/customisation.md describe a dropdown snapshot picker on the dashboard Debug panel. The merged code only adds one link, Download all (.tgz) — an earlier commit in this PR replaced the dropdown, docs weren't updated to match.
  2. docs/predbat-plan-card.md still says snapshots are matched "nearest" to a slot and labelled by "how many snapshots ago" — the shipped JS does exact floored-slot matching and labels with absolute time instead.

Efficiency

  1. create_debug_yaml() runs twice per cycle when debug_enable is on and a capture is due — update_pred() calls it directly, then _capture_debug_history() calls it again internally.
  2. html_debug_history_download's id=latest path does two full index round-trips (see solcast entity nomenclature  #4).
  3. load_all_snapshots awaits snapshot bodies sequentially instead of asyncio.gather (already used elsewhere, e.g. annual_job.py).
  4. The bulk .tgz route buffers every snapshot body plus the full archive in memory rather than streaming — worth a thought given Predbat commonly runs on Pi-class hardware.
  5. findNearestDebugSnapshot does an O(rows × snapshots) linear scan with repeated Date parsing per row, on every 5s poll of the Yesterday view — could build a Map once when debugHistoryData is fetched.
  6. The plan table renders once synchronously, then again after loadDebugHistoryData() resolves — a visible flicker on first entry to the History/Yesterday view.

Reuse / duplication

  1. web.py's new _storage() helper duplicates ComponentBase.storage, which WebInterface already inherits — could just call self.storage.
  2. debug_history.py's ring-buffer (list/discard/prune) logic duplicates annual_store.py's equivalent rather than sharing it, and has already diverged (dedup + max_age only added to one side).
  3. The plan-slot flooring math in _capture_debug_history() re-derives from scratch what output.py already computes via self.minutes_now.

Simplification

  1. annotate_steps_back()/steps_back is computed and shipped on every /debug_history_list response, but the final JS only reads .id/.timestamp — looks like a leftover from an earlier design (before the move from "N steps back" labels to absolute time).
  2. Three near-identical prune loops (same-minute dedup, max_age, max_count) could collapse into one parametrized helper.

Test coverage

Per this repo's usual bar, a couple of gaps:

  1. The three new web.py routes and _storage() have no test coverage — tests/test_web_annual.py has an established pattern for this (build a FakeRequest, call the handler directly) that isn't applied here.
  2. The new client-side JS (findNearestDebugSnapshot, loadDebugHistoryData, the Debug column rendering) is untested — tests/test_plan_why_reason.py has a precedent for testing this kind of embedded JS via a Python mirror of the function.

Minor

  1. Dead .cspell dictionary entry (onfocus) left over from the removed dropdown markup.
  2. Same-minute dedup keys on local-time strings, so a UK DST fall-back's repeated HH:MM could in theory collide and silently drop a real, distinct capture.

Most worth fixing before merge: #1#4 and #6 (correctness/security), #8#9 (docs actively mislead users right now), and #21#22 (test coverage). The rest is good-to-have cleanup, happy to see it split into a follow-up if you'd rather keep this PR focused.

@springfall2008

Copy link
Copy Markdown
Owner

Suggestion: split debug_enable's file-write behavior from this feature, and give the rotating buffer its own switch

Two related follow-ups worth doing in this same PR while we're touching this area:

1. debug_enable still causes unbounded disk growth — the original problem isn't actually fixed yet

predbat.py's if self.debug_enable: self.create_debug_yaml() (still present, right next to the new _capture_debug_history() call) writes a brand-new predbat_debug_HH_MM_SS.yaml straight to config_root via raw os.makedirs/open() every ~5-minute cycle, with no cap and no pruning, whenever a user leaves the switch on. That's exactly the "unbounded disk growth if left on" issue this PR's own commit message cites as the motivation for the rotating buffer — but this PR adds the new bounded buffer alongside it rather than replacing it, so the original bug ships unchanged.

Suggest removing that call site entirely. Nothing is lost:

  • The /debug_yaml route already serves the same content on demand without writing to disk (write_file=False).
  • The new rotating buffer now covers "what was Predbat doing recently" automatically.

debug_enable's other ~40 usages (verbose logging in plan.py/execute.py/octopus.py, and the cache/kernel-reuse bypass in prediction.py/prediction_kernel.py for more accurate step-by-step debugging) are a separate concern and should stay as-is — only the disk-write coupling should go.

2. Give the rotating buffer its own on/off switch, rather than overloading debug_history_count == 0

Right now the feature's only off-switch is setting the debug_history_count input_number to 0. That's workable but inconsistent with how this codebase normally does on/off — debug_enable and debug_history_force_capture are both real switch CONFIG_ITEMS. Suggest adding switch.predbat_debug_history_enable (defaulting either way, your call) so "is this feature on" and "how many snapshots do I keep" are independent settings, matching the existing pattern instead of a magic-zero convention.

chalfontchubby added a commit that referenced this pull request Aug 9, 2026
…ded raw disk writes

Per @springfall2008's review on #4438: "debug_enable still causes
unbounded disk growth - the original problem isn't actually fixed yet."
The rolling debug-history buffer (#4417) shipped alongside the original
raw create_debug_yaml() disk write, not in place of it, so leaving the
switch on still wrote an uncapped predbat_debug_HH_MM_SS.yaml every
~5-minute cycle.

Rather than remove the raw write outright: debug_enable also gates
verbose logging and the C++ kernel bypass (the more accurate but far
slower prediction path, see #4453's investigation), both genuinely useful
for watching a live issue develop cycle to cycle - finer-grained than the
history buffer's coarsest 1-hour interval floor (debug_history_interval's
min is 1, unit is hours). Removing the write entirely would be a real
capability loss for exactly the kind of live debugging this session has
been doing.

Instead, track when debug_enable was turned on (debug_enable_started) and
auto-disable it (expose_config, so it's visible in HA too) after
DEBUG_ENABLE_MAX_HOURS (2h). Bounds both the disk growth and the standing
performance cost of leaving the slow path on by accident, while preserving
full-resolution capture for someone actively watching a problem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Aug 9, 2026
…imum raised to 1

Per @springfall2008's review on #4438: "give the rotating buffer its own
on/off switch, rather than overloading debug_history_count == 0" -
inconsistent with how the codebase normally does on/off (debug_enable and
debug_history_force_capture are both real switch CONFIG_ITEMS already).

Defaults on, preserving existing behaviour for anyone already relying on
debug_history_count=15 as "on". debug_history_force_capture still fires
regardless of this switch, matching the existing "explicit capture now
request is a different intent to the rolling background history" design.

Also raises debug_history_count's config-schema minimum from 0 to 1 - with
a dedicated switch, count=0 no longer needs to double as an off-switch, so
removing it as a valid value avoids "switch is on but count is 0" becoming
a second, silently-conflicting way to end up disabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Aug 9, 2026
…on genuine success

Per @springfall2008's review on #4438 (items 1-3):

1. debug_history_last_capture was reset unconditionally after the
   try/except, so a failed capture (an exception, caught and logged as a
   Warning) was silently treated as if it had succeeded - deferring the
   next routine retry a full debug_history_interval instead of leaving it
   to retry at the normal cadence.
2. The storage-unavailable path returned before that reset, so it was
   never throttled at all - every ~5-minute cycle re-entered and re-logged
   the same warning indefinitely. Fixed with a separate
   debug_history_storage_warned timestamp, deliberately independent of the
   capture throttle, so a later genuine capture attempt is never skipped
   just because the warning was recently logged.
3. debug_history_force_capture reset on trigger, before storage was even
   checked or a capture attempted - contradicting docs/customisation.md's
   "Predbat resets the switch back off itself once the snapshot has been
   taken." A failed forced capture now leaves the switch on, so it retries
   every cycle until it genuinely succeeds (or the switch is turned off),
   rather than the request being silently swallowed by an early reset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Aug 9, 2026
…hot id

Per @springfall2008's review on #4438:

Item 4 (race condition): html_debug_history_download's id=latest case
called load_snapshot(storage, "latest") - which resolves "latest"
internally but discards the resolved id - then called list_snapshots() a
second, independent time just to rebuild the filename. A capture landing
between the two calls could serve one snapshot's bytes under a different
snapshot's filename. Added resolve_and_load_snapshot(), returning
(resolved_id, text) from a single resolution, and used it in the route so
the id used for the filename is always the same one the data actually
came from. load_snapshot() now delegates to it, unchanged for existing
callers.

Item 6 (reflected XSS): "Snapshot {} not found".format(snapshot_id)
reflected the raw ?id= query param unescaped into a text/html response.
Escaped via html.escape(), matching the existing pattern used elsewhere
in web.py (e.g. the plan-log highlighting).

Item 21 (test coverage): the three debug-history web routes had zero
tests. Added test_web_debug_history_routes.py covering all three
(list/download/download-all) against a real StorageLocalFiles backend,
including the specific race (item 4) and XSS (item 6) fixes above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Aug 9, 2026
…havior

Per @springfall2008's review on #4438 (items 8-9): docs described the
dropdown snapshot picker and "nearest"/"how many snapshots ago" matching
from an earlier design in this PR, replaced during development by a
single "Download all (.tgz)" link and exact floored-slot matching
(confirmed against the shipped JS - findNearestDebugSnapshot's tolerance
is 1000ms, guarding only sub-second formatting noise, not a real nearest-
within-a-window search; labels use absolute local time, not "N ago").

Updated docs/web-interface.md, docs/predbat-plan-card.md, and
docs/customisation.md to describe what actually ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Aug 9, 2026
Per @springfall2008's review on #4438 (item 22): findNearestDebugSnapshot,
loadDebugHistoryData, and the plan table's Debug column were untested.
Follows test_plan_why_reason.py's established precedent for this kind of
embedded-JS test - assert on the JS source text's structure directly
(there's no JS engine in this suite), rather than executing it.

Covers: the exact/tight-tolerance (not nearest-of-several) snapshot match,
the fetch being wired to the Yesterday view switch rather than the
frequent plan poll, and the Debug column linking by the matched
snapshot's own id with a real time label - locking in the same shipped
behaviour docs were just corrected to describe.

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

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review - went through it in priority order. Everything flagged as "most worth fixing before merge" is addressed, split into separate commits so each is reviewable on its own:

Correctness (items 1-4, 6)

  • 50d33564 - debug_enable still caused unbounded disk growth even after this PR - the rotating buffer shipped alongside the original raw write, not in place of it. Rather than remove the write outright (it, and the verbose logging/kernel-bypass path it also gates, are genuinely useful at finer resolution than the history buffer's 1-hour floor for someone actively watching a live issue), it now auto-disables after 2 hours if left on.
  • f1de7ed0 - items 1-3: debug_history_last_capture and the force-capture switch now only reset on a genuine successful capture, not unconditionally. A failed attempt no longer silently defers the next routine retry a full interval, the storage-unavailable warning is throttled instead of re-logging every ~5 minutes, and a failed forced capture leaves the switch on to retry rather than resetting before the snapshot was actually taken.
  • 50706e09 - items 4 and 6: added resolve_and_load_snapshot() so id=latest resolves and loads in one call instead of two independent ones (closing the race where a capture landing in between could serve mismatched bytes/filename), and escaped the reflected ?id= query param in the 404 body.

Docs (items 8-9)

  • 60b6af40 - updated web-interface.md, predbat-plan-card.md, customisation.md to describe the single "Download all (.tgz)" link and exact floored-slot matching that actually ships, not the earlier dropdown/nearest-match design.

Tests (items 21-22)

  • 50706e09 also added test_web_debug_history_routes.py (all three routes, including the item 4/6 fixes, against a real StorageLocalFiles backend).
  • 1134c514 added test_debug_history_client_js.py for findNearestDebugSnapshot/loadDebugHistoryData/the Debug column - following test_plan_why_reason.py's existing precedent for this kind of embedded-JS test (asserting on the JS source structure, since there's no JS engine in this suite).

Design suggestions in your second comment

  • Went with your switch.predbat_debug_history_enable suggestion (7e790fc2) - also raised debug_history_count's minimum from 0 to 1, so it can no longer double as a second, conflicting off-switch.
  • Kept the raw debug_enable disk write rather than removing it outright, for the reason in 50d33564 above - open to discussing further if you'd still rather see it gone entirely.

Full suite + pre-commit clean after every commit. The remaining items (#5, #7, #10-20, #23-24) are left as follow-up per your own note that they're good-to-have cleanup rather than blocking - happy to pick any of them up next if you'd like them in this PR instead.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

@springfall2008 i think most/all
Your concerns have been addressed- could you take another look.

@springfall2008

Copy link
Copy Markdown
Owner

Reviewed this — solid implementation, nice test coverage on the storage/pruning paths and the JS structure. Didn't find any functional bugs in the capture/prune/route logic (traced the eviction fallback, the format="text" vs "yaml" handling, and the ?id= download route for injection risk — all fine). Two things worth resolving before merge:

  1. PR description vs. shipped UI don't match. The description says web.py adds "a dropdown on the dashboard's Debug panel" for browsing snapshots, but the diff only adds a single "Download all (.tgz)" link to the status table — there's no per-snapshot picker on the dashboard itself. The only ways to grab one specific older snapshot are: know its ?id= already, use ?id=latest, or find it via the plan's History/Yesterday view's Debug column (which only shows a link when a plan row lands on the exact floored capture slot). Was the dropdown simplified away in an earlier review round and the description just never got updated, or is this a gap worth adding?

  2. The new debug_enable auto-disable-after-2h behaviour isn't documented. docs/customisation.md already has a section on switch.predbat_debug_enable (with a user-recipe HA automation that manually turns it off after 10 minutes for exactly this "left on by accident" reason). This PR adds a built-in DEBUG_ENABLE_MAX_HOURS auto-disable, which is a real user-visible behaviour change for anyone running a longer debug session, but only the new "Debug history" doc subsection was added — the existing debug_enable writeup wasn't touched. Worth a one-line mention there so this doesn't surprise anyone.

Minor nit: in test_debug_history.py, the same-minute-dedup test's rationale comment ("this used to reach the UI as two identically-named chart series and broke 'Deselect all'") looks like it was copied from annual_store.py's equivalent test — debug-history snapshots aren't rendered as chart series anywhere in this PR, so the comment's "why" doesn't actually describe this feature. Not wrong, just a confusing pointer for a future reader.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

I've been nagging Claude to keep the pr description valid on an update, but it's on me that I didn't notice we had drifted.

Deselect all is leakage from a follow on patch that adds a chart plotting Soc predictions for each of the debug snapshots which has helped diagnose drift between evolving plans. That is quite a nerdy chart though (not the only one in predbat perhaps).

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

I'll update the docs in a bit.

chalfontchubby and others added 13 commits August 18, 2026 21:51
…oks (#4417)

Predbat's create_debug_yaml() (what bug reporters attach for a --redo
replay) only ever wrote when switch.predbat_debug_enable was already
on, on every ~5-minute cycle with no pruning at all - unbounded disk
growth if left on, and no history to look back at for anyone who
didn't already have it on before something went wrong.

Adds a rolling buffer instead, mirroring the existing annual_store.py
ring-buffer pattern and the Storage component's save/expiry/cleanup:

- debug_history.py: capture/list/load/prune snapshots, keyed by
  timestamp, independent of debug_enable.
- Three new config items: debug_history_count (default 15, 0 = off),
  debug_history_interval (default 3h; 15x3h matches the History tab's
  worst-case "yesterday + today so far" coverage), and
  debug_history_force_capture - a self-resetting switch so an
  automation that notices something worth investigating can trigger an
  immediate, precisely-timed capture rather than waiting on the
  routine interval, and fetch it via ?id=latest without a separate
  lookup round-trip.
- predbat.py: _capture_debug_history(), throttled in-memory, wired
  alongside the existing debug_enable capture call.
- web.py: /debug_history_list and /debug_history_download routes,
  reusing the existing html_file() attachment-serving helper, plus a
  dropdown on the dashboard Debug panel.
- web_helper.py: a Debug column on the plan's History/Yesterday view
  (only there - its rows are the ones with a real past timestamp to
  match against), fetched and matched client-side by wall-clock time
  rather than threaded through publish_html_plan(), which is shared by
  three other call sites with no relationship to a captured snapshot's
  timing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a "Debug history" subsection to customisation.md covering the
three new config items, documents the dashboard's new History
dropdown in web-interface.md, and adds the plan History view's new
Debug column to predbat-plan-card.md's column reference alongside the
other conditionally-shown columns.

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

Rebuilding the <select>'s options via appendChild doesn't reliably
repaint the closed box's displayed label - confirmed live, it kept
showing the "Click to load..." placeholder text until the dropdown
was manually opened once, even though the options had already
populated correctly underneath. Explicitly setting selectedIndex = 0
after populating forces a real value-change the browser has to
redraw for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ot symmetric

A snapshot captures Predbat's full live state (including its current
plan) at one moment - it can describe a row shortly at or after that
moment, but tells you nothing reliable about a row well after it,
since Predbat replans every cycle and the plan may have moved on by
then. The previous +/-90 minute symmetric window treated a snapshot
25 minutes before a row the same as one 35 minutes after it, so two
rows 30 minutes apart could both point at the same earlier snapshot
as if it still described the later one - confirmed live, dogfooding
this against a real instance.

Replaces it with a small forward grace (30 min, covers a snapshot
landing a little into a row's own slot) plus a much larger backward
window (3.5h, comfortably covers the default 3-hourly interval),
picking the closest snapshot that satisfies that asymmetric bound
rather than the closest by absolute distance in either direction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…steps-back labels

Two related UX problems in the plan History view's Debug column (#4417):

1. Snapshots were captured with a raw self.now_utc timestamp - whatever
   moment the ~5-minute cycle happened to trigger the capture, not aligned
   to the plan's own 30-minute (plan_interval_minutes) grid. The row-to-
   snapshot matching in web_helper.py therefore needed a fuzzy
   nearest-within-a-window search, which let several adjacent rows all
   claim the same snapshot - not the one row it most neatly corresponds to.

2. The dropdown and the plan column link both labelled a snapshot by how
   many captures back it was ("3 steps back") rather than its actual time.

Fixed both together: _capture_debug_history() now floors the capture
timestamp to self.midnight_utc + N * plan_interval_minutes - the exact same
anchor and step output.py uses to build every plan row's own row.time. A
snapshot's timestamp is then either an exact match for one row or it isn't
a match at all, which also gives each snapshot at most one owning row for
free (two rows plan_interval_minutes apart can never both equal the same
floored capture instant). findNearestDebugSnapshot() simplified from an
asymmetric forward-grace/backward-window search down to a direct timestamp
comparison (1s tolerance, only for formatting noise). The throttle-tracking
timestamp (debug_history_last_capture) deliberately still uses the real
capture moment, not the floored one, so routine-interval spacing tracks
actual elapsed time regardless of how the snapshot itself gets labelled.

Both the dashboard dropdown and the plan column link now show the
snapshot's absolute time instead of "N steps back" - the dropdown already
had the absolute time alongside the steps-back suffix, so that part is
just dropping the suffix; the plan column link switches from "N back" to a
short local time. debug_history.py's annotate_steps_back()/steps_back
field is untouched - still useful for API consumers, just no longer the
primary UI label.

Added a dedicated regression test for the slot-flooring behaviour
(debug_history_capture_alignment) and fixed an existing test
(test 6b in debug_history_capture) that advanced now_utc by only 1 minute
between two forced captures - previously enough for a distinct snapshot id,
now floors back to the same slot and would collide.

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

Chasing a user through the per-snapshot picker to find the right moment is
exactly the back-and-forth this buffer was meant to avoid. One link now
downloads every retained snapshot as a gzip tarball; the plan page's own
History/Yesterday view (a separate consumer of the same snapshot index) is
untouched.

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

steps_back is the snapshot's current position in the retention ring, not a
property of the snapshot itself - it shifts as newer captures push it back.
Baking it into the filename meant the same real capture got a different name
in every archive downloaded after it, making archives from different times
impossible to merge/deduplicate by filename. snapshot_id (its capture
timestamp) is already unique and stable; that's all the filename needs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tention window

Two captures landing in the same calendar minute (a routine capture and a
close-by force-capture) used to both stay in the ring, reaching the UI as
two identically-named chart series and breaking "Deselect all", which
keys off series name. capture_snapshot() now discards the older of any
same-minute pair, keeping the newer.

Also add an optional max_age prune so a burst of close-together captures
can't leave something far older than the buffer's intended window sitting
there just because the count cap alone hasn't caught up to it - wired
through from predbat.py as interval_hours * count.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ded raw disk writes

Per @springfall2008's review on #4438: "debug_enable still causes
unbounded disk growth - the original problem isn't actually fixed yet."
The rolling debug-history buffer (#4417) shipped alongside the original
raw create_debug_yaml() disk write, not in place of it, so leaving the
switch on still wrote an uncapped predbat_debug_HH_MM_SS.yaml every
~5-minute cycle.

Rather than remove the raw write outright: debug_enable also gates
verbose logging and the C++ kernel bypass (the more accurate but far
slower prediction path, see #4453's investigation), both genuinely useful
for watching a live issue develop cycle to cycle - finer-grained than the
history buffer's coarsest 1-hour interval floor (debug_history_interval's
min is 1, unit is hours). Removing the write entirely would be a real
capability loss for exactly the kind of live debugging this session has
been doing.

Instead, track when debug_enable was turned on (debug_enable_started) and
auto-disable it (expose_config, so it's visible in HA too) after
DEBUG_ENABLE_MAX_HOURS (2h). Bounds both the disk growth and the standing
performance cost of leaving the slow path on by accident, while preserving
full-resolution capture for someone actively watching a problem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…imum raised to 1

Per @springfall2008's review on #4438: "give the rotating buffer its own
on/off switch, rather than overloading debug_history_count == 0" -
inconsistent with how the codebase normally does on/off (debug_enable and
debug_history_force_capture are both real switch CONFIG_ITEMS already).

Defaults on, preserving existing behaviour for anyone already relying on
debug_history_count=15 as "on". debug_history_force_capture still fires
regardless of this switch, matching the existing "explicit capture now
request is a different intent to the rolling background history" design.

Also raises debug_history_count's config-schema minimum from 0 to 1 - with
a dedicated switch, count=0 no longer needs to double as an off-switch, so
removing it as a valid value avoids "switch is on but count is 0" becoming
a second, silently-conflicting way to end up disabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…on genuine success

Per @springfall2008's review on #4438 (items 1-3):

1. debug_history_last_capture was reset unconditionally after the
   try/except, so a failed capture (an exception, caught and logged as a
   Warning) was silently treated as if it had succeeded - deferring the
   next routine retry a full debug_history_interval instead of leaving it
   to retry at the normal cadence.
2. The storage-unavailable path returned before that reset, so it was
   never throttled at all - every ~5-minute cycle re-entered and re-logged
   the same warning indefinitely. Fixed with a separate
   debug_history_storage_warned timestamp, deliberately independent of the
   capture throttle, so a later genuine capture attempt is never skipped
   just because the warning was recently logged.
3. debug_history_force_capture reset on trigger, before storage was even
   checked or a capture attempted - contradicting docs/customisation.md's
   "Predbat resets the switch back off itself once the snapshot has been
   taken." A failed forced capture now leaves the switch on, so it retries
   every cycle until it genuinely succeeds (or the switch is turned off),
   rather than the request being silently swallowed by an early reset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hot id

Per @springfall2008's review on #4438:

Item 4 (race condition): html_debug_history_download's id=latest case
called load_snapshot(storage, "latest") - which resolves "latest"
internally but discards the resolved id - then called list_snapshots() a
second, independent time just to rebuild the filename. A capture landing
between the two calls could serve one snapshot's bytes under a different
snapshot's filename. Added resolve_and_load_snapshot(), returning
(resolved_id, text) from a single resolution, and used it in the route so
the id used for the filename is always the same one the data actually
came from. load_snapshot() now delegates to it, unchanged for existing
callers.

Item 6 (reflected XSS): "Snapshot {} not found".format(snapshot_id)
reflected the raw ?id= query param unescaped into a text/html response.
Escaped via html.escape(), matching the existing pattern used elsewhere
in web.py (e.g. the plan-log highlighting).

Item 21 (test coverage): the three debug-history web routes had zero
tests. Added test_web_debug_history_routes.py covering all three
(list/download/download-all) against a real StorageLocalFiles backend,
including the specific race (item 4) and XSS (item 6) fixes above.

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

Per @springfall2008's review on #4438 (items 8-9): docs described the
dropdown snapshot picker and "nearest"/"how many snapshots ago" matching
from an earlier design in this PR, replaced during development by a
single "Download all (.tgz)" link and exact floored-slot matching
(confirmed against the shipped JS - findNearestDebugSnapshot's tolerance
is 1000ms, guarding only sub-second formatting noise, not a real nearest-
within-a-window search; labels use absolute local time, not "N ago").

Updated docs/web-interface.md, docs/predbat-plan-card.md, and
docs/customisation.md to describe what actually ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chalfontchubby and others added 2 commits August 18, 2026 21:52
Per @springfall2008's review on #4438 (item 22): findNearestDebugSnapshot,
loadDebugHistoryData, and the plan table's Debug column were untested.
Follows test_plan_why_reason.py's established precedent for this kind of
embedded-JS test - assert on the JS source text's structure directly
(there's no JS engine in this suite), rather than executing it.

Covers: the exact/tight-tolerance (not nearest-of-several) snapshot match,
the fetch being wired to the Yesterday view switch rather than the
frequent plan poll, and the Debug column linking by the matched
snapshot's own id with a real time label - locking in the same shipped
behaviour docs were just corrected to describe.

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

- docs/customisation.md: mention the 2-hour auto-disable next to the
  existing switch.predbat_debug_enable writeup (review comment on #4438).
- test_debug_history.py: same-minute-dedup test comment referenced a
  "Deselect all" chart-series regression that belongs to a different,
  unmerged branch - reworded to describe what this PR actually guards
  against (id/filename collision on the floored-to-minute snapshot id).
@chalfontchubby
chalfontchubby force-pushed the feat/rolling-debug-history branch from 1134c51 to adffcc1 Compare August 18, 2026 20:59
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Docs updated - customisation.md now mentions the 2-hour debug_enable auto-disable next to the existing writeup, and the same-minute-dedup test comment in test_debug_history.py no longer references the unrelated "Deselect all" chart-series issue.

Also rebased onto current main to clear the merge conflicts - branch is clean and CI's running now.

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.

Rolling debug-snapshot buffer with configurable interval/count, and a download UI

2 participants