|
19 | 19 | % (default: 'auto') |
20 | 20 | % 'Channels' - [1 x C] channel indices to analyze. Default: all good |
21 | 21 | % 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) |
23 | 24 | % 'Overlap' - Fractional overlap between windows, 0 to <1 |
24 | 25 | % (default: 0.5) |
25 | 26 | % 'FreqRange' - [1x2] frequency range to return in Hz. Upper bound is |
|
28 | 29 | % |
29 | 30 | % Inputs: |
30 | 31 | % 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. |
32 | 37 | % |
33 | 38 | % Outputs: |
34 | 39 | % result - Struct with fields: |
|
39 | 44 | % .fs - Sampling rate |
40 | 45 | % |
41 | 46 | % 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. |
45 | 57 | % |
46 | 58 | % Algorithm: |
47 | 59 | % 1. Select signal from data struct based on Signal parameter |
|
108 | 120 |
|
109 | 121 | %% Select columns from signal data |
110 | 122 | 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 |
113 | 124 | 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 |
119 | 143 | 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; |
121 | 152 | 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)); |
127 | 156 | else |
128 | 157 | % HbO, HbR, OD — columns map directly to channels |
129 | 158 | validCh = channels(channels <= size(sigData, 2)); |
|
139 | 168 | freqRange(2) = min(freqRange(2), nyquist); |
140 | 169 |
|
141 | 170 | %% 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); |
144 | 176 | nfft = max(256, 2^nextpow2(windowSamples)); |
145 | 177 |
|
| 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 | + |
146 | 194 | % Check for pwelch availability |
147 | 195 | hasPwelch = ~isempty(which('pwelch')); |
148 | 196 |
|
|
182 | 230 |
|
183 | 231 | %% Detect physiological peaks |
184 | 232 | 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); |
188 | 234 | end |
189 | 235 |
|
190 | 236 | end |
|
292 | 338 | end |
293 | 339 |
|
294 | 340 |
|
| 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 | + |
295 | 372 | function bandResult = detectBandPeak(freqs, psdMatrix, bandLimits, bandName) |
296 | 373 | % DETECTBANDPEAK Find peak in a physiological frequency band |
297 | 374 | % |
|
0 commit comments