Skip to content

Commit 6b768cb

Browse files
committed
fix: address PR #68 code review findings
Blockers A1 IconCardWidget.deriveStateFromThreshold returned 'active' for healthy monitor-kind / CompositeThreshold branches; resolveIconColor only routes 'ok|warn|alarm|info', so those icons rendered mid-gray instead of green. Replaced both occurrences with 'ok'. A2 FastSenseWidget.autoScaleY_ clobbered user Y zoom on every live tick. Added a YLim PostSet listener + IsSettingYLim/UserZoomedY guards mirroring the existing X-zoom pattern so a mouse-scroll zoom stays pinned until the user explicitly re-resets. A3 MarkdownRenderer.getCSS still branched on legacy theme names ('industrial','ocean' → dark CSS), so info-popup contrast didn't match the aliased light theme struct. Collapsed to a single dark-vs-everything-else test matching the trimmed catalog. Design concerns B1 FastSense.addLine datenum auto-promotion now respects an explicit 'XType','numeric' NV-pair — numeric counters that happen to land in the datenum window (e.g. step counters starting at 700000) can opt out. Detected by walking raw varargin since parseOpts defaults XType to 'numeric' and can't distinguish default-from-explicit. B2 plantConfig MonitorDef field renamed to MinDurationSeconds so the unit lives in the name; the datenum-unit conversion is pulled into cfg.MonitorMinDurationFor (closure) and called once in registerPlantTags. cfg.TimeBase now documents the demo's time-base choice. Any future consumer that reads MonitorDefs directly has to acknowledge the unit in the field name. Nits + follow-ups N1 StatusWidget label path guards isempty(obj.Sensor.Y) before indexing end. N3/N4 FastSenseDefaults / FastSenseToolbar docstrings updated to list only 'light'/'dark' with a note that legacy names alias. F2 FastSenseWidget.LiveViewMode is now a public property (default 'reset') forwarded to FastSense. Callers in long-running deployments can set 'follow' for a sliding-window or 'preserve' for frozen axes, instead of the hard-coded 'reset'. F3 EventTimelineWidget severity colouring now keys off the numeric ev.Severity field (1=ok/info, 2=warn, 3=alarm, per EVENT-04) with a ThresholdLabel keyword fallback for pre-Severity events. F4 New tests/test_fastsense_datenum_autopromote.m covers the heuristic, the small-X no-promote path, the explicit-numeric opt-out, the explicit-datenum opt-in, the straddle case, and empty-X. Examples example_themes.m and example_toolbar.m updated to use the surviving 'light'/'dark' preset names. N5 (suggested dead VisibleRows assignment) was rejected on closer look — the assignment at allocatePanels:334 runs AFTER TotalRows has been accumulated, so it refreshes the visible window against the newly expanded total row count; ensureViewport's earlier call to computeVisibleRows uses the pre-accumulation value. Regression suite: 81/81 (new test file picked up automatically).
1 parent ec29280 commit 6b768cb

13 files changed

Lines changed: 215 additions & 79 deletions

demo/industrial_plant/private/plantConfig.m

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@
107107
% release threshold) as a pair of function handles.
108108
cfg.MonitorDefs = mkMonitorDefs_();
109109

110+
% Demo time-base: the data generator timestamps samples with MATLAB
111+
% datenum (days since 0000-01-00). MonitorTag.MinDuration is measured
112+
% in parent-X native units, so seconds → days.
113+
cfg.TimeBase = 'datenum';
114+
cfg.SecondsPerTimeUnit = 86400; % days → seconds; inverse factor below
115+
cfg.MonitorMinDurationFor = @(seconds) seconds / 86400;
116+
110117
% ---- Display thresholds for FastSense plots -----------------------
111118
% Parallel table to MonitorDefs that expresses the trip value in a
112119
% form diagram widgets can draw as a horizontal line. Each entry is
@@ -146,16 +153,23 @@
146153
@(x,y) y < 20, @(x,y) y > 30, 2, 'medium', 'Cooling')];
147154
end
148155

149-
function d = mkDef_(key, parentKey, condFn, offFn, minDur, crit, sub)
156+
function d = mkDef_(key, parentKey, condFn, offFn, minDurSeconds, crit, sub)
150157
%MKDEF_ Build one MonitorDef entry.
158+
% Debounce is authored in SECONDS (`MinDurationSeconds`) so the
159+
% config stays human-readable. The demo's parent SensorTags time-
160+
% stamp samples with MATLAB datenum (days), so registerPlantTags
161+
% converts via cfg.MonitorMinDurationDays(mDef.MinDurationSeconds)
162+
% before handing the value to MonitorTag.MinDuration. Any other
163+
% consumer that wants to read this directly must be aware of the
164+
% unit — the field name carries that contract.
151165
d = struct( ...
152-
'Key', key, ...
153-
'ParentKey', parentKey, ...
154-
'ConditionFn', condFn, ...
155-
'AlarmOffFn', offFn, ...
156-
'MinDuration', minDur, ...
157-
'Criticality', crit, ...
158-
'Subsystem', sub);
166+
'Key', key, ...
167+
'ParentKey', parentKey, ...
168+
'ConditionFn', condFn, ...
169+
'AlarmOffFn', offFn, ...
170+
'MinDurationSeconds', minDurSeconds, ...
171+
'Criticality', crit, ...
172+
'Subsystem', sub);
159173
end
160174

161175
function t = mkDisplayThresholds_()

demo/industrial_plant/private/registerPlantTags.m

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,18 +94,15 @@
9494

9595
mDefs = cfg.MonitorDefs; % expects 4 entries in fixed order, see plantConfig
9696

97-
% MonitorTag.MinDuration is expressed in parent-X native units. The
98-
% demo's parent SensorTags carry time as MATLAB datenum (days since
99-
% 0000-01-00), so a config value of "1 second" must be rescaled to
100-
% 1/86400 days. Without this, a 3-second breach reads as 3.5e-5 days
101-
% and always falls below the 1-day debounce window → monitor never
102-
% fires and StatusWidget / IconCardWidget stay green.
103-
secondsPerDay = 86400;
97+
% MinDurationSeconds carries debounce in human-readable seconds;
98+
% cfg.MonitorMinDurationFor converts to the parent-X native unit
99+
% (datenum days in this demo). See plantConfig.mkDef_ docstring.
100+
toMinDuration = cfg.MonitorMinDurationFor;
104101

105102
mFeedlinePressureHigh = MonitorTag(mDefs(1).Key, ...
106103
TagRegistry.get(mDefs(1).ParentKey), mDefs(1).ConditionFn, ...
107104
'AlarmOffConditionFn', mDefs(1).AlarmOffFn, ...
108-
'MinDuration', mDefs(1).MinDuration / secondsPerDay, ...
105+
'MinDuration', toMinDuration(mDefs(1).MinDurationSeconds), ...
109106
'Criticality', mDefs(1).Criticality, ...
110107
'EventStore', store, ...
111108
'Name', prettyName_(mDefs(1).Key));
@@ -114,7 +111,7 @@
114111
mReactorPressureCritical = MonitorTag(mDefs(2).Key, ...
115112
TagRegistry.get(mDefs(2).ParentKey), mDefs(2).ConditionFn, ...
116113
'AlarmOffConditionFn', mDefs(2).AlarmOffFn, ...
117-
'MinDuration', mDefs(2).MinDuration / secondsPerDay, ...
114+
'MinDuration', toMinDuration(mDefs(2).MinDurationSeconds), ...
118115
'Criticality', mDefs(2).Criticality, ...
119116
'EventStore', store, ...
120117
'Name', prettyName_(mDefs(2).Key));
@@ -123,7 +120,7 @@
123120
mReactorTemperatureHigh = MonitorTag(mDefs(3).Key, ...
124121
TagRegistry.get(mDefs(3).ParentKey), mDefs(3).ConditionFn, ...
125122
'AlarmOffConditionFn', mDefs(3).AlarmOffFn, ...
126-
'MinDuration', mDefs(3).MinDuration / secondsPerDay, ...
123+
'MinDuration', toMinDuration(mDefs(3).MinDurationSeconds), ...
127124
'Criticality', mDefs(3).Criticality, ...
128125
'EventStore', store, ...
129126
'Name', prettyName_(mDefs(3).Key));
@@ -132,7 +129,7 @@
132129
mCoolingFlowLow = MonitorTag(mDefs(4).Key, ...
133130
TagRegistry.get(mDefs(4).ParentKey), mDefs(4).ConditionFn, ...
134131
'AlarmOffConditionFn', mDefs(4).AlarmOffFn, ...
135-
'MinDuration', mDefs(4).MinDuration / secondsPerDay, ...
132+
'MinDuration', toMinDuration(mDefs(4).MinDurationSeconds), ...
136133
'Criticality', mDefs(4).Criticality, ...
137134
'EventStore', store, ...
138135
'Name', prettyName_(mDefs(4).Key));

examples/01-basics/example_themes.m

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
y2 = cos(x*2*pi/8) + 0.3*randn(1,n);
1212
y3 = 0.5*sin(x*2*pi/5) + 0.15*randn(1,n);
1313

14-
%% 1. All 6 built-in theme presets
15-
themes = {'default', 'dark', 'light', 'industrial', 'scientific', 'ocean'};
14+
%% 1. Built-in theme presets ('light' and 'dark')
15+
% Legacy preset names ('default', 'industrial', 'scientific', 'ocean')
16+
% are accepted and aliased to 'light' for backward compatibility.
17+
themes = {'light', 'dark'};
1618

1719
for i = 1:numel(themes)
1820
themeName = themes{i};
@@ -26,7 +28,7 @@
2628
title(fp.hAxes, sprintf('Theme: %s', themeName));
2729
end
2830

29-
fprintf('All 6 themes rendered. Compare the styles!\n');
31+
fprintf('Both themes rendered. Compare the styles!\n');
3032

3133
%% 2. Color palettes via LineColorOrder ('vibrant', 'muted', 'colorblind')
3234
palettes = {'vibrant', 'muted', 'colorblind'};
@@ -50,9 +52,9 @@
5052
title(fp.hAxes, 'Custom theme overrides (dark + larger font/lines)');
5153

5254
%% 4. reapplyTheme — switch theme on a rendered plot
53-
fp.Theme = FastSenseTheme('ocean');
55+
fp.Theme = FastSenseTheme('light');
5456
fp.reapplyTheme();
55-
title(fp.hAxes, 'reapplyTheme: switched from dark to ocean');
57+
title(fp.hAxes, 'reapplyTheme: switched from dark to light');
5658
fprintf('reapplyTheme() demo: switched theme on an already-rendered plot.\n');
5759

5860
%% 5. FastSenseDefaults — inspect global defaults

examples/01-basics/example_toolbar.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
fprintf('Try: Data Cursor (click), Crosshair (hover), Grid, Legend, Autoscale Y, Export PNG\n');
2626

2727
%% Dashboard with toolbar
28-
fig = FastSenseGrid(1, 2, 'Theme', 'industrial');
28+
fig = FastSenseGrid(1, 2, 'Theme', 'dark');
2929
fp1 = fig.tile(1);
3030
fp1.addLine(x, y1, 'DisplayName', 'Pressure');
3131
fp1.addThreshold(1.0, 'Direction', 'upper', 'ShowViolations', true);

libs/Dashboard/EventTimelineWidget.m

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,11 +297,16 @@ function refresh(obj)
297297
if ~isempty(ev.ThresholdLabel)
298298
lbl = [ev.SensorName '' ev.ThresholdLabel];
299299
end
300-
% Color based on direction/severity hint in label
301-
if ~isempty(strfind(lower(ev.ThresholdLabel), 'alarm'))
300+
% Colour routing is driven by the numeric Severity field
301+
% (1=ok/info, 2=warn, 3=alarm; see Event.m EVENT-04) with
302+
% a ThresholdLabel keyword fallback for events authored
303+
% before Severity existed.
304+
clr = warnColor;
305+
if isfield(ev, 'Severity') && ~isempty(ev.Severity) && ev.Severity >= 3
306+
clr = alarmColor;
307+
elseif ~isfield(ev, 'Severity') && ~isempty(ev.ThresholdLabel) && ...
308+
~isempty(strfind(lower(ev.ThresholdLabel), 'alarm'))
302309
clr = alarmColor;
303-
else
304-
clr = warnColor;
305310
end
306311
evts(end+1) = struct('startTime', ev.StartTime, ...
307312
'endTime', ev.EndTime, 'label', lbl, 'color', clr); %#ok<AGROW>

libs/Dashboard/FastSenseWidget.m

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,23 @@
1919
YLabel = '' % Y-axis label (auto-set from Sensor if empty)
2020
YLimits = [] % Fixed Y-axis range [min max]; empty = auto-scale
2121
ShowThresholdLabels = false % show inline name labels on threshold lines
22+
% Forwarded to FastSense.LiveViewMode on render:
23+
% 'reset' — window covers the full X range every tick (default:
24+
% matches dashboard-demo expectation that users see
25+
% every sample since session start)
26+
% 'follow' — window of current width tracks the latest sample
27+
% (use for long-running deployments where the full
28+
% range would exhaust memory / downsampling budget)
29+
% 'preserve' — frozen at the initial X range (legacy behaviour)
30+
LiveViewMode = 'reset'
2231
end
2332
% (Tag property now lives on the DashboardWidget base class — Plan 1009-02.)
2433

2534
properties (SetAccess = private)
2635
FastSenseObj = []
2736
IsSettingTime = false % guard to distinguish programmatic vs user xlim change
37+
IsSettingYLim = false % guard so autoScaleY_ does not flip UserZoomedY
38+
UserZoomedY = false % true after user mouse-zooms Y; suspends autoScaleY_
2839
CachedXMin = inf % cached minimum of X data for O(1) getTimeRange()
2940
CachedXMax = -inf % cached maximum of X data for O(1) getTimeRange()
3041
LastTagRef = [] % Tag handle snapshot for cache-invalidation
@@ -71,14 +82,14 @@ function render(obj, parentPanel)
7182
obj.FastSenseObj = fp;
7283
fp.ShowThresholdLabels = obj.ShowThresholdLabels;
7384

74-
% Slide the X window as new samples arrive on updateData(). The
75-
% default empty LiveViewMode leaves the window frozen at the
76-
% initial render's data range, so later samples appear off the
77-
% right edge of live widgets. 'reset' grows the window to cover
78-
% the full current X range each tick — appropriate for
79-
% dashboard use where users expect to see all data since the
80-
% live pipeline started.
81-
fp.LiveViewMode = 'reset';
85+
% Slide the X window as new samples arrive on updateData().
86+
% Forwarded from the widget-level LiveViewMode property so
87+
% callers can swap between 'reset' (default: window grows to
88+
% cover all samples — best for short demos), 'follow' (fixed-
89+
% width window tracking the latest sample — best for long-
90+
% running deployments), and 'preserve' (frozen at the initial
91+
% X range — legacy behaviour).
92+
fp.LiveViewMode = obj.LiveViewMode;
8293

8394
% Bind data — Tag-first dispatch (v2.0).
8495
if ~isempty(obj.Tag)
@@ -148,6 +159,12 @@ function render(obj, parentPanel)
148159
addlistener(ax, 'XLim', 'PostSet', @(~,~) obj.onXLimChanged());
149160
catch
150161
end
162+
% Listen for manual Y zoom so autoScaleY_ stops fighting the
163+
% user after a scroll / drag / programmatic ylim.
164+
try
165+
addlistener(ax, 'YLim', 'PostSet', @(~,~) obj.onYLimChanged());
166+
catch
167+
end
151168
end
152169

153170
function refresh(obj)
@@ -205,10 +222,16 @@ function autoScaleY_(obj, y)
205222
% samples outside the initial range would fall off the chart.
206223
% This helper recomputes the Y extent every tick (including any
207224
% threshold values so MonitorTag lines stay visible) and updates
208-
% the axes. Skipped when the widget has a user-pinned YLimits.
225+
% the axes. Skipped when:
226+
% - the widget has a user-pinned YLimits NV-pair, or
227+
% - the user manually zoomed Y via mouse (UserZoomedY),
228+
% so we never fight an explicit human interaction.
209229
if ~isempty(obj.YLimits)
210230
return;
211231
end
232+
if obj.UserZoomedY
233+
return;
234+
end
212235
if isempty(obj.FastSenseObj) || ~obj.FastSenseObj.IsRendered
213236
return;
214237
end
@@ -241,7 +264,25 @@ function autoScaleY_(obj, y)
241264
else
242265
pad = max(abs(yMax) * 0.1, 1);
243266
end
244-
set(ax, 'YLim', [yMin - pad, yMax + pad]);
267+
obj.IsSettingYLim = true;
268+
try
269+
set(ax, 'YLim', [yMin - pad, yMax + pad]);
270+
catch
271+
end
272+
obj.IsSettingYLim = false;
273+
end
274+
275+
function onYLimChanged(obj)
276+
%ONYLIMCHANGED Detach widget from automatic Y rescale after user zoom.
277+
% Fired by the YLim PostSet listener. When the YLim change came
278+
% from inside autoScaleY_ (IsSettingYLim==true) we ignore it; any
279+
% other source — mouse scroll, drag, zoom toolbar, programmatic
280+
% ylim() from user code — counts as a manual override and
281+
% latches UserZoomedY so live ticks stop fighting the user.
282+
if obj.IsSettingYLim
283+
return;
284+
end
285+
obj.UserZoomedY = true;
245286
end
246287

247288
function setTimeRange(obj, tStart, tEnd)

libs/Dashboard/IconCardWidget.m

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ function refresh(obj)
383383
% CompositeThreshold: delegate to computeStatus, no val needed (per D-04)
384384
if isa(obj.Threshold, 'CompositeThreshold')
385385
cStatus = obj.Threshold.computeStatus();
386-
if strcmp(cStatus, 'ok'), state = 'active'; else, state = 'alarm'; end
386+
if strcmp(cStatus, 'ok'), state = 'ok'; else, state = 'alarm'; end
387387
return;
388388
end
389389
% Monitor-kind tags are their own binary alarm signal.
@@ -398,7 +398,7 @@ function refresh(obj)
398398
if val(end) > 0.5
399399
state = 'alarm';
400400
else
401-
state = 'active';
401+
state = 'ok';
402402
end
403403
return;
404404
end

libs/Dashboard/MarkdownRenderer.m

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -297,34 +297,29 @@ bodyHtml char(10) ...
297297
end
298298

299299
function css = getCSS(themeName)
300-
switch themeName
301-
case {'dark', 'industrial', 'ocean'}
302-
bg = '#1a1a2e';
303-
fg = '#d4d4d4';
304-
codeBg = '#2d2d44';
305-
linkColor = '#5ca8e6';
306-
hrColor = '#3a3a5c';
307-
tableBorder = '#3a3a5c';
308-
tableHeadBg = '#2d2d44';
309-
tableStripeBg = '#22223a';
310-
case {'light', 'scientific'}
311-
bg = '#ffffff';
312-
fg = '#2d2d2d';
313-
codeBg = '#f4f4f4';
314-
linkColor = '#0066cc';
315-
hrColor = '#ddd';
316-
tableBorder = '#ddd';
317-
tableHeadBg = '#f4f4f4';
318-
tableStripeBg = '#fafafa';
319-
otherwise
320-
bg = '#ffffff';
321-
fg = '#2d2d2d';
322-
codeBg = '#f4f4f4';
323-
linkColor = '#0066cc';
324-
hrColor = '#ddd';
325-
tableBorder = '#ddd';
326-
tableHeadBg = '#f4f4f4';
327-
tableStripeBg = '#fafafa';
300+
% Only 'dark' and 'light' remain as distinct presets. Every
301+
% other name (empty, 'default', or a legacy alias like
302+
% 'industrial' / 'scientific' / 'ocean') resolves to the light
303+
% CSS palette, matching how FastSenseTheme / DashboardTheme
304+
% alias those names to their 'light' structs.
305+
if strcmp(themeName, 'dark')
306+
bg = '#1a1a2e';
307+
fg = '#d4d4d4';
308+
codeBg = '#2d2d44';
309+
linkColor = '#5ca8e6';
310+
hrColor = '#3a3a5c';
311+
tableBorder = '#3a3a5c';
312+
tableHeadBg = '#2d2d44';
313+
tableStripeBg = '#22223a';
314+
else
315+
bg = '#ffffff';
316+
fg = '#2d2d2d';
317+
codeBg = '#f4f4f4';
318+
linkColor = '#0066cc';
319+
hrColor = '#ddd';
320+
tableBorder = '#ddd';
321+
tableHeadBg = '#f4f4f4';
322+
tableStripeBg = '#fafafa';
328323
end
329324
css = sprintf([ ...
330325
'body { font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; ' ...

libs/Dashboard/StatusWidget.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ function refresh(obj)
144144
lbl = sprintf('%s: %s', obj.Title, upper(obj.CurrentStatus));
145145
end
146146
end
147-
elseif ~isempty(obj.Sensor)
147+
elseif ~isempty(obj.Sensor) && ~isempty(obj.Sensor.Y)
148148
val = obj.Sensor.Y(end);
149149
units = '';
150150
if ~isempty(obj.Sensor.Units)

libs/FastSense/FastSense.m

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,20 @@ function addLine(obj, x, y, varargin)
408408
knownDefaults.DataStore = [];
409409
[known, passthrough] = parseOpts(knownDefaults, varargin, obj.Verbose);
410410

411+
% Detect whether the caller explicitly passed 'XType' as an
412+
% NV-pair. parseOpts defaults it to 'numeric', so an
413+
% unspecified call is indistinguishable from
414+
% 'XType','numeric' at the parsed-options layer — we walk
415+
% raw varargin here to tell them apart.
416+
explicitXType = false;
417+
for k = 1:2:numel(varargin)-1
418+
if (ischar(varargin{k}) || (isstring(varargin{k}) && isscalar(varargin{k}))) ...
419+
&& strcmpi(char(varargin{k}), 'XType')
420+
explicitXType = true;
421+
break;
422+
end
423+
end
424+
411425
% Set XType if explicitly provided
412426
if strcmp(known.XType, 'datenum')
413427
obj.XType = 'datenum';
@@ -416,11 +430,14 @@ function addLine(obj, x, y, varargin)
416430

417431
% Auto-promote XType to 'datenum' when X values fall inside the
418432
% MATLAB serial-date range for years 1910-2100 (datenum 697000
419-
% .. 769000). This makes Tag/SensorTag data that is timestamped
420-
% with `now()` render with proper date ticks instead of raw
421-
% scientific-notation numbers, without requiring every caller
422-
% to pass 'XType', 'datenum' by hand. Skipped when X is empty.
423-
if strcmp(obj.XType, 'numeric') && isnumeric(x) && ~isempty(x)
433+
% .. 769000). Lets Tag/SensorTag data timestamped with `now()`
434+
% render with proper date ticks instead of scientific-notation
435+
% numbers, without requiring every caller to pass 'XType',
436+
% 'datenum' by hand. Suppressed when the caller explicitly
437+
% asked for 'numeric' (numeric counters / indices that happen
438+
% to land in the datenum window must be able to opt out).
439+
if ~explicitXType && strcmp(obj.XType, 'numeric') && ...
440+
isnumeric(x) && ~isempty(x)
424441
xMinProbe = min(x);
425442
xMaxProbe = max(x);
426443
if isfinite(xMinProbe) && isfinite(xMaxProbe) && ...

0 commit comments

Comments
 (0)