Skip to content

DEV-896 Fix Live Data plot stutter: O(1) monotonic-X trace bounds, batched chart locking - #280

Open
jyong15 wants to merge 9 commits into
masterfrom
DEV-896
Open

jyong15 wants to merge 9 commits into
masterfrom
DEV-896

Conversation

@jyong15

@jyong15 jyong15 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes the stuttering/janky Live Data plots (worst at UHD) at their root causes in the jchart2d-based plotting layer. Diagnosis was verified against the actual jchart2d bytecode the build resolves (3.3.2 — note the libs/ folder carries a stale 3.2.2 jar):

  • Trace2DLtd.addPointInternal() evicts the oldest ring-buffer point once full, and when the evicted point holds an extreme it runs a full linear rescan of the buffer (minXSearch() etc.). For time-series data X is monotonically increasing, so the evicted point always holds min-X → an O(buffer) scan on essentially every sample in steady state.
  • That work happens inside ATrace2D.addPoint() while holding synchronized(chart) — the same monitor Chart2D.paintComponent() needs — so the data thread starves the Swing EDT, and the per-sample lock churn multiplies it.

Changes (ShimmerDriverPC)

New Trace2DLtdMonotonicX (extends Trace2DLtd) — overrides addPointInternal and minXSearch/maxXSearch. When the ring buffer is provably sorted ascending by X (tracked via the run of consecutive non-decreasing-X insertions ≥ live buffer size), min/max X are answered in O(1) from the buffer's oldest/youngest elements. Anything that breaks the guarantee — backward X (rewind/replay), NaN-X discontinuity markers, in-place point mutation (STATE_CHANGED), buffer resize — falls back to the exact superclass scan until a full buffer of monotonic samples restores it. Y-range maintenance is deliberately left to the superclass (already amortized O(1) for random Y). Swapped in at the three live-plot construction sites in BasicPlotManagerPC; existing (Trace2DLtd) casts and the downsampler's runtime setMaxSize() calls are unaffected.

Batched chart locking (BasicPlotManagerPC)filterDataAndPlot's multi-trace loop now acquires the chart monitor once per sample instead of once per trace-point (Java monitors are reentrant, so the library's internal per-point lock becomes free), and a new addPointsToTrace(trace, x[], y[], from, to) adds FFT bins in bounded 256-point chunks per lock acquisition. The trace list is snapshotted before entering the chart monitor to preserve the app's existing mListofTraces → chart lock ordering (see review notes below).

Cross-repo note

Shimmer-Advance-API's ShimmerDriverAdvancedPC includes :ShimmerDriverPC from source in its settings.gradle, so Consensys picks this up with no changes on that side (verified compiling). ConsensysApps is not involved.

Testing

  • gradle compileJava: ShimmerDriverPC and ShimmerDriverAdvancedPC (Shimmer-Advance-API, building against these sources) both pass.
  • Standalone correctness harness driving Trace2DLtdMonotonicX vs stock Trace2DLtd through a headless Chart2D: monotonic streaming past eviction, setMaxSize shrink+grow mid-stream, rewind/replay, repeated equal X — 11,596 min/max checks, 0 mismatches.
  • Needs real-hardware verification with live streaming (especially at UHD/4K): plots should scroll smoothly, UI should stay responsive while streaming, axis auto-scaling should look identical, and clearing/resizing traces mid-stream should behave as before.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JjgNkS6wtPkQUdK3KdHVdN


Generated by Claude Code

claude added 3 commits July 16, 2026 12:12
Live time-series plots stutter because Trace2DLtd rescans the whole ring
buffer (minXSearch) on every eviction: with strictly increasing X the
evicted oldest point always holds minX, so this runs every sample, and it
happens under synchronized(chart) - the monitor Chart2D.paintComponent()
needs - starving the Swing EDT.

Trace2DLtdMonotonicX overrides minXSearch/maxXSearch to O(1) (oldest/youngest
ring element) whenever the buffer is known sorted ascending by X, tracking a
non-decreasing-X run length so it falls back to the exact superclass scan on
rewind/replay. Y is left to the superclass (already amortized O(1)). Swap the
three live-plot construction sites in BasicPlotManagerPC; existing
(Trace2DLtd) casts still work as it extends Trace2DLtd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JjgNkS6wtPkQUdK3KdHVdN
Each ATrace2D.addPoint() takes synchronized(chart) - the same monitor
Chart2D.paintComponent() holds - so plotting a multi-channel sample or an
FFT frame grabbed the monitor once per point, repeatedly locking out the EDT.

