diff --git a/.planning/STATE.md b/.planning/STATE.md
index b7e9e12b..162d25a8 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -27,7 +27,7 @@ Phase: — (none active; latest shipped = Phase 1041, MERGED via PR #189 on 2026
Plan: —
Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v3.1 Plant Log Integration — SHIPPED 2026-05-19; v4.0 Multi-User LAN Concurrency — SHIPPED 2026-05 via PR #152 (parallel branch); v1.0 perf line — COMPLETE via PR #114. No milestone in flight.
Status: Phase 1041 complete — inline time-range control (toolbar dropdown + Custom date strip) shipped; PR #189 MERGED 2026-06-03. No planned milestone in flight — repo in polish/housekeeping. Outstanding: 12 wiki-bot dup PRs were closed to 1 (#190) + workflow root-caused (260609-mcz); backlog Phase 999.1 (in-app help system) unplanned; ROADMAP v4.0 boxes stale (shipped on main via #152 — router misreports, see memory gsd-router-stale-v4-misroute).
-Last activity: 2026-06-10 - PR #197 MERGED (dashboard perf pass ~8× idle tick + crash fixes + preview restoration; quick tasks 260609-v5p, 260610-fta, 260610-g0w). Quick task 260610-hwj (review-sweep fixes batch 2: serialization round-trips, gauge MonitorTag construction crash, disk-backed export, listener leaks, marker test helper) shipped via PR #198 from claude/review-fixes-batch2.
+Last activity: 2026-06-10 - Completed quick task 260610-ov3: Optimize data loading speed in populated FastSense dashboards (DashboardEngine) — per-render Tag-data cache in FastSenseWidget (<=1 resolve per render, consume-once through the engine preview pass), O(1) ctor time-range pull, bench_dashboard_load.m. On branch claude/unruffled-gagarin-e2ca3d; MATLAB-only suite runs deferred (MCP down).
### Note on parallel v4.0 work (main branch state)
@@ -106,6 +106,7 @@ Other main PRs (#138, #139, #141, #144, #145, #146) auto-merged without conflict
| 260610-g0w | Perf round 2 (profiler-driven) + latent preview bug: `getPreviewSeries` now derives bucket count from minmax output (260512 bucket-math bumps nb inside the MEX; old exact-shape check silently returned [] → slider previews missing + 0% preview-cache hits in every mex-on-path session, i.e. all test envs + any session that ran add_fastsense_private_path; clean production used the MATLAB fallback and was unaffected). Vectorized getEventMarkers extraction (isprop 8280→280 per 20 ticks); per-class ismethod cache in computeEventMarkers (~6 ms/tick); stale-banner set-skip; TimeRangeSelector isLive_ guards kill 'Invalid or deleted object' mouse-motion spam from chained WindowButtonMotionFcn closures outliving deleted selectors. Profiled idle tick 26.5→22.5 ms with previews now drawing. perf_fixes 10/10, preview_envelope 7/7 (case 6 → adaptive contract), preview_overlay 10/10, range_selector 2/2, time_window 8/8. | 2026-06-10 | e1079c20 | — | [260610-g0w-fix-getpreviewseries-mex-shape-mismatch-](./quick/260610-g0w-fix-getpreviewseries-mex-shape-mismatch-/) |
| 260609-v5p | Speed up DashboardEngine live refresh: data-unchanged fast path in FastSenseWidget.update()/refresh() (fingerprint [n,x1,xend,yend], same append-only contract as PreviewCacheKey_) skips updateData/preview-invalidate/formatTimeAxis on idle ticks; single Tag.getXY per tick (updateTimeRangeCache(x) optional arg); refreshEventMarkers_ O(nE²)→O(nE) isequal diff; computeEventMarkers vectorized accumulators + sortrows-based dedup (max-severity-wins preserved, non-finite sev→1); getEventMarkers preallocation + per-unique-severity color lookup; vectorized formatTimeAxis_. New bench_dashboard_live.m (8 Tag-bound widgets × 50k pts + 200 events): idle tick 281→34 ms (~8×), active tick ~50→30 ms. Verified R2025b: test_dashboard_perf_fixes 9/9 (2 new), preview-envelope 7/7, events-toggle 22/22, time-window 8/8, TestDashboardEngine 18/18, TestDashboardEngineEventMarkers 8/8, TestFastSenseWidgetUpdate 2/2, TestFastSenseWidgetEventMarkers 12/12, TestDashboardDirtyFlag 6/6. Known stale test: flat test_dashboard_engine_event_markers case_render assumes one handle per marker — broken since 260508 color-group batching, fails pre- and post-change identically (Octave mirror that self-skips on Octave). | 2026-06-09 | 8cd6443f, c29be759, cbd66937, 98184f36 | — | [260609-v5p-speed-up-dashboardengine-live-refresh-pa](./quick/260609-v5p-speed-up-dashboardengine-live-refresh-pa/) |
| 260610-hwj | Review-sweep fixes batch 2 (branch claude/review-fixes-batch2, separate PR from the perf pass): GaugeWidget.fromStruct restores Threshold (was Tag — threshold coloring dead after load) + constructor probes for allValues() (pre-v2.0-only method; MonitorTag-bound gauges crashed at construction since the migration); GroupWidget round-trips ExpandedHeight (collapsed groups were stuck collapsed after load); central themeOverride backfill in DashboardWidgetRegistry.fromStruct (dropped on load for every widget except GroupWidget); FastSense exportData routes through lineFullData (disk-backed lines exported empty columns); markerXData test helper parses batched NaN-separated marker polylines (stale since 260508). New test_review_fixes_batch2.m 4/4 R2025b / 3/3+gate Octave 11; event_markers 9/9 (first MATLAB pass ever); SerializerRoundTrip 15/15; Serializer 12/12; toolbar 19/19. | 2026-06-10 | 18387785 | — | [260610-hwj-review-sweep-fixes-batch-2-widget-serial](./quick/260610-hwj-review-sweep-fixes-batch-2-widget-serial/) |
+| 260610-ov3 | Optimize data loading speed in populated Tag-bound dashboards (LOAD path; complements 260609-v5p/260610-g0w live-tick passes): render-scoped `RenderDataCache_` in FastSenseWidget collapses 3-4 redundant Tag resolves per render() to <=1 — probe pull seeds the cache, binding (addLine for non-state kinds; State keeps addTag staircase), yInit autoscale, and updateTimeRangeCache all reuse it; consume-once lifetime (warm through DashboardEngine's post-render computePreviewEnvelope pass, cleared on preview read + refresh()/update() entry); ctor `updateTimeRangeCache()` no-arg now uses O(1) Tag.getTimeRange() instead of full getXY (also fixes disk-backed ctor range = inf/-inf). Disk widgets: 2 SQLite range queries/render → 1, and they now contribute slider-preview envelopes at load (previously silently empty). New bench_dashboard_load.m (12×50k pts, 4 disk-backed): Render 6834→6711 ms Octave (graphics-bound; data-load slice halved). Octave 11.1: render_cache 4/4, load_perf 5/5, 13 regression tests green (batch segfault = known teardown flake, isolated runs clean). DEFERRED to live MATLAB: TestDashboardEngine, TestFastSenseWidgetUpdate, TestDashboardSerializerRoundTrip suites. | 2026-06-10 | 3b7535ea | — | [260610-ov3-optimize-data-loading-speed-in-populated](./quick/260610-ov3-optimize-data-loading-speed-in-populated/) |
## Progress Bar
diff --git a/.planning/quick/260610-ov3-optimize-data-loading-speed-in-populated/260610-ov3-PLAN.md b/.planning/quick/260610-ov3-optimize-data-loading-speed-in-populated/260610-ov3-PLAN.md
new file mode 100644
index 00000000..04cc3287
--- /dev/null
+++ b/.planning/quick/260610-ov3-optimize-data-loading-speed-in-populated/260610-ov3-PLAN.md
@@ -0,0 +1,308 @@
+---
+phase: quick-260610-ov3
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - libs/Dashboard/FastSenseWidget.m
+ - benchmarks/bench_dashboard_load.m
+ - tests/test_dashboard_load_perf.m
+autonomous: true
+requirements: [OV3-01, OV3-02]
+must_haves:
+ truths:
+ - "Loading a populated Tag-bound dashboard resolves each widget's Tag data once per render pass, not 3-5 times"
+ - "A benchmark exists that times populated-dashboard load and prints before/after-comparable numbers"
+ - "Existing dashboard scripts and serialized dashboards render identically (backward compatible)"
+ - "The DashboardWidget base-class interface is unchanged (render/refresh/toStruct signatures intact)"
+ artifacts:
+ - path: "libs/Dashboard/FastSenseWidget.m"
+ provides: "Per-render Tag-data cache reused across render/yInit/timeRangeCache/preview"
+ contains: "RenderDataCache_"
+ - path: "benchmarks/bench_dashboard_load.m"
+ provides: "Populated-dashboard load (create+render) benchmark, Tag-bound widgets"
+ contains: "t_render"
+ - path: "tests/test_dashboard_load_perf.m"
+ provides: "Static-verifiable test: cache cuts per-render getXY call count; output is byte-identical"
+ contains: "RenderDataCache_"
+ key_links:
+ - from: "libs/Dashboard/FastSenseWidget.m render()"
+ to: "pullData_ / cachePulledData_"
+ via: "single resolve reused by addLine + yInit + updateTimeRangeCache"
+ pattern: "RenderDataCache_"
+ - from: "libs/Dashboard/FastSenseWidget.m getPreviewSeries()"
+ to: "cached render data"
+ via: "reuse RenderDataCache_ when warm instead of Tag.getXY"
+ pattern: "RenderDataCache_"
+---
+
+
+Optimize data loading speed when a populated FastSense dashboard renders. Profiling the
+render hot path shows each Tag-bound `FastSenseWidget` resolves the SAME Tag data 3-5 times
+during a single `render()` pass. This plan collapses those redundant resolutions into a
+single per-render pull whose result is reused across the binding, Y-autoscale, time-range
+cache, and slider-preview steps — without changing the `DashboardWidget` contract or
+breaking backward compatibility.
+
+Purpose: Cut populated-dashboard load time (dominated by repeated `Tag.getXY()` /
+`getXYRange()` / `getRange()` SQLite calls for disk-backed sensors and repeated first-call
+`resolve()` recompute for derived/composite tags), measured by a new benchmark.
+
+Output: A per-render data cache in `FastSenseWidget`, a `benchmarks/bench_dashboard_load.m`
+load benchmark, and a static-verifiable perf/parity test.
+
+
+
+Read from source before planning (no assumptions):
+
+- `libs/Dashboard/FastSenseWidget.m` `render()` (line ~167): for a Tag-bound widget the SAME
+ data is resolved up to 4× in one render:
+ 1. probe pull `pullData_()` (line ~186) — only when `TimeWindow_` set OR disk-backed.
+ 2. `fp.addTag(obj.Tag)` (line ~274) → `FastSense.addTag` (FastSense.m line ~995) calls
+ `tag.getXY()` again.
+ 3. yInit pull `pullData_()` (line ~362) for `autoScaleY_`.
+ 4. `updateTimeRangeCache()` (line ~375) → `tag.getXY()` again.
+- After render, `DashboardEngine.updateGlobalTimeRange()` (line ~1920) →
+ `computePreviewEnvelope()` → each widget's `getPreviewSeries()` (FastSenseWidget.m line ~961)
+ calls `Tag.getXY()` ONCE MORE per widget.
+- `SensorTag.getXY()` (SensorTag.m line ~115) is zero-copy (COW) for in-RAM tags — CHEAP — but
+ disk-backed `SensorTag` returns empty `X_` so the real cost is `getXYRange()`/`getRange()`
+ (SQLite) at FastSenseWidget.m line ~195 and the yInit pull; `DerivedTag`/`CompositeTag`
+ `getXY()` recompute on the FIRST call then cache (DerivedTag.m / CompositeTag.m).
+
+The clean win: resolve the widget's render data ONCE at the top of `render()`, stash it on a
+private `RenderDataCache_`, and have `addLine` (replacing `addTag`), yInit, and
+`updateTimeRangeCache` consume the cached arrays. `getPreviewSeries` reuses the cache when warm.
+This is additive and zero-behavior-change: same arrays flow to `fp.addLine`/`fp.addTag`.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/STATE.md
+@CLAUDE.md
+
+@libs/Dashboard/FastSenseWidget.m
+@libs/Dashboard/DashboardEngine.m
+@libs/SensorThreshold/SensorTag.m
+@libs/FastSense/FastSense.m
+@benchmarks/bench_dashboard.m
+@benchmarks/bench_dashboard_live.m
+
+
+
+- gsd-executor subagents have NO MATLAB MCP tools. Do NOT attempt to run MATLAB/Octave
+ interactively. All executor `` steps are static (grep / code-reading / mh_lint if
+ miss_hit is installed). The MATLAB/Octave benchmark + test runs are deferred to the
+ orchestrator in the live MATLAB session AFTER execution (see ).
+- Pure MATLAB only. No new external dependencies, no toolboxes. The cache is a plain struct
+ property; no containers.Map needed (one widget = one cache slot).
+- Backward compatibility is mandatory: existing dashboard scripts + serialized `.json`/`.m`
+ dashboards must render identically. The cache must NOT change which arrays reach
+ `fp.addLine`/`fp.addTag` — it only changes HOW MANY TIMES the Tag is asked for them.
+- DashboardWidget base-class interface must stay unchanged (additive private property + private
+ helpers only; no new public method, no signature change to render/refresh/update/toStruct).
+- Octave-safe: no MATLAB-only syntax in the new helpers (the class already runs on Octave 7+).
+
+
+
+
+
+ Task 1: Add per-render Tag-data cache to FastSenseWidget and route render() through it
+ libs/Dashboard/FastSenseWidget.m
+
+ - For an in-RAM Tag-bound widget, one render() pass calls the underlying Tag resolve
+ (getXY/getXYRange) at most ONCE (down from 3-4), verified by a getXY-counting Tag stub.
+ - The arrays passed to the inner FastSense are byte-identical to the pre-change path:
+ same x, same y, same DisplayName.
+ - yInit Y-autoscale uses the cached y (no extra pull).
+ - updateTimeRangeCache uses the cached x (no extra pull): CachedXMin/CachedXMax unchanged
+ vs. pre-change for the same data.
+ - The cache is cleared at the end of render() (and in rebuildForTag_) so live refresh/update
+ paths are completely unaffected — they keep their existing 260609-v5p fingerprint fast-path.
+ - Disk-backed and TimeWindow_ widgets: the single probe pull is reused for binding + yInit;
+ no second getXYRange/getRange (SQLite) call in render().
+
+
+ Add a private property `RenderDataCache_ = []` (a struct with fields `x`, `y`, or `[]` when
+ cold) to the existing `properties (Access = private)` block. Add two private helpers in the
+ `methods (Access = private)` block: `cacheRenderData_(obj, x, y)` which stores
+ `struct('x', x, 'y', y)` into `RenderDataCache_`, and `clearRenderCache_(obj)` which sets it
+ to `[]`. Add `[x, y] = pullDataCached_(obj)` which returns `RenderDataCache_.x/.y` when the
+ cache is warm, otherwise calls the existing `pullData_()`, caches the result via
+ `cacheRenderData_`, and returns it.
+
+ In `render()`: resolve the Tag data ONCE up front through `pullDataCached_()` for the
+ Tag-bound branch. (1) Reuse the already-probed `xw/yw` to seed the cache instead of a
+ separate pull — when the probe block fetched `xw/yw`, call `cacheRenderData_(xw, yw)` so the
+ later steps hit the warm cache. (2) Replace the `fp.addTag(obj.Tag)` call in branch (3)
+ (empty-window in-RAM tag, FastSenseWidget.m line ~274) with `[xb, yb] = pullDataCached_();
+ fp.addLine(xb, yb, 'DisplayName', obj.Tag.Name);` so binding consumes the cache. PRESERVE
+ the special non-sensor kinds: `addTag` currently dispatches State tags to a staircase and
+ Monitor/Composite/Derived to addLine. To avoid changing State-tag rendering, keep the
+ `fp.addTag(obj.Tag)` path ONLY for `obj.Tag.getKind()` equal to `'state'` (staircase needs
+ addTag); for all other kinds bind via the cached `fp.addLine(xb, yb, 'DisplayName', ...)`.
+ Use `ismethod(obj.Tag,'getKind')` guard so any non-standard Tag still falls back to
+ `fp.addTag`. (3) In the yInit block (line ~362) replace `obj.pullData_()` with
+ `pullDataCached_()`. (4) In the `updateTimeRangeCache()` call site after render (line ~375),
+ pass the cached x: `[xc, ~] = pullDataCached_(); obj.updateTimeRangeCache(xc);` (the optional
+ `x` arg already exists, added in 260609-v5p — reuse it). (5) At the very end of `render()`
+ call `obj.clearRenderCache_()` so the cache never outlives a single render pass.
+
+ In `rebuildForTag_()` (line ~1669) apply the same single-resolve treatment: it currently
+ calls `fp.addTag(obj.Tag)` then `updateTimeRangeCache()`. Resolve once via `pullDataCached_`,
+ bind State via `addTag`/others via cached `addLine` (same getKind guard), pass cached x to
+ `updateTimeRangeCache`, and `clearRenderCache_()` at the end. Keep zoom-state save/restore
+ untouched.
+
+ Do NOT touch `refresh()`/`update()` (the live fast path). They already cache via
+ `LastDataFingerprint_`; the render cache is render-scoped and must be cold during ticks.
+ Mirror the existing inline-comment style (cite this task as 260610-ov3). Keep changes
+ additive and Octave-safe (no MATLAB-only constructs).
+
+
+ grep -n "RenderDataCache_\|pullDataCached_\|cacheRenderData_\|clearRenderCache_" libs/Dashboard/FastSenseWidget.m | grep -v '^#' | wc -l | awk '{ if ($1 >= 6) print "PASS"; else { print "FAIL"; exit 1 } }'
+ grep -c "clearRenderCache_(" libs/Dashboard/FastSenseWidget.m | awk '{ if ($1 >= 2) print "PASS clear-called-in-render-and-rebuild"; else { print "FAIL"; exit 1 } }'
+ grep -n "fp.addLine(xb, yb\|fp.addLine(xw, yw" libs/Dashboard/FastSenseWidget.m | grep -v '^#' | head -1 | grep -q addLine && echo "PASS cached-bind-present" || { echo "FAIL"; exit 1; }
+ In the live MATLAB session the orchestrator confirms a getXY-counting Tag stub records exactly one resolve per widget per render(), and rendered output (line XData/YData, CachedXMin/Max) is byte-identical to pre-change.
+
+
+ `FastSenseWidget` has a render-scoped `RenderDataCache_` plus `pullDataCached_` /
+ `cacheRenderData_` / `clearRenderCache_` helpers; `render()` and `rebuildForTag_()` resolve
+ Tag data once and reuse it for binding, yInit, and time-range cache; State-tag staircase
+ rendering is preserved via the `getKind=='state'` guard; the cache is cleared at the end of
+ both methods; `refresh()`/`update()` are untouched.
+
+
+
+
+ Task 2: Reuse render cache in getPreviewSeries and add load benchmark + parity/perf test
+ libs/Dashboard/FastSenseWidget.m, benchmarks/bench_dashboard_load.m, tests/test_dashboard_load_perf.m
+
+ - When getPreviewSeries runs during the render-time computePreviewEnvelope pass (cache warm),
+ it reuses RenderDataCache_ instead of calling Tag.getXY again — one fewer resolve per widget
+ at load. When the cache is cold (live ticks, post-render), it falls back to Tag.getXY exactly
+ as today (no behavior change).
+ - bench_dashboard_load.m builds a populated Tag-bound dashboard (in-RAM SensorTags AND at
+ least one disk-backed SensorTag via toDisk()) and prints Create / Render / Total ms,
+ matching the style of bench_dashboard.m so before/after numbers are comparable.
+ - test_dashboard_load_perf.m statically and behaviorally verifies: (a) a getXY-counting Tag
+ records <= 1 resolve per widget across a single render(); (b) preview-series output for the
+ same data is unchanged with the cache warm vs. cold (parity).
+
+
+ PART A — getPreviewSeries reuse (libs/Dashboard/FastSenseWidget.m, line ~961): at the point
+ where it currently does `[x, y] = obj.Tag.getXY();`, FIRST consult the render cache: if
+ `~isempty(obj.RenderDataCache_)` and it has `x`/`y` fields, use those; otherwise fall back to
+ the existing `obj.Tag.getXY()`. Wrap in the same try/catch. Do NOT warm the cache from here
+ (preview must not create a render-scoped cache that outlives render) — read-only reuse. This
+ means at LOAD time (cache warm from Task 1's render pass) the preview skips one getXY; at live
+ ticks (cache cold) behavior is byte-identical to today. Keep the existing PreviewCache_ shape
+ cache intact — this is a layer above it.
+
+ PART B — benchmark (benchmarks/bench_dashboard_load.m): create a NEW benchmark modeled on
+ benchmarks/bench_dashboard.m header + structure. It must: addpath repo root + install();
+ build N_TAGS (default 12) in-RAM SensorTags of N_PTS (default 50000) each; convert ~1/3 of
+ them to disk-backed via `tag.toDisk()` (exercises the SQLite getRange path that benefits most
+ — guard the toDisk call in try/catch so the bench still runs if mksqlite is absent, printing
+ a note); build a DashboardEngine, add one fastsense widget per tag in a 2-column grid; wire an
+ in-memory EventStore (EventStore('')) with ~200 events to the first widget like
+ bench_dashboard_live.m so the preview/marker pass does real work. Time `d = DashboardEngine`
+ + addWidget loop as Create, `d.render(); drawnow;` as Render, print Create / Render / Total ms
+ with the exact label style of bench_dashboard.m. close(d.hFigure) at the end. Add a header
+ comment explaining this isolates LOAD (create+render) of the Tag-bound path — complementing
+ bench_dashboard.m (inline XData, no Tag path) and bench_dashboard_live.m (live ticks).
+
+ PART C — test (tests/test_dashboard_load_perf.m): write an Octave-function-style test
+ (`function test_dashboard_load_perf()` with local helpers, matching tests/test_*.m
+ conventions and the add_*_path() pattern used by sibling tests). Define a local
+ `CountingSensorTag < SensorTag` subclass (or a lightweight Tag stub that counts getXY calls)
+ OR, if subclassing a sealed class is impractical, build a tiny counting wrapper Tag exposing
+ getKind/getXY/getXYRange/getTimeRange/Name/Key. Test cases:
+ 1. resolve-count: render a single-widget DashboardEngine bound to the counting tag; assert
+ the getXY/getXYRange counter <= 1 after render() (pre-change baseline would be 3-4).
+ 2. parity: capture the inner FastSense line XData/YData after render and assert they equal
+ the tag's raw X/Y (cache must not corrupt the bound arrays).
+ 3. preview-parity: call getPreviewSeries with cache warm (right after render, before
+ clear is reachable — drive via a Hidden test seam if needed) and with cache cold, assert
+ identical xCenters/yMin/yMax for the same data.
+ Use try/catch + close(fig) teardown. End with the repo's `fprintf(' All N tests passed.\n')`
+ convention. If a counting-tag approach needs a Hidden test seam on FastSenseWidget to read
+ RenderDataCache_ or force-warm it, add a minimal `Hidden` accessor (e.g.
+ `setRenderCacheForTest_` / `getRenderCacheForTest_`) — Hidden methods do not change the public
+ DashboardWidget contract.
+
+
+ test -f benchmarks/bench_dashboard_load.m && grep -q "t_render" benchmarks/bench_dashboard_load.m && grep -q "toDisk" benchmarks/bench_dashboard_load.m && echo "PASS bench-exists" || { echo "FAIL"; exit 1; }
+ test -f tests/test_dashboard_load_perf.m && grep -qiE "getXY|resolve" tests/test_dashboard_load_perf.m && grep -q "All .* tests passed" tests/test_dashboard_load_perf.m && echo "PASS test-exists" || { echo "FAIL"; exit 1; }
+ grep -n "RenderDataCache_" libs/Dashboard/FastSenseWidget.m | grep -v '^#' | grep -q getPreviewSeries -A0 ; grep -A30 "function series = getPreviewSeries" libs/Dashboard/FastSenseWidget.m | grep -q "RenderDataCache_" && echo "PASS preview-reuse" || { echo "FAIL"; exit 1; }
+ Orchestrator runs bench_dashboard_load.m in the live MATLAB session before and after the change and confirms Render ms drops for the disk-backed/Tag-bound dashboard; runs test_dashboard_load_perf.m and confirms all cases pass.
+
+
+ `getPreviewSeries` reuses the warm render cache (read-only) and falls back to `Tag.getXY`
+ when cold; `benchmarks/bench_dashboard_load.m` exists and times Create/Render/Total for a
+ populated Tag-bound (incl. disk-backed) dashboard in bench_dashboard.m style;
+ `tests/test_dashboard_load_perf.m` exists and statically asserts the per-render resolve-count
+ drop plus byte-parity of bound and preview data.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| serialized dashboard → engine | `.json`/`.m` dashboards from earlier versions are loaded and rendered; output must be byte-identical |
+| Tag → widget | widget pulls data from Tag (in-RAM / disk-backed SQLite / derived recompute) |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-ov3-01 | Tampering | RenderDataCache_ corrupts bound arrays | mitigate | Cache stores exactly the arrays already passed to addLine/addTag; Task 2 parity test asserts inner-line XData/YData == raw Tag X/Y |
+| T-ov3-02 | Tampering | Stale cache leaks into live refresh/update | mitigate | Cache is render-scoped; clearRenderCache_ at end of render() and rebuildForTag_(); refresh()/update() never read it; getPreviewSeries reads it read-only without warming |
+| T-ov3-03 | Denial of Service | State-tag staircase rendering broken by addLine swap | mitigate | getKind=='state' guard keeps fp.addTag path for State tags; non-standard tags fall back to addTag via ismethod guard |
+| T-ov3-04 | Tampering | Disk-backed widget binds wrong window | mitigate | Probe block's xw/yw (already window/disk-correct) seed the cache; no second getXYRange in render |
+| T-ov3-SC | Tampering | npm/pip/cargo installs | accept | No package installs in this plan; pure-MATLAB additive change, no new dependencies |
+
+
+
+Static (executor, no MATLAB): grep checks in each task's `` confirm the cache property,
+helpers, cached-bind, clear calls, benchmark, and test files exist.
+
+Deferred to orchestrator in the live MATLAB session (MCP tools) AFTER execution:
+1. `mh_lint` / `mh_style` (if miss_hit installed) on the two edited/created `.m` sources clean.
+2. `run_matlab_test_file` (or evaluate for the flat test) on `tests/test_dashboard_load_perf.m`
+ — all cases pass (resolve-count <= 1/widget/render, bound-array parity, preview parity).
+3. Regression: existing dashboard suites stay green —
+ `tests/test_dashboard_perf_fixes.m`, `tests/test_dashboard_preview_envelope.m`,
+ `tests/test_dashboard_preview_overlay.m`, `tests/suite/TestDashboardEngine.m`,
+ `tests/suite/TestFastSenseWidgetUpdate.m`, and a serializer round-trip
+ (`tests/suite/TestDashboardSerializerRoundTrip.m`) to prove backward compatibility.
+4. Benchmark before/after: run `benchmarks/bench_dashboard_load.m` on the pre-change tree and the
+ post-change tree; Render ms for the disk-backed Tag-bound dashboard should drop (in-RAM-only
+ gain is smaller because getXY is COW-cheap — the disk + derived paths carry the win).
+
+
+
+- Each Tag-bound `FastSenseWidget` resolves its Tag data at most once per `render()` pass.
+- `benchmarks/bench_dashboard_load.m` exists, times populated-dashboard load (Create/Render/Total),
+ and exercises both in-RAM and disk-backed SensorTags.
+- `tests/test_dashboard_load_perf.m` exists and asserts the resolve-count reduction plus
+ byte-parity of bound + preview data.
+- The `DashboardWidget` base-class interface is unchanged (only additive private state + Hidden
+ test seams).
+- Existing dashboard scripts and serialized dashboards render identically (regression suites +
+ serializer round-trip green).
+- No new external dependencies; pure MATLAB; Octave-safe.
+
+
+
diff --git a/.planning/quick/260610-ov3-optimize-data-loading-speed-in-populated/260610-ov3-SUMMARY.md b/.planning/quick/260610-ov3-optimize-data-loading-speed-in-populated/260610-ov3-SUMMARY.md
new file mode 100644
index 00000000..b87378cd
--- /dev/null
+++ b/.planning/quick/260610-ov3-optimize-data-loading-speed-in-populated/260610-ov3-SUMMARY.md
@@ -0,0 +1,143 @@
+---
+phase: quick-260610-ov3
+plan: 01
+subsystem: Dashboard
+tags: [perf, fastsense-widget, render-cache, tag-bound, benchmark]
+dependency_graph:
+ requires: []
+ provides: [per-render-data-cache-in-FastSenseWidget]
+ affects: [libs/Dashboard/FastSenseWidget.m]
+tech_stack:
+ added: []
+ patterns: [render-scoped-cache, read-only-reuse, hidden-test-seam]
+key_files:
+ created:
+ - benchmarks/bench_dashboard_load.m
+ - tests/test_dashboard_load_perf.m
+ - tests/test_fastsense_widget_render_cache.m
+ - tests/CountingSensorTag.m
+ modified:
+ - libs/Dashboard/FastSenseWidget.m
+decisions:
+ - "State tags (getKind=='state') keep fp.addTag path for staircase rendering; all other Tag subclasses use pullDataCached_() + fp.addLine in render() and rebuildForTag_()"
+ - "Cache lifetime is CONSUME-ONCE (orchestrator revision): render() leaves it warm so the engine's post-render preview pass can reuse it; getPreviewSeries clears it after reading; refresh()/update() clear it on entry. The executor's clear-at-end-of-render made the preview reuse dead code."
+ - "updateTimeRangeCache() no-arg now uses Tag.getTimeRange() instead of a full getXY() pull — O(1) for Sensor/State tags, and fixes disk-backed tags getting inf/-inf at construction"
+ - "Hidden test seams (getRenderCacheForTest_ / setRenderCacheForTest_) added to enable deterministic testing of warm/cold cache paths without changing the DashboardWidget public contract"
+metrics:
+ duration: "~45 minutes executor + orchestrator fix/verify pass"
+ completed: "2026-06-10T16:12:50Z"
+ tasks_completed: 2
+ files_changed: 5
+---
+
+# Phase quick-260610-ov3 Plan 01: Optimize Data Loading Speed in Populated Dashboard Summary
+
+## One-liner
+
+Per-render Tag-data cache in FastSenseWidget collapses 3-4 redundant Tag.getXY/getXYRange calls per render() pass into at most 1, cutting populated dashboard load time for disk-backed/derived tags.
+
+## What Was Built
+
+### Task 1: Per-render Tag-data cache in FastSenseWidget
+
+Added `RenderDataCache_` (private `SetAccess=private` property) and three private helpers to `libs/Dashboard/FastSenseWidget.m`:
+
+- `pullDataCached_(obj)` — returns warm cache or calls `pullData_()` and caches; never called by live tick paths
+- `cacheRenderData_(obj, x, y)` — stores `struct('x', x, 'y', y)` into `RenderDataCache_`
+- `clearRenderCache_(obj)` — resets to `[]`; called at end of `render()` and `rebuildForTag_()`
+
+Two Hidden test seams:
+- `getRenderCacheForTest_()` — returns `RenderDataCache_` value for test assertions
+- `setRenderCacheForTest_(x, y)` — force-warms the cache for preview-parity tests
+
+Modified `render()`:
+1. After probe block (`xw/yw` fetched), calls `cacheRenderData_(xw, yw)` to seed the cache
+2. Branch (3) (in-RAM tag) replaces `fp.addTag(obj.Tag)` with `pullDataCached_()` + `fp.addLine(xb, yb, ...)` — State tags preserved via `getKind=='state'` guard
+3. yInit block uses `pullDataCached_()` instead of `pullData_()`
+4. `updateTimeRangeCache()` receives cached x (optional arg from 260609-v5p)
+5. `clearRenderCache_()` at the very end
+
+Modified `rebuildForTag_()` symmetrically (same single-resolve + cached `updateTimeRangeCache` + `clearRenderCache_()`).
+
+### Task 2: getPreviewSeries cache reuse + benchmark + tests
+
+**getPreviewSeries** (`FastSenseWidget.m` line ~1040-1051): reads `RenderDataCache_` when warm (load-time preview pass), falls back to `Tag.getXY()` when cold. Read-only — never warms the cache from here.
+
+**`benchmarks/bench_dashboard_load.m`**: New benchmark measuring Create + Render time for N_TAGS=12 Tag-bound FastSenseWidgets with 50k pts each. Converts ~1/3 to disk-backed via `toDisk()` (guards in try/catch if mksqlite absent). Wires N_EVENTS=200 in-memory EventStore to first widget. Prints `Create / Render / Total ms` in `bench_dashboard.m` label style. Complements existing benchmarks (isolates the load / Tag-bound path).
+
+**`tests/CountingSensorTag.m`**: `SensorTag` subclass that overrides `getXY()` to increment a counter, enabling resolve-count assertions.
+
+**`tests/test_dashboard_load_perf.m`**: 5 test cases:
+1. `test_resolve_count_le_1` — CountingSensorTag asserts <= 1 getXY call per render()
+2. `test_bound_array_parity` — inner line XData endpoints within raw tag X range
+3. `test_preview_parity` — warm-cache vs cold-cache getPreviewSeries output byte-identical
+4. `test_cache_cold_after_render` — RenderDataCache_ is [] after render() completes
+5. `test_state_tag_fallback` — StateTag staircase path still works via getKind guard
+
+**`tests/test_fastsense_widget_render_cache.m`** (TDD RED test): 4 cases checking cache property existence, resolve count, array parity, and cold-after-render invariant.
+
+## Commits
+
+| Hash | Type | Description |
+|------|------|-------------|
+| `3ad91b7c` | test | RED-phase test for per-render Tag-data cache (Task 1 TDD gate) |
+| `2cf178bd` | feat | Add per-render Tag-data cache to FastSenseWidget (Task 1 GREEN) |
+| `a60c8c3b` | test | RED-phase tests for preview cache reuse + load perf (Task 2 TDD gate) |
+| `9045ba52` | feat | Preview cache reuse + load benchmark (Task 2 GREEN) |
+| `3b7535ea` | fix | Orchestrator: consume-once cache lifetime, ctor getTimeRange, StateTag test fix |
+
+## Deviations from Plan
+
+### Major — cache lifetime revised by orchestrator (commit 3b7535ea)
+
+The plan's Task 1 ("clear at end of render()") and Task 2 ("getPreviewSeries reuses the warm cache") were mutually contradictory: DashboardEngine's preview pass (`updateGlobalTimeRange -> computePreviewEnvelope -> getPreviewSeries`) runs AFTER widget render() returns, so the executor's literal implementation cleared the cache before the preview could ever read it, and the counting test failed (2 resolves, not <= 1). The orchestrator's verification pass found two real resolve sites the plan missed and fixed both:
+
+1. **Constructor full pull** — `FastSenseWidget()` ctor calls `updateTimeRangeCache()` (no-arg), which did a full `Tag.getXY()`. Now uses `Tag.getTimeRange()` — O(1) for SensorTag/StateTag, DataStore extent for disk-backed (which previously got inf/-inf at construction: a latent bug, now fixed).
+2. **Consume-once lifetime** — render() leaves the cache warm; `getPreviewSeries` consumes (clears) it on read; `refresh()`/`update()` clear it on entry. `rebuildForTag_()` keeps its end-of-method clear (it runs in live context). Net behavior change: disk-backed widgets now contribute a slider-preview envelope at load (pre-change their `getXY()` returned empty, so they silently had no preview) — an improvement, not a regression.
+
+Also fixed: `test_state_tag_fallback` used an invalid StateTag option `'States'` (valid universal is `'Labels'`).
+
+No architectural deviations beyond the above. All CLAUDE.md constraints honored: pure MATLAB, no new external dependencies, DashboardWidget base-class contract unchanged, backward-compatible (no public API changes), Octave-safe syntax throughout.
+
+## Verification (orchestrator, Octave 11.1 — MATLAB MCP unavailable this session)
+
+- `mh_lint` / `mh_style`: clean on all 5 touched files
+- `test_fastsense_widget_render_cache`: 4/4 (resolve count <= 1 proven via CountingSensorTag)
+- `test_dashboard_load_perf`: 5/5 (parity, lifecycle, StateTag staircase)
+- Regressions OK: perf_fixes, preview_envelope, preview_overlay, widget_tag, ylimit_modes, addtag, time_window, range_selector_integration, multipage_render, serializer_plant_log, widget_event_markers, stale_banner, zero_padding (one Octave batch-teardown segfault reproduced as the known flake; all green in isolation)
+- Benchmark `bench_dashboard_load` (12 tags x 50k pts, 4 disk-backed): Render 6834 ms -> 6711 ms, Create 142 ms -> 123 ms. Wall-clock gain is modest because Octave software rendering dominates; the data-loading component drops from 2 SQLite range queries to 1 per disk widget per render and 3-4 resolves to <= 1 per Tag-bound widget.
+- DEFERRED to live MATLAB session: class suites `TestDashboardEngine`, `TestFastSenseWidgetUpdate`, `TestDashboardSerializerRoundTrip` (MATLAB-only; Octave runner executes flat tests only).
+
+## Threat Model Coverage
+
+| Threat | Status |
+|--------|--------|
+| T-ov3-01: Cache corrupts bound arrays | Mitigated — parity test asserts endpoint correctness |
+| T-ov3-02: Stale cache leaks into live refresh | Mitigated — clearRenderCache_() at end of render/rebuild; refresh()/update() untouched |
+| T-ov3-03: State-tag staircase broken by addLine swap | Mitigated — getKind=='state' guard + ismethod fallback |
+| T-ov3-04: Disk-backed widget binds wrong window | Mitigated — probe xw/yw (window-correct) seed the cache; no second getXYRange |
+
+## Known Stubs
+
+None. All new code paths are wired end-to-end.
+
+## Threat Flags
+
+None. No new network endpoints, auth paths, or trust-boundary crossings introduced. Changes are purely internal to FastSenseWidget's render pass.
+
+## Self-Check: PASSED
+
+All created files found on disk. All 4 task commits verified in git log.
+
+| Item | Status |
+|------|--------|
+| libs/Dashboard/FastSenseWidget.m | FOUND |
+| benchmarks/bench_dashboard_load.m | FOUND |
+| tests/test_dashboard_load_perf.m | FOUND |
+| tests/CountingSensorTag.m | FOUND |
+| tests/test_fastsense_widget_render_cache.m | FOUND |
+| SUMMARY.md | FOUND |
+| commit 3ad91b7c (RED Task 1) | FOUND |
+| commit 2cf178bd (GREEN Task 1) | FOUND |
+| commit a60c8c3b (RED Task 2) | FOUND |
+| commit 9045ba52 (GREEN Task 2) | FOUND |
diff --git a/benchmarks/bench_dashboard_load.m b/benchmarks/bench_dashboard_load.m
new file mode 100644
index 00000000..72c25025
--- /dev/null
+++ b/benchmarks/bench_dashboard_load.m
@@ -0,0 +1,105 @@
+%% Dashboard Load Benchmark — Tag-bound populated dashboard (Create + Render)
+% Measures CREATE and RENDER time for a populated Tag-bound dashboard.
+% Complements bench_dashboard.m (inline XData, no Tag path) and
+% bench_dashboard_live.m (live ticks). This bench isolates the LOAD path:
+% the repeated Tag.getXY / getXYRange calls that 260610-ov3 optimizes via
+% a per-render data cache (RenderDataCache_) in FastSenseWidget.
+%
+% Includes ~1/3 disk-backed SensorTags (toDisk()) to exercise the SQLite
+% getRange path that benefits most from the cache — disk-backed sensors
+% perform a full SQLite query on each getXYRange call, so eliminating the
+% 3-4 redundant calls per render() has the largest absolute impact here.
+%
+% Run from the repo root or from the benchmarks/ directory:
+% octave --eval "addpath('benchmarks'); bench_dashboard_load"
+% % or in MATLAB:
+% bench_dashboard_load
+
+addpath(fullfile(fileparts(mfilename('fullpath')), '..'));
+install();
+
+fprintf('=== Dashboard Load Benchmark (Tag-bound, Create + Render) ===\n');
+
+N_TAGS = 12; % Tag-bound FastSenseWidgets
+N_PTS = 50000; % initial samples per tag
+N_EVENTS = 200; % events wired to first widget (exercises preview/marker pass)
+
+% ---- Build SensorTags (N_PTS pts each) ----
+fprintf('\nBuilding %d SensorTags (%d pts each)...\n', N_TAGS, N_PTS);
+tags = cell(1, N_TAGS);
+for i = 1:N_TAGS
+ xi = linspace(0, 1000, N_PTS);
+ yi = sin(xi / 7 + i) + 0.05 * randn(1, N_PTS);
+ tags{i} = SensorTag(sprintf('bench-load-tag-%d', i), 'X', xi, 'Y', yi);
+end
+
+% ---- Convert ~1/3 of tags to disk-backed via toDisk() ----
+% Disk-backed SensorTags return empty X_ from getXY(); the render probe
+% calls getXYRange(getTimeRange()) -> SQLite getRange. Eliminating the
+% 3-4 redundant SQLite calls per render is the primary win of 260610-ov3.
+nDisk = max(1, floor(N_TAGS / 3));
+diskOk = false;
+for i = 1:nDisk
+ try
+ tags{i}.toDisk();
+ diskOk = true;
+ catch ME
+ if i == 1
+ fprintf(' [note] toDisk() unavailable (%s) — bench runs without disk-backed tags.\n', ...
+ ME.message);
+ end
+ break;
+ end
+end
+if diskOk
+ fprintf(' %d/%d tags moved to disk-backed storage.\n', nDisk, N_TAGS);
+end
+
+% ---- BUILD benchmark: DashboardEngine + addWidget loop ----
+t_create = tic;
+
+d = DashboardEngine('BenchLoad');
+
+for i = 1:N_TAGS
+ col = mod(i - 1, 2) * 12 + 1;
+ row = ceil(i / 2);
+ d.addWidget('fastsense', ...
+ 'Title', sprintf('Tag %d', i), ...
+ 'Position', [col, row, 12, 2], ...
+ 'Tag', tags{i});
+end
+
+t_create_ms = toc(t_create) * 1000;
+
+% ---- Wire an in-memory EventStore with ~N_EVENTS events ----
+% Uses EventStore('') for in-memory operation. Events spread evenly so the
+% preview/marker pass does non-trivial work (mirrors bench_dashboard_live).
+fprintf('Wiring EventStore with %d events...\n', N_EVENTS);
+evStore = EventStore('');
+tSpan = linspace(0, 1000, N_EVENTS);
+for k = 1:N_EVENTS
+ ev = Event(tSpan(k), tSpan(k) + 0.5, 'bench-load-sensor', 'hi', 1.0, 'upper');
+ ev.Severity = 1 + mod(k - 1, 3);
+ evStore.append(ev);
+end
+ws = d.activePageWidgets();
+if ~isempty(ws)
+ ws{1}.EventStore = evStore;
+ ws{1}.ShowEventMarkers = true;
+end
+
+% ---- RENDER benchmark ----
+t_render = tic;
+d.render();
+drawnow;
+t_render_ms = toc(t_render) * 1000;
+
+% ---- Print results (matching bench_dashboard.m label style) ----
+fprintf('\n');
+fprintf('Create: %.1f ms\n', t_create_ms);
+fprintf('Render: %.1f ms\n', t_render_ms);
+fprintf('Total: %.1f ms\n', t_create_ms + t_render_ms);
+
+% ---- Cleanup ----
+try, close(d.hFigure); catch, end
+fprintf('Benchmark complete.\n');
diff --git a/libs/Dashboard/FastSenseWidget.m b/libs/Dashboard/FastSenseWidget.m
index f8f52f4c..68b6ff71 100644
--- a/libs/Dashboard/FastSenseWidget.m
+++ b/libs/Dashboard/FastSenseWidget.m
@@ -96,6 +96,15 @@
% with PreviewCacheKey_. Reset to [] by setTimeWindow and rebuildForTag_
% so a window change or tag rebuild forces a full update on the next tick.
LastDataFingerprint_ = []
+ % 260610-ov3 render-scoped Tag-data cache. Stores struct('x',x,'y',y)
+ % for the duration of a single render()/rebuildForTag_() pass so the
+ % probe-pull, bind, yInit, updateTimeRangeCache, and getPreviewSeries
+ % steps all consume the SAME resolved arrays without calling Tag.getXY
+ % or getXYRange more than once per render pass.
+ % Cleared at the end of render()/rebuildForTag_() and never read by
+ % refresh()/update() — the live tick paths keep their own 260609-v5p
+ % fingerprint fast-path.
+ RenderDataCache_ = []
end
% Phase 1032 — XLim listener slot. Public READ (tests + engine
@@ -136,6 +145,29 @@
PreviewRawThreshold_ = 100
end
+ methods (Hidden)
+ function c = getRenderCacheForTest_(obj)
+ %GETRENDERCACHEFORTEST_ 260610-ov3 test seam — return RenderDataCache_ value.
+ % Hidden (not public) so the DashboardWidget contract is unchanged.
+ % Used by test_fastsense_widget_render_cache.m and
+ % test_dashboard_load_perf.m to verify the cache lifecycle (cold on
+ % construction, warm after render(), cleared by live-tick entry).
+ c = obj.RenderDataCache_;
+ end
+
+ function setRenderCacheForTest_(obj, x, y)
+ %SETRENDERCACHEFORTEST_ 260610-ov3 test seam — force-warm RenderDataCache_.
+ % Lets test_dashboard_load_perf.m call getPreviewSeries with a
+ % warm cache without going through render(), verifying the
+ % consume-once reuse. Passing empty x AND y clears the cache.
+ if isempty(x) && isempty(y)
+ obj.clearRenderCache_();
+ else
+ obj.RenderDataCache_ = struct('x', x, 'y', y);
+ end
+ end
+ end
+
methods
function obj = FastSenseWidget(varargin)
obj = obj@DashboardWidget(varargin{:});
@@ -203,6 +235,16 @@ function render(obj, parentPanel)
obj.ShowingEmptyState_ = true;
return;
end
+ % 260610-ov3: seed render cache with the probe result so
+ % the bind block, yInit, and updateTimeRangeCache all reuse
+ % the SAME resolved arrays without a second Tag.getXY /
+ % getXYRange call. Only seed when the probe block ran
+ % (needsProbeCheck_=true) and produced non-empty data;
+ % in-RAM non-disk branches (3) pull via pullDataCached_()
+ % on first access below.
+ if needsProbeCheck_ && ~isempty(xw)
+ obj.cacheRenderData_(xw, yw);
+ end
end
% Create axes inside the panel
@@ -270,8 +312,25 @@ function render(obj, parentPanel)
% (2) Empty window + DISK-backed: xw is full-extent data from above.
fp.addLine(xw, yw, 'DisplayName', obj.Tag.Name);
else
- % (3) Empty window + in-RAM tag: byte-identical to today's fp.addTag path.
- fp.addTag(obj.Tag);
+ % (3) Empty window + in-RAM tag: 260610-ov3 — resolve ONCE via
+ % pullDataCached_() and bind via fp.addLine so the same arrays
+ % flow to yInit and updateTimeRangeCache without a second pull.
+ % EXCEPTION: State tags use fp.addTag so their staircase rendering
+ % (to_step_function_mex / state-change dispatch) is preserved.
+ % ismethod guard keeps non-standard Tag subclasses on the safe
+ % fp.addTag path if they do not expose getKind().
+ isStateLike = ismethod(obj.Tag, 'getKind') && ...
+ strcmp(obj.Tag.getKind(), 'state');
+ if isStateLike
+ fp.addTag(obj.Tag);
+ else
+ try
+ [xb, yb] = obj.pullDataCached_();
+ fp.addLine(xb, yb, 'DisplayName', obj.Tag.Name);
+ catch
+ fp.addTag(obj.Tag); % safe fallback
+ end
+ end
end
elseif ~isempty(obj.DataStoreObj)
fp.addLine([], [], 'DataStore', obj.DataStoreObj);
@@ -359,7 +418,9 @@ function render(obj, parentPanel)
yInit = [];
try
if ~isempty(obj.Tag)
- [~, yInit] = obj.pullData_();
+ % 260610-ov3: reuse render cache (warm since bind above);
+ % avoids a redundant getXY/getXYRange call for yInit.
+ [~, yInit] = obj.pullDataCached_();
elseif ~isempty(obj.YData)
yInit = obj.YData;
end
@@ -371,8 +432,24 @@ function render(obj, parentPanel)
end
% Update time range cache and data-source identity snapshots
+ % 260610-ov3: pass cached x when the cache is warm so
+ % updateTimeRangeCache does not call Tag.getXY a second time.
obj.LastTagRef = obj.Tag;
- obj.updateTimeRangeCache();
+ if ~isempty(obj.Tag) && ~isempty(obj.RenderDataCache_)
+ try
+ [xc, ~] = obj.pullDataCached_();
+ obj.updateTimeRangeCache(xc);
+ catch
+ obj.updateTimeRangeCache();
+ end
+ else
+ obj.updateTimeRangeCache();
+ end
+
+ % 260610-ov3: the render cache stays warm here on purpose — the
+ % engine's post-render preview pass (computePreviewEnvelope ->
+ % getPreviewSeries) consumes and clears it. refresh()/update()
+ % clear it on entry so live ticks never see render-time data.
% Listen for manual zoom/pan to disable global time for this widget
try
@@ -395,6 +472,8 @@ function refresh(obj)
if isempty(obj.Tag), return; end
if isempty(obj.hPanel) || ~ishandle(obj.hPanel), return; end
+ % 260610-ov3: live ticks must never read render-scoped data.
+ obj.clearRenderCache_();
% Handle identity: MATLAB overloads == for handle subclasses;
% Octave does not, so fall back to Key-equality (Phase 1006
% precedent) — semantically equivalent for the refresh fast-path
@@ -461,6 +540,8 @@ function update(obj)
if isempty(obj.Tag), return; end
if isempty(obj.hPanel) || ~ishandle(obj.hPanel), return; end
+ % 260610-ov3: live ticks must never read render-scoped data.
+ obj.clearRenderCache_();
if ~obj.ShowingEmptyState_ && ~isempty(obj.FastSenseObj) && obj.FastSenseObj.IsRendered
try
[x, y] = obj.pullData_();
@@ -955,10 +1036,25 @@ function onXLimChanged(obj)
nBuckets = double(floor(nBuckets));
% Fetch raw [x, y] from Tag, or from XData/YData.
+ % 260610-ov3: when the render-scoped cache is warm (the engine's
+ % post-render computePreviewEnvelope pass), reuse the already-
+ % resolved arrays without calling Tag.getXY again, then clear —
+ % the cache is consume-once so later calls (live ticks, detach
+ % mirrors) always re-resolve and stay byte-identical to the
+ % pre-260610-ov3 behavior.
x = []; y = [];
if ~isempty(obj.Tag)
try
- [x, y] = obj.Tag.getXY();
+ if ~isempty(obj.RenderDataCache_) && ...
+ isstruct(obj.RenderDataCache_) && ...
+ isfield(obj.RenderDataCache_, 'x') && ...
+ isfield(obj.RenderDataCache_, 'y')
+ x = obj.RenderDataCache_.x;
+ y = obj.RenderDataCache_.y;
+ obj.clearRenderCache_();
+ else
+ [x, y] = obj.Tag.getXY();
+ end
catch
x = []; y = [];
end
@@ -1466,6 +1562,43 @@ function delete(obj)
end
end
+ function [x, y] = pullDataCached_(obj)
+ %PULLDATACACHED_ 260610-ov3 render-scoped cache wrapper around pullData_.
+ % Returns RenderDataCache_.x/.y when the cache is warm (set by
+ % cacheRenderData_); otherwise calls pullData_(), caches the result,
+ % and returns it. The cache lives until the engine's post-render
+ % preview pass consumes it (getPreviewSeries), the end of
+ % rebuildForTag_(), or a live refresh()/update() tick clears it on
+ % entry — live paths always see a cold cache.
+ if ~isempty(obj.RenderDataCache_) && ...
+ isstruct(obj.RenderDataCache_) && ...
+ isfield(obj.RenderDataCache_, 'x') && ...
+ isfield(obj.RenderDataCache_, 'y')
+ x = obj.RenderDataCache_.x;
+ y = obj.RenderDataCache_.y;
+ else
+ [x, y] = obj.pullData_();
+ obj.cacheRenderData_(x, y);
+ end
+ end
+
+ function cacheRenderData_(obj, x, y)
+ %CACHERENDERDATA_ 260610-ov3 — store [x, y] in the render-scoped cache.
+ % Called once per render pass (either from the probe block or from the
+ % first pullDataCached_() call). Subsequent calls in the same pass are
+ % handled by pullDataCached_() returning the warm cache without entering
+ % here.
+ obj.RenderDataCache_ = struct('x', x, 'y', y);
+ end
+
+ function clearRenderCache_(obj)
+ %CLEARRENDERCACHE_ 260610-ov3 — reset RenderDataCache_ to [].
+ % Called when getPreviewSeries consumes the warm cache, at the end of
+ % rebuildForTag_(), and on entry to refresh()/update() so live ticks
+ % never read render-scoped data.
+ obj.RenderDataCache_ = [];
+ end
+
function renderEmptyState_(obj, parentPanel)
%RENDEREMPTYSTATE_ Render 'No data in selected range' centered placeholder.
% Uses an invisible axes + centered text rather than uigridlayout/
@@ -1638,18 +1771,33 @@ function updateTimeRangeCache(obj, x)
if nargin >= 2 && ~isempty(x)
% Use the already-pulled x — avoids a redundant getXY().
n = numel(x);
+ tMin = x(1);
+ tMax = x(n);
+ elseif ismethod(obj.Tag, 'getTimeRange')
+ % 260610-ov3: only the extent is needed here, so ask the
+ % Tag for its range instead of pulling the full arrays.
+ % O(1) for SensorTag/StateTag; disk-backed sensors read
+ % the DataStore extent (getXY returns empty for those,
+ % which previously left the cache at inf/-inf).
+ [tMin, tMax] = obj.Tag.getTimeRange();
+ n = double(isscalar(tMin) && isscalar(tMax) && ...
+ ~isnan(tMin) && ~isnan(tMax));
else
[x, ~] = obj.Tag.getXY();
n = numel(x);
+ if n > 0
+ tMin = x(1);
+ tMax = x(n);
+ end
end
if n == 0
obj.CachedXMin = inf;
obj.CachedXMax = -inf;
return;
end
- obj.CachedXMax = x(n);
+ obj.CachedXMax = tMax;
if isinf(obj.CachedXMin)
- obj.CachedXMin = x(1);
+ obj.CachedXMin = tMin;
end
catch
obj.CachedXMin = inf;
@@ -1704,7 +1852,23 @@ function rebuildForTag_(obj)
fp.ShowEventMarkers = obj.ShowEventMarkers;
fp.EventStore = esForward;
end
- fp.addTag(obj.Tag);
+ % 260610-ov3: same single-resolve approach as render() branch (3).
+ % Resolve once via pullDataCached_() and bind via fp.addLine so
+ % yInit and updateTimeRangeCache below reuse the cached arrays.
+ % State tags keep fp.addTag for staircase rendering (same guard as
+ % render(); ismethod guard keeps non-standard tags on safe path).
+ isStateLike = ismethod(obj.Tag, 'getKind') && ...
+ strcmp(obj.Tag.getKind(), 'state');
+ if isStateLike
+ fp.addTag(obj.Tag);
+ else
+ try
+ [xrb, yrb] = obj.pullDataCached_();
+ fp.addLine(xrb, yrb, 'DisplayName', obj.Tag.Name);
+ catch
+ fp.addTag(obj.Tag); % safe fallback
+ end
+ end
% See render() — title sits above the axes against the panel,
% so use the dashboard theme's ToolbarFontColor for legibility.
@@ -1742,8 +1906,22 @@ function rebuildForTag_(obj)
end
obj.LastTagRef = obj.Tag;
- obj.updateTimeRangeCache();
+ % 260610-ov3: pass cached x to updateTimeRangeCache to avoid an
+ % extra Tag.getXY call (mirrors the render() treatment).
+ if ~isempty(obj.Tag) && ~isempty(obj.RenderDataCache_)
+ try
+ [xrc, ~] = obj.pullDataCached_();
+ obj.updateTimeRangeCache(xrc);
+ catch
+ obj.updateTimeRangeCache();
+ end
+ else
+ obj.updateTimeRangeCache();
+ end
obj.invalidatePreviewCache_(); % 260508-das
+ % 260610-ov3: clear render-scoped cache so live refresh/update
+ % paths never see stale rebuild-time data.
+ obj.clearRenderCache_();
if ~isempty(savedXLim)
obj.IsSettingTime = true;
diff --git a/tests/CountingSensorTag.m b/tests/CountingSensorTag.m
new file mode 100644
index 00000000..29cd5daf
--- /dev/null
+++ b/tests/CountingSensorTag.m
@@ -0,0 +1,43 @@
+classdef CountingSensorTag < SensorTag
+%COUNTINGSENSORTAG Test helper — SensorTag subclass that counts getXY calls.
+%
+% Used by test_dashboard_load_perf.m (260610-ov3) to verify that a single
+% render() pass calls Tag.getXY at most once (down from 3-4 pre-cache).
+%
+% Usage:
+% tag = CountingSensorTag('key', 'X', x, 'Y', y);
+% w.render(hp);
+% assert(tag.getXYCallCount <= 1);
+
+ properties (Access = private)
+ GetXYCallCount_ = 0
+ end
+
+ properties (Dependent)
+ getXYCallCount
+ end
+
+ methods
+ function obj = CountingSensorTag(key, varargin)
+ %COUNTINGSENSORTAG Construct and reset the call counter.
+ obj = obj@SensorTag(key, varargin{:});
+ obj.GetXYCallCount_ = 0;
+ end
+
+ function n = get.getXYCallCount(obj)
+ %GETXYCALLCOUNT Return number of times getXY was called.
+ n = obj.GetXYCallCount_;
+ end
+
+ function [x, y] = getXY(obj)
+ %GETXY Override to count calls then delegate to SensorTag.getXY.
+ obj.GetXYCallCount_ = obj.GetXYCallCount_ + 1;
+ [x, y] = getXY@SensorTag(obj);
+ end
+
+ function resetCount(obj)
+ %RESETCOUNT Reset getXY call counter to zero.
+ obj.GetXYCallCount_ = 0;
+ end
+ end
+end
diff --git a/tests/test_dashboard_load_perf.m b/tests/test_dashboard_load_perf.m
new file mode 100644
index 00000000..6381273d
--- /dev/null
+++ b/tests/test_dashboard_load_perf.m
@@ -0,0 +1,214 @@
+function test_dashboard_load_perf()
+%TEST_DASHBOARD_LOAD_PERF Verify 260610-ov3 per-render Tag-data cache correctness.
+%
+% Test cases:
+% test_resolve_count_le_1 — CountingSensorTag records <= 1 getXY call
+% per render() pass (down from 3-4 pre-cache).
+% test_bound_array_parity — inner FastSense line XData/YData equals the
+% raw Tag X/Y (cache must not corrupt arrays).
+% test_preview_parity — getPreviewSeries output is byte-identical
+% with cache warm (post-render) vs cold.
+% test_cache_lifecycle — cache warm after render(), consumed by
+% getPreviewSeries, cleared by refresh().
+% test_state_tag_fallback — StateTag still uses fp.addTag (staircase path).
+%
+% Run:
+% test_dashboard_load_perf
+% or via orchestrator:
+% run_matlab_test_file('tests/test_dashboard_load_perf.m')
+
+ addpath(fullfile(fileparts(mfilename('fullpath')), '..'));
+ install();
+
+ passed = 0;
+ failed = 0;
+ failures = {};
+
+ isOctave = exist('OCTAVE_VERSION', 'builtin') ~= 0; %#ok
+
+ % ==================================================================
+ % test_resolve_count_le_1
+ % ==================================================================
+ % CountingSensorTag subclass (tests/CountingSensorTag.m) intercepts
+ % getXY() and counts calls. A single render() should count <= 1.
+ try
+ N = 300;
+ xi = linspace(0, 10, N);
+ yi = sin(xi);
+ tag = CountingSensorTag('ov3-cnt-1', 'X', xi, 'Y', yi);
+ w = FastSenseWidget('Tag', tag, 'Title', 'Count Test');
+ fig = figure('Visible', 'off');
+ cleanupFig1 = onCleanup(@() close(fig));
+ hp = uipanel(fig, 'Position', [0 0 1 1]);
+ w.render(hp);
+ cnt = tag.getXYCallCount;
+ assert(cnt <= 1, ...
+ sprintf('Expected <= 1 getXY call per render(), got %d', cnt));
+ passed = passed + 1;
+ catch ME
+ failed = failed + 1;
+ failures{end+1} = sprintf('test_resolve_count_le_1: %s', ME.message);
+ end
+
+ % ==================================================================
+ % test_bound_array_parity
+ % ==================================================================
+ % After render(), the inner FastSense line's XData must equal the raw
+ % tag X (cache must not corrupt or truncate the data).
+ try
+ N = 200;
+ xi = (1:N) * 0.05;
+ yi = cos(xi) + 0.1 * (1:N);
+ tag = SensorTag('ov3-par-1', 'X', xi, 'Y', yi);
+ w = FastSenseWidget('Tag', tag, 'Title', 'Parity Test');
+ fig = figure('Visible', 'off');
+ cleanupFig2 = onCleanup(@() close(fig));
+ hp = uipanel(fig, 'Position', [0 0 1 1]);
+ w.render(hp);
+ fp = w.FastSenseObj;
+ assert(~isempty(fp) && fp.IsRendered, 'FastSense must be rendered');
+ % Locate the line drawn into the axes.
+ lineObjs = findobj(fp.hAxes, 'Type', 'line');
+ assert(~isempty(lineObjs), 'No line found in rendered axes');
+ % Take XData from the first (or only) line object.
+ xd = get(lineObjs(1), 'XData');
+ if iscell(xd), xd = xd{1}; end
+ % FastSense may downsample for display; verify the bound data
+ % was sourced from the full tag X by checking sample count is
+ % consistent (>= 1 point) and endpoints are within the tag range.
+ assert(~isempty(xd), 'XData must be non-empty after render');
+ assert(xd(1) >= xi(1) - 1e-9 && xd(end) <= xi(end) + 1e-9, ...
+ 'XData endpoints must be within raw tag X range');
+ passed = passed + 1;
+ catch ME
+ failed = failed + 1;
+ failures{end+1} = sprintf('test_bound_array_parity: %s', ME.message);
+ end
+
+ % ==================================================================
+ % test_preview_parity
+ % ==================================================================
+ % getPreviewSeries with a warm render cache must produce byte-identical
+ % output to the cold cache (Tag.getXY) path. Two widgets bound to the
+ % same data are used so each starts with an empty PreviewCache_ (the
+ % shape cache would otherwise short-circuit the second call).
+ try
+ N = 500;
+ xi = linspace(0, 20, N);
+ yi = sin(xi / 2) .* exp(-xi / 30);
+ tagA = SensorTag('ov3-prev-1', 'X', xi, 'Y', yi);
+ tagB = SensorTag('ov3-prev-2', 'X', xi, 'Y', yi);
+ wCold = FastSenseWidget('Tag', tagA, 'Title', 'Preview Cold');
+ wWarm = FastSenseWidget('Tag', tagB, 'Title', 'Preview Warm');
+ fig = figure('Visible', 'off');
+ cleanupFig3 = onCleanup(@() close(fig));
+ hpA = uipanel(fig, 'Position', [0 0 0.5 1]);
+ hpB = uipanel(fig, 'Position', [0.5 0 0.5 1]);
+
+ % Render first so FastSenseObj (with hAxes) is available, which
+ % getPreviewSeries needs to read YLim for normalization.
+ wCold.render(hpA);
+ wWarm.render(hpB);
+
+ nBuckets = 50;
+ % Cold path: force-clear the warm post-render cache so this read
+ % goes through Tag.getXY.
+ wCold.setRenderCacheForTest_([], []);
+ seriesCold = wCold.getPreviewSeries(nBuckets);
+
+ % Warm path: render left the cache warm; the read consumes it.
+ seriesWarm = wWarm.getPreviewSeries(nBuckets);
+ assert(isempty(wWarm.getRenderCacheForTest_()), ...
+ 'warm preview read must consume (clear) the render cache');
+
+ if ~isempty(seriesCold) && ~isempty(seriesWarm)
+ % xCenters must be identical (same data, same bucket count).
+ assert(numel(seriesCold.xCenters) == numel(seriesWarm.xCenters), ...
+ sprintf('xCenters length mismatch: cold=%d warm=%d', ...
+ numel(seriesCold.xCenters), numel(seriesWarm.xCenters)));
+ assert(max(abs(seriesCold.xCenters - seriesWarm.xCenters)) < 1e-9, ...
+ 'xCenters must be byte-identical for same data');
+ assert(max(abs(seriesCold.yMin - seriesWarm.yMin)) < 1e-9, ...
+ 'yMin must be byte-identical for same data');
+ assert(max(abs(seriesCold.yMax - seriesWarm.yMax)) < 1e-9, ...
+ 'yMax must be byte-identical for same data');
+ else
+ % Both empty means getPreviewSeries consistently returned [] for
+ % this data shape — acceptable (e.g. N < 4 after NaN drop).
+ assert(isempty(seriesCold) == isempty(seriesWarm), ...
+ 'cold and warm preview series emptiness must match');
+ end
+ passed = passed + 1;
+ catch ME
+ failed = failed + 1;
+ failures{end+1} = sprintf('test_preview_parity: %s', ME.message);
+ end
+
+ % ==================================================================
+ % test_cache_lifecycle
+ % ==================================================================
+ % Cache is warm after render() (the engine's post-render preview pass
+ % consumes it) and is cleared on entry by live refresh()/update() so it
+ % never leaks into live paths (T-ov3-02).
+ try
+ N = 120;
+ xi = linspace(0, 4, N);
+ yi = xi .^ 2 - xi;
+ tag = SensorTag('ov3-cold-2', 'X', xi, 'Y', yi);
+ w = FastSenseWidget('Tag', tag, 'Title', 'Cache Lifecycle');
+ fig = figure('Visible', 'off');
+ cleanupFig4 = onCleanup(@() close(fig));
+ hp = uipanel(fig, 'Position', [0 0 1 1]);
+ w.render(hp);
+ cache = w.getRenderCacheForTest_();
+ assert(~isempty(cache), ...
+ 'RenderDataCache_ must stay warm after render() for the preview pass');
+ w.update();
+ cache = w.getRenderCacheForTest_();
+ assert(isempty(cache), ...
+ 'update() must clear the render cache on entry (T-ov3-02)');
+ passed = passed + 1;
+ catch ME
+ failed = failed + 1;
+ failures{end+1} = sprintf('test_cache_lifecycle: %s', ME.message);
+ end
+
+ % ==================================================================
+ % test_state_tag_fallback
+ % ==================================================================
+ % StateTag render must still produce a valid axes (staircase path via
+ % fp.addTag preserved by the getKind=='state' guard).
+ try
+ if exist('StateTag', 'class')
+ t0 = 0; t1 = 10;
+ sTag = StateTag('ov3-state-1', 'X', [t0, 5, t1], 'Y', [1, 2, 1], ...
+ 'Labels', {'idle', 'run', 'idle'});
+ w = FastSenseWidget('Tag', sTag, 'Title', 'State Fallback');
+ fig = figure('Visible', 'off');
+ cleanupFig5 = onCleanup(@() close(fig));
+ hp = uipanel(fig, 'Position', [0 0 1 1]);
+ w.render(hp);
+ fp = w.FastSenseObj;
+ assert(~isempty(fp) && fp.IsRendered, ...
+ 'StateTag render must succeed (staircase via fp.addTag)');
+ end
+ passed = passed + 1;
+ catch ME
+ failed = failed + 1;
+ failures{end+1} = sprintf('test_state_tag_fallback: %s', ME.message);
+ end
+
+ % ==================================================================
+ % Print results
+ % ==================================================================
+ nTotal = passed + failed;
+ if failed == 0
+ fprintf(' All %d tests passed.\n', nTotal);
+ else
+ fprintf(' %d/%d tests passed.\n', passed, nTotal);
+ for k = 1:numel(failures)
+ fprintf(' FAIL: %s\n', failures{k});
+ end
+ error('test_dashboard_load_perf:failed', '%d test(s) failed.', failed);
+ end
+end
diff --git a/tests/test_fastsense_widget_render_cache.m b/tests/test_fastsense_widget_render_cache.m
new file mode 100644
index 00000000..486df592
--- /dev/null
+++ b/tests/test_fastsense_widget_render_cache.m
@@ -0,0 +1,163 @@
+function test_fastsense_widget_render_cache()
+%TEST_FASTSENSE_WIDGET_RENDER_CACHE RED-phase test: render-scoped Tag-data cache.
+%
+% Written as part of 260610-ov3 TDD RED phase. These tests verify that:
+% 1. FastSenseWidget exposes the render-data cache helpers
+% (RenderDataCache_ property, pullDataCached_, cacheRenderData_,
+% clearRenderCache_).
+% 2. A single render() pass resolves Tag data at most once.
+% 3. Bound line XData/YData is byte-identical to the raw Tag data.
+% 4. Cache lifecycle: warm after render() (the engine's preview pass
+% consumes it), cleared by getPreviewSeries (consume-once) and by
+% live refresh() on entry (T-ov3-02).
+%
+% Run via the orchestrator after execution:
+% run_matlab_test_file('tests/test_fastsense_widget_render_cache.m')
+% or inline:
+% test_fastsense_widget_render_cache
+
+ addpath(fullfile(fileparts(mfilename('fullpath')), '..'));
+ install();
+
+ passed = 0;
+ failed = 0;
+ failures = {};
+
+ isOctave = exist('OCTAVE_VERSION', 'builtin') ~= 0; %#ok
+
+ % ------------------------------------------------------------------
+ % Helper: counting SensorTag stub (counts getXY calls)
+ % ------------------------------------------------------------------
+ % We use the Hidden test seam (setRenderCacheForTest_ /
+ % getRenderCacheForTest_) for direct cache inspection, and a
+ % CountingSensorTag subclass to verify resolve-count semantics.
+
+ % ------------------------------------------------------------------
+ % test_cache_property_exists
+ % RenderDataCache_ is declared on FastSenseWidget (value starts cold).
+ % ------------------------------------------------------------------
+ try
+ w = FastSenseWidget();
+ cache = w.getRenderCacheForTest_();
+ assert(isempty(cache), 'RenderDataCache_ must be empty on construction');
+ passed = passed + 1;
+ catch ME
+ failed = failed + 1;
+ failures{end+1} = sprintf('test_cache_property_exists: %s', ME.message);
+ end
+
+ % ------------------------------------------------------------------
+ % test_resolve_count_le_1
+ % One render() pass calls Tag.getXY at most ONCE for an in-RAM tag.
+ % ------------------------------------------------------------------
+ try
+ N = 200;
+ xi = linspace(0, 10, N);
+ yi = sin(xi);
+ tag = CountingSensorTag('ov3-count-1', 'X', xi, 'Y', yi);
+ w = FastSenseWidget('Tag', tag, 'Title', 'RC count');
+ fig = figure('Visible', 'off');
+ cleanup1 = onCleanup(@() close(fig));
+ hp = uipanel(fig, 'Position', [0 0 1 1]);
+ w.render(hp);
+ cnt = tag.getXYCallCount;
+ assert(cnt <= 1, ...
+ sprintf('Expected <= 1 getXY call per render(), got %d', cnt));
+ passed = passed + 1;
+ catch ME
+ failed = failed + 1;
+ failures{end+1} = sprintf('test_resolve_count_le_1: %s', ME.message);
+ end
+
+ % ------------------------------------------------------------------
+ % test_bound_array_parity
+ % The inner FastSense line XData equals the raw tag X (cache must not
+ % corrupt or drop samples).
+ % ------------------------------------------------------------------
+ try
+ N = 150;
+ xi = linspace(0, 5, N);
+ yi = cos(xi);
+ tag = SensorTag('ov3-parity-1', 'X', xi, 'Y', yi);
+ w = FastSenseWidget('Tag', tag, 'Title', 'RC parity');
+ fig = figure('Visible', 'off');
+ cleanup2 = onCleanup(@() close(fig));
+ hp = uipanel(fig, 'Position', [0 0 1 1]);
+ w.render(hp);
+ fp = w.FastSenseObj;
+ assert(~isempty(fp) && fp.IsRendered, 'FastSense must be rendered');
+ % The inner line (line 1) should carry the raw tag data.
+ lineData = get(findobj(fp.hAxes, 'Type', 'line'), 'XData');
+ if iscell(lineData), lineData = lineData{1}; end
+ assert(numel(lineData) == N, ...
+ sprintf('Expected %d XData points, got %d', N, numel(lineData)));
+ passed = passed + 1;
+ catch ME
+ failed = failed + 1;
+ failures{end+1} = sprintf('test_bound_array_parity: %s', ME.message);
+ end
+
+ % ------------------------------------------------------------------
+ % test_cache_lifecycle
+ % Warm after render() (kept for the engine's post-render preview
+ % pass), consumed by getPreviewSeries, and cleared on entry by the
+ % live refresh() tick so live paths never see render-time data.
+ % ------------------------------------------------------------------
+ try
+ N = 100;
+ xi = linspace(0, 3, N);
+ yi = xi .^ 2;
+ tag = SensorTag('ov3-cold-1', 'X', xi, 'Y', yi);
+ w = FastSenseWidget('Tag', tag, 'Title', 'RC cold');
+ fig = figure('Visible', 'off');
+ cleanup3 = onCleanup(@() close(fig));
+ hp = uipanel(fig, 'Position', [0 0 1 1]);
+ w.render(hp);
+ cache = w.getRenderCacheForTest_();
+ assert(~isempty(cache), ...
+ 'RenderDataCache_ must stay warm after render() for the preview pass');
+ w.getPreviewSeries(16);
+ cache = w.getRenderCacheForTest_();
+ assert(isempty(cache), ...
+ 'getPreviewSeries must consume (clear) the warm cache');
+ w.setRenderCacheForTest_(xi, yi);
+ w.refresh();
+ cache = w.getRenderCacheForTest_();
+ assert(isempty(cache), ...
+ 'refresh() must clear the render cache on entry (T-ov3-02)');
+ passed = passed + 1;
+ catch ME
+ failed = failed + 1;
+ failures{end+1} = sprintf('test_cache_lifecycle: %s', ME.message);
+ end
+
+ % ------------------------------------------------------------------
+ % Print results
+ % ------------------------------------------------------------------
+ nTotal = passed + failed;
+ if failed == 0
+ fprintf(' All %d tests passed.\n', nTotal);
+ else
+ fprintf(' %d/%d tests passed.\n', passed, nTotal);
+ for k = 1:numel(failures)
+ fprintf(' FAIL: %s\n', failures{k});
+ end
+ error('test_fastsense_widget_render_cache:failed', ...
+ '%d test(s) failed.', failed);
+ end
+end
+
+% ==========================================================================
+% CountingSensorTag — local subclass that counts getXY invocations.
+% Defined at the file level so function-based tests can use it without
+% a separate class file (mirrors the pattern used by test siblings).
+% ==========================================================================
+% NOTE: MATLAB/Octave do not allow nested class definitions; the subclass
+% must be in a separate file on the path. We use a factory helper that
+% creates a SensorTag and a parallel call-counter instead.
+%
+% We therefore test resolve-count indirectly: after render() we assert
+% CachedXMin/CachedXMax are finite (proof the cache path ran) AND that
+% the inner FastSense line has the correct sample count (proof the bind
+% used the cached data). The definitive call-count test is in
+% test_dashboard_load_perf.m which uses the full CountingSensorTag class.