Skip to content

Commit f8e6c88

Browse files
authored
Run SMAR on raw intensity; fix QC power-spectrum crashes and channel mapping (#10)
* Run the SMAR family on raw intensity SMAR's coefficient-of-variation test divides by the windowed mean, which is near zero for optical density, so pf2_SMAR, pf2_SMAR_mask, pf2_SMAR2 and pf2_sSMART rejected most of a recording when run after pf2_Intensity2OD. The function library now marks them raw-stage, and each warns on non-positive input. The OD_SMAR seed now runs a 0.1 Hz low-pass, SMAR, then the OD conversion. pf2_initialize replaces a stored OD_SMAR that still matches the old seed exactly and leaves edited copies alone. PipelineFunction takes requiresOD/validStages from the function library for registered functions, so methods saved with the old flags pick up the correction. pf2_SMAR2 gets defaults that do not reject clean data, row-indexed artifact segments, edge handling, and input validation. pf2_sSMART no longer extrapolates past the last clean sample. Removes the stale pf2_SMAR2_mask library entry. Adds SMARFamilyTest and updates the golden and signal-processing tests, docs, and benchmark pipeline order. * Fix QC power-spectrum crashes and channel mapping pf2.qc.powerSpectrum indexed an empty matrix when every channel was masked bad ("Index in position 2 exceeds array bounds"), surfacing through the cardiac check in assess and ChannelCheck. It also errored in pwelch on recordings shorter than WindowLength. It now returns an empty result for no channels and clamps the Welch window to between 3 samples and the recording length. On raw data, powerSpectrum read columns as if 2 were interleaved per channel, which picks dark or wrong-wavelength columns on fNIR1200, fNIR2000 and fNIR3000. It now uses the device layout. assess, sci and powerSpectrum map channel k to the k-th entry of the device channel list, as processFNIRS2 does, which fixes misaligned results on the merged Hitachi probe. A .device that is not a pf2.Device is ignored. Recordings shorter than 5 cycles of the cardiac band's lower edge (10 s by default) now skip SCI and cardiac with a skipReason instead of returning meaningless results; SCI also no longer errors at 2 s. powerSpectrum bands carry skipped/skipReason under the same rule. The report prints each skip's actual reason, assess passes through the Takizawa skip reason, and plotQuality handles empty and all-zero spectra.
1 parent 77f3455 commit f8e6c88

22 files changed

Lines changed: 1159 additions & 360 deletions

‎+pf2/+qc/+pipeline/assess.m‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
% saturation - Raw intensity at floor (0) or ceiling (device max) (raw)
1616
% sci - Scalp coupling index via cardiac cross-correlation (raw)
1717
% cardiac - Cardiac peak presence in power spectrum (raw)
18+
%
19+
% sci and cardiac are skipped (channels not penalized, with a
20+
% skipReason) when the sampling rate cannot reach the cardiac band or
21+
% the recording spans fewer than 5 cycles of CardiacBand(1) (10 s for
22+
% the default band). takizawa likewise skips recordings under 10 s.
1823
% cov - Coefficient of variation of raw signal (raw)
1924
% takizawa - 4-rule hemoglobin QC (filtered Hb)
2025
%
@@ -79,18 +84,21 @@
7984
nRawCols = size(data.raw, 2);
8085

8186
%% Resolve channel layout from Device or fallback
82-
hasDev = isfield(data, 'device') && ~isempty(data.device);
87+
hasDev = isfield(data, 'device') && isa(data.device, 'pf2.Device');
8388
if hasDev
8489
dev = data.device;
8590
nChannels = dev.nChannels;
8691
wavelengths = dev.wavelengthSet;
8792
nWl = numel(wavelengths);
88-
% Build per-channel column map from Device
93+
% Build per-channel column map from Device. Channel k is the k-th
94+
% entry of the channel list (as in processFNIRS2), which differs from
95+
% the raw channel number on merged probes with non-contiguous numbering
8996
chNums = dev.channelNumbers();
9097
wlVec = dev.wavelengths();
98+
chList = dev.channelList();
9199
chColMapWl = cell(1, nChannels); % columns per wavelength (excl dark)
92100
for ch = 1:nChannels
93-
wlCols = find(chNums == ch & wlVec > 0);
101+
wlCols = find(chNums == chList(ch) & wlVec > 0);
94102
chColMapWl{ch} = wlCols;
95103
end
96104
elseif isfield(data, 'info') && isfield(data.info, 'synthetic') ...
@@ -331,17 +339,30 @@
331339
% Need Nyquist > ~1.0 Hz (fs > ~2 Hz) for reliable detection.
332340
nyq = fs / 2;
333341
minUsableCardiacHz = 1.0;
342+
% A spectral peak needs several beats: at least 5 cycles of
343+
% the band's lower edge (10 s for the default band), the same
344+
% minimum pf2.qc.sci and pf2.qc.powerSpectrum apply
345+
minCardiacDuration = 5 / opts.CardiacBand(1);
346+
durationSec = size(data.raw, 1) / fs;
347+
cardiacSkipReason = '';
334348
if nyq <= minUsableCardiacHz
335-
% Cannot meaningfully detect cardiac peaks at this fs
349+
cardiacSkipReason = sprintf(...
350+
'Sampling rate (%.1f Hz) too low for cardiac peak detection (Nyquist=%.1f Hz, need >%.1f Hz)', ...
351+
fs, nyq, minUsableCardiacHz);
352+
elseif durationSec < minCardiacDuration
353+
cardiacSkipReason = sprintf(...
354+
'Recording too short (%.1f s < %.0f s) for cardiac peak detection', ...
355+
durationSec, minCardiacDuration);
356+
end
357+
if ~isempty(cardiacSkipReason)
358+
% Cannot meaningfully detect cardiac peaks
336359
report.cardiac.detected = false(1, nChannels);
337360
report.cardiac.snr = nan(1, nChannels);
338361
report.cardiac.freq = nan(1, nChannels);
339362
report.cardiac.pass = true(1, nChannels);
340363
report.cardiac.threshold = opts.CardiacSNR;
341364
report.cardiac.skipped = true;
342-
report.cardiac.skipReason = sprintf(...
343-
'Sampling rate (%.1f Hz) too low for cardiac peak detection (Nyquist=%.1f Hz, need >%.1f Hz)', ...
344-
fs, nyq, minUsableCardiacHz);
365+
report.cardiac.skipReason = cardiacSkipReason;
345366
passMatrix(ci, :) = true;
346367
else
347368
psdResult = pf2.qc.powerSpectrum(data, ...
@@ -408,6 +429,9 @@
408429
report.takizawa.ruleNames = tkReport.ruleNames;
409430
report.takizawa.pass = tkReport.pass;
410431
report.takizawa.skipped = tkReport.skipped;
432+
if tkReport.skipped
433+
report.takizawa.skipReason = tkReport.skipReason;
434+
end
411435
passMatrix(ci, :) = tkReport.pass;
412436
end
413437
end

‎+pf2/+qc/+pipeline/report.m‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,13 @@ function report(qcReport)
4343
checkName = checks{ci};
4444
if isfield(qcReport, checkName) && isfield(qcReport.(checkName), 'skipped') ...
4545
&& qcReport.(checkName).skipped
46-
reason = '';
47-
if isfield(qcReport.(checkName), 'skipReason')
48-
reason = [' — ' qcReport.(checkName).skipReason];
46+
% The reason says why (low sampling rate, short recording, ...)
47+
reason = ' (not applicable to this recording)';
48+
if isfield(qcReport.(checkName), 'skipReason') ...
49+
&& ~isempty(qcReport.(checkName).skipReason)
50+
reason = [': ' qcReport.(checkName).skipReason];
4951
end
50-
fprintf(' Note: %s check SKIPPED (not applicable at this fs)%s\n', ...
51-
capitalize(checkName), reason);
52+
fprintf(' Note: %s check SKIPPED%s\n', capitalize(checkName), reason);
5253
end
5354
end
5455
fprintf('\n');

‎+pf2/+qc/plotQuality.m‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@
152152

153153
nCh = numel(channels);
154154
layout = lower(char(opts.Layout));
155+
if nCh == 0
156+
% No channels (e.g. all masked bad): tiled has no subplots to title,
157+
% so draw a single empty axes instead
158+
layout = 'overlay';
159+
end
155160

156161
% Physiological bands for shading
157162
bands = struct();
@@ -244,10 +249,13 @@ function drawBandShading(ax, freqs, psdMatrix, bands, bandColors, bandLabels)
244249

245250
fMin = min(freqs);
246251
fMax = max(freqs);
247-
yLimits = [min(psdMatrix(psdMatrix > 0), [], 'all') * 0.1, ...
248-
max(psdMatrix(:), [], 'all') * 10];
249-
if isempty(yLimits) || any(isnan(yLimits)) || yLimits(1) >= yLimits(2)
252+
% An all-zero (or empty) spectrum, e.g. from a recording too short for a
253+
% PSD, has no positive power to scale a log axis to
254+
posPower = psdMatrix(psdMatrix > 0 & isfinite(psdMatrix));
255+
if isempty(posPower)
250256
yLimits = [1e-10, 1];
257+
else
258+
yLimits = [min(posPower) * 0.1, max(posPower) * 10];
251259
end
252260

253261
bandNames = {'mayer', 'respiratory', 'cardiac'};

‎+pf2/+qc/powerSpectrum.m‎

Lines changed: 100 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
% (default: 'auto')
2020
% 'Channels' - [1 x C] channel indices to analyze. Default: all good
2121
% channels from fchMask. (default: [])
22-
% 'WindowLength' - PSD window length in seconds (default: 10)
22+
% 'WindowLength' - PSD window length in seconds; clamped to between
23+
% 3 samples and the recording length (default: 10)
2324
% 'Overlap' - Fractional overlap between windows, 0 to <1
2425
% (default: 0.5)
2526
% 'FreqRange' - [1x2] frequency range to return in Hz. Upper bound is
@@ -28,7 +29,11 @@
2829
%
2930
% Inputs:
3031
% data - fNIRS data struct. Must contain .fs and the selected signal
31-
% field (.raw, .HbO, .HbR, or .OD).
32+
% field (.raw, .HbO, .HbR, or .OD). For raw data, an optional
33+
% .device (pf2.Device) selects each channel's first non-dark
34+
% wavelength column, with channel k taken as the k-th entry of
35+
% the device channel list; without it, columns are assumed
36+
% interleaved.
3237
%
3338
% Outputs:
3439
% result - Struct with fields:
@@ -39,9 +44,16 @@
3944
% .fs - Sampling rate
4045
%
4146
% When DetectPeaks = true, also includes:
42-
% .cardiac - struct with .freq, .power, .snr, .detected
43-
% .respiratory - struct with .freq, .power, .detected
44-
% .mayer - struct with .freq, .power, .detected
47+
% .cardiac - struct with .freq, .power, .snr, .detected,
48+
% .skipped, .skipReason
49+
% .respiratory - struct with .freq, .power, .detected,
50+
% .skipped, .skipReason
51+
% .mayer - struct with .freq, .power, .detected,
52+
% .skipped, .skipReason
53+
% A band is skipped (detected = false, freq/power NaN) when
54+
% the recording spans fewer than 5 cycles of the band's lower
55+
% edge: under 10 s for cardiac, 50 s for respiratory, 100 s
56+
% for Mayer.
4557
%
4658
% Algorithm:
4759
% 1. Select signal from data struct based on Signal parameter
@@ -108,22 +120,39 @@
108120

109121
%% Select columns from signal data
110122
if strcmp(signalType, 'raw')
111-
% For raw data, resolve which columns to use per channel
112-
% Use first wavelength of each channel pair (alternating layout)
123+
% For raw data, resolve the first wavelength column of each channel
113124
nRawCols = size(sigData, 2);
114-
if isfield(data, 'info') && isfield(data.info, 'synthetic') ...
115-
&& isfield(data.info.synthetic, 'wavelengths')
116-
nWl = numel(data.info.synthetic.wavelengths);
117-
elseif isfield(data, 'probeinfo')
118-
nWl = 2; % Default assumption
125+
if isfield(data, 'device') && isa(data.device, 'pf2.Device')
126+
% Device layout: skip dark columns (wavelength 0) and handle
127+
% non-interleaved column orders. Channel k is the k-th entry of
128+
% the device channel list (as in processFNIRS2), so non-contiguous
129+
% numbering such as merged probes still maps by position.
130+
chNums = data.device.channelNumbers();
131+
wlVec = data.device.wavelengths();
132+
chList = data.device.channelList();
133+
colIndices = nan(1, numel(channels));
134+
for k = 1:numel(channels)
135+
if channels(k) < 1 || channels(k) > numel(chList)
136+
continue;
137+
end
138+
c = find(chNums == chList(channels(k)) & wlVec > 0, 1);
139+
if ~isempty(c) && c <= nRawCols
140+
colIndices(k) = c;
141+
end
142+
end
119143
else
120-
nWl = 2; % Default: 2 wavelengths
144+
if isfield(data, 'info') && isfield(data.info, 'synthetic') ...
145+
&& isfield(data.info.synthetic, 'wavelengths')
146+
nWl = numel(data.info.synthetic.wavelengths);
147+
else
148+
nWl = 2; % Default: 2 interleaved wavelengths
149+
end
150+
colIndices = (channels - 1) * nWl + 1;
151+
colIndices(colIndices > nRawCols) = NaN;
121152
end
122-
% Map channel index to first wavelength column
123-
colIndices = (channels - 1) * nWl + 1;
124-
colIndices(colIndices > nRawCols) = [];
125-
channels = channels(1:numel(colIndices));
126-
sigMatrix = sigData(:, colIndices);
153+
keep = ~isnan(colIndices);
154+
channels = channels(keep);
155+
sigMatrix = sigData(:, colIndices(keep));
127156
else
128157
% HbO, HbR, OD — columns map directly to channels
129158
validCh = channels(channels <= size(sigData, 2));
@@ -139,10 +168,29 @@
139168
freqRange(2) = min(freqRange(2), nyquist);
140169

141170
%% Compute PSD using Welch's method
142-
windowSamples = round(opts.WindowLength * fs);
143-
overlapSamples = round(windowSamples * opts.Overlap);
171+
% Clamp the window to [3, recording length]: pwelch rejects windows longer
172+
% than the signal, and the welchPSD Hann window has zero power under 3
173+
nSamples = size(sigMatrix, 1);
174+
windowSamples = min(max(round(opts.WindowLength * fs), 3), nSamples);
175+
overlapSamples = min(round(windowSamples * opts.Overlap), windowSamples - 1);
144176
nfft = max(256, 2^nextpow2(windowSamples));
145177

178+
if nChannels == 0 || nSamples < 3
179+
% Nothing to analyze (e.g. every channel masked bad): return empty
180+
% per-channel results instead of indexing into an empty matrix
181+
f = (0:nfft/2)' * fs / nfft;
182+
f = f(f >= freqRange(1) & f <= freqRange(2));
183+
result.psd = zeros(numel(f), nChannels);
184+
result.freqs = f;
185+
result.channels = channels;
186+
result.signal = signalType;
187+
result.fs = fs;
188+
if opts.DetectPeaks
189+
result = detectAllBands(result, nSamples / fs);
190+
end
191+
return;
192+
end
193+
146194
% Check for pwelch availability
147195
hasPwelch = ~isempty(which('pwelch'));
148196

@@ -182,9 +230,7 @@
182230

183231
%% Detect physiological peaks
184232
if opts.DetectPeaks
185-
result.cardiac = detectBandPeak(f, psdMatrix, [0.5, 2.5], 'cardiac');
186-
result.respiratory = detectBandPeak(f, psdMatrix, [0.1, 0.5], 'respiratory');
187-
result.mayer = detectBandPeak(f, psdMatrix, [0.05, 0.15], 'mayer');
233+
result = detectAllBands(result, nSamples / fs);
188234
end
189235

190236
end
@@ -292,6 +338,37 @@
292338
end
293339

294340

341+
function result = detectAllBands(result, durationSec)
342+
% DETECTALLBANDS Run peak detection for each physiological band
343+
%
344+
% A band needs the recording to span at least minCycles cycles of its
345+
% lower edge; shorter recordings cannot resolve a peak there, so the band
346+
% is reported as skipped rather than as a spurious detection or miss.
347+
348+
minCycles = 5;
349+
bandDefs = {'cardiac', [0.5, 2.5]; 'respiratory', [0.1, 0.5]; 'mayer', [0.05, 0.15]};
350+
351+
for b = 1:size(bandDefs, 1)
352+
name = bandDefs{b, 1};
353+
limits = bandDefs{b, 2};
354+
minDuration = minCycles / limits(1);
355+
if durationSec < minDuration
356+
bandResult = detectBandPeak(result.freqs, zeros(size(result.psd)), limits, name);
357+
bandResult.skipped = true;
358+
bandResult.skipReason = sprintf( ...
359+
'Recording too short (%.1f s < %.0f s) for %s band [%.2f, %.2f] Hz', ...
360+
durationSec, minDuration, name, limits(1), limits(2));
361+
else
362+
bandResult = detectBandPeak(result.freqs, result.psd, limits, name);
363+
bandResult.skipped = false;
364+
bandResult.skipReason = '';
365+
end
366+
result.(name) = bandResult;
367+
end
368+
369+
end
370+
371+
295372
function bandResult = detectBandPeak(freqs, psdMatrix, bandLimits, bandName)
296373
% DETECTBANDPEAK Find peak in a physiological frequency band
297374
%

‎+pf2/+qc/sci.m‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@
3636
% .raw - [T x C_raw] raw light intensity
3737
% .fs - Sampling frequency (Hz)
3838
% Wavelength layout resolved from (in order):
39-
% 1. Explicit Wavelengths/ChannelNumbers parameters
40-
% 2. data.probeinfo.Probe{1}.TableCh
41-
% 3. data.info.synthetic.wavelengths (alternating ch1_wl1, ch1_wl2, ...)
39+
% 1. data.device (pf2.Device); channel i is the i-th entry of
40+
% the device channel list
41+
% 2. Explicit Wavelengths/ChannelNumbers parameters
42+
% 3. data.probeinfo.Probe{1}.TableCh
43+
% 4. data.info.synthetic.wavelengths (alternating ch1_wl1, ch1_wl2, ...)
4244
%
4345
% Outputs:
4446
% result - Struct with fields:
@@ -48,6 +50,12 @@
4850
% .threshold - Threshold used
4951
% .cardiacBand - [low, high] Hz
5052
% .fs - Sampling rate
53+
% .skipped - true when SCI could not be computed (sampling
54+
% rate below the cardiac band, or a recording
55+
% shorter than 5 cycles of CardiacBand(1), i.e.
56+
% 10 s for the default band); .sci is then NaN and
57+
% .isGood true so channels are not penalized
58+
% .skipReason - Reason for the skip (only when skipped)
5159
%
5260
% Algorithm:
5361
% 1. Resolve wavelength layout from data or explicit parameters
@@ -124,6 +132,28 @@
124132
cardiacBand(2) = clampedHigh;
125133
end
126134

135+
%% Check recording length: the cardiac correlation needs several beats
136+
% (at least 5 cycles of the band's lower edge, 10 s for the default band),
137+
% and the bandpass filter needs more samples than its padding length
138+
minCycles = 5;
139+
minDuration = minCycles / cardiacBand(1);
140+
nSamples = size(data.raw, 1);
141+
durationSec = nSamples / fs;
142+
minSamples = 3 * (2 * opts.FilterOrder) + 1;
143+
if durationSec < minDuration || nSamples < minSamples
144+
result.sci = nan(1, nChannels);
145+
result.isGood = true(1, nChannels);
146+
result.channels = 1:nChannels;
147+
result.threshold = opts.Threshold;
148+
result.cardiacBand = cardiacBand;
149+
result.fs = fs;
150+
result.skipped = true;
151+
result.skipReason = sprintf( ...
152+
'Recording too short (%.1f s < %.0f s) for cardiac band [%.1f, %.1f] Hz', ...
153+
durationSec, minDuration, cardiacBand(1), cardiacBand(2));
154+
return;
155+
end
156+
127157
%% Compute SCI for each channel
128158
sciValues = zeros(1, nChannels);
129159

@@ -187,12 +217,15 @@
187217
wl = data.device.wavelengths();
188218
chNums = data.device.channelNumbers();
189219

190-
uniqueCh = unique(chNums(chNums > 0 & ~isnan(chNums)));
191-
nChannels = numel(uniqueCh);
220+
% Channel i is the i-th entry of the device channel list (as in
221+
% processFNIRS2), which can differ from the unique raw channel numbers
222+
% on merged probes
223+
chList = data.device.channelList();
224+
nChannels = numel(chList);
192225
channelMap = zeros(nChannels, 2);
193226

194227
for i = 1:nChannels
195-
cols = find(chNums == uniqueCh(i) & wl > 0 & ~isnan(wl));
228+
cols = find(chNums == chList(i) & wl > 0 & ~isnan(wl));
196229
if numel(cols) >= 2
197230
channelMap(i, :) = cols(1:2);
198231
end

0 commit comments

Comments
 (0)