filterDataAndPlot now wraps its per-sample multi-trace loop in a single
synchronized(mChart) (monitors are reentrant, so addPoint's inner lock is
free); the hold is bounded by the trace count. Add addPointsToTrace(...) that
adds many points to one trace under one lock in bounded 256-point chunks, and
use it for the FFT bin loop. Event-marker, downsampling and per-sample
filtering semantics are unchanged; BasicProcessWithCallBack is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JjgNkS6wtPkQUdK3KdHVdN
…harden monotonic trace

- filterDataAndPlot accessed mListofTraces (size/get) while holding the new
  chart monitor (chart -> list order), while clearAllDataBuffer and the
  trace-resize path hold the mListofTraces monitor while calling
  chart-locking trace mutators (list -> chart). Snapshot the trace list
  before entering the chart monitor so the cycle cannot form.
- Trace2DLtdMonotonicX: NaN X (jchart2d discontinuation marker) now
  contributes nothing to the ascending run instead of being absorbed by the
  first-point guard; in-place point mutation (STATE_CHANGED) resets the run
  so the class stays a safe drop-in for Trace2DLtd; tracking fields made
  volatile so the unlocked conservative resets cannot tear and spuriously
  enable the fast path; corrected the setMaxSize-grow Javadoc reasoning.

Correctness test: 11596 checks vs stock Trace2DLtd, 0 mismatches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JjgNkS6wtPkQUdK3KdHVdN

jyong15 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial review summary (automated reviewer pass over this changeset, verified against decompiled jchart2d 3.3.2 bytecode)

Verified sound:

  • The run ≥ size "provably sorted" invariant is airtight: a ring buffer holds exactly the last size() insertions, so after any out-of-order X the fast path stays off until a full buffer of fresh monotonic samples has evicted the offending point — the exact superclass scan covers the interim. setMaxSize shrink keeps only the youngest (sorted) elements; grow leaves elements unchanged; both hold the invariant.
  • minXSearch/maxXSearch callers were enumerated (eviction, firePointChanged REMOVED/CHANGED, removePoint, setMaxSize) and the fast-path answer is correct or safely disabled in each context; equal-X ties are fine; tracking fields are only mutated under the chart+trace locks (plus conservative volatile resets).
  • Lock ordering: the library exclusively takes chart→trace (checked addPoint, removeAllPoints, setMaxSize, property setters, firePointChanged); the new outer chart lock preserves that order.
  • The wrapped loop is byte-identical logic (git diff -w clean); the FFT batch reproduces the original point order and start-bin offset exactly.

Critical finding, fixed before this PR was opened: the first version of the batching held the chart monitor while calling mListofTraces.size()/.get() (chart→list order), while clearAllDataBuffer() and the trace-resize path hold the mListofTraces monitor while calling chart-locking trace mutators (list→chart). A user clearing/resizing plots mid-stream could deadlock the EDT and data thread permanently. Fixed by snapshotting the trace list before entering the chart monitor.

Minor hardening also applied: NaN-X discontinuity markers now reset the ascending run (previously absorbed by the first-point guard); in-place point mutation (STATE_CHANGED) resets the run so the class remains a safe drop-in even for usage patterns the app doesn't currently have; tracking fields made volatile so a torn 64-bit write can never spuriously enable the fast path.

Notes, no action needed:

  • The lock trade is churn→duration by design: the chart monitor is now held across one multi-trace pass (bounded by trace count) or one 256-point FFT chunk.
  • filterDataAndPlotList and filterDataAndPlotBasic are secondary paths that still add points per-sample; same batching treatment could be applied later if those paths matter.

Generated by Claude Code

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q29AEJZbwa7aVYeAj61Py
…den snapshot indexing

Review follow-ups on the batched chart locking in filterDataAndPlot:

- Swing under the chart monitor: updateHrPanelIfVisible() is overridden
  downstream (Consensys PlotManagerPC -> setPnlHR -> setLblHRValue) to do
  JLabel setText/revalidate/repaint from the data thread. Calling it inside
  synchronized(chart) meant the data thread took chart -> Swing tree /
  RepaintManager locks while the EDT takes tree lock -> chart inside
  Chart2D.paintComponent(): a lock inversion. The props of each trace needing
  an HR update are now recorded under the monitor and updateHrPanelIfVisible()
  is called after it is released - same args, same number of calls (one per
  matching trace), same order.

- Console I/O under the monitor: throwExceptionSignalNotFound() (which dumps
  the whole ObjectCluster per missing signal) and printSignalProps() are now
  also recorded and replayed after the monitor is released, so a blocking
  console write can no longer stall the EDT. Debug output content is
  unchanged: printSignalProps() gained an overload taking an already-read
  trace size, so the size is still sampled at the original point in the loop.

- The deferral buffers are reused instance fields, not per-sample
  allocations, and are only touched while the mListofPropertiestoPlot monitor
  is held (which the method holds for the whole batch). They are cleared at
  the start of each batch so a throw out of the loop cannot leak entries.

- Off-by-one: the trace bounds check was 'indexOfTrace > length' (pre-existing
  as '> mListofTraces.size()'); index == length is already out of bounds, so
  it is now '>='.

- Snapshot staleness: the trace array is snapshotted before entering the chart
  monitor (to preserve the mListofTraces -> chart lock ordering), so a trace
  removed mid-sample can still be in it. Added a cheap null / getRenderer()
  guard. A full "still attached" check is not viable here: jchart2d 3.3.2's
  Chart2D.removeTrace() does not clear the trace renderer and Chart2D
  .getTraces() builds a fresh TreeSet per call.

- The per-sample toArray() at the snapshot is left as is (documented): any
  reuse would need the mListofTraces monitor at exactly the point where not
  taking it is the whole reason for the snapshot.

Lock ordering re-verified: nothing inside synchronized(chartMonitor)
synchronizes on mListofTraces or calls into Swing. addPointsToTrace's chunked
chart lock does neither either.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q29AEJZbwa7aVYeAj61Py
Copilot AI lite review requested due to automatic review settings September 4, 2026 06:52

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

The deferred replay path can dereference mChart when it may be null (explicitly supported by the new chartMonitor fallback), risking a NullPointerException.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR targets Live Data plot stutter in the PC plotting layer (jchart2d) by avoiding per-sample O(n) X-bound rescans for monotonic time-series data and by reducing lock contention between the data thread and the Swing EDT.

Changes:

  • Introduces Trace2DLtdMonotonicX, a Trace2DLtd subclass that provides O(1) min/max X when the ring buffer is provably monotonic in X.
  • Switches live plot trace construction in BasicPlotManagerPC to use Trace2DLtdMonotonicX.
  • Batches chart-monitor locking in filterDataAndPlot(...) and adds a chunked addPointsToTrace(...) helper for FFT bin plotting; defers Swing/console work until after releasing the chart lock.
File summaries
File Description
ShimmerDriverPC/src/main/java/com/shimmerresearch/guiUtilities/plot/Trace2DLtdMonotonicX.java Adds a monotonic-X optimized Trace2DLtd variant to eliminate redundant min/max X rescans.
ShimmerDriverPC/src/main/java/com/shimmerresearch/guiUtilities/plot/BasicPlotManagerPC.java Adopts the new trace type and batches chart locking + defers I/O/Swing work to reduce EDT starvation.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

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

…dy fast-path edge cases

Round-2 review follow-ups.

- Deferred work was dropped on the "Trace does not exist" throw: that throw is
  inside synchronized(chartMonitor) while the replay ran after the block, so
  the HR updates and console dumps already recorded for earlier traces in the
  sample were lost (they used to run inline, and the downstream Consensys HR
  override counts calls, so a dropped call is observable). The loop's
  exception is now stashed, the replay runs, and the exception is rethrown
  afterwards. Replay therefore happens on every exit path.

- Bounded the chart-monitor hold. Holding it across every trace of a sample
  made the worst-case EDT wait N_traces x O(buffer), because
  Trace2DLtd.addPointInternal still runs a full minYSearch/maxYSearch whenever
  the evicted point held the Y extreme - which for monotone or drifting Y
  channels (battery, temperature, GSR baseline, counters) is essentially every
  sample. The monitor is now released and re-acquired every TRACE_BATCH_MAX
  (8) traces, alongside the existing POINT_BATCH_MAX. The trace snapshot is
  still taken once, before the first acquisition, so the mListofTraces -> chart
  ordering is unchanged. Trace2DLtdMonotonicX's javadoc no longer implies the
  Y rescan is a non-issue; it now says Y is left to the superclass and that
  monotone Y channels still pay it.

- Trace2DLtdMonotonicX: the O(1) X fast path returned before stock
  minXSearch/maxXSearch's trailing expandMinXErrorBarBounds/
  expandMaxXErrorBarBounds, so an installed error bar policy would have been
  ignored. Both overrides now delegate wholly to the superclass when
  getErrorBarPolicies() is non-empty, and the "behaves identically to
  Trace2DLtd" wording is replaced by a description of that opt-out.

- Dropped the getRenderer()==null snapshot-staleness guard. It could not
  detect staleness (Chart2D.removeTrace does not clear the renderer) and what
  it did catch was a never-attached trace, silently swallowing jchart2d's
  IllegalStateException and skipping the mCurrentXValue update. Only a plain
  null-check on the trace remains, with an accurate comment.

- Replay now uses a single ordered list of DeferredPlotAction records instead
  of one list per kind, so the original per-trace interleaving is preserved -
  the two console kinds share System.out, so grouping by kind reordered debug
  output. The stale "the three kinds are independent" comment is gone.

- The deferral buffers are method-local again. As instance fields they relied
  on mListofPropertiestoPlot being a stable monitor, but it is a public
  non-final field that AbstractPlotManager's constructors reassign. The
  per-sample allocation is dwarfed by the mListofTraces.toArray() snapshot the
  batch already needs, and the "reused rather than allocated per sample"
  comment is corrected.

- HR recording stays unconditional: no HR-enabled flag exists in the base
  class (only the Consensys override knows mIsHRVisible), and that override
  counts calls, so it must stay one recorded action per matching trace. The
  comment now says so.

Re-verified: nothing inside synchronized(chartMonitor) touches mListofTraces
or calls Swing, the snapshot precedes the first acquisition, and the replay
runs on all exit paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q29AEJZbwa7aVYeAj61Py
Copilot AI review requested due to automatic review settings September 4, 2026 07:12

jyong15 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial review — round 2 (after merging master and addressing round-1 findings)

Branch was merged with master (b8fc2d8, clean), round-1 findings were addressed in a1c5e09, and an independent adversarial pass against the decompiled jchart2d 3.3.2 bytecode (the version ShimmerDriverPC/build.gradle resolves) produced the findings below, all addressed in f436ff0.

Verified sound

  • Trace2DLtdMonotonicX is correct. Trace2DLtd.addPointInternal adds to the ring buffer before running minXSearch/maxXSearch, so the fast path's getOldest()/getYoungest() see post-add state. RingBufferArrayFast.size() is the live element count, so run >= size() compares against the right number. The invariant "the buffer holds the most recent size() insertions" survives every mutation: add evicts only the oldest, setBufferSize drops the oldest on shrink and preserves order on grow, Trace2DLtd.removePointInternal is literally return null, and removeAllPoints disables the fast path. The NaN-X reset lands exactly on the insertion that evicts the NaN marker.
  • The batched lock is the right monitor in the right order. ATrace2D.addPoint locks m_renderer then this; every trace in mListofTraces is attached to mChart at insertion, so the outer chart lock makes the inner per-point locks reentrant. The pre-batch toArray takes the mListofTraces monitor before the chart monitor, matching the existing mListofTraces → chart order. Every call inside the chart monitor was enumerated: none reaches mListofTraces, Swing, or the AWT tree lock.
  • HR deferral is behaviour-preserving: one call per matching trace, same order, same args, so the Consensys HR counter window and setPnlHR output are unchanged.
  • No downstream breakage: nobody overrides printSignalProps; both PlotManagerPC overrides are still called; ConsensysApps' super.addPointToTrace(...) signature is untouched. The stale jchart2d-3.2.2 jar in Consensys_lib exposes the same protected members, so the subclass stays binary-compatible if that jar wins at runtime.

Findings (addressed in f436ff0)

  1. Medium — deferred work was dropped when the loop threw. The "Trace does not exist" throw sat inside the chart monitor while the replay loops came after it, so on that path earlier traces' HR updates and console dumps were lost. Now the batch is wrapped so replay runs on every exit path and the exception is rethrown afterwards.
  2. Medium — only the X axis was fixed, and batching lengthened the Y worst case. addPointInternal runs the identical min/maxYSearch rescan on eviction, and for monotone or drifting Y channels (battery, temperature, GSR baseline, counters) the evicted oldest point holds the Y extreme on essentially every sample. Holding the lock across all traces made the EDT's worst-case wait N_traces × O(buffer). The chart monitor is now released every TRACE_BATCH_MAX = 8 traces, and the javadoc no longer claims Y is cheap.
  3. Low — the fast path skipped expand{Min,Max}XErrorBarBounds(). Now delegates wholly to super when any IErrorBarPolicy is registered (none is in these repos today).
  4. Low — the getRenderer()==null staleness guard was dead code that would have silently swallowed jchart2d's IllegalStateException for a never-attached trace. Removed; only a null-check remains.
  5. Low — deferred console output was regrouped by kind. Now a single ordered list replays in the original interleaving.
  6. Low — the shared deferral buffers relied on mListofPropertiestoPlot being a stable monitor, but it is a public non-final field reassigned in AbstractPlotManager constructors. Buffers are now method-local.
  7. Low — comment/hot-path mismatches corrected. No HR-visibility flag exists in the base class, so HR recording stays unconditional.

Merge + compile

The merged master range (Verisense sensors, Configuration/ShimmerObject, DEV-975 BT module versions, two build.gradle version bumps) is entirely disjoint from the two changed plotting files; the jchart2d dependency is unchanged. ShimmerDriverPC gradle compileJava exits 0 on f436ff0. ShimmerDriverAdvancedPC also compiled clean against SJAA master in this round; an unrelated SensorArduino.java failure seen once earlier did not reproduce.

Still needs the real-hardware UHD streaming check in the PR description, ideally including a drifting-Y channel such as battery to exercise item 2.


Generated by Claude Code

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

There is a confirmed thread-safety issue in the new trace snapshotting logic and a confirmed avoidable allocation/GC cost in the hot path that should be addressed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

…hen not overridden

- filterDataAndPlot() supports mChart == null (the chart-monitor fallback), and
  the deferred replay can run throwExceptionSignalNotFound() or
  printSignalProps() in that state, both of which printed mChart.getName().
  Both now go through getChartNameForPrinting(), which yields "<no chart>"
  when no chart is set. The third replayed action, updateHrPanelIfVisible(),
  is a no-op in this class and the downstream override touches only the props,
  the ObjectCluster and its own labels, so it needs nothing.

- The per-trace deferred HR action is now only recorded when the runtime class
  actually overrides updateHrPanelIfVisible(); in this base class it is a
  no-op, so the record and its allocation were pure waste. Detected once per
  instance by walking the class hierarchy with getDeclaredMethod() (the method
  is protected, so getMethod() would not see it) from the runtime class up to
  BasicPlotManagerPC, cached in a final field. Any unexpected reflective
  failure defaults to true, i.e. to the previous unconditional behaviour.
  Verified: the base class resolves to false and an overriding subclass to
  true, so the Consensys PlotManagerPC path keeps exactly one call per
  matching trace.

- Documented why the mListofTraces snapshot needs no explicit
  synchronized(mListofTraces): the field is a Collections.synchronizedList, so
  toArray() already copies under that list's own mutex, and the index
  alignment with mListofPropertiestoPlot that actually matters is protected by
  the mListofPropertiestoPlot monitor held for the whole sample. No code
  change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q29AEJZbwa7aVYeAj61Py
Copilot AI review requested due to automatic review settings September 4, 2026 07:25

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.

🔵 Needs a closer look

It introduces non-trivial concurrency and performance-path changes in the live plotting hot path that require careful human validation (especially under real streaming/EDT load).

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

filterDataAndPlot() allocated an ArrayList for the deferred chart-monitor work
on every sample, even though on the typical sample nothing is recorded into it
(no debug mode, no missing signal, and no updateHrPanelIfVisible() override).
The local now starts null and is created on first record, via a small
recordDeferredPlotAction() helper that returns the list to assign back;
replayDeferredPlotActions() treats null as "nothing recorded" and returns
immediately.

Semantics are unchanged: actions are still appended in the order they are
recorded and replayed in that same order, and the replay still runs on every
exit path from the batched loop, the "Trace does not exist" throw included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q29AEJZbwa7aVYeAj61Py
Copilot AI review requested due to automatic review settings September 4, 2026 07:31

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.

🔵 Needs a closer look

The change materially alters concurrency/locking behavior in a performance-critical Swing plotting path and should receive final human review plus real-hardware validation before approval.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q29AEJZbwa7aVYeAj61Py
Copilot AI review requested due to automatic review settings September 7, 2026 05:38

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.

🔵 Needs a closer look

It changes core live-plot concurrency/locking behavior and trace-bound computations in a UI-critical path and needs final human review plus real-hardware streaming validation to ensure there are no responsiveness or correctness regressions.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

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