diff --git a/+exploreFNIRS/+core/Experiment.m b/+exploreFNIRS/+core/Experiment.m index 064624d0..003e8dd1 100644 --- a/+exploreFNIRS/+core/Experiment.m +++ b/+exploreFNIRS/+core/Experiment.m @@ -759,7 +759,7 @@ % Build channel labels as cell array if ~isempty(channels) - chLabels = cellstr(num2str(channels(:))); + chLabels = pf2_base.channelLabels(channels); else chLabels = {}; end @@ -800,7 +800,7 @@ % Build channel labels as cell array if ~isempty(channels) - chLabels = cellstr(num2str(channels(:))); + chLabels = pf2_base.channelLabels(channels); else chLabels = {}; end diff --git a/+exploreFNIRS/+export/mergeGbyTablesLong.m b/+exploreFNIRS/+export/mergeGbyTablesLong.m index d3b9dea3..19d03692 100644 --- a/+exploreFNIRS/+export/mergeGbyTablesLong.m +++ b/+exploreFNIRS/+export/mergeGbyTablesLong.m @@ -17,7 +17,8 @@ % times - Time points to include (default: [] = all) % exportAux - Include auxiliary data (default: false) % exportROI - Include ROI-averaged data (default: false) -% channelLabels - Cell array of channel name strings (default: numbers) +% channelLabels - Cell array of channel name strings (default: zero-padded +% numbers from pf2_base.channelLabels) % % Outputs: % mergedTables - Combined table in long format with columns: @@ -53,7 +54,7 @@ end if(nargin<7) - channelLabels=cellstr(num2str(channelIndexes)); + channelLabels=pf2_base.channelLabels(channelIndexes); else channelLabels=cellstr(channelLabels); end @@ -117,7 +118,7 @@ if(emptyChannelFlag&&exportFNIR) numCh=size(curBarGA.HbO.data,2); channelIndexes=1:numCh; - channelLabels=cellstr(num2str(channelIndexes(:))); + channelLabels=pf2_base.channelLabels(channelIndexes); elseif(exportFNIR) numCh=length(channelIndexes); else @@ -190,7 +191,7 @@ end if(exportAux&&isfield(curBarGA,'Aux')) - curAuxFields=fields(curBarGA.Aux); + curAuxFields=fieldnames(curBarGA.Aux); for aux=1:length(curAuxFields) curAuxName=curAuxFields{aux}; curAux= curBarGA.Aux.(curAuxName); diff --git a/+exploreFNIRS/+export/mergeGbyTablesWide.m b/+exploreFNIRS/+export/mergeGbyTablesWide.m index 84287caf..3a7d7ffd 100644 --- a/+exploreFNIRS/+export/mergeGbyTablesWide.m +++ b/+exploreFNIRS/+export/mergeGbyTablesWide.m @@ -17,7 +17,8 @@ % times - Time points to include (default: [] = all) % exportAux - Include auxiliary data (default: false) % exportROI - Include ROI-averaged data (default: false) -% optodeNames - Cell array of optode/channel names (default: numbers) +% optodeNames - Cell array of optode/channel names (default: zero-padded +% numbers from pf2_base.channelLabels) % % Outputs: % mergedTables - Combined table in wide format with columns: @@ -53,7 +54,7 @@ end if(nargin<7) - optodeNames=num2str(channels); + optodeNames=pf2_base.channelLabels(channels); else optodeNames=cellstr(optodeNames); end @@ -106,7 +107,7 @@ if(emptyChannelFlag&&exportFNIR) numCh=size(curBarGA.HbO.data,2); channels=1:numCh; - optodeNames=cellstr(num2str(channels(:))); + optodeNames=pf2_base.channelLabels(channels); elseif(exportFNIR) numCh=length(channels); else @@ -185,7 +186,7 @@ if(exportAux&&isfield(curBarGA,'Aux')) %warning('To-do add AUX fields to export wide'); % trouble is syncing up timing between each - curAuxFields=fields(curBarGA.Aux); + curAuxFields=fieldnames(curBarGA.Aux); for aux=1:length(curAuxFields) curAuxName=curAuxFields{aux}; curAux= curBarGA.Aux.(curAuxName); diff --git a/+exploreFNIRS/+plot/temporal.m b/+exploreFNIRS/+plot/temporal.m index b60e4ae5..ab9e63ce 100644 --- a/+exploreFNIRS/+plot/temporal.m +++ b/+exploreFNIRS/+plot/temporal.m @@ -366,7 +366,7 @@ else dataTime=data2plot.(bioM).time; - varNames=fields(data2plot.(bioM)); + varNames=fieldnames(data2plot.(bioM)); timeIdx=ismember(varNames,'time'); firstVar=find(~timeIdx); data2plot.(bioM)=data2plot.(bioM).(varNames{firstVar(1)}); diff --git a/+pf2/+data/+aux/accelFeatures.m b/+pf2/+data/+aux/accelFeatures.m index 1c88fb9e..b2e46aa3 100644 --- a/+pf2/+data/+aux/accelFeatures.m +++ b/+pf2/+data/+aux/accelFeatures.m @@ -1,4 +1,4 @@ -function [feat, info] = accelFeatures(x, fs, opts) +function [feat, info] = accelFeatures(x, fs, varargin) % ACCELFEATURES Derive motion features from a multi-axis accelerometer signal % % Computes summary motion features from accelerometer/IMU axes: the vector @@ -36,11 +36,14 @@ % % See also: pf2_base.auxSignalType, pf2.data.auxOnGrid -arguments - x {mustBeNumeric} - fs {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} - opts.RemoveGravity (1,1) logical = true -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +x = pf2_base.args.check(x, '', '', {@mustBeNumeric}, 'Invalid argument at position 1.'); +fs = pf2_base.args.check(fs, '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive}, 'Invalid argument at position 2.'); +opts = pf2_base.args.parse('accelFeatures', varargin, {}, { + 'RemoveGravity', (true), '1,1', 'logical', {} + }); removeGravity = opts.RemoveGravity; if isrow(x) diff --git a/+pf2/+data/+aux/addFeature.m b/+pf2/+data/+aux/addFeature.m index 01d641d3..269b2eaa 100644 --- a/+pf2/+data/+aux/addFeature.m +++ b/+pf2/+data/+aux/addFeature.m @@ -1,4 +1,4 @@ -function data = addFeature(data, name, values, opts) +function data = addFeature(data, name, values, varargin) % ADDFEATURE Store a derived signal as a typed auxiliary feature % % Writes a derived feature (e.g. an HR series from heartRateFrom, RVT from @@ -38,14 +38,17 @@ % See also: pf2_base.normalizeAux, pf2.data.aux.heartRateFrom, % pf2.data.aux.respFeatures, pf2.export.asSNIRF -arguments - data {mustBeA(data, 'struct')} - name {mustBeText} - values {mustBeNumeric} - opts.Time {mustBeNumeric} = [] - opts.Unit = '' - opts.VarNames = {} -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +data = pf2_base.args.check(data, '', '', {@(v) mustBeA(v, 'struct')}, 'Invalid argument at position 1.'); +name = pf2_base.args.check(name, '', '', {@mustBeText}, 'Invalid argument at position 2.'); +values = pf2_base.args.check(values, '', '', {@mustBeNumeric}, 'Invalid argument at position 3.'); +opts = pf2_base.args.parse('addFeature', varargin, {}, { + 'Time', ([]), '', '', {@mustBeNumeric} + 'Unit', (''), '', '', {} + 'VarNames', ({}), '', '', {} + }); name = matlab.lang.makeValidName(char(string(name))); t = opts.Time; diff --git a/+pf2/+data/+aux/edaDecompose.m b/+pf2/+data/+aux/edaDecompose.m index 85d71cce..7987eaed 100644 --- a/+pf2/+data/+aux/edaDecompose.m +++ b/+pf2/+data/+aux/edaDecompose.m @@ -1,4 +1,4 @@ -function [tonic, phasic, info] = edaDecompose(x, fs, opts) +function [tonic, phasic, info] = edaDecompose(x, fs, varargin) % EDADECOMPOSE Split electrodermal activity into tonic and phasic components % % Decomposes a galvanic skin response / electrodermal activity (GSR/EDA) @@ -40,11 +40,14 @@ % % See also: pf2_base.auxSignalType, pf2.data.aux.heartRateFrom -arguments - x {mustBeNumeric} - fs {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} - opts.TonicCutoff {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = 0.05 -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +x = pf2_base.args.check(x, '', '', {@mustBeNumeric}, 'Invalid argument at position 1.'); +fs = pf2_base.args.check(fs, '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive}, 'Invalid argument at position 2.'); +opts = pf2_base.args.parse('edaDecompose', varargin, {}, { + 'TonicCutoff', (0.05), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + }); cutoff = opts.TonicCutoff; x = x(:); diff --git a/+pf2/+data/+aux/eegBandPower.m b/+pf2/+data/+aux/eegBandPower.m index edf0925b..f178d14d 100644 --- a/+pf2/+data/+aux/eegBandPower.m +++ b/+pf2/+data/+aux/eegBandPower.m @@ -1,4 +1,4 @@ -function [bandPower, info] = eegBandPower(x, fs, opts) +function [bandPower, info] = eegBandPower(x, fs, varargin) % EEGBANDPOWER Extract canonical EEG band-power feature series % % Converts a raw EEG waveform (one or more channels) into per-band power @@ -46,12 +46,15 @@ % % See also: pf2_base.auxSignalType, pf2.data.auxOnGrid -arguments - x {mustBeNumeric} - fs {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} - opts.Bands = [] - opts.SmoothWin {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = 1 -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +x = pf2_base.args.check(x, '', '', {@mustBeNumeric}, 'Invalid argument at position 1.'); +fs = pf2_base.args.check(fs, '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive}, 'Invalid argument at position 2.'); +opts = pf2_base.args.parse('eegBandPower', varargin, {}, { + 'Bands', ([]), '', '', {} + 'SmoothWin', (1), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + }); bands = opts.Bands; smoothWin = opts.SmoothWin; diff --git a/+pf2/+data/+aux/heartRateFrom.m b/+pf2/+data/+aux/heartRateFrom.m index 421458b3..ca3dd034 100644 --- a/+pf2/+data/+aux/heartRateFrom.m +++ b/+pf2/+data/+aux/heartRateFrom.m @@ -1,4 +1,4 @@ -function [hr, info] = heartRateFrom(x, fs, opts) +function [hr, info] = heartRateFrom(x, fs, varargin) % HEARTRATEFROM Derive a heart-rate (bpm) series from a PPG or EKG waveform % % Detects cardiac beats in a pulsatile waveform (photoplethysmography or @@ -47,13 +47,16 @@ % % See also: pf2_base.auxSignalType, pf2.data.auxOnGrid, pf2.data.aux.edaDecompose -arguments - x {mustBeNumeric} - fs {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} - opts.Band {mustBeNumeric} = [0.5 5] - opts.MinBPM {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = 30 - opts.MaxBPM {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = 220 -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +x = pf2_base.args.check(x, '', '', {@mustBeNumeric}, 'Invalid argument at position 1.'); +fs = pf2_base.args.check(fs, '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive}, 'Invalid argument at position 2.'); +opts = pf2_base.args.parse('heartRateFrom', varargin, {}, { + 'Band', ([0.5 5]), '', '', {@mustBeNumeric} + 'MinBPM', (30), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + 'MaxBPM', (220), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + }); band = opts.Band; maxBPM = opts.MaxBPM; diff --git a/+pf2/+data/+aux/hrvFeatures.m b/+pf2/+data/+aux/hrvFeatures.m index 38581a07..1b475034 100644 --- a/+pf2/+data/+aux/hrvFeatures.m +++ b/+pf2/+data/+aux/hrvFeatures.m @@ -1,4 +1,4 @@ -function [hrv, info] = hrvFeatures(x, fs, opts) +function [hrv, info] = hrvFeatures(x, varargin) % HRVFEATURES Heart-rate variability metrics from a waveform or beat series % % Computes standard time- and frequency-domain HRV metrics, which serve as @@ -55,16 +55,20 @@ % % See also: pf2.data.aux.heartRateFrom, pf2_base.auxSignalType -arguments - x {mustBeNumeric} - fs {mustBeNumeric} = [] - opts.Input = 'waveform' - opts.IBIUnit = 'ms' - opts.Band {mustBeNumeric} = [0.5 5] - opts.LFBand {mustBeNumeric} = [0.04 0.15] - opts.HFBand {mustBeNumeric} = [0.15 0.40] - opts.ResampleFs {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = 4 -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +x = pf2_base.args.check(x, '', '', {@mustBeNumeric}, 'Invalid argument at position 1.'); +[fs, opts] = pf2_base.args.parse('hrvFeatures', varargin, { + 'fs', ([]), '', '', {@mustBeNumeric} + }, { + 'Input', ('waveform'), '', '', {} + 'IBIUnit', ('ms'), '', '', {} + 'Band', ([0.5 5]), '', '', {@mustBeNumeric} + 'LFBand', ([0.04 0.15]), '', '', {@mustBeNumeric} + 'HFBand', ([0.15 0.40]), '', '', {@mustBeNumeric} + 'ResampleFs', (4), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + }); inputType = lower(char(opts.Input)); ibiUnit = lower(char(opts.IBIUnit)); lfBand = opts.LFBand; diff --git a/+pf2/+data/+aux/hrvSeries.m b/+pf2/+data/+aux/hrvSeries.m index 7f078e88..eb38f23b 100644 --- a/+pf2/+data/+aux/hrvSeries.m +++ b/+pf2/+data/+aux/hrvSeries.m @@ -1,4 +1,4 @@ -function [series, t] = hrvSeries(signal, fs, opts) +function [series, t] = hrvSeries(signal, fs, varargin) % HRVSERIES Time-resolved HRV metrics via a sliding window over a waveform % % Computes standard HRV metrics in successive overlapping windows of a PPG @@ -130,19 +130,22 @@ % pf2.data.aux.addFeature, pf2.data.auxOnGrid, % pf2.data.slidingWindows -arguments - signal {mustBeNumeric, mustBeNonempty} - fs {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} - opts.Window {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = 60 - opts.Step {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = [] - opts.Overlap {mustBeNumeric, mustBeScalarOrEmpty} = [] - opts.Metric = 'all' - opts.MinBeats {mustBeNumeric, mustBeScalarOrEmpty} = [] - opts.Band {mustBeNumeric} = [0.5 5] - opts.LFBand {mustBeNumeric} = [0.04 0.15] - opts.HFBand {mustBeNumeric} = [0.15 0.40] - opts.ResampleFs {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = 4 -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +signal = pf2_base.args.check(signal, '', '', {@mustBeNumeric, @mustBeNonempty}, 'Invalid argument at position 1.'); +fs = pf2_base.args.check(fs, '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive}, 'Invalid argument at position 2.'); +opts = pf2_base.args.parse('hrvSeries', varargin, {}, { + 'Window', (60), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + 'Step', ([]), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + 'Overlap', ([]), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty} + 'Metric', ('all'), '', '', {} + 'MinBeats', ([]), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty} + 'Band', ([0.5 5]), '', '', {@mustBeNumeric} + 'LFBand', ([0.04 0.15]), '', '', {@mustBeNumeric} + 'HFBand', ([0.15 0.40]), '', '', {@mustBeNumeric} + 'ResampleFs', (4), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + }); winLen = opts.Window; if isempty(opts.MinBeats) diff --git a/+pf2/+data/+aux/respFeatures.m b/+pf2/+data/+aux/respFeatures.m index 3a0a6f90..e2e110e0 100644 --- a/+pf2/+data/+aux/respFeatures.m +++ b/+pf2/+data/+aux/respFeatures.m @@ -1,4 +1,4 @@ -function [feat, info] = respFeatures(x, fs, opts) +function [feat, info] = respFeatures(x, fs, varargin) % RESPFEATURES Derive respiration rate and RVT from a respiration waveform % % Detects breaths in a respiration signal (belt / RIP / derived) and returns @@ -53,13 +53,16 @@ % % See also: pf2.data.aux.heartRateFrom, pf2_base.auxSignalType, pf2.data.auxOnGrid -arguments - x {mustBeNumeric} - fs {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} - opts.Band {mustBeNumeric} = [0.1 0.5] - opts.MinRate {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = 5 - opts.MaxRate {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = 60 -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +x = pf2_base.args.check(x, '', '', {@mustBeNumeric}, 'Invalid argument at position 1.'); +fs = pf2_base.args.check(fs, '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive}, 'Invalid argument at position 2.'); +opts = pf2_base.args.parse('respFeatures', varargin, {}, { + 'Band', ([0.1 0.5]), '', '', {@mustBeNumeric} + 'MinRate', (5), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + 'MaxRate', (60), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + }); band = opts.Band; maxRate = opts.MaxRate; diff --git a/+pf2/+data/auxOnGrid.m b/+pf2/+data/auxOnGrid.m index f08c1b8c..d80eb0a1 100644 --- a/+pf2/+data/auxOnGrid.m +++ b/+pf2/+data/auxOnGrid.m @@ -1,4 +1,4 @@ -function [vals, info] = auxOnGrid(data, name, opts) +function [vals, info] = auxOnGrid(data, name, varargin) % AUXONGRID Resample a named auxiliary signal onto a target time base % % Returns an auxiliary signal aligned to an arbitrary time grid (the fNIRS @@ -50,16 +50,19 @@ % % See also: pf2_base.normalizeAux, pf2_base.auxSignalType, pf2.data.resample -arguments - data {mustBeA(data, 'struct')} - name {mustBeText} - opts.Time {mustBeNumeric} = [] - opts.Channels = [] - opts.Method = 'linear' - opts.Offset (1,1) {mustBeNumeric} = 0 - opts.AntiAlias (1,1) logical = true - opts.MaxGap (1,1) {mustBeNumeric, mustBePositive} = Inf -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +data = pf2_base.args.check(data, '', '', {@(v) mustBeA(v, 'struct')}, 'Invalid argument at position 1.'); +name = pf2_base.args.check(name, '', '', {@mustBeText}, 'Invalid argument at position 2.'); +opts = pf2_base.args.parse('auxOnGrid', varargin, {}, { + 'Time', ([]), '', '', {@mustBeNumeric} + 'Channels', ([]), '', '', {} + 'Method', ('linear'), '', '', {} + 'Offset', (0), '1,1', '', {@mustBeNumeric} + 'AntiAlias', (true), '1,1', 'logical', {} + 'MaxGap', (Inf), '1,1', '', {@mustBeNumeric, @mustBePositive} + }); name = char(string(name)); method = char(string(opts.Method)); diff --git a/+pf2/+data/betasToSegments.m b/+pf2/+data/betasToSegments.m index ac3c258a..19c2fe26 100644 --- a/+pf2/+data/betasToSegments.m +++ b/+pf2/+data/betasToSegments.m @@ -1,4 +1,4 @@ -function segments = betasToSegments(glmResults, data, opts) +function segments = betasToSegments(glmResults, data, varargin) % BETASTOSEGMENTS Package GLM betas into Experiment-compatible pseudo-segments % % Converts first-level GLM beta weights into fNIRS-like structs that can be @@ -58,15 +58,18 @@ % exploreFNIRS.core.Experiment % --- Parse inputs --- -arguments - glmResults {mustBeA(glmResults, 'struct')} - data {mustBeA(data, 'struct')} - opts.Biomarker = 'HbO' - opts.Conditions {mustBeA(opts.Conditions, 'cell')} = {} - opts.ConditionMap {mustBeA(opts.ConditionMap, 'cell')} = {} - opts.Units = '\beta' - opts.BiomarkerResults = [] -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +glmResults = pf2_base.args.check(glmResults, '', '', {@(v) mustBeA(v, 'struct')}, 'Invalid argument at position 1.'); +data = pf2_base.args.check(data, '', '', {@(v) mustBeA(v, 'struct')}, 'Invalid argument at position 2.'); +opts = pf2_base.args.parse('betasToSegments', varargin, {}, { + 'Biomarker', ('HbO'), '', '', {} + 'Conditions', ({}), '', '', {@(v) mustBeA(v, 'cell')} + 'ConditionMap', ({}), '', '', {@(v) mustBeA(v, 'cell')} + 'Units', ('\beta'), '', '', {} + 'BiomarkerResults', ([]), '', '', {} + }); biomarker = char(opts.Biomarker); conditions = opts.Conditions; diff --git a/+pf2/+data/blockAverage.m b/+pf2/+data/blockAverage.m index a20e59fe..a6b7a661 100644 --- a/+pf2/+data/blockAverage.m +++ b/+pf2/+data/blockAverage.m @@ -1,4 +1,4 @@ -function ga = blockAverage(segments, opts) +function ga = blockAverage(segments, varargin) % BLOCKAVERAGE Trial/grand average of epoched fNIRS segments onto a common grid % % Averages a cell array of epoched fNIRS structs (e.g. from @@ -66,12 +66,14 @@ % See also: pf2.data.extractBlocks, pf2.data.defineBlocks, % exploreFNIRS.core.Experiment, grandAvgFNIRS -arguments - segments - opts.ResampleInterval {mustBeNumeric} = [] - opts.AverageAux = false - opts.HierarchyVars {mustBeNumeric} = [] -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +opts = pf2_base.args.parse('blockAverage', varargin, {}, { + 'ResampleInterval', ([]), '', '', {@mustBeNumeric} + 'AverageAux', (false), '', '', {} + 'HierarchyVars', ([]), '', '', {@mustBeNumeric} + }); pf2_base.ensureStatsFallbacks(); % ensure stats-toolbox fallbacks (nan*) are on the path before use diff --git a/+pf2/+data/blocksToEvents.m b/+pf2/+data/blocksToEvents.m index 301546f4..1e0c5079 100644 --- a/+pf2/+data/blocksToEvents.m +++ b/+pf2/+data/blocksToEvents.m @@ -1,4 +1,4 @@ -function events = blocksToEvents(blocks, opts) +function events = blocksToEvents(blocks, varargin) % BLOCKSTOEVENTS Convert block definitions to GLM event structs % % Groups blocks by condition and collects onset times, durations, and @@ -41,10 +41,13 @@ % See also: pf2.data.defineBlocks, pf2_base.fnirs.buildDesignMatrix, % pf2_base.fnirs.fitGLM -arguments - blocks {mustBeA(blocks, 'struct')} - opts.GroupBy = 'Condition' -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +blocks = pf2_base.args.check(blocks, '', '', {@(v) mustBeA(v, 'struct')}, 'Invalid argument at position 1.'); +opts = pf2_base.args.parse('blocksToEvents', varargin, {}, { + 'GroupBy', ('Condition'), '', '', {} + }); groupField = char(opts.GroupBy); diff --git a/+pf2/+data/dedupeMarkers.m b/+pf2/+data/dedupeMarkers.m index a36b97f3..4c58601f 100644 --- a/+pf2/+data/dedupeMarkers.m +++ b/+pf2/+data/dedupeMarkers.m @@ -1,4 +1,4 @@ -function out = dedupeMarkers(data, opts) +function out = dedupeMarkers(data, varargin) % DEDUPEMARKERS Collapse near-duplicate markers firing within a tolerance % % Removes near-duplicate event markers, where "near-duplicate" means a row @@ -73,11 +73,13 @@ % See also: pf2.data.removeMarkers, pf2.data.defineBlocks, ... % pf2.data.getMarkers, pf2_base.normalizeMarkers -arguments - data - opts.Tolerance (1,1) {mustBeNumeric, mustBeNonnegative} = 0.05 - opts.Verbose (1,1) logical = true -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +opts = pf2_base.args.parse('dedupeMarkers', varargin, {}, { + 'Tolerance', (0.05), '1,1', '', {@mustBeNumeric, @mustBeNonnegative} + 'Verbose', (true), '1,1', 'logical', {} + }); % --- Cell array input: apply to each element --- if iscell(data) diff --git a/+pf2/+data/defineBlocks.m b/+pf2/+data/defineBlocks.m index 796d0ddc..1308b6bf 100644 --- a/+pf2/+data/defineBlocks.m +++ b/+pf2/+data/defineBlocks.m @@ -1,4 +1,4 @@ -function blocks = defineBlocks(data, markerCodes, duration, opts) +function blocks = defineBlocks(data, varargin) % DEFINEBLOCKS Create block definition struct array from event markers % % Parses fNIRS markers into a block definition array describing time @@ -110,6 +110,20 @@ % on an un-extracted code does not raise a false alarm; clean flagged codes % with pf2.data.dedupeMarkers. % +% An empty result is always reported rather than returned silently, each +% with its own identifier so batch scripts can turn it off: +% pf2:defineBlocks:noMarkers - data.markers has no rows +% pf2:defineBlocks:codesNotFound - none of the requested marker (or +% end marker) codes occur; lists the +% codes present, and notes non-integer +% codes, which usually mean the marker +% columns are out of order +% pf2:defineBlocks:noPairedBlocks - start codes occur but no end marker +% follows them +% pf2:defineBlocks:allBlocksFiltered - every block falls outside +% MinDuration/MaxDuration +% Messages name the recording (info.filename or info.SubjectID) when set. +% % Example: % % Simple: marker codes + fixed duration % blocks = pf2.data.defineBlocks(data, [49, 50], 30); @@ -150,27 +164,30 @@ % See also: pf2.data.extractBlocks, pf2.data.getMarkers, pf2.data.split, ... % pf2.data.dedupeMarkers, pf2.data.removeMarkers -arguments - data - markerCodes {mustBeNumeric} = [] - duration {mustBeNumeric, mustBeScalarOrEmpty} = [] - opts.MarkerCode {mustBeNumeric} = [] - opts.Duration (1,1) {mustBeNumeric, mustBeNonnegative} = 0 - opts.UseDuration (1,1) logical = false - opts.StartMarker {mustBeNumeric} = [] - opts.EndMarker {mustBeNumeric} = [] - opts.ConditionMap = [] % [] = not provided (auto-populate); a cell = provided - opts.ConditionField = 'Condition' - opts.InfoTable = [] - opts.InfoFields (1,1) struct = struct() - opts.MinDuration (1,1) {mustBeNumeric} = 0 - opts.MaxDuration (1,1) {mustBeNumeric} = Inf - opts.SortByTime (1,1) logical = true - opts.PrePad (1,1) {mustBeNumeric, mustBeNonnegative} = 0 - opts.PostPad (1,1) {mustBeNumeric, mustBeNonnegative} = 0 - opts.MarkerWindow = 'core' - opts.Embed (1,1) logical = true -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +[markerCodes, duration, opts] = pf2_base.args.parse('defineBlocks', varargin, { + 'markerCodes', ([]), '', '', {@mustBeNumeric} + 'duration', ([]), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty} + }, { + 'MarkerCode', ([]), '', '', {@mustBeNumeric} + 'Duration', (0), '1,1', '', {@mustBeNumeric, @mustBeNonnegative} + 'UseDuration', (false), '1,1', 'logical', {} + 'StartMarker', ([]), '', '', {@mustBeNumeric} + 'EndMarker', ([]), '', '', {@mustBeNumeric} + 'ConditionMap', ([]), '', '', {} + 'ConditionField', ('Condition'), '', '', {} + 'InfoTable', ([]), '', '', {} + 'InfoFields', (struct()), '1,1', 'struct', {} + 'MinDuration', (0), '1,1', '', {@mustBeNumeric} + 'MaxDuration', (Inf), '1,1', '', {@mustBeNumeric} + 'SortByTime', (true), '1,1', 'logical', {} + 'PrePad', (0), '1,1', '', {@mustBeNumeric, @mustBeNonnegative} + 'PostPad', (0), '1,1', '', {@mustBeNumeric, @mustBeNonnegative} + 'MarkerWindow', ('core'), '', '', {} + 'Embed', (true), '1,1', 'logical', {} + }); % --- Cell array input: apply to each element (requires Embed) --- if iscell(data) @@ -280,6 +297,8 @@ end if isempty(mrk) || size(mrk, 1) == 0 + warning('pf2:defineBlocks:noMarkers', ... + 'data.markers is empty%s, so no blocks were defined.', recordingLabel(data)); blocks = struct('startTime', {}, 'endTime', {}, 'duration', {}, ... 'markerCode', {}, 'markerIndex', {}, 'amplitude', {}, 'info', {}); if embedBlocks @@ -289,6 +308,17 @@ return; end +% Requested codes that match nothing return zero blocks; say so, since a +% malformed marker array (e.g. columns out of order) otherwise fails silently. +startCodes = extractCodes; +if hasMarkerCode && hasEndMarker + startCodes = markerCode(:); +end +startFound = warnIfCodesMissing(mrk, startCodes, 'marker', data); +if hasEndMarker + warnIfCodesMissing(mrk, endMarker(:), 'end marker', data); +end + % Build blocks based on mode if hasMarkerCode && hasEndMarker % MarkerCode + EndMarker: use start/end pairing with MarkerCode as start @@ -300,6 +330,12 @@ end if isempty(blocks) + if startFound && hasEndMarker + warning('pf2:defineBlocks:noPairedBlocks', ... + ['Start marker(s) %s occur%s but no end marker %s follows them, ', ... + 'so no blocks were defined.'], codeList(startCodes), ... + recordingLabel(data), codeList(endMarker(:))); + end if embedBlocks data.blocks = blocks; blocks = data; @@ -463,6 +499,12 @@ % Filter by duration if minDur > 0 || isfinite(maxDur) keep = arrayfun(@(b) b.duration >= minDur && b.duration <= maxDur, blocks); + if ~isempty(keep) && ~any(keep) + warning('pf2:defineBlocks:allBlocksFiltered', ... + ['All %d block(s)%s fall outside MinDuration/MaxDuration ', ... + '[%g, %g] s, so none were kept.'], numel(keep), recordingLabel(data), ... + minDur, maxDur); + end blocks = blocks(keep); % Re-number after filtering for k = 1:length(blocks) @@ -674,3 +716,87 @@ end end + +function found = warnIfCodesMissing(mrk, codes, kind, data) +% WARNIFCODESMISSING Warn when none of the requested codes occur in the markers +% +% Inputs: +% mrk - Marker array [M x 4] (Time, Code, Duration, Amplitude) +% codes - Requested codes (column vector) +% kind - Word used in the message ('marker' or 'end marker') +% data - fNIRS struct, used to name the recording +% +% Outputs: +% found - True when at least one requested code occurs + +found = true; +if isempty(codes) + return; +end +present = unique(mrk(:, 2)); +found = any(ismember(codes, present)); +if found + return; +end +msg = sprintf('None of the requested %s codes (%s) occur in data.markers%s. Codes present: %s.', ... + kind, codeList(codes), recordingLabel(data), codeList(present)); +finitePresent = present(isfinite(present)); +if ~isempty(finitePresent) && any(finitePresent ~= round(finitePresent)) + msg = [msg ' The codes present are not all integers, which usually means the ' ... + 'marker columns are out of order: data.markers should be a table with Time, ' ... + 'Code, Duration and Amplitude columns (see pf2_base.normalizeMarkers).']; +end +warning('pf2:defineBlocks:codesNotFound', '%s', msg); +end + +function s = codeList(codes) +% CODELIST Short text list of codes for messages (first 10 shown) +% +% Inputs: +% codes - Numeric vector +% +% Outputs: +% s - e.g. '49, 50' or '1, 2, 3, ... (25 codes)' + +codes = codes(:)'; +if isempty(codes) + s = 'none'; + return; +end +shown = codes(1:min(10, end)); +s = strjoin(arrayfun(@(c) sprintf('%g', c), shown, 'UniformOutput', false), ', '); +if numel(codes) > 10 + s = sprintf('%s, ... (%d codes)', s, numel(codes)); +end +end + +function s = recordingLabel(data) +% RECORDINGLABEL ' for ' from info.filename (file name only) or +% info.SubjectID, else '' +% +% Inputs: +% data - fNIRS struct +% +% Outputs: +% s - Text to splice into messages so batch warnings name the recording + +s = ''; +if ~isfield(data, 'info') || ~isstruct(data.info) + return; +end +for f = {'filename', 'SubjectID'} + if isfield(data.info, f{1}) && ~isempty(data.info.(f{1})) + v = data.info.(f{1}); + if isnumeric(v) + v = num2str(v); + end + v = char(string(v)); + if strcmp(f{1}, 'filename') + [~, name, ext] = fileparts(v); + v = [name ext]; + end + s = sprintf(' for %s', v); + return; + end +end +end diff --git a/+pf2/+data/extractBlocks.m b/+pf2/+data/extractBlocks.m index 185c6459..dcab05fb 100644 --- a/+pf2/+data/extractBlocks.m +++ b/+pf2/+data/extractBlocks.m @@ -1,4 +1,4 @@ -function segments = extractBlocks(data, blocks, opts) +function segments = extractBlocks(data, varargin) % EXTRACTBLOCKS Extract each block as a separate fNIRS struct % % Takes an fNIRS data structure and a block definition array (from @@ -87,21 +87,24 @@ % % See also: pf2.data.defineBlocks, pf2.data.split, pf2.data.setT0 -arguments - data - blocks = [] - opts.PreTime {mustBeNumeric} = [] - opts.PostTime {mustBeNumeric} = [] - opts.Buffer = [] % [] = not given; effective default 2 s (both sides) - opts.BaselineWindow {mustBeNumeric} = [] - opts.SetT0 (1,1) logical = true - opts.OverwriteInfo (1,1) logical = true - opts.CopyInfo (1,1) logical = true % Kept for backward compat, ignored - opts.SkipInvalid (1,1) logical = true - opts.RejectByAux = '' - opts.AuxMotionFraction (1,1) {mustBeNumeric} = 0.2 - opts.AuxMotionThresh = [] -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +[blocks, opts] = pf2_base.args.parse('extractBlocks', varargin, { + 'blocks', ([]), '', '', {} + }, { + 'PreTime', ([]), '', '', {@mustBeNumeric} + 'PostTime', ([]), '', '', {@mustBeNumeric} + 'Buffer', ([]), '', '', {} + 'BaselineWindow', ([]), '', '', {@mustBeNumeric} + 'SetT0', (true), '1,1', 'logical', {} + 'OverwriteInfo', (true), '1,1', 'logical', {} + 'CopyInfo', (true), '1,1', 'logical', {} + 'SkipInvalid', (true), '1,1', 'logical', {} + 'RejectByAux', (''), '', '', {} + 'AuxMotionFraction', (0.2), '1,1', '', {@mustBeNumeric} + 'AuxMotionThresh', ([]), '', '', {} + }); % --- Cell array input: iterate and concatenate --- if iscell(data) diff --git a/+pf2/+data/getMarkers.m b/+pf2/+data/getMarkers.m index e2f515ab..6974e5df 100644 --- a/+pf2/+data/getMarkers.m +++ b/+pf2/+data/getMarkers.m @@ -1,4 +1,4 @@ -function [markerTimes, tableMrkTimes, matchedPatterns] = getMarkers(fNIR, markersStart, markersEnd, opts) +function [markerTimes, tableMrkTimes, matchedPatterns] = getMarkers(fNIR, varargin) % GETMARKERS Extract event marker times matching specified codes or patterns % % Searches fNIRS marker data to find events matching specified marker codes @@ -60,18 +60,21 @@ -arguments - fNIR - markersStart {mustBeNumericOrLogical} = [] - markersEnd {mustBeNumericOrLogical} = [] - opts.markerPattern = [] - opts.markerColumn {mustBeNumericOrLogical} = 2 - opts.markerVariableName {mustBeText} = '' - opts.timeColumn {mustBeNumericOrLogical} = 1 - opts.returnIndices {mustBeNumericOrLogical} = false - opts.exactMatch {mustBeNumericOrLogical} = false - opts.sortTimes {mustBeNumericOrLogical} = false -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +[markersStart, markersEnd, opts] = pf2_base.args.parse('getMarkers', varargin, { + 'markersStart', ([]), '', '', {@mustBeNumericOrLogical} + 'markersEnd', ([]), '', '', {@mustBeNumericOrLogical} + }, { + 'markerPattern', ([]), '', '', {} + 'markerColumn', (2), '', '', {@mustBeNumericOrLogical} + 'markerVariableName', (''), '', '', {@mustBeText} + 'timeColumn', (1), '', '', {@mustBeNumericOrLogical} + 'returnIndices', (false), '', '', {@mustBeNumericOrLogical} + 'exactMatch', (false), '', '', {@mustBeNumericOrLogical} + 'sortTimes', (false), '', '', {@mustBeNumericOrLogical} + }); validfNIR_Input = @(x) (isstruct(x) && (isfield(x,'raw')||isfield(x,'time')||isfield(x,'info'))); diff --git a/+pf2/+data/importBlockInfo.m b/+pf2/+data/importBlockInfo.m index 6574af21..d0c7f99f 100644 --- a/+pf2/+data/importBlockInfo.m +++ b/+pf2/+data/importBlockInfo.m @@ -1,4 +1,4 @@ -function blocks = importBlockInfo(blocks, source, opts) +function blocks = importBlockInfo(blocks, source, varargin) % IMPORTBLOCKINFO Import block-level metadata into block structs % % Attaches per-block metadata to a block struct array (from defineBlocks). @@ -90,17 +90,18 @@ % % See also: pf2.data.importInfo, pf2.data.defineBlocks, pf2.data.extractBlocks -arguments - blocks - source - opts.Field = '' - opts.Keys = {} - opts.MarkerCode {mustBeNumeric} = [] - opts.Condition = '' - opts.Sheet = 1 - opts.Overwrite (1,1) logical = true - opts.ReadOptions {mustBeA(opts.ReadOptions, 'cell')} = {} -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +opts = pf2_base.args.parse('importBlockInfo', varargin, {}, { + 'Field', (''), '', '', {} + 'Keys', ({}), '', '', {} + 'MarkerCode', ([]), '', '', {@mustBeNumeric} + 'Condition', (''), '', '', {} + 'Sheet', (1), '', '', {} + 'Overwrite', (true), '1,1', 'logical', {} + 'ReadOptions', ({}), '', '', {@(v) mustBeA(v, 'cell')} + }); keys = cellstr(opts.Keys); if numel(keys) == 1 && isempty(keys{1}) diff --git a/+pf2/+data/infoFromTable.m b/+pf2/+data/infoFromTable.m index eaee7cfe..aef49cb9 100644 --- a/+pf2/+data/infoFromTable.m +++ b/+pf2/+data/infoFromTable.m @@ -1,4 +1,4 @@ -function data = infoFromTable(data, T, value, opts) +function data = infoFromTable(data, T, varargin) % INFOFROMTABLE Write table columns back into .info fields of fNIRS structs % % Maps table rows positionally to fNIRS data structs: row 1 updates @@ -55,13 +55,15 @@ % % See also: pf2.data.infoToTable, pf2.data.importInfo -arguments - data - T - value = "__pf2_infoFromTable_novalue__" - opts.Overwrite (1,1) logical = true - opts.Clear (1,1) logical = false -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +[value, opts] = pf2_base.args.parse('infoFromTable', varargin, { + 'value', ("__pf2_infoFromTable_novalue__"), '', '', {} + }, { + 'Overwrite', (true), '1,1', 'logical', {} + 'Clear', (false), '1,1', 'logical', {} + }); % A sentinel default marks "value was not supplied" (single-field mode % requires an explicit value argument, matching the original ~isempty(varargin) diff --git a/+pf2/+data/labelMarkers.m b/+pf2/+data/labelMarkers.m index 2d0d1222..e921ba35 100644 --- a/+pf2/+data/labelMarkers.m +++ b/+pf2/+data/labelMarkers.m @@ -1,4 +1,4 @@ -function out = labelMarkers(data, map, opts) +function out = labelMarkers(data, varargin) % LABELMARKERS Attach categorical labels to marker codes % % Adds (or updates) a categorical column on the marker table that maps each @@ -64,13 +64,16 @@ % % See also: pf2.data.defineBlocks, pf2.data.getMarkers, pf2_base.normalizeMarkers -arguments - data - map = [] - opts.VarName {mustBeTextScalar} = 'Label' - opts.Ordinal (1,1) logical = false - opts.Categories = [] -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +[map, opts] = pf2_base.args.parse('labelMarkers', varargin, { + 'map', ([]), '', '', {} + }, { + 'VarName', ('Label'), '', '', {@mustBeTextScalar} + 'Ordinal', (false), '1,1', 'logical', {} + 'Categories', ([]), '', '', {} + }); % --- Cell array input: apply to each element --- if iscell(data) diff --git a/+pf2/+data/removeMarkers.m b/+pf2/+data/removeMarkers.m index 4caee48f..9f1d9d02 100644 --- a/+pf2/+data/removeMarkers.m +++ b/+pf2/+data/removeMarkers.m @@ -1,4 +1,4 @@ -function out = removeMarkers(data, codes, opts) +function out = removeMarkers(data, varargin) % REMOVEMARKERS Remove marker rows by code, time window, or row index % % Drops rows from the event marker table selected by marker Code, by a time @@ -65,13 +65,16 @@ % See also: pf2.data.dedupeMarkers, pf2.data.getMarkers, ... % pf2.data.defineBlocks, pf2_base.normalizeMarkers -arguments - data - codes {mustBeNumeric} = [] - opts.Time {mustBeNumeric} = [] - opts.Indices = [] - opts.Verbose (1,1) logical = true -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +[codes, opts] = pf2_base.args.parse('removeMarkers', varargin, { + 'codes', ([]), '', '', {@mustBeNumeric} + }, { + 'Time', ([]), '', '', {@mustBeNumeric} + 'Indices', ([]), '', '', {} + 'Verbose', (true), '1,1', 'logical', {} + }); % --- Cell array input: apply to each element --- if iscell(data) diff --git a/+pf2/+data/resample.m b/+pf2/+data/resample.m index dbb348ef..cb335b63 100644 --- a/+pf2/+data/resample.m +++ b/+pf2/+data/resample.m @@ -1,4 +1,4 @@ -function [outFNIR, pFit] = resample(fNIR, segmentLength, blLength, opts) +function [outFNIR, pFit] = resample(fNIR, varargin) % RESAMPLE Downsample and time-average fNIRS data % % Resamples fNIRS data to a lower sampling rate by averaging samples within @@ -60,21 +60,24 @@ % % See also: pf2.data.split, pf2.data.setT0, pf2.data.concatenate -arguments - fNIR - segmentLength (1,1) {mustBeNumeric, mustBePositive} = 1 - blLength {mustBeNumeric} = [] - opts.blfNIR = [] - opts.centerOnT0 (1,1) logical = false - opts.timeOutMode {mustBeMember(opts.timeOutMode, {'mid','start','end'})} = 'start' - opts.nanRejectionLevel (1,1) {mustBeNumeric, mustBeNonnegative} = 0.7 - opts.averageAux (1,1) logical = false - opts.flattenAux (1,1) logical = false - opts.trimAux (1,1) logical = false - opts.polyDegree (1,1) {mustBeNumeric, mustBeNonnegative} = 1 - opts.centerOnTime {mustBeNumeric} = NaN - opts.specifiedTimepoints {mustBeNumeric} = [] -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +[segmentLength, blLength, opts] = pf2_base.args.parse('resample', varargin, { + 'segmentLength', (1), '1,1', '', {@mustBeNumeric, @mustBePositive} + 'blLength', ([]), '', '', {@mustBeNumeric} + }, { + 'blfNIR', ([]), '', '', {} + 'centerOnT0', (false), '1,1', 'logical', {} + 'timeOutMode', ('start'), '', '', {@(v) mustBeMember(v, {'mid','start','end'})} + 'nanRejectionLevel', (0.7), '1,1', '', {@mustBeNumeric, @mustBeNonnegative} + 'averageAux', (false), '1,1', 'logical', {} + 'flattenAux', (false), '1,1', 'logical', {} + 'trimAux', (false), '1,1', 'logical', {} + 'polyDegree', (1), '1,1', '', {@mustBeNumeric, @mustBeNonnegative} + 'centerOnTime', (NaN), '', '', {@mustBeNumeric} + 'specifiedTimepoints', ([]), '', '', {@mustBeNumeric} + }); segLength=segmentLength; % How long is each segment, ie: 1 sample blfNIR=opts.blfNIR; % Separate baseline source struct/array (mutated as a local below) @@ -404,7 +407,7 @@ end end -if(isfield(fNIR,'Aux')&&~isempty(fNIR.Aux)&&length(fields(fNIR.Aux))>1) +if(isfield(fNIR,'Aux')&&~isempty(fNIR.Aux)&&length(fieldnames(fNIR.Aux))>1) if(averageAux) % Attempts to resample any field (and align with fNIRS) % criteria: @@ -437,7 +440,7 @@ validTimeFields={'time','t','Time'}; if(outFNIR.Aux.flattened) - auxTrimFields=fields(outFNIR.Aux); + auxTrimFields=fieldnames(outFNIR.Aux); for atIdx=1:length(auxTrimFields) curVar=outFNIR.Aux.(auxTrimFields{atIdx}); @@ -462,7 +465,7 @@ validFields=pf2_base.pf2_getFNIRSfields(); -fdataFields=fields(fNIR); % Copy known fields +fdataFields=fieldnames(fNIR); % Copy known fields for fieldIdx=1:length(fdataFields) memberIdx=ismember(validFields,fdataFields{fieldIdx}); if(any(memberIdx)&&~strcmp(fdataFields{fieldIdx},'time')... @@ -519,7 +522,7 @@ end function [outAuxStruct] = recursiveAuxResample(aux_in,flattenAux,segLength,centerOnTime,nir_time,fTimeInd,nanRejectionLevel,parent_time_in,parentTimeInd) - auxFields=fields(aux_in); + auxFields=fieldnames(aux_in); if(nargin<8) parent_time_in=[]; @@ -765,7 +768,7 @@ if(auxFieldHasTime(f)||flattenAux) if(exist('curTimeNames','var')&&~isempty(curTimeNames)) - outAuxStruct.(curFieldName)=table(t_aux_resample,'VariableNames',curTimeNames); + outAuxStruct.(curFieldName)=table(t_aux_resample,'VariableNames',cellstr(curTimeNames)); else outAuxStruct.(curFieldName)=table(t_aux_resample,'VariableNames',{'time'}); end @@ -863,7 +866,7 @@ else struct2unpack=recursiveAuxResample(aux_in.(curFieldName),true,segLength,centerOnTime,nir_time,fTimeInd,nanRejectionLevel,local_time,localTimeInd); - fields2assign=fields(struct2unpack); + fields2assign=fieldnames(struct2unpack); for f2=1:length(fields2assign) subFieldName=fields2assign{f2}; newFieldName=sprintf('%s_%s',curFieldName,subFieldName); @@ -880,7 +883,7 @@ end function [outAuxStruct] = recursiveAuxFlatten(aux_in,nir_time,parent_time_in) - auxFields=fields(aux_in); + auxFields=fieldnames(aux_in); if(nargin<3) parent_time_in=[]; @@ -1050,7 +1053,7 @@ - outAuxStruct.(curFieldName)=table(t_aux,'VariableNames',curTimeNames); + outAuxStruct.(curFieldName)=table(t_aux,'VariableNames',cellstr(curTimeNames)); @@ -1113,7 +1116,7 @@ struct2unpack=recursiveAuxFlatten(aux_in.(curFieldName),nir_time,local_time); - fields2assign=fields(struct2unpack); + fields2assign=fieldnames(struct2unpack); for f2=1:length(fields2assign) subFieldName=fields2assign{f2}; newFieldName=sprintf('%s_%s',curFieldName,subFieldName); diff --git a/+pf2/+data/setMarkerDict.m b/+pf2/+data/setMarkerDict.m index e097e898..9d838216 100644 --- a/+pf2/+data/setMarkerDict.m +++ b/+pf2/+data/setMarkerDict.m @@ -1,4 +1,4 @@ -function data = setMarkerDict(data, dict, opts) +function data = setMarkerDict(data, dict, varargin) % SETMARKERDICT Set or merge the dataset's marker dictionary % % Stores a canonical code->label dictionary at data.info.markerDict so that @@ -46,11 +46,12 @@ % See also: pf2.data.getMarkerDict, pf2.data.labelMarkers, % pf2_base.normalizeMarkerDict -arguments - data - dict - opts.Merge (1,1) logical = true -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +opts = pf2_base.args.parse('setMarkerDict', varargin, {}, { + 'Merge', (true), '1,1', 'logical', {} + }); % Cell array: apply to each element if iscell(data) diff --git a/+pf2/+data/setT0.m b/+pf2/+data/setT0.m index de9ceed1..bb6f3c72 100644 --- a/+pf2/+data/setT0.m +++ b/+pf2/+data/setT0.m @@ -1,4 +1,4 @@ -function outFNIR=setT0(fnirStruct,t0time) +function outFNIR=setT0(fnirStruct, t0time) % SETT0 Shift time alignment of fNIRS data to a new reference point % % Adjusts the time vector and related temporal fields in an fNIRS struct @@ -42,10 +42,10 @@ % % See also: pf2.data.split, pf2.data.getMarkers, pf2.data.resample -arguments - fnirStruct (1,1) struct - t0time {mustBeA(t0time, ["numeric", "duration", "datetime"])} -end +% Inputs are checked with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. +fnirStruct = pf2_base.args.check(fnirStruct, '1,1', 'struct', {}, 'Invalid argument at position 1.'); +t0time = pf2_base.args.check(t0time, '', '', {@(v) mustBeA(v, {'numeric', 'duration', 'datetime'})}, 'Invalid argument at position 2.'); outFNIR=fnirStruct; @@ -122,7 +122,7 @@ end if(pf2_base.isnestedfield(outFNIR,'Aux')) && ~isempty(outFNIR.Aux) - auxFields=fields(outFNIR.Aux); + auxFields=fieldnames(outFNIR.Aux); % Skip known time fields that are handled separately below timeFieldNames = {'t', 'time', 'Time', 'elapsedTime'}; for f=1:length(auxFields) diff --git a/+pf2/+data/slidingWindows.m b/+pf2/+data/slidingWindows.m index e237a7d9..cabbc371 100644 --- a/+pf2/+data/slidingWindows.m +++ b/+pf2/+data/slidingWindows.m @@ -1,4 +1,4 @@ -function blocks = slidingWindows(data, opts) +function blocks = slidingWindows(data, varargin) % SLIDINGWINDOWS Define fixed-length sliding-window blocks over a recording % % Tiles a continuous recording with fixed-length time windows at a regular @@ -94,18 +94,20 @@ % % See also: pf2.data.defineBlocks, pf2.data.extractBlocks, pf2.data.split -arguments - data - opts.Length {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = [] - opts.Step {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = [] - opts.Overlap {mustBeNumeric, mustBeScalarOrEmpty, mustBeNonnegative, mustBeLessThan(opts.Overlap, 1)} = [] - opts.Start {mustBeNumeric, mustBeScalarOrEmpty} = [] - opts.End {mustBeNumeric, mustBeScalarOrEmpty} = [] - opts.Partial (1,1) logical = false - opts.Condition {mustBeText} = '' - opts.ConditionField {mustBeText} = 'Condition' - opts.Embed (1,1) logical = true -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +opts = pf2_base.args.parse('slidingWindows', varargin, {}, { + 'Length', ([]), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + 'Step', ([]), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBePositive} + 'Overlap', ([]), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty, @mustBeNonnegative, @(v) mustBeLessThan(v, 1)} + 'Start', ([]), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty} + 'End', ([]), '', '', {@mustBeNumeric, @mustBeScalarOrEmpty} + 'Partial', (false), '1,1', 'logical', {} + 'Condition', (''), '', '', {@mustBeText} + 'ConditionField', ('Condition'), '', '', {@mustBeText} + 'Embed', (true), '1,1', 'logical', {} + }); % --- Cell array input: apply to each element (requires Embed) --- if iscell(data) diff --git a/+pf2/+data/split.m b/+pf2/+data/split.m index 265c08e9..200a47ab 100644 --- a/+pf2/+data/split.m +++ b/+pf2/+data/split.m @@ -371,7 +371,7 @@ validFields=pf2_base.pf2_getFNIRSfields(); -fdataFields=fields(fNIR); % Copy known fields +fdataFields=fieldnames(fNIR); % Copy known fields for i=1:length(fdataFields) memberIdx=ismember(validFields,fdataFields{i}); if(any(memberIdx)&&~strcmp(fdataFields{i},'time')&&~strcmp(fdataFields{i},'ROI')&&~isfield(outfNIR,fdataFields{i})) @@ -390,7 +390,7 @@ validTimeFields={'time','t','Time','elapsedTime'}; if(outfNIR.Aux.flattened) - auxTrimFields=fields(outfNIR.Aux); + auxTrimFields=fieldnames(outfNIR.Aux); for atIdx=1:length(auxTrimFields) curVar=outfNIR.Aux.(auxTrimFields{atIdx}); @@ -469,7 +469,7 @@ end function [outAuxStruct] = recursiveAuxFlatten(aux_in,nir_time,parent_time_in) - auxFields=fields(aux_in); + auxFields=fieldnames(aux_in); if(nargin<3) parent_time_in=[]; @@ -511,9 +511,9 @@ outAuxStruct.(validTimeFields{cur_time_ind})=local_time; end - szLocalTime(:)=size(local_time); - szParentTime(:)=size(parent_time_in); - szNIRTime(:)=size(nir_time); + szLocalTime=size(local_time); + szParentTime=size(parent_time_in); + szNIRTime=size(nir_time); % look through each aux field for times @@ -653,7 +653,7 @@ - outAuxStruct.(curFieldName)=table(t_aux,'VariableNames',curTimeNames); + outAuxStruct.(curFieldName)=table(t_aux,'VariableNames',cellstr(curTimeNames)); @@ -716,7 +716,7 @@ struct2unpack=recursiveAuxFlatten(aux_in.(curFieldName),nir_time,local_time); - fields2assign=fields(struct2unpack); + fields2assign=fieldnames(struct2unpack); for f2=1:length(fields2assign) subFieldName=fields2assign{f2}; newFieldName=sprintf('%s_%s',curFieldName,subFieldName); diff --git a/+pf2/+export/asSNIRF.m b/+pf2/+export/asSNIRF.m index 551dd3ed..c1d0660c 100644 --- a/+pf2/+export/asSNIRF.m +++ b/+pf2/+export/asSNIRF.m @@ -198,7 +198,7 @@ end % Add probe metadata to general metadata - probeFields = fields(probeMetaData); + probeFields = fieldnames(probeMetaData); for p = 1:length(probeFields) metaDataTags.(probeFields{p}) = probeMetaData.(probeFields{p}); end @@ -636,7 +636,7 @@ end % Copy all relevant info fields - infoFields = fields(info); + infoFields = fieldnames(info); for i = 1:length(infoFields) fieldName = infoFields{i}; fieldValue = info.(fieldName); diff --git a/+pf2/+import/+sampleData/experiment.m b/+pf2/+import/+sampleData/experiment.m index 9213e825..81b8d53d 100644 --- a/+pf2/+import/+sampleData/experiment.m +++ b/+pf2/+import/+sampleData/experiment.m @@ -72,7 +72,7 @@ stage = validatestring(stage, {'raw', 'blocks', 'extracted', 'aligned'}); % Process sample data once -raw = pf2.import.sampleData.fNIR2000(); +raw = pf2.import.sampleData('fNIR2000'); % by-name form also works under Octave processed = processFNIRS2(raw); % Experiment parameters diff --git a/+pf2/+import/+sampleData/group.m b/+pf2/+import/+sampleData/group.m index 2d7b3dfe..36acb37f 100644 --- a/+pf2/+import/+sampleData/group.m +++ b/+pf2/+import/+sampleData/group.m @@ -52,7 +52,7 @@ opts = p.Results; % Aligned block segments (4 subjects x 6 blocks), ready for Experiment -allData = pf2.import.sampleData.experiment('aligned'); +allData = pf2.import.sampleData('experiment', 'aligned'); % by-name form also works under Octave % Build and prepare the Experiment ex = exploreFNIRS.core.Experiment(allData); diff --git a/+pf2/+import/importHitachiMES.m b/+pf2/+import/importHitachiMES.m index b65feb36..3edc954d 100644 --- a/+pf2/+import/importHitachiMES.m +++ b/+pf2/+import/importHitachiMES.m @@ -137,7 +137,7 @@ delimiter='\t'; while(~feof(fid)&&(ischar(lineF))&&~strcmp(lineF,'Data')) - [lineSplit,idx]=strsplit(lineF,delimiter); + lineSplit=strsplit(lineF,delimiter); if(length(lineSplit)>1) varField=renameValid(strtrim(lineSplit{1})); header.(varField)=strtrim(lineF(length(lineSplit{1})+2:end)); @@ -151,7 +151,7 @@ frewind(fid); lineF=fgetl(fid); while(~feof(fid)&&(ischar(lineF))&&~strcmp(lineF,'Data')) - [lineSplit,idx]=strsplit(lineF,delimiter); + lineSplit=strsplit(lineF,delimiter); if(length(lineSplit)>1) varField=renameValid(strtrim(lineSplit{1})); header.(varField)=strtrim(lineF(length(lineSplit{1})+2:end)); diff --git a/+pf2/+import/importNIRX.m b/+pf2/+import/importNIRX.m index 504c001c..9cbf266b 100644 --- a/+pf2/+import/importNIRX.m +++ b/+pf2/+import/importNIRX.m @@ -408,7 +408,7 @@ fNIR.info.probeInfo.read(); pInfo=fNIR.info.probeInfo; - if(ismember('Markers',fields(pInfo))&&isfield(pInfo.Markers,'Events')) + if(ismember('Markers',fieldnames(pInfo))&&isfield(pInfo.Markers,'Events')) NIRX_mrk=strsplit(pInfo.Markers.Events,'\n'); numL=0; for l=1:length(NIRX_mrk) @@ -423,19 +423,19 @@ end end - if(ismember('ImagingParameters',fields(pInfo))&&isfield(pInfo.ImagingParameters,'SamplingRate')) + if(ismember('ImagingParameters',fieldnames(pInfo))&&isfield(pInfo.ImagingParameters,'SamplingRate')) fNIR.fs=pInfo.ImagingParameters.SamplingRate; fNIR.info.SamplingRate=fNIR.fs; end - if(ismember('ImagingParameters',fields(pInfo))&&isfield(pInfo.ImagingParameters,'Wavelengths')) + if(ismember('ImagingParameters',fieldnames(pInfo))&&isfield(pInfo.ImagingParameters,'Wavelengths')) s=pInfo.ImagingParameters.Wavelengths; s(s==''''|s=='"')=[]; s=strsplit(s,'\t'); fNIR.info.curWv=str2double(s); end - if(ismember('DataStructure',fields(pInfo))&&isfield(pInfo.DataStructure,'S_D_Key')) + if(ismember('DataStructure',fieldnames(pInfo))&&isfield(pInfo.DataStructure,'S_D_Key')) s=pInfo.DataStructure.S_D_Key; s(s==''''|s=='"')=[]; sdkeyLines=strsplit(s,','); @@ -444,7 +444,7 @@ end end - if(ismember('DataStructure',fields(pInfo))&&isfield(pInfo.DataStructure,'S_D_Mask')) + if(ismember('DataStructure',fieldnames(pInfo))&&isfield(pInfo.DataStructure,'S_D_Mask')) s=pInfo.DataStructure.S_D_Mask; s(s==''''|s=='"')=[]; sdmaskLines=strsplit(s,'\n'); @@ -458,27 +458,27 @@ end end - if(ismember('ChannelsDistance',fields(pInfo))&&isfield(pInfo.ChannelsDistance,'ChanDis')) + if(ismember('ChannelsDistance',fieldnames(pInfo))&&isfield(pInfo.ChannelsDistance,'ChanDis')) s=pInfo.ChannelsDistance.ChanDis; s(s==''''|s=='"')=[]; splitLine=strsplit(s,'\t'); fNIR.info.ChanDis=str2double(splitLine)/10; %convert to cm end - if(ismember('GeneralInfo',fields(pInfo))&&isfield(pInfo.GeneralInfo,'Subject')) + if(ismember('GeneralInfo',fieldnames(pInfo))&&isfield(pInfo.GeneralInfo,'Subject')) fNIR.info.SubjectID=pInfo.GeneralInfo.Subject; end - if(ismember('GeneralInfo',fields(pInfo))&&isfield(pInfo.GeneralInfo,'FileName')) + if(ismember('GeneralInfo',fieldnames(pInfo))&&isfield(pInfo.GeneralInfo,'FileName')) fNIR.info.Filename=pInfo.GeneralInfo.FileName; end - if(ismember('GeneralInfo',fields(pInfo))&&isfield(pInfo.GeneralInfo,'Date')) + if(ismember('GeneralInfo',fieldnames(pInfo))&&isfield(pInfo.GeneralInfo,'Date')) fNIR.info.Date=pInfo.GeneralInfo.Date; fNIR.info.StartDateTime=datetime(pInfo.GeneralInfo.Date); end - if(ismember('GeneralInfo',fields(pInfo))&&isfield(pInfo.GeneralInfo,'Time')) + if(ismember('GeneralInfo',fieldnames(pInfo))&&isfield(pInfo.GeneralInfo,'Time')) fNIR.info.Time=pInfo.GeneralInfo.Time; %fNIR.info.StartDateTime=pInfo.GeneralInfo.Time; end diff --git a/+pf2/+import/importSNIRF.m b/+pf2/+import/importSNIRF.m index 31c5bffb..7cc7fbc6 100644 --- a/+pf2/+import/importSNIRF.m +++ b/+pf2/+import/importSNIRF.m @@ -980,7 +980,7 @@ function outTable = struct2table(structObj) % Convert structure array to table - fieldsInStruct = fields(structObj(1)); + fieldsInStruct = fieldnames(structObj(1)); outTable = table(); for n = 1:length(structObj) @@ -1005,7 +1005,7 @@ function outStruct = stripStruct(structObj) % Clean up metadata structure by removing null characters and whitespace - fieldsInStruct = fields(structObj); + fieldsInStruct = fieldnames(structObj); outStruct = structObj; diff --git a/+pf2/+import/sampleData.m b/+pf2/+import/sampleData.m index 969d09f2..ec97385d 100644 --- a/+pf2/+import/sampleData.m +++ b/+pf2/+import/sampleData.m @@ -1,11 +1,14 @@ -function fNIR=sampleData() +function varargout=sampleData(name, varargin) % SAMPLEDATA Load a bundled example fNIRS dataset for testing/tutorials % % Loads the default bundled fNIR Devices sample recording (fNIR1200, with % event markers) so you can try the toolbox without supplying your own file. % Returns a ready-to-process data struct. Additional named datasets are % available as sibling functions in the sampleData namespace (call with -% parentheses), each covering a different device or workflow stage. +% parentheses), each covering a different device or workflow stage. They +% can also be requested by name, sampleData('fNIR2000'), which is the form +% that works under GNU Octave: there this function hides the sampleData +% namespace, so pf2.import.sampleData.fNIR2000() does not resolve. % % Syntax: % data = pf2.import.sampleData() % default fNIR1200 recording @@ -16,11 +19,18 @@ % pf2.import.sampleData.group() % grouped Experiment object % pf2.import.sampleData.Hitachi_ETG4000_3x5() % Hitachi ETG-4000 (3x5) % pf2.import.sampleData.Hitachi_ETG4000_3x11() % Hitachi ETG-4000 (3x11) +% data = pf2.import.sampleData('fNIR2000') % same, by name (MATLAB and Octave) +% [ex, allData] = pf2.import.sampleData('group', 'GroupBy', {'Condition'}) % % Inputs: -% (none) +% name - (optional) Dataset name: 'fNIR2000', 'fNIR1200', 'experiment', +% 'group', 'Hitachi_ETG4000_3x5' or 'Hitachi_ETG4000_3x11'. +% Omit for the default fNIR1200 recording. +% varargin - Arguments passed to the named dataset function. % % Outputs: +% varargout - Without a name: the fNIRS data struct below. With a name: +% the named dataset function's outputs. % fNIR - fNIRS data struct (.raw, .time, .fs, .fchMask, .markers, .info, % .device) ready to pass to processFNIRS2. With no output argument % the channel-check GUI is shown; with an output it loads headless. @@ -34,6 +44,23 @@ % See also: processFNIRS2, pf2.import.importNIR, % pf2.import.sampleData.fNIR2000, pf2.import.sampleData.experiment +if nargin >= 1 + datasets = {'fNIR2000', 'fNIR1200', 'experiment', 'group', ... + 'Hitachi_ETG4000_3x5', 'Hitachi_ETG4000_3x11'}; + idx = find(strcmpi(char(name), datasets), 1); + if isempty(idx) + error('pf2:import:sampleData:unknownDataset', ... + 'Unknown sample dataset "%s". Available: %s.', char(name), strjoin(datasets, ', ')); + end + fn = ['pf2.import.sampleData.' datasets{idx}]; + if nargout == 0 + feval(fn, varargin{:}); + else + [varargout{1:nargout}] = feval(fn, varargin{:}); + end + return; +end + [filepath]=mfilename('fullpath'); slashes=find(filepath'=='/'|filepath'=='\'); @@ -43,7 +70,7 @@ mrkfilepath=sprintf('%s../../sampledata/sampleNIR.mrk',filepath); if(nargout>0) - fNIR=pf2.import.importNIR(nirfilepath,mrkfilepath,false); % just load data + varargout{1}=pf2.import.importNIR(nirfilepath,mrkfilepath,false); % just load data else - fNIR=pf2.import.importNIR(nirfilepath,mrkfilepath,true); % show GUI + varargout{1}=pf2.import.importNIR(nirfilepath,mrkfilepath,true); % show GUI; sets ans end \ No newline at end of file diff --git a/+pf2/+methods/+oxy/create.m b/+pf2/+methods/+oxy/create.m index 160e26f4..45fb639e 100644 --- a/+pf2/+methods/+oxy/create.m +++ b/+pf2/+methods/+oxy/create.m @@ -59,6 +59,12 @@ function create(methodName, varargin) if strcmpi(methodName, 'None') error('pf2:ReservedName', '''None'' is a reserved method name'); end +% Method libraries address methods as cfg.(methodName), which a property +% or method name of the INI class would shadow. +if pf2_base.external.INI.isReservedName(methodName) + error('pf2:ReservedName', ... + '''%s'' cannot be used as a method name (it is reserved by the methods library).', methodName); +end % Check if method already exists methodExists = ismember(methodName, methodsLib.cfg.Sections); diff --git a/+pf2/+methods/+raw/create.m b/+pf2/+methods/+raw/create.m index 84aad095..ff8a14a4 100644 --- a/+pf2/+methods/+raw/create.m +++ b/+pf2/+methods/+raw/create.m @@ -66,6 +66,12 @@ function create(methodName, varargin) if strcmpi(methodName, 'None') error('pf2:ReservedName', '''None'' is a reserved method name'); end +% Method libraries address methods as cfg.(methodName), which a property +% or method name of the INI class would shadow. +if pf2_base.external.INI.isReservedName(methodName) + error('pf2:ReservedName', ... + '''%s'' cannot be used as a method name (it is reserved by the methods library).', methodName); +end % Check if method already exists methodExists = ismember(methodName, methodsLib.cfg.Sections); diff --git a/+pf2/+qc/+pipeline/apply.m b/+pf2/+qc/+pipeline/apply.m index c3b11708..436db007 100644 --- a/+pf2/+qc/+pipeline/apply.m +++ b/+pf2/+qc/+pipeline/apply.m @@ -1,4 +1,4 @@ -function data = apply(data, report, opts) +function data = apply(data, report, varargin) % APPLY Update fchMask based on a QC pipeline report % % ANDs the QC check results with the existing fchMask so that previously @@ -31,13 +31,16 @@ % % See also: pf2.qc.pipeline.assess, pf2.qc.pipeline.report -arguments - data struct - report struct - opts.Checks = 'all' - opts.Mode = 'and' - opts.MarkNoisy (1,1) logical = false -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +data = pf2_base.args.check(data, '', 'struct', {}, 'Invalid argument at position 1.'); +report = pf2_base.args.check(report, '', 'struct', {}, 'Invalid argument at position 2.'); +opts = pf2_base.args.parse('apply', varargin, {}, { + 'Checks', ('all'), '', '', {} + 'Mode', ('and'), '', '', {} + 'MarkNoisy', (false), '1,1', 'logical', {} + }); %% Validate report assert(isfield(report, 'pass'), 'pf2:qc:pipeline:badReport', ... diff --git a/+pf2/+qc/+pipeline/assess.m b/+pf2/+qc/+pipeline/assess.m index bb462652..d06b9c74 100644 --- a/+pf2/+qc/+pipeline/assess.m +++ b/+pf2/+qc/+pipeline/assess.m @@ -1,4 +1,4 @@ -function report = assess(data, opts) +function report = assess(data, varargin) % ASSESS Standalone fNIRS channel quality assessment pipeline % % Performs lightweight internal processing on raw fNIRS data and runs @@ -58,19 +58,22 @@ % See also: pf2.qc.pipeline.apply, pf2.qc.pipeline.report, % pf2.qc.pipeline.plotReport, pf2.qc.sci, pf2.qc.powerSpectrum -arguments - data struct - opts.Checks {mustBeA(opts.Checks, 'cell')} = {'saturation','sci','cardiac','cov','takizawa'} - opts.SaturationThreshold (1,1) {mustBeNumeric} = 0.1 - opts.SCIThreshold (1,1) {mustBeNumeric} = 0.75 - opts.CardiacBand {mustBeNumeric} = [0.5, 2.5] - opts.CardiacSNR (1,1) {mustBeNumeric} = 3 - opts.CoVThreshold (1,1) {mustBeNumeric} = 0.2 - opts.LPFCutoff (1,1) {mustBeNumeric} = 0.1 - opts.TakizawaStrict (1,1) logical = false - opts.Wavelengths {mustBeNumeric} = [] - opts.ChannelNumbers {mustBeNumeric} = [] -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +data = pf2_base.args.check(data, '', 'struct', {}, 'Invalid argument at position 1.'); +opts = pf2_base.args.parse('assess', varargin, {}, { + 'Checks', ({'saturation','sci','cardiac','cov','takizawa'}), '', '', {@(v) mustBeA(v, 'cell')} + 'SaturationThreshold', (0.1), '1,1', '', {@mustBeNumeric} + 'SCIThreshold', (0.75), '1,1', '', {@mustBeNumeric} + 'CardiacBand', ([0.5, 2.5]), '', '', {@mustBeNumeric} + 'CardiacSNR', (3), '1,1', '', {@mustBeNumeric} + 'CoVThreshold', (0.2), '1,1', '', {@mustBeNumeric} + 'LPFCutoff', (0.1), '1,1', '', {@mustBeNumeric} + 'TakizawaStrict', (false), '1,1', 'logical', {} + 'Wavelengths', ([]), '', '', {@mustBeNumeric} + 'ChannelNumbers', ([]), '', '', {@mustBeNumeric} + }); %% Validate required fields assert(isfield(data, 'raw'), 'pf2:qc:pipeline:noRaw', ... diff --git a/+pf2/+qc/+pipeline/plotReport.m b/+pf2/+qc/+pipeline/plotReport.m index 48f85443..91fc1394 100644 --- a/+pf2/+qc/+pipeline/plotReport.m +++ b/+pf2/+qc/+pipeline/plotReport.m @@ -1,4 +1,4 @@ -function fig = plotReport(qcReport, opts) +function fig = plotReport(qcReport, varargin) % PLOTREPORT Visual QC dashboard showing all quality metrics % % Creates a 4-panel figure summarizing QC results across channels: @@ -32,13 +32,16 @@ % % See also: pf2.qc.pipeline.assess, pf2.qc.pipeline.report -arguments - qcReport struct - opts.Visible {mustBeText} = 'on' - opts.SavePath {mustBeText} = '' - opts.TightLayout (1,1) logical = false - opts.Title {mustBeText} = 'QC Pipeline Report' -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +qcReport = pf2_base.args.check(qcReport, '', 'struct', {}, 'Invalid argument at position 1.'); +opts = pf2_base.args.parse('plotReport', varargin, {}, { + 'Visible', ('on'), '', '', {@mustBeText} + 'SavePath', (''), '', '', {@mustBeText} + 'TightLayout', (false), '1,1', 'logical', {} + 'Title', ('QC Pipeline Report'), '', '', {@mustBeText} + }); %% Validate assert(isfield(qcReport, 'pass'), 'pf2:qc:pipeline:badReport', ... diff --git a/+pf2/+qc/plotQuality.m b/+pf2/+qc/plotQuality.m index a590a463..872c9385 100644 --- a/+pf2/+qc/plotQuality.m +++ b/+pf2/+qc/plotQuality.m @@ -1,4 +1,4 @@ -function fig = plotQuality(qcResult, opts) +function fig = plotQuality(qcResult, varargin) % PLOTQUALITY Visualize fNIRS signal quality metrics % % Dispatches based on the type of QC result passed in. Produces an SCI bar @@ -45,14 +45,17 @@ % % See also: pf2.qc.sci, pf2.qc.powerSpectrum -arguments - qcResult struct - opts.Channels {mustBeNumeric} = [] - opts.Layout = 'overlay' - opts.Visible = 'on' - opts.SavePath = '' - opts.Title = '' -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +qcResult = pf2_base.args.check(qcResult, '', 'struct', {}, 'Invalid argument at position 1.'); +opts = pf2_base.args.parse('plotQuality', varargin, {}, { + 'Channels', ([]), '', '', {@mustBeNumeric} + 'Layout', ('overlay'), '', '', {} + 'Visible', ('on'), '', '', {} + 'SavePath', (''), '', '', {} + 'Title', (''), '', '', {} + }); %% Dispatch based on result type if isfield(qcResult, 'sci') diff --git a/+pf2/+qc/powerSpectrum.m b/+pf2/+qc/powerSpectrum.m index 7d1ee0cf..d5e2a7c0 100644 --- a/+pf2/+qc/powerSpectrum.m +++ b/+pf2/+qc/powerSpectrum.m @@ -1,4 +1,4 @@ -function result = powerSpectrum(data, opts) +function result = powerSpectrum(data, varargin) % POWERSPECTRUM Power spectral density with physiological peak detection % % Computes power spectral density per channel using Welch's method and @@ -84,15 +84,18 @@ % % See also: pf2.qc.sci, pf2.qc.plotQuality, pwelch -arguments - data struct - opts.Signal = 'auto' - opts.Channels {mustBeNumeric} = [] - opts.WindowLength (1,1) {mustBeNumeric, mustBePositive} = 10 - opts.Overlap (1,1) {mustBeNumeric} = 0.5 - opts.FreqRange {mustBeNumeric} = [0, 5] - opts.DetectPeaks (1,1) logical = true -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +data = pf2_base.args.check(data, '', 'struct', {}, 'Invalid argument at position 1.'); +opts = pf2_base.args.parse('powerSpectrum', varargin, {}, { + 'Signal', ('auto'), '', '', {} + 'Channels', ([]), '', '', {@mustBeNumeric} + 'WindowLength', (10), '1,1', '', {@mustBeNumeric, @mustBePositive} + 'Overlap', (0.5), '1,1', '', {@mustBeNumeric} + 'FreqRange', ([0, 5]), '', '', {@mustBeNumeric} + 'DetectPeaks', (true), '1,1', 'logical', {} + }); %% Validate and resolve signal assert(isfield(data, 'fs'), 'pf2:qc:powerSpectrum:noFs', ... diff --git a/+pf2/+qc/sci.m b/+pf2/+qc/sci.m index b6e46cd2..6185c8a4 100644 --- a/+pf2/+qc/sci.m +++ b/+pf2/+qc/sci.m @@ -1,4 +1,4 @@ -function result = sci(data, opts) +function result = sci(data, varargin) % SCI Scalp Coupling Index for fNIRS channel quality assessment % % Measures optode-scalp contact quality by cross-correlating cardiac @@ -76,14 +76,17 @@ % % See also: pf2.qc.powerSpectrum, pf2.qc.plotQuality, bpf -arguments - data struct - opts.CardiacBand {mustBeNumeric} = [0.5, 2.5] - opts.FilterOrder (1,1) {mustBeNumeric, mustBePositive} = 4 - opts.Wavelengths {mustBeNumeric} = [] - opts.ChannelNumbers {mustBeNumeric} = [] - opts.Threshold (1,1) {mustBeNumeric} = 0.75 -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +data = pf2_base.args.check(data, '', 'struct', {}, 'Invalid argument at position 1.'); +opts = pf2_base.args.parse('sci', varargin, {}, { + 'CardiacBand', ([0.5, 2.5]), '', '', {@mustBeNumeric} + 'FilterOrder', (4), '1,1', '', {@mustBeNumeric, @mustBePositive} + 'Wavelengths', ([]), '', '', {@mustBeNumeric} + 'ChannelNumbers', ([]), '', '', {@mustBeNumeric} + 'Threshold', (0.75), '1,1', '', {@mustBeNumeric} + }); %% Validate required fields assert(isfield(data, 'raw'), 'pf2:qc:sci:noRaw', ... diff --git a/+pf2/+qc/takizawa.m b/+pf2/+qc/takizawa.m index e88560df..12bafafc 100644 --- a/+pf2/+qc/takizawa.m +++ b/+pf2/+qc/takizawa.m @@ -1,4 +1,4 @@ -function report = takizawa(data, opts) +function report = takizawa(data, varargin) % TAKIZAWA Automatic channel quality assessment using Takizawa criteria % % Evaluates four rules derived from Takizawa et al. (2008, 2014) to @@ -88,19 +88,22 @@ % % See also: pf2.qc.pipeline.assess, pf2.qc.sci, pf2_TakizawaRejection -arguments - data struct - opts.Strict (1,1) logical = false - opts.HFNoiseRatio (1,1) {mustBeNumeric} = 4 - opts.HFNoiseWindow (1,1) {mustBeNumeric} = 15 - opts.HFNoiseFraction (1,1) {mustBeNumeric} = 0.5 - opts.LFThreshold (1,1) {mustBeNumeric} = 0.3 - opts.CorrThreshold (1,1) {mustBeNumeric} = -0.9 - opts.BodyMovementThreshold (1,1) {mustBeNumeric} = 0.5 - opts.BodyMovementWindow (1,1) {mustBeNumeric} = 2 - opts.ProtocolDuration (1,1) {mustBeNumeric} = 90 - opts.IncludeBandPower (1,1) logical = false -end +% Inputs are parsed with pf2_base.args rather than an arguments block so +% the function also runs under GNU Octave. Spec rows are {name, default, +% size, class, validators}. +data = pf2_base.args.check(data, '', 'struct', {}, 'Invalid argument at position 1.'); +opts = pf2_base.args.parse('takizawa', varargin, {}, { + 'Strict', (false), '1,1', 'logical', {} + 'HFNoiseRatio', (4), '1,1', '', {@mustBeNumeric} + 'HFNoiseWindow', (15), '1,1', '', {@mustBeNumeric} + 'HFNoiseFraction', (0.5), '1,1', '', {@mustBeNumeric} + 'LFThreshold', (0.3), '1,1', '', {@mustBeNumeric} + 'CorrThreshold', (-0.9), '1,1', '', {@mustBeNumeric} + 'BodyMovementThreshold', (0.5), '1,1', '', {@mustBeNumeric} + 'BodyMovementWindow', (2), '1,1', '', {@mustBeNumeric} + 'ProtocolDuration', (90), '1,1', '', {@mustBeNumeric} + 'IncludeBandPower', (false), '1,1', 'logical', {} + }); %% Resolve Hb data if isfield(data, 'HbO') && isfield(data, 'HbR') diff --git a/+pf2/Device.m b/+pf2/Device.m index 5b0c4149..76808be0 100644 --- a/+pf2/Device.m +++ b/+pf2/Device.m @@ -23,7 +23,7 @@ % % See also: pf2_base.loadDeviceCfg, pf2_base.resolveDeviceFromData - properties (SetAccess = immutable) + properties (SetAccess = private) name % Config name, e.g. 'fNIR_Devices_fNIR2000' manufacturer % e.g. 'fNIR Devices' model % e.g. 'Model 2000' @@ -337,6 +337,9 @@ return; end + % Under GNU Octave, load packages and shims (no-op in MATLAB) + pf2_base.env.ensureOctaveCompat(); + % Resolve config name from data struct if isstruct(nameOrData) if isfield(nameOrData, 'info') && isfield(nameOrData.info, 'probename') diff --git a/+pf2/ProcessingContext.m b/+pf2/ProcessingContext.m index fdde72b2..c0d58c4c 100644 --- a/+pf2/ProcessingContext.m +++ b/+pf2/ProcessingContext.m @@ -63,6 +63,11 @@ % then applies any Name-Value settings. obj@pf2_base.ProcessingContext(); + % copy() constructs with COPY_TOKEN and then overwrites every + % property, so skip loading the method libraries. + if nargin == 1 && isequal(varargin{1}, pf2_base.ProcessingContext.COPY_TOKEN) + return; + end obj.loadMethods(); if nargin > 0 obj.configure(varargin{:}); diff --git a/+pf2_base/+args/check.m b/+pf2_base/+args/check.m new file mode 100644 index 00000000..1a9017fd --- /dev/null +++ b/+pf2_base/+args/check.m @@ -0,0 +1,147 @@ +function value = check(value, sizeSpec, className, validators, label) +% CHECK Convert, size-check and validate one input like an arguments block +% +% Applies an arguments-block declaration to a single value, in the order +% MATLAB uses: convert to the declared class, check (or reshape to) the +% declared size, then run each validator. Used by pf2_base.args.parse and +% directly for required positional inputs that carry a declaration. +% +% Reference: +% Internal pf2 implementation; behaviour matched against MATLAB R2025b +% argument validation. +% +% Syntax: +% value = pf2_base.args.check(value, sizeSpec, className, validators) +% value = pf2_base.args.check(value, sizeSpec, className, validators, label) +% +% Inputs: +% value - Input value. +% sizeSpec - '' (no size declaration) or a comma-separated spec such as +% '1,1', '1,:', ':,1' or ':,3'. +% className - '' (no class declaration) or a class name. 'struct' +% accepts structs and turns empty numeric, char or logical +% arrays into field-less struct arrays; other classes are +% converted by calling the class name as a function. +% validators - Cell array of function handles called with the value. +% label - (optional) Prefix for error messages, e.g. +% 'Invalid argument at position 2.' +% +% Outputs: +% value - Converted value. +% +% Example: +% flag = pf2_base.args.check(1, '1,1', 'logical', {}); % -> true +% +% See also: pf2_base.args.parse + + if nargin < 5 + label = ''; + end + + if ~isempty(className) + value = convertClass(value, className, label); + end + if ~isempty(sizeSpec) + value = checkSize(value, sizeSpec, label); + end + for k = 1:numel(validators) + try + validators{k}(value); + catch err + msg = strtrim([label ' ' err.message]); + if isempty(err.identifier) + error('pf2:args:validationFailed', '%s', msg); + end + error(err.identifier, '%s', msg); + end + end +end + +function value = convertClass(value, className, label) +% CONVERTCLASS Convert to the declared class, as an arguments block does. + if isa(value, className) + return; + end + converted = false; + if strcmp(className, 'struct') + if isempty(value) && (isnumeric(value) || ischar(value) || islogical(value)) + value = repmat(struct(), size(value)); + converted = true; + end + else + try + value = feval(className, value); + converted = true; + catch + end + end + if ~converted + error('MATLAB:validation:UnableToConvert', ... + '%s Value must be of type %s or be convertible to %s.', ... + label, className, className); + end +end + +function value = checkSize(value, sizeSpec, label) +% CHECKSIZE Enforce a size declaration such as '1,1', '1,:', ':,1' or ':,3'. + parts = strtrim(strsplit(sizeSpec, ',')); + want = nan(1, numel(parts)); % NaN = any length (':') + for k = 1:numel(parts) + if ~strcmp(parts{k}, ':') + want(k) = str2double(parts{k}); + end + end + sz = size(value); + sz(end+1:numel(want)) = 1; + + if numel(sz) == numel(want) && all(isnan(want) | sz == want) + return; + end + + % A 0-by-0 empty takes size 0 in every free dimension and keeps the + % fixed ones, when that still describes an empty array. + if isequal(size(value), [0 0]) + newSize = want; + newSize(isnan(newSize)) = 0; + if prod(newSize) == 0 + value = reshape(value, newSize); + return; + end + end + + % A scalar expands to the fixed dimensions (free dimensions become 1). + if isscalar(value) + newSize = want; + newSize(isnan(newSize)) = 1; + value = repmat(value, newSize); + return; + end + + % A vector (at most one non-singleton dimension, including N-D shapes + % such as 1x1x3) reshapes to a declared vector shape, or to a column + % when every dimension is free. + isVectorSpec = sum(isnan(want)) == 1 && all(want(~isnan(want)) == 1); + if sum(size(value) ~= 1) <= 1 + if isVectorSpec + newSize = want; + newSize(isnan(newSize)) = numel(value); + value = reshape(value, newSize); + return; + elseif all(isnan(want)) + newSize = ones(1, numel(want)); + newSize(1) = numel(value); + value = reshape(value, newSize); + return; + end + end + + isVectorSpec = sum(isnan(want)) == 1 && all(want(~isnan(want)) == 1); + if all(want == 1) + detail = 'Value must be a scalar.'; + elseif isVectorSpec + detail = 'Value must be a vector.'; + else + detail = sprintf('Value must be of size %s.', strrep(sizeSpec, ',', 'x')); + end + error('MATLAB:validation:IncompatibleSize', '%s %s', label, detail); +end diff --git a/+pf2_base/+args/noDefault.m b/+pf2_base/+args/noDefault.m new file mode 100644 index 00000000..5ac5e158 --- /dev/null +++ b/+pf2_base/+args/noDefault.m @@ -0,0 +1,23 @@ +function s = noDefault() +% NODEFAULT Sentinel default for a name-value argument with no default +% +% Used in a pf2_base.args.parse spec to mark a name-value argument that +% should be absent from the returned opts struct unless the caller passes +% it, matching an arguments-block entry declared without a default. +% +% Reference: +% Internal pf2 implementation. +% +% Syntax: +% s = pf2_base.args.noDefault() +% +% Inputs: +% (none) +% +% Outputs: +% s - Sentinel struct recognised by pf2_base.args.parse. +% +% See also: pf2_base.args.parse + + s = struct('pf2ArgsNoDefault', true); +end diff --git a/+pf2_base/+args/parse.m b/+pf2_base/+args/parse.m new file mode 100644 index 00000000..193afe1a --- /dev/null +++ b/+pf2_base/+args/parse.m @@ -0,0 +1,160 @@ +function varargout = parse(fname, inputs, posSpec, nvSpec) +% PARSE Parse optional positional and name-value inputs like an arguments block +% +% Portable replacement for a function's arguments block. GNU Octave cannot +% parse name-value entries (opts.Name = default), so functions on the +% Octave-supported path declare their optional inputs as specs and call +% this instead. It reproduces the arguments-block rules that callers rely on: +% - Optional positional inputs are filled in order until a text value +% names a name-value argument with an even number of inputs remaining; +% the rest are name-value pairs. +% - Names match case-insensitively, exactly or by unique prefix; a repeated +% name takes its last value. +% - A name-value argument declared without a default is absent from opts +% unless passed. +% - Each value is converted to the declared class, checked against the +% declared size (vectors reshape to the declared orientation), then +% passed to the validators, as pf2_base.args.check describes. +% - Errors use the arguments block's identifiers (MATLAB:TooManyInputs, +% MATLAB:validation:*, and the validators' own). +% +% Reference: +% Internal pf2 implementation; behaviour matched against MATLAB R2025b +% argument validation. +% +% Syntax: +% [p1, ..., pN, opts] = pf2_base.args.parse(fname, inputs, posSpec, nvSpec) +% [p1, ..., pN] = pf2_base.args.parse(fname, inputs, posSpec, {}) +% +% Inputs: +% fname - Calling function name (char), used in error messages. +% inputs - Cell array of the inputs after the required positional ones +% (typically varargin). +% posSpec - N-by-5 cell, one row per optional positional input: +% {name, default, size, class, validators}. size is '' or a +% size spec such as '1,1' or '1,:'; class is '' or a class name; +% validators is a cell of function handles taking the value. +% nvSpec - M-by-5 cell, one row per name-value argument, same columns. +% Use pf2_base.args.noDefault() as the default for an argument +% that must be absent when not passed. +% +% Outputs: +% p1..pN - Optional positional values (defaults when not passed). +% opts - Struct of name-value values, returned when nvSpec is non-empty. +% +% Example: +% function out = myFun(data, varargin) +% [scale, opts] = pf2_base.args.parse('myFun', varargin, { +% 'scale', 1, '1,1', '', {@mustBePositive} +% }, { +% 'Verbose', false, '1,1', 'logical', {} +% }); +% +% See also: pf2_base.args.check, pf2_base.args.noDefault + + if isempty(posSpec) + posSpec = cell(0, 5); + end + if isempty(nvSpec) + nvSpec = cell(0, 5); + end + nPos = size(posSpec, 1); + nvNames = nvSpec(:, 1); + + % Optional positional inputs end at the first text value that names a + % name-value argument and leaves an even number of inputs from there + % on; otherwise that text is taken as a positional value. + k = 0; + while k < numel(inputs) && k < nPos + v = inputs{k + 1}; + if ~isempty(nvNames) && isNameText(v) && mod(numel(inputs) - k, 2) == 0 ... + && ~isempty(matchName(char(v), nvNames)) + break; + end + k = k + 1; + end + rest = inputs(k + 1:end); + if ~isempty(rest) && isempty(nvNames) + error('MATLAB:TooManyInputs', ... + 'Invalid argument at position %d. Function %s accepts at most %d optional positional input(s).', ... + k + 1, fname, nPos); + end + + % Check the structure of the name-value list (pairing, names) before + % validating any value, as MATLAB does. + given = struct(); + if ~isempty(nvNames) + if mod(numel(rest), 2) ~= 0 + error('MATLAB:TooManyInputs', ['Invalid argument list. Make sure ' ... + 'name-value arguments include both the name and the value.']); + end + for j = 1:2:numel(rest) + name = rest{j}; + if ~isNameText(name) + error('MATLAB:TooManyInputs', ... + 'Invalid argument at position %d. Function %s accepts at most %d optional positional input(s).', ... + k + j, fname, nPos); + end + idx = matchName(char(name), nvNames); + if numel(idx) > 1 + error('MATLAB:functionValidation:AmbiguousInputName', ... + 'Invalid argument name ''%s''. Name is ambiguous among %s.', char(name), ... + strjoin(strcat('''', nvNames(idx)', ''''), ', ')); + end + if isempty(idx) + error('MATLAB:TooManyInputs', ... + 'Invalid argument name ''%s''. Name must be %s.', char(name), ... + strjoin(strcat('''', nvNames', ''''), ', ')); + end + given.(nvNames{idx}) = rest{j + 1}; + end + end + + varargout = cell(1, nPos + ~isempty(nvNames)); + for j = 1:nPos + if j <= k + value = inputs{j}; + else + value = posSpec{j, 2}; + end + label = sprintf('Invalid argument at position %d.', j); + varargout{j} = pf2_base.args.check(value, posSpec{j, 3}, posSpec{j, 4}, posSpec{j, 5}, label); + end + + if isempty(nvNames) + return; + end + + opts = struct(); + for j = 1:numel(nvNames) + name = nvNames{j}; + if isfield(given, name) + value = given.(name); + elseif isNoDefault(nvSpec{j, 2}) + continue; + else + value = nvSpec{j, 2}; + end + label = sprintf('Invalid value for ''%s'' argument.', name); + opts.(name) = pf2_base.args.check(value, nvSpec{j, 3}, nvSpec{j, 4}, nvSpec{j, 5}, label); + end + varargout{end} = opts; +end + +function tf = isNameText(v) +% ISNAMETEXT True for a char row or string scalar. + tf = (ischar(v) && (isrow(v) || isempty(v))) || (isa(v, 'string') && isscalar(v)); +end + +function idx = matchName(name, names) +% MATCHNAME Index of NAME in NAMES: exact (case-insensitive), else unique prefix. + idx = find(strcmpi(name, names)); + if isempty(idx) && ~isempty(name) + idx = find(strncmpi(name, names, numel(name))); + end +end + +function tf = isNoDefault(v) +% ISNODEFAULT True for the pf2_base.args.noDefault() sentinel. + tf = isstruct(v) && isscalar(v) && isfield(v, 'pf2ArgsNoDefault'); +end diff --git a/+pf2_base/+env/ensureOctaveCompat.m b/+pf2_base/+env/ensureOctaveCompat.m new file mode 100644 index 00000000..e327670a --- /dev/null +++ b/+pf2_base/+env/ensureOctaveCompat.m @@ -0,0 +1,71 @@ +function ensureOctaveCompat() +% ENSUREOCTAVECOMPAT Load Octave packages and MATLAB-function shims under Octave +% +% One-time setup for running the headless processing path under GNU Octave. +% Under MATLAB this returns immediately. Under Octave it loads the community +% packages the toolbox relies on (datatypes for table/datetime/categorical/ +% string; signal and statistics for filtering and nan* reductions) and adds +% compat_shims/octave, which supplies MATLAB functions core Octave lacks +% (contains, strip, strlength, extractAfter/extractBefore, isStringScalar, +% the two-argument form of round, and the mustBeA, mustBeText, +% mustBeTextScalar and mustBeScalarOrEmpty validators). Missing packages are reported once +% with a warning; the shims are still added. +% +% Called from pf2_base.ensureStatsFallbacks, which runs at the importer, +% device-loading, and pf2_base.pf2_initialize entry points. An explicit call +% is needed only before using other parts of the API in a fresh Octave +% session. +% +% Reference: +% Internal pf2 implementation. Packages from https://packages.octave.org +% (install with pkg install -forge datatypes signal statistics). +% +% Syntax: +% pf2_base.env.ensureOctaveCompat() +% +% Inputs: +% (none) +% +% Outputs: +% (none) +% +% Example: +% % Fresh Octave session +% addpath('/path/to/processFNIRS2'); +% pf2_base.env.ensureOctaveCompat(); +% data = pf2.import.importNIR('sampledata/sampleNIR.nir'); +% +% See also: pf2_base.env.isOctave, pf2_base.env.runtime, +% pf2_base.ensureStatsFallbacks + + persistent done + if ~isempty(done) || ~pf2_base.env.isOctave() + return; + end + done = true; + + missing = {}; + pkgs = {'datatypes', 'signal', 'statistics'}; + for k = 1:numel(pkgs) + try + pkg('load', pkgs{k}); %#ok<*PKG> Octave-only built-in + catch + missing{end+1} = pkgs{k}; %#ok + end + end + if ~isempty(missing) + warning('pf2:env:missingPackages', ... + ['Octave packages not available: %s. Install with ' ... + 'pkg install -forge %s'], strjoin(missing, ', '), strjoin(missing, ' ')); + end + + % Shims go at the end of the path so package-provided functions win. + % The round shim intentionally shadows the built-in; silence that + % one-time notice. + shimDir = fullfile(pf2_base.pf2_defaultRootPath(), 'compat_shims', 'octave'); + if exist(shimDir, 'dir') == 7 + prev = warning('off', 'Octave:shadowed-function'); + addpath(shimDir, '-end'); + warning(prev); + end +end diff --git a/+pf2_base/+env/runtime.m b/+pf2_base/+env/runtime.m index 00a5d1ce..bac54eae 100644 --- a/+pf2_base/+env/runtime.m +++ b/+pf2_base/+env/runtime.m @@ -22,9 +22,11 @@ % version - Version string from version() % isOctave - Logical scalar (see pf2_base.env.isOctave) % missingPkgs - Cellstr of required-but-unavailable packages. -% On Octave, checks the 'signal' package (provides -% butter/filtfilt/pwelch). Empty on MATLAB or when -% all required packages are present. +% On Octave, checks 'datatypes' (table/datetime/ +% categorical/string), 'signal' (pwelch and other +% spectral estimators), and 'statistics' (nan* +% reductions). Empty on MATLAB or when all +% required packages are present. % % Example: % r = pf2_base.env.runtime(); @@ -44,7 +46,7 @@ if info.isOctave info.name = 'Octave'; % Packages the headless processing path depends on. - required = {'signal'}; + required = {'datatypes', 'signal', 'statistics'}; for k = 1:numel(required) if ~localHasOctavePackage(required{k}) info.missingPkgs{end+1} = required{k}; diff --git a/+pf2_base/+external/.!36603!INI.m b/+pf2_base/+external/.!36603!INI.m deleted file mode 100644 index ae58f7bc..00000000 --- a/+pf2_base/+external/.!36603!INI.m +++ /dev/null @@ -1,527 +0,0 @@ -%% Manager for initialization file -% Version : v1.02 -% Author : E. Ogier -% Release : 31th aug. 2017 -% -% VERSIONS : -% - v1.0 (04 mar. 2016) : initial version -% - v1.01 (13 apr. 2016) : correction of a bug occurring when a string including "=" symbol is read -% - v1.02 (31 aug. 2017) : correction of a bug in "read" method when dynamic properties were previously created -% -% OBJECT PROPERTIES : -% - File <'INI.ini'> : Default file name -% - CommentChar <'%'> : Comment character when reading -% - NewLineChar <'\r\n'> : New line characters when writing -% - Sections <{}> : Sections names -% -% OBJECT METHODS : -% - I = INI(PROPERTY1,VALUE1,PROPERTY2,VALUE2,...) : Create an INI object and set the specified properties -% - I.set(PROPERTY1,VALUE1,PROPERTY2,VALUE2,...) : Set the specified properties -% - I.get(PROPERTY) : Get the specified property -% - I.read() : Read the file corresponding to the previously defined file name -% - I.read(FILE) : Read the specified file -% - I.write() : Write the file corresponding to the previously defined file name -% - I.write(FILE) : Write the specified file -% - I.add(NAME1,STRUCTURE1,NAME2,STRUCTURE2) : Add the sections NAME1 and NAME2 and store STRUCTURE1 and STRUCTURE2 structures respectively -% - I.remove() : Remove all the sections stored internally -% - I.remove(SECTION1,SECTION2) : Remove the sections called SECTION1 and SECTION2 -% -% EXAMPLE 1 : -% -% %% INI file writing -% -% % Initialisation -% I = INI(); -% File = 'Configuration.ini'; -% -% %% Section "Timer" -% -% % Creation of a timer -% Timer = timer(); -% -% % Add section -% I.add('Timer',Timer); -% -% %% Section "Serial" -% -% % Creation of a serial port -% COM = serial('COM1'); -% -% % Add section -% I.add('Serial',COM); -% -% %% Section "UserData" -% -% % Data specified by user -% Structure.Field1 = 'Data1'; -% Structure.Field2 = {'Data21','Data22'}; -% Structure.Field3 = 'Data3'; -% UserData = struct('Vector',0:9,'Matrix',ones(3,3),'Structure',Structure); -% -% % Add section -% I.add('UserData',UserData); -% -% -% %% Writing -% -% % Write file -% I.write(File); -% -% % Open "Configuration.ini" -% winopen(File); -% -% EXAMPLE 2 : -% -% %% INI file reading -% % Creation of an example file called "Configuration.ini" : execute EXAMPLE 1 -% -% % Initialization -% File = 'Configuration.ini'; -% I = INI('File',File); -% -% % INI file reading -% I.read(); -% -% % Sections from INI file -% Sections = I.get('Sections'); -% -% % Sections names -% fprintf(1,'Sections of "%s" file :\n',File); -% -% % Sections data -% for s = 1:numel(Sections) -% fprintf(1,'- Section "%s"\n',Sections{s}); -% disp(I.get(Sections{s})); -% end - -classdef INI < hgsetget & dynamicprops - - properties - File = 'INI.ini'; % Default file - CommentChar = '%'; % Comment character when reading - NewLineChar = '\r\n'; % New line characters when writing - Sections = {}; % Sections names - end - - methods - - % Constructor - function Object = INI(varargin) - - for v = 1:2:length(varargin) - Property = varargin{v}; - Value = varargin{v+1}; - set(Object,Property,Value); - end - - end - - % Function 'set' - function Object = set(Object,varargin) - - Properties = varargin(1:2:end); - Values = varargin(2:2:end); - - for n = 1:length(Properties) - [is, Property] = isproperty(Object,Properties{n}); - if is - Object.(Property) = Values{n}; - else - error('Property "%s" not supported !',Properties{n}); - end - end - - end - - % Function 'get' - function Value = get(varargin) - - switch nargin - case 1 - Value = varargin{1}; - otherwise - Object = varargin{1}; - [is, Property] = isproperty(Object,varargin{2}); - if is - Value = Object.(Property); - else - [is, Property] = isproperty(Object,rename(varargin{2})); - if is - Value = Object.(Property); - else - error('Property "%s" not supported !',varargin{2}); - end - end - end - - end - - % Function 'ispropery' - function [is, Property] = isproperty(Object,Property) - - Properties = properties(Object); - [is, b] = ismember(lower(Property),lower(Properties)); - if is - Property = Properties{b}; - else - Property = []; - end - - end - - % Function 'read' INI file - function Object = read(Object,varargin) - - if nargin == 2 - set(Object,'File',varargin{1}); - end - - Object.Sections = {}; - DynamicProperties = setdiff(properties(Object),{'File','CommentChar','NewLineChar','Sections'}); - for Property = DynamicProperties' - delete(findprop(Object,cell2mat(Property))); - end - - SplitChars = ['[=' Object.CommentChar '\[\]]']; - - ID = fopen(get(Object,'File')); - - while ~feof(ID) - - Line = fgetl(ID); - - if ~isempty(Line)&&sum(Line)~=-1 - - [String,~,~,~,SplitChar] = regexp(Line,SplitChars,'split'); - - if ~isempty(SplitChar) - - switch SplitChar{1} - - % Comment - case Object.CommentChar - - % Section - case '[' - - if numel(SplitChar)>=2 - if strcmp(SplitChar{2},']') - Section0 = strTrimStrip(String{2}); - Section = rename(Section0); - if ~ismember(Section,properties(Object)) - addprop(Object,Section); - Object.Sections{end+1} = Section0; - end - end - end - - % Field - case '=' - fieldEndLine=ftell(ID); - [String,~,~,~,~] = regexp(Line,['[=' Object.CommentChar ']'],'split'); - Field = rename(strTrimStrip(String{1})); - Value = String{2}; - Struct_section = get(Object,Section); - - - Value2 = strTrimStrip(Value); - if ~isempty(Value2)&&(strcmp(Value2(1),'''')||strcmp(Value2(1),'"')) - for s = 3:numel(String) % looks like its trying to acount for spaces? - Value = [ Value '=' String{s}]; %#ok - end - - isString=true; - else - isString=false; - end - - - - Line = fgetl(ID); - - while(~isempty(Line)&&sum(Line)~=-1) - [String,~,~,~,SplitChar] = regexp(Line,SplitChars,'split'); - - if(isempty(SplitChar)) - splitChar=''; - else - splitChar=SplitChar{1}; - end - - switch (splitChar) - % Comment - case Object.CommentChar - Line=''; - break; - % Section - case '[' - Line=''; - break; - %Value - case '=' - Line=''; - break; - otherwise - if(isString) - Value=sprintf('%s\n%s',Value,Line); - else - Value=sprintf('%s...%s',Value,Line); - end - fieldEndLine=ftell(ID); - Line = fgetl(ID); - end - - end - - fseek(ID,fieldEndLine,'bof'); %rewind - - if(isString) - Struct_section.(Field) = strTrimStrip(Value); - else - try %Try to convert to number or cell - Struct_section.(Field) = eval(strTrimStrip(Value)); - catch %#ok Fall back to string - Struct_section.(Field) = strTrimStrip(Value); - end - end - set(Object,Section,Struct_section); - - end - - end - - end - - end - - fclose(ID); - - Object.Sections = sort(Object.Sections); - - end - - % Function 'write' INI file - function Object = write(Object,varargin) - - if nargin == 2 - set(Object,'File',varargin{1}); - end - - Object.Sections = sort(Object.Sections); - - ID = fopen(get(Object,'File'),'w'); - - for s = 1:numel(Object.Sections) - - % Section - Section = Object.Sections{s}; - fprintf(ID,['[%s]' Object.NewLineChar],Section); - Section = rename(Section); - Fields = fieldnames(Object.(Section)); - - % Maximum name length - M = 1; - for f = 1:numel(Fields) - M = max(M,length(Fields{f})); - end - - % Fields - for f = 1:numel(Fields) - - % Field name - Field = Fields{f}; - Value = Object.(Section).(Field); - fprintf(ID,'%s%s= ',Field,blanks(max(1,M+1-length(Field)))); - - % Field value - Write_value(ID,Value); - - % Field end - fprintf(ID, Object.NewLineChar); - - end - - % Section end - fprintf(ID, Object.NewLineChar); - - end - - fclose(ID); - - % Function 'Write value' (struct, cell, char or numeric) - function Write_value(ID,Value) - - switch class(Value) - case 'struct' - Symbols = {'struct(',')'}; - case 'cell' - Symbols = {'{','}'}; - case 'char' - if(Value(1)==''''&&Value(end)=='''')||(Value(1)=='"'&&Value(end)=='"') - Symbols = {'',''}; - else - Symbols = {'''',''''}; - end - otherwise - if isscalar(Value) - Symbols = {'',''}; - else - Symbols = {'[',']'}; - end - end - - fprintf(ID,Symbols{1}); - - if isstring(Value) - Class = 'char'; - elseif isobject(Value) - Class = 'struct'; - else - Class = class(Value); - end - - switch Class - case 'struct' - Fields_struct = fieldnames(Value); - for n = 1:numel(Fields_struct) - Field_struct = Fields_struct{n}; - fprintf(ID,'''%s'',',Field_struct); - Write_value(ID,Value.(Field_struct)); - if n < numel(Fields_struct) - fprintf(ID,','); - end - end - case 'char' - fprintf(ID,'%s',Value); - otherwise - for n = 1:size(Value,1) - for m = 1:size(Value,2) - switch class(Value) - case 'cell' - Write_value(ID,Value{n,m}); - if m < size(Value,2) - fprintf(ID,','); - end - otherwise - if(islogical(Value)) - Value=double(Value); - end - fprintf(ID,Format(Value(n,m)),Value(n,m)); - if m < size(Value,2) - fprintf(ID,','); - end - end - end - if n < size(Value,1) - fprintf(ID,';'); - end - end - end - - fprintf(ID,Symbols{2}); - - function Format = Format(Data) - - switch class(Data) - case {'uint8','uint16','uint32','uint64'} - Format = '%u'; - case {'int8','int16','int32','int64'} - Format = '%d'; - case {'single','double'} - Format = '%G'; - case 'char' - Format = '%s'; - otherwise - error('Type "%s" not supported.',class(Data)); - end - - end - - end - - end - - % Function 'add' sections - function Object = add(Object,varargin) - - Properties = varargin(1:2:end); - Structures = varargin(2:2:end); - - for n = 1:length(Properties) - - Section = Properties{n}; - Section2 = rename(Section); - Structure = Structures{n}; - - addprop(Object,Section2); - set(Object,Section2,Structure); - Object.Sections{end+1} = Section; - - end - - Object.Sections = sort(Object.Sections); - - end - - % Function 'remove' [all]/[sections] - function Object = remove(Object,varargin) - - switch nargin - - case 0 - - Object.Sections = {}; - DynamicProperties = setdiff(properties(Object),{'File','CommentChar','NewLineChar','Sections'}); - for Property = DynamicProperties' - delete(Property{1}); - end - - otherwise - - Sections_clear = varargin; - for n = 1:numel(Sections_clear) - - Section_clear = Sections_clear{n}; - - for s = 1:numel(Object.Sections) - if strcmpi(rename(Object.Sections{s}),rename(Section_clear)) - Object.Sections = setdiff(Object.Sections,Object.Sections{s}); - break - end - end - - [is, Property] = isproperty(Object,rename(Section_clear)); - if is - delete(Object.findprop(Property)); - else - error('Property "%s" not supported !',Property); - end - - end - - end - - end - - end - -end - -% Function 'rename' expression to generate variable name -function Name2 = rename(Name) - -persistent Numbers LowerCases UpperCases - -if isempty(Numbers) - Numbers = arrayfun(@(n) {sprintf('%u',n)},0:9); - LowerCases = arrayfun(@(n) {char(n+96)},1:26); - UpperCases = arrayfun(@(n) {char(n+64)},1:26); -end - -Name2 = ''; -for n = 1:length(Name) - Character = Name(n); - switch Character - case Numbers - case LowerCases - case UpperCases diff --git a/+pf2_base/+external/INI.m b/+pf2_base/+external/INI.m index b952619c..ec5d7dc7 100644 --- a/+pf2_base/+external/INI.m +++ b/+pf2_base/+external/INI.m @@ -1,4 +1,4 @@ -classdef INI < dynamicprops +classdef INI < handle % INI Reader/writer for INI-style configuration files. % % Summary: @@ -8,8 +8,9 @@ % key = value % ; a comment % -% Each section is exposed as a dynamic property on the object whose value -% is a struct of that section's key/value pairs. Values are stored as +% Each section is exposed as a property-style field on the object +% (obj.
) whose value is a struct of that section's key/value +% pairs. Values are stored as % evaluable MATLAB literals on write (numbers, strings, cells, structs, % matrices) and recovered on read by evaluating the right-hand side, with % a fall-back to the raw string when evaluation fails. This makes the @@ -17,11 +18,16 @@ % round-tripping packed processing-method structs. % % Sections are tracked by their original (human-readable) names in the -% Sections property; the corresponding dynamic property uses a sanitized, +% Sections property; the corresponding field uses a sanitized, % MATLAB-valid identifier derived from that name. Reading/writing/lookup % accept the original name and resolve to the sanitized property % internally, so callers may use either form. % +% Section data lives in a private struct and is reached through overloaded +% subsref/subsasgn rather than dynamicprops, so the class also runs under +% GNU Octave (which lacks dynamicprops). Objects saved by the earlier +% dynamicprops implementation are rebuilt by loadobj. +% % Inputs (constructor, name/value pairs): % 'File' - char/string. Path of the file to read/write. Default 'INI.ini'. % 'CommentChar' - char. Comment marker recognized on read (whole-line and @@ -64,6 +70,11 @@ % Map from sanitized property name -> original section name. Lets % write() and lookups recover the human-readable header. PropToSection = struct(); + % Section data keyed by sanitized property name. + Data = struct(); + % True for an object loaded from a save made by the dynamicprops + % implementation until its sections are migrated on first use. + MigrationPending = false; end methods @@ -76,6 +87,7 @@ function obj = set(obj, varargin) % Set one or more named properties (case-insensitive). + obj.ensureMigrated(); for k = 1:2:numel(varargin) name = varargin{k}; value = varargin{k+1}; @@ -84,13 +96,18 @@ error('pf2_base:external:INI:badProperty', ... 'Property "%s" not supported.', name); end - obj.(prop) = value; + if obj.isPublicProp(prop) + obj.(prop) = value; + else + obj.Data.(prop) = value; + end end end function value = get(obj, name) % Get a property by name (case-insensitive; accepts original % section names as well as sanitized identifiers). + obj.ensureMigrated(); if nargin < 2 value = obj; return; @@ -100,11 +117,16 @@ error('pf2_base:external:INI:badProperty', ... 'Property "%s" not supported.', name); end - value = obj.(prop); + if obj.isPublicProp(prop) + value = obj.(prop); + else + value = obj.Data.(prop); + end end function obj = read(obj, varargin) - % Read an INI file into per-section dynamic properties. + % Read an INI file into per-section fields. + obj.ensureMigrated(); if nargin >= 2 && ~isempty(varargin{1}) obj.File = varargin{1}; end @@ -135,7 +157,7 @@ end % Commit the previous section before starting a new one. if ~isempty(currentSection) - obj.(currentSection) = data; + obj.Data.(currentSection) = data; end sectionName = strtrim(line(2:close(1)-1)); currentSection = obj.ensureSection(sectionName); @@ -178,7 +200,7 @@ end if ~isempty(currentSection) - obj.(currentSection) = data; + obj.Data.(currentSection) = data; end obj.Sections = sort(obj.Sections); @@ -186,6 +208,7 @@ function obj = write(obj, varargin) % Serialize all sections to an INI file. + obj.ensureMigrated(); if nargin >= 2 && ~isempty(varargin{1}) obj.File = varargin{1}; end @@ -202,10 +225,10 @@ for s = 1:numel(obj.Sections) sectionName = obj.Sections{s}; prop = obj.sectionProperty(sectionName); - if isempty(prop) || ~isprop(obj, prop) + if isempty(prop) || ~obj.hasProp(prop) continue; end - value = obj.(prop); + value = obj.Data.(prop); value = pf2_base.external.INI.coerceToStruct(value); fprintf(fid, '[%s]%s', sectionName, obj.NewLineChar); @@ -225,17 +248,19 @@ function obj = add(obj, varargin) % Add one or more sections, each holding a struct (or struct-like % object). add(NAME1, S1, NAME2, S2, ...). + obj.ensureMigrated(); for k = 1:2:numel(varargin) sectionName = varargin{k}; value = varargin{k+1}; prop = obj.ensureSection(sectionName); - obj.(prop) = value; + obj.Data.(prop) = value; end obj.Sections = sort(obj.Sections); end function obj = remove(obj, varargin) % remove() clears all sections; remove(N1, N2, ...) removes named. + obj.ensureMigrated(); if isempty(varargin) obj.clearSections(); return; @@ -249,10 +274,295 @@ end end + + function disp(obj) + % Display the fixed properties followed by each section. + builtin('disp', obj); + if ~isscalar(obj) + return; + end + obj.ensureMigrated(); + for k = 1:numel(obj.Sections) + prop = obj.sectionProperty(obj.Sections{k}); + if isempty(prop) || ~isfield(obj.Data, prop) + continue; + end + fprintf(' [%s]\n', obj.Sections{k}); + value = obj.Data.(prop); % evalc runs without class access in Octave + text = strtrim(evalc('disp(value)')); + if ~isempty(text) + fprintf(' %s\n', strrep(text, newline, [newline ' '])); + end + end + end + + function tf = isprop(obj, name) + % True for the fixed properties and for registered sections. + obj.ensureMigrated(); + tf = obj.hasProp(name); + end + + function varargout = subsref(obj, s) + % obj.
[...] reads section data; everything else + % (fixed properties, method calls) uses built-in indexing. + s = pf2_base.external.INI.charSubs(s); + if strcmp(s(1).type, '()') && numel(s) > 1 + % Index into an object array, then continue on the result. + [varargout{1:nargout}] = subsref(builtin('subsref', obj, s(1)), s(2:end)); + return; + end + if ~isscalar(obj) && strcmp(s(1).type, '.') + % Comma-separated list over the array elements. + varargout = cell(1, numel(obj)); + for k = 1:numel(obj) + varargout{k} = subsref(builtin('subsref', obj, ... + struct('type', '()', 'subs', {{k}})), s); + end + return; + end + obj.checkPublicAccess(s); + obj.ensureMigrated(); + if strcmp(s(1).type, '.') && isfield(obj.Data, s(1).subs) + value = obj.Data.(s(1).subs); + if numel(s) > 1 + [varargout{1:nargout}] = subsref(value, s(2:end)); + else + varargout = {value}; + end + else + [varargout{1:nargout}] = builtin('subsref', obj, s); + end + end + + function obj = subsasgn(obj, s, value) + % obj.
[...] = V writes into an existing section. + s = pf2_base.external.INI.charSubs(s); + if strcmp(s(1).type, '()') && numel(s) > 1 + % Assign through an indexed element; handle semantics + % update it in place. + subsasgn(builtin('subsref', obj, s(1)), s(2:end), value); + return; + end + obj.checkPublicAccess(s); + if ~isscalar(obj) + obj = builtin('subsasgn', obj, s, value); + return; + end + obj.ensureMigrated(); + if strcmp(s(1).type, '.') && isfield(obj.Data, s(1).subs) + if numel(s) > 1 + value = subsasgn(obj.Data.(s(1).subs), s(2:end), value); + end + obj.Data.(s(1).subs) = value; + else + obj = builtin('subsasgn', obj, s, value); + end + end + + function n = numArgumentsFromSubscript(obj, s, context) + % Section and property reads return a single value unless + % trailing brace indexing expands to a comma-separated list. + s = pf2_base.external.INI.charSubs(s); + if strcmp(s(1).type, '()') && numel(s) > 1 + n = numArgumentsFromSubscript(builtin('subsref', obj, s(1)), s(2:end), context); + return; + end + if ~isscalar(obj) + n = numel(obj); + return; + end + obj.ensureMigrated(); + isDot = strcmp(s(1).type, '.') && ischar(s(1).subs); + if isDot && isfield(obj.Data, s(1).subs) + base = obj.Data.(s(1).subs); + elseif ~pf2_base.env.isOctave() + n = builtin('numArgumentsFromSubscript', obj, s, context); + return; + elseif isDot && obj.isPublicProp(s(1).subs) + % Octave has no built-in to defer to; every public method + % returns one value, so only brace reads need counting. + base = obj.(s(1).subs); + else + n = 1; + return; + end + n = 1; + if numel(s) > 1 && strcmp(s(end).type, '{}') + if numel(s) > 2 + base = subsref(base, s(2:end-1)); + end + n = numel(base(s(end).subs{:})); + end + end + + end + + methods (Static) + + function obj = loadobj(s) + % Rebuild objects saved by the dynamicprops implementation, + % whose sections were stored as dynamic properties. MATLAB + % re-attaches those dynamic properties only after loadobj + % returns, so the object is marked here and migrated on first + % use (see migrateLoadedSections). + if isa(s, 'pf2_base.external.INI') + obj = s; + mapFields = fieldnames(obj.PropToSection); + obj.MigrationPending = ~all(isfield(obj.Data, mapFields)) || ... + any(cellfun(@(f) obj.isReserved(f), mapFields)); + return; + end + obj = pf2_base.external.INI(); + fixed = {'File', 'CommentChar', 'NewLineChar', 'Sections'}; + for k = 1:numel(fixed) + if isfield(s, fixed{k}) + obj.(fixed{k}) = s.(fixed{k}); + end + end + if isfield(s, 'PropToSection') && isstruct(s.PropToSection) + obj.PropToSection = s.PropToSection; + end + if isfield(s, 'Data') && isstruct(s.Data) + obj.Data = s.Data; + end + mapFields = fieldnames(obj.PropToSection); + for k = 1:numel(mapFields) + if isfield(s, mapFields{k}) + obj.Data.(mapFields{k}) = s.(mapFields{k}); + end + end + obj.MigrationPending = true; + end + + function tf = isReservedName(name) + % True when NAME (after sanitizing) is a property or method + % name of this class and so cannot be addressed as cfg.(NAME). + % Callers that create sections from user-chosen names (method + % libraries) use this to reject such names up front. + tf = any(strcmp(pf2_base.external.INI.sanitizeName(name), ... + pf2_base.external.INI.reservedNames())); + end + end methods (Access = private) + function tf = isPublicProp(~, name) + % True for the class's own public (non-section) property names. + tf = any(strcmp(name, {'File', 'CommentChar', 'NewLineChar', 'Sections'})); + end + + function tf = isReserved(~, name) + % True for names a section must not take: any property or + % method of the class, including inherited handle methods. + tf = any(strcmp(name, pf2_base.external.INI.reservedNames())); + end + + function ensureMigrated(obj) + % Finish migrating an object loaded from an old save. + if obj.MigrationPending + obj.MigrationPending = false; + obj.migrateLoadedSections(); + end + end + + function tf = hasProp(obj, name) + % True for a public property or a registered section field. + tf = obj.isPublicProp(name) || isfield(obj.Data, name); + end + + function checkPublicAccess(~, s) + % The overloaded subsref/subsasgn run with class access, so + % reject outside references to private members explicitly. + % Octave already enforces this itself. + if pf2_base.env.isOctave() || ~strcmp(s(1).type, '.') || ~ischar(s(1).subs) + return; + end + name = s(1).subs; + [privProps, privMethods] = pf2_base.external.INI.nonPublicMembers(); + if any(strcmp(name, privProps)) + error('MATLAB:class:GetProhibited', ... + 'Cannot access the ''%s'' property of class ''INI''.', name); + end + if any(strcmp(name, privMethods)) + error('MATLAB:class:MethodRestricted', ... + 'Cannot access method ''%s'' in class ''pf2_base.external.INI''.', name); + end + end + + function migrateLoadedSections(obj) + % Move section values held in leftover dynamic properties into + % Data, re-key any section whose name is now reserved, then + % fill remaining gaps from File. + if ~pf2_base.env.isOctave() + mapFields = fieldnames(obj.PropToSection); + for k = 1:numel(mapFields) + p = findprop(obj, mapFields{k}); + if pf2_base.external.INI.isDynamicProperty(p) + if ~isfield(obj.Data, mapFields{k}) + obj.Data.(mapFields{k}) = obj.(mapFields{k}); + end + delete(p); + end + end + end + mapFields = fieldnames(obj.PropToSection); + for k = 1:numel(mapFields) + if obj.isReserved(mapFields{k}) + name = obj.PropToSection.(mapFields{k}); + hasValue = isfield(obj.Data, mapFields{k}); + if hasValue + value = obj.Data.(mapFields{k}); + obj.Data = rmfield(obj.Data, mapFields{k}); + end + obj.PropToSection = rmfield(obj.PropToSection, mapFields{k}); + prop = obj.ensureSection(name); + if hasValue + obj.Data.(prop) = value; + else + obj.Data = rmfield(obj.Data, prop); + end + end + end + obj.restoreMissingSections(); + end + + function restoreMissingSections(obj) + % Re-read File when registered sections have no data (an object + % saved by the dynamicprops implementation). If the file is + % gone, drop the dangling registrations so Sections stays + % consistent with the data actually held. + mapFields = fieldnames(obj.PropToSection); + missing = mapFields(~isfield(obj.Data, mapFields)); + if isempty(missing) + return; + end + if exist(obj.File, 'file') == 2 + try + fromFile = pf2_base.external.INI('File', obj.File); + fromFile.read(); + for k = 1:numel(missing) + name = obj.PropToSection.(missing{k}); + if ismember(name, fromFile.Sections) + obj.Data.(missing{k}) = fromFile.get(name); + end + end + catch + % Leave the rest missing; they are pruned below. + end + missing = mapFields(~isfield(obj.Data, mapFields)); + end + if isempty(missing) + return; + end + for k = 1:numel(missing) + obj.dropSectionByProperty(missing{k}); + end + warning('pf2_base:external:INI:sectionsNotRestored', ... + ['Section data from a saved INI object could not be ' ... + 'restored from "%s".'], obj.File); + end + function prop = resolveProperty(obj, name) % Resolve a user-supplied name to an existing property name, % matching the fixed properties case-insensitively and section @@ -266,18 +576,18 @@ end % Original section name? secProp = obj.sectionProperty(name); - if ~isempty(secProp) && isprop(obj, secProp) + if ~isempty(secProp) && obj.hasProp(secProp) prop = secProp; return; end % Already a valid dynamic property name? - if isprop(obj, name) + if obj.hasProp(name) prop = name; return; end % Sanitized form of an arbitrary name. cand = pf2_base.external.INI.sanitizeName(name); - if ~isempty(cand) && isprop(obj, cand) + if ~isempty(cand) && obj.hasProp(cand) prop = cand; end end @@ -297,22 +607,24 @@ function prop = ensureSection(obj, sectionName) % Register a section by its original name, creating the backing - % dynamic property if needed, and return the property name. + % field if needed, and return the property name. prop = obj.sectionProperty(sectionName); - if ~isempty(prop) && isprop(obj, prop) + if ~isempty(prop) && obj.hasProp(prop) return; end prop = pf2_base.external.INI.sanitizeName(sectionName); if isempty(prop) prop = 'Section'; end - % Avoid collision with the fixed properties. - while any(strcmp(prop, {'File', 'CommentChar', 'NewLineChar', ... - 'Sections', 'PropToSection'})) + % Avoid collision with properties and method names, which + % section lookup would otherwise shadow. + % Also avoid a field already registered to a different section + % (sanitized names can coincide, and x_ prefixes can collide). + while obj.isReserved(prop) || isfield(obj.PropToSection, prop) prop = ['x_' prop]; %#ok end - if ~isprop(obj, prop) - obj.addprop(prop); + if ~isfield(obj.Data, prop) + obj.Data.(prop) = []; end obj.PropToSection.(prop) = sectionName; if ~ismember(sectionName, obj.Sections) @@ -321,14 +633,8 @@ end function clearSections(obj) - % Delete all dynamic section properties and reset tracking. - mapFields = fieldnames(obj.PropToSection); - for k = 1:numel(mapFields) - p = findprop(obj, mapFields{k}); - if ~isempty(p) - delete(p); - end - end + % Delete all section data and reset tracking. + obj.Data = struct(); obj.PropToSection = struct(); obj.Sections = {}; end @@ -340,9 +646,8 @@ function dropSectionByProperty(obj, prop) obj.Sections(strcmp(obj.Sections, sectionName)) = []; obj.PropToSection = rmfield(obj.PropToSection, prop); end - p = findprop(obj, prop); - if ~isempty(p) - delete(p); + if isfield(obj.Data, prop) + obj.Data = rmfield(obj.Data, prop); end end @@ -403,6 +708,68 @@ function dropSectionByProperty(obj, prop) methods (Static, Access = private) + function names = reservedNames() + % Every property and method name of the class, including the + % handle methods, listed explicitly so the result is the same + % in MATLAB and Octave. A test checks it against the class. + names = [{'File', 'CommentChar', 'NewLineChar', 'Sections', ... + 'PropToSection', 'Data', 'MigrationPending', ... + 'INI', 'set', 'get', 'read', 'write', 'add', 'remove', ... + 'disp', 'isprop', 'subsref', 'subsasgn', ... + 'numArgumentsFromSubscript', 'loadobj', 'isReservedName', ... + 'addlistener', 'delete', 'eq', 'findobj', 'findprop', ... + 'ge', 'gt', 'isvalid', 'le', 'listener', 'lt', 'ne', ... + 'notify', 'display', 'empty'}, pf2_base.external.INI.privateMethodNames()]; + end + + function [props, meths] = nonPublicMembers() + % Non-public property and method names, from the class + % metadata (MATLAB only; used by checkPublicAccess). + persistent cachedProps cachedMeths + if isempty(cachedProps) + mc = meta.class.fromName('pf2_base.external.INI'); + isPub = @(a) ischar(a) && strcmp(a, 'public'); + pl = mc.PropertyList; + cachedProps = {pl(~arrayfun(@(p) isPub(p.GetAccess), pl)).Name}; + ml = mc.MethodList; + cachedMeths = {ml(~arrayfun(@(m) isPub(m.Access), ml)).Name}; + end + props = cachedProps; + meths = cachedMeths; + end + + function s = charSubs(s) + % Convert string-valued dynamic field names to char so the + % section lookup and the access guard see them. + for k = 1:numel(s) + if strcmp(s(k).type, '.') && isstring(s(k).subs) + s(k).subs = char(s(k).subs); + end + end + end + + function tf = isDynamicProperty(p) + % True for a dynamic property (class name differs by release). + tf = ~isempty(p) && (isa(p, 'meta.DynamicProperty') || ... + isa(p, 'matlab.metadata.DynamicProperty')); + end + + function names = privateMethodNames() + % Private instance and static method names, listed explicitly + % so reservedNames is the same in MATLAB and Octave. + names = {'isPublicProp', 'isReserved', 'ensureMigrated', 'hasProp', ... + 'checkPublicAccess', 'migrateLoadedSections', ... + 'restoreMissingSections', 'resolveProperty', ... + 'sectionProperty', 'ensureSection', 'clearSections', ... + 'dropSectionByProperty', 'stripInlineComment', ... + 'isCommentLine', 'privateMethodNames', 'reservedNames', ... + 'nonPublicMembers', 'charSubs', 'isDynamicProperty', ... + 'looksLikeAssignment', 'parseValue', 'parseQuoted', ... + 'bracketsBalanced', 'toLiteral', 'cellLiteral', ... + 'numericLiteral', 'coerceToStruct', 'sanitizeName', ... + 'readFileText'}; + end + function tf = looksLikeAssignment(line) % Heuristic: a continuation line that itself looks like "key =" % (an identifier followed by '=') belongs to the next field, not diff --git a/+pf2_base/+external/zp2sos.m b/+pf2_base/+external/zp2sos.m index f8572b64..99d56f7e 100644 --- a/+pf2_base/+external/zp2sos.m +++ b/+pf2_base/+external/zp2sos.m @@ -149,7 +149,10 @@ % Complex roots: keep those with positive imaginary part; pair with conjugate. cpos = r(~isReal & imag(r) > 0); -cpos = sort(cpos, 'ascend', 'ComparisonMethod', 'real'); +% Ascending by real part, ties by imaginary part (MATLAB's +% 'ComparisonMethod','real'; written out because Octave's sort lacks it). +[~, ord] = sortrows([real(cpos(:)), imag(cpos(:))]); +cpos = cpos(ord); for ii = 1:numel(cpos) quad{end+1} = [cpos(ii); conj(cpos(ii))]; %#ok end diff --git a/+pf2_base/+fnirs/bvoxy.m b/+pf2_base/+fnirs/bvoxy.m index 88a69e48..c5e9590c 100644 --- a/+pf2_base/+fnirs/bvoxy.m +++ b/+pf2_base/+fnirs/bvoxy.m @@ -208,12 +208,12 @@ addRequired(p,'distanceSrcDet',validDataInput); %source detector distance for each column in channels (in cm) addOptional(p,'baselineSamples',[1:50],validDataInput); %array of samples to get from baseline (in seconds addOptional(p,'age',25,validScalarPosNum); %Age to calculate DPF from (in years) +addOptional(p,'coefs',[],validDataInput); %If empty calculate instead of using mfg coefficients +addOptional(p,'isOD',false,@islogical); %Indicates that data is in light intensity instead of OD addParameter(p,'DiffPathlengthFactor',[],validScalarNum); % Fixed DPF (typical 3-10); overrides the age calculation addParameter(p,'PartialVolumeCorrection',[],@(x) isempty(x)||(isnumeric(x)&&isvector(x))); % Partial-volume correction divisor(s) (>=1), scalar or per-optode: L = SD.*DPF./pvc addParameter(p,'PartialPathlengthFactor',[],@(x) isempty(x)||(isnumeric(x)&&isvector(x))); % ESCAPE HATCH: a complete effective factor, L = SD.*ppf (no DPF/PVC decomposition) addParameter(p,'NoPathlength',false,@islogical); % Force same fixed path length to use (typical is 5.93) -addOptional(p,'coefs',[],validDataInput); %If empty calculate instead of using mfg coefficients -addOptional(p,'isOD',false,@islogical); %Indicates that data is in light intensity instead of OD %addOptional(p,'dirtyBaseline',false,@islogical); %get first non-NA value to use as baseline if nothing is readily apparent; parse(p,varargin{:}); diff --git a/+pf2_base/+fnirs/processStageOD2Hb.m b/+pf2_base/+fnirs/processStageOD2Hb.m index 2d4b134e..a272c6f7 100644 --- a/+pf2_base/+fnirs/processStageOD2Hb.m +++ b/+pf2_base/+fnirs/processStageOD2Hb.m @@ -1,4 +1,4 @@ -function outData = processStageOD2Hb(data, time, subjectAge, DirtyBaseline, curProbe, baseline, dpfMode, dpfFixed, defaultAge, options) +function outData = processStageOD2Hb(data, time, subjectAge, DirtyBaseline, curProbe, baseline, dpfMode, dpfFixed, defaultAge, varargin) % PROCESSSTAGEOD2HB Convert optical density to hemoglobin concentrations % % Beer-Lambert conversion stage of the fNIRS processing pipeline. @@ -60,20 +60,15 @@ % % See also: pf2_base.fnirs.processStageRaw2OD, pf2_base.fnirs.processStageFilterHb, pf2_base.fnirs.bvoxy -arguments - data - time - subjectAge - DirtyBaseline - curProbe - baseline - dpfMode - dpfFixed - defaultAge - options.BaselineSamples = [] - options.PPF = [] - options.PVC = [] -end +% Name-value options. inputParser rather than an arguments block so the +% function also parses under GNU Octave. +ip = inputParser; +ip.FunctionName = 'processStageOD2Hb'; +ip.addParameter('BaselineSamples', []); +ip.addParameter('PPF', []); +ip.addParameter('PVC', []); +ip.parse(varargin{:}); +options = ip.Results; % Determine DPF settings. Comparisons are case-insensitive (strcmpi) as % defense-in-depth: processFNIRS2's validator canonicalizes DPFmode to diff --git a/+pf2_base/+gui/updateCurrentDevice.m b/+pf2_base/+gui/updateCurrentDevice.m index 0d5710bb..4242bd40 100644 --- a/+pf2_base/+gui/updateCurrentDevice.m +++ b/+pf2_base/+gui/updateCurrentDevice.m @@ -50,9 +50,9 @@ curOptTable = curProbe.TableOpt; curSDTable = curProbe.TableSD; - curChTable.ProbeInd(:) = i; - curOptTable.ProbeInd(:) = i; - curSDTable.ProbeInd(:) = i; + curChTable.ProbeInd = repmat(i, height(curChTable), 1); + curOptTable.ProbeInd = repmat(i, height(curOptTable), 1); + curSDTable.ProbeInd = repmat(i, height(curSDTable), 1); if i == 1 curCh = curChTable; diff --git a/+pf2_base/+tests/+data/octave_reference.mat b/+pf2_base/+tests/+data/octave_reference.mat new file mode 100644 index 00000000..9133ba24 Binary files /dev/null and b/+pf2_base/+tests/+data/octave_reference.mat differ diff --git a/+pf2_base/+tests/+integration/FullPipelineTest.m b/+pf2_base/+tests/+integration/FullPipelineTest.m index d472499a..59b33dc9 100644 --- a/+pf2_base/+tests/+integration/FullPipelineTest.m +++ b/+pf2_base/+tests/+integration/FullPipelineTest.m @@ -26,9 +26,16 @@ % See also: processFNIRS2, pf2.import.sampleData, matlab.unittest.TestCase properties (TestParameter) - % Define method combinations for parameterized testing - rawMethod = {'None', 'x1_lpf', 'x5_TDDR'}; - oxyMethod = {'None', 'lpf_car'}; + % Method combinations, built with the Pipeline API so the test does + % not depend on the user's stored method library. Each value lists + % pipeline steps as {funcName, paramStruct} pairs; {} means 'None'. + rawMethod = struct( ... + 'None', {{}}, ... + 'OD_lpf', {{{'pf2_Intensity2OD', struct()}, {'pf2_lpf', struct('freq_cut', 0.1)}}}, ... + 'OD_TDDR', {{{'pf2_Intensity2OD', struct()}, {'pf2_MotionCorrectTDDR', struct()}}}); + oxyMethod = struct( ... + 'None', {{}}, ... + 'lpf_CAR', {{{'pf2_lpf', struct('freq_cut', 0.1)}, {'pf2_CAR', struct()}}}); end properties @@ -287,38 +294,28 @@ function testDifferentMethods(testCase, rawMethod, oxyMethod) testCase.assumeNotEmpty(testCase.fNIR2000Data, ... 'fNIR2000 sample data not available'); - % Set the raw processing method - try - pf2.methods.raw.setMethod(rawMethod); - catch ME - testCase.assumeFail(sprintf(... - 'Could not set raw method %s: %s', rawMethod, ME.message)); + % Run through an isolated ProcessingContext so the user's global + % method selection is neither required nor changed. + ctx = pf2.ProcessingContext(); + if ~isempty(rawMethod) + ctx.rawMethod = buildMethod(pf2_base.RawPipeline('TestRaw'), rawMethod); end - - % Set the oxy processing method - try - pf2.methods.oxy.setMethod(oxyMethod); - catch ME - testCase.assumeFail(sprintf(... - 'Could not set oxy method %s: %s', oxyMethod, ME.message)); + if ~isempty(oxyMethod) + ctx.oxyMethod = buildMethod(pf2_base.OxyPipeline('TestOxy'), oxyMethod); end - - % Process with the configured methods - processed = processFNIRS2(testCase.fNIR2000Data); + processed = ctx.process(testCase.fNIR2000Data); + label = sprintf('raw=%s, oxy=%s', ctx.rawMethodName, ctx.oxyMethodName); % Verify output has required fields testCase.verifyTrue(isfield(processed, 'HbO'), ... - sprintf('Output with raw=%s, oxy=%s should have HbO', ... - rawMethod, oxyMethod)); + sprintf('Output with %s should have HbO', label)); testCase.verifyTrue(isfield(processed, 'HbR'), ... - sprintf('Output with raw=%s, oxy=%s should have HbR', ... - rawMethod, oxyMethod)); + sprintf('Output with %s should have HbR', label)); % Verify data is valid (not all NaN) testCase.verifyFalse(all(isnan(processed.HbO(:))), ... - sprintf('HbO with raw=%s, oxy=%s should not be all NaN', ... - rawMethod, oxyMethod)); + sprintf('HbO with %s should not be all NaN', label)); end end @@ -394,3 +391,15 @@ function testRawDataPreserved(testCase) end end end + +function method = buildMethod(pipe, steps) +% BUILDMETHOD Add {funcName, params} steps to a pipeline and return its method. +for k = 1:numel(steps) + if isempty(fieldnames(steps{k}{2})) + pipe = pipe.add(steps{k}{1}); + else + pipe = pipe.add(steps{k}{1}, steps{k}{2}); + end +end +method = pipe.toMethod(); +end diff --git a/+pf2_base/+tests/+unit/AllPlotsSmokeTest.m b/+pf2_base/+tests/+unit/AllPlotsSmokeTest.m index 531d4e4b..1b715930 100644 --- a/+pf2_base/+tests/+unit/AllPlotsSmokeTest.m +++ b/+pf2_base/+tests/+unit/AllPlotsSmokeTest.m @@ -283,6 +283,13 @@ function testPlotDyadMatrix(testCase) methods (Test) function testCreateFigure(testCase) + % The white background is the light-theme result. Rendering + % tests earlier in the suite (and exportgraphics) can leave + % groot's defaultFigureColor dark, which PlotStyle reads as dark + % mode, so pin light mode here as PlotStyleTest does. + prevForceLight = pf2_base.plot.PlotStyle.getForceLightMode(); + pf2_base.plot.PlotStyle.setForceLightMode(true); + restoreTheme = onCleanup(@() pf2_base.plot.PlotStyle.setForceLightMode(prevForceLight)); %#ok fig = pf2_base.plot.createFigure('Visible', 'off'); testCase.verifyTrue(ishandle(fig)); testCase.verifyEqual(get(fig, 'Color'), [1 1 1]); diff --git a/+pf2_base/+tests/+unit/BlockDefinitionTest.m b/+pf2_base/+tests/+unit/BlockDefinitionTest.m index 40dc8345..e2bccad8 100644 --- a/+pf2_base/+tests/+unit/BlockDefinitionTest.m +++ b/+pf2_base/+tests/+unit/BlockDefinitionTest.m @@ -245,7 +245,9 @@ function testMaxDurationFilter(testCase) function testEmptyMarkers(testCase) % No matching markers returns empty struct array data = pf2_base.tests.unit.BlockDefinitionTest.makeSyntheticData(); - blocks = pf2.data.defineBlocks(data, 'MarkerCode', 999, 'Duration', 10, 'Embed', false); + blocks = testCase.verifyWarning(@() pf2.data.defineBlocks(data, ... + 'MarkerCode', 999, 'Duration', 10, 'Embed', false), ... + 'pf2:defineBlocks:codesNotFound'); testCase.verifyEmpty(blocks, 'Should return empty for no matching markers'); testCase.verifyTrue(isstruct(blocks), 'Empty result should still be struct'); @@ -255,10 +257,72 @@ function testEmptyMarkerArray(testCase) % Data with empty markers returns empty data = pf2_base.tests.unit.BlockDefinitionTest.makeSyntheticData(); data.markers = pf2_base.normalizeMarkers([]); + blocks = testCase.verifyWarning(@() pf2.data.defineBlocks(data, ... + 'MarkerCode', 49, 'Duration', 10, 'Embed', false), ... + 'pf2:defineBlocks:noMarkers'); + + testCase.verifyEmpty(blocks); + end + + function testCodesNotFoundNamesPresentCodes(testCase) + % The warning lists the codes that are present and the recording + data = pf2_base.tests.unit.BlockDefinitionTest.makeSyntheticData(); + lastwarn('', ''); + w = warning('off', 'pf2:defineBlocks:codesNotFound'); + restore = onCleanup(@() warning(w)); + pf2.data.defineBlocks(data, 'MarkerCode', [7 8], 'Duration', 10, 'Embed', false); + [msg, wid] = lastwarn(); + testCase.verifyEqual(wid, 'pf2:defineBlocks:codesNotFound'); + testCase.verifySubstring(msg, '7, 8'); + testCase.verifySubstring(msg, 'Codes present: 49'); + testCase.verifySubstring(msg, 'for S01'); + end + + function testMalformedMarkerColumnsHint(testCase) + % Marker columns given as [code, time] put times in the Code + % column; the warning points at the column order. + data = pf2_base.tests.unit.BlockDefinitionTest.makeSyntheticData(); + data.markers = pf2_base.normalizeMarkers([49 30.5; 49 90.25; 49 180.75]); + lastwarn('', ''); + w = warning('off', 'pf2:defineBlocks:codesNotFound'); + restore = onCleanup(@() warning(w)); blocks = pf2.data.defineBlocks(data, 'MarkerCode', 49, 'Duration', 10, 'Embed', false); + [msg, wid] = lastwarn(); + testCase.verifyEmpty(blocks); + testCase.verifyEqual(wid, 'pf2:defineBlocks:codesNotFound'); + testCase.verifySubstring(msg, 'out of order'); + end + function testNoPairedBlocksWarning(testCase) + % Start codes present but no end code after them + data = pf2_base.tests.unit.BlockDefinitionTest.makeSyntheticData(); + % End code 2 occurs only before every start code 1. + data.markers = pf2_base.normalizeMarkers([5 2; 10 1; 30 1]); + blocks = testCase.verifyWarning(@() pf2.data.defineBlocks(data, ... + 'StartMarker', 1, 'EndMarker', 2, 'Embed', false), ... + 'pf2:defineBlocks:noPairedBlocks'); + testCase.verifyEmpty(blocks); + end + + function testAllBlocksFilteredWarning(testCase) + % Every block shorter than MinDuration + data = pf2_base.tests.unit.BlockDefinitionTest.makeSyntheticData(); + blocks = testCase.verifyWarning(@() pf2.data.defineBlocks(data, ... + 'MarkerCode', 49, 'MinDuration', 100, 'Embed', false), ... + 'pf2:defineBlocks:allBlocksFiltered'); testCase.verifyEmpty(blocks); end + + function testMatchingCodesDoNotWarn(testCase) + % A normal call raises none of the zero-block warnings + data = pf2_base.tests.unit.BlockDefinitionTest.makeSyntheticData(); + lastwarn('', ''); + blocks = pf2.data.defineBlocks(data, 'MarkerCode', [49 7], 'Duration', 10, 'Embed', false); + [~, wid] = lastwarn(); + testCase.verifyNumElements(blocks, 3); + testCase.verifyFalse(startsWith(wid, 'pf2:defineBlocks:'), ... + sprintf('Unexpected warning %s', wid)); + end end %% extractBlocks - Basic Extraction diff --git a/+pf2_base/+tests/+unit/INIFileTest.m b/+pf2_base/+tests/+unit/INIFileTest.m index 2db16a8a..92b46772 100644 --- a/+pf2_base/+tests/+unit/INIFileTest.m +++ b/+pf2_base/+tests/+unit/INIFileTest.m @@ -120,5 +120,62 @@ function testReadDeviceCfg(tc) tc.verifyNotEmpty(r.Sections); end + function testReservedNamesCoverClassMembers(tc) + % Every property and method of the class (including inherited + % handle methods) must be reserved, so a section can never + % shadow one. + mc = meta.class.fromName('pf2_base.external.INI'); + names = [{mc.PropertyList.Name}, {mc.MethodList.Name}]; + for k = 1:numel(names) + tc.verifyTrue(pf2_base.external.INI.isReservedName(names{k}), ... + sprintf('"%s" is not reserved', names{k})); + end + end + + function testSectionNamedLikeMethodIsPrefixed(tc) + % A section named like a method or internal property keeps its + % original name for get/write but does not shadow the member. + c = pf2_base.external.INI('File', tc.TmpFile); + c.add('write', struct('x', 1), 'Data', struct('y', 2), 'x_write', struct('z', 3)); + tc.verifyEqual(c.get('write'), struct('x', 1)); + tc.verifyEqual(c.get('x_write'), struct('z', 3)); + c.write(); + r = pf2_base.external.INI('File', tc.TmpFile); + r.read(); + tc.verifyEqual(sort(r.Sections), sort({'Data', 'write', 'x_write'})); + tc.verifyEqual(r.get('Data'), struct('y', 2)); + tc.verifyEqual(r.get('write'), struct('x', 1)); + end + + function testPrivateMembersNotReachable(tc) + % The overloaded indexing runs with class access; it must not + % expose private members, including via string or indexed + % references. + c = pf2_base.external.INI('File', tc.TmpFile); + c.add('Info', struct('a', 1)); + tc.verifyError(@() c.Data, 'MATLAB:class:GetProhibited'); + tc.verifyError(@() c.("PropToSection"), 'MATLAB:class:GetProhibited'); + tc.verifyError(@() c.clearSections(), 'MATLAB:class:MethodRestricted'); + tc.verifyError(@() subsasgn(c, substruct('()', {1}, '.', 'Data'), 5), ... + 'MATLAB:class:GetProhibited'); + tc.verifyEqual(c.get('Info'), struct('a', 1)); + end + + function testStringAndIndexedSectionAccess(tc) + % String dynamic names and indexed element access reach sections. + c = pf2_base.external.INI('File', tc.TmpFile); + c.add('Info', struct('Name', 'A')); + tc.verifyEqual(c.("Info").Name, 'A'); + c(1).Info.Name = 'B'; + tc.verifyEqual(c.Info.Name, 'B'); + d = pf2_base.external.INI('File', tc.TmpFile); + d.add('Info', struct('Name', 'C')); + arr = [c d]; + tc.verifyEqual({arr.File}, {tc.TmpFile, tc.TmpFile}); + tc.verifyEqual(arr(2).Info.Name, 'C'); + arr(2).Info.Name = 'D'; + tc.verifyEqual(d.Info.Name, 'D'); + end + end end diff --git a/+pf2_base/+tests/+unit/SplitTest.m b/+pf2_base/+tests/+unit/SplitTest.m index f48af3e1..d6f991c6 100644 --- a/+pf2_base/+tests/+unit/SplitTest.m +++ b/+pf2_base/+tests/+unit/SplitTest.m @@ -365,5 +365,20 @@ function testSplitDataValuesAreFromOriginal(testCase) testCase.verifyEqual(seg.HbO, expectedHbO, 'AbsTol', 1e-15, ... 'Extracted HbO should exactly match original data slice'); end + + function testAuxTableWithTimeColumn(testCase) + % An aux signal stored as a table with its own time column + % (e.g. a derived HRV series). Splitting and resampling rebuild + % its time table, which R2025b's table() rejected when given a + % single char variable name. + data = testCase.processedData; + t = (data.time(1):1:data.time(end))'; + data.Aux.hrv = table(t, 60 + randn(numel(t), 1), ... + 'VariableNames', {'time', 'RMSSD'}); + seg = pf2.data.split(data, data.time(1) + 20, data.time(1) + 80); + testCase.verifyTrue(isfield(seg, 'Aux'), 'split dropped the Aux field'); + rs = pf2.data.resample(data, 0.5); + testCase.verifyTrue(isfield(rs, 'Aux'), 'resample dropped the Aux field'); + end end end diff --git a/+pf2_base/+tests/+unit/WaveletCoherenceTest.m b/+pf2_base/+tests/+unit/WaveletCoherenceTest.m new file mode 100644 index 00000000..25315816 --- /dev/null +++ b/+pf2_base/+tests/+unit/WaveletCoherenceTest.m @@ -0,0 +1,87 @@ +classdef WaveletCoherenceTest < matlab.unittest.TestCase +% WAVELETCOHERENCETEST Tests for pf2_base.wavelet.wcoherence +% +% Covers: +% - bounds: identical signals give coherence near 1, values lie in [0, 1] +% - missing input: an all-NaN series gives NaN, both when the CWTs are +% computed internally and when cached CWTs are supplied (the cached +% coefficients of a NaN series are NaN, and the clamp to 1 previously +% turned them into perfect coherence) +% - constant input gives NaN +% - a series with a short gap stays finite and in range +% +% Example: +% results = runtests('pf2_base.tests.unit.WaveletCoherenceTest'); + + properties + fs = 2 + n = 1200 + band = [0.009 0.06] + end + + methods (Test) + + function testIdenticalSignalsNearOne(tc) + rng(1); + x = cumsum(randn(tc.n, 1)); + r = pf2_base.wavelet.wcoherence(x, x, tc.fs, 'FreqRange', tc.band); + tc.verifyGreaterThan(r.value, 0.99); + tc.verifyLessThanOrEqual(r.value, 1); + end + + function testValuesWithinUnitInterval(tc) + rng(2); + r = pf2_base.wavelet.wcoherence(randn(tc.n, 1), randn(tc.n, 1), tc.fs, ... + 'FreqRange', tc.band); + w = r.wcoh(isfinite(r.wcoh)); + tc.verifyGreaterThanOrEqual(min(w), 0); + tc.verifyLessThanOrEqual(max(w), 1); + end + + function testAllNaNInputGivesNaN(tc) + rng(3); + r = pf2_base.wavelet.wcoherence(randn(tc.n, 1), nan(tc.n, 1), tc.fs, ... + 'FreqRange', tc.band); + tc.verifyTrue(isnan(r.value)); + end + + function testCachedCwtOfNaNSeriesGivesNaN(tc) + rng(4); + X = randn(tc.n, 2); + Y = randn(tc.n, 2); + Y(:, 2) = NaN; + args = {'FreqRange', [0.0045 0.12], 'VoicesPerOctave', 8}; + Wx = pf2_base.wavelet.cwt(X, tc.fs, args{:}); + Wy = pf2_base.wavelet.cwt(Y, tc.fs, args{:}); + slice = @(W, c) struct('coeffs', W.coeffs(:, :, c), 'freqs', W.freqs, ... + 'coi', W.coi, 'scales', W.scales); + + rGood = pf2_base.wavelet.wcoherence(X(:, 1), Y(:, 1), tc.fs, ... + 'FreqRange', tc.band, 'CwtX', slice(Wx, 1), 'CwtY', slice(Wy, 1)); + rNaN = pf2_base.wavelet.wcoherence(X(:, 1), Y(:, 2), tc.fs, ... + 'FreqRange', tc.band, 'CwtX', slice(Wx, 1), 'CwtY', slice(Wy, 2)); + + tc.verifyTrue(isfinite(rGood.value)); + tc.verifyLessThan(rGood.value, 1); + tc.verifyTrue(isnan(rNaN.value)); + end + + function testConstantInputGivesNaN(tc) + rng(5); + r = pf2_base.wavelet.wcoherence(randn(tc.n, 1), zeros(tc.n, 1), tc.fs, ... + 'FreqRange', tc.band); + tc.verifyTrue(isnan(r.value)); + end + + function testShortGapStaysFinite(tc) + rng(6); + y = randn(tc.n, 1); + y(100:160) = NaN; + r = pf2_base.wavelet.wcoherence(randn(tc.n, 1), y, tc.fs, 'FreqRange', tc.band); + tc.verifyTrue(isfinite(r.value)); + tc.verifyGreaterThanOrEqual(r.value, 0); + tc.verifyLessThanOrEqual(r.value, 1); + end + + end +end diff --git a/+pf2_base/+tests/generateOctaveReference.m b/+pf2_base/+tests/generateOctaveReference.m new file mode 100644 index 00000000..a1cce71d --- /dev/null +++ b/+pf2_base/+tests/generateOctaveReference.m @@ -0,0 +1,57 @@ +function ref = generateOctaveReference(savePath) +% GENERATEOCTAVEREFERENCE Build the MATLAB reference used by the Octave CI subset +% +% Runs the headless operations exercised by pf2_base.tests.runOctaveSubset +% under MATLAB and saves their results, so the Octave job can check parity +% against MATLAB rather than only checking that the code runs. Re-run this +% under MATLAB whenever an intended change alters any of these results +% (device geometry, Beer-Lambert conversion, filters, TDDR). +% +% Reference: +% Internal pf2 implementation. +% +% Syntax: +% pf2_base.tests.generateOctaveReference() +% ref = pf2_base.tests.generateOctaveReference(savePath) +% +% Inputs: +% savePath - (optional) Output .mat path. Default: +% +pf2_base/+tests/+data/octave_reference.mat +% +% Outputs: +% ref - Struct saved to savePath (MAT -v7, readable by Octave) with fields: +% devices - struct array: name, nChannels, wavelengths, +% sdDistances, mni (empty when the device has no MNI) +% import - rawSize, rawSum, time sum for sampleNIR.nir +% default - HbO, HbR (every 5th sample) from processFNIRS2 with +% default methods on pf2.import.sampleData() +% pipeline - HbO, HbR (every 5th sample) from Intensity2OD + TDDR +% and LPF 0.1 Hz + HPF 0.01 Hz through a ProcessingContext +% epochs - marker times, block/segment/window counts, and the +% trial-averaged HbO from the block-averaging recipe +% qc - per-channel pass flags, SCI and CoV values +% matlabVersion, created +% +% Example: +% pf2_base.tests.generateOctaveReference(); +% +% See also: pf2_base.tests.runOctaveSubset + + root = pf2_base.pf2_defaultRootPath(); + if nargin < 1 || isempty(savePath) + savePath = fullfile(root, '+pf2_base', '+tests', '+data', 'octave_reference.mat'); + end + + ref = struct(); + ref.devices = pf2_base.tests.octaveSubsetOps('devices'); + ref.import = pf2_base.tests.octaveSubsetOps('import'); + ref.default = pf2_base.tests.octaveSubsetOps('default'); + ref.pipeline = pf2_base.tests.octaveSubsetOps('pipeline'); + ref.epochs = pf2_base.tests.octaveSubsetOps('epochs'); + ref.qc = pf2_base.tests.octaveSubsetOps('qc'); + ref.matlabVersion = version(); + ref.created = char(datetime('now', 'Format', 'yyyy-MM-dd''T''HH:mm:ss')); + + save(savePath, '-struct', 'ref', '-v7'); + fprintf('Saved Octave reference to %s\n', savePath); +end diff --git a/+pf2_base/+tests/octaveSubsetOps.m b/+pf2_base/+tests/octaveSubsetOps.m new file mode 100644 index 00000000..a13a2048 --- /dev/null +++ b/+pf2_base/+tests/octaveSubsetOps.m @@ -0,0 +1,101 @@ +function out = octaveSubsetOps(op) +% OCTAVESUBSETOPS Headless operations shared by the Octave reference and runner +% +% Single definition of each operation checked by the Octave CI subset, so +% pf2_base.tests.generateOctaveReference (MATLAB) and +% pf2_base.tests.runOctaveSubset (Octave or MATLAB) run identical code. +% +% Reference: +% Internal pf2 implementation. +% +% Syntax: +% out = pf2_base.tests.octaveSubsetOps(op) +% +% Inputs: +% op - 'devices' | 'import' | 'default' | 'pipeline' | 'epochs' | 'qc' +% +% Outputs: +% out - For 'devices', a struct array (name, nChannels, wavelengths, +% sdDistances, mni) for every shipped device that loads. For +% 'import', a struct (rawSize, rawSum, timeSum). For 'default' and +% 'pipeline', a struct (HbO, HbR) holding every 5th sample. For +% 'epochs', a struct (markerTimes, nBlocks, nSegments, avgTime, +% avgHbO, nWindows) from getMarkers, defineBlocks, extractBlocks, +% blockAverage and slidingWindows on the processed sample. For +% 'qc', a struct (pass, sci, cov) from pf2.qc.pipeline.assess. +% +% See also: pf2_base.tests.generateOctaveReference, +% pf2_base.tests.runOctaveSubset + + root = pf2_base.pf2_defaultRootPath(); + switch op + case 'devices' + files = dir(fullfile(root, 'devices', '*.cfg')); + names = sort({files.name}); + out = struct('name', {}, 'nChannels', {}, 'wavelengths', {}, ... + 'sdDistances', {}, 'mni', {}); + for k = 1:numel(names) + name = regexprep(names{k}, '\.cfg$', ''); + try + dev = pf2.Device.load(name); + catch + continue; + end + mni = []; + if dev.hasMNI() + mni = dev.mniPositions(); + end + out(end+1) = struct('name', name, 'nChannels', dev.nChannels, ... + 'wavelengths', dev.wavelengths(), ... + 'sdDistances', dev.sdDistances(), 'mni', mni); %#ok + end + + case 'import' + data = pf2.import.importNIR(fullfile(root, 'sampledata', 'sampleNIR.nir')); + raw = data.raw; + out = struct('rawSize', size(raw), 'rawSum', sum(raw(~isnan(raw))), ... + 'timeSum', sum(data.time)); + + case 'default' + proc = processFNIRS2(pf2.import.sampleData()); + out = struct('HbO', proc.HbO(1:5:end, :), 'HbR', proc.HbR(1:5:end, :)); + + case 'pipeline' + rp = pf2_base.RawPipeline('OctaveRefRaw'); + rp = rp.add('pf2_Intensity2OD'); + rp = rp.add('pf2_MotionCorrectTDDR'); + opPipe = pf2_base.OxyPipeline('OctaveRefOxy'); + opPipe = opPipe.add('pf2_lpf', struct('freq_cut', 0.1)); + opPipe = opPipe.add('pf2_hpf', struct('freq_cut', 0.01)); + ctx = pf2.ProcessingContext(); + ctx.rawMethod = rp.toMethod(); + ctx.oxyMethod = opPipe.toMethod(); + proc = ctx.process(pf2.import.sampleData()); + out = struct('HbO', proc.HbO(1:5:end, :), 'HbR', proc.HbR(1:5:end, :)); + + case 'epochs' + proc = processFNIRS2(pf2.import.sampleData()); + out = struct(); + out.markerTimes = pf2.data.getMarkers(proc, 50); + blocks = pf2.data.defineBlocks(proc, 50, 15, 'Embed', false); + out.nBlocks = numel(blocks); + segments = pf2.data.extractBlocks(proc, blocks, 'PreTime', 5, ... + 'PostTime', 15, 'SetT0', true); + out.nSegments = numel(segments); + ga = pf2.data.blockAverage(segments); + out.avgTime = ga.time; + out.avgHbO = ga.HbO.Mean; + windows = pf2.data.slidingWindows(proc, 'Length', 10, ... + 'Overlap', 0.5, 'Embed', false); + out.nWindows = numel(windows); + + case 'qc' + report = pf2.qc.pipeline.assess(pf2.import.sampleData(), ... + 'Checks', {'saturation', 'sci', 'cov'}); + out = struct('pass', double(report.pass), ... + 'sci', report.sci.values, 'cov', report.cov.values); + + otherwise + error('pf2:tests:octaveSubsetOps:unknownOp', 'Unknown operation "%s".', op); + end +end diff --git a/+pf2_base/+tests/runOctaveSubset.m b/+pf2_base/+tests/runOctaveSubset.m new file mode 100644 index 00000000..fa12ea72 --- /dev/null +++ b/+pf2_base/+tests/runOctaveSubset.m @@ -0,0 +1,215 @@ +function nFailed = runOctaveSubset() +% RUNOCTAVESUBSET Plain-function test lane for the GNU Octave CI job +% +% Octave does not implement matlab.unittest, so the classdef suite cannot +% run there. This runner checks the headless path that is supported under +% Octave: package loading, the INI reader/writer, device loading, import, +% default processing, the Pipeline API through a ProcessingContext, the +% block-averaging recipe (markers, blocks, epochs, averages, sliding +% windows), and the QC pipeline. +% Numerical results are compared with a MATLAB reference +% (+pf2_base/+tests/+data/octave_reference.mat, built by +% pf2_base.tests.generateOctaveReference), so the lane checks parity with +% MATLAB as well as that the code runs. It also runs under MATLAB, where it +% checks the reference is current. +% +% Under Octave, the root pf2.m (the GUI launcher) shadows the +pf2 package, +% so it must not be on the path; the CI job removes it from its checkout. +% +% Reference: +% Internal pf2 implementation. +% +% Syntax: +% pf2_base.tests.runOctaveSubset() +% nFailed = pf2_base.tests.runOctaveSubset() +% +% Inputs: +% (none) +% +% Outputs: +% nFailed - Number of failed checks. With no output requested, the +% function errors when any check fails, so `octave-cli --eval` +% and `matlab -batch` exit non-zero. +% +% Example: +% octave-cli --eval "addpath(pwd); pf2_base.tests.runOctaveSubset()" +% +% See also: pf2_base.tests.generateOctaveReference, +% pf2_base.tests.octaveSubsetOps, pf2_base.env.ensureOctaveCompat + + pf2_base.env.ensureOctaveCompat(); + root = pf2_base.pf2_defaultRootPath(); + ref = load(fullfile(root, '+pf2_base', '+tests', '+data', 'octave_reference.mat')); + tol = 1e-9; % relative to the reference's largest magnitude + + results = {}; + results{end+1} = check('runtime packages', @() checkRuntime()); + results{end+1} = check('pf2 package resolves', @() checkPackage()); + results{end+1} = check('INI round trip', @() checkINI(root)); + results{end+1} = check('round/contains shims', @() checkShims()); + results{end+1} = check('device geometry', @() checkDevices(ref.devices, tol)); + results{end+1} = check('importNIR', @() checkImport(ref.import)); + results{end+1} = check('sampleData by name', @() checkSampleData()); + results{end+1} = check('processFNIRS2 default', @() checkHb(ref.default, 'default', tol)); + results{end+1} = check('Pipeline API via ProcessingContext', @() checkHb(ref.pipeline, 'pipeline', tol)); + results{end+1} = check('ProcessingContext copy/validation', @() checkContext()); + results{end+1} = check('markers, blocks, epochs, averaging, windows', @() checkEpochs(ref.epochs, tol)); + results{end+1} = check('QC pipeline (saturation, SCI, CoV)', @() checkQC(ref.qc, tol)); + + ok = cellfun(@(r) r, results); + info = pf2_base.env.runtime(); + fprintf('\n%d of %d checks passed under %s %s.\n', sum(ok), numel(ok), ... + info.name, info.version); + nFailed = sum(~ok); + if nargout == 0 && nFailed > 0 + error('pf2:tests:runOctaveSubset:failed', '%d Octave subset check(s) failed.', nFailed); + end +end + +function ok = check(name, fn) +% CHECK Run one check, print PASS/FAIL with the first error line. + try + fn(); + ok = true; + fprintf('PASS %s\n', name); + catch err + ok = false; + msg = strtok(err.message, char(10)); + fprintf('FAIL %s: %s\n', name, msg); + for k = 1:min(3, numel(err.stack)) + fprintf(' at %s:%d\n', err.stack(k).name, err.stack(k).line); + end + end +end + +function checkRuntime() + info = pf2_base.env.runtime(); + assert(isempty(info.missingPkgs), 'missing packages: %s', strjoin(info.missingPkgs, ', ')); +end + +function checkPackage() + % Fails under Octave when root pf2.m is on the path. + w = which('pf2'); + assert(~pf2_base.env.isOctave() || isempty(w) || isempty(strfind(w, 'pf2.m')), ... + 'root pf2.m is on the path and shadows the +pf2 package under Octave'); + assert(isa(pf2.Device.load('fNIR_Devices_fNIR2000'), 'pf2.Device'), ... + 'pf2.Device.load did not return a pf2.Device'); +end + +function checkINI(root) + c = pf2_base.external.INI('File', fullfile(root, 'devices', 'fNIR_Devices_fNIR2000.cfg')); + c.read(); + assert(ismember('Info', c.Sections), 'Info section missing'); + c.add('Octave Check', struct('a', 1, 'b', {{'x', 'y'}})); + f = [tempname() '.ini']; + cleanup = onCleanup(@() deleteIfExists(f)); + c.write(f); + r = pf2_base.external.INI('File', f); + r.read(); + assert(isequal(sort(r.Sections), sort(c.Sections)), 'sections differ after round trip'); + assert(isequal(r.get('Octave Check'), c.get('Octave Check')), 'section data differs after round trip'); +end + +function checkShims() + assert(isequal(round([0.285 1.005 -2.675], 2), [0.29 1.01 -2.68]), 'round(x, n) ties'); + assert(round(2.4999999999999996, 0) == 2, 'round(x, 0) near tie'); + hits = contains({'Abc', 'x'}, 'a', 'IgnoreCase', true); + assert(contains('abc', '') && isequal(hits, [true false]), 'contains'); +end + +function checkDevices(refDevices, tol) + got = pf2_base.tests.octaveSubsetOps('devices'); + gotNames = {got.name}; + for k = 1:numel(refDevices) + r = refDevices(k); + idx = find(strcmp(gotNames, r.name), 1); + assert(~isempty(idx), 'device %s did not load', r.name); + g = got(idx); + assert(isequal(g.nChannels, r.nChannels), '%s: nChannels differs', r.name); + assertClose(g.wavelengths, r.wavelengths, tol, [r.name ' wavelengths']); + assertClose(g.sdDistances, r.sdDistances, tol, [r.name ' sdDistances']); + assertClose(g.mni, r.mni, tol, [r.name ' MNI positions']); + end +end + +function checkImport(r) + g = pf2_base.tests.octaveSubsetOps('import'); + assert(isequal(g.rawSize, r.rawSize), 'raw size differs'); + assertClose(g.rawSum, r.rawSum, 1e-12, 'raw sum'); + assertClose(g.timeSum, r.timeSum, 1e-12, 'time sum'); +end + +function checkSampleData() + % Under Octave, sampleData.m hides the +sampleData package, so named + % datasets are reached as sampleData('name'). + d = pf2.import.sampleData('fNIR2000'); + assert(isfield(d, 'raw') && ~isempty(d.raw), 'fNIR2000 has no raw data'); + assert(height(d.markers) == 27, 'fNIR2000 has %d markers, expected 27', height(d.markers)); + dict = pf2.data.getMarkerDict(d); + assert(isequal(dict.Code(:)', [1 2]), 'marker dictionary codes differ'); +end + +function checkHb(r, op, tol) + g = pf2_base.tests.octaveSubsetOps(op); + assertClose(g.HbO, r.HbO, tol, [op ' HbO']); + assertClose(g.HbR, r.HbR, tol, [op ' HbR']); +end + +function checkEpochs(r, tol) + g = pf2_base.tests.octaveSubsetOps('epochs'); + assertClose(g.markerTimes, r.markerTimes, tol, 'marker times'); + assert(g.nBlocks == r.nBlocks, 'block count %d, expected %d', g.nBlocks, r.nBlocks); + assert(g.nSegments == r.nSegments, 'segment count %d, expected %d', g.nSegments, r.nSegments); + assert(g.nWindows == r.nWindows, 'window count %d, expected %d', g.nWindows, r.nWindows); + assertClose(g.avgTime, r.avgTime, tol, 'averaged time base'); + assertClose(g.avgHbO, r.avgHbO, tol, 'averaged HbO'); +end + +function checkQC(r, tol) + g = pf2_base.tests.octaveSubsetOps('qc'); + assert(isequal(g.pass, r.pass), 'per-channel pass flags differ'); + assertClose(g.sci, r.sci, tol, 'SCI values'); + assertClose(g.cov, r.cov, tol, 'CoV values'); +end + +function checkContext() + ctx = pf2.ProcessingContext('SubjectAge', 30); + c2 = ctx.copy(); + c2.subjectAge = 40; + assert(ctx.subjectAge == 30 && c2.subjectAge == 40, 'copy() is not independent'); + assert(isa(c2, 'pf2.ProcessingContext'), 'copy() changed the class'); + rejected = false; + try + ctx.dpfMode = 'Bogus'; + catch + rejected = true; + end + assert(rejected, 'invalid dpfMode was accepted'); + ctx.ppf = [6; 7]; + assert(isequal(size(ctx.ppf), [1 2]), 'ppf column was not reshaped to a row'); +end + +function assertClose(got, want, tol, label) + assert(isequal(size(got), size(want)), '%s: size %s, expected %s', label, ... + mat2str(size(got)), mat2str(size(want))); + if isempty(want) + return; + end + nanGot = isnan(got); + assert(isequal(nanGot, isnan(want)), '%s: NaN pattern differs', label); + scale = max(abs(want(~nanGot))); + if isempty(scale) || scale == 0 + scale = 1; + end + err = max(abs(got(~nanGot) - want(~nanGot))); + if isempty(err) + err = 0; + end + assert(err <= tol * scale, '%s: max abs difference %.3g exceeds %.3g', label, err, tol * scale); +end + +function deleteIfExists(f) + if exist(f, 'file') == 2 + delete(f); + end +end diff --git a/+pf2_base/+wavelet/wcoherence.m b/+pf2_base/+wavelet/wcoherence.m index a9b765cd..160fc4eb 100644 --- a/+pf2_base/+wavelet/wcoherence.m +++ b/+pf2_base/+wavelet/wcoherence.m @@ -37,7 +37,10 @@ % % Outputs: % result - Struct with fields: -% .value - Mean WCT magnitude in FreqRange (scalar, COI-masked) +% .value - Mean WCT magnitude in FreqRange (scalar, COI-masked). +% NaN when either input has no finite samples or is +% constant, or when the supplied CWT coefficients are NaN; +% coherence is undefined for those inputs. % .pvalue - NaN (use permutation test for significance) % .method - 'wcoherence' % .windowed - false @@ -97,6 +100,9 @@ y = y(:); T = length(x); + % Coherence is undefined for a missing or constant input + degenerate = isDegenerate(x) || isDegenerate(y); + % NaN handling: linear interpolation x = fillNaN(x); y = fillNaN(y); @@ -155,7 +161,13 @@ % --- Wavelet coherence --- wcoh = abs(sWxy).^2 ./ (sWxx .* sWyy + eps(class(sWxx))); - wcoh = min(wcoh, 1); + % Clamp rounding overshoot; min(NaN, 1) would return 1 and turn + % NaN coefficients (e.g. a cached CWT of a missing series) into + % perfect coherence + wcoh(wcoh > 1) = 1; + if degenerate + wcoh(:) = NaN; + end % --- Build time vector --- times = (0:T-1)' / fs; @@ -251,6 +263,13 @@ end +function tf = isDegenerate(v) +% True when v has fewer than two finite samples or no variation + f = v(isfinite(v)); + tf = numel(f) < 2 || max(f) == min(f); +end + + function v = fillNaN(v) % Linear interpolation of NaN values nanIdx = isnan(v); diff --git a/+pf2_base/GUIContext.m b/+pf2_base/GUIContext.m index 9b4fe5e5..20ef6499 100644 --- a/+pf2_base/GUIContext.m +++ b/+pf2_base/GUIContext.m @@ -75,8 +75,13 @@ end methods - function obj = GUIContext() + function obj = GUIContext(varargin) % GUICONTEXT Create a new GUI context with defaults + % The only accepted input is COPY_TOKEN, passed by copy(). + if nargin > 1 || (nargin == 1 && ~isequal(varargin{1}, ... + pf2_base.ProcessingContext.COPY_TOKEN)) + error('MATLAB:TooManyInputs', 'Too many input arguments.'); + end obj@pf2_base.ProcessingContext(); end diff --git a/+pf2_base/Pipeline.m b/+pf2_base/Pipeline.m index c77bb77a..19217fa9 100644 --- a/+pf2_base/Pipeline.m +++ b/+pf2_base/Pipeline.m @@ -31,13 +31,37 @@ % See also: pf2_base.RawPipeline, pf2_base.OxyPipeline, % pf2_base.PipelineFunction + % Types are enforced by set methods rather than inline declarations, + % which do not parse under GNU Octave. properties (SetAccess = protected) - name char = '' - description char = '' - steps cell = {} + name = '' % char + description = '' % char + steps = {} % cell of pf2_base.PipelineFunction end methods + function obj = set.name(obj, v) + obj.name = pf2_base.Pipeline.asChar(v); + end + + function obj = set.description(obj, v) + obj.description = pf2_base.Pipeline.asChar(v); + end + + function obj = set.steps(obj, v) + % As a 'cell' declaration: an empty numeric, char or logical + % array becomes an empty cell of the same size. + if ~iscell(v) + if isempty(v) && (isnumeric(v) || ischar(v) || islogical(v)) + v = cell(size(v)); + else + error('MATLAB:validation:UnableToConvert', ... + 'steps must be a cell array.'); + end + end + obj.steps = v; + end + function obj = Pipeline(name, varargin) % Constructor. % @@ -519,6 +543,18 @@ function disp(obj) end end + methods (Static, Access = private) + function v = asChar(v) + % Convert with char(), as a 'char' property declaration does. + try + v = char(v); + catch + error('MATLAB:validation:UnableToConvert', ... + 'Value must be convertible to char.'); + end + end + end + methods (Static) function p = fromMethod(methodName, stage) % FROMMETHOD Build a Pipeline from an existing named method. diff --git a/+pf2_base/PipelineFunction.m b/+pf2_base/PipelineFunction.m index 776746e5..b1744747 100644 --- a/+pf2_base/PipelineFunction.m +++ b/+pf2_base/PipelineFunction.m @@ -68,43 +68,46 @@ % % See also: pf2_base.pf2_unpackMethod, pf2_base.fnirs.processStageRaw2OD - properties (SetAccess = immutable) - funcName char - funcHandle function_handle - name char - description char - validStages double - requiresOD logical - style char - role char % '' | 'intensity2od' | 'motion' | 'filter' | 'rejection' | 'roi' | 'transform' + % Untyped declarations with type-matched defaults: inline property types + % do not parse under GNU Octave. The constructor assigns inputs that + % could arrive as another type with an explicit cast. + properties (SetAccess = private) + funcName = '' + funcHandle = @(varargin) [] + name = '' + description = '' + validStages = [] + requiresOD = logical([]) + style = '' + role = '' % '' | 'intensity2od' | 'motion' | 'filter' | 'rejection' | 'roi' | 'transform' % Precomputed argument mapping - argNames cell - argDefaults cell - specialMask logical - specialTypes uint8 - customIndices double - customNames cell + argNames = {} + argDefaults = {} + specialMask = logical([]) + specialTypes = zeros(0, 0, 'uint8') + customIndices = [] + customNames = {} % Per-argument metadata (parallel to argNames; missing entries empty) % Used by validators and the methods editor to render type-aware widgets. - argTypes cell % {'double'|'int'|'logical'|'string'|'enum'|'special'|''} - argChoices cell % {cell of valid values | []} for enum types - argRanges cell % {[min,max] | []} for numeric types - argUnits cell % {char | ''} - argDescriptions cell % {char | ''} + argTypes = {} % {'double'|'int'|'logical'|'string'|'enum'|'special'|''} + argChoices = {} % {cell of valid values | []} for enum types + argRanges = {} % {[min,max] | []} for numeric types + argUnits = {} % {char | ''} + argDescriptions = {} % {char | ''} % Precomputed output mapping - outputNames cell - xOutIdx double - maskOutIdx double - timeMaskOutIdx double - structOutIdx double - roiOutIdx double - nOutputs double + outputNames = {} + xOutIdx = [] + maskOutIdx = [] + timeMaskOutIdx = [] + structOutIdx = [] + roiOutIdx = [] + nOutputs = [] % Convenience flags - isIntensity2OD logical + isIntensity2OD = logical([]) end % Special argument type enum constants @@ -177,11 +180,11 @@ obj.funcName = char(funcName); obj.funcHandle = str2func(obj.funcName); - obj.name = p.Results.Name; - obj.description = p.Results.Description; - obj.validStages = p.Results.ValidStages; - obj.requiresOD = p.Results.RequiresOD; - obj.style = p.Results.Style; + obj.name = char(p.Results.Name); + obj.description = char(p.Results.Description); + obj.validStages = double(p.Results.ValidStages); + obj.requiresOD = logical(p.Results.RequiresOD); + obj.style = char(p.Results.Style); obj.role = lower(char(p.Results.Role)); % Derive isIntensity2OD from role first; fall back to name match % so legacy registrations without an explicit role keep working. diff --git a/+pf2_base/ProcessingContext.m b/+pf2_base/ProcessingContext.m index 65858fb9..1da1a2b7 100644 --- a/+pf2_base/ProcessingContext.m +++ b/+pf2_base/ProcessingContext.m @@ -1,4 +1,4 @@ -classdef ProcessingContext < matlab.mixin.Copyable +classdef ProcessingContext < handle % PROCESSINGCONTEXT Encapsulates all processing settings for fNIRS analysis % % ProcessingContext provides an alternative to global variables (PF2, setF) @@ -83,13 +83,29 @@ % ctx = ProcessingContext.fromGlobals(); % save('analysis_settings.mat', 'ctx'); % + % Property types and validators are enforced by set methods rather than + % inline declarations (e.g. 'dpfMode (1,:) char {mustBeMember(...)}'), + % and copy() is implemented here rather than inherited from + % matlab.mixin.Copyable, so the class also parses under GNU Octave. + % The set methods convert, size-check and validate in the same order as + % the inline declarations, with the same error identifiers. One + % difference: objects such as datetime or categorical assigned to a + % struct-valued property now error instead of being converted with + % struct(). + % % See also: processFNIRS2, pf2_base.pf2_initialize + properties (Constant, Hidden) + % Passed to a subclass constructor by copy() to skip any expensive + % initialization; the copy's properties are overwritten afterwards. + COPY_TOKEN = '__pf2_ProcessingContext_copy__' + end + properties % DPF (Differential Pathlength Factor) settings - dpfMode (1,:) char {mustBeMember(dpfMode, {'None', 'Fixed', 'Calc', 'PPF'})} = 'Calc' - dpfFixedValue (1,1) double {mustBePositive} = 5.93 - subjectAge (1,1) double {mustBePositive} = 25 + dpfMode = 'Calc' % 'None' | 'Fixed' | 'Calc' | 'PPF' + dpfFixedValue = 5.93 % positive scalar + subjectAge = 25 % positive scalar % Partial-volume correction applied to the fixed or age-calculated DPF: % L = SD.*DPF./pvc. Empty means 1 (no correction). A scalar or per-optode @@ -100,45 +116,46 @@ % Complete effective pathlength factor (escape hatch), used when % dpfMode == 'PPF': L = SD.*ppf, with no DPF/PVC. Scalar or [ppf1 ppf2]. - ppf (1,:) double = [] + ppf = zeros(1, 0) % double row % Baseline settings - baselineStartTime (1,1) double = 0 - baselineLength (1,1) double {mustBeNonnegative} = 10 - useAbsoluteTime (1,1) logical = false - windowStartTime (1,1) double = 0 - dirtyBaseline (1,1) logical = false + baselineStartTime = 0 % scalar + baselineLength = 10 % non-negative scalar + useAbsoluteTime = false % logical scalar + windowStartTime = 0 % scalar + dirtyBaseline = false % logical scalar % Method configuration - rawMethodName (1,:) char = 'None' - rawMethod struct = struct() - oxyMethodName (1,:) char = 'None' - oxyMethod struct = struct() + rawMethodName = 'None' % char row + rawMethod = struct() + oxyMethodName = 'None' % char row + oxyMethod = struct() % Method libraries (loaded from config files) - rawMethodsLib struct = struct() - oxyMethodsLib struct = struct() + rawMethodsLib = struct() + oxyMethodsLib = struct() % Device configuration - device struct = struct() + device = struct() % Quality control - rejectLevel (1,1) double {mustBeInRange(rejectLevel, 0, 1)} = 0 - processRejected (1,1) logical = false + rejectLevel = 0 % scalar in [0, 1] + processRejected = false % logical scalar % Output options - outputLegacyMarkers (1,1) logical = false + outputLegacyMarkers = false % logical scalar % Paths - rootPath (1,:) char = '' + rootPath = '' % char row end methods - function obj = ProcessingContext() + function obj = ProcessingContext(varargin) % PROCESSINGCONTEXT Create a new processing context with defaults % % The context is initialized with sensible defaults. Use % fromGlobals() to create a context matching current global state. + % Any input (copy() passes COPY_TOKEN) is ignored here. obj.rootPath = pf2_base.pf2_defaultRootPath(); end @@ -218,11 +235,13 @@ function loadMethods(obj) end end - % copy() is inherited from matlab.mixin.Copyable: it returns an - % independent instance of the concrete class WITHOUT invoking the - % constructor (so it never re-loads method libraries or touches - % globals). This is what per-worker / per-subject configuration needs, - % since plain assignment (ctx2 = ctx) only aliases the handle: + % copy() returns an independent instance of the concrete class. It + % constructs that class with COPY_TOKEN, which subclass constructors + % must accept and use to skip expensive setup (loading method + % libraries, reading globals), then copies every public property. + % Subclasses that keep state in non-public properties must copy it + % themselves. This is what per-worker / per-subject configuration + % needs, since plain assignment (ctx2 = ctx) only aliases the handle: % % parfor i = 1:N % c = baseCtx.copy(); @@ -230,6 +249,122 @@ function loadMethods(obj) % out{i} = processFNIRS2(data{i}, 'Context', c); % end + function c = copy(obj) + % COPY Independent shallow copy of the context + % + % Returns a new context of the same class with every property + % copied (handle-valued contents are shared, as with + % matlab.mixin.Copyable). Use one copy per parfor iteration; + % plain assignment aliases the same handle. + % + % Syntax: + % c = ctx.copy(); + + if isempty(obj) + c = obj; + return; + end + props = properties(obj(1)); + props = props(~strcmp(props, 'COPY_TOKEN')); + for n = numel(obj):-1:1 + c(n) = feval(class(obj(n)), pf2_base.ProcessingContext.COPY_TOKEN); + for k = 1:numel(props) + c(n).(props{k}) = obj(n).(props{k}); + end + end + c = reshape(c, size(obj)); + end + + function obj = set.dpfMode(obj, v) + v = pf2_base.ProcessingContext.asCharRow(v); + mustBeMember(v, {'None', 'Fixed', 'Calc', 'PPF'}); + obj.dpfMode = v; + end + + function obj = set.dpfFixedValue(obj, v) + v = pf2_base.ProcessingContext.asScalarDouble(v); + mustBePositive(v); + obj.dpfFixedValue = v; + end + + function obj = set.subjectAge(obj, v) + v = pf2_base.ProcessingContext.asScalarDouble(v); + mustBePositive(v); + obj.subjectAge = v; + end + + function obj = set.ppf(obj, v) + obj.ppf = pf2_base.ProcessingContext.asDoubleRow(v); + end + + function obj = set.baselineStartTime(obj, v) + obj.baselineStartTime = pf2_base.ProcessingContext.asScalarDouble(v); + end + + function obj = set.baselineLength(obj, v) + v = pf2_base.ProcessingContext.asScalarDouble(v); + mustBeNonnegative(v); + obj.baselineLength = v; + end + + function obj = set.useAbsoluteTime(obj, v) + obj.useAbsoluteTime = pf2_base.ProcessingContext.asScalarLogical(v); + end + + function obj = set.windowStartTime(obj, v) + obj.windowStartTime = pf2_base.ProcessingContext.asScalarDouble(v); + end + + function obj = set.dirtyBaseline(obj, v) + obj.dirtyBaseline = pf2_base.ProcessingContext.asScalarLogical(v); + end + + function obj = set.rawMethodName(obj, v) + obj.rawMethodName = pf2_base.ProcessingContext.asCharRow(v); + end + + function obj = set.rawMethod(obj, v) + obj.rawMethod = pf2_base.ProcessingContext.asStruct(v); + end + + function obj = set.oxyMethodName(obj, v) + obj.oxyMethodName = pf2_base.ProcessingContext.asCharRow(v); + end + + function obj = set.oxyMethod(obj, v) + obj.oxyMethod = pf2_base.ProcessingContext.asStruct(v); + end + + function obj = set.rawMethodsLib(obj, v) + obj.rawMethodsLib = pf2_base.ProcessingContext.asStruct(v); + end + + function obj = set.oxyMethodsLib(obj, v) + obj.oxyMethodsLib = pf2_base.ProcessingContext.asStruct(v); + end + + function obj = set.device(obj, v) + obj.device = pf2_base.ProcessingContext.asStruct(v); + end + + function obj = set.rejectLevel(obj, v) + v = pf2_base.ProcessingContext.asScalarDouble(v); + pf2_base.ProcessingContext.mustBeInRange(v, 0, 1); + obj.rejectLevel = v; + end + + function obj = set.processRejected(obj, v) + obj.processRejected = pf2_base.ProcessingContext.asScalarLogical(v); + end + + function obj = set.outputLegacyMarkers(obj, v) + obj.outputLegacyMarkers = pf2_base.ProcessingContext.asScalarLogical(v); + end + + function obj = set.rootPath(obj, v) + obj.rootPath = pf2_base.ProcessingContext.asCharRow(v); + end + function s = toStruct(obj) % TOSTRUCT Convert context to a plain struct for saving % @@ -405,12 +540,90 @@ function loadMethods(obj) obj.loadMethods(); end end -end -function mustBeInRange(value, minVal, maxVal) - % Custom validator for rejectLevel - if value < minVal || value > maxVal - error('MATLAB:validators:mustBeInRange', ... - 'Value must be between %g and %g', minVal, maxVal); + methods (Static, Access = private) + % The helpers below follow the order MATLAB applies to a typed + % property declaration: convert to the declared class, then check + % or reshape to the declared size, then run validators. Conversion + % failures raise MATLAB:validation:UnableToConvert and size + % failures MATLAB:validation:IncompatibleSize, as the declarations + % did. + + function v = asCharRow(v) + % As a '(1,:) char' declaration. + v = pf2_base.ProcessingContext.convertTo(v, @char); + v = pf2_base.ProcessingContext.asRow(v); + end + + function v = asScalarDouble(v) + % As a '(1,1) double' declaration. + v = pf2_base.ProcessingContext.convertTo(v, @double); + pf2_base.ProcessingContext.requireScalar(v); + end + + function v = asDoubleRow(v) + % As a '(1,:) double' declaration. + v = pf2_base.ProcessingContext.convertTo(v, @double); + v = pf2_base.ProcessingContext.asRow(v); + end + + function v = asScalarLogical(v) + % As a '(1,1) logical' declaration. + v = pf2_base.ProcessingContext.convertTo(v, @logical); + pf2_base.ProcessingContext.requireScalar(v); + end + + function v = asStruct(v) + % As a 'struct' declaration: structs pass through, and an + % empty numeric, char or logical array becomes a field-less + % struct array of the same size. + if isstruct(v) + return; + end + if isempty(v) && (isnumeric(v) || ischar(v) || islogical(v)) + v = repmat(struct(), size(v)); + return; + end + error('MATLAB:validation:UnableToConvert', ... + 'Value must be a struct.'); + end + + function v = convertTo(v, convertFcn) + % Apply a class conversion, reporting failure as MATLAB does. + try + v = convertFcn(v); + catch + error('MATLAB:validation:UnableToConvert', ... + 'Value must be convertible to %s.', func2str(convertFcn)); + end + end + + function v = asRow(v) + % Reshape to a row when at most one dimension is non-singleton + % (including N-D arrays such as 1x1x3) or the input is 0x0, as + % a '(1,:)' size declaration does; reject anything else. + if sum(size(v) ~= 1) <= 1 || isequal(size(v), [0 0]) + v = reshape(v, 1, []); + else + error('MATLAB:validation:IncompatibleSize', ... + 'Value must be a 1-by-N vector.'); + end + end + + function requireScalar(v) + % Reject non-scalars, as a '(1,1)' size declaration does. + if ~isscalar(v) + error('MATLAB:validation:IncompatibleSize', ... + 'Value must be a scalar.'); + end + end + + function mustBeInRange(value, minVal, maxVal) + % Range validator for rejectLevel. + if value < minVal || value > maxVal + error('MATLAB:validators:mustBeInRange', ... + 'Value must be between %g and %g', minVal, maxVal); + end + end end end diff --git a/+pf2_base/channelLabels.m b/+pf2_base/channelLabels.m new file mode 100644 index 00000000..8a27ad62 --- /dev/null +++ b/+pf2_base/channelLabels.m @@ -0,0 +1,36 @@ +function labels = channelLabels(channels) +% CHANNELLABELS Zero-padded text labels for channel numbers +% +% Builds one label per channel number, zero-padded to the width of the +% largest number ('01', '02', ..., '16'), so labels sort in channel order +% and contain no spaces. Used for channel columns and channel-name fields +% in exported group tables. +% +% Reference: +% Internal pf2 implementation. +% +% Syntax: +% labels = pf2_base.channelLabels(channels) +% +% Inputs: +% channels - Numeric vector of non-negative integer channel numbers. +% +% Outputs: +% labels - Column cell array of char labels, one per channel ({} when +% channels is empty). +% +% Example: +% pf2_base.channelLabels([1 2 10]) % -> {'01'; '02'; '10'} +% +% See also: exploreFNIRS.export.mergeGbyTablesWide, +% exploreFNIRS.export.mergeGbyTablesLong + + channels = channels(:); + if isempty(channels) + labels = cell(0, 1); + return; + end + width = numel(sprintf('%d', max(channels))); + fmt = sprintf('%%0%dd', width); + labels = arrayfun(@(c) sprintf(fmt, c), channels, 'UniformOutput', false); +end diff --git a/+pf2_base/closeProgressHandles.m b/+pf2_base/closeProgressHandles.m index c55c2f5e..b4a8fffd 100644 --- a/+pf2_base/closeProgressHandles.m +++ b/+pf2_base/closeProgressHandles.m @@ -27,7 +27,7 @@ function closeProgressHandles() ProgressHandles.h=struct(); return; else - validFields=fields(ProgressHandles.h); + validFields=fieldnames(ProgressHandles.h); for i=1:length(validFields) if(isvalid(ProgressHandles.h.(validFields{i}))) diff --git a/+pf2_base/ensureStatsFallbacks.m b/+pf2_base/ensureStatsFallbacks.m index 997ffb22..9272386e 100644 --- a/+pf2_base/ensureStatsFallbacks.m +++ b/+pf2_base/ensureStatsFallbacks.m @@ -27,6 +27,10 @@ function ensureStatsFallbacks() % % See also: pf2_base.pf2_initialize, pf2_base.hasStatsToolbox +% Under GNU Octave, load the required packages and MATLAB-function shims +% first (no-op under MATLAB). +pf2_base.env.ensureOctaveCompat(); + % When the toolbox is installed its own nan* functions are on the path and % take precedence; nothing to add. if pf2_base.hasStatsToolbox() @@ -35,7 +39,8 @@ function ensureStatsFallbacks() root = pf2_base.pf2_defaultRootPath(); shimDir = fullfile(root, 'compat_shims'); -if exist(shimDir, 'dir') == 7 && ~contains([pathsep path pathsep], [pathsep shimDir pathsep]) +% strfind rather than contains: this runs before the Octave shims exist. +if exist(shimDir, 'dir') == 7 && isempty(strfind([pathsep path pathsep], [pathsep shimDir pathsep])) addpath(shimDir, '-end'); end diff --git a/+pf2_base/loadDeviceCfg.m b/+pf2_base/loadDeviceCfg.m index c12471ea..b246aa9b 100644 --- a/+pf2_base/loadDeviceCfg.m +++ b/+pf2_base/loadDeviceCfg.m @@ -94,6 +94,11 @@ if ~forceLoad&&~isempty(deviceTable) && any(strcmp(name, deviceTable.Probe)) && loadFromGlobal global setF setF = deviceTable.ProbeInfo(strcmp(name, deviceTable.Probe)); + % MATLAB stores the struct column as a struct array; Octave's datatypes + % keeps it as a cell column. + if iscell(setF) + setF = setF{1}; + end probeInfo = setF.device; return; end @@ -115,7 +120,7 @@ container.device = probeInfo; existingDevice = strcmp(deviceTable.Probe,name); deviceTable(existingDevice,:)=[]; - deviceTable = [deviceTable; {name, container}]; + deviceTable = [deviceTable; cell2table({name, container}, 'VariableNames', {'Probe', 'ProbeInfo'})]; setF.device=probeInfo; end @@ -320,7 +325,7 @@ p.TableCh.ColNumber = (1:length(p.ChannelNumbers))'; p.TableCh.OptodeNumber = p.ChannelNumbers(:); p.TableCh.isTime = p.TableCh.OptodeNumber == 0; - if(nansum(p.TableCh.isTime)>1) + if(sum(p.TableCh.isTime)>1) warning('More than one time column specified, please set only one channel to be # 0. Using first ch 0 column'); firstIdx=find(p.TableCh.isTime); p.TableCh.isTime(firstIdx+1:end)=false; @@ -484,10 +489,12 @@ %Assign labels if(~ismember('Label',p.TableSD.Properties.VariableNames)) + sdLabels=cell(length(p.TableSD.Index),1); for sd=1:length(p.TableSD.Index) - typeLabel=sprintf('%s',p.TableSD.Type(sd)); - p.TableSD.Label{sd}=sprintf('%s%i',typeLabel(1),p.TableSD.Index(sd)); + typeLabel=char(p.TableSD.Type(sd)); + sdLabels{sd}=sprintf('%s%i',typeLabel(1),p.TableSD.Index(sd)); end + p.TableSD.Label=sdLabels; end %Assign source/detector idx @@ -628,13 +635,13 @@ p.TableSD.proj2D=plane2D_sd(:,zeroCoordIndex); end - for c=1:length(p.ChannelList) - chIdxMatch=(find(p.ChannelNumbers==p.ChannelList(c))); - p.TableOpt.Ch(c,1:length(chIdxMatch))=chIdxMatch; - p.TableOpt.wv(c,1:length(chIdxMatch))=p.Wavelength(chIdxMatch); - if(~hasLabel) - p.TableOpt.Label{c}=sprintf('Opt%i', p.ChannelList(c)); - end + % Build the per-optode columns whole, then assign; growing a table + % variable by indexed assignment is MATLAB-only behavior. + [chMap, wvMap, optLabels] = optodeColumnMaps(p); + p.TableOpt.Ch = chMap; + p.TableOpt.wv = wvMap; + if(~hasLabel) + p.TableOpt.Label=optLabels; end p.OptPos3D_mean=nanmean(xyz,1); @@ -664,7 +671,7 @@ % Size the layout off the channel count, not a coordinate column, so a % 2D-only device (no Pos3D -> no OptPos.z) still builds a layout. - p.OptPos.subplot_layout(:)=cell(p.NumOptodes,1); + p.OptPos.subplot_layout=cell(p.NumOptodes,1); p.OptPos.subplot_layout(~p.IsShortSeparation)=p.OptLayout2D(:); if(includeSSchannels) p.OptPos.subplot_layout_ss=p.OptLayout2D_ss(:); @@ -792,13 +799,11 @@ function checkCoordinateBounds(p, coordSys, devLabel) % Per-channel raw-column map + labels (mirrors generateProbeLayout's tail). hasLabel = ismember('Label', p.TableOpt.Properties.VariableNames); - for c = 1:n - chIdxMatch = find(p.ChannelNumbers == p.ChannelList(c)); - p.TableOpt.Ch(c, 1:length(chIdxMatch)) = chIdxMatch; - p.TableOpt.wv(c, 1:length(chIdxMatch)) = p.Wavelength(chIdxMatch); - if ~hasLabel - p.TableOpt.Label{c} = sprintf('Opt%i', p.ChannelList(c)); - end + [chMap, wvMap, optLabels] = optodeColumnMaps(p); + p.TableOpt.Ch = chMap; + p.TableOpt.wv = wvMap; + if ~hasLabel + p.TableOpt.Label = optLabels; end % A PlotStructure grid ([row col channel] rows) becomes a declared layout. @@ -1012,6 +1017,25 @@ function checkCoordinateBounds(p, coordSys, devLabel) end - - - +function [chMap, wvMap, labels] = optodeColumnMaps(p) +% OPTODECOLUMNMAPS Per-optode raw-column indices, wavelengths, and labels +% +% Inputs: +% p - Probe info struct with ChannelList, ChannelNumbers, Wavelength +% +% Outputs: +% chMap - [nOpt x maxWv] raw column index per optode (zero-padded) +% wvMap - [nOpt x maxWv] wavelength per column (zero-padded) +% labels - {nOpt x 1} default labels 'Opt' + + nOpt = length(p.ChannelList); + chMap = zeros(nOpt, 0); + wvMap = zeros(nOpt, 0); + labels = cell(nOpt, 1); + for c = 1:nOpt + chIdxMatch = find(p.ChannelNumbers == p.ChannelList(c)); + chMap(c, 1:length(chIdxMatch)) = chIdxMatch; + wvMap(c, 1:length(chIdxMatch)) = p.Wavelength(chIdxMatch); + labels{c} = sprintf('Opt%i', p.ChannelList(c)); + end +end diff --git a/+pf2_base/loadExistingMaskOrCheck.m b/+pf2_base/loadExistingMaskOrCheck.m index 4a87693c..f64ca80f 100644 --- a/+pf2_base/loadExistingMaskOrCheck.m +++ b/+pf2_base/loadExistingMaskOrCheck.m @@ -62,7 +62,7 @@ if exist(filestr, 'file') == 2 chMaskFile=load(filestr); - maskFields=fields(chMaskFile); + maskFields=fieldnames(chMaskFile); potentiallyValidMasks=find(contains(lower(maskFields),'mask')); % Just accept anything that says mask diff --git a/+pf2_base/mergestructs.m b/+pf2_base/mergestructs.m index 8b75fca1..f62bbc91 100644 --- a/+pf2_base/mergestructs.m +++ b/+pf2_base/mergestructs.m @@ -65,7 +65,7 @@ structOut=inputStruct2; end -structFields2=fields(inputStruct2); +structFields2=fieldnames(inputStruct2); numFields = length(structFields2); diff --git a/+pf2_base/normalizeMarkerDict.m b/+pf2_base/normalizeMarkerDict.m index e0b5648f..7e6b7bbf 100644 --- a/+pf2_base/normalizeMarkerDict.m +++ b/+pf2_base/normalizeMarkerDict.m @@ -69,10 +69,19 @@ error('pf2:normalizeMarkerDict:badCell', ... 'Cell dictionary needs at least two columns {code, label}.'); end - % Parse codes by value, so a text code '49' becomes 49 (not its codepoint) - codes = double(str2double(string(x(:, 1)))); - codes = codes(:); - labels = string(x(:, 2)); + % Parse codes by value, so a text code '49' becomes 49 (not its + % codepoint). Element by element: Octave's string() of a numeric cell + % does not keep one element per row. + codes = nan(size(x, 1), 1); + for r = 1:size(x, 1) + v = x{r, 1}; + if (isnumeric(v) || islogical(v)) && isscalar(v) + codes(r) = double(v); + else + codes(r) = str2double(v); + end + end + labels = reshape(string(x(:, 2)), [], 1); d = table(codes, labels, 'VariableNames', canon); for c = 3:size(x, 2) col = x(:, c); diff --git a/+pf2_base/pf2_initialize.m b/+pf2_base/pf2_initialize.m index fd6a1ffc..bb024da8 100644 --- a/+pf2_base/pf2_initialize.m +++ b/+pf2_base/pf2_initialize.m @@ -83,10 +83,10 @@ function pf2_initialize() if(~isfield(PF2,'defaultRootPath')) [pF2_folder,~,~] = fileparts(mfilename('fullpath')); PF2.defaultRootPath=pf2_base.pf2_defaultRootPath(); - curdir=cd; - cd(PF2.defaultRootPath); - addpath(PF2.defaultRootPath,'base_functions','functions','GUI'); - cd(curdir); + % Absolute paths: Octave's addpath stores relative entries verbatim, so + % they would stop resolving after leaving the root folder. + rootDir=PF2.defaultRootPath; + addpath(rootDir,fullfile(rootDir,'base_functions'),fullfile(rootDir,'functions'),fullfile(rootDir,'GUI')); end PF2.defaultOxyMethodsPath=sprintf('%s/pf2_oxy_methods_stored_processFNIRS2.cfg',prefdir); diff --git a/+pf2_base/pf2version.m b/+pf2_base/pf2version.m index 42466410..e087ee3a 100644 --- a/+pf2_base/pf2version.m +++ b/+pf2_base/pf2version.m @@ -29,8 +29,8 @@ % % See also: pf2_initialize, processFNIRS2 -pf2ver='v1.2.0'; -dateStr='October 9 2026'; +pf2ver='v1.2.1'; +dateStr='October 16 2026'; verString=sprintf('processFNIRS2 Release %s\n',pf2ver); diff --git a/.github/workflows/octave.yml b/.github/workflows/octave.yml new file mode 100644 index 00000000..9127b9b6 --- /dev/null +++ b/.github/workflows/octave.yml @@ -0,0 +1,67 @@ +name: octave + +# GNU Octave compatibility lane for the headless single-subject path: +# package loading, INI reader/writer, device loading, import, default +# processing, and the Pipeline API through a ProcessingContext, with +# numerical results compared against a MATLAB reference +# (+pf2_base/+tests/+data/octave_reference.mat). +# +# Notes: +# - Octave needs no license, so this lane runs on every push and pull request. +# - Root pf2.m (the MATLAB GUI launcher) shadows the +pf2 package under +# Octave, so it is removed from the CI checkout before the run. +# - Regenerate the reference under MATLAB with +# pf2_base.tests.generateOctaveReference() +# whenever an intended change alters device geometry or processing output. +# - Octave packages are installed with -local (the job runs as root, and a +# global install would land outside the cached folder) and cached. The +# install step installs any package missing after the cache restore, so a +# stale or empty cache cannot skip it; bump CACHE_VERSION to force a fresh +# cache. + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + OCTAVE_PACKAGES: datatypes control signal statistics + CACHE_VERSION: 3 + +jobs: + octave-subset: + name: Octave 11.3.0 headless subset + runs-on: ubuntu-latest + timeout-minutes: 45 + container: + image: gnuoctave/octave:11.3.0 + steps: + - uses: actions/checkout@v4 + + - name: Cache Octave packages + id: pkg-cache + uses: actions/cache@v4 + with: + # Package files and Octave's list of installed packages live in + # separate folders; both are needed for pkg to see a restore. + path: | + ~/.local/share/octave + ~/.config/octave + key: octave-11.3.0-pkgs-${{ env.CACHE_VERSION }}-${{ env.OCTAVE_PACKAGES }} + + - name: Install missing Octave packages + run: | + octave-cli --no-gui --eval "need = strsplit(getenv('OCTAVE_PACKAGES')); have = cellfun(@(p) p.name, pkg('list'), 'UniformOutput', false); miss = setdiff(need, have); if isempty(miss), disp('all packages present'); else, printf('installing: %s\n', strjoin(miss, ' ')); pkg('install', '-local', '-forge', miss{:}); end" + + - name: List Octave packages + run: octave-cli --no-gui --eval "pkg list" + + - name: Remove GUI launcher that shadows +pf2 + run: rm -f pf2.m + + - name: Run Octave subset + run: | + octave-cli --no-gui --eval "addpath(pwd); pf2_base.tests.runOctaveSubset()" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2006a873..bd392405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## v1.2.1 (2026-10-16) +Groundwork for running headless single-subject processing under GNU Octave. + +### Added + +- GNU Octave CI (`.github/workflows/octave.yml`) runs `pf2_base.tests.runOctaveSubset` in the `gnuoctave/octave:11.3.0` image on every push and pull request. It covers package loading, the INI reader/writer, every shipped device, import, default processing, the Pipeline API through a `ProcessingContext`, the block-averaging recipe (markers, blocks, epochs, averages, sliding windows) and the QC pipeline, and compares numerical results with a MATLAB reference (`+pf2_base/+tests/+data/octave_reference.mat`, rebuilt with `pf2_base.tests.generateOctaveReference`). The job removes the root `pf2.m` GUI launcher from its checkout, since under Octave it shadows the `+pf2` package. +- `pf2.import.sampleData(name, ...)` loads a named sample dataset (`'fNIR2000'`, `'experiment'`, `'group'`, ...), equivalent to `pf2.import.sampleData.(...)`. Under Octave this is the form that works, since there `sampleData.m` hides the `+sampleData` namespace. +- `pf2_base.args.parse` and `pf2_base.args.check` reproduce arguments-block input handling (optional positional inputs, name-value matching, class conversion, size checks, validators and their error identifiers) in a form Octave can run. +- `pf2_base.env.ensureOctaveCompat` loads the Octave `datatypes`, `signal` and `statistics` packages and adds `compat_shims/octave` (`contains`, `strip`, `strlength`, `extractAfter`, `extractBefore`, `isStringScalar`, two-argument `round`). It runs automatically at the importer, device-loading and initialization entry points and does nothing under MATLAB. +- `pf2_base.env.runtime` now reports missing `datatypes` and `statistics` packages as well as `signal`. + +### Changed + +- `pf2.data.defineBlocks` warns instead of silently returning no blocks when the markers are empty (`pf2:defineBlocks:noMarkers`), when none of the requested marker or end-marker codes occur (`pf2:defineBlocks:codesNotFound`, which lists the codes present and flags non-integer codes as a likely column-order problem), when start codes have no following end code (`pf2:defineBlocks:noPairedBlocks`), and when every block falls outside `MinDuration`/`MaxDuration` (`pf2:defineBlocks:allBlocksFiltered`). Messages name the recording when `info.filename` or `info.SubjectID` is set. +- `pf2_base.external.INI` no longer derives from `dynamicprops`. Sections are held in a private struct and reached through overloaded indexing, so `cfg.
`, `cfg.("Section")`, `get`, `add`, `remove`, `read` and `write` behave as before, including on object arrays and indexed elements. Display lists each section. A section whose name matches a property or method of the class (for example `write` or `Data`) is stored under an `x_` prefix and still reached by its original name through `get`, `remove` and `write`; the old class refused such names. +- `pf2.methods.raw.create` and `pf2.methods.oxy.create` reject method names that match a property or method of `INI`, since method libraries address methods as `cfg.(name)`. +- INI objects saved by earlier versions keep their sections: MATLAB re-attaches the old dynamic properties after loading, and the first access moves them into the new store. Any section still missing is re-read from `File`, with a warning if it cannot be restored. Under Octave, saving and loading classdef objects keeps only their declared properties, so INI section data does not survive a save there. +- `pf2_base.fnirs.processStageOD2Hb` parses its name-value options with `inputParser` instead of an `arguments` block. +- The functions in `pf2.data` (including `pf2.data.aux`) and `pf2.qc` parse their inputs with `pf2_base.args` instead of `arguments` blocks, whose name-value entries Octave cannot parse. Inputs are handled as before: a comparison of the original and new parsing over several thousand input combinations (defaults, positional prefixes, name variants, wrong types, unknown and ambiguous names, unpaired lists) found no difference in values, classes or error identifiers. +- Exported group tables (`Experiment.toWideTable`, `toLongTable`, `writeCSV`, and the default labels of `exploreFNIRS.export.mergeGbyTablesWide`/`mergeGbyTablesLong`) name channels with zero-padded numbers from the new `pf2_base.channelLabels` (`HbO_Opt01`, `Opt01_HbO`). With 10 or more channels these were space-padded (`HbO_Opt 1`); columns now sort in channel order and contain no spaces. LME model labels are unchanged. The wide export's default labels no longer fail when no names are passed. +- `pf2_base.ProcessingContext`, `pf2_base.Pipeline` and `pf2_base.PipelineFunction` declare properties without inline types or validators, which Octave cannot parse. Set methods on `ProcessingContext` and `Pipeline` convert, size-check and validate in the same order as the declarations, with the same MATLAB error identifiers. Under MATLAB, ordinary values (char, string scalars, numeric, logical, structs and empty arrays) convert and error as before. Exceptions: objects such as `datetime`, `categorical` or `table` assigned to a struct-valued `ProcessingContext` property now error instead of being converted with `struct()`; complex values with a zero imaginary part are accepted as real instead of failing `mustBeReal`; and `matlab.lang.OnOffSwitchState` values assigned to logical properties are stored as `logical`. Under Octave, `mustBeMember` and `mustBePositive` raise errors without an identifier. +- `ProcessingContext` derives from `handle` and implements `copy()` itself instead of inheriting `matlab.mixin.Copyable`. `copy()` still returns an independent shallow copy of the same class and size, including `pf2.ProcessingContext` and `pf2_base.GUIContext`, without reloading the method libraries. Subclass constructors receive `ProcessingContext.COPY_TOKEN` from `copy()` and must accept it. + +### Fixes + +- `pf2_base.tests.unit.AllPlotsSmokeTest/testCreateFigure` pins light mode, as `PlotStyleTest` does, so a dark default figure color left by earlier rendering tests no longer fails it. +- `pf2_base.tests.integration.FullPipelineTest/testDifferentMethods` builds its method combinations with the Pipeline API and runs them through a `ProcessingContext`, so it no longer skips on machines without the named methods in their library or changes the global method selection. +- `pf2_base.normalizeMarkerDict` parses `{code, label}` cell dictionaries element by element, so numeric codes keep one row each under Octave. +- `pf2_base.external.zp2sos` orders complex roots with `sortrows` instead of `sort(..., 'ComparisonMethod', 'real')`, which Octave lacks; the resulting second-order sections are identical. +- `pf2.data.split` and `pf2.data.resample` failed on aux signals stored as tables with their own time column (for example a derived HRV series): MATLAB R2025b's `table` rejects a single char name for `'VariableNames'`. The time-column name is now passed as a cell array. v1.1.0 is affected the same way. +- `pf2_base.wavelet.wcoherence` returns NaN when either input has no finite samples or is constant. With cached CWTs (`'CwtX'`/`'CwtY'`) the coefficients of a series containing NaN are NaN, and the clamp `min(wcoh, 1)` turned them into a coherence of exactly 1, because `min` ignores NaN; computed internally, an all-NaN series was zero-filled and gave 0. The clamp now leaves NaN in place. Hyperscanning analyses that cache per-channel CWTs and include a fully rejected channel or ROI were inflated toward 1 for every pair involving it. `WaveletCoherenceTest` covers both paths. +- `pf2.import.importHitachiMES` no longer requests the unused second output of `strsplit`, and `pf2.data.split` assigns its time-size variables directly; both failed under Octave. +- `loadDeviceCfg` and `pf2_base.gui.updateCurrentDevice` assign whole table columns instead of growing them by indexed assignment, and no longer call `sprintf` on a categorical or `nansum` on a logical. +- `bvoxy` declares its positional `coefs` and `isOD` options before its name-value parameters. Argument handling under MATLAB is unchanged. +- Calls to the undocumented `fields` alias use `fieldnames`. +- `pf2.Device` and `pf2_base.PipelineFunction` properties use `SetAccess = private` instead of `immutable`, which Octave does not support. They remain read-only outside the class. +- `processFNIRS2`'s local device helper is renamed so Octave resolves the call to `pf2_base.gui.updateCurrentDevice` instead of recursing into the local function. +- `pf2_initialize` adds the toolbox subfolders by absolute path. Octave stored the relative entries verbatim, so they stopped resolving after the working folder changed. +- Removed a stray editor temporary file (`+pf2_base/+external/.!36603!INI.m`). + ## v1.2.0 (2026-10-09) processFNIRS2 moves to the AyazLab organization. diff --git a/CITATION.cff b/CITATION.cff index eeac5420..b44ac9cd 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -8,8 +8,8 @@ abstract: >- statistics. type: software doi: 10.5281/zenodo.23348858 -version: 1.2.0 -date-released: "2026-10-09" +version: 1.2.1 +date-released: "2026-10-16" license: GPL-3.0-or-later repository-code: "https://github.com/AyazLab/processFNIRS2" url: "https://github.com/AyazLab/processFNIRS2" diff --git a/GUI/processFNIRS2_GUI.m b/GUI/processFNIRS2_GUI.m index ffee414d..5237a968 100644 --- a/GUI/processFNIRS2_GUI.m +++ b/GUI/processFNIRS2_GUI.m @@ -268,7 +268,7 @@ function processFNIRS2_GUI_OpeningFcn(hObject, eventdata, handles, varargin) end - dataFields=fields(data); % copy bio marker fields + dataFields=fieldnames(data); % copy bio marker fields for i=1:length(dataFields) curField=dataFields{i}; for j=1:length(validBioFields) @@ -1032,7 +1032,7 @@ function updateCurrentFiltersListbox(handles) [validFields]=pf2_base.pf2_getFNIRSfields(); if(nargout>0) - fdataFields=fields(PF2.GUIPF2.data); + fdataFields=fieldnames(PF2.GUIPF2.data); for i=1:length(fdataFields) memberIdx=ismember(validFields,fdataFields{i}); if(any(memberIdx)) @@ -1042,7 +1042,7 @@ function updateCurrentFiltersListbox(handles) if(isfield(PF2,'GUIPF2')&&isfield(PF2.GUIPF2,'data')&&isfield(PF2.GUIPF2.data,'stage')&&(size(PF2.GUIPF2.data.stage,2)==5)) if(outputData.ProcessOxy&&~isempty(PF2.GUIPF2.data.stage{5})) - stage5fields=fields(PF2.GUIPF2.data.stage{5}); + stage5fields=fieldnames(PF2.GUIPF2.data.stage{5}); for i=1:length(stage5fields) outfNIR.(stage5fields{i})=PF2.GUIPF2.data.stage{5}.(stage5fields{i}); end diff --git a/README.md b/README.md index 935aca60..9a084246 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![MATLAB](https://img.shields.io/badge/MATLAB-R2025b-blue.svg) ![License](https://img.shields.io/badge/license-GPLv3-blue.svg) -![Version](https://img.shields.io/badge/version-1.2.0-green.svg) +![Version](https://img.shields.io/badge/version-1.2.1-green.svg) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23348858.svg)](https://doi.org/10.5281/zenodo.23348858) A modular MATLAB toolbox for functional Near-Infrared Spectroscopy (fNIRS) data @@ -353,7 +353,7 @@ licenses are documented in [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md). If you use processFNIRS2 in your research, please cite the software (machine-readable metadata is in [CITATION.cff](CITATION.cff)): -> Curtin, A., & Ayaz, H. (2026). *processFNIRS2* (version 1.2.0) +> Curtin, A., & Ayaz, H. (2026). *processFNIRS2* (version 1.2.1) > [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.23348858 The DOI above always resolves to the latest release. To cite the exact version diff --git a/compat_shims/octave/contains.m b/compat_shims/octave/contains.m new file mode 100644 index 00000000..9b8f1d83 --- /dev/null +++ b/compat_shims/octave/contains.m @@ -0,0 +1,59 @@ +function tf = contains(str, pat, varargin) +%CONTAINS True where a string contains a pattern (GNU Octave fallback). +% +% Octave-only shim for MATLAB's CONTAINS, which core Octave does not +% provide. pf2_base.env.ensureOctaveCompat adds this folder to the path +% under Octave only; MATLAB never sees it. +% +% Inputs: +% str - char, cellstr, or string array to search. +% pat - char, cellstr, or string array of patterns. True when any +% pattern occurs. +% varargin - Optional 'IgnoreCase', logical (default false). +% +% Outputs: +% tf - Logical scalar for char input, otherwise logical array the size +% of str. +% +% See also: strfind, startsWith, endsWith + +ignoreCase = false; +for k = 1:2:numel(varargin) + if strcmpi(varargin{k}, 'IgnoreCase') + ignoreCase = logical(varargin{k+1}); + end +end + +isCharInput = ischar(str); +str = toCell(str); +pat = toCell(pat); +if ignoreCase + str = lower(str); + pat = lower(pat); +end + +tf = false(size(str)); +for i = 1:numel(str) + for j = 1:numel(pat) + if isempty(pat{j}) || ~isempty(strfind(str{i}, pat{j})) + tf(i) = true; + break; + end + end +end +if isCharInput + tf = tf(1); +end + +end + +function c = toCell(x) +% TOCELL Normalize char/cellstr/string input to a cellstr. +if ischar(x) + c = {x}; +elseif iscell(x) + c = x; +else + c = cellstr(x); +end +end diff --git a/compat_shims/octave/extractAfter.m b/compat_shims/octave/extractAfter.m new file mode 100644 index 00000000..158c3684 --- /dev/null +++ b/compat_shims/octave/extractAfter.m @@ -0,0 +1,27 @@ +function out = extractAfter(str, pat) +%EXTRACTAFTER Text after the first occurrence of a pattern (GNU Octave fallback). +% +% Octave-only shim for MATLAB's EXTRACTAFTER (text-pattern form only). +% Added to the path by pf2_base.env.ensureOctaveCompat under Octave only. +% +% Inputs: +% str - char row or cellstr. +% pat - char pattern. +% +% Outputs: +% out - Text following the first match; '' when there is no match. +% +% See also: extractBefore, strfind + +if iscell(str) + out = cellfun(@(s) extractAfter(s, pat), str, 'UniformOutput', false); + return; +end +k = strfind(str, pat); +if isempty(k) + out = ''; +else + out = str(k(1) + numel(pat):end); +end + +end diff --git a/compat_shims/octave/extractBefore.m b/compat_shims/octave/extractBefore.m new file mode 100644 index 00000000..ccd11aaa --- /dev/null +++ b/compat_shims/octave/extractBefore.m @@ -0,0 +1,27 @@ +function out = extractBefore(str, pat) +%EXTRACTBEFORE Text before the first occurrence of a pattern (GNU Octave fallback). +% +% Octave-only shim for MATLAB's EXTRACTBEFORE (text-pattern form only). +% Added to the path by pf2_base.env.ensureOctaveCompat under Octave only. +% +% Inputs: +% str - char row or cellstr. +% pat - char pattern. +% +% Outputs: +% out - Text preceding the first match; '' when there is no match. +% +% See also: extractAfter, strfind + +if iscell(str) + out = cellfun(@(s) extractBefore(s, pat), str, 'UniformOutput', false); + return; +end +k = strfind(str, pat); +if isempty(k) + out = ''; +else + out = str(1:k(1) - 1); +end + +end diff --git a/compat_shims/octave/isStringScalar.m b/compat_shims/octave/isStringScalar.m new file mode 100644 index 00000000..a8b479b3 --- /dev/null +++ b/compat_shims/octave/isStringScalar.m @@ -0,0 +1,17 @@ +function tf = isStringScalar(x) +%ISSTRINGSCALAR True for a 1x1 string object (GNU Octave fallback). +% +% Octave-only shim for MATLAB's ISSTRINGSCALAR. Added to the path by +% pf2_base.env.ensureOctaveCompat under Octave only. +% +% Inputs: +% x - Any value. +% +% Outputs: +% tf - Logical scalar; true when x is a string scalar. +% +% See also: isstring, ischar + +tf = isa(x, 'string') && isscalar(x); + +end diff --git a/compat_shims/octave/mustBeA.m b/compat_shims/octave/mustBeA.m new file mode 100644 index 00000000..a3b588d3 --- /dev/null +++ b/compat_shims/octave/mustBeA.m @@ -0,0 +1,32 @@ +function mustBeA(value, classNames) +%MUSTBEA Validate that value is one of the given classes (GNU Octave fallback). +% +% Octave-only shim for MATLAB's MUSTBEA. Passes when isa(value, c) is +% true for any listed class. The category names 'numeric', 'float' and +% 'integer' are accepted, as isa defines them. Added to the path by +% pf2_base.env.ensureOctaveCompat under Octave only. +% +% Inputs: +% value - Value to validate. +% classNames - Class name (char or string) or a list of names (cellstr +% or string array). +% +% Outputs: +% (none) Errors with MATLAB:validators:mustBeA otherwise. +% +% See also: isa + +if ischar(classNames) + classNames = {classNames}; +elseif ~iscell(classNames) + classNames = cellstr(classNames); +end +for k = 1:numel(classNames) + if isa(value, classNames{k}) + return; + end +end +error('MATLAB:validators:mustBeA', 'Value must be one of the following types: %s.', ... + strjoin(strcat('''', classNames(:)', ''''), ', ')); + +end diff --git a/compat_shims/octave/mustBeScalarOrEmpty.m b/compat_shims/octave/mustBeScalarOrEmpty.m new file mode 100644 index 00000000..fc7d2cf3 --- /dev/null +++ b/compat_shims/octave/mustBeScalarOrEmpty.m @@ -0,0 +1,19 @@ +function mustBeScalarOrEmpty(value) +%MUSTBESCALAROREMPTY Validate that value is scalar or empty (GNU Octave fallback). +% +% Octave-only shim for MATLAB's MUSTBESCALAROREMPTY. Added to the path by +% pf2_base.env.ensureOctaveCompat under Octave only. +% +% Inputs: +% value - Value to validate. +% +% Outputs: +% (none) Errors with MATLAB:validators:mustBeScalarOrEmpty otherwise. +% +% See also: isscalar, isempty + +if ~(isscalar(value) || isempty(value)) + error('MATLAB:validators:mustBeScalarOrEmpty', 'Value must be scalar or empty.'); +end + +end diff --git a/compat_shims/octave/mustBeText.m b/compat_shims/octave/mustBeText.m new file mode 100644 index 00000000..9fad39f8 --- /dev/null +++ b/compat_shims/octave/mustBeText.m @@ -0,0 +1,21 @@ +function mustBeText(value) +%MUSTBETEXT Validate that value is text (GNU Octave fallback). +% +% Octave-only shim for MATLAB's MUSTBETEXT: passes for a char array, a +% cell array of char vectors, or a string array. Added to the path by +% pf2_base.env.ensureOctaveCompat under Octave only. +% +% Inputs: +% value - Value to validate. +% +% Outputs: +% (none) Errors with MATLAB:validators:mustBeText when value is not text. +% +% See also: mustBeTextScalar + +if ~(ischar(value) || iscellstr(value) || isa(value, 'string')) + error('MATLAB:validators:mustBeText', ... + 'Value must be text (a character vector, cell array of character vectors, or string array).'); +end + +end diff --git a/compat_shims/octave/mustBeTextScalar.m b/compat_shims/octave/mustBeTextScalar.m new file mode 100644 index 00000000..530ee5f5 --- /dev/null +++ b/compat_shims/octave/mustBeTextScalar.m @@ -0,0 +1,23 @@ +function mustBeTextScalar(value) +%MUSTBETEXTSCALAR Validate that value is a single piece of text (GNU Octave fallback). +% +% Octave-only shim for MATLAB's MUSTBETEXTSCALAR: passes for a char row +% vector (including '') or a string scalar. Added to the path by +% pf2_base.env.ensureOctaveCompat under Octave only. +% +% Inputs: +% value - Value to validate. +% +% Outputs: +% (none) Errors with MATLAB:validators:mustBeTextScalar otherwise. +% +% See also: mustBeText + +ok = (ischar(value) && (isrow(value) || isequal(size(value), [0 0]))) || ... + (isa(value, 'string') && isscalar(value)); +if ~ok + error('MATLAB:validators:mustBeTextScalar', ... + 'Value must be a character vector or string scalar.'); +end + +end diff --git a/compat_shims/octave/round.m b/compat_shims/octave/round.m new file mode 100644 index 00000000..24ad172b --- /dev/null +++ b/compat_shims/octave/round.m @@ -0,0 +1,80 @@ +function y = round(x, n, type) +%ROUND Round with an optional digit count (GNU Octave fallback). +% +% Octave's built-in ROUND accepts only ROUND(X). This shim adds MATLAB's +% ROUND(X, N) (N decimal digits; negative N rounds left of the decimal +% point) and ROUND(X, N, 'decimals'). It defers to the built-in for the +% one-argument form. Added to the path by pf2_base.env.ensureOctaveCompat +% under Octave only. +% +% Inputs: +% x - Numeric array (double or single when n is given). +% n - (optional) Number of decimal digits. +% type - (optional) 'decimals' (default). 'significant' is not +% supported. +% +% Outputs: +% y - Rounded values, same size as x. +% +% See also: floor, ceil, fix + +if nargin < 2 + y = builtin('round', x); + return; +end +if nargin >= 3 + if strcmpi(type, 'significant') + error('pf2:octave:roundSignificant', ... + 'round(x, n, ''significant'') is not supported under Octave.'); + elseif ~strcmpi(type, 'decimals') + error('MATLAB:round:InvalidRoundingType', ... + 'Rounding type must be ''decimals'' or ''significant''.'); + end +end +if ~isfloat(x) + error('MATLAB:round:NonfloatMultipleArgs', ... + 'First argument must be double or single with more than one input.'); +end +if ~(isnumeric(n) && isscalar(n) && isreal(n) && n == fix(n)) + error('MATLAB:round:SecondArg', ... + 'Number of digits must be an integer-valued scalar.'); +end +n = double(n); +% Beyond the double range every value is unchanged (n large) or rounds to +% zero (n very negative); avoid 10^n overflowing to Inf. +if n > 308 + y = x; + return; +elseif n < -308 + y = x .* 0; + return; +end +% Single input is rounded in double precision and cast back. +isSingle = isa(x, 'single'); +x = double(x); +if n >= 0 + scaled = x .* 10^n; +else + scaled = x ./ 10^(-n); +end +y = builtin('round', scaled); +% Decimal ties such as 0.285 or 1.005 are stored slightly below the half +% point, so scaling lands one ulp under .5. MATLAB rounds these away from +% zero; do the same, only for positive n and below 2^52, where a one-ulp +% shortfall can come from the decimal representation. +frac = abs(scaled - fix(scaled)); +tie = n > 0 & abs(frac - 0.5) <= eps(abs(scaled)) & frac < 0.5 & abs(scaled) < 2^52; +y(tie) = fix(scaled(tie)) + sign(scaled(tie)); +if n >= 0 + y = y ./ 10^n; +else + y = y .* 10^(-n); +end +% Where scaling overflowed, x has no digits at that position to round. +overflow = ~isfinite(scaled) & isfinite(x); +y(overflow) = x(overflow); +if isSingle + y = single(y); +end + +end diff --git a/compat_shims/octave/strip.m b/compat_shims/octave/strip.m new file mode 100644 index 00000000..c31ac79c --- /dev/null +++ b/compat_shims/octave/strip.m @@ -0,0 +1,60 @@ +function out = strip(str, side, stripChar) +%STRIP Remove leading and/or trailing characters (GNU Octave fallback). +% +% Octave-only shim for MATLAB's STRIP. Added to the path by +% pf2_base.env.ensureOctaveCompat under Octave only. +% +% Inputs: +% str - char row or cellstr. +% side - (optional) 'both' (default), 'left', or 'right'. Any +% other single character in this position is taken as +% stripChar, as in MATLAB's strip(str, stripChar). +% stripChar - (optional) Single character to remove. Default removes +% whitespace. +% +% Outputs: +% out - Stripped text, same type as str. +% +% See also: strtrim, deblank + +if nargin < 2 || isempty(side) + side = 'both'; +end +if nargin < 3 + stripChar = ''; + if ~any(strcmpi(side, {'both', 'left', 'right'})) + stripChar = char(side); + side = 'both'; + end +end + +if iscell(str) + out = cellfun(@(s) stripOne(s, side, stripChar), str, 'UniformOutput', false); +else + out = stripOne(str, side, stripChar); +end + +end + +function s = stripOne(s, side, stripChar) +% STRIPONE Strip a single char row. +if isempty(stripChar) + keep = ~isspace(s); +else + keep = s ~= stripChar; +end +first = find(keep, 1, 'first'); +last = find(keep, 1, 'last'); +if isempty(first) + s = s([]); + return; +end +switch lower(side) + case 'left' + s = s(first:end); + case 'right' + s = s(1:last); + otherwise + s = s(first:last); +end +end diff --git a/compat_shims/octave/strlength.m b/compat_shims/octave/strlength.m new file mode 100644 index 00000000..28293aaf --- /dev/null +++ b/compat_shims/octave/strlength.m @@ -0,0 +1,22 @@ +function n = strlength(str) +%STRLENGTH Number of characters in each string (GNU Octave fallback). +% +% Octave-only shim for MATLAB's STRLENGTH. Added to the path by +% pf2_base.env.ensureOctaveCompat under Octave only. +% +% Inputs: +% str - char row, cellstr, or string array. +% +% Outputs: +% n - Character count; a scalar for char input, otherwise an array the +% size of str. +% +% See also: numel, length + +if ischar(str) + n = numel(str); +else + n = cellfun(@numel, cellstr(str)); +end + +end diff --git a/docs/CITING.md b/docs/CITING.md index 50c3afaf..f4b0b7b5 100644 --- a/docs/CITING.md +++ b/docs/CITING.md @@ -12,12 +12,13 @@ release is archived on [Zenodo](https://doi.org/10.5281/zenodo.23348858). | [10.5281/zenodo.23348858](https://doi.org/10.5281/zenodo.23348858) | processFNIRS2 as a whole; always resolves to the latest release | Citing the toolbox in general | | Version DOI, listed on the [Zenodo record](https://doi.org/10.5281/zenodo.23348858) under "Versions" | One specific release | Methods sections and code-availability statements, so readers can reproduce your analysis | -Check the version you ran with `pf2_base.pf2version()`. For v1.2.0 the version -DOI is [10.5281/zenodo.23348859](https://doi.org/10.5281/zenodo.23348859). +Check the version you ran with `pf2_base.pf2version()`, then take that release's +version DOI from the "Versions" list on the Zenodo record. Zenodo assigns each +version DOI when the release is published, so the code itself cannot carry it. ## APA -> Curtin, A., & Ayaz, H. (2026). *processFNIRS2* (Version v1.2.0) [Computer +> Curtin, A., & Ayaz, H. (2026). *processFNIRS2* (Version v1.2.1) [Computer > software]. Zenodo. https://doi.org/10.5281/zenodo.23348858 ## BibTeX @@ -26,7 +27,7 @@ DOI is [10.5281/zenodo.23348859](https://doi.org/10.5281/zenodo.23348859). @software{curtin2026processfnirs2, author = {Curtin, Adrian and Ayaz, Hasan}, title = {processFNIRS2}, - version = {v1.2.0}, + version = {v1.2.1}, publisher = {Zenodo}, year = {2026}, doi = {10.5281/zenodo.23348858}, diff --git a/docs/README.md b/docs/README.md index e1ebc5fe..16fa024d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,7 +24,7 @@ below. Runnable tutorials live in If you use processFNIRS2 in your research, please cite it: -> Curtin, A., & Ayaz, H. (2026). *processFNIRS2* (Version v1.2.0) [Computer +> Curtin, A., & Ayaz, H. (2026). *processFNIRS2* (Version v1.2.1) [Computer > software]. Zenodo. https://doi.org/10.5281/zenodo.23348858 See [Citing processFNIRS2](CITING.md) for BibTeX and for choosing between the diff --git a/exploreFNIRS.m b/exploreFNIRS.m index c00e8f9d..a4e27aa9 100644 --- a/exploreFNIRS.m +++ b/exploreFNIRS.m @@ -2228,7 +2228,7 @@ function initializeGUIvalues(handles) end if(isfield(ExFNIRS.data{i},'Aux')) - ExFNIRS.data{i}.info.emptyAux=isempty(ExFNIRS.data{i}.Aux)||isempty(fields(ExFNIRS.data{i}.Aux)); + ExFNIRS.data{i}.info.emptyAux=isempty(ExFNIRS.data{i}.Aux)||isempty(fieldnames(ExFNIRS.data{i}.Aux)); else ExFNIRS.data{i}.info.emptyAux=true; end @@ -4367,7 +4367,7 @@ function setExChannelMode(modeStr,handles,initROI,initAux,initOPT) for i=1:length(ExFNIRS.curPreprocessedFNIR.fNIR) if(pf2_base.isnestedfield(ExFNIRS.curPreprocessedFNIR.fNIR{i},'Aux')) - curAuxNames=fields(ExFNIRS.curPreprocessedFNIR.fNIR{i}.Aux); + curAuxNames=fieldnames(ExFNIRS.curPreprocessedFNIR.fNIR{i}.Aux); if(any(~ismember(curAuxNames,auxNames))) for auxf=1:length(curAuxNames) diff --git a/processFNIRS2.m b/processFNIRS2.m index 5f63860d..e2a3c824 100644 --- a/processFNIRS2.m +++ b/processFNIRS2.m @@ -583,7 +583,7 @@ end - dataFields=fields(data); % copy bio marker fields + dataFields=fieldnames(data); % copy bio marker fields for i=1:length(dataFields) curField=dataFields{i}; for j=1:length(validBioFields) @@ -661,7 +661,7 @@ % Thread the device locally. On the context path (useContext) globals are % left untouched; on the legacy path procDevice mirrors setF.device and the % globals are updated as before for backward compatibility. -[fData, procDevice]=updateCurrentDevice(fData, procDevice, useContext); +[fData, procDevice]=updateCurrentDevice_local(fData, procDevice, useContext); [numDataRows,numDataCols]=size(fData.stage{1}); @@ -799,7 +799,7 @@ end if(nargout>0) - fdataFields=fields(fData); + fdataFields=fieldnames(fData); for i=1:length(fdataFields) memberIdx=ismember(validFields,fdataFields{i}); if(any(memberIdx)) @@ -811,7 +811,7 @@ if(isfield(fData,'stage')&&(size(fData.stage,2)==5)) if(outputData.ProcessOxy&&~isempty(fData.stage{5})) - stage5fields=fields(fData.stage{5}); + stage5fields=fieldnames(fData.stage{5}); for i=1:length(stage5fields) outfNIR.(stage5fields{i})=fData.stage{5}.(stage5fields{i}); end @@ -915,8 +915,8 @@ % UIWAIT makes processFNIRS2 wait for user response (see UIRESUME) % uiwait(handles.figure1); -function [fData, device]=updateCurrentDevice(fData, device, isolated) -% UPDATECURRENTDEVICE Build merged probe tables and resolve timing. +function [fData, device]=updateCurrentDevice_local(fData, device, isolated) +% UPDATECURRENTDEVICE_LOCAL Build merged probe tables and resolve timing. % % Threads the device locally. When ISOLATED is true (a ProcessingContext is % in play) the global PF2/setF caches are NOT written, keeping context-based