Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .claude/scheduled_tasks.lock

This file was deleted.

4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,7 @@ wiki-gen-summary.md
# .planning/ and docs/superpowers/ are tracked GSD planning docs (un-ignored intentionally).
.superpowers/
.matlab-tests-passed

# Agent working files — machine-generated, not project docs.
docs/.feature-ideas/
.claude/scheduled_tasks.lock
2 changes: 1 addition & 1 deletion benchmarks/benchmark.m
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
% 4. Linked axes with automatic re-downsample
% In MATLAB (with JIT compilation), the downsampling cost is much lower.

addpath(fullfile(fileparts(mfilename('fullpath')), '..'));install();
addpath(fullfile(fileparts(mfilename('fullpath')), '..')); install();
addpath(fullfile(fileparts(mfilename('fullpath')), '..', 'libs', 'FastSense', 'private'));

sizes = [1e4, 1e5, 1e6, 5e6, 10e6, 50e6];
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/benchmark_features.m
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
% 6. FastSenseGrid tiled dashboard overhead
% 7. Combined: all features together vs baseline

addpath(fullfile(fileparts(mfilename('fullpath')), '..'));install();
addpath(fullfile(fileparts(mfilename('fullpath')), '..')); install();
addpath(fullfile(fileparts(mfilename('fullpath')), '..', 'libs', 'FastSense', 'private'));

sizes = [1e4, 1e5, 1e6, 5e6, 10e6];
Expand Down
3 changes: 1 addition & 2 deletions benchmarks/benchmark_memory.m
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function benchmark_memory()
% Example:
% benchmark_memory();

addpath(fullfile(fileparts(mfilename('fullpath')), '..'));install();
addpath(fullfile(fileparts(mfilename('fullpath')), '..')); install();

fprintf('=== FastSense Memory Benchmark ===\n\n');

Expand Down Expand Up @@ -129,7 +129,6 @@ function benchmark_memory()
fprintf('\n=== Benchmark complete ===\n');
end


function s = formatPoints(n)
%FORMATPOINTS Format point count as human-readable string.
if n >= 1e6
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/benchmark_zoom.m
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
% Measures per-frame latency for individual zoom and pan operations.
% Forces GPU flush with getframe() to get true frame delivery time.

addpath(fullfile(fileparts(mfilename('fullpath')), '..'));install();
addpath(fullfile(fileparts(mfilename('fullpath')), '..')); install();
addpath(fullfile(fileparts(mfilename('fullpath')), '..', 'libs', 'FastSense', 'private'));

sizes = [1e5, 1e6, 10e6, 50e6];
Expand Down
3 changes: 1 addition & 2 deletions benchmarks/profile_datastore.m
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ function profile_datastore()
% 3. Full render with disk-backed lines
% 4. Zoom/pan re-downsample cycle

addpath(fullfile(fileparts(mfilename('fullpath')), '..'));install();
addpath(fullfile(fileparts(mfilename('fullpath')), '..')); install();

n = 5e6; % 5M points — enough to see bottlenecks, fast enough to iterate
fprintf('Generating %dM points...\n', n/1e6);
Expand Down Expand Up @@ -109,7 +109,6 @@ function profile_datastore()
fprintf('Run "profile viewer" to inspect detailed call trees.\n');
end


function printTopFunctions(label)
%PRINTTOPFUNCTIONS Print top 15 functions by total time from profiler.
stats = profile('info');
Expand Down
4 changes: 2 additions & 2 deletions demo/industrial_plant/buildSensorExcursions.m
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
tRel = (tHist - tHist(1)) * 86400; % seconds since first sample

% Baseline: sine + Gaussian noise (matches makeDataGenerator's model).
y = b.mean + b.amp * sin(2*pi*tRel/b.period + b.phase) ...
+ b.noise * randn(size(tRel));
y = b.mean + b.amp * sin(2*pi*tRel/b.period + b.phase) + ...
b.noise * randn(size(tRel));

% Excursion overlay (monitored sensors only). Task 3 fills this in;
% for now this is a no-op.
Expand Down
8 changes: 4 additions & 4 deletions demo/industrial_plant/private/buildDashboard.m
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,13 @@ function demoClose_(fig, ctx)
% down (writer + pipeline keep running so the companion's catalog
% stays live). If the companion is already closed or absent, a full
% teardownDemo runs so the writer + pipeline don't leak.
companionAlive = isfield(ctx, 'companion') && ~isempty(ctx.companion) ...
&& isvalid(ctx.companion) && ctx.companion.IsOpen;
companionAlive = isfield(ctx, 'companion') && ~isempty(ctx.companion) && ...
isvalid(ctx.companion) && ctx.companion.IsOpen;
if companionAlive
% Stop dashboard's live timer; leave writer/pipeline running.
try
if ~isempty(ctx.engine) && isvalid(ctx.engine) ...
&& ismethod(ctx.engine, 'stopLive')
if ~isempty(ctx.engine) && isvalid(ctx.engine) && ...
ismethod(ctx.engine, 'stopLive')
ctx.engine.stopLive();
end
catch
Expand Down
4 changes: 2 additions & 2 deletions demo/industrial_plant/private/makeDataGenerator.m
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ function industrialPlantTick_(tObj, ~)
b = cfg.Baselines.(field);
rng = cfg.Ranges.(field);

y = b.mean + b.amp * sin(2*pi*tRel/b.period + b.phase) ...
+ b.noise * randn();
y = b.mean + b.amp * sin(2*pi*tRel/b.period + b.phase) + ...
b.noise * randn();

% Inject deliberate anomalies for event demo (D-11 in CONTEXT).
if strcmp(key, 'reactor.pressure')
Expand Down
9 changes: 4 additions & 5 deletions demo/industrial_plant/run_demo.m
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,12 @@ function demoClose_(fig, ctx)
% down (writer + pipeline keep running so the companion's catalog
% stays live). If the companion is already closed or absent, a full
% teardownDemo runs so the writer + pipeline don't leak.
companionAlive = isfield(ctx, 'companion') && ~isempty(ctx.companion) ...
&& isvalid(ctx.companion) && ctx.companion.IsOpen;
companionAlive = isfield(ctx, 'companion') && ~isempty(ctx.companion) && ...
isvalid(ctx.companion) && ctx.companion.IsOpen;
if companionAlive
try
if ~isempty(ctx.engine) && isvalid(ctx.engine) ...
&& ismethod(ctx.engine, 'stopLive')
if ~isempty(ctx.engine) && isvalid(ctx.engine) && ...
ismethod(ctx.engine, 'stopLive')
ctx.engine.stopLive();
end
catch
Expand Down Expand Up @@ -204,4 +204,3 @@ function waitForFirstSample_(tagKey, maxSeconds)
pause(0.1);
end
end

2 changes: 1 addition & 1 deletion demo/industrial_plant/seedHistory.m
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function seedHistory(store, cfg)
% --- Time vector ---------------------------------------------------
nowRef = now();
nDays = 7;
tHist = (nowRef - nDays : 1/86400 : nowRef)';
tHist = (nowRef - nDays:1/86400:nowRef)';

% --- 1) Sensor history --------------------------------------------
for i = 1:numel(cfg.SensorKeys)
Expand Down
2 changes: 1 addition & 1 deletion demo/industrial_plant/seedPlantLog.m
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@

% Build the 30-entry historical pool (shift-pattern times across days 0..6).
% Mix shift-starts with overnight maintenance entries for variety.
rows = { ...
rows = {
% daysAgo secondsIntoDay message unit shift operator
6, shiftA, 'Operator Mehta starting morning shift, all systems nominal', 'ALL', 'A', 'Mehta'; ...
6, shiftB, 'Routine maintenance: cooling pump filter changed', 'Cooling', 'B', 'Yamamoto'; ...
Expand Down
7 changes: 3 additions & 4 deletions install.m
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@
% smoke) gets it. Best-effort: honour FASTSENSE_SKIP_BUILD (CI with
% cached/prebuilt binaries) and never block install() on a missing
% C compiler (FileLock falls back to pure-MATLAB sidecar mode).
if isempty(getenv('FASTSENSE_SKIP_BUILD')) ...
&& exist('lockfile_mex', 'file') ~= 3 ...
&& exist('build_concurrency_mex', 'file') == 2
if isempty(getenv('FASTSENSE_SKIP_BUILD')) && ...
exist('lockfile_mex', 'file') ~= 3 && ...
exist('build_concurrency_mex', 'file') == 2
try
build_concurrency_mex();
catch concErr
Expand Down Expand Up @@ -296,7 +296,6 @@ function verify_installation(root)
end
end


function tag = get_octave_platform_tag()
%GET_OCTAVE_PLATFORM_TAG Return platform tag string for Octave subdir layout.
% Returns a char such as 'macos-arm64', 'linux-x86_64', etc., or '' when
Expand Down
6 changes: 3 additions & 3 deletions libs/Concurrency/AtomicWriter.m
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,9 @@ function write(finalPath, payloadFn, identity, opts)
end
stampId = AtomicWriter.optGet_(opts, 'StampIdentity', false);
pid = double(ClusterIdentity.pid());
eps = char(datetime('now', 'TimeZone', 'UTC'), 'yyyyMMddHHmmssSSS');
stamp = char(datetime('now', 'TimeZone', 'UTC'), 'yyyyMMddHHmmssSSS');
rnd = sprintf('%06d', randi([0 999999]));
tempPath = sprintf('%s.tmp.%d.%s.%s', finalPath, pid, eps, rnd);
tempPath = sprintf('%s.tmp.%d.%s.%s', finalPath, pid, stamp, rnd);
try
payloadFn(tempPath);
catch err
Expand All @@ -149,7 +149,7 @@ function write(finalPath, payloadFn, identity, opts)

if stampId
sidecarTemp = sprintf('%s.identity.tmp.%d.%s.%s', ...
finalPath, pid, eps, rnd);
finalPath, pid, stamp, rnd);
sidecarFinal = [finalPath, '.identity.json'];
fid = fopen(sidecarTemp, 'w');
if fid > 0
Expand Down
12 changes: 6 additions & 6 deletions libs/Concurrency/FileLock.m
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,8 @@ function delete(obj)
obj.identity_ = ClusterIdentity.resolve();
pid = double(obj.identity_.pid);
rnd = sprintf('%06d', randi([0, 999999]));
eps = char(datetime('now', 'TimeZone', 'UTC'), 'yyyyMMddHHmmssSSS');
tmpBody = sprintf('%s.tmp.%d.%s.%s', obj.bodyPath_, pid, eps, rnd);
stamp = char(datetime('now', 'TimeZone', 'UTC'), 'yyyyMMddHHmmssSSS');
tmpBody = sprintf('%s.tmp.%d.%s.%s', obj.bodyPath_, pid, stamp, rnd);

% Write tentative body to temp file.
txt = LockFileFormat.encodeBody(obj.identity_, obj.Key);
Expand Down Expand Up @@ -487,9 +487,9 @@ function writeBody_(obj)
%WRITEBODY_ Write the identity body file atomically.
txt = LockFileFormat.encodeBody(obj.identity_, obj.Key);
pid = double(obj.identity_.pid);
eps = char(datetime('now', 'TimeZone', 'UTC'), 'yyyyMMddHHmmssSSS');
stamp = char(datetime('now', 'TimeZone', 'UTC'), 'yyyyMMddHHmmssSSS');
rnd = sprintf('%06d', randi([0, 999999]));
tmpBp = sprintf('%s.tmp.%d.%s.%s', obj.bodyPath_, pid, eps, rnd);
tmpBp = sprintf('%s.tmp.%d.%s.%s', obj.bodyPath_, pid, stamp, rnd);
fid = fopen(tmpBp, 'w');
if fid < 0
% Non-fatal: body write failed; heartbeat will retry.
Expand Down Expand Up @@ -534,9 +534,9 @@ function heartbeat_(obj)
fclose(fid);
txt = LockFileFormat.updateHeartbeat(txt);
pid = double(obj.identity_.pid);
eps = char(datetime('now', 'TimeZone', 'UTC'), 'yyyyMMddHHmmssSSS');
stamp = char(datetime('now', 'TimeZone', 'UTC'), 'yyyyMMddHHmmssSSS');
rnd = sprintf('%06d', randi([0, 999999]));
tmpBp = sprintf('%s.hb.%d.%s.%s', obj.bodyPath_, pid, eps, rnd);
tmpBp = sprintf('%s.hb.%d.%s.%s', obj.bodyPath_, pid, stamp, rnd);
fid2 = fopen(tmpBp, 'w');
if fid2 < 0; return; end
fprintf(fid2, '%s', txt);
Expand Down
21 changes: 17 additions & 4 deletions libs/Dashboard/IconCardWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -354,10 +354,23 @@ function refresh(obj)
methods (Access = private)
function relayout_(obj)
%RELAYOUT_ Rebuild pixel-scaled elements on panel resize.
if isempty(obj.hPanel) || ~ishandle(obj.hPanel), return; end
try DashboardWidget.clearPanelControls(obj.hPanel); catch, end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'axes')); catch, end
obj.render(obj.hPanel);
% Skip when the panel is gone or mid-teardown: a SizeChangedFcn can
% fire while the figure is closing, where the panel still passes
% ishandle() but its children (and any axes render() creates) die
% under us -> fill()/newplot throws. BeingDeleted catches that;
% the try/catch is belt-and-suspenders for the racy window since
% this relayout is purely cosmetic.
if isempty(obj.hPanel) || ~ishandle(obj.hPanel) || ...
strcmp(get(obj.hPanel, 'BeingDeleted'), 'on')
return;
end
try
DashboardWidget.clearPanelControls(obj.hPanel);
delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'axes'));
obj.render(obj.hPanel);
catch
% resize raced figure teardown; nothing to relayout.
end
end

function color = resolveIconColor(obj, theme)
Expand Down
16 changes: 12 additions & 4 deletions libs/Dashboard/SparklineCardWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,24 @@ function refresh(obj)
nPts = min(obj.NSparkPoints, numel(yData));
ySnip = yData(end - nPts + 1:end);

% Flat-data guard — ensure y-range is non-zero
yMin = min(ySnip);
yMax = max(ySnip);
% Range guards for empty / flat / all-NaN sparkdata (e.g. a
% real-mode sensor with no samples leaves ySnip all-NaN, so
% min/max are NaN). Keep the limits finite and strictly
% increasing so the set() below never throws on [NaN NaN], a
% zero-width span, or a single-point XLim — it just renders blank.
yMin = min(ySnip);
yMax = max(ySnip);
if ~isfinite(yMin) || ~isfinite(yMax)
yMin = 0; yMax = 1;
end
yRange = yMax - yMin;
if yRange == 0
yRange = 1;
end
xHi = max(nPts, 2);

if ~isempty(obj.hSparkAx) && ishandle(obj.hSparkAx)
set(obj.hSparkAx, 'XLim', [1 nPts], ...
set(obj.hSparkAx, 'XLim', [1 xHi], ...
'YLim', [yMin - 0.1 * yRange, yMax + 0.1 * yRange]);
end

Expand Down
57 changes: 49 additions & 8 deletions libs/FastSense/FastSense.m
Original file line number Diff line number Diff line change
Expand Up @@ -1123,13 +1123,8 @@ function render(obj, progressBar)
set(obj.hAxes, 'XScale', obj.XScale);
set(obj.hAxes, 'YScale', obj.YScale);

% --- Compute full X range (X is sorted, just check endpoints) ---
xmin = Inf; xmax = -Inf;
for i = 1:numel(obj.Lines)
[xiMin, xiMax] = obj.lineXRange(i);
if xiMin < xmin; xmin = xiMin; end
if xiMax > xmax; xmax = xiMax; end
end
% --- Compute full X range (normalised for empty/degenerate data) ---
[xmin, xmax] = obj.computeFullXRange();

% --- Render bands (constant y, full x span, back layer) ---
for i = 1:numel(obj.Bands)
Expand Down Expand Up @@ -1473,6 +1468,11 @@ function render(obj, progressBar)
yLimHigh = ymax + yPad;
end

% Mirror the X-range guard for Y: with every line empty the linear
% branch above leaves ymin/ymax at Inf/-Inf (the log branch already
% falls back to [0.1 1]). Normalise before applying the limits.
[yLimLow, yLimHigh] = FastSense.normalizeFiniteRange(yLimLow, yLimHigh);

set(obj.hAxes, 'XLim', [xmin, xmax]);
set(obj.hAxes, 'YLim', [yLimLow, yLimHigh]);
% Manual mode prevents MATLAB from auto-adjusting during zoom
Expand Down Expand Up @@ -2418,16 +2418,40 @@ function refreshEventLayer(obj)
end

function [xMin, xMax] = lineXRange(obj, i)
%LINEXRANGE Return X endpoints for line i.
%LINEXRANGE Return X endpoints for line i (NaN if the line is empty).
if obj.lineOnDisk(i)
xMin = obj.Lines(i).DataStore.XMin;
xMax = obj.Lines(i).DataStore.XMax;
elseif obj.Lines(i).NumPoints == 0
% Empty in-memory line — e.g. a tag bound to a real-mode
% source that was unreachable, so it holds zero samples.
% Indexing X(1)/X(end) would error; NaN is inert in the
% min/max comparisons every caller runs, so an empty line
% simply contributes nothing to the axis range.
xMin = NaN;
xMax = NaN;
else
xMin = obj.Lines(i).X(1);
xMax = obj.Lines(i).X(end);
end
end

function [xmin, xmax] = computeFullXRange(obj)
%COMPUTEFULLXRANGE Full X span across all lines, normalised.
% X is sorted, so only endpoints are checked. lineXRange returns
% NaN for empty lines, which is inert in the min/max below, so a
% line with no data contributes nothing. With no lines (or all
% empty) the range stays Inf/-Inf and normalizeFiniteRange coerces
% it to a valid, strictly increasing interval for render().
xmin = Inf; xmax = -Inf;
for i = 1:numel(obj.Lines)
[xiMin, xiMax] = obj.lineXRange(i);
if xiMin < xmin; xmin = xiMin; end
if xiMax > xmax; xmax = xiMax; end
end
[xmin, xmax] = FastSense.normalizeFiniteRange(xmin, xmax);
end

function onEventMarkerClick_(obj, src, ~)
%ONEVENTMARKERCLICK_ ButtonDownFcn dispatcher for event markers.
% Hidden public so TestFastSenseEventClick can call it for direct
Expand Down Expand Up @@ -4617,6 +4641,23 @@ function propagateXLim(obj, newXLim)
% ======================== STATIC HELPERS =============================
% Persistent registry for linked FastSense groups.
methods (Static, Access = private)
function [lo, hi] = normalizeFiniteRange(lo, hi)
%NORMALIZEFINITERANGE Coerce an axis range to a finite, increasing interval.
% [lo, hi] = NORMALIZEFINITERANGE(lo, hi) guarantees a valid axis
% span for set(...,'XLim'/'YLim',...). An empty/all-NaN source
% leaves the accumulated range at Inf/-Inf, and a single shared
% sample collapses lo==hi; both would make bands, thresholds and
% the limit set invalid. Non-finite input normalises to [0 1]; a
% zero-width span is padded symmetrically. The plot renders blank
% — the desired outcome when a real source is empty.
if ~(isfinite(lo) && isfinite(hi))
lo = 0; hi = 1;
elseif lo >= hi
pad = max(0.5, abs(hi) * 1e-6);
lo = lo - pad; hi = hi + pad;
end
end

function registry = getLinkRegistry(action, group, obj)
%GETLINKREGISTRY Persistent registry for linked FastSense instances.
% registry = GETLINKREGISTRY(action, group, obj) manages a
Expand Down
8 changes: 4 additions & 4 deletions libs/Help/WikiBrowser.m
Original file line number Diff line number Diff line change
Expand Up @@ -610,11 +610,11 @@ function buildTocTree_(obj)
return;
end
delete(obj.hTocTree_.Children);
toc = WikiPageIndex.buildToc(obj.WikiDir);
for gi = 1:numel(toc)
pageToc = WikiPageIndex.buildToc(obj.WikiDir);
for gi = 1:numel(pageToc)
groupNode = uitreenode(obj.hTocTree_);
groupNode.Text = toc(gi).group;
entries = toc(gi).entries;
groupNode.Text = pageToc(gi).group;
entries = pageToc(gi).entries;
for ei = 1:numel(entries)
leaf = uitreenode(groupNode);
leaf.Text = entries(ei).title;
Expand Down
Loading
Loading