From a567d5602dd1a65296c4ed42aca48d1f0de8601b Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 10 Jun 2026 20:35:32 +0200 Subject: [PATCH 1/2] feat(dashboard): time-varying threshold specs in FastSenseWidget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastSense core has long supported dynamic limits — addThreshold(thX, thY, ...) draws step-function thresholds with full violation detection (compute_violations_dynamic + violation_cull MEX). But FastSenseWidget's 'Thresholds' option only understood scalar struct('Value',..) entries, so dashboards could never reach that capability: state-dependent limits (e.g. a tighter band only while a machine is MEASURING) had to be drawn as constant lines. Spec entries may now carry X/Y vectors instead of Value: {struct('X', thX, 'Y', thY, 'Direction', 'upper', 'Label', 'UWL')} applyThresholds_ forwards them to the core's time-varying form; NaN Y samples mean "no limit here" and break the drawn line. Scalar and time-varying entries mix freely in one spec. autoscaleY_ now folds the finite part of Y series into the auto Y-range (omitnan), matching the scalar-Value behavior. Two new suite tests cover the X/Y pass-through (fields preserved, Value stays empty) and scalar/time-varying coexistence. Co-Authored-By: Claude Fable 5 --- libs/Dashboard/FastSenseWidget.m | 33 +++++++++++++++---- tests/suite/TestFastSenseWidget.m | 55 +++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/libs/Dashboard/FastSenseWidget.m b/libs/Dashboard/FastSenseWidget.m index 68b6ff71..4940dab7 100644 --- a/libs/Dashboard/FastSenseWidget.m +++ b/libs/Dashboard/FastSenseWidget.m @@ -353,6 +353,8 @@ function render(obj, parentPanel) % 'auto' — no-op (default) % numeric scalar/vector — one upper threshold per value % cell of structs — {struct('Value',..,'Direction',..,'Label',..), ...} + % entries may instead carry X/Y vectors for a + % time-varying limit: struct('X',..,'Y',..,'Direction',..) applyThresholds_(fp, obj.Thresholds); % Set title and axis labels. @@ -894,9 +896,14 @@ function autoScaleY_(obj, y) if iscell(obj.Thresholds) for i = 1:numel(obj.Thresholds) e = obj.Thresholds{i}; - if isstruct(e) && isfield(e, 'Value') && isfinite(e.Value) + if isstruct(e) && isfield(e, 'Value') && ... + ~isempty(e.Value) && isfinite(e.Value) yMin = min(yMin, e.Value); yMax = max(yMax, e.Value); + elseif isstruct(e) && isfield(e, 'Y') && ... + ~isempty(e.Y) && any(isfinite(e.Y(:))) + yMin = min(yMin, min(e.Y(:), [], 'omitnan')); + yMax = max(yMax, max(e.Y(:), [], 'omitnan')); end end elseif isnumeric(obj.Thresholds) && ~isempty(obj.Thresholds) @@ -2023,7 +2030,9 @@ function rebuildForTag_(obj) function applyThresholds_(fp, spec) %APPLYTHRESHOLDS_ Push a Thresholds spec into a FastSense instance. % Accepts 'auto' / [] (no-op), numeric scalar/vector (upper lines), - % or a cell of structs with fields Value / Direction / Label. + % or a cell of structs: Value (scalar limit) or X / Y vectors + % (time-varying step limit; NaN Y = no limit), plus optional + % Direction / Label. if isempty(spec) return; end @@ -2040,7 +2049,12 @@ function applyThresholds_(fp, spec) if iscell(spec) for i = 1:numel(spec) e = spec{i}; - if ~isstruct(e) || ~isfield(e, 'Value') + if ~isstruct(e) + continue; + end + isTimeVarying = isfield(e, 'X') && isfield(e, 'Y') && ... + ~isempty(e.X) && ~isempty(e.Y); + if ~isTimeVarying && ~isfield(e, 'Value') continue; end dir = 'upper'; @@ -2051,10 +2065,17 @@ function applyThresholds_(fp, spec) if isfield(e, 'Label') && ~isempty(e.Label) lbl = e.Label; end - if isempty(lbl) - fp.addThreshold(e.Value, 'Direction', dir); + args = {'Direction', dir}; + if ~isempty(lbl) + args = [args, {'Label', lbl}]; %#ok + end + if isTimeVarying + % Time-varying entry — forward to the core step-function + % form addThreshold(thX, thY, ...). NaN samples in Y break + % the line where no limit applies (state-dependent limits). + fp.addThreshold(e.X, e.Y, args{:}); else - fp.addThreshold(e.Value, 'Direction', dir, 'Label', lbl); + fp.addThreshold(e.Value, args{:}); end end end diff --git a/tests/suite/TestFastSenseWidget.m b/tests/suite/TestFastSenseWidget.m index 953907cc..f17fd82f 100644 --- a/tests/suite/TestFastSenseWidget.m +++ b/tests/suite/TestFastSenseWidget.m @@ -54,6 +54,61 @@ function testRenderCreatesAxes(testCase) testCase.verifyTrue(isa(w.FastSenseObj, 'FastSense')); end + function testTimeVaryingThresholdSpecReachesFastSense(testCase) + % A 'Thresholds' entry with X/Y vectors must forward to the + % core's time-varying addThreshold(thX, thY, ...) so dashboards + % can draw state-dependent (dynamic) limits. NaN Y samples mean + % "no limit here" and break the drawn line. + hFig = figure('Visible', 'off'); + testCase.addTeardown(@() close(hFig)); + hp = uipanel('Parent', hFig, 'Units', 'normalized', ... + 'Position', [0 0 1 1]); + + thX = 1:100; + thY = 5 * ones(1, 100); + thY(40:60) = NaN; % limit inactive mid-range + w = FastSenseWidget('Thresholds', ... + {struct('X', thX, 'Y', thY, ... + 'Direction', 'upper', 'Label', 'UWL')}); + w.XData = 1:100; + w.YData = rand(1, 100); + w.render(hp); + + th = w.FastSenseObj.Thresholds; + testCase.assertNumElements(th, 1, ... + 'X/Y spec entry must create exactly one threshold'); + testCase.verifyNumElements(th(1).Y, 100, ... + 'threshold must keep its per-sample Y series'); + testCase.verifyEmpty(th(1).Value, ... + 'time-varying threshold must not carry a scalar Value'); + testCase.verifyEqual(th(1).Direction, 'upper'); + testCase.verifyEqual(th(1).Label, 'UWL'); + end + + function testMixedScalarAndTimeVaryingThresholds(testCase) + % Scalar Value entries and X/Y entries must coexist in one spec. + hFig = figure('Visible', 'off'); + testCase.addTeardown(@() close(hFig)); + hp = uipanel('Parent', hFig, 'Units', 'normalized', ... + 'Position', [0 0 1 1]); + + thX = 1:50; + thY = [2 * ones(1, 25), NaN(1, 25)]; + w = FastSenseWidget('Thresholds', { ... + struct('Value', 3, 'Direction', 'upper', 'Label', 'UAL'), ... + struct('X', thX, 'Y', thY, 'Direction', 'lower')}); + w.XData = 1:50; + w.YData = rand(1, 50); + w.render(hp); + + th = w.FastSenseObj.Thresholds; + testCase.assertNumElements(th, 2, ... + 'both spec entries must create thresholds'); + testCase.verifyEqual(th(1).Value, 3); + testCase.verifyNumElements(th(2).Y, 50); + testCase.verifyEqual(th(2).Direction, 'lower'); + end + function testToStructRoundTrip(testCase) w = FastSenseWidget('Title', 'My Plot', 'Position', [5 2 16 3]); w.XData = 1:10; From 7e3ab376593591e9ce91bc0cbe2913e7e8a89f34 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Thu, 11 Jun 2026 10:06:32 +0200 Subject: [PATCH 2/2] feat(dashboard): Color/LineStyle pass-through in threshold specs Threshold spec entries can now carry optional Color and LineStyle, forwarded to FastSense.addThreshold so dashboards can severity-style limit lines (e.g. warn yellow solid, alarm red solid) per entry instead of inheriting the theme default for every line. Co-Authored-By: Claude Fable 5 --- libs/Dashboard/FastSenseWidget.m | 8 +++++++- tests/suite/TestFastSenseWidget.m | 34 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/libs/Dashboard/FastSenseWidget.m b/libs/Dashboard/FastSenseWidget.m index 4940dab7..d9d97023 100644 --- a/libs/Dashboard/FastSenseWidget.m +++ b/libs/Dashboard/FastSenseWidget.m @@ -2032,7 +2032,7 @@ function applyThresholds_(fp, spec) % Accepts 'auto' / [] (no-op), numeric scalar/vector (upper lines), % or a cell of structs: Value (scalar limit) or X / Y vectors % (time-varying step limit; NaN Y = no limit), plus optional - % Direction / Label. + % Direction / Label / Color / LineStyle (severity styling). if isempty(spec) return; end @@ -2069,6 +2069,12 @@ function applyThresholds_(fp, spec) if ~isempty(lbl) args = [args, {'Label', lbl}]; %#ok end + if isfield(e, 'Color') && ~isempty(e.Color) + args = [args, {'Color', e.Color}]; %#ok + end + if isfield(e, 'LineStyle') && ~isempty(e.LineStyle) + args = [args, {'LineStyle', e.LineStyle}]; %#ok + end if isTimeVarying % Time-varying entry — forward to the core step-function % form addThreshold(thX, thY, ...). NaN samples in Y break diff --git a/tests/suite/TestFastSenseWidget.m b/tests/suite/TestFastSenseWidget.m index f17fd82f..62356846 100644 --- a/tests/suite/TestFastSenseWidget.m +++ b/tests/suite/TestFastSenseWidget.m @@ -109,6 +109,40 @@ function testMixedScalarAndTimeVaryingThresholds(testCase) testCase.verifyEqual(th(2).Direction, 'lower'); end + function testThresholdSpecStyleReachesFastSense(testCase) + % Color / LineStyle on a Thresholds spec entry must reach the + % core threshold (severity styling: e.g. warn yellow solid, + % alarm red solid) for both scalar and time-varying entries. + hFig = figure('Visible', 'off'); + testCase.addTeardown(@() close(hFig)); + hp = uipanel('Parent', hFig, 'Units', 'normalized', ... + 'Position', [0 0 1 1]); + + warnColor = [0.93 0.69 0.13]; + alarmColor = [0.7 0 0]; + thX = 1:50; + thY = 4 * ones(1, 50); + w = FastSenseWidget('Thresholds', { ... + struct('Value', 5, 'Direction', 'upper', 'Label', 'UAL', ... + 'Color', alarmColor, 'LineStyle', '-'), ... + struct('X', thX, 'Y', thY, 'Direction', 'upper', ... + 'Label', 'UWL', 'Color', warnColor, 'LineStyle', '-')}); + w.XData = 1:50; + w.YData = rand(1, 50); + w.render(hp); + + th = w.FastSenseObj.Thresholds; + testCase.assertNumElements(th, 2); + testCase.verifyEqual(th(1).Color, alarmColor, ... + 'scalar entry Color must pass through'); + testCase.verifyEqual(th(1).LineStyle, '-', ... + 'scalar entry LineStyle must pass through'); + testCase.verifyEqual(th(2).Color, warnColor, ... + 'time-varying entry Color must pass through'); + testCase.verifyEqual(th(2).LineStyle, '-', ... + 'time-varying entry LineStyle must pass through'); + end + function testToStructRoundTrip(testCase) w = FastSenseWidget('Title', 'My Plot', 'Position', [5 2 16 3]); w.XData = 1:10;