From c475d2a50c3c7458b3711a67eda273b05b6b41ad Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 26 May 2026 18:49:00 +0200 Subject: [PATCH 1/4] fix(industrial-plant-demo): show all tag events in FastSense widgets Wire 'ShowEventMarkers', true + 'EventStore', ctx.store into all 9 FastSenseWidget construction sites across the 5 build*Page.m files (2 Overview, 2 FeedLine, 3 Reactor, 1 Cooling, 1 Events). Without these NV-pairs FastSenseWidget defaults ShowEventMarkers=false and EventStore=[], which silenced both the constructor-time forward (FastSenseWidget.m:159) and the live-tick refreshEventMarkers_ guard (FastSenseWidget.m:1242), so round event markers never painted on demo plots even though the engine itself had an EventStore. Also drops the now-incorrect %#ok pragma from buildFeedLinePage.m, buildReactorPage.m, and buildCoolingPage.m, since ctx.store is now referenced in each body. No changes to FastSenseWidget defaults (preserves back-compat for non-demo dashboards). No new tests; manual UAT via run_demo.m. Co-Authored-By: Claude Opus 4.7 (1M context) --- demo/industrial_plant/private/buildCoolingPage.m | 4 +++- demo/industrial_plant/private/buildEventsPage.m | 2 ++ demo/industrial_plant/private/buildFeedLinePage.m | 6 +++++- demo/industrial_plant/private/buildOverviewPage.m | 4 ++++ demo/industrial_plant/private/buildReactorPage.m | 8 +++++++- 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/demo/industrial_plant/private/buildCoolingPage.m b/demo/industrial_plant/private/buildCoolingPage.m index c7912fd0..9ba71dba 100644 --- a/demo/industrial_plant/private/buildCoolingPage.m +++ b/demo/industrial_plant/private/buildCoolingPage.m @@ -1,4 +1,4 @@ -function buildCoolingPage(engine, ctx) %#ok +function buildCoolingPage(engine, ctx) %BUILDCOOLINGPAGE Populate the Cooling page. % Raw axes for cooling.flow vs time (demonstrates RawAxesWidget), % a static table summarising cooling stats, a scatter of in_temp vs @@ -18,6 +18,8 @@ function buildCoolingPage(engine, ctx) %#ok engine.addWidget('fastsense', ... 'Title', 'Cooling Flow', ... 'Tag', coolFlow, ... + 'ShowEventMarkers', true, ... + 'EventStore', ctx.store, ... 'Thresholds', thFlow, ... 'Description', ['FastSenseWidget | Tag: SensorTag cooling.flow. ' ... 'Lower-threshold line from MonitorTag cooling.flow.low ' ... diff --git a/demo/industrial_plant/private/buildEventsPage.m b/demo/industrial_plant/private/buildEventsPage.m index 4535cf88..8d15a33d 100644 --- a/demo/industrial_plant/private/buildEventsPage.m +++ b/demo/industrial_plant/private/buildEventsPage.m @@ -40,6 +40,8 @@ function buildEventsPage(engine, ctx) fsP = FastSenseWidget( ... 'Title', 'Reactor Pressure with Event Markers', ... 'Tag', reactorPress, ... + 'ShowEventMarkers', true, ... + 'EventStore', ctx.store, ... 'Thresholds', thPress, ... 'Description', ['FastSenseWidget | Tag: SensorTag reactor.pressure. ' ... 'Threshold from MonitorTag reactor.pressure.critical ' ... diff --git a/demo/industrial_plant/private/buildFeedLinePage.m b/demo/industrial_plant/private/buildFeedLinePage.m index 7e98b219..12b6bbb4 100644 --- a/demo/industrial_plant/private/buildFeedLinePage.m +++ b/demo/industrial_plant/private/buildFeedLinePage.m @@ -1,4 +1,4 @@ -function buildFeedLinePage(engine, ctx) %#ok +function buildFeedLinePage(engine, ctx) %BUILDFEEDLINEPAGE Populate the Feed Line page. % FastSense plots for pressure + flow, a status indicator bound to the % feedline.pressure.high MonitorTag, a bar chart summarising feedline @@ -25,6 +25,8 @@ function buildFeedLinePage(engine, ctx) %#ok fsPress = FastSenseWidget( ... 'Title', 'Feedline Pressure', ... 'Tag', feedPress, ... + 'ShowEventMarkers', true, ... + 'EventStore', ctx.store, ... 'Thresholds', thFeedPress, ... 'Description', ['FastSenseWidget | Tag: SensorTag feedline.pressure. ' ... 'Threshold line from MonitorTag feedline.pressure.high ' ... @@ -33,6 +35,8 @@ function buildFeedLinePage(engine, ctx) %#ok fsFlow = FastSenseWidget( ... 'Title', 'Feedline Flow', ... 'Tag', feedFlow, ... + 'ShowEventMarkers', true, ... + 'EventStore', ctx.store, ... 'Description', ['FastSenseWidget | Tag: SensorTag feedline.flow. ' ... 'No MonitorTag defined for this signal.'], ... 'Position', [1 4 16 3]); diff --git a/demo/industrial_plant/private/buildOverviewPage.m b/demo/industrial_plant/private/buildOverviewPage.m index 6cc93683..56022eb3 100644 --- a/demo/industrial_plant/private/buildOverviewPage.m +++ b/demo/industrial_plant/private/buildOverviewPage.m @@ -67,6 +67,8 @@ function buildOverviewPage(engine, ctx) engine.addWidget('fastsense', ... 'Title', 'Reactor Pressure (live)', ... 'Tag', reactorPressure, ... + 'ShowEventMarkers', true, ... + 'EventStore', ctx.store, ... 'Thresholds', thReactorPress, ... 'Description', ['FastSenseWidget | Tag: SensorTag reactor.pressure. ' ... 'Threshold line drawn from MonitorTag ' ... @@ -141,6 +143,8 @@ function buildOverviewPage(engine, ctx) engine.addWidget('fastsense', ... 'Title', 'Feedline Pressure', ... 'Tag', feedlinePressure, ... + 'ShowEventMarkers', true, ... + 'EventStore', ctx.store, ... 'Thresholds', thFeedPress, ... 'Description', ['FastSenseWidget | Tag: SensorTag feedline.pressure. ' ... 'Threshold line drawn from MonitorTag ' ... diff --git a/demo/industrial_plant/private/buildReactorPage.m b/demo/industrial_plant/private/buildReactorPage.m index a6be9aac..7bfd4151 100644 --- a/demo/industrial_plant/private/buildReactorPage.m +++ b/demo/industrial_plant/private/buildReactorPage.m @@ -1,4 +1,4 @@ -function buildReactorPage(engine, ctx) %#ok +function buildReactorPage(engine, ctx) %BUILDREACTORPAGE Populate the Reactor page. % Second FastSense instance of reactor.pressure (no detach here, the % detached one lives on Overview), a fastsense for reactor.temperature, @@ -28,6 +28,8 @@ function buildReactorPage(engine, ctx) %#ok fsP = FastSenseWidget( ... 'Title', 'Reactor Pressure', ... 'Tag', reactorPress, ... + 'ShowEventMarkers', true, ... + 'EventStore', ctx.store, ... 'Thresholds', thPress, ... 'Description', ['FastSenseWidget | Tag: SensorTag reactor.pressure. ' ... 'Threshold line from MonitorTag reactor.pressure.critical ' ... @@ -37,6 +39,8 @@ function buildReactorPage(engine, ctx) %#ok fsT = FastSenseWidget( ... 'Title', 'Reactor Temperature', ... 'Tag', reactorTemp, ... + 'ShowEventMarkers', true, ... + 'EventStore', ctx.store, ... 'Thresholds', thTemp, ... 'Description', ['FastSenseWidget | Tag: SensorTag reactor.temperature. ' ... 'Threshold line from MonitorTag reactor.temperature.high ' ... @@ -62,6 +66,8 @@ function buildReactorPage(engine, ctx) %#ok fsRpm = FastSenseWidget( ... 'Title', 'Reactor RPM (live)', ... 'Tag', reactorRpm, ... + 'ShowEventMarkers', true, ... + 'EventStore', ctx.store, ... 'Description', ['FastSenseWidget | Tag: SensorTag reactor.rpm. ' ... 'Housed in the Advanced collapsible below.'], ... 'Position', [1 1 24 3]); From d0709b4724a3df44d01c2e4b9fe09b3875bdf247 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 26 May 2026 18:52:35 +0200 Subject: [PATCH 2/4] docs(quick-260526-pw3): record FastSense widget event-marker fix in STATE.md Quick task 260526-pw3 wired ShowEventMarkers=true + EventStore=ctx.store into all 9 FastSenseWidget call sites across the 5 industrial-plant demo build*Page.m files (shipped in c475d2a). This commit records that work in the STATE.md Quick Tasks Completed table and updates Last activity. Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/STATE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index f9c18bbd..d16754a9 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -28,7 +28,7 @@ Phase: 1028 (tag-update-perf-mex-simd) — COMPLETE 2026-05-19 (this branch) Plan: 6 of 6 executed (with 03/04 deferred per Plan 02d data). Shipped plans: 01, 02, 02b, 02d, 05, 06. Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v4.0 Multi-User LAN Concurrency — shipping via PR #152 (parallel branch); v1.0 perf line tracks phase 1028 — now COMPLETE via PR #114. Status: Phase 1028 closed. WithIO `tickMin` reduced 4497 ms → 3603 ms (−19.9%) on Octave Linux x86_64 CI run 26089658442, almost entirely from Plan 02d's in-memory prior-state cache. Plan 06 ships per-tick fs-stat coalescing reducing 1600 → 1 syscalls/tick (−99.94% mechanism-level; wall-time +3.2% within variance on tmpfs CI). PR #114 carries the phase. Follow-up candidates for a future perf phase: in-memory propagation refactor; `containers.Map` → struct-array refactor; `.mat` save-side optimization. K2/K3/K4 deferred per data (target regions bucket as 0 ms post-cache). -Last activity: 2026-05-19 — Completed phase 1028: Tag update perf — MEX + SIMD. Plan 06 shipped per-tick fs-stat coalescing seam (1600 → 1 syscall/tick = −99.94% reduction; wall-time within variance), phase wrap docs (VERIFICATION.md Final Result, ROADMAP.md, STATE.md, 1028-06-SUMMARY.md). Cumulative phase win: WithIO −19.9% from Plan 02d's read-side cache. K2/K3/K4 deferred per data; in-memory propagation + Map refactor remain as candidates for a follow-up perf phase. +Last activity: 2026-05-26 - Completed quick task 260526-pw3: In the industrial plant demo, ensure all events are shown for the tags in their FastSense widgets ### Note on parallel v4.0 work (main branch state) @@ -88,6 +88,7 @@ Other main PRs (#138, #139, #141, #144, #145, #146) auto-merged without conflict | 260513-q7w | Debounced post-resize refresh + ZOMBIE-PANEL fix that stops widgets going white during drag-resize and tab switching — TWO parallel timers on every figure resize event (300 ms cheap two-pass refresh + 1.2 s unconditional rerenderWidgets backstop). switchPage cancels both timers AND waits up to 3 s for in-flight rerenderWidgets to complete before mutating state. `IsRerendering_` flag prevents rerender-cascade scheduling. Re-entrancy guard aborts instead of self-rescheduling. **Root-cause fix**: rerenderWidgets now deletes the OUTER cell panel (via hCellPanel, falling back to hPanel for pre-realization widgets) — previous code deleted only `hPanel` which after realization points to the INNER content panel, leaving the outer cell + its WidgetButtonBar chrome alive on the canvas as "zombies" that stacked up over multiple rerenders and painted over freshly switched-to pages. test_dashboard_range_selector_integration 2/2, test_dashboard_time_sync_all_pages 5/5; canvas-children-count canary verifies zero zombie accumulation across 4 rerenders + resize + tab switch (constant 29) | 2026-05-13 | 577bf95, 99c8808, 4eda604, bc305dc, 54d5aa0, 20bcd4c | — | [260513-q7w-during-dashboard-figure-resize-fastsense](./quick/260513-q7w-during-dashboard-figure-resize-fastsense/) | | 260513-sfp | Add auto-y-limit control buttons (V/A/L) to FastSenseWidget WidgetButtonBar — new YLimitMode property (auto-visible / auto-all / locked, default 'auto-visible' reproduces pre-260513-sfp behaviour), setYLimitMode public method (clears UserZoomedY on explicit click so click re-engages autoscale), autoScaleY_ refactored to dispatch on mode AFTER existing precedence guards (YLimits pin / UserZoomedY / FastSense.LiveViewMode=='follow') so 260513-ovt Follow semantics are preserved. DashboardLayout duck-types widget chrome via ismethod(widget,'setYLimitMode'), so future widgets that expose Y-rescale modes opt in without touching DashboardLayout. ASCII glyphs (V/A/L) match existing Info/Detach. reflowChrome_ re-anchors on resize. toStruct omits the default so legacy dashboards stay diff-invisible. test_fastsense_widget_ylimit_modes 11/11, test_fastsense_widget_tag 7/7, test_fastsense_follow_toggle 10/10, test_dashboard_time_sync_all_pages 5/5. Verified on live industrial-plant demo, all 8 scenarios approved. Known caveat: V/A/L cluster butts against Info button (0-px gap) — inherited from pre-existing addInfoIcon 28-px-typo, explicitly out-of-scope per plan; logged in deferred-items.md | 2026-05-13 | 4db9138, cc18c7f, a9cc181 | Verified | [260513-sfp-add-auto-y-limit-control-buttons-to-fast](./quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/) | | 260513-s0y | Add Tile + Close all buttons to FastSenseCompanion top toolbar — private OpenedFigures_ tracking + syncOpenedFigures_ (walks Engines_ before tile/close-all) + public trackOpenedFigure hook (InspectorPane.onOpenDetail_ and CompanionEventViewer.openEventDashboard_ forward their figure handles). tileOpenedWindows: ceil(sqrt(N))×ceil(N/cols) grid on monitor containing the companion, 24px margin, 8px gutter, row-major top-down. Before set(Position), coerces each figure to WindowState='normal' + Units='pixels' — root cause of initial "Tile does nothing" report was DashboardEngine.render defaulting to Units='normalized' (pixel rects got treated as screen fractions, pushing figures off-canvas). closeAllOpenedWindows: snapshot + close(h) per handle (honors each figure's CloseRequestFcn). Inner toolbar grid 1×4→1×6 (Events / Live / Tile / Close all / spacer / gear; gear Layout.Column 4→6). 9 sub-tests in test_companion_tile_close_buttons.m PASS; TestFastSenseCompanion regression 64/64 PASS. Verified on live industrial-plant demo. Shipped as PR #143. | 2026-05-14 | 182d6f1, 2867caa, 1be2cc8, e58bc35, c47c0c1, db9ef88 | Shipped (PR #143) | [260513-s0y-add-tile-windows-and-close-all-windows-b](./quick/260513-s0y-add-tile-windows-and-close-all-windows-b/) | +| 260526-pw3 | Show all tag events in FastSense widgets across industrial plant demo | 2026-05-26 | c475d2a | — | [260526-pw3-in-the-industrial-plant-demo-ensure-all-](./quick/260526-pw3-in-the-industrial-plant-demo-ensure-all-/) | | 260519-bs4 | Add Tag Status Table window to FastSenseCompanion — new `TagStatusTableWindow.m` (classical figure, not uifigure, per CONTEXT.md), opened via new **Tags ↗** button on companion top toolbar (col 3 in the post-merge 1×7 grid: Events / Live / Tags / Tile / Close all / spacer / gear). Detached-only window with 12-column `uitable`: Key, Name, Type, Criticality, Units, Latest, Status (smart per-type — Monitor→OK/ALARM, State→state label, others→—), Last updated (X(end) timestamp), Activity (Live/Inactive at 5-min threshold), Events (count from EventStore), Samples, Labels. All 18 demo tags listed (snapshot from `TagRegistry.find(@(t)true)`). Two parallel refresh paths: (a) push-on-write via existing `FastSenseCompanion.scanLiveTagUpdates_` → `markStatusTableDirty_(keys)` when companion is in Live mode, (b) window-owned `RefreshTimer_` (1s fixedSpacing, unique UUID name, BusyMode='drop', self-stop after 2 consecutive tick errors) so the table refreshes regardless of companion's IsLive — addresses user feedback that Activity/Last updated must stay correct when companion is idle. Pause/Resume polling toggle freezes both paths (markTagsDirty becomes a no-op while paused; header shows "Last refreshed: HH:MM:SS (paused)"). "Last refreshed" heartbeat label updates every tick. Filter chips mirror TagCatalogPane pattern: Type (Sensor/Monitor/Composite/State/Derived), Criticality (Low/Medium/High/Safety), Activity (Live/Inactive) — multi-toggle, AND-across-groups / OR-within-group; broadened free-text search across Key+Name+Units+Labels. Push-on-write hook in companion stays — both mechanisms run in parallel. Six atomic commits + 1 merge: 01 base class + 11 pure-logic tests; 02 companion wiring + 7 lifecycle tests; 03 Activity column + own timer (+5 logic + 2 lifecycle tests, deviation from "push-on-write only" CONTEXT decision per user); 04 last-refreshed header + chip filters + broader search (+4 logic + 2 lifecycle tests); 05 Pause/Resume polling toggle (+4 lifecycle tests); 06 Events count column (+4 logic + 1 lifecycle test); 07 merge with main (PR #143 toolbar grid conflict). Final test counts post-merge: `test_companion_tag_status_table` 24/24 (pure-logic), `TestTagStatusTableWindow` 16/16 (UI lifecycle), `test_companion_tile_close_buttons` 9/9 (main's new test still PASS), `TestFastSenseCompanion` 64/64 (no regression) = 113/113 total. Verified end-to-end on live industrial-plant demo: 4 MonitorTags showed real event counts (29/32/33/35), 14 others showed 0; Activity flipped Live→Inactive at exactly 5-min boundary via static buildRow_ proof; companion IsLive=0 throughout (window polled itself). Deferred / out-of-scope: (1) polling-scope clarification dismissed by user (heartbeat-only vs. passive-observation vs. only-update-changed-cells — left as-is, table updates all cells every tick); (2) Info button + markdown help — scoped up to a milestone-sized "unified in-app help/wiki" effort, parked as backlog 999.1. | 2026-05-19 | b2ed937, e8a1be5, 43d2d3b, 2a24965, 50d464c, 10df740, 73a3bf1 | Verified | [260519-bs4-implement-a-new-table-view-in-the-compan](./quick/260519-bs4-implement-a-new-table-view-in-the-compan/) | ## Progress Bar From abdc80ba446b667d2e3859802769d548683b258d Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 26 May 2026 20:03:08 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(companion):=20add=20PerTag=20composer?= =?UTF-8?q?=20mode=20=E2=80=94=20spawn=20one=20window=20per=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a third composer mode "PerTag" to FastSenseCompanion. When the user selects N tags and clicks Plot in PerTag mode, the companion spawns N independent FastSense Companion figures — one per tag — each identical to today's single-tag "Open Detail" output (one FastSenseWidget, LinkedGrid layout). Every spawned figure is tracked in OpenedFigures_ so the existing Tile and Close-all toolbar buttons sweep them. Four files modified, no new files created: - libs/FastSenseCompanion/AdHocPlotEventData.m: widen Mode validator from {'Overlay','LinkedGrid'} to {'Overlay','LinkedGrid','PerTag'}. No new properties; no constructor signature change. - libs/FastSenseCompanion/InspectorPane.m: add hModePerTag_ button next to hModeOverlay_ / hModeLinked_ in the composer panel, grow the toggle grid from [1 2] to [1 3], extend applyModeToggleStyles_ to a 3-branch styler. The composer's onModeToggle_ and onPlot_ are untouched — mode-agnostic; they forward ComposerMode_ into AdHocPlotEventData directly. - libs/FastSenseCompanion/FastSenseCompanion.m: branch onOpenAdHocPlotRequested_ on evt.Mode. Overlay / LinkedGrid paths are byte-equivalent to prior behaviour. New PerTag branch loops openAdHocPlot({tags{k}}, 'LinkedGrid', obj.Theme) per tag, tracks each figure via trackOpenedFigure_, and accumulates skipped names into a single end-of-loop uialert. Per-iteration try/catch so one failed tag does not abort the batch. - tests/suite/TestFastSenseCompanion.m: extend driveSelectAndPlot_ helper to a 3-way mode click, and add testPerTagModeSpawnsNFigures. The new test registers 3 MockPlottableTag entries, drives PerTag + Plot, then asserts (a) getOpenedFiguresForTest_ delta == 3, (b) ≥3 new figures on groot with Name starting "FastSense Companion", and (c) no orphan timers after tearing down (mirrors ADHOC-05). Verification on MATLAB R2025b: - checkcode: zero new findings on all 4 files. - Constructor smoke: PerTag accepted; bogus modes rejected with FastSenseCompanion:invalidEventData; Overlay / LinkedGrid still work. - Suite TestFastSenseCompanion: 73 tests total (72 baseline + 1 new), testPerTagModeSpawnsNFigures PASSES, all 6 ADHOC tests still PASS. - Two pre-existing failures (testToolbarHasWikiButton actual=7 expected=6; testToolbarGearMovedToColumn8 actual=9 expected=8) are unrelated to R9X — confirmed by re-running them against a stash of the unmodified HEAD: same actual-vs-expected mismatch. Root cause: incomplete merge cleanup in commit e2ded77 (1×9 toolbar tests fix updated TestFastSenseCompanionPlantLogToolbar.m but not the two matching tests in TestFastSenseCompanion.m). Out of scope. Implements R9X-01 through R9X-05. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/FastSenseCompanion/AdHocPlotEventData.m | 4 +- libs/FastSenseCompanion/FastSenseCompanion.m | 89 ++++++++++++++++---- libs/FastSenseCompanion/InspectorPane.m | 33 +++++++- tests/suite/TestFastSenseCompanion.m | 63 +++++++++++++- 4 files changed, 164 insertions(+), 25 deletions(-) diff --git a/libs/FastSenseCompanion/AdHocPlotEventData.m b/libs/FastSenseCompanion/AdHocPlotEventData.m index 6adc7d40..aa330400 100644 --- a/libs/FastSenseCompanion/AdHocPlotEventData.m +++ b/libs/FastSenseCompanion/AdHocPlotEventData.m @@ -10,7 +10,7 @@ % % Properties (read-only after construction): % TagKeys - cellstr of selected tag keys -% Mode - char in {'Overlay','LinkedGrid'} +% Mode - char in {'Overlay','LinkedGrid','PerTag'} % % See also InspectorPane, FastSenseCompanion, event.EventData. @@ -36,7 +36,7 @@ 'AdHocPlotEventData: tagKeys{%d} must be char.', i); end end - validModes = {'Overlay', 'LinkedGrid'}; + validModes = {'Overlay', 'LinkedGrid', 'PerTag'}; if ~ischar(mode) || ~any(strcmp(mode, validModes)) error('FastSenseCompanion:invalidEventData', ... 'AdHocPlotEventData: mode must be one of: %s. Got: ''%s''.', ... diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index b8e08b05..e86e149b 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -2091,24 +2091,77 @@ function onOpenAdHocPlotRequested_(obj, ~, evt) tags{end+1} = obj.Registry_.get(keys{k}); %#ok end end - [hFig, skipped] = openAdHocPlot(tags, mode, obj.Theme); - % S0Y-01: track the ad-hoc figure so Tile / Close all see it. - try - obj.trackOpenedFigure_(hFig); - catch - end - obj.addLogEntry('info', sprintf( ... - 'Opened ad-hoc plot: %d tag(s) [%s]', ... - numel(tags), char(mode))); - if ~isempty(skipped) - obj.addLogEntry('warn', sprintf( ... - 'Ad-hoc plot skipped %d tag(s): %s', ... - numel(skipped), strjoin(skipped, ', '))); - msg = sprintf( ... - 'Plot opened, but some tags were skipped:\n - %s', ... - strjoin(skipped, sprintf('\n - '))); - uialert(obj.hFig_, msg, 'FastSense Companion', ... - 'Icon', 'warning'); + if strcmp(mode, 'PerTag') + % R9X PerTag branch: spawn one DashboardEngine window per + % tag, each identical to the single-tag "Open Detail" + % output (LinkedGrid layout, one FastSenseWidget). Track + % every spawned figure so the Tile / Close-all toolbar + % sees them. A no-data tag does NOT abort the batch — it + % is appended to skippedAll and the loop continues. + skippedAll = {}; + for kk = 1:numel(tags) + try + tgK = tags{kk}; + [hFig_k, skipped_k] = openAdHocPlot({tgK}, ... + 'LinkedGrid', obj.Theme); + try + obj.trackOpenedFigure_(hFig_k); + catch + end + try + nm = tgK.Name; + catch + nm = sprintf('', kk); + end + obj.addLogEntry('info', sprintf( ... + 'Opened per-tag plot: %s', char(nm))); + if ~isempty(skipped_k) + skippedAll = [skippedAll, skipped_k(:)']; %#ok + end + catch IterME + try + tgK = tags{kk}; + nm = tgK.Name; + catch + nm = sprintf('', kk); + end + skippedAll{end+1} = sprintf('%s (%s)', char(nm), ... + IterME.message); %#ok + obj.addLogEntry('warn', sprintf( ... + 'Per-tag plot skipped (%s): %s', ... + char(nm), IterME.message)); + end + end + if ~isempty(skippedAll) + obj.addLogEntry('warn', sprintf( ... + 'Ad-hoc plot skipped %d tag(s): %s', ... + numel(skippedAll), strjoin(skippedAll, ', '))); + msg = sprintf( ... + 'Plot opened, but some tags were skipped:\n - %s', ... + strjoin(skippedAll, sprintf('\n - '))); + uialert(obj.hFig_, msg, 'FastSense Companion', ... + 'Icon', 'warning'); + end + else + [hFig, skipped] = openAdHocPlot(tags, mode, obj.Theme); + % S0Y-01: track the ad-hoc figure so Tile / Close all see it. + try + obj.trackOpenedFigure_(hFig); + catch + end + obj.addLogEntry('info', sprintf( ... + 'Opened ad-hoc plot: %d tag(s) [%s]', ... + numel(tags), char(mode))); + if ~isempty(skipped) + obj.addLogEntry('warn', sprintf( ... + 'Ad-hoc plot skipped %d tag(s): %s', ... + numel(skipped), strjoin(skipped, ', '))); + msg = sprintf( ... + 'Plot opened, but some tags were skipped:\n - %s', ... + strjoin(skipped, sprintf('\n - '))); + uialert(obj.hFig_, msg, 'FastSense Companion', ... + 'Icon', 'warning'); + end end catch ME obj.addLogEntry('error', sprintf('Ad-hoc plot failed: %s', ME.message)); diff --git a/libs/FastSenseCompanion/InspectorPane.m b/libs/FastSenseCompanion/InspectorPane.m index 2957dca8..dc914852 100644 --- a/libs/FastSenseCompanion/InspectorPane.m +++ b/libs/FastSenseCompanion/InspectorPane.m @@ -45,6 +45,7 @@ RenderedMultiKeys_ = {} % cellstr of keys captured at last full render hModeOverlay_ = [] % "Overlay" mode button (multitag state only) hModeLinked_ = [] % "Linked grid" mode button (multitag state only) + hModePerTag_ = [] % "Per Tag" mode button (multitag state only) hPlotBtn_ = [] % Plot CTA (multitag state only) State_ = 'welcome' Payload_ = struct() @@ -108,7 +109,8 @@ function detach(obj) obj.Listeners_ = {}; obj.hSparkAxes_ = []; obj.hSparkPanel_ = []; obj.hOpenDetail_ = []; obj.hPlayBtn_ = []; obj.hPauseBtn_ = []; obj.hChipsGrid_ = []; - obj.hModeOverlay_ = []; obj.hModeLinked_ = []; obj.hPlotBtn_ = []; + obj.hModeOverlay_ = []; obj.hModeLinked_ = []; obj.hModePerTag_ = []; + obj.hPlotBtn_ = []; end function refreshLive(obj) @@ -352,6 +354,7 @@ function renderState_(obj) obj.hRangeLbl_ = []; obj.hOpenDetail_ = []; obj.hPlayBtn_ = []; obj.hPauseBtn_ = []; obj.hChipsGrid_ = []; obj.hModeOverlay_ = []; obj.hModeLinked_ = []; + obj.hModePerTag_ = []; obj.hPlotBtn_ = []; obj.hTagTable_ = []; obj.hDashTable_ = []; obj.hTagTitle_ = []; obj.hDashTitle_ = []; obj.RenderedTagKey_ = ''; obj.RenderedDashName_ = ''; @@ -1170,9 +1173,9 @@ function renderMultitag_(obj) obj.renderMultiSparkline_(k, tg); end - mg = uigridlayout(g, [1 2]); + mg = uigridlayout(g, [1 3]); mg.Layout.Row = nT + 2; mg.Layout.Column = 1; - mg.ColumnWidth = {'1x', '1x'}; mg.RowHeight = {'1x'}; + mg.ColumnWidth = {'1x', '1x', '1x'}; mg.RowHeight = {'1x'}; mg.Padding = [0 0 0 0]; mg.ColumnSpacing = 4; mg.BackgroundColor = t.WidgetBackground; obj.hModeOverlay_ = uibutton(mg, 'push'); @@ -1183,6 +1186,10 @@ function renderMultitag_(obj) obj.hModeLinked_.Layout.Row = 1; obj.hModeLinked_.Layout.Column = 2; obj.hModeLinked_.Text = 'Linked grid'; obj.hModeLinked_.FontSize = 11; obj.hModeLinked_.ButtonPushedFcn = @(~,~) obj.onModeToggle_('LinkedGrid'); + obj.hModePerTag_ = uibutton(mg, 'push'); + obj.hModePerTag_.Layout.Row = 1; obj.hModePerTag_.Layout.Column = 3; + obj.hModePerTag_.Text = 'Per Tag'; obj.hModePerTag_.FontSize = 11; + obj.hModePerTag_.ButtonPushedFcn = @(~,~) obj.onModeToggle_('PerTag'); obj.applyModeToggleStyles_(); obj.hPlotBtn_ = uibutton(g, 'push'); @@ -1299,6 +1306,8 @@ function refreshMultiInPlace_(obj) function applyModeToggleStyles_(obj) %APPLYMODETOGGLESTYLES_ Highlight active mode button; style inactive as idle. + % Three branches — exactly one of {Overlay, Linked grid, Per Tag} + % renders in the active accent style; the other two render idle. t = obj.Theme_; if strcmp(obj.ComposerMode_, 'Overlay') obj.hModeOverlay_.BackgroundColor = t.Accent; @@ -1307,13 +1316,29 @@ function applyModeToggleStyles_(obj) obj.hModeLinked_.BackgroundColor = t.WidgetBackground; obj.hModeLinked_.FontColor = t.ToolbarFontColor; obj.hModeLinked_.FontWeight = 'normal'; - else + obj.hModePerTag_.BackgroundColor = t.WidgetBackground; + obj.hModePerTag_.FontColor = t.ToolbarFontColor; + obj.hModePerTag_.FontWeight = 'normal'; + elseif strcmp(obj.ComposerMode_, 'LinkedGrid') obj.hModeLinked_.BackgroundColor = t.Accent; obj.hModeLinked_.FontColor = t.DashboardBackground; obj.hModeLinked_.FontWeight = 'bold'; obj.hModeOverlay_.BackgroundColor = t.WidgetBackground; obj.hModeOverlay_.FontColor = t.ToolbarFontColor; obj.hModeOverlay_.FontWeight = 'normal'; + obj.hModePerTag_.BackgroundColor = t.WidgetBackground; + obj.hModePerTag_.FontColor = t.ToolbarFontColor; + obj.hModePerTag_.FontWeight = 'normal'; + else % 'PerTag' + obj.hModePerTag_.BackgroundColor = t.Accent; + obj.hModePerTag_.FontColor = t.DashboardBackground; + obj.hModePerTag_.FontWeight = 'bold'; + obj.hModeOverlay_.BackgroundColor = t.WidgetBackground; + obj.hModeOverlay_.FontColor = t.ToolbarFontColor; + obj.hModeOverlay_.FontWeight = 'normal'; + obj.hModeLinked_.BackgroundColor = t.WidgetBackground; + obj.hModeLinked_.FontColor = t.ToolbarFontColor; + obj.hModeLinked_.FontWeight = 'normal'; end end diff --git a/tests/suite/TestFastSenseCompanion.m b/tests/suite/TestFastSenseCompanion.m index a61335a5..067d0bd5 100644 --- a/tests/suite/TestFastSenseCompanion.m +++ b/tests/suite/TestFastSenseCompanion.m @@ -563,6 +563,65 @@ function testADHOC05_noOrphanTimersAfterPlotAndClose(testCase) 'ADHOC-05: spawn+close cycle must leave no orphan companion-owned timers'); end + % ---- R9X-01..05: PerTag composer mode spawns one window per tag ---- + + function testPerTagModeSpawnsNFigures(testCase) + %TESTPERTAGMODESPAWNSNFIGURES R9X: PerTag mode spawns N independent figures. + % Builds the companion with 3 MockPlottableTag entries, drives the + % composer into PerTag mode, clicks Plot, then asserts exactly + % 3 figures were added to OpenedFigures_ (delta vs preCount) and + % at least 3 new figure handles appeared in groot. Tears down each + % spawned figure and verifies no orphan timers leaked — mirroring + % ADHOC-05's lifecycle hygiene check. + testCase.assumeFalse(exist('OCTAVE_VERSION', 'builtin') ~= 0, ... + 'PerTag mode test is MATLAB-only.'); + TagRegistry.clear(); + testCase.addTeardown(@() TagRegistry.clear()); + for i = 1:3 + k = sprintf('p%d', i); + TagRegistry.register(k, MockPlottableTag(k, ... + 'Name', sprintf('P%d', i), 'X', 1:30, 'Y', (1:30) + i)); + end + app = FastSenseCompanion('Theme', 'dark'); + testCase.addTeardown(@() closeIfOpen_(app)); + app.refreshCatalog(); + drawnow; + preTimers = timerfindall(); + preFigs = findobj(groot, 'Type', 'figure'); + preCount = numel(app.getOpenedFiguresForTest_()); + testCase.driveSelectAndPlot_(app, {'p1', 'p2', 'p3'}, 'PerTag'); + drawnow; + postCount = numel(app.getOpenedFiguresForTest_()); + testCase.verifyEqual(postCount - preCount, 3, ... + 'PerTag mode must track exactly N=3 figures for 3 selected tags'); + postFigs = findobj(groot, 'Type', 'figure'); + newFigs = setdiff(postFigs, preFigs); + testCase.verifyGreaterThanOrEqual(numel(newFigs), 3, ... + 'PerTag mode must spawn at least 3 new figures'); + for i = 1:numel(newFigs) + testCase.verifyTrue(ishandle(newFigs(i)), ... + 'PerTag mode: each spawned figure must be a valid handle'); + testCase.verifyEqual(get(newFigs(i), 'Type'), 'figure', ... + 'PerTag mode: each spawned must be classical figure (Type=figure)'); + nm = get(newFigs(i), 'Name'); + testCase.verifyNotEmpty(strfind(nm, 'FastSense Companion'), ... + 'PerTag mode: each spawned figure Name must start with "FastSense Companion"'); + end + % Lifecycle hygiene: close each spawned figure, close the companion, + % and verify no orphan companion-owned timers remain (mirrors ADHOC-05). + for i = 1:numel(newFigs) + if ishandle(newFigs(i)) + delete(newFigs(i)); + end + end + app.close(); + drawnow; + postTimers = timerfindall(); + newTimers = setdiff(postTimers, preTimers); + testCase.verifyEmpty(newTimers, ... + 'PerTag mode: spawn+close cycle must leave no orphan companion-owned timers'); + end + % ---- QUICK-LIVEUPDATES-01: scanLiveTagUpdates_ guard regression ---- function testScanLiveTagUpdatesPopulatesTableAfterGrowth(testCase) @@ -1509,8 +1568,10 @@ function driveSelectAndPlot_(testCase, app, keys, mode) %#ok ps = struct(s.InspectorPane_); if strcmp(mode, 'Overlay') feval(ps.hModeOverlay_.ButtonPushedFcn, [], []); - else + elseif strcmp(mode, 'LinkedGrid') feval(ps.hModeLinked_.ButtonPushedFcn, [], []); + else % 'PerTag' + feval(ps.hModePerTag_.ButtonPushedFcn, [], []); end drawnow; feval(ps.hPlotBtn_.ButtonPushedFcn, [], []); From 13306025a763fe67ab446057275582e2880e0fd8 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 26 May 2026 20:11:21 +0200 Subject: [PATCH 4/4] docs(quick-260526-r9x): record PerTag composer mode in STATE.md Quick task 260526-r9x shipped a 3rd composer mode "PerTag" in the FastSenseCompanion ad-hoc plot composer that spawns one independent DashboardEngine window per selected tag (shipped in abdc80b). Verifier confirmed all 6 must-haves pass; goal-backward audit clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/STATE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index d16754a9..fbf02096 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -28,7 +28,7 @@ Phase: 1028 (tag-update-perf-mex-simd) — COMPLETE 2026-05-19 (this branch) Plan: 6 of 6 executed (with 03/04 deferred per Plan 02d data). Shipped plans: 01, 02, 02b, 02d, 05, 06. Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v4.0 Multi-User LAN Concurrency — shipping via PR #152 (parallel branch); v1.0 perf line tracks phase 1028 — now COMPLETE via PR #114. Status: Phase 1028 closed. WithIO `tickMin` reduced 4497 ms → 3603 ms (−19.9%) on Octave Linux x86_64 CI run 26089658442, almost entirely from Plan 02d's in-memory prior-state cache. Plan 06 ships per-tick fs-stat coalescing reducing 1600 → 1 syscalls/tick (−99.94% mechanism-level; wall-time +3.2% within variance on tmpfs CI). PR #114 carries the phase. Follow-up candidates for a future perf phase: in-memory propagation refactor; `containers.Map` → struct-array refactor; `.mat` save-side optimization. K2/K3/K4 deferred per data (target regions bucket as 0 ms post-cache). -Last activity: 2026-05-26 - Completed quick task 260526-pw3: In the industrial plant demo, ensure all events are shown for the tags in their FastSense widgets +Last activity: 2026-05-26 - Completed quick task 260526-r9x: Add PerTag composer mode to FastSenseCompanion - spawn one DashboardEngine window per selected tag ### Note on parallel v4.0 work (main branch state) @@ -89,6 +89,7 @@ Other main PRs (#138, #139, #141, #144, #145, #146) auto-merged without conflict | 260513-sfp | Add auto-y-limit control buttons (V/A/L) to FastSenseWidget WidgetButtonBar — new YLimitMode property (auto-visible / auto-all / locked, default 'auto-visible' reproduces pre-260513-sfp behaviour), setYLimitMode public method (clears UserZoomedY on explicit click so click re-engages autoscale), autoScaleY_ refactored to dispatch on mode AFTER existing precedence guards (YLimits pin / UserZoomedY / FastSense.LiveViewMode=='follow') so 260513-ovt Follow semantics are preserved. DashboardLayout duck-types widget chrome via ismethod(widget,'setYLimitMode'), so future widgets that expose Y-rescale modes opt in without touching DashboardLayout. ASCII glyphs (V/A/L) match existing Info/Detach. reflowChrome_ re-anchors on resize. toStruct omits the default so legacy dashboards stay diff-invisible. test_fastsense_widget_ylimit_modes 11/11, test_fastsense_widget_tag 7/7, test_fastsense_follow_toggle 10/10, test_dashboard_time_sync_all_pages 5/5. Verified on live industrial-plant demo, all 8 scenarios approved. Known caveat: V/A/L cluster butts against Info button (0-px gap) — inherited from pre-existing addInfoIcon 28-px-typo, explicitly out-of-scope per plan; logged in deferred-items.md | 2026-05-13 | 4db9138, cc18c7f, a9cc181 | Verified | [260513-sfp-add-auto-y-limit-control-buttons-to-fast](./quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/) | | 260513-s0y | Add Tile + Close all buttons to FastSenseCompanion top toolbar — private OpenedFigures_ tracking + syncOpenedFigures_ (walks Engines_ before tile/close-all) + public trackOpenedFigure hook (InspectorPane.onOpenDetail_ and CompanionEventViewer.openEventDashboard_ forward their figure handles). tileOpenedWindows: ceil(sqrt(N))×ceil(N/cols) grid on monitor containing the companion, 24px margin, 8px gutter, row-major top-down. Before set(Position), coerces each figure to WindowState='normal' + Units='pixels' — root cause of initial "Tile does nothing" report was DashboardEngine.render defaulting to Units='normalized' (pixel rects got treated as screen fractions, pushing figures off-canvas). closeAllOpenedWindows: snapshot + close(h) per handle (honors each figure's CloseRequestFcn). Inner toolbar grid 1×4→1×6 (Events / Live / Tile / Close all / spacer / gear; gear Layout.Column 4→6). 9 sub-tests in test_companion_tile_close_buttons.m PASS; TestFastSenseCompanion regression 64/64 PASS. Verified on live industrial-plant demo. Shipped as PR #143. | 2026-05-14 | 182d6f1, 2867caa, 1be2cc8, e58bc35, c47c0c1, db9ef88 | Shipped (PR #143) | [260513-s0y-add-tile-windows-and-close-all-windows-b](./quick/260513-s0y-add-tile-windows-and-close-all-windows-b/) | | 260526-pw3 | Show all tag events in FastSense widgets across industrial plant demo | 2026-05-26 | c475d2a | — | [260526-pw3-in-the-industrial-plant-demo-ensure-all-](./quick/260526-pw3-in-the-industrial-plant-demo-ensure-all-/) | +| 260526-r9x | Add PerTag composer mode to FastSenseCompanion — spawn one DashboardEngine window per selected tag | 2026-05-26 | abdc80b | Verified | [260526-r9x-add-pertag-composer-mode-to-fastsensecom](./quick/260526-r9x-add-pertag-composer-mode-to-fastsensecom/) | | 260519-bs4 | Add Tag Status Table window to FastSenseCompanion — new `TagStatusTableWindow.m` (classical figure, not uifigure, per CONTEXT.md), opened via new **Tags ↗** button on companion top toolbar (col 3 in the post-merge 1×7 grid: Events / Live / Tags / Tile / Close all / spacer / gear). Detached-only window with 12-column `uitable`: Key, Name, Type, Criticality, Units, Latest, Status (smart per-type — Monitor→OK/ALARM, State→state label, others→—), Last updated (X(end) timestamp), Activity (Live/Inactive at 5-min threshold), Events (count from EventStore), Samples, Labels. All 18 demo tags listed (snapshot from `TagRegistry.find(@(t)true)`). Two parallel refresh paths: (a) push-on-write via existing `FastSenseCompanion.scanLiveTagUpdates_` → `markStatusTableDirty_(keys)` when companion is in Live mode, (b) window-owned `RefreshTimer_` (1s fixedSpacing, unique UUID name, BusyMode='drop', self-stop after 2 consecutive tick errors) so the table refreshes regardless of companion's IsLive — addresses user feedback that Activity/Last updated must stay correct when companion is idle. Pause/Resume polling toggle freezes both paths (markTagsDirty becomes a no-op while paused; header shows "Last refreshed: HH:MM:SS (paused)"). "Last refreshed" heartbeat label updates every tick. Filter chips mirror TagCatalogPane pattern: Type (Sensor/Monitor/Composite/State/Derived), Criticality (Low/Medium/High/Safety), Activity (Live/Inactive) — multi-toggle, AND-across-groups / OR-within-group; broadened free-text search across Key+Name+Units+Labels. Push-on-write hook in companion stays — both mechanisms run in parallel. Six atomic commits + 1 merge: 01 base class + 11 pure-logic tests; 02 companion wiring + 7 lifecycle tests; 03 Activity column + own timer (+5 logic + 2 lifecycle tests, deviation from "push-on-write only" CONTEXT decision per user); 04 last-refreshed header + chip filters + broader search (+4 logic + 2 lifecycle tests); 05 Pause/Resume polling toggle (+4 lifecycle tests); 06 Events count column (+4 logic + 1 lifecycle test); 07 merge with main (PR #143 toolbar grid conflict). Final test counts post-merge: `test_companion_tag_status_table` 24/24 (pure-logic), `TestTagStatusTableWindow` 16/16 (UI lifecycle), `test_companion_tile_close_buttons` 9/9 (main's new test still PASS), `TestFastSenseCompanion` 64/64 (no regression) = 113/113 total. Verified end-to-end on live industrial-plant demo: 4 MonitorTags showed real event counts (29/32/33/35), 14 others showed 0; Activity flipped Live→Inactive at exactly 5-min boundary via static buildRow_ proof; companion IsLive=0 throughout (window polled itself). Deferred / out-of-scope: (1) polling-scope clarification dismissed by user (heartbeat-only vs. passive-observation vs. only-update-changed-cells — left as-is, table updates all cells every tick); (2) Info button + markdown help — scoped up to a milestone-sized "unified in-app help/wiki" effort, parked as backlog 999.1. | 2026-05-19 | b2ed937, e8a1be5, 43d2d3b, 2a24965, 50d464c, 10df740, 73a3bf1 | Verified | [260519-bs4-implement-a-new-table-view-in-the-compan](./quick/260519-bs4-implement-a-new-table-view-in-the-compan/) | ## Progress Bar