diff --git a/+exploreFNIRS/+connectivity/alignMatrices.m b/+exploreFNIRS/+connectivity/alignMatrices.m new file mode 100644 index 00000000..0b4f7be5 --- /dev/null +++ b/+exploreFNIRS/+connectivity/alignMatrices.m @@ -0,0 +1,201 @@ +function [aligned, masterChannels, masterLabels, nValid] = alignMatrices(results, mode) +% ALIGNMATRICES Align connectivity results from subjects with different channels +% +% Maps each subject's/dyad's connectivity result into a common channel- +% indexed grid so that matrix entry (i,j) always represents the same +% channel pair across subjects, regardless of per-subject channel rejection. +% +% Syntax: +% [aligned, masterCh, masterLabels, nValid] = ... +% exploreFNIRS.connectivity.alignMatrices(results, 'union') +% [aligned, masterCh, masterLabels, nValid] = ... +% exploreFNIRS.connectivity.alignMatrices(results, 'intersection') +% [aligned, masterCh, masterLabels, nValid] = ... +% exploreFNIRS.connectivity.alignMatrices(results, 0.75) +% +% Inputs: +% results - Cell array of result structs. Each must have one of: +% Connectivity: .matrix [N x N] and .channels [1 x N] +% Hyperscanning 'same': .values [N x 1] and .channelsA [1 x N] +% Hyperscanning 'all': .values [Na x Nb] and .channelsA, .channelsB +% mode - Alignment mode: +% 'union' - All channels present in any subject (default) +% 'intersection' - Only channels present in every subject +% numeric 0-1 - Channels present in >= mode fraction of subjects +% +% Outputs: +% aligned - 3D array with aligned values. NaN where a subject lacks +% data for a channel. Shape: [M x M x K] for connectivity, +% [M x 1 x K] for hyperscanning 'same', [Ma x Mb x K] for 'all'. +% masterChannels - Master channel vector (or {masterA, masterB} for 'all' pairing) +% masterLabels - Cell array of labels (or {labelsA, labelsB} for 'all' pairing) +% nValid - Per-cell count of subjects contributing a non-NaN value +% +% See also: exploreFNIRS.connectivity.computeMatrix, +% exploreFNIRS.hyperscanning.computeGroup + + if nargin < 2 + mode = 'union'; + end + + K = length(results); + if K == 0 + error('exploreFNIRS:connectivity:alignMatrices', 'Empty results cell array'); + end + + % Detect result shape + isConnectivity = isfield(results{1}, 'matrix'); + isHyperAll = ~isConnectivity && isfield(results{1}, 'channelsB') && ... + isfield(results{1}, 'values') && ~isvector(results{1}.values); + + if isHyperAll + [aligned, masterChannels, masterLabels, nValid] = ... + alignHyperscanningAll(results, K, mode); + elseif isConnectivity + [aligned, masterChannels, masterLabels, nValid] = ... + alignConnectivity(results, K, mode); + else + % Hyperscanning 'same' pairing (vector values) + [aligned, masterChannels, masterLabels, nValid] = ... + alignHyperscanningSame(results, K, mode); + end +end + + +function [aligned, masterCh, masterLabels, nValid] = alignConnectivity(results, K, mode) +% Align NxN connectivity matrices + + % Collect all channel vectors + allChannels = cell(K, 1); + for k = 1:K + allChannels{k} = results{k}.channels(:)'; + end + + masterCh = computeMasterChannels(allChannels, K, mode); + M = length(masterCh); + + % Build aligned 3D array + aligned = nan(M, M, K); + for k = 1:K + [~, masterIdx, subIdx] = intersect(masterCh, allChannels{k}); + aligned(masterIdx, masterIdx, k) = results{k}.matrix(subIdx, subIdx); + end + + nValid = sum(~isnan(aligned), 3); + + % Build labels + masterLabels = buildLabels(results, masterCh, 'labels', 'channels'); +end + + +function [aligned, masterCh, masterLabels, nValid] = alignHyperscanningSame(results, K, mode) +% Align Nx1 hyperscanning 'same' pairing vectors + + allChannels = cell(K, 1); + for k = 1:K + allChannels{k} = results{k}.channelsA(:)'; + end + + masterCh = computeMasterChannels(allChannels, K, mode); + M = length(masterCh); + + aligned = nan(M, 1, K); + for k = 1:K + [~, masterIdx, subIdx] = intersect(masterCh, allChannels{k}); + aligned(masterIdx, 1, k) = results{k}.values(subIdx); + end + + nValid = sum(~isnan(aligned), 3); + + masterLabels = buildLabels(results, masterCh, 'labelsA', 'channelsA'); +end + + +function [aligned, masterChannels, masterLabels, nValid] = alignHyperscanningAll(results, K, mode) +% Align Na x Nb hyperscanning 'all' pairing matrices + + allChA = cell(K, 1); + allChB = cell(K, 1); + for k = 1:K + allChA{k} = results{k}.channelsA(:)'; + allChB{k} = results{k}.channelsB(:)'; + end + + masterA = computeMasterChannels(allChA, K, mode); + masterB = computeMasterChannels(allChB, K, mode); + Ma = length(masterA); + Mb = length(masterB); + + aligned = nan(Ma, Mb, K); + for k = 1:K + [~, mIdxA, sIdxA] = intersect(masterA, allChA{k}); + [~, mIdxB, sIdxB] = intersect(masterB, allChB{k}); + aligned(mIdxA, mIdxB, k) = results{k}.values(sIdxA, sIdxB); + end + + nValid = sum(~isnan(aligned), 3); + + labelsA = buildLabels(results, masterA, 'labelsA', 'channelsA'); + labelsB = buildLabels(results, masterB, 'labelsB', 'channelsB'); + + masterChannels = {masterA, masterB}; + masterLabels = {labelsA, labelsB}; +end + + +function master = computeMasterChannels(allChannels, K, mode) +% Compute the master channel set based on alignment mode + + if ischar(mode) || isstring(mode) + switch lower(char(mode)) + case 'union' + master = allChannels{1}; + for k = 2:K + master = union(master, allChannels{k}); + end + case 'intersection' + master = allChannels{1}; + for k = 2:K + master = intersect(master, allChannels{k}); + end + otherwise + error('exploreFNIRS:connectivity:alignMatrices', ... + 'Unknown alignment mode "%s". Use ''union'', ''intersection'', or a numeric threshold.', char(mode)); + end + elseif isnumeric(mode) && isscalar(mode) && mode > 0 && mode <= 1 + % Threshold mode: channels in >= mode fraction of subjects + all = []; + for k = 1:K + all = union(all, allChannels{k}); + end + counts = zeros(size(all)); + for k = 1:K + counts = counts + ismember(all, allChannels{k}); + end + master = all(counts >= mode * K); + else + error('exploreFNIRS:connectivity:alignMatrices', ... + 'mode must be ''union'', ''intersection'', or a numeric threshold in (0, 1].'); + end + + master = sort(master(:)'); +end + + +function labels = buildLabels(results, masterCh, labelField, chField) +% Build labels for master channel set from the first result that has them + + labels = arrayfun(@(c) sprintf('Ch%d', c), masterCh, 'UniformOutput', false); + + for k = 1:length(results) + if isfield(results{k}, labelField) && ~isempty(results{k}.(labelField)) + subCh = results{k}.(chField)(:)'; + subLabels = results{k}.(labelField); + if iscell(subLabels) + [~, mIdx, sIdx] = intersect(masterCh, subCh); + labels(mIdx) = subLabels(sIdx); + end + break; + end + end +end diff --git a/+exploreFNIRS/+connectivity/computeBetaSeries.m b/+exploreFNIRS/+connectivity/computeBetaSeries.m new file mode 100644 index 00000000..54a34051 --- /dev/null +++ b/+exploreFNIRS/+connectivity/computeBetaSeries.m @@ -0,0 +1,238 @@ +function result = computeBetaSeries(data, blocks, varargin) +% COMPUTEBETASERIES Beta-series correlation connectivity +% +% Computes trial-by-trial GLM beta weights and correlates them across +% channels (or ROIs) to produce a connectivity matrix. Two estimation +% strategies are available: Least Squares All (LSA) fits a single GLM with +% one regressor per trial, while Least Squares Separate (LSS) fits N +% separate GLMs, each isolating one trial from the rest. +% +% Reference: +% Rissman, J., Gazzaley, A., & D'Esposito, M. (2004). Measuring +% functional connectivity during distinct stages of a cognitive task. +% NeuroImage, 23(2), 752-763. +% +% Syntax: +% result = exploreFNIRS.connectivity.computeBetaSeries(data, blocks) +% result = exploreFNIRS.connectivity.computeBetaSeries(data, blocks, ... +% 'Method', 'LSS', 'Correlation', 'spearman') +% +% Inputs: +% data - Processed fNIRS struct with .HbO, .HbR, .time, .fs, .fchMask +% blocks - Struct array from pf2.data.defineBlocks +% +% Name-Value Parameters: +% Method - Estimation method: 'LSA' (default) or 'LSS' +% Biomarker - 'HbO' (default), 'HbR', 'HbTotal', 'HbDiff', 'CBSI' +% Correlation - Correlation type: 'pearson' (default) or 'spearman' +% Condition - Condition name(s) to include (default: all) +% Char or cell array of chars matching blocks.info.Condition +% DriftOrder - Legendre drift polynomial order (default: 3) +% FitMethod - GLM fit method: 'OLS' (default) or 'AR-IRLS' +% UseROI - Use ROI-level data (default: false) +% Channels - Channel subset (default: all good channels) +% +% Outputs: +% result - Struct compatible with computeMatrix output format: +% .matrix - [N x N] correlation matrix of trial betas +% .pmatrix - [N x N] p-value matrix +% .channels - Channel/ROI indices used +% .labels - Cell array of labels +% .method - 'betaseries_LSA' or 'betaseries_LSS' +% .biomarker - Biomarker used +% .useROI - Whether ROI mode was used +% .betas - [nTrials x nCh] trial beta matrix +% .nTrials - Number of trials used +% .trialLabels - Cell array of trial condition labels +% +% Example: +% [subjects, blockDefs] = pf2.import.sampleData.experiment('blocks'); +% d = processFNIRS2(subjects{1}); +% result = exploreFNIRS.connectivity.computeBetaSeries(d, blockDefs{1}); +% exploreFNIRS.connectivity.plotMatrix(result); +% +% See also: exploreFNIRS.connectivity.computeMatrix, +% exploreFNIRS.connectivity.computePPI, pf2_base.fnirs.fitGLM + +% --- Parse inputs --- +p = inputParser; +addRequired(p, 'data', @isstruct); +addRequired(p, 'blocks', @isstruct); +addParameter(p, 'Method', 'LSA', @(x) ischar(x) && ismember(upper(x), {'LSA','LSS'})); +addParameter(p, 'Biomarker', 'HbO', @ischar); +addParameter(p, 'Correlation', 'pearson', @(x) ischar(x) && ismember(lower(x), {'pearson','spearman'})); +addParameter(p, 'Condition', {}, @(x) ischar(x) || iscell(x)); +addParameter(p, 'DriftOrder', 3, @(x) isnumeric(x) && isscalar(x)); +addParameter(p, 'FitMethod', 'OLS', @(x) ischar(x) && ismember(upper(x), {'OLS','AR-IRLS'})); +addParameter(p, 'UseROI', false, @islogical); +addParameter(p, 'Channels', [], @isnumeric); +parse(p, data, blocks, varargin{:}); +opts = p.Results; + +estMethod = upper(opts.Method); +bioM = opts.Biomarker; +corrType = lower(opts.Correlation); + +% Normalize condition filter to cell +condFilter = opts.Condition; +if ischar(condFilter) && ~isempty(condFilter) + condFilter = {condFilter}; +end + +% --- Determine signal and channels --- +if opts.UseROI + if ~isfield(data, 'ROI') || ~isfield(data.ROI, bioM) + error('exploreFNIRS:connectivity:computeBetaSeries', ... + 'ROI data not found. Run defineROI + buildROI first.'); + end + signal = data.ROI.(bioM); + roiNames = {}; + if isfield(data.ROI, 'info') && istable(data.ROI.info) + roiNames = data.ROI.info.Properties.RowNames; + end +else + if ~isfield(data, bioM) + error('exploreFNIRS:connectivity:computeBetaSeries', ... + 'Biomarker "%s" not found in data.', bioM); + end + signal = data.(bioM); + roiNames = {}; +end + +nTotal = size(signal, 2); +if ~isempty(opts.Channels) + channels = opts.Channels; +elseif opts.UseROI + channels = 1:nTotal; +elseif isfield(data, 'fchMask') + channels = find(data.fchMask); +else + channels = 1:nTotal; +end +channels = channels(channels <= nTotal); +nCh = length(channels); + +% --- Filter blocks by condition --- +if ~isempty(condFilter) + keep = false(1, length(blocks)); + for b = 1:length(blocks) + if isfield(blocks(b), 'info') && isfield(blocks(b).info, 'Condition') + keep(b) = ismember(blocks(b).info.Condition, condFilter); + end + end + blocks = blocks(keep); +end + +nTrials = length(blocks); +if nTrials < 2 + error('exploreFNIRS:connectivity:computeBetaSeries', ... + 'Need at least 2 trials for beta-series correlation (got %d).', nTrials); +end + +% Build trial labels +trialLabels = cell(1, nTrials); +for t = 1:nTrials + if isfield(blocks(t), 'info') && isfield(blocks(t).info, 'Condition') + trialLabels{t} = blocks(t).info.Condition; + else + trialLabels{t} = sprintf('trial_%03d', t); + end +end + +% --- Extract trial betas --- +switch estMethod + case 'LSA' + trialBetas = fitLSA(data, blocks, signal, channels, opts); + case 'LSS' + trialBetas = fitLSS(data, blocks, signal, channels, opts); +end + +% --- Correlate trial betas across channels --- +[R, P] = pf2_base.compat.corr(trialBetas, 'Type', corrType, 'Rows', 'pairwise'); + +% --- Build output struct (computeMatrix-compatible) --- +result.matrix = R; +result.pmatrix = P; +result.channels = channels; +result.method = sprintf('betaseries_%s', estMethod); +result.biomarker = bioM; +result.useROI = opts.UseROI; +result.betas = trialBetas; +result.nTrials = nTrials; +result.trialLabels = trialLabels; + +if opts.UseROI && ~isempty(roiNames) + result.labels = roiNames(channels); +else + result.labels = arrayfun(@(c) sprintf('Ch%d', c), channels, ... + 'UniformOutput', false); +end + +end + + +%% Local helper functions + +function trialBetas = fitLSA(data, blocks, signal, channels, opts) +% FITLSA Least Squares All — one regressor per trial in a single GLM + + nTrials = length(blocks); + + % Build per-trial events: each trial is its own regressor + events = struct('name', {}, 'onsets', {}, 'duration', {}, 'amplitude', {}); + for t = 1:nTrials + events(t).name = sprintf('trial_%03d', t); + events(t).onsets = blocks(t).startTime; + events(t).duration = blocks(t).duration; + events(t).amplitude = 1; + end + + % Build design matrix and fit + [X, names] = pf2_base.fnirs.buildDesignMatrix(data.time, data.fs, events, ... + 'DriftOrder', opts.DriftOrder, 'IncludeConstant', true); + glmResult = pf2_base.fnirs.fitGLM(signal(:, channels), X, names, ... + 'Method', opts.FitMethod); + + % Extract trial betas: first nTrials regressors are the trial regressors + trialBetas = glmResult.beta(1:nTrials, :); % [nTrials x nCh] +end + + +function trialBetas = fitLSS(data, blocks, signal, channels, opts) +% FITLSS Least Squares Separate — isolate each trial in its own GLM + + nTrials = length(blocks); + nCh = length(channels); + trialBetas = zeros(nTrials, nCh); + + for t = 1:nTrials + % Build events: isolated trial + all others lumped + evTarget = struct('name', 'target', ... + 'onsets', blocks(t).startTime, ... + 'duration', blocks(t).duration, ... + 'amplitude', 1); + + otherIdx = setdiff(1:nTrials, t); + if ~isempty(otherIdx) + evOther = struct('name', 'others', ... + 'onsets', [blocks(otherIdx).startTime], ... + 'duration', [blocks(otherIdx).duration], ... + 'amplitude', 1); + events = [evTarget, evOther]; + else + events = evTarget; + end + + [X, names] = pf2_base.fnirs.buildDesignMatrix(data.time, data.fs, events, ... + 'DriftOrder', opts.DriftOrder, 'IncludeConstant', true); + glmResult = pf2_base.fnirs.fitGLM(signal(:, channels), X, names, ... + 'Method', opts.FitMethod); + + % Target regressor is first + trialBetas(t, :) = glmResult.beta(1, :); + + if mod(t, 10) == 0 || t == nTrials + fprintf(' LSS: fitted trial %d/%d\n', t, nTrials); + end + end +end diff --git a/+exploreFNIRS/+connectivity/computeDynamicFC.m b/+exploreFNIRS/+connectivity/computeDynamicFC.m new file mode 100644 index 00000000..39942103 --- /dev/null +++ b/+exploreFNIRS/+connectivity/computeDynamicFC.m @@ -0,0 +1,126 @@ +function result = computeDynamicFC(data, varargin) +% COMPUTEDYNAMICFC Time-varying functional connectivity via sliding windows +% +% Computes a sequence of connectivity matrices over a sliding time window, +% capturing how inter-channel coupling evolves over time. +% +% Syntax: +% result = exploreFNIRS.connectivity.computeDynamicFC(data) +% result = exploreFNIRS.connectivity.computeDynamicFC(data, 'WindowSize', 20) +% result = exploreFNIRS.connectivity.computeDynamicFC(data, 'Method', 'spearman') +% +% Inputs: +% data - Processed fNIRS struct with .HbO, .HbR, .time, .fs, .fchMask +% +% Name-Value Parameters: +% Method - Coupling method: 'pearson' (default), 'spearman', 'xcorr', +% 'coherence', 'wcoherence', 'granger', 'transferentropy' +% Biomarker - 'HbO' (default), 'HbR', 'HbTotal', 'HbDiff', 'CBSI' +% WindowSize - Window duration in seconds (default: 30) +% WindowStep - Step size in seconds (default: 5) +% Channels - Channel indices to include (default: all good channels) +% CouplingArgs - Extra args passed to coupling function (default: {}) +% Accelerate - Acceleration mode passed to computeMatrix: 'auto' (default), +% 'gpu', 'parfor', 'none' +% +% Outputs: +% result - Struct with fields: +% .matrices - [C x C x W] connectivity matrices per window +% .windowTimes - [W x 1] center time of each window (seconds) +% .method - Coupling method name +% .biomarker - Biomarker used +% .labels - Cell array of channel labels +% .channels - Channel indices used +% .windowSize - Window duration in seconds +% .windowStep - Step size in seconds +% +% Example: +% data = pf2.import.sampleData.fNIR2000(); +% processed = processFNIRS2(data); +% dfc = exploreFNIRS.connectivity.computeDynamicFC(processed, ... +% 'WindowSize', 20, 'WindowStep', 5); +% imagesc(dfc.matrices(:,:,1)); % First window +% +% References: +% Allen, E. A., Damaraju, E., Plis, S. M., Erhardt, E. B., Eichele, T. +% & Calhoun, V. D. (2014). Tracking whole-brain connectivity dynamics in +% the resting state. Cerebral Cortex, 24(3), 663-676. +% DOI: 10.1093/cercor/bhs352 +% +% Hutchison, R. M., Womelsdorf, T., Allen, E. A., et al. (2013). Dynamic +% functional connectivity: promise, issues, and interpretations. +% NeuroImage, 80, 360-378. DOI: 10.1016/j.neuroimage.2013.05.079 +% +% See also: exploreFNIRS.connectivity.computeMatrix, +% exploreFNIRS.connectivity.detectStates, +% exploreFNIRS.connectivity.plotDynamicFC + + p = inputParser; + addRequired(p, 'data', @isstruct); + addParameter(p, 'Method', 'pearson', @ischar); + addParameter(p, 'Biomarker', 'HbO', @ischar); + addParameter(p, 'WindowSize', 30, @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(p, 'WindowStep', 5, @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'CouplingArgs', {}, @iscell); + addParameter(p, 'Accelerate', 'auto', @(x) ischar(x) && ismember(lower(x), {'auto','gpu','parfor','none'})); + parse(p, data, varargin{:}); + opts = p.Results; + + timeVec = data.time(:); + tStart = timeVec(1); + tEnd = timeVec(end); + duration = tEnd - tStart; + + if opts.WindowSize > duration + error('exploreFNIRS:connectivity:computeDynamicFC', ... + 'WindowSize (%.1f s) exceeds data duration (%.1f s)', ... + opts.WindowSize, duration); + end + + % Compute window start times + winStarts = tStart:opts.WindowStep:(tEnd - opts.WindowSize); + nWin = length(winStarts); + + if nWin < 1 + error('exploreFNIRS:connectivity:computeDynamicFC', ... + 'No complete windows fit in the data. Reduce WindowSize or WindowStep.'); + end + + % Compute first window to determine matrix size + firstResult = exploreFNIRS.connectivity.computeMatrix(data, ... + 'Method', opts.Method, 'Biomarker', opts.Biomarker, ... + 'Channels', opts.Channels, ... + 'TimeWindow', [winStarts(1), winStarts(1) + opts.WindowSize], ... + 'CouplingArgs', opts.CouplingArgs, ... + 'Accelerate', opts.Accelerate); + + nCh = size(firstResult.matrix, 1); + matrices = nan(nCh, nCh, nWin); + matrices(:, :, 1) = firstResult.matrix; + + windowTimes = zeros(nWin, 1); + windowTimes(1) = winStarts(1) + opts.WindowSize / 2; + + % Compute remaining windows + for w = 2:nWin + tWin = [winStarts(w), winStarts(w) + opts.WindowSize]; + res = exploreFNIRS.connectivity.computeMatrix(data, ... + 'Method', opts.Method, 'Biomarker', opts.Biomarker, ... + 'Channels', opts.Channels, ... + 'TimeWindow', tWin, ... + 'CouplingArgs', opts.CouplingArgs, ... + 'Accelerate', opts.Accelerate); + matrices(:, :, w) = res.matrix; + windowTimes(w) = winStarts(w) + opts.WindowSize / 2; + end + + result.matrices = matrices; + result.windowTimes = windowTimes; + result.method = opts.Method; + result.biomarker = opts.Biomarker; + result.labels = firstResult.labels; + result.channels = firstResult.channels; + result.windowSize = opts.WindowSize; + result.windowStep = opts.WindowStep; +end diff --git a/+exploreFNIRS/+connectivity/computeInterROI.m b/+exploreFNIRS/+connectivity/computeInterROI.m new file mode 100644 index 00000000..6cec04c4 --- /dev/null +++ b/+exploreFNIRS/+connectivity/computeInterROI.m @@ -0,0 +1,86 @@ +function result = computeInterROI(data, varargin) +% COMPUTEINTERROI Between-ROI pairwise coupling analysis +% +% Computes pairwise coupling between all ROI-averaged time series. This is +% a convenience wrapper around computeMatrix with UseROI=true, provided for +% clarity and discoverability in ROI-level analyses. +% +% Requires data to have ROI-level time series (data.ROI.) and +% ROI definitions (data.ROI.info). Generate these by running defineROI and +% buildROI (pf2_build_nanmean_ROI) before calling this function. +% +% Syntax: +% result = exploreFNIRS.connectivity.computeInterROI(data) +% result = exploreFNIRS.connectivity.computeInterROI(data, 'Method', 'spearman') +% result = exploreFNIRS.connectivity.computeInterROI(data, ... +% 'Biomarker', 'HbR', 'TimeWindow', [5, 30]) +% +% Inputs: +% data - Processed fNIRS struct with .ROI. (ROI-averaged time +% series) and .ROI.info (table with ROI names as RowNames) +% +% Name-Value Parameters: +% Method - Coupling method: 'pearson' (default), 'spearman', 'xcorr', +% 'coherence', 'wcoherence' +% Biomarker - Biomarker to use: 'HbO' (default), 'HbR', 'HbTotal', +% 'HbDiff', 'CBSI' +% TimeWindow - [start, end] in seconds to restrict analysis (default: [] = full) +% CouplingArgs - Cell array of extra args passed to coupling function (default: {}) +% +% Outputs: +% result - Struct with fields: +% .matrix - [nROI x nROI] symmetric coupling matrix +% .pmatrix - [nROI x nROI] p-value matrix +% .labels - Cell array of ROI names +% .method - Coupling method name +% .biomarker - Biomarker used +% .channels - ROI indices (1:nROI) +% .useROI - true +% .nSamples - Number of time samples used +% +% Example: +% data = pf2.import.sampleData.fNIR2000(); +% processed = processFNIRS2(data); +% processed = pf2.probe.roi.defineROI(processed, {1:6, 7:12, 13:18}, ... +% {'Left', 'Center', 'Right'}); +% processed = pf2_build_nanmean_ROI(processed); +% result = exploreFNIRS.connectivity.computeInterROI(processed, ... +% 'Method', 'pearson'); +% disp(result.matrix); +% disp(result.labels); +% +% References: +% Rubinov, M. & Sporns, O. (2010). Complex network measures of brain +% connectivity: Uses and interpretations. NeuroImage, 52(3), 1059-1069. +% DOI: 10.1016/j.neuroimage.2009.10.003 +% +% See also: exploreFNIRS.connectivity.computeMatrix, +% exploreFNIRS.connectivity.computeIntraROI, +% exploreFNIRS.connectivity.plotInterROI, +% pf2.probe.roi.defineROI, pf2_build_nanmean_ROI + + p = inputParser; + addRequired(p, 'data', @isstruct); + addParameter(p, 'Method', 'pearson', @ischar); + addParameter(p, 'Biomarker', 'HbO', @ischar); + addParameter(p, 'TimeWindow', [], @(v) isnumeric(v) && (isempty(v) || length(v) == 2)); + addParameter(p, 'CouplingArgs', {}, @iscell); + parse(p, data, varargin{:}); + opts = p.Results; + + % Build argument list for computeMatrix + args = {'UseROI', true, ... + 'Method', opts.Method, ... + 'Biomarker', opts.Biomarker}; + + if ~isempty(opts.TimeWindow) + args = [args, {'TimeWindow', opts.TimeWindow}]; + end + + if ~isempty(opts.CouplingArgs) + args = [args, {'CouplingArgs', opts.CouplingArgs}]; + end + + % Delegate to computeMatrix with UseROI=true + result = exploreFNIRS.connectivity.computeMatrix(data, args{:}); +end diff --git a/+exploreFNIRS/+connectivity/computeIntraROI.m b/+exploreFNIRS/+connectivity/computeIntraROI.m new file mode 100644 index 00000000..53da13e3 --- /dev/null +++ b/+exploreFNIRS/+connectivity/computeIntraROI.m @@ -0,0 +1,195 @@ +function result = computeIntraROI(data, varargin) +% COMPUTEINTRAROI Within-ROI pairwise channel coupling analysis +% +% For each ROI, extracts the constituent channels and computes pairwise +% coupling between all channel pairs within that ROI. Produces summary +% statistics (mean, SD) and the full within-ROI coupling matrix. +% +% Syntax: +% result = exploreFNIRS.connectivity.computeIntraROI(data) +% result = exploreFNIRS.connectivity.computeIntraROI(data, 'Method', 'spearman') +% result = exploreFNIRS.connectivity.computeIntraROI(data, ... +% 'Biomarker', 'HbR', 'TimeWindow', [5, 30]) +% +% Inputs: +% data - Processed fNIRS struct with biomarker fields (.HbO, .HbR, etc.), +% .time, .fs, and ROI definitions in data.ROI.info (table with +% 'Optodes' column containing cell array of channel indices per ROI) +% +% Name-Value Parameters: +% Method - Coupling method: 'pearson' (default), 'spearman', 'xcorr', +% 'coherence', 'wcoherence', 'granger', 'transferentropy' +% Biomarker - Biomarker to use: 'HbO' (default), 'HbR', 'HbTotal', +% 'HbDiff', 'CBSI' +% TimeWindow - [start, end] in seconds to restrict analysis (default: [] = full) +% CouplingArgs - Cell array of extra args passed to coupling function (default: {}) +% +% Outputs: +% result - Struct with fields: +% .roiMetrics - [1 x nROI] struct array, each containing: +% .meanCoupling - Mean of upper triangle of within-ROI coupling matrix +% .sdCoupling - SD of upper triangle of within-ROI coupling matrix +% .matrix - [nChannels x nChannels] within-ROI coupling matrix +% .channels - Channel indices belonging to this ROI +% .roiName - Name of this ROI (from ROI info table) +% .method - Coupling method used +% +% Example: +% data = pf2.import.sampleData.fNIR2000(); +% processed = processFNIRS2(data); +% processed = pf2.probe.roi.defineROI(processed, {1:6, 7:12, 13:18}, ... +% {'Left', 'Center', 'Right'}); +% result = exploreFNIRS.connectivity.computeIntraROI(processed, ... +% 'Method', 'pearson', 'Biomarker', 'HbO'); +% disp(result.roiMetrics(1).meanCoupling); +% +% References: +% Rubinov, M. & Sporns, O. (2010). Complex network measures of brain +% connectivity: Uses and interpretations. NeuroImage, 52(3), 1059-1069. +% DOI: 10.1016/j.neuroimage.2009.10.003 +% +% See also: exploreFNIRS.connectivity.computeMatrix, +% exploreFNIRS.connectivity.computeInterROI, +% exploreFNIRS.connectivity.plotIntraROI + + p = inputParser; + addRequired(p, 'data', @isstruct); + addParameter(p, 'Method', 'pearson', @ischar); + addParameter(p, 'Biomarker', 'HbO', @ischar); + addParameter(p, 'TimeWindow', [], @(v) isnumeric(v) && (isempty(v) || length(v) == 2)); + addParameter(p, 'CouplingArgs', {}, @iscell); + parse(p, data, varargin{:}); + opts = p.Results; + + bioM = opts.Biomarker; + + % Validate ROI definitions exist + if ~isfield(data, 'ROI') || ~isfield(data.ROI, 'info') || ~istable(data.ROI.info) + error('exploreFNIRS:connectivity:computeIntraROI', ... + 'ROI definitions not found. data.ROI.info must be a table with a Channels column.'); + end + + roiInfo = data.ROI.info; + if ~ismember('Optodes', roiInfo.Properties.VariableNames) + error('exploreFNIRS:connectivity:computeIntraROI', ... + 'ROI info table must contain an "Optodes" column.'); + end + + % Validate biomarker field + if ~isfield(data, bioM) + error('exploreFNIRS:connectivity:computeIntraROI', ... + 'Biomarker "%s" not found in data.', bioM); + end + + signal = data.(bioM); % [T x C] + timeVec = data.time; + fs = data.fs; + + % Apply time window + if ~isempty(opts.TimeWindow) + tMask = timeVec >= opts.TimeWindow(1) & timeVec <= opts.TimeWindow(2); + signal = signal(tMask, :); + end + + % Get coupling function handle + couplingFn = getCouplingFn(opts.Method); + + % Get ROI names and channel lists + roiNames = roiInfo.Properties.RowNames; + nROIs = height(roiInfo); + + roiMetrics = struct('meanCoupling', {}, 'sdCoupling', {}, ... + 'matrix', {}, 'channels', {}, 'roiName', {}); + + for r = 1:nROIs + chIdx = roiInfo.Optodes{r}; + if ~isnumeric(chIdx) + chIdx = cell2mat(chIdx); + end + nCh = length(chIdx); + + % Detect directed methods + isDirected = ismember(lower(opts.Method), {'granger', 'transferentropy'}); + + % Compute pairwise coupling within this ROI + mat = nan(nCh, nCh); + if isDirected + for i = 1:nCh + mat(i, i) = 0; + for j = 1:nCh + if i == j, continue; end + xi = signal(:, chIdx(i)); + xj = signal(:, chIdx(j)); + if all(isnan(xi)) || all(isnan(xj)) + continue; + end + res = couplingFn(xi, xj, fs, opts.CouplingArgs{:}); + val = res.value; + if res.windowed + val = mean(val, 'omitnan'); + end + mat(i, j) = val; + end + end + else + for i = 1:nCh + mat(i, i) = 1; + for j = (i+1):nCh + xi = signal(:, chIdx(i)); + xj = signal(:, chIdx(j)); + if all(isnan(xi)) || all(isnan(xj)) + continue; + end + res = couplingFn(xi, xj, fs, opts.CouplingArgs{:}); + val = res.value; + if res.windowed + val = mean(val, 'omitnan'); + end + mat(i, j) = val; + mat(j, i) = val; + end + end + end + + % Extract off-diagonal values for summary statistics + if isDirected + offMask = ~eye(nCh, 'logical'); + else + offMask = triu(true(nCh), 1); + end + offVals = mat(offMask); + + roiMetrics(r).meanCoupling = mean(offVals, 'omitnan'); + roiMetrics(r).sdCoupling = std(offVals, 'omitnan'); + roiMetrics(r).matrix = mat; + roiMetrics(r).channels = chIdx; + roiMetrics(r).roiName = roiNames{r}; + end + + result.roiMetrics = roiMetrics; + result.method = opts.Method; +end + + +function fn = getCouplingFn(method) +% Resolve coupling method name to function handle + switch lower(method) + case 'pearson' + fn = @exploreFNIRS.coupling.pearson; + case 'spearman' + fn = @exploreFNIRS.coupling.spearman; + case 'xcorr' + fn = @exploreFNIRS.coupling.xcorr; + case 'coherence' + fn = @exploreFNIRS.coupling.coherence; + case 'wcoherence' + fn = @exploreFNIRS.coupling.wcoherence; + case 'granger' + fn = @exploreFNIRS.coupling.granger; + case 'transferentropy' + fn = @exploreFNIRS.coupling.transferEntropy; + otherwise + error('exploreFNIRS:connectivity:computeIntraROI', ... + 'Unknown coupling method "%s".', method); + end +end diff --git a/+exploreFNIRS/+connectivity/computeMatrix.m b/+exploreFNIRS/+connectivity/computeMatrix.m new file mode 100644 index 00000000..8b8d9c4b --- /dev/null +++ b/+exploreFNIRS/+connectivity/computeMatrix.m @@ -0,0 +1,712 @@ +function result = computeMatrix(data, varargin) +% COMPUTEMATRIX Compute channel-to-channel or ROI-to-ROI connectivity matrix +% +% Calculates pairwise coupling between all channels (or ROIs) of a single +% fNIRS dataset, producing a symmetric connectivity matrix. For directed +% methods (granger, transferentropy), produces an asymmetric matrix. +% +% Syntax: +% result = exploreFNIRS.connectivity.computeMatrix(data) +% result = exploreFNIRS.connectivity.computeMatrix(data, 'Method', 'spearman') +% result = exploreFNIRS.connectivity.computeMatrix(data, 'UseROI', true) +% result = exploreFNIRS.connectivity.computeMatrix(data, 'Biomarker', 'HbR', ... +% 'Channels', 1:10, 'TimeWindow', [5, 25]) +% +% Inputs: +% data - Processed fNIRS struct with .HbO, .HbR, .time, .fs, .fchMask +% For ROI mode: must also have .ROI.HbO, .ROI.info +% +% Name-Value Parameters: +% Method - Coupling method: 'pearson' (default), 'spearman', 'xcorr', +% 'coherence', 'wcoherence', 'granger', 'transferentropy', +% 'partialcorr', 'mutualinfo' +% Biomarker - Biomarker to use: 'HbO' (default), 'HbR', 'HbTotal', 'HbDiff', 'CBSI' +% Channels - Channel/ROI indices to include (default: all good channels or all ROIs) +% TimeWindow - [start, end] in seconds to restrict analysis (default: full range) +% CouplingArgs - Cell array of extra args passed to coupling function (default: {}) +% UseROI - Use ROI-level data instead of channels (default: false) +% Requires data.ROI. and data.ROI.info to exist. +% Accelerate - Acceleration mode: 'auto' (default), 'gpu', 'parfor', 'none' +% 'auto' selects GPU batch for pearson/spearman when GPU is available, +% parfor for other methods when pool is running and nPairs > 20. +% +% Outputs: +% result - Struct with fields: +% .matrix - [N x N] symmetric coupling matrix (NaN for bad entries) +% .pmatrix - [N x N] p-value matrix +% .channels - Channel/ROI indices used +% .labels - Cell array of labels (ROI names when UseROI=true) +% .method - Coupling method name +% .biomarker - Biomarker used +% .nSamples - Number of time samples used +% .useROI - Whether ROI mode was used +% +% Example: +% data = pf2.import.sampleData.fNIR2000(); +% processed = processFNIRS2(data); +% result = exploreFNIRS.connectivity.computeMatrix(processed, ... +% 'Method', 'pearson', 'Biomarker', 'HbO'); +% imagesc(result.matrix); +% +% % ROI-level connectivity +% processed = pf2.probe.roi.defineROI(processed, {[1:6],[7:12],[13:18]}, ... +% {'Left','Center','Right'}); +% processed = pf2_build_nanmean_ROI(processed); +% result = exploreFNIRS.connectivity.computeMatrix(processed, ... +% 'UseROI', true, 'Method', 'pearson'); +% +% References: +% Rubinov, M. & Sporns, O. (2010). Complex network measures of brain +% connectivity: Uses and interpretations. NeuroImage, 52(3), 1059-1069. +% DOI: 10.1016/j.neuroimage.2009.10.003 +% +% Scholkmann, F., Holper, L., Wolf, U. & Wolf, M. (2013). A new +% methodical approach in neuroscience: assessing inter-personal brain +% coupling using functional near-infrared imaging (fNIRI) hyperscanning. +% Frontiers in Human Neuroscience, 7, 813. +% DOI: 10.3389/fnhum.2013.00813 +% +% See also: exploreFNIRS.coupling.pearson, exploreFNIRS.connectivity.plotMatrix, +% pf2.probe.roi.defineROI, pf2_build_nanmean_ROI + + p = inputParser; + addRequired(p, 'data', @isstruct); + addParameter(p, 'Method', 'pearson', @ischar); + addParameter(p, 'Biomarker', 'HbO', @ischar); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'TimeWindow', [], @(v) isnumeric(v) && (isempty(v) || length(v) == 2)); + addParameter(p, 'CouplingArgs', {}, @iscell); + addParameter(p, 'UseROI', false, @islogical); + addParameter(p, 'Accelerate', 'auto', @(x) ischar(x) && ismember(lower(x), {'auto','gpu','parfor','none'})); + parse(p, data, varargin{:}); + opts = p.Results; + accelMode = lower(opts.Accelerate); + + bioM = opts.Biomarker; + + if opts.UseROI + % ROI mode: use data.ROI. + if ~isfield(data, 'ROI') || ~isfield(data.ROI, bioM) + error('exploreFNIRS:connectivity:computeMatrix', ... + 'ROI data not found. Run defineROI + buildROI first.'); + end + signal = data.ROI.(bioM); % [T x R] + roiNames = {}; + if isfield(data.ROI, 'info') && istable(data.ROI.info) + roiNames = data.ROI.info.Properties.RowNames; + end + else + % Channel mode + if ~isfield(data, bioM) + error('exploreFNIRS:connectivity:computeMatrix', ... + 'Biomarker "%s" not found in data', bioM); + end + signal = data.(bioM); % [T x C] + roiNames = {}; + end + + timeVec = data.time; + fs = data.fs; + + % Apply time window + if ~isempty(opts.TimeWindow) + tMask = timeVec >= opts.TimeWindow(1) & timeVec <= opts.TimeWindow(2); + signal = signal(tMask, :); + end + + nSamples = size(signal, 1); + nTotal = size(signal, 2); + + % Determine channels/ROIs to include + if ~isempty(opts.Channels) + channels = opts.Channels; + elseif opts.UseROI + channels = 1:nTotal; + elseif isfield(data, 'fchMask') + channels = find(data.fchMask); + else + channels = 1:nTotal; + end + channels = channels(channels <= nTotal); + nCh = length(channels); + + % Detect directed methods (asymmetric: compute both i->j and j->i) + methodLower = lower(opts.Method); + isDirected = ismember(methodLower, {'granger', 'transferentropy'}); + isBatchable = ismember(methodLower, {'pearson', 'spearman'}); + isBatchWcoh = strcmp(methodLower, 'wcoherence'); + isBatchPartialCorr = strcmp(methodLower, 'partialcorr'); + + % Determine acceleration strategy + if nCh > 1 + nPairs = nCh * (nCh - 1); + if ~isDirected + nPairs = nPairs / 2; + end + else + nPairs = 0; + end + + useGPU = false; + useParfor = false; + + if isBatchable && ~isDirected + % Pearson/Spearman can be done as a single matrix multiply + switch accelMode + case 'auto' + gpuInfo = pf2_base.accel.isGPUAvailable(); + useGPU = gpuInfo.available && nCh >= 4; + case 'gpu' + useGPU = true; + case 'parfor' + % parfor doesn't help for batch matrix ops, fall through to serial + case 'none' + % serial + end + else + % Other methods: parfor over pairs + switch accelMode + case 'auto' + [canPf, poolOn] = pf2_base.accel.canParfor(); + useParfor = canPf && poolOn && nPairs > 20; + case 'parfor' + [canPf, ~] = pf2_base.accel.canParfor(); + useParfor = canPf; + case 'gpu' + % GPU doesn't help for these methods; fall back to parfor if possible + [canPf, poolOn] = pf2_base.accel.canParfor(); + useParfor = canPf && poolOn; + case 'none' + % serial + end + end + + % Compute pairwise coupling + if isBatchPartialCorr + % Batch precision-matrix path for partial correlation + [matrix, pmatrix] = computeBatchPartialCorr(signal, channels, nCh, nSamples); + elseif isBatchable && ~isDirected + % Batch matrix path for pearson/spearman (vectorized, works on CPU or GPU) + [matrix, pmatrix] = computeBatchCorrelation(signal, channels, nCh, nSamples, methodLower, useGPU); + elseif isBatchWcoh + % Batch CWT path for wcoherence: pre-compute CWT once per channel, + % then derive pairwise coherence from pre-computed transforms + [matrix, pmatrix] = computeBatchWcoherence(signal, channels, nCh, fs, opts); + elseif useParfor + [matrix, pmatrix] = computeParfor(signal, channels, nCh, fs, opts, isDirected); + else + [matrix, pmatrix] = computeSerial(signal, channels, nCh, fs, opts, isDirected); + end + + result.matrix = matrix; + result.pmatrix = pmatrix; + result.channels = channels; + result.method = opts.Method; + result.biomarker = bioM; + result.nSamples = nSamples; + result.useROI = opts.UseROI; + + % Build labels + if opts.UseROI && ~isempty(roiNames) + result.labels = roiNames(channels); + else + result.labels = arrayfun(@(c) sprintf('Ch%d', c), channels, ... + 'UniformOutput', false); + end +end + + +function [matrix, pmatrix] = computeBatchWcoherence(signal, channels, nCh, fs, opts) +% COMPUTEBATCHWCOHERENCE Batch wavelet coherence via pre-computed CWT +% +% Pre-computes the CWT for all channels once, pre-computes smoothed +% auto-spectra S(|Wxx|^2) once per channel, then derives pairwise +% coherence needing only one cross-spectrum + smooth per pair. +% For N channels this computes N CWTs + N auto-smooths instead of +% N*(N-1)/2 pairs * (2 CWTs + 2 auto-smooths) each. + + S = signal(:, channels); + + % Parse CouplingArgs for wcoherence-specific parameters + couplingArgs = opts.CouplingArgs; + wcohOpts = {}; + if ~isempty(couplingArgs) + wcohOpts = couplingArgs; + end + + % Extract VoicesPerOctave from coupling args if present + vpo = 10; % default + for k = 1:2:length(wcohOpts) + if ischar(wcohOpts{k}) && strcmpi(wcohOpts{k}, 'VoicesPerOctave') + vpo = wcohOpts{k+1}; + end + end + + % Pre-compute CWT for all channels at once (single precision for speed) + cwtResult = pf2_base.wavelet.cwt(S, fs, 'VoicesPerOctave', vpo, 'Precision', 'single'); + % cwtResult.coeffs is [F x T x nCh] + + matrix = nan(nCh); + pmatrix = nan(nCh); + + freqs = cwtResult.freqs; + scales = cwtResult.scales; + coi = cwtResult.coi; + + % Extract smoothFactor from coupling args (default 1) + smoothFactor = 1; + for k = 1:2:length(wcohOpts) + if ischar(wcohOpts{k}) && strcmpi(wcohOpts{k}, 'SmoothFactor') + smoothFactor = wcohOpts{k+1}; + end + end + + % Pre-compute smoothed auto-spectra for ALL channels + % This is the key optimization: S(|Wxx|^2) is computed once per channel + % instead of once per pair involving that channel. + smoothedAuto = cell(nCh, 1); + for i = 1:nCh + Wi = cwtResult.coeffs(:, :, i); % [F x T] + smoothedAuto{i} = smoothCWTLocal(abs(Wi).^2, scales, fs, smoothFactor); + end + + % Build individual CWT structs for wcoherence (share metadata) + baseCwt = struct('freqs', freqs, 'scales', scales, ... + 'coi', coi, 'fs', cwtResult.fs, ... + 'omega0', cwtResult.omega0); + + for i = 1:nCh + matrix(i, i) = 1; + pmatrix(i, i) = 0; + + cwtI = baseCwt; + cwtI.coeffs = cwtResult.coeffs(:, :, i); + + for j = (i+1):nCh + if all(isnan(S(:, i))) || all(isnan(S(:, j))) + continue; + end + + cwtJ = baseCwt; + cwtJ.coeffs = cwtResult.coeffs(:, :, j); + + % Pass pre-computed smoothed auto-spectra to skip redundant smoothing + res = pf2_base.wavelet.wcoherence(S(:, i), S(:, j), fs, ... + 'CwtX', cwtI, 'CwtY', cwtJ, ... + 'SmoothedAutoX', smoothedAuto{i}, ... + 'SmoothedAutoY', smoothedAuto{j}, ... + wcohOpts{:}); + + matrix(i, j) = res.value; + matrix(j, i) = res.value; + pmatrix(i, j) = res.pvalue; + pmatrix(j, i) = res.pvalue; + end + end +end + + +function S = smoothCWTLocal(W, scales, fs, smoothFactor) +% Local copy of CWT smoothing for pre-computing auto-spectra. +% Time: FFT-based Gaussian convolution. Scale: 0.6-octave boxcar. + + [nF, T] = size(W); + dt = 1 / fs; + isRealW = isreal(W); + + nfftSmooth = 2^nextpow2(T + max(ceil(3 * smoothFactor * scales / dt))); + Wf = fft(W, nfftSmooth, 2); + + S = zeros(nF, T, 'like', W); + for fi = 1:nF + sigma_t = smoothFactor * scales(fi) / dt; + halfWidth = ceil(3 * sigma_t); + if halfWidth < 1 + S(fi, :) = W(fi, 1:T); + continue; + end + halfWidth = min(halfWidth, floor(T/2)); + + kernel = zeros(1, nfftSmooth, 'like', real(W(1))); + kernel(1:halfWidth+1) = exp(-(0:halfWidth).^2 / (2 * sigma_t^2)); + kernel(end-halfWidth+1:end) = kernel(halfWidth+1:-1:2); + kernel = kernel / sum(kernel); + kernelF = fft(kernel, nfftSmooth); + + smoothed = ifft(Wf(fi, :) .* kernelF, nfftSmooth); + if isRealW + S(fi, :) = real(smoothed(1:T)); + else + S(fi, :) = smoothed(1:T); + end + end + + scaleSmooth = 0.6; + log2scales = log2(scales); + Sout = S; + for fi = 1:nF + mask = abs(log2scales - log2scales(fi)) <= scaleSmooth / 2; + if sum(mask) > 1 + Sout(fi, :) = mean(S(mask, :), 1); + end + end + S = Sout; +end + + +function [matrix, pmatrix] = computeBatchCorrelation(signal, channels, nCh, nSamples, method, useGPU) +% COMPUTEBATCHCORRELATION Vectorized NxN correlation via matrix multiply +% +% For pearson: standardize columns, then R = (S'*S) / (n-1) +% For spearman: rank-transform each column first, then same formula + + S = signal(:, channels); + + % Remove rows with any NaN + validRows = all(~isnan(S), 2); + S = S(validRows, :); + n = size(S, 1); + + if n < 3 + matrix = nan(nCh); + pmatrix = nan(nCh); + return; + end + + % For spearman: convert to ranks (handles ties via tiedrank) + if strcmp(method, 'spearman') + for col = 1:nCh + S(:, col) = pf2_base.compat.tiedrank(S(:, col)); + end + end + + % Standardize: zero mean, unit std + S = S - mean(S, 1); + colStd = std(S, 0, 1); + + % Handle zero-variance columns + zeroVar = colStd < eps; + colStd(zeroVar) = 1; % avoid division by zero + S = S ./ colStd; + + % GPU transfer + if useGPU + [S, ~] = pf2_base.accel.toGPU(S, 'Force', true); + end + + % Correlation matrix in one multiply + matrix = (S' * S) / (n - 1); + matrix = pf2_base.accel.gather(matrix); + + % Mark zero-variance channels as NaN + matrix(zeroVar, :) = NaN; + matrix(:, zeroVar) = NaN; + + % Clamp to [-1, 1] for numerical safety + matrix = max(min(matrix, 1), -1); + + % Set diagonal + for i = 1:nCh + matrix(i, i) = 1; + end + + % P-values via t-statistic: t = r * sqrt((n-2)/(1-r^2)) + % Compute only off-diagonal entries to avoid division by zero on diagonal + pmatrix = zeros(nCh); + offDiag = ~eye(nCh, 'logical'); + r_off = matrix(offDiag); + r2_off = min(r_off .^ 2, 1 - eps); + tstat_off = r_off .* sqrt((n - 2) ./ (1 - r2_off)); + pmatrix(offDiag) = 2 * pf2_base.compat.tcdf(-abs(tstat_off), n - 2); + + % NaN for zero-variance + pmatrix(zeroVar, :) = NaN; + pmatrix(:, zeroVar) = NaN; +end + + +function [matrix, pmatrix] = computeBatchPartialCorr(signal, channels, nCh, nSamples) +% COMPUTEBATCHPARTIALCORR Partial correlation via precision matrix inversion +% +% Computes partial correlations for all channel pairs simultaneously by +% inverting the covariance matrix. The partial correlation between channels +% i and j is: r_ij = -P(i,j) / sqrt(P(i,i) * P(j,j)) where P = inv(C). + + S = signal(:, channels); + + % Remove NaN rows (listwise deletion) + validRows = ~any(isnan(S), 2); + Sv = S(validRows, :); + n = size(Sv, 1); + + matrix = nan(nCh, nCh); + pmatrix = nan(nCh, nCh); + + if n < nCh + 2 + % Not enough observations for precision matrix + return; + end + + % Covariance matrix + C = cov(Sv); + + % Regularize if near-singular (Ledoit-Wolf shrinkage) + condNum = cond(C); + if condNum > 1e10 || any(eig(C) < eps) + % Shrinkage: C_reg = (1 - alpha)*C + alpha*diag(diag(C)) + alpha = 0.01; + C = (1 - alpha) * C + alpha * diag(diag(C)); + end + + % Precision matrix + P = inv(C); %#ok - intentional; inversion is the algorithm + + % Partial correlation: r_ij = -P(i,j) / sqrt(P(i,i) * P(j,j)) + d = sqrt(diag(P)); + pcorr = -P ./ (d * d'); + + % Diagonal = 1 by definition + pcorr(logical(eye(nCh))) = 1; + + % Clamp to [-1, 1] + pcorr = max(min(pcorr, 1), -1); + + matrix = pcorr; + + % p-values via t-distribution: df = n - nCh (not n-2) + df = n - nCh; + if df > 0 + tStat = pcorr .* sqrt(df ./ (1 - pcorr.^2 + eps)); + pmatrix = 2 * (1 - pf2_base.compat.tcdf(abs(tStat), df)); + pmatrix(logical(eye(nCh))) = 0; + else + pmatrix = nan(nCh, nCh); + pmatrix(logical(eye(nCh))) = 0; + end + + % NaN out channels that were all-NaN in original + for ch = 1:nCh + if all(isnan(signal(:, channels(ch)))) + matrix(ch, :) = NaN; + matrix(:, ch) = NaN; + pmatrix(ch, :) = NaN; + pmatrix(:, ch) = NaN; + end + end +end + + +function [matrix, pmatrix] = computeParfor(signal, channels, nCh, fs, opts, isDirected) +% COMPUTEPARFOR Parallel pairwise coupling via parfor + + couplingFn = getCouplingFn(opts.Method); + + matrix = nan(nCh, nCh); + pmatrix = nan(nCh, nCh); + + if isDirected + % All ordered pairs (i,j) where i ~= j + pairs = zeros(nCh * (nCh - 1), 2); + idx = 0; + for i = 1:nCh + for j = 1:nCh + if i ~= j + idx = idx + 1; + pairs(idx, :) = [i, j]; + end + end + end + pairs = pairs(1:idx, :); + + % Extract signals for parfor (avoid broadcast of full matrix) + sigCh = signal(:, channels); + + nPairs = size(pairs, 1); + vals = nan(nPairs, 1); + pvals = nan(nPairs, 1); + + parfor k = 1:nPairs + xi = sigCh(:, pairs(k, 1)); + xj = sigCh(:, pairs(k, 2)); + + if all(isnan(xi)) || all(isnan(xj)) + continue; + end + + res = couplingFn(xi, xj, fs, opts.CouplingArgs{:}); + val = res.value; + pval = res.pvalue; + if res.windowed + val = mean(val, 'omitnan'); + pval = combinePvalues(pval); + end + vals(k) = val; + pvals(k) = pval; + end + + % Fill matrix + for i = 1:nCh + matrix(i, i) = 0; + pmatrix(i, i) = 1; + end + for k = 1:nPairs + matrix(pairs(k, 1), pairs(k, 2)) = vals(k); + pmatrix(pairs(k, 1), pairs(k, 2)) = pvals(k); + end + else + % Upper triangle pairs + pairs = nchoosek(1:nCh, 2); + sigCh = signal(:, channels); + + nPairs = size(pairs, 1); + vals = nan(nPairs, 1); + pvals = nan(nPairs, 1); + + parfor k = 1:nPairs + xi = sigCh(:, pairs(k, 1)); + xj = sigCh(:, pairs(k, 2)); + + if all(isnan(xi)) || all(isnan(xj)) + continue; + end + + res = couplingFn(xi, xj, fs, opts.CouplingArgs{:}); + val = res.value; + pval = res.pvalue; + if res.windowed + val = mean(val, 'omitnan'); + pval = combinePvalues(pval); + end + vals(k) = val; + pvals(k) = pval; + end + + % Fill symmetric matrix + for i = 1:nCh + matrix(i, i) = 1; + pmatrix(i, i) = 0; + end + for k = 1:nPairs + i = pairs(k, 1); + j = pairs(k, 2); + matrix(i, j) = vals(k); + matrix(j, i) = vals(k); + pmatrix(i, j) = pvals(k); + pmatrix(j, i) = pvals(k); + end + end +end + + +function [matrix, pmatrix] = computeSerial(signal, channels, nCh, fs, opts, isDirected) +% COMPUTESERIAL Original serial pairwise coupling loop + + couplingFn = getCouplingFn(opts.Method); + + matrix = nan(nCh, nCh); + pmatrix = nan(nCh, nCh); + + if isDirected + % Directed: iterate all pairs (i,j) where i ~= j + for i = 1:nCh + matrix(i, i) = 0; + pmatrix(i, i) = 1; + for j = 1:nCh + if i == j, continue; end + xi = signal(:, channels(i)); + xj = signal(:, channels(j)); + + if all(isnan(xi)) || all(isnan(xj)) + continue; + end + + res = couplingFn(xi, xj, fs, opts.CouplingArgs{:}); + val = res.value; + pval = res.pvalue; + + if res.windowed + val = mean(val, 'omitnan'); + pval = combinePvalues(pval); + end + + matrix(i, j) = val; + pmatrix(i, j) = pval; + end + end + else + % Symmetric: upper triangle only, mirror to lower + for i = 1:nCh + matrix(i, i) = 1; + pmatrix(i, i) = 0; + for j = (i+1):nCh + xi = signal(:, channels(i)); + xj = signal(:, channels(j)); + + % Skip if either channel is all NaN + if all(isnan(xi)) || all(isnan(xj)) + continue; + end + + res = couplingFn(xi, xj, fs, opts.CouplingArgs{:}); + val = res.value; + pval = res.pvalue; + + % For windowed results, take the mean + if res.windowed + val = mean(val, 'omitnan'); + pval = combinePvalues(pval); + end + + matrix(i, j) = val; + matrix(j, i) = val; + pmatrix(i, j) = pval; + pmatrix(j, i) = pval; + end + end + end +end + + +function fn = getCouplingFn(method) +% Resolve coupling method name to function handle + switch lower(method) + case 'pearson' + fn = @exploreFNIRS.coupling.pearson; + case 'spearman' + fn = @exploreFNIRS.coupling.spearman; + case 'xcorr' + fn = @exploreFNIRS.coupling.xcorr; + case 'coherence' + fn = @exploreFNIRS.coupling.coherence; + case 'wcoherence' + fn = @exploreFNIRS.coupling.wcoherence; + case 'granger' + fn = @exploreFNIRS.coupling.granger; + case 'transferentropy' + fn = @exploreFNIRS.coupling.transferEntropy; + case 'hbica' + fn = @exploreFNIRS.coupling.hbica; + case 'partialcorr' + fn = @exploreFNIRS.coupling.partialCorr; + case 'mutualinfo' + fn = @exploreFNIRS.coupling.mutualInfo; + otherwise + error('exploreFNIRS:connectivity:computeMatrix', ... + 'Unknown coupling method "%s". Use: pearson, spearman, xcorr, coherence, wcoherence, granger, transferentropy, hbica, partialcorr, mutualinfo', method); + end +end + + +function p = combinePvalues(pvals) +% Combine p-values using Fisher's method (chi-squared test) + pvals = pvals(~isnan(pvals)); + if isempty(pvals) + p = NaN; + return; + end + % Clamp to eps to avoid log(0) = -Inf + pvals = max(pvals, eps); + chi2stat = -2 * sum(log(pvals)); + df = 2 * length(pvals); + p = 1 - chi2cdf(chi2stat, df); +end diff --git a/+exploreFNIRS/+connectivity/computePPI.m b/+exploreFNIRS/+connectivity/computePPI.m new file mode 100644 index 00000000..a8c34037 --- /dev/null +++ b/+exploreFNIRS/+connectivity/computePPI.m @@ -0,0 +1,431 @@ +function result = computePPI(data, blocks, seedChannels, varargin) +% COMPUTEPPI Psychophysiological Interaction connectivity analysis +% +% Tests whether functional coupling between a seed region and target +% channels changes as a function of task condition. Implements generalized +% PPI (gPPI) by fitting a GLM that includes: (1) one HRF-convolved task +% regressor per condition (psychological main effects), (2) the seed time +% course (physiological main effect), and (3) one seed x condition +% interaction term per condition (the PPI terms). The contrast of interest +% (e.g. Hard vs Easy) is then a linear contrast across the per-condition PPI +% terms. +% +% Interaction term construction: the PPI regressor for a condition is formed +% in NEURAL/psychological space, not in measured hemodynamic space. The seed +% is multiplied by the condition's UN-convolved task boxcar -- NOT by the +% HRF-convolved task regressor -- because convolution does not distribute over +% the pointwise product (HRF(seed .* boxcar) ~= HRF(seed) .* HRF(boxcar)), so +% multiplying two already-convolved signals does not estimate a neural +% interaction. With Deconvolve=true the seed is first deconvolved to a neural +% estimate, the product is formed, and the result is re-convolved with the HRF +% (McLaren et al. 2012 gPPI). With Deconvolve=false the measured seed is +% multiplied by the boxcar directly (classic Friston et al. 1997 PPI, computed +% in measured space without re-convolution). +% +% gPPI design rationale: forming a single psychological regressor as +% HRF(condA - condB) and a single interaction makes that psychological +% column an exact linear combination of the per-condition task regressors, +% so the design becomes rank deficient and the interaction beta is not an +% interpretable partialled effect. Building one interaction per condition +% (McLaren et al., 2012) keeps the design full rank and yields per-condition +% PPI slopes that can be contrasted directly. +% +% The seed is mean-centered before forming the interaction terms (so the +% interaction captures condition-dependent *changes* in coupling, not the +% mean coupling), following standard PPI practice. +% +% Reference: +% McLaren, D. G., Ries, M. L., Xu, G., & Johnson, S. C. (2012). +% A generalized form of context-dependent psychophysiological interactions +% (gPPI): a comparison to standard approaches. NeuroImage, 61(4), 1277-1286. +% DOI: 10.1016/j.neuroimage.2012.03.068 +% +% Syntax: +% result = exploreFNIRS.connectivity.computePPI(data, blocks, seedChannels) +% result = exploreFNIRS.connectivity.computePPI(data, blocks, [1 2 3], ... +% 'Contrast', {'Hard', 'Easy'}, 'Biomarker', 'HbO') +% result = exploreFNIRS.connectivity.computePPI(data, blocks, seedChannels, ... +% 'SeedData', speaker) % cross-brain seed (speaker -> listener) +% result = exploreFNIRS.connectivity.computePPI(data, blocks, [], ... +% 'SeedSignal', hrvOnGrid) % external continuous seed (e.g. HRV) +% +% Inputs: +% data - Processed fNIRS struct with .HbO, .HbR, .time, .fs, .fchMask. +% Supplies the TARGET channels (and the seed too, unless an +% external seed is given via SeedData/SeedSignal). +% blocks - Struct array from pf2.data.defineBlocks +% seedChannels - Scalar or vector of seed channel indices. If multiple, the +% seed time course is the mean across channels. May be [] +% when 'SeedSignal' is supplied. +% +% Name-Value Parameters: +% Biomarker - 'HbO' (default), 'HbR', 'HbTotal', 'HbDiff', 'CBSI' +% Contrast - Task contrast specification: +% Cell pair {'condA', 'condB'}: condA=+1, condB=-1 +% Single string 'cond': condition vs implicit baseline +% (default: first two conditions, sorted) +% SeedData - Processed fNIRS struct to draw the seed from instead of +% `data` (default: []). Enables CROSS-BRAIN PPI: e.g. pass the +% speaker's struct to test speaker-seed -> listener-target +% coupling. Must share the target time grid (same number of +% samples). The seed is taken from SeedData at seedChannels. +% SeedSignal - Arbitrary continuous seed time series [T x 1] or [T x k] +% aligned to data.time (default: []). When supplied, the seed +% is this signal (mean across columns if k>1) and seedChannels +% is ignored. Use for a physiological seed such as EKG-derived +% HRV aligned via pf2.data.auxOnGrid. +% DriftOrder - Legendre drift polynomial order (default: 3) +% FitMethod - GLM fit method: 'OLS' (default) or 'AR-IRLS' +% Deconvolve - Wiener deconvolution of seed before interaction (default: +% false). Note: the deconvolution uses a fixed-fraction noise +% estimate and is a simplified estimator; leave off unless you +% have characterized it for your data. +% Channels - Target channel subset (default: all good channels) +% UseROI - Use ROI-level data for targets (default: false) +% SeedROI - Use ROI index for seed instead of channel (default: false) +% +% Outputs: +% result - Struct with fields: +% .ppi_beta - [1 x nTargets] PPI CONTRAST beta (e.g. Hard-Easy +% modulation of seed->target coupling) +% .ppi_tstat - [1 x nTargets] t-statistics for the PPI contrast +% .ppi_pval - [1 x nTargets] p-values for the PPI contrast +% .matrix - [1 x nTargets] PPI contrast betas (plot compatibility) +% .pmatrix - [1 x nTargets] PPI contrast p-values (plot compatibility) +% .ppiConditions - Cell array of condition names with PPI terms +% .ppiBetaPerCondition - [nConditions x nTargets] per-condition PPI betas +% .contrastVector - [1 x P] contrast applied to the PPI terms +% .channels - Target channel/ROI indices +% .labels - Cell array of target labels +% .seedChannels - Seed channel indices used ([] when SeedSignal used) +% .seedSource - 'data' | 'SeedData' | 'SeedSignal' +% .method - 'PPI' +% .biomarker - Biomarker used +% .useROI - Whether ROI mode was used for targets +% .contrast - Contrast specification used +% .fullResults - Full fitGLM results struct (includes .contrast) +% .designMatrix - Extended design matrix used +% .regressorNames - Names of all regressors +% +% Example: +% [subjects, blockDefs] = pf2.import.sampleData.experiment('blocks'); +% d = processFNIRS2(subjects{1}); +% result = exploreFNIRS.connectivity.computePPI(d, blockDefs{1}, 1:3, ... +% 'Contrast', {'Hard', 'Easy'}); +% bar(result.ppi_beta); +% xlabel('Target Channel'); ylabel('PPI Beta (Hard-Easy)'); +% +% See also: exploreFNIRS.connectivity.computeBetaSeries, +% exploreFNIRS.connectivity.computeMatrix, pf2_base.fnirs.fitGLM + +% --- Parse inputs --- +p = inputParser; +addRequired(p, 'data', @isstruct); +addRequired(p, 'blocks', @isstruct); +addRequired(p, 'seedChannels', @(x) isnumeric(x)); +addParameter(p, 'Biomarker', 'HbO', @ischar); +addParameter(p, 'Contrast', {}, @(x) ischar(x) || iscell(x)); +addParameter(p, 'SeedData', [], @(x) isempty(x) || isstruct(x)); +addParameter(p, 'SeedSignal', [], @isnumeric); +addParameter(p, 'DriftOrder', 3, @(x) isnumeric(x) && isscalar(x)); +addParameter(p, 'FitMethod', 'OLS', @(x) ischar(x) && ismember(upper(x), {'OLS','AR-IRLS'})); +addParameter(p, 'Deconvolve', false, @islogical); +addParameter(p, 'Channels', [], @isnumeric); +addParameter(p, 'UseROI', false, @islogical); +addParameter(p, 'SeedROI', false, @islogical); +parse(p, data, blocks, seedChannels, varargin{:}); +opts = p.Results; + +bioM = opts.Biomarker; +T = length(data.time); + +% --- Extract target signal matrix --- +if opts.UseROI + if ~isfield(data, 'ROI') || ~isfield(data.ROI, bioM) + error('exploreFNIRS:connectivity:computePPI:noROI', ... + 'ROI data not found. Run defineROI + buildROI first.'); + end + targetSignal = data.ROI.(bioM); + roiNames = {}; + if isfield(data.ROI, 'info') && istable(data.ROI.info) + roiNames = data.ROI.info.Properties.RowNames; + end +else + if ~isfield(data, bioM) + error('exploreFNIRS:connectivity:computePPI:noBiomarker', ... + 'Biomarker "%s" not found in data.', bioM); + end + targetSignal = data.(bioM); + roiNames = {}; +end + +% --- Extract seed signal --- +% Priority: explicit SeedSignal > SeedData struct > the target data struct. +if ~isempty(opts.SeedSignal) + ss = opts.SeedSignal; + if size(ss, 1) ~= T + error('exploreFNIRS:connectivity:computePPI:seedLength', ... + 'SeedSignal must have %d rows (one per data.time sample); got %d.', ... + T, size(ss, 1)); + end + seedSig = mean(ss, 2, 'omitnan'); + seedSource = 'SeedSignal'; +else + if isempty(opts.SeedData) + seedStruct = data; + seedSource = 'data'; + else + seedStruct = opts.SeedData; + seedSource = 'SeedData'; + end + + if isempty(seedChannels) + error('exploreFNIRS:connectivity:computePPI:noSeed', ... + 'Provide seedChannels (or a SeedSignal) to define the seed.'); + end + + if opts.SeedROI + if ~isfield(seedStruct, 'ROI') || ~isfield(seedStruct.ROI, bioM) + error('exploreFNIRS:connectivity:computePPI:noSeedROI', ... + 'ROI data not found for seed. Run defineROI + buildROI first.'); + end + seedMat = seedStruct.ROI.(bioM); + else + if ~isfield(seedStruct, bioM) + error('exploreFNIRS:connectivity:computePPI:noSeedBiomarker', ... + 'Biomarker "%s" not found in seed data.', bioM); + end + seedMat = seedStruct.(bioM); + end + + if size(seedMat, 1) ~= T + error('exploreFNIRS:connectivity:computePPI:seedLength', ... + ['Seed data has %d samples but targets have %d. Align/resample ' ... + 'the seed to the target time grid before computing PPI.'], ... + size(seedMat, 1), T); + end + if min(seedChannels) < 1 || max(seedChannels) > size(seedMat, 2) + error('exploreFNIRS:connectivity:computePPI:seedChannelRange', ... + 'seedChannels out of range for seed data (%d columns).', ... + size(seedMat, 2)); + end + + seedSig = mean(seedMat(:, seedChannels), 2, 'omitnan'); +end + +% Fill any gaps in the seed so the interaction terms are well defined. A +% continuous external seed (e.g. windowed HRV) can carry NaNs at the edges. +if any(isnan(seedSig)) + nFilled = sum(isnan(seedSig)); + seedSig = fillmissing(seedSig, 'linear', 'EndValues', 'nearest'); + warning('exploreFNIRS:connectivity:computePPI:seedGaps', ... + 'Seed contained %d NaN sample(s); filled by linear interpolation.', ... + nFilled); +end + +% Determine target channels +nTotal = size(targetSignal, 2); +if ~isempty(opts.Channels) + targetCh = opts.Channels; +elseif opts.UseROI + targetCh = 1:nTotal; +elseif isfield(data, 'fchMask') + targetCh = find(data.fchMask); +else + targetCh = 1:nTotal; +end +targetCh = targetCh(targetCh <= nTotal); +nTargets = length(targetCh); %#ok + +% --- Determine contrast conditions --- +contrastSpec = opts.Contrast; +if ischar(contrastSpec) && ~isempty(contrastSpec) + contrastSpec = {contrastSpec}; +end + +% Auto-detect conditions from blocks if contrast not specified +if isempty(contrastSpec) + condNames = {}; + for b = 1:length(blocks) + if isfield(blocks(b), 'info') && isfield(blocks(b).info, 'Condition') + cond = blocks(b).info.Condition; + % Coerce to char the same way blocksToEvents names conditions, so + % auto-detected names match the per-condition task regressors (and + % numeric conditions do not error in ismember against a cellstr). + if isnumeric(cond) + cond = num2str(cond); + else + cond = char(cond); + end + if ~ismember(cond, condNames) + condNames{end+1} = cond; %#ok + end + end + end + condNames = sort(condNames); % deterministic order across subjects + if length(condNames) >= 2 + contrastSpec = condNames(1:2); + elseif length(condNames) == 1 + contrastSpec = condNames(1); + else + error('exploreFNIRS:connectivity:computePPI:noConditions', ... + 'Could not auto-detect conditions. Specify ''Contrast'' explicitly.'); + end +end + +% --- Optionally deconvolve seed (recover neural estimate) --- +hrfData = pf2_base.fnirs.buildHRF(data.fs); +hrf = hrfData(:, 2); +if opts.Deconvolve + seedNeural = wienerDeconv(seedSig, hrf, data.fs); +else + seedNeural = seedSig; +end + +% Mean-center the seed for the interaction terms (standard PPI practice). +seedCentered = seedNeural - mean(seedNeural, 'omitnan'); + +% --- Build standard task design matrix (one HRF column per condition) --- +events = pf2.data.blocksToEvents(blocks, 'GroupBy', 'Condition'); +[Xtask, taskNames] = pf2_base.fnirs.buildDesignMatrix(data.time, data.fs, events, ... + 'DriftOrder', opts.DriftOrder, 'IncludeConstant', true); + +condList = {events.name}; + +% --- Un-convolved psychological boxcars (one per condition) --- +% gPPI forms the interaction in NEURAL/psychological space: the seed is +% multiplied by the condition's UN-convolved task boxcar, NOT by the +% HRF-convolved task regressor in Xtask. Re-build the design with a unit +% impulse "HRF" (so conv(stim,1) returns the raw boxcar) and no drift/constant. +[boxcars, boxNames] = pf2_base.fnirs.buildDesignMatrix(data.time, data.fs, events, ... + 'HRF', 1, 'DriftOrder', -1, 'IncludeConstant', false); + +% --- Build one seed x condition interaction per condition (gPPI) --- +ppiColumns = zeros(T, numel(condList)); +ppiNames = cell(1, numel(condList)); +for c = 1:numel(condList) + boxCol = find(strcmp(boxNames, condList{c}), 1); + if isempty(boxCol) + error('exploreFNIRS:connectivity:computePPI:missingCondition', ... + 'Condition "%s" has no task regressor; cannot form its PPI term.', ... + condList{c}); + end + % Interaction in neural space: (centered) seed x psychological boxcar. + psi = seedCentered .* boxcars(:, boxCol); + if opts.Deconvolve + % McLaren gPPI: re-convolve the neural-space product to hemodynamics. + cv = conv(psi, hrf); + psi = cv(1:T); + end + ppiColumns(:, c) = psi; + ppiNames{c} = ['PPI_' condList{c}]; +end + +% --- Assemble extended design matrix --- +% [ task regressors + drift | seed main effect | per-condition PPI terms ] +X = [Xtask, seedCentered, ppiColumns]; +regressorNames = [taskNames, {'seed'}, ppiNames]; + +% --- Collinearity guard --- +% With the per-condition gPPI design this should be full rank; warn if not +% so a degenerate design (e.g. a near-constant seed, or conditions that never +% co-occur with the seed) is not silently absorbed by the pseudoinverse. +rankX = rank(X); +if rankX < size(X, 2) + warning('exploreFNIRS:connectivity:computePPI:rankDeficient', ... + ['PPI design matrix is rank deficient (rank %d < %d columns); beta ' ... + 'estimates rely on the pseudoinverse and may be uninterpretable. ' ... + 'Check for collinear conditions or a near-constant seed.'], ... + rankX, size(X, 2)); +end + +% --- Build the PPI contrast across the per-condition interaction terms --- +C = zeros(1, size(X, 2)); +condA = contrastSpec{1}; +idxA = find(strcmp(regressorNames, ['PPI_' condA]), 1); +if isempty(idxA) + error('exploreFNIRS:connectivity:computePPI:contrastCondition', ... + 'Contrast condition "%s" is not present in the blocks.', condA); +end +C(idxA) = 1; +if numel(contrastSpec) >= 2 + condB = contrastSpec{2}; + idxB = find(strcmp(regressorNames, ['PPI_' condB]), 1); + if isempty(idxB) + error('exploreFNIRS:connectivity:computePPI:contrastCondition', ... + 'Contrast condition "%s" is not present in the blocks.', condB); + end + C(idxB) = -1; + contrastName = sprintf('PPI_%s-%s', condA, condB); +else + contrastName = sprintf('PPI_%s', condA); +end + +% --- Fit GLM on target channels with the PPI contrast --- +glmResult = pf2_base.fnirs.fitGLM(targetSignal(:, targetCh), X, regressorNames, ... + 'Method', opts.FitMethod, 'Contrasts', C, 'ContrastNames', {contrastName}); + +% --- Extract PPI contrast statistics (the effect of interest) --- +result.ppi_beta = glmResult.contrast.beta(1, :); +result.ppi_tstat = glmResult.contrast.tstat(1, :); +result.ppi_pval = glmResult.contrast.pval(1, :); + +% Plot-compatible fields +result.matrix = result.ppi_beta; +result.pmatrix = result.ppi_pval; + +% Per-condition PPI slopes (for inspection / custom contrasts) +ppiIdx = find(startsWith(regressorNames, 'PPI_')); +result.ppiConditions = condList; +result.ppiBetaPerCondition = glmResult.beta(ppiIdx, :); +result.contrastVector = C; + +result.channels = targetCh; +result.seedChannels = seedChannels; +result.seedSource = seedSource; +result.method = 'PPI'; +result.biomarker = bioM; +result.useROI = opts.UseROI; +result.contrast = contrastSpec; +result.fullResults = glmResult; +result.designMatrix = X; +result.regressorNames = regressorNames; + +% Build labels +if opts.UseROI && ~isempty(roiNames) + result.labels = roiNames(targetCh); +else + result.labels = arrayfun(@(c) sprintf('Ch%d', c), targetCh, ... + 'UniformOutput', false); +end + +end + + +%% Local helper functions + +function neural = wienerDeconv(signal, hrf, fs) %#ok +% WIENERDECONV Estimate neural signal via Wiener deconvolution +% +% Deconvolves the HRF from the hemodynamic signal to recover an estimate +% of the underlying neural activity. Uses a frequency-domain Wiener filter +% with a fixed-fraction noise estimate (simplified; not a fully regularized +% deconvolution). + + T = length(signal); + + % Zero-pad HRF to match signal length + hrfPad = zeros(T, 1); + hrfPad(1:length(hrf)) = hrf; + + % FFT + S = fft(signal); + H = fft(hrfPad); + + % Wiener filter: estimate noise power as fraction of signal power + noisePower = 0.01 * mean(abs(S).^2); + W = conj(H) ./ (abs(H).^2 + noisePower); + + % Deconvolve and return to time domain + neural = real(ifft(S .* W)); +end diff --git a/+exploreFNIRS/+connectivity/detectStates.m b/+exploreFNIRS/+connectivity/detectStates.m new file mode 100644 index 00000000..67ddf3a9 --- /dev/null +++ b/+exploreFNIRS/+connectivity/detectStates.m @@ -0,0 +1,121 @@ +function states = detectStates(dynamicResult, varargin) +% DETECTSTATES K-means clustering of dynamic connectivity states +% +% Identifies recurring connectivity patterns (states) from a time series +% of connectivity matrices produced by computeDynamicFC. Each time window +% is assigned to its nearest centroid state. +% +% Syntax: +% states = exploreFNIRS.connectivity.detectStates(dynamicResult) +% states = exploreFNIRS.connectivity.detectStates(dynamicResult, 'K', 4) +% states = exploreFNIRS.connectivity.detectStates(dynamicResult, ... +% 'Replicates', 20, 'Distance', 'sqeuclidean') +% +% Inputs: +% dynamicResult - Output from computeDynamicFC with: +% .matrices [C x C x W], .windowTimes [W x 1] +% +% Name-Value Parameters: +% K - Number of states (default: 3) +% Replicates - Number of k-means replicates (default: 10) +% Distance - Distance metric for kmeans: 'correlation' (default), +% 'sqeuclidean', 'cityblock', 'cosine' +% +% Outputs: +% states - Struct with fields: +% .assignments - [W x 1] state label per window (1..K) +% .centroidMatrices - {1 x K} cell array, each [C x C] centroid matrix +% .silhouette - [W x 1] silhouette values per window +% .K - Number of states used +% .centroids - [K x features] raw centroid vectors +% .windowTimes - [W x 1] center times from dynamicResult +% +% Example: +% dfc = exploreFNIRS.connectivity.computeDynamicFC(processed); +% states = exploreFNIRS.connectivity.detectStates(dfc, 'K', 3); +% disp(states.assignments'); +% +% References: +% Allen, E. A., Damaraju, E., Plis, S. M., Erhardt, E. B., Eichele, T. +% & Calhoun, V. D. (2014). Tracking whole-brain connectivity dynamics in +% the resting state. Cerebral Cortex, 24(3), 663-676. +% DOI: 10.1093/cercor/bhs352 +% +% See also: exploreFNIRS.connectivity.computeDynamicFC, +% exploreFNIRS.connectivity.plotDynamicFC + + p = inputParser; + addRequired(p, 'dynamicResult', @isstruct); + addParameter(p, 'K', 3, @(v) isnumeric(v) && isscalar(v) && v >= 2); + addParameter(p, 'Replicates', 10, @(v) isnumeric(v) && isscalar(v) && v >= 1); + addParameter(p, 'Distance', 'correlation', @ischar); + parse(p, dynamicResult, varargin{:}); + opts = p.Results; + + matrices = dynamicResult.matrices; % [C x C x W] + [nCh, ~, nWin] = size(matrices); + + if nWin < opts.K + error('exploreFNIRS:connectivity:detectStates', ... + 'Number of windows (%d) must be >= K (%d)', nWin, opts.K); + end + + % Check if matrices are asymmetric (directed method) + testMat = matrices(:, :, 1); + isAsymmetric = any(abs(testMat - testMat') > 1e-10, 'all'); + + if isAsymmetric + % Directed methods: use full matrix excluding diagonal + triMask = ~eye(nCh, 'logical'); + else + % Symmetric: use upper triangle only + triMask = triu(true(nCh), 1); + end + nFeatures = sum(triMask(:)); + features = zeros(nWin, nFeatures); + + for w = 1:nWin + mat = matrices(:, :, w); + features(w, :) = mat(triMask)'; + end + + % Replace NaN features with 0 for clustering + features(isnan(features)) = 0; + + % Run k-means + [assignments, centroids] = kmeans(features, opts.K, ... + 'Replicates', opts.Replicates, ... + 'Distance', opts.Distance, ... + 'MaxIter', 500); + + % Compute silhouette values + silVals = silhouette(features, assignments, opts.Distance); + + % Reconstruct centroid matrices + centroidMatrices = cell(1, opts.K); + for k = 1:opts.K + if isAsymmetric + % Directed: assign off-diagonal directly, diagonal = 0 + mat = zeros(nCh); + mat(triMask) = centroids(k, :); + else + % Symmetric: fill upper triangle and mirror to lower + mat = nan(nCh); + mat(triMask) = centroids(k, :); + mat = mat'; + mat(triMask) = centroids(k, :); + mat = mat'; + for c = 1:nCh + mat(c, c) = 1; + end + end + centroidMatrices{k} = mat; + end + + states.assignments = assignments; + states.centroidMatrices = centroidMatrices; + states.silhouette = silVals; + states.K = opts.K; + states.centroids = centroids; + states.windowTimes = dynamicResult.windowTimes; +end diff --git a/+exploreFNIRS/+connectivity/normalizeResult.m b/+exploreFNIRS/+connectivity/normalizeResult.m new file mode 100644 index 00000000..878dd2f9 --- /dev/null +++ b/+exploreFNIRS/+connectivity/normalizeResult.m @@ -0,0 +1,34 @@ +function result = normalizeResult(result) +% NORMALIZERESULT Map group connectivity result to plot-compatible format +% +% Group-level results from Experiment.connectivity() / interROI() store +% averaged matrices in .Mean, while plot functions expect .matrix. +% This utility copies .Mean → .matrix when .matrix is absent, making +% group results directly plottable without manual struct wrapping. +% +% Single-subject results (which already have .matrix) pass through +% unchanged. +% +% Syntax: +% result = exploreFNIRS.connectivity.normalizeResult(result) +% +% Inputs: +% result - Connectivity result struct (single-subject or group) +% +% Outputs: +% result - Struct with .matrix guaranteed to exist +% +% See also: exploreFNIRS.connectivity.plotMatrix, +% exploreFNIRS.connectivity.plotChord + + if isfield(result, 'matrix') + return; + end + + if isfield(result, 'Mean') + result.matrix = result.Mean; + if ~isfield(result, 'pmatrix') + result.pmatrix = []; + end + end +end diff --git a/+exploreFNIRS/+connectivity/plotBlockComparison.m b/+exploreFNIRS/+connectivity/plotBlockComparison.m new file mode 100644 index 00000000..3686e387 --- /dev/null +++ b/+exploreFNIRS/+connectivity/plotBlockComparison.m @@ -0,0 +1,247 @@ +function fig = plotBlockComparison(blockResults, varargin) +% PLOTBLOCKCOMPARISON Compare connectivity across blocks +% +% Visualizes how connectivity changes across blocks (e.g., different task +% conditions or time periods). Accepts the block-wise output from +% Experiment.connectivity('Blocks', blocks). +% +% Syntax: +% fig = exploreFNIRS.connectivity.plotBlockComparison(blockResults) +% fig = exploreFNIRS.connectivity.plotBlockComparison(blockResults, ... +% 'Metric', 'mean', 'GroupIndex', 1) +% +% Inputs: +% blockResults - Struct array from Experiment.connectivity('Blocks', blocks) +% Each element has .blockNumber, .blockInfo, .groups +% +% Name-Value Parameters: +% GroupIndex - Which group to plot (default: 1, first group) +% Metric - How to summarize each connectivity matrix: +% 'mean' (default) - mean of upper triangle +% 'median' - median of upper triangle +% 'density' - fraction of significant connections +% ChannelPair - [i, j] specific channel pair to track (default: []) +% If provided, plots that pair's coupling across blocks +% PThreshold - Significance threshold for density metric (default: 0.05) +% BarWidth - Bar width (default: 0.6) +% ShowIndividual - Show individual subject dots (default: true) +% Colors - Custom color matrix [nBlocks x 3] (default: auto) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 700) +% SaveHeight - Height in pixels (default: 400) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.connectivity.plotMatrix, pf2.data.defineBlocks + + p = inputParser; + addRequired(p, 'blockResults', @isstruct); + addParameter(p, 'GroupIndex', 1, @(v) isnumeric(v) && isscalar(v)); + addParameter(p, 'Metric', 'mean', @ischar); + addParameter(p, 'ChannelPair', [], @(v) isempty(v) || (isnumeric(v) && numel(v) == 2)); + addParameter(p, 'PThreshold', 0.05, @isnumeric); + addParameter(p, 'BarWidth', 0.6, @isnumeric); + addParameter(p, 'ShowIndividual', true, @islogical); + addParameter(p, 'Colors', [], @isnumeric); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 700, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, blockResults, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + nBlocks = length(blockResults); + gi = opts.GroupIndex; + + % Extract per-block metrics + blockMeans = zeros(1, nBlocks); + blockSEMs = zeros(1, nBlocks); + blockLabels = cell(1, nBlocks); + subjectValues = cell(1, nBlocks); + + for b = 1:nBlocks + grp = blockResults(b).groups(gi); + + if ~isempty(opts.ChannelPair) + % Track a specific channel pair + ci = opts.ChannelPair(1); + cj = opts.ChannelPair(2); + blockMeans(b) = grp.Mean(ci, cj); + blockSEMs(b) = grp.SEM(ci, cj); + + % Individual subject values + nSubj = length(grp.matrices); + vals = zeros(nSubj, 1); + for s = 1:nSubj + m = grp.matrices{s}; + if ci <= size(m, 1) && cj <= size(m, 2) + vals(s) = m(ci, cj); + else + vals(s) = NaN; + end + end + subjectValues{b} = vals; + else + % Summarize the whole matrix + mat = grp.Mean; + nCh = size(mat, 1); + + switch lower(opts.Metric) + case 'mean' + offVals = getOffDiagonal(mat); + blockMeans(b) = mean(offVals, 'omitnan'); + % SEM from individual subjects + nSubj = length(grp.matrices); + subjMeans = zeros(nSubj, 1); + for s = 1:nSubj + subjMeans(s) = mean(getOffDiagonal(grp.matrices{s}), 'omitnan'); + end + blockSEMs(b) = std(subjMeans, 'omitnan') / sqrt(nSubj); + subjectValues{b} = subjMeans; + + case 'median' + offVals = getOffDiagonal(mat); + blockMeans(b) = median(offVals, 'omitnan'); + nSubj = length(grp.matrices); + subjMeds = zeros(nSubj, 1); + for s = 1:nSubj + subjMeds(s) = median(getOffDiagonal(grp.matrices{s}), 'omitnan'); + end + blockSEMs(b) = std(subjMeds, 'omitnan') / sqrt(nSubj); + subjectValues{b} = subjMeds; + + case 'density' + % Fraction of significant connections (one-sample t-test) + nSubj = length(grp.matrices); + if nSubj < 3 + warning('exploreFNIRS:connectivity:plotBlockComparison', ... + 'Density metric requires >= 3 subjects, got %d', nSubj); + blockMeans(b) = NaN; + blockSEMs(b) = 0; + subjectValues{b} = []; + else + % Detect directed methods: check matrix asymmetry + testMat = grp.Mean; + isAsymmetric = any(abs(testMat - testMat') > 1e-10, 'all'); + if isAsymmetric + mask = ~eye(nCh, 'logical'); + else + mask = triu(true(nCh), 1); + end + [ri, ci] = find(mask); + nPairs = length(ri); + pVals = ones(nPairs, 1); + for pi = 1:nPairs + vals = cellfun(@(m) m(ri(pi), ci(pi)), grp.matrices); + [~, pVals(pi)] = pf2_base.compat.ttest(vals); + end + blockMeans(b) = mean(pVals < opts.PThreshold); + blockSEMs(b) = 0; + subjectValues{b} = []; + end + + otherwise + error('exploreFNIRS:connectivity:plotBlockComparison', ... + 'Unknown metric "%s". Use: mean, median, density', opts.Metric); + end + end + + % Block label + if isfield(blockResults(b).blockInfo, 'Condition') + blockLabels{b} = blockResults(b).blockInfo.Condition; + else + blockLabels{b} = sprintf('Block %d', blockResults(b).blockNumber); + end + end + + % Create figure + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + ax = axes('Parent', fig); + + if isempty(opts.Colors) + cmap = lines(nBlocks); + else + cmap = opts.Colors; + end + + hold(ax, 'on'); + + for b = 1:nBlocks + bar(ax, b, blockMeans(b), opts.BarWidth, ... + 'FaceColor', cmap(b, :), 'EdgeColor', 'none', ... + 'FaceAlpha', 0.7); + end + + % Error bars + errorbar(ax, 1:nBlocks, blockMeans, blockSEMs, 'k.', ... + 'LineWidth', sty.AxisLineWidth, 'CapSize', 6); + + % Individual subject dots + if opts.ShowIndividual + for b = 1:nBlocks + vals = subjectValues{b}; + if ~isempty(vals) + jitter = (rand(length(vals), 1) - 0.5) * 0.2; + plot(ax, b + jitter, vals, 'o', ... + 'MarkerSize', 4, 'MarkerFaceColor', cmap(b, :) * 0.7, ... + 'MarkerEdgeColor', 'none'); + end + end + end + + % Zero line + plot(ax, [0.5, nBlocks + 0.5], [0, 0], '-', ... + 'Color', sty.ZeroLineColor, 'LineWidth', 0.5); + + hold(ax, 'off'); + + set(ax, 'XTick', 1:nBlocks, 'XTickLabel', pf2_base.plot.escapeTeX(blockLabels)); + xlabel(ax, 'Block'); + + if ~isempty(opts.ChannelPair) + ylabel(ax, sprintf('Coupling (Ch%d-Ch%d)', opts.ChannelPair(1), opts.ChannelPair(2))); + else + ylabel(ax, sprintf('Connectivity (%s)', opts.Metric)); + end + + if ~isempty(opts.Title) + title(ax, pf2_base.plot.escapeTeX(opts.Title)); + else + grp = blockResults(1).groups(gi); + titleStr = sprintf('Connectivity by Block (%s, %s, %s)', ... + grp.method, grp.biomarker, grp.label); + title(ax, pf2_base.plot.escapeTeX(titleStr)); + end + + box(ax, 'on'); + sty.applyToAxes(ax); + + pf2_base.plot.handleSave(fig, opts); + +end + + +function vals = getOffDiagonal(mat) +% Extract off-diagonal values (upper triangle for symmetric, all for directed) + isAsymmetric = any(abs(mat - mat') > 1e-10, 'all'); + if isAsymmetric + mask = ~eye(size(mat), 'logical'); + else + mask = triu(true(size(mat)), 1); + end + vals = mat(mask); +end diff --git a/+exploreFNIRS/+connectivity/plotChord.m b/+exploreFNIRS/+connectivity/plotChord.m new file mode 100644 index 00000000..05242f4d --- /dev/null +++ b/+exploreFNIRS/+connectivity/plotChord.m @@ -0,0 +1,499 @@ +function fig = plotChord(result, varargin) +% PLOTCHORD Chord diagram / connectogram for channel/ROI connectivity +% +% Visualizes a connectivity matrix as a chord diagram with nodes arranged +% on a unit circle and quadratic Bezier arcs connecting coupled pairs. +% Arc width encodes coupling magnitude; arc color encodes coupling sign by +% default, or can be set to a single uniform color. +% +% Nodes can additionally be colored by a per-node statistic (e.g. a +% condition contrast such as Delta-r Together-Apart) with a matching +% colorbar, and grouped under region anchor labels (L-FP, R-DLPFC, ...) - +% reproducing the publication-style connectogram where the colorbar tracks +% the *node* value rather than the edges. +% +% Syntax: +% fig = exploreFNIRS.connectivity.plotChord(result) +% fig = exploreFNIRS.connectivity.plotChord(result, 'MinThreshold', 0.3) +% fig = exploreFNIRS.connectivity.plotChord(result, 'ArcWidth', 'fixed') +% fig = exploreFNIRS.connectivity.plotChord(result, 'NodeValues', dr, ... +% 'ColorbarLabel', '\Deltar (Together - Apart)', ... +% 'EdgeColor', [0.55 0.75 0.88], 'GroupLabels', 'auto') +% +% Inputs: +% result - Connectivity result struct from computeMatrix with fields: +% .matrix, .pmatrix, .channels, .method, .biomarker, .labels +% +% Name-Value Parameters: +% MinThreshold - Minimum absolute coupling value to draw (default: 0) +% ArcWidth - 'proportional' (default) or 'fixed' +% proportional: width scales with absolute value +% fixed: uniform line width for all arcs +% SignificanceMask - Mask non-significant connections (default: false) +% PThreshold - p-value threshold for masking (default: 0.05) +% NodeSize - Scatter marker size (default: 100) +% NodeColors - [N x 3] explicit node RGB. Highest precedence; when +% given, NodeValues colouring and its colorbar are off. +% NodeValues - Per-node scalar driving node color + a colorbar: +% [] (default) legacy flat-blue nodes, no bar +% [N x 1] numeric, mapped through NodeColormap +% 'auto' signed node strength (mean off-diagonal +% /'signed' coupling per node) - keeps sign +% 'strength' absolute node strength (mean |off-diag|), +% /'degree' the conventional unsigned weighted degree +% NodeColormap - Colormap name or [M x 3] for NodeValues (default: +% 'rdbu', CVD-safe diverging, for signed values). When +% the values are all non-negative and this is left at +% default, a sequential map ('viridis') over [0, m] is +% used instead. +% NodeCLim - [lo hi] node color limits (default: symmetric about 0). +% ColorbarLabel - Label for the node colorbar (default: auto). Empty +% string hides the colorbar even when NodeValues is set. +% GroupLabels - Region anchor labels around the ring: +% [] (default) per-node labels as before +% 'auto' infer groups from result.labels +% {1xN}/[N x 1] per-node group name ('' = none) +% ShowLabels - Force per-node labels on/off ([] = auto: on unless +% group anchors are shown). +% RingGuide - Draw a faint guide circle through the nodes (default: true) +% EdgeColor - [] (default) color arcs by coupling sign, or an RGB +% triplet to draw every arc in one uniform color. +% ArcAlpha - Arc transparency (default: 0.6) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 600) +% SaveHeight - Height in pixels (default: 600) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% Notes: +% - NodeColors > NodeValues > flat blue, in decreasing precedence. +% - 'auto' group inference strips a trailing index from each label (so +% 'L-FP 1','L-FP 2' share the 'L-FP' anchor); it is skipped when the +% labels yield one group or all-distinct groups (nothing to anchor). +% - The colorbar tracks NodeValues; arc colors are drawn as explicit RGB +% and are independent of the axes colormap. +% - MinThreshold / SignificanceMask affect the drawn EDGES only. The node +% value ('auto'/'signed'/'strength' or a supplied vector) is computed +% from the full matrix, so a node can be strongly colored while few or no +% arcs touch it. +% - 'auto'/'strength' node values are a data-dependent descriptive summary, +% not a statistic. With default limits the color scale is rescaled per +% figure from that matrix, so node colors are NOT comparable across +% figures/subjects/conditions unless you pass a shared explicit NodeCLim. +% - RingGuide defaults to true: a faint guide circle is drawn through the +% nodes (set false to restore the bare layout). +% +% Example: +% r = exploreFNIRS.connectivity.computeMatrix(proc, 'Method', 'pearson'); +% % Publication-style connectogram: nodes colored by a contrast, region +% % anchors from labels, uniform subtle edges. +% exploreFNIRS.connectivity.plotChord(r, ... +% 'NodeValues', deltaR, 'NodeColormap', 'rdbu', ... +% 'ColorbarLabel', '\Deltar (Together - Apart)', ... +% 'EdgeColor', [0.55 0.75 0.88], 'GroupLabels', 'auto', ... +% 'MinThreshold', 0.3, 'SavePath', 'connectogram.png'); +% % Or derive a node strength when no contrast is available: +% exploreFNIRS.connectivity.plotChord(r, 'NodeValues', 'auto'); +% +% See also: exploreFNIRS.connectivity.plotMatrix, exploreFNIRS.connectivity.plotDirected + + p = inputParser; + addRequired(p, 'result', @isstruct); + addParameter(p, 'MinThreshold', 0, @isnumeric); + addParameter(p, 'ArcWidth', 'proportional', @ischar); + addParameter(p, 'SignificanceMask', false, @islogical); + addParameter(p, 'PThreshold', 0.05, @isnumeric); + addParameter(p, 'NodeSize', 100, @isnumeric); + addParameter(p, 'NodeColors', [], @isnumeric); + addParameter(p, 'NodeValues', [], @(x) isempty(x) || isnumeric(x) || ... + ischar(x) || isstring(x)); + addParameter(p, 'NodeColormap', 'rdbu', @(x) ischar(x) || isstring(x) || isnumeric(x)); + addParameter(p, 'NodeCLim', [], @(x) isempty(x) || (isnumeric(x) && numel(x) == 2)); + addParameter(p, 'ColorbarLabel', [], @(x) isempty(x) || ischar(x) || isstring(x)); + addParameter(p, 'GroupLabels', [], @(x) isempty(x) || ischar(x) || isstring(x) || iscell(x)); + addParameter(p, 'ShowLabels', [], @(x) isempty(x) || islogical(x)); + addParameter(p, 'RingGuide', true, @islogical); + addParameter(p, 'EdgeColor', [], @(x) isempty(x) || (isnumeric(x) && numel(x) == 3)); + addParameter(p, 'ArcAlpha', 0.6, @isnumeric); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 600, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + result = exploreFNIRS.connectivity.normalizeResult(result); + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + mat = result.matrix; + nCh = size(mat, 1); + + % Build labels + if isfield(result, 'labels') && ~isempty(result.labels) + chLabels = pf2_base.plot.escapeTeX(result.labels); + else + chLabels = arrayfun(@(c) sprintf('Ch%d', c), result.channels, ... + 'UniformOutput', false); + end + + % Apply significance mask + if opts.SignificanceMask && isfield(result, 'pmatrix') + nonsig = result.pmatrix > opts.PThreshold; + mat(nonsig) = 0; + end + + % Zero diagonal + for i = 1:nCh + mat(i, i) = 0; + end + + % Resolve per-node values + colors (NodeColors > NodeValues > flat blue) + nodeVals = resolveNodeValues(opts.NodeValues, mat, nCh); + nodeColorActive = ~isempty(nodeVals) && ... + (isempty(opts.NodeColors) || size(opts.NodeColors, 1) < nCh); + valueNodeColors = []; % only assigned when nodeColorActive + if nodeColorActive + % Diverging 'rdbu'/symmetric limits suit a SIGNED value. When the + % node values are all non-negative and the caller left the colormap + % and limits at defaults, switch to a sequential map over [0, m] so + % half the diverging map is not wasted (and no false sign is implied). + defaultCmap = ismember('NodeColormap', p.UsingDefaults); + defaultCLim = ismember('NodeCLim', p.UsingDefaults); + nonNeg = ~any(nodeVals < 0); + cmapSpec = opts.NodeColormap; + if nonNeg && defaultCmap + cmapSpec = 'viridis'; + end + nodeCmap = resolveNodeColormap(cmapSpec, 256); + nodeCLim = opts.NodeCLim; + if isempty(nodeCLim) + m = max(abs(nodeVals), [], 'omitnan'); + if isempty(m) || ~isfinite(m) || m == 0, m = 1; end + if nonNeg && defaultCLim + nodeCLim = [0, m]; + else + nodeCLim = [-m, m]; + end + end + nodeCLim = sort(nodeCLim); + valueNodeColors = valuesToColors(nodeVals, nodeCmap, nodeCLim); + end + + % Resolve region anchor groups + groups = resolveGroups(opts.GroupLabels, chLabels, nCh); + showNodeLabels = opts.ShowLabels; + if isempty(showNodeLabels) + showNodeLabels = isempty(groups); % default: hide per-node when grouped + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + ax = axes('Parent', fig); + sty = pf2_base.plot.PlotStyle.getDefault(); + + hold(ax, 'on'); + axis(ax, 'equal'); + axis(ax, 'off'); + + % Node positions on unit circle + angles = linspace(0, 2*pi, nCh + 1); + angles = angles(1:nCh); + nodeX = cos(angles); + nodeY = sin(angles); + + % Faint guide ring through the nodes + if opts.RingGuide + th = linspace(0, 2*pi, 200); + plot(ax, cos(th), sin(th), '-', 'Color', [0.8 0.8 0.8], ... + 'LineWidth', 0.75, 'HandleVisibility', 'off'); + end + + % Colormap for arcs (diverging: blue = negative, red = positive) + cmap = divergingColormap(256); + + maxVal = max(abs(mat(:)), [], 'omitnan'); + if isempty(maxVal) || ~isfinite(maxVal) || maxVal == 0 + maxVal = 1; + end + + % Draw arcs (upper triangle only for symmetric) + for i = 1:nCh + for j = (i+1):nCh + val = mat(i, j); + if isnan(val) || abs(val) < opts.MinThreshold + continue; + end + + % Quadratic Bezier arc + midX = (nodeX(i) + nodeX(j)) / 2; + midY = (nodeY(i) + nodeY(j)) / 2; + % Control point pulled toward center + pullFactor = 0.5; + ctrlX = midX * (1 - pullFactor); + ctrlY = midY * (1 - pullFactor); + + t = linspace(0, 1, 80); + bx = (1-t).^2 * nodeX(i) + 2*(1-t).*t * ctrlX + t.^2 * nodeX(j); + by = (1-t).^2 * nodeY(i) + 2*(1-t).*t * ctrlY + t.^2 * nodeY(j); + + % Arc width + if strcmpi(opts.ArcWidth, 'proportional') + lw = 0.5 + 3.0 * abs(val) / maxVal; + else + lw = 1.5; + end + + % Arc color: uniform EdgeColor if given, else by coupling sign + if ~isempty(opts.EdgeColor) + arcColor = opts.EdgeColor(:)'; + else + cidx = round((val / maxVal + 1) / 2 * 255) + 1; + cidx = max(1, min(256, cidx)); + arcColor = cmap(cidx, :); + end + + plot(ax, bx, by, '-', 'Color', [arcColor, opts.ArcAlpha], ... + 'LineWidth', lw); + end + end + + % Draw nodes (NodeColors > NodeValues > flat blue) + if ~isempty(opts.NodeColors) && size(opts.NodeColors, 1) >= nCh + nodeColors = opts.NodeColors; + elseif nodeColorActive + nodeColors = valueNodeColors; + else + nodeColors = repmat([0.3, 0.5, 0.8], nCh, 1); + end + scatter(ax, nodeX, nodeY, opts.NodeSize, nodeColors, 'filled', ... + 'MarkerEdgeColor', sty.ForegroundColor, 'LineWidth', 0.8); + + % Per-node labels + if showNodeLabels + labelOffset = 1.15; + for i = 1:nCh + ha = 'center'; + if nodeX(i) > 0.1 + ha = 'left'; + elseif nodeX(i) < -0.1 + ha = 'right'; + end + text(ax, nodeX(i) * labelOffset, nodeY(i) * labelOffset, ... + pf2_base.plot.escapeTeX(chLabels{i}), ... + 'HorizontalAlignment', ha, 'FontSize', 9, ... + 'Color', sty.ForegroundColor); + end + end + + % Region anchor labels (one per group, at the group's mean angle) + if ~isempty(groups) + groupOffset = 1.3; + for g = 1:numel(groups) + ga = meanAngle(angles(groups(g).nodes)); + gx = cos(ga); gy = sin(ga); + ha = 'center'; + if gx > 0.1 + ha = 'left'; + elseif gx < -0.1 + ha = 'right'; + end + text(ax, gx * groupOffset, gy * groupOffset, ... + pf2_base.plot.escapeTeX(groups(g).name), ... + 'HorizontalAlignment', ha, 'FontSize', 11, ... + 'FontWeight', 'bold', 'Color', sty.ForegroundColor); + end + end + + if ~isempty(opts.Title) + title(ax, pf2_base.plot.escapeTeX(opts.Title)); + else + titleStr = sprintf('Chord Diagram (%s, %s)', result.method, result.biomarker); + if opts.SignificanceMask + titleStr = sprintf('%s [p < %.2f]', titleStr, opts.PThreshold); + end + title(ax, pf2_base.plot.escapeTeX(titleStr)); + end + + hold(ax, 'off'); + sty.applyToAxes(ax); + + % Pad limits so node (1.15) / group (1.3) labels are not clipped + lim = 1.15; + if showNodeLabels, lim = 1.32; end + if ~isempty(groups), lim = 1.5; end + xlim(ax, [-lim lim]); ylim(ax, [-lim lim]); + + % Node colorbar (tracks NodeValues, independent of arc colors) + cbLabel = opts.ColorbarLabel; + if nodeColorActive && ~(ischar(cbLabel) || isstring(cbLabel)) % [] -> auto + cbLabel = nodeColorbarLabel(result); + end + if nodeColorActive && ~isempty(char(string(cbLabel))) + colormap(ax, nodeCmap); + set(ax, 'CLim', nodeCLim); + % colorbar() steals horizontal space from the axes; with axis-equal that + % shrinks the whole (square) chord layout and nudges nodes near +/-90 deg + % toward the edge. Capture the axes position, add the colorbar, then + % restore the axes so the diagram keeps its size -- the colorbar sits in + % the right-hand whitespace of the equal-aspect axes. + axPos = get(ax, 'Position'); + cb = colorbar(ax); + set(ax, 'Position', axPos); + cb.Label.String = pf2_base.plot.escapeTeX(char(string(cbLabel))); + end + + % Save + if ~isempty(opts.SavePath) + pf2_base.plot.handleSave(fig, opts); + end +end + + +function vals = resolveNodeValues(param, mat, nCh) +% Resolve the per-node value vector from the NodeValues parameter. +% [] -> [] (legacy flat-blue nodes) +% 'auto' -> signed node strength (mean off-diagonal coupling) +% [N x 1]/[1xN] -> as given + vals = []; + if isempty(param) + return; + end + if ischar(param) || isstring(param) + key = lower(char(string(param))); + switch key + case {'auto', 'signed'} + M = mat; + M(1:nCh+1:end) = NaN; % ignore diagonal + vals = mean(M, 2, 'omitnan'); % signed mean coupling per node + case {'strength', 'degree', 'abs', 'absstrength'} + M = abs(mat); % unsigned weighted degree + M(1:nCh+1:end) = NaN; % ignore diagonal + vals = mean(M, 2, 'omitnan'); + otherwise + error('exploreFNIRS:connectivity:plotChord:badNodeValues', ... + ['NodeValues string must be ''auto''/''signed'' (signed ', ... + 'mean coupling) or ''strength''/''degree'' (absolute); ', ... + 'got ''%s''.'], key); + end + return; + end + vals = param(:); + if numel(vals) ~= nCh + error('exploreFNIRS:connectivity:plotChord:nodeValuesSize', ... + 'NodeValues must have %d elements (one per node); got %d.', ... + nCh, numel(vals)); + end +end + + +function cmap = resolveNodeColormap(name, n) +% Resolve a node colormap from a name or an [M x 3] matrix. + if isnumeric(name) && size(name, 2) == 3 + cmap = name; + return; + end + cmap = pf2_base.plot.brainColormap(char(string(name)), n); +end + + +function colors = valuesToColors(vals, cmap, clim) +% Map a value vector to RGB rows through cmap over the limits clim. + n = size(cmap, 1); + span = clim(2) - clim(1); + if span == 0, span = 1; end + idx = round((vals - clim(1)) / span * (n - 1)) + 1; + idx(~isfinite(idx)) = 1; + idx = max(1, min(n, idx)); + colors = cmap(idx, :); +end + + +function groups = resolveGroups(param, labels, nCh) +% Resolve region anchor groups -> struct array with .name and .nodes. +% [] -> [] (no anchors) +% 'auto' -> infer from labels by stripping a trailing index +% {1xN}/strings -> explicit per-node group name ('' = no group) + groups = struct('name', {}, 'nodes', {}); + if isempty(param) + return; + end + if (ischar(param) || isstring(param)) && isscalar(string(param)) + if ~strcmpi(char(string(param)), 'auto') + error('exploreFNIRS:connectivity:plotChord:badGroupLabels', ... + 'GroupLabels string must be ''auto''.'); + end + names = cellfun(@stripIndexSuffix, cellstr(string(labels(:))), ... + 'UniformOutput', false); + else + names = cellstr(string(param(:))); + if numel(names) ~= nCh + error('exploreFNIRS:connectivity:plotChord:groupLabelsSize', ... + 'GroupLabels must have %d elements (one per node); got %d.', ... + nCh, numel(names)); + end + end + + uniq = unique(names(~cellfun(@isempty, names)), 'stable'); + % Degenerate auto-inference yields no useful anchors, so skip it. This + % covers default labels like {'Ch1'..'ChN'} (all collapse to 'Ch' -> one + % group) and labels with no shared stem (N distinct groups). The guard is + % intentionally 'auto'-only: an explicit per-node GroupLabels cell is + % taken at face value, even if every node ends up its own anchor. + if (ischar(param) || isstring(param)) && ... + (numel(uniq) <= 1 || numel(uniq) >= nCh) + return; + end + for k = 1:numel(uniq) + groups(k).name = uniq{k}; %#ok + groups(k).nodes = find(strcmp(names, uniq{k})); %#ok + end +end + + +function s = stripIndexSuffix(s) +% Strip a trailing channel/ROI index so 'L-FP 2' -> 'L-FP'. + s = regexprep(char(s), '[\s_\-]*\d+\s*$', ''); + s = strtrim(s); +end + + +function a = meanAngle(angles) +% Circular mean of a set of angles (radians). + a = atan2(mean(sin(angles)), mean(cos(angles))); +end + + +function lbl = nodeColorbarLabel(result) +% Default colorbar label for node values - self-identifies as node-level so +% it is not mistaken for an edge scale. + lbl = 'Node value'; + if isfield(result, 'method') && ~isempty(result.method) + lbl = sprintf('Node value (%s)', char(string(result.method))); + end +end + + +function cmap = divergingColormap(n) +% Blue-white-red diverging colormap + half = floor(n / 2); + + r1 = linspace(0.2, 1, half)'; + g1 = linspace(0.3, 1, half)'; + b1 = linspace(0.8, 1, half)'; + + r2 = linspace(1, 0.8, n - half)'; + g2 = linspace(1, 0.2, n - half)'; + b2 = linspace(1, 0.2, n - half)'; + + cmap = [r1 g1 b1; r2 g2 b2]; +end diff --git a/+exploreFNIRS/+connectivity/plotDirected.m b/+exploreFNIRS/+connectivity/plotDirected.m new file mode 100644 index 00000000..d932abba --- /dev/null +++ b/+exploreFNIRS/+connectivity/plotDirected.m @@ -0,0 +1,293 @@ +function fig = plotDirected(result, varargin) +% PLOTDIRECTED Visualize directed (asymmetric) connectivity matrix +% +% Renders a directed connectivity matrix as either an asymmetric heatmap +% or a circular graph with directed arcs and arrowheads. Designed for +% directed methods such as Granger causality and transfer entropy. +% +% Syntax: +% fig = exploreFNIRS.connectivity.plotDirected(result) +% fig = exploreFNIRS.connectivity.plotDirected(result, 'Layout', 'circular') +% fig = exploreFNIRS.connectivity.plotDirected(result, 'MinThreshold', 0.5) +% +% Inputs: +% result - Connectivity result struct from computeMatrix with fields: +% .matrix, .pmatrix, .channels, .method, .biomarker, .labels +% +% Name-Value Parameters: +% Layout - 'matrix' (default) or 'circular' +% matrix: asymmetric heatmap (rows = source, cols = target) +% circular: nodes on a circle with directed arcs +% MinThreshold - Minimum absolute value to display (default: 0) +% SignificanceMask - Mask non-significant connections (default: false) +% PThreshold - p-value threshold for masking (default: 0.05) +% ArrowScale - Scale factor for arrow size in circular layout (default: 1) +% CLim - Color limits [cmin cmax] for matrix layout (default: auto) +% ShowValues - Show values in matrix cells (default: false) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 600) +% SaveHeight - Height in pixels (default: 600) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.connectivity.computeMatrix, exploreFNIRS.connectivity.plotMatrix + + p = inputParser; + addRequired(p, 'result', @isstruct); + addParameter(p, 'Layout', 'matrix', @ischar); + addParameter(p, 'MinThreshold', 0, @isnumeric); + addParameter(p, 'SignificanceMask', false, @islogical); + addParameter(p, 'PThreshold', 0.05, @isnumeric); + addParameter(p, 'ArrowScale', 1, @isnumeric); + addParameter(p, 'CLim', [], @(v) isempty(v) || (isnumeric(v) && length(v) == 2)); + addParameter(p, 'ShowValues', false, @islogical); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 600, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + result = exploreFNIRS.connectivity.normalizeResult(result); + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + mat = result.matrix; + nCh = size(mat, 1); + + % Build labels + if isfield(result, 'labels') && ~isempty(result.labels) + chLabels = result.labels; + else + chLabels = arrayfun(@(c) sprintf('Ch%d', c), result.channels, ... + 'UniformOutput', false); + end + + % Apply significance mask + if opts.SignificanceMask && isfield(result, 'pmatrix') + nonsig = result.pmatrix > opts.PThreshold; + mat(nonsig) = 0; + end + + % Apply threshold + if opts.MinThreshold > 0 + mat(abs(mat) < opts.MinThreshold) = 0; + end + + % Zero out diagonal + for i = 1:nCh + mat(i, i) = 0; + end + + switch lower(opts.Layout) + case 'matrix' + fig = plotMatrixLayout(mat, chLabels, result, opts); + case 'circular' + fig = plotCircularLayout(mat, chLabels, result, opts); + otherwise + error('exploreFNIRS:connectivity:plotDirected', ... + 'Unknown layout "%s". Use: matrix, circular', opts.Layout); + end + + % Save + if ~isempty(opts.SavePath) + pf2_base.plot.handleSave(fig, opts); + end +end + + +function fig = plotMatrixLayout(mat, chLabels, result, opts) +% Asymmetric heatmap: rows = source (from), columns = target (to) + + nCh = size(mat, 1); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + ax = axes('Parent', fig); + sty = pf2_base.plot.PlotStyle.getDefault(); + + if isempty(opts.CLim) + maxVal = max(abs(mat(:))); + if maxVal == 0 + maxVal = 1; + end + cLim = [0, maxVal]; + else + cLim = opts.CLim; + end + + imagesc(ax, mat, cLim); + axis(ax, 'square'); + + chLabels = pf2_base.plot.escapeTeX(chLabels); + set(ax, 'XTick', 1:nCh, 'XTickLabel', chLabels, 'XTickLabelRotation', 45); + set(ax, 'YTick', 1:nCh, 'YTickLabel', chLabels); + + % Reduce label density for large matrices + if nCh > 20 + tickStep = ceil(nCh / 20); + ticks = 1:tickStep:nCh; + set(ax, 'XTick', ticks, 'XTickLabel', chLabels(ticks)); + set(ax, 'YTick', ticks, 'YTickLabel', chLabels(ticks)); + end + + % Hot colormap for directed values (typically positive F-stats or TE) + cmap = hot(256); + cmap = flipud(cmap); + colormap(ax, cmap); + cb = colorbar(ax); + cb.Label.String = result.method; + + xlabel(ax, 'Target (to)'); + ylabel(ax, 'Source (from)'); + + % Show values in cells + if opts.ShowValues && nCh <= 20 + for i = 1:nCh + for j = 1:nCh + if i ~= j && mat(i,j) ~= 0 && ~isnan(mat(i,j)) + txt = sprintf('%.2f', mat(i,j)); + textColor = 'k'; + if mat(i,j) > 0.7 * max(abs(mat(:))) + textColor = 'w'; + end + text(ax, j, i, txt, 'HorizontalAlignment', 'center', ... + 'FontSize', 7, 'Color', textColor); + end + end + end + end + + if ~isempty(opts.Title) + title(ax, pf2_base.plot.escapeTeX(opts.Title)); + else + titleStr = sprintf('Directed Connectivity (%s, %s)', result.method, result.biomarker); + if opts.SignificanceMask + titleStr = sprintf('%s [p < %.2f]', titleStr, opts.PThreshold); + end + title(ax, pf2_base.plot.escapeTeX(titleStr)); + end + + sty.applyToAxes(ax); +end + + +function fig = plotCircularLayout(mat, chLabels, result, opts) +% Nodes on a circle with directed arcs + + nCh = size(mat, 1); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + ax = axes('Parent', fig); + sty = pf2_base.plot.PlotStyle.getDefault(); + + hold(ax, 'on'); + axis(ax, 'equal'); + axis(ax, 'off'); + + % Node positions on unit circle + angles = linspace(0, 2*pi, nCh + 1); + angles = angles(1:nCh); + nodeX = cos(angles); + nodeY = sin(angles); + + % Find max value for normalization + maxVal = max(abs(mat(:))); + if maxVal == 0 + maxVal = 1; + end + + % Color map for arcs + cmap = hot(256); + cmap = flipud(cmap); + + % Draw arcs + for i = 1:nCh + for j = 1:nCh + if i == j, continue; end + val = mat(i, j); + if val == 0 || isnan(val), continue; end + + % Quadratic Bezier from node i to node j + % Control point at center, pulled inward + midX = (nodeX(i) + nodeX(j)) / 2; + midY = (nodeY(i) + nodeY(j)) / 2; + % Pull control point toward center + pullFactor = 0.3; + ctrlX = midX * (1 - pullFactor); + ctrlY = midY * (1 - pullFactor); + + % Bezier curve + t = linspace(0, 1, 50); + bx = (1-t).^2 * nodeX(i) + 2*(1-t).*t * ctrlX + t.^2 * nodeX(j); + by = (1-t).^2 * nodeY(i) + 2*(1-t).*t * ctrlY + t.^2 * nodeY(j); + + % Line width proportional to value + lw = 0.5 + 2.5 * abs(val) / maxVal; + + % Color from colormap + cidx = max(1, min(256, round(abs(val) / maxVal * 255) + 1)); + arcColor = cmap(cidx, :); + + plot(ax, bx, by, '-', 'Color', arcColor, 'LineWidth', lw); + + % Arrowhead at destination + arrowLen = 0.08 * opts.ArrowScale; + % Direction at end of curve (tangent) + dx = bx(end) - bx(end-1); + dy = by(end) - by(end-1); + normD = sqrt(dx^2 + dy^2); + if normD > 0 + dx = dx / normD; + dy = dy / normD; + end + % Arrow tip slightly before the node + tipX = nodeX(j) - dx * 0.08; + tipY = nodeY(j) - dy * 0.08; + % Arrow wings + perpX = -dy; + perpY = dx; + wing1X = tipX - dx * arrowLen + perpX * arrowLen * 0.4; + wing1Y = tipY - dy * arrowLen + perpY * arrowLen * 0.4; + wing2X = tipX - dx * arrowLen - perpX * arrowLen * 0.4; + wing2Y = tipY - dy * arrowLen - perpY * arrowLen * 0.4; + + fill(ax, [tipX, wing1X, wing2X], [tipY, wing1Y, wing2Y], ... + arcColor, 'EdgeColor', 'none'); + end + end + + % Draw nodes + nodeSize = 80; + scatter(ax, nodeX, nodeY, nodeSize, [0.3, 0.5, 0.8], 'filled', ... + 'MarkerEdgeColor', sty.ForegroundColor, 'LineWidth', 0.8); + + % Node labels + chLabels = pf2_base.plot.escapeTeX(chLabels); + labelOffset = 1.15; + for i = 1:nCh + text(ax, nodeX(i) * labelOffset, nodeY(i) * labelOffset, ... + pf2_base.plot.escapeTeX(chLabels{i}), ... + 'HorizontalAlignment', 'center', 'FontSize', 9); + end + + if ~isempty(opts.Title) + title(ax, pf2_base.plot.escapeTeX(opts.Title)); + else + title(ax, pf2_base.plot.escapeTeX(sprintf('Directed Connectivity (%s, %s)', result.method, result.biomarker))); + end + + hold(ax, 'off'); + sty.applyToAxes(ax); +end diff --git a/+exploreFNIRS/+connectivity/plotDynamicFC.m b/+exploreFNIRS/+connectivity/plotDynamicFC.m new file mode 100644 index 00000000..85fe6982 --- /dev/null +++ b/+exploreFNIRS/+connectivity/plotDynamicFC.m @@ -0,0 +1,172 @@ +function fig = plotDynamicFC(dynamicResult, varargin) +% PLOTDYNAMICFC Visualize time-varying functional connectivity +% +% Multi-panel figure showing dynamic connectivity over time. Top panel +% displays global connectivity strength per window. If state detection +% results are provided, a middle panel shows state assignments as a color +% bar and a bottom row displays centroid matrices for each state. +% +% Syntax: +% fig = exploreFNIRS.connectivity.plotDynamicFC(dynamicResult) +% fig = exploreFNIRS.connectivity.plotDynamicFC(dynamicResult, 'States', states) +% fig = exploreFNIRS.connectivity.plotDynamicFC(dynamicResult, 'SavePath', 'dfc.png') +% +% Inputs: +% dynamicResult - Output from computeDynamicFC with: +% .matrices [C x C x W], .windowTimes [W x 1], .method +% +% Name-Value Parameters: +% States - Output from detectStates (default: [], no state display) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 900) +% SaveHeight - Height in pixels (default: 700) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.connectivity.computeDynamicFC, +% exploreFNIRS.connectivity.detectStates + + p = inputParser; + addRequired(p, 'dynamicResult', @isstruct); + addParameter(p, 'States', [], @(v) isempty(v) || isstruct(v)); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 900, @isnumeric); + addParameter(p, 'SaveHeight', 700, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, dynamicResult, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + matrices = dynamicResult.matrices; % [C x C x W] + windowTimes = dynamicResult.windowTimes; + [nCh, ~, nWin] = size(matrices); + hasStates = ~isempty(opts.States); + + % Compute global connectivity per window (mean upper triangle) + triMask = triu(true(nCh), 1); + globalConn = zeros(nWin, 1); + for w = 1:nWin + mat = matrices(:, :, w); + vals = mat(triMask); + globalConn(w) = mean(vals, 'omitnan'); + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + + if hasStates + if isfield(opts.States, 'K') + K = opts.States.K; + else + K = length(opts.States.centroidMatrices); + end + % Layout: top = time series, middle = state bar, bottom = centroids + ax1 = subplot(3, 1, 1, 'Parent', fig); + ax2 = subplot(3, 1, 2, 'Parent', fig); + ax3 = subplot(3, 1, 3, 'Parent', fig); + else + ax1 = axes('Parent', fig); + end + + % Panel 1: Global connectivity over time + plot(ax1, windowTimes, globalConn, '-', 'LineWidth', sty.LineWidth, ... + 'Color', [0.2, 0.4, 0.8]); + xlabel(ax1, 'Time (s)'); + ylabel(ax1, 'Mean Connectivity'); + + if ~isempty(opts.Title) + title(ax1, opts.Title); + else + bioLabel = ''; + if isfield(dynamicResult, 'biomarker') + bioLabel = [', ' dynamicResult.biomarker]; + end + title(ax1, pf2_base.plot.escapeTeX(sprintf('Dynamic FC (%s%s)', ... + dynamicResult.method, bioLabel))); + end + + xlim(ax1, [windowTimes(1), windowTimes(end)]); + grid(ax1, 'on'); + sty.applyToAxes(ax1); + + if hasStates + assignments = opts.States.assignments; + stateColors = lines(K); + + % Panel 2: State color bar + % Create an image of state assignments + stateImg = zeros(1, nWin, 3); + for w = 1:nWin + stateImg(1, w, :) = stateColors(assignments(w), :); + end + image(ax2, windowTimes, 1, stateImg); + set(ax2, 'YTick', []); + xlabel(ax2, 'Time (s)'); + ylabel(ax2, 'State'); + xlim(ax2, [windowTimes(1), windowTimes(end)]); + title(ax2, sprintf('State Assignments (K=%d, mean silhouette=%.2f)', ... + K, mean(opts.States.silhouette, 'omitnan'))); + sty.applyToAxes(ax2); + + % Panel 3: Centroid matrices side by side + delete(ax3); + centroidMatrices = opts.States.centroidMatrices; + + maxAbsVal = 0; + for k = 1:K + cMat = centroidMatrices{k}; + cMat(logical(eye(size(cMat)))) = NaN; + maxAbsVal = max(maxAbsVal, max(abs(cMat(:)), [], 'omitnan')); + end + if maxAbsVal == 0 + maxAbsVal = 1; + end + + for k = 1:K + ax = subplot(3, K, 2*K + k, 'Parent', fig); + cMat = centroidMatrices{k}; + imagesc(ax, cMat, [-maxAbsVal, maxAbsVal]); + axis(ax, 'square'); + title(ax, sprintf('State %d', k), 'Color', stateColors(k, :)); + set(ax, 'XTick', [], 'YTick', []); + colormap(ax, divergingColormap(256)); + + nInState = sum(assignments == k); + xlabel(ax, sprintf('n=%d', nInState)); + sty.applyToAxes(ax); + end + end + + % Save + if ~isempty(opts.SavePath) + pf2_base.plot.handleSave(fig, opts); + end +end + + +function cmap = divergingColormap(n) +% Blue-white-red diverging colormap + half = floor(n / 2); + + r1 = linspace(0.2, 1, half)'; + g1 = linspace(0.3, 1, half)'; + b1 = linspace(0.8, 1, half)'; + + r2 = linspace(1, 0.8, n - half)'; + g2 = linspace(1, 0.2, n - half)'; + b2 = linspace(1, 0.2, n - half)'; + + cmap = [r1 g1 b1; r2 g2 b2]; +end diff --git a/+exploreFNIRS/+connectivity/plotInterROI.m b/+exploreFNIRS/+connectivity/plotInterROI.m new file mode 100644 index 00000000..341fc720 --- /dev/null +++ b/+exploreFNIRS/+connectivity/plotInterROI.m @@ -0,0 +1,93 @@ +function fig = plotInterROI(result, varargin) +% PLOTINTERROI Visualize between-ROI connectivity as chord diagram or matrix +% +% Convenience wrapper that dispatches to plotChord or plotMatrix depending +% on the chosen PlotType. Exists for discoverability alongside computeInterROI. +% +% Syntax: +% fig = exploreFNIRS.connectivity.plotInterROI(result) +% fig = exploreFNIRS.connectivity.plotInterROI(result, 'PlotType', 'matrix') +% fig = exploreFNIRS.connectivity.plotInterROI(result, 'MinThreshold', 0.3) +% +% Inputs: +% result - Connectivity result struct from computeInterROI (or computeMatrix +% with UseROI=true), with fields: .matrix, .pmatrix, .labels, +% .method, .biomarker, .useROI +% +% Name-Value Parameters: +% PlotType - 'chord' (default) or 'matrix' +% MinThreshold - Minimum coupling value to display connections (default: 0) +% Connections below this threshold are hidden. +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 600) +% SaveHeight - Height in pixels (default: 550) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% Example: +% result = exploreFNIRS.connectivity.computeInterROI(processed); +% fig = exploreFNIRS.connectivity.plotInterROI(result, ... +% 'PlotType', 'chord', 'MinThreshold', 0.3); +% +% See also: exploreFNIRS.connectivity.computeInterROI, +% exploreFNIRS.connectivity.plotMatrix, +% exploreFNIRS.connectivity.computeIntraROI + + p = inputParser; + addRequired(p, 'result', @isstruct); + addParameter(p, 'PlotType', 'chord', @(v) ischar(v) && ismember(lower(v), {'chord', 'matrix'})); + addParameter(p, 'MinThreshold', 0, @isnumeric); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 550, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + result = exploreFNIRS.connectivity.normalizeResult(result); + + % Apply threshold: zero out connections below MinThreshold + if opts.MinThreshold > 0 + threshResult = result; + mat = threshResult.matrix; + mat(abs(mat) < opts.MinThreshold & ~eye(size(mat, 1))) = 0; + threshResult.matrix = mat; + else + threshResult = result; + end + + % Build common pass-through arguments + passArgs = {}; + if ~isempty(opts.Title) + passArgs = [passArgs, {'Title', opts.Title}]; + end + passArgs = [passArgs, {'Visible', opts.Visible}]; + if ~isempty(opts.SavePath) + passArgs = [passArgs, {'SavePath', opts.SavePath}]; + end + passArgs = [passArgs, {'SaveWidth', opts.SaveWidth, ... + 'SaveHeight', opts.SaveHeight, ... + 'SaveDPI', opts.SaveDPI}]; + + switch lower(opts.PlotType) + case 'chord' + % Try plotChord; fall back to plotMatrix if not available + if ~isempty(which('exploreFNIRS.connectivity.plotChord')) + fig = exploreFNIRS.connectivity.plotChord(threshResult, passArgs{:}); + else + % Chord plot not yet available, fall back to matrix + warning('exploreFNIRS:connectivity:plotInterROI', ... + 'plotChord not available; falling back to matrix plot.'); + fig = exploreFNIRS.connectivity.plotMatrix(threshResult, passArgs{:}); + end + + case 'matrix' + fig = exploreFNIRS.connectivity.plotMatrix(threshResult, passArgs{:}); + end +end diff --git a/+exploreFNIRS/+connectivity/plotIntraROI.m b/+exploreFNIRS/+connectivity/plotIntraROI.m new file mode 100644 index 00000000..af6fa7bc --- /dev/null +++ b/+exploreFNIRS/+connectivity/plotIntraROI.m @@ -0,0 +1,220 @@ +function fig = plotIntraROI(result, varargin) +% PLOTINTRAAROI Visualize within-ROI coupling as bar chart or radar plot +% +% Renders the output of computeIntraROI as either a bar chart (one bar per +% ROI showing mean within-ROI coupling with variability error bars) or a +% radar/spider plot with ROI names around the circumference. +% +% Syntax: +% fig = exploreFNIRS.connectivity.plotIntraROI(result) +% fig = exploreFNIRS.connectivity.plotIntraROI(result, 'PlotType', 'radar') +% fig = exploreFNIRS.connectivity.plotIntraROI(result, 'SortBy', 'coupling') +% fig = exploreFNIRS.connectivity.plotIntraROI(result, 'SavePath', 'intra.png') +% +% Inputs: +% result - Output struct from computeIntraROI with fields: +% .roiMetrics (struct array), .method +% +% Name-Value Parameters: +% PlotType - 'bar' (default) or 'radar' +% ShowIndividualChannels - Show individual channel pair values (default: false) +% SortBy - Sort ROIs by 'name' (default) or 'coupling' +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 700) +% SaveHeight - Height in pixels (default: 450) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% Example: +% result = exploreFNIRS.connectivity.computeIntraROI(processed); +% fig = exploreFNIRS.connectivity.plotIntraROI(result, ... +% 'PlotType', 'bar', 'SortBy', 'coupling'); +% +% See also: exploreFNIRS.connectivity.computeIntraROI, +% exploreFNIRS.connectivity.plotInterROI + + p = inputParser; + addRequired(p, 'result', @isstruct); + addParameter(p, 'PlotType', 'bar', @(v) ischar(v) && ismember(lower(v), {'bar', 'radar'})); + addParameter(p, 'ShowIndividualChannels', false, @islogical); + addParameter(p, 'SortBy', 'name', @(v) ischar(v) && ismember(lower(v), {'name', 'coupling'})); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 700, @isnumeric); + addParameter(p, 'SaveHeight', 450, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + + metrics = result.roiMetrics; + nROIs = length(metrics); + + % Extract values + roiNames = {metrics.roiName}; + meanVals = [metrics.meanCoupling]; + sdVals = [metrics.sdCoupling]; + + % Sort + switch lower(opts.SortBy) + case 'coupling' + [meanVals, sortIdx] = sort(meanVals, 'descend'); + sdVals = sdVals(sortIdx); + roiNames = roiNames(sortIdx); + metrics = metrics(sortIdx); + case 'name' + [roiNames, sortIdx] = sort(roiNames); + meanVals = meanVals(sortIdx); + sdVals = sdVals(sortIdx); + metrics = metrics(sortIdx); + end + + % Create figure + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'SavePath', opts.SavePath, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight); + + switch lower(opts.PlotType) + case 'bar' + ax = axes('Parent', fig); + plotBar(ax, roiNames, meanVals, sdVals, metrics, opts); + case 'radar' + ax = axes('Parent', fig); + plotRadar(ax, roiNames, meanVals, nROIs, opts); + end + + % Apply style + sty = pf2_base.plot.PlotStyle.getDefault(); + sty.applyToAxes(ax); + + % Title + if ~isempty(opts.Title) + title(ax, opts.Title); + else + title(ax, sprintf('Within-ROI Coupling (%s)', result.method)); + end + + % Save + pf2_base.plot.handleSave(fig, opts); +end + + +function plotBar(ax, roiNames, meanVals, sdVals, metrics, opts) +% Bar chart with one bar per ROI + + nROIs = length(roiNames); + cmap = lines(nROIs); + + hold(ax, 'on'); + + for r = 1:nROIs + bar(ax, r, meanVals(r), 0.6, ... + 'FaceColor', cmap(r, :), 'EdgeColor', 'none', 'FaceAlpha', 0.7); + end + + % Error bars from channel pair variability (SD) + errorbar(ax, 1:nROIs, meanVals, sdVals, 'k.', ... + 'LineWidth', 1.2, 'CapSize', 6); + + % Individual channel pair values + if opts.ShowIndividualChannels + for r = 1:nROIs + mat = metrics(r).matrix; + nCh = size(mat, 1); + utMask = triu(true(nCh), 1); + utVals = mat(utMask); + utVals = utVals(~isnan(utVals)); + if ~isempty(utVals) + jitter = (rand(length(utVals), 1) - 0.5) * 0.2; + plot(ax, r + jitter, utVals, 'o', ... + 'MarkerSize', 3, 'MarkerFaceColor', cmap(r, :) * 0.7, ... + 'MarkerEdgeColor', 'none'); + end + end + end + + hold(ax, 'off'); + + set(ax, 'XTick', 1:nROIs, 'XTickLabel', pf2_base.plot.escapeTeX(roiNames), 'XTickLabelRotation', 30); + xlabel(ax, 'ROI'); + ylabel(ax, 'Mean Within-ROI Coupling'); + xlim(ax, [0.4, nROIs + 0.6]); + box(ax, 'on'); +end + + +function plotRadar(ax, roiNames, meanVals, nROIs, ~) +% Radar/spider plot with ROI names around the circumference + + sty = pf2_base.plot.PlotStyle.getDefault(); + + % Compute angles for each ROI + angles = linspace(0, 2*pi, nROIs + 1); + angles = angles(1:end-1); + + % Normalize values to [0, 1] range for radar display + minVal = min(meanVals); + maxVal = max(meanVals); + if maxVal == minVal + normVals = ones(size(meanVals)) * 0.5; + else + normVals = (meanVals - minVal) / (maxVal - minVal); + end + + % Close the polygon + anglesPlot = [angles, angles(1)]; + normPlot = [normVals, normVals(1)]; + + hold(ax, 'on'); + + % Draw grid circles + gridLevels = [0.25, 0.5, 0.75, 1.0]; + gridClr = sty.GridColor; + for g = gridLevels + theta = linspace(0, 2*pi, 100); + plot(ax, g * cos(theta), g * sin(theta), '-', ... + 'Color', gridClr, 'LineWidth', 0.5); + end + + % Draw radial lines + for r = 1:nROIs + plot(ax, [0, cos(angles(r))], [0, sin(angles(r))], '-', ... + 'Color', gridClr, 'LineWidth', 0.5); + end + + % Plot data polygon + xData = normPlot .* cos(anglesPlot); + yData = normPlot .* sin(anglesPlot); + fill(ax, xData, yData, [0.3, 0.6, 0.9], ... + 'FaceAlpha', 0.3, 'EdgeColor', [0.2, 0.4, 0.7], 'LineWidth', 1.5); + plot(ax, xData, yData, 'o-', ... + 'Color', [0.2, 0.4, 0.7], 'MarkerFaceColor', [0.2, 0.4, 0.7], ... + 'MarkerSize', 5, 'LineWidth', 1.5); + + % Label each ROI + labelOffset = 1.15; + for r = 1:nROIs + lx = labelOffset * cos(angles(r)); + ly = labelOffset * sin(angles(r)); + ha = 'center'; + if cos(angles(r)) > 0.1 + ha = 'left'; + elseif cos(angles(r)) < -0.1 + ha = 'right'; + end + text(ax, lx, ly, sprintf('%s (%.2f)', pf2_base.plot.escapeTeX(roiNames{r}), meanVals(r)), ... + 'HorizontalAlignment', ha, 'FontSize', 9); + end + + hold(ax, 'off'); + + axis(ax, 'equal'); + axis(ax, 'off'); + xlim(ax, [-1.4, 1.4]); + ylim(ax, [-1.4, 1.4]); +end diff --git a/+exploreFNIRS/+connectivity/plotMatrix.m b/+exploreFNIRS/+connectivity/plotMatrix.m new file mode 100644 index 00000000..5e2e8152 --- /dev/null +++ b/+exploreFNIRS/+connectivity/plotMatrix.m @@ -0,0 +1,162 @@ +function fig = plotMatrix(result, varargin) +% PLOTMATRIX Heatmap visualization of a connectivity matrix +% +% Renders a channel-to-channel connectivity matrix as a heatmap with +% optional significance masking and customizable appearance. +% +% Syntax: +% fig = exploreFNIRS.connectivity.plotMatrix(result) +% fig = exploreFNIRS.connectivity.plotMatrix(result, 'SignificanceMask', true) +% fig = exploreFNIRS.connectivity.plotMatrix(result, 'SavePath', 'conn.png') +% +% Inputs: +% result - Connectivity result struct from computeMatrix, with fields: +% .matrix, .pmatrix, .channels, .method, .biomarker +% +% Name-Value Parameters: +% SignificanceMask - Mask non-significant cells (default: false) +% PThreshold - Significance threshold for masking (default: 0.05) +% CLim - Color limits [cmin cmax] (default: [-1, 1]) +% Colormap - Colormap name or matrix (default: 'RdBu_r' diverging) +% ShowValues - Display r values in cells (default: false) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 600) +% SaveHeight - Height in pixels (default: 550) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.connectivity.computeMatrix + + p = inputParser; + addRequired(p, 'result', @isstruct); + addParameter(p, 'SignificanceMask', false, @islogical); + addParameter(p, 'PThreshold', 0.05, @isnumeric); + addParameter(p, 'CLim', [-1, 1], @(v) isnumeric(v) && length(v) == 2); + addParameter(p, 'Colormap', '', @(v) ischar(v) || isnumeric(v)); + addParameter(p, 'ShowValues', false, @islogical); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 550, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + result = exploreFNIRS.connectivity.normalizeResult(result); + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + mat = result.matrix; + channels = result.channels; + nCh = length(channels); + + % Apply significance mask + if opts.SignificanceMask && isfield(result, 'pmatrix') + nonsig = result.pmatrix > opts.PThreshold; + mat(nonsig) = 0; + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + ax = axes('Parent', fig); + + imagesc(ax, mat, opts.CLim); + axis(ax, 'square'); + + % Channel/ROI labels + if isfield(result, 'labels') && ~isempty(result.labels) + chLabels = result.labels; + else + chLabels = arrayfun(@(c) sprintf('Ch%d', c), channels, 'UniformOutput', false); + end + chLabels = pf2_base.plot.escapeTeX(chLabels); + set(ax, 'XTick', 1:nCh, 'XTickLabel', chLabels, 'XTickLabelRotation', 45); + set(ax, 'YTick', 1:nCh, 'YTickLabel', chLabels); + + % Reduce label density for large matrices + if nCh > 20 + tickStep = ceil(nCh / 20); + ticks = 1:tickStep:nCh; + set(ax, 'XTick', ticks, 'XTickLabel', chLabels(ticks)); + set(ax, 'YTick', ticks, 'YTickLabel', chLabels(ticks)); + end + + % Colormap + if isempty(opts.Colormap) + cmap = divergingColormap(256); + elseif ischar(opts.Colormap) + cmap = colormap(ax, opts.Colormap); + else + cmap = opts.Colormap; + end + colormap(ax, cmap); + cb = colorbar(ax); + cb.Label.String = result.method; + + % Show values in cells + if opts.ShowValues && nCh <= 20 + for i = 1:nCh + for j = 1:nCh + if ~isnan(mat(i,j)) && i ~= j + txt = sprintf('%.2f', mat(i,j)); + textColor = 'k'; + if abs(mat(i,j)) > 0.7 + textColor = 'w'; + end + text(ax, j, i, txt, 'HorizontalAlignment', 'center', ... + 'FontSize', sty.LegendFontSize - 2, 'Color', textColor); + end + end + end + end + + % Title + if ~isempty(opts.Title) + title(ax, opts.Title); + else + titleStr = sprintf('Connectivity (%s, %s)', result.method, result.biomarker); + if opts.SignificanceMask + titleStr = sprintf('%s [p < %.2f]', titleStr, opts.PThreshold); + end + title(ax, titleStr); + end + + if isfield(result, 'useROI') && result.useROI + xlabel(ax, 'ROI'); + ylabel(ax, 'ROI'); + else + xlabel(ax, 'Channel'); + ylabel(ax, 'Channel'); + end + + sty.applyToAxes(ax); + + pf2_base.plot.handleSave(fig, opts); +end + + +function cmap = divergingColormap(n) +% Blue-white-red diverging colormap + half = floor(n / 2); + + % Blue to white + r1 = linspace(0.2, 1, half)'; + g1 = linspace(0.3, 1, half)'; + b1 = linspace(0.8, 1, half)'; + + % White to red + r2 = linspace(1, 0.8, n - half)'; + g2 = linspace(1, 0.2, n - half)'; + b2 = linspace(1, 0.2, n - half)'; + + cmap = [r1 g1 b1; r2 g2 b2]; +end diff --git a/+exploreFNIRS/+core/ColorScheme.m b/+exploreFNIRS/+core/ColorScheme.m new file mode 100644 index 00000000..896f9f1b --- /dev/null +++ b/+exploreFNIRS/+core/ColorScheme.m @@ -0,0 +1,435 @@ +classdef ColorScheme +% COLORSCHEME Hierarchical color rules for multi-factor experiment plots +% +% Defines per-value colors and effects that resolve hierarchically across +% factors. Assign base colors to one factor (e.g., Group) and modifier +% effects to another (e.g., Condition) to get distinct, meaningful colors +% for every group combination. +% +% Syntax: +% cs = exploreFNIRS.core.ColorScheme() +% cs = cs.set('Group', 'Patient', [0.85, 0.2, 0.2]) +% cs = cs.set('Condition', 'Easy', 'lighten', 0.25) +% colors = cs.resolve(groups) +% +% Methods: +% set - Define color and/or effect for a factor value +% setBase - Set a global base color (all factors become modifiers) +% setPriority - Override factor priority order +% resolve - Resolve to [nGroups x 3] RGB for a groups struct array +% preview - Visualize resolved colors for all factor combinations +% +% Example: +% cs = exploreFNIRS.core.ColorScheme(); +% cs = cs.set('Group', 'Patient', [0.85, 0.2, 0.2]); +% cs = cs.set('Group', 'Healthy', [0.2, 0.65, 0.3]); +% cs = cs.set('Condition', 'Easy', 'lighten', 0.25); +% cs = cs.set('Condition', 'Hard', 'darken', 0.15); +% +% % Assign to Experiment +% ex.colorScheme = cs; +% fig = ex.plotBar('Biomarker', 'HbO', 'TimeWindow', [5, 20]); +% % Patient|Easy = lighter red, Patient|Hard = darker red +% % Healthy|Easy = lighter green, Healthy|Hard = darker green +% +% See also: exploreFNIRS.core.Experiment, exploreFNIRS.core.getGroupColors + + properties + % Struct array: factor, value, color ([1x3] or []), effect, amount + rules = struct('factor', {}, 'value', {}, 'color', {}, ... + 'effect', {}, 'amount', {}) + + % Cell array of factor names in priority order + % First factor with a color rule = base color source + % Remaining factors = modifiers + priority = {} + + % Global base color [1x3] RGB (optional) + % When set, all factor rules act as modifiers on this base + baseColor = [] + end + + properties (Access = private) + % Track factor order from set() calls for auto-priority + factorOrder = {} + end + + methods + + function obj = set(obj, factor, value, varargin) + % SET Define color and/or effect for a factor-value pair + % + % cs = cs.set(factor, value, color) + % cs = cs.set(factor, value, effectName, amount) + % cs = cs.set(factor, value, color, effectName, amount) + % + % Inputs: + % factor - Factor name (e.g., 'Group', 'Condition') + % value - Factor value (e.g., 'Patient', 'Easy') + % color - [1x3] RGB vector (optional) + % effectName - 'lighten', 'darken', 'saturate', 'desaturate' + % amount - Effect strength 0-1 + + color = []; + effect = ''; + amount = 0; + + if ~isempty(varargin) + idx = 1; + % First optional arg: color or effect name + if isnumeric(varargin{idx}) && numel(varargin{idx}) == 3 + color = varargin{idx}(:)'; + idx = idx + 1; + end + % Next: effect name + amount + if idx <= length(varargin) && ischar(varargin{idx}) + effect = lower(varargin{idx}); + idx = idx + 1; + if idx <= length(varargin) && isnumeric(varargin{idx}) + amount = varargin{idx}; + end + end + end + + % Validate + if ~isempty(color) + validateattributes(color, {'numeric'}, {'size', [1, 3], '>=', 0, '<=', 1}); + end + if ~isempty(effect) + validEffects = {'lighten', 'darken', 'saturate', 'desaturate'}; + if ~ismember(effect, validEffects) + error('exploreFNIRS:core:ColorScheme:set', ... + 'Effect must be one of: %s', strjoin(validEffects, ', ')); + end + end + + % Track factor order for auto-priority + if ~ismember(factor, obj.factorOrder) + obj.factorOrder{end+1} = factor; + end + + % Check for existing rule with same factor+value + found = false; + for i = 1:length(obj.rules) + if strcmp(obj.rules(i).factor, factor) && ... + strcmp(obj.rules(i).value, char(string(value))) + if ~isempty(color) + obj.rules(i).color = color; + end + if ~isempty(effect) + obj.rules(i).effect = effect; + obj.rules(i).amount = amount; + end + found = true; + break; + end + end + + if ~found + newRule = struct('factor', factor, ... + 'value', char(string(value)), ... + 'color', color, ... + 'effect', effect, ... + 'amount', amount); + if isempty(obj.rules) + obj.rules = newRule; + else + obj.rules(end+1) = newRule; + end + end + end + + + function obj = setPriority(obj, factorList) + % SETPRIORITY Override the factor priority order + % + % cs = cs.setPriority({'Group', 'Condition'}) + % + % First factor = base color source; rest = modifiers. + + if ~iscell(factorList) || isempty(factorList) + error('exploreFNIRS:core:ColorScheme:setPriority', ... + 'factorList must be a non-empty cell array of factor names'); + end + obj.priority = factorList; + end + + + function obj = setBase(obj, color) + % SETBASE Set a global base color + % + % cs = cs.setBase([0.5, 0.5, 0.5]) + % + % When set, all factors act as modifiers on this base color. + + validateattributes(color, {'numeric'}, {'size', [1, 3], '>=', 0, '<=', 1}); + obj.baseColor = color; + end + + + function colors = resolve(obj, groups) + % RESOLVE Resolve color scheme to [nGroups x 3] RGB matrix + % + % colors = cs.resolve(groups) + % + % For each group, extracts factor values from gbyTables, then: + % 1. Walks priority list to find base color + % 2. Applies modifier effects from remaining factors + % 3. Falls back to default palette for unmatched groups + + nGroups = length(groups); + colors = nan(nGroups, 3); + + % Determine priority order + prio = obj.priority; + if isempty(prio) + prio = obj.factorOrder; + end + + defaultPalette = exploreFNIRS.core.getGroupColors(nGroups); + + for g = 1:nGroups + T = groups(g).gbyTables; + + % Step 1: find base color + clr = obj.baseColor; + + for pi = 1:length(prio) + factor = prio{pi}; + val = getFactorValue(T, factor); + if isempty(val), continue; end + + rule = findRule(obj.rules, factor, val); + if ~isempty(rule) && ~isempty(rule.color) + clr = rule.color; + break; + end + end + + % Fallback to default palette + if isempty(clr) || any(isnan(clr)) + clr = defaultPalette(g, :); + end + + % Step 2: apply modifier effects from all factors + for pi = 1:length(prio) + factor = prio{pi}; + val = getFactorValue(T, factor); + if isempty(val), continue; end + + rule = findRule(obj.rules, factor, val); + if ~isempty(rule) && ~isempty(rule.effect) + clr = applyEffect(clr, rule.effect, rule.amount); + end + end + + colors(g, :) = clr; + end + end + + + function fig = preview(obj, varargin) + % PREVIEW Visualize the resolved colors for all factor combinations + % + % fig = cs.preview() + % fig = cs.preview('Visible', 'off', 'SavePath', 'scheme.png') + % + % Builds synthetic groups from the rules, resolves colors, and + % renders a horizontal bar chart showing each factor combination + % with its resolved color. + % + % Name-Value Parameters: + % Visible - 'on' (default) or 'off' + % SavePath - File path to save (default: '') + % SaveWidth - Width in pixels (default: 600) + % SaveHeight - Height in pixels (default: 400) + % SaveDPI - Resolution (default: 150) + + p = inputParser; + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, varargin{:}); + opts = p.Results; + + % Build synthetic groups from rules + prio = obj.priority; + if isempty(prio) + prio = obj.factorOrder; + end + [groups, labels] = buildSyntheticGroups(obj.rules, prio); + + if isempty(groups) + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'SavePath', opts.SavePath, 'Width', opts.SaveWidth, ... + 'Height', max(200, opts.SaveHeight)); + ax = axes(fig); + text(ax, 0.5, 0.5, 'No rules defined', ... + 'HorizontalAlignment', 'center', ... + 'FontSize', 12, 'Units', 'normalized'); + axis(ax, 'off'); + pf2_base.plot.handleSave(fig, opts); + return; + end + + % Resolve colors + colors = obj.resolve(groups); + + nGroups = length(groups); + figH = max(200, 40 * nGroups + 80); + if isempty(opts.SavePath) + figH = max(figH, opts.SaveHeight); + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'SavePath', opts.SavePath, 'Width', opts.SaveWidth, ... + 'Height', figH); + ax = axes(fig); + + barh(ax, 1:nGroups, ones(nGroups, 1), 'FaceColor', 'flat'); + bObj = ax.Children(1); + bObj.CData = colors; + + set(ax, 'YTick', 1:nGroups, 'YTickLabel', pf2_base.plot.escapeTeX(labels), ... + 'YDir', 'reverse', 'XTick', []); + xlim(ax, [0, 1.05]); + xlabel(ax, ''); + + % Annotate hex color on each bar + for i = 1:nGroups + hexStr = sprintf('#%02X%02X%02X', ... + round(colors(i,1)*255), round(colors(i,2)*255), round(colors(i,3)*255)); + % Choose text color for readability + lum = 0.299*colors(i,1) + 0.587*colors(i,2) + 0.114*colors(i,3); + if lum > 0.5 + txtClr = [0, 0, 0]; + else + txtClr = [1, 1, 1]; + end + text(ax, 0.5, i, hexStr, ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'middle', ... + 'FontSize', 9, 'Color', txtClr, 'FontWeight', 'bold'); + end + + title(ax, 'ColorScheme Preview'); + box(ax, 'off'); + + pf2_base.plot.handleSave(fig, opts); + end + + end +end + + +%% Local helpers + +function val = getFactorValue(T, factor) +% Extract string value of a factor from a gbyTables row + val = ''; + if ~istable(T), return; end + if ~ismember(factor, T.Properties.VariableNames), return; end + v = T.(factor)(1); + if isnumeric(v) + val = num2str(v); + else + val = char(string(v)); + end +end + + +function rule = findRule(rules, factor, value) +% Find a rule matching factor + value + rule = []; + for i = 1:length(rules) + if strcmp(rules(i).factor, factor) && strcmp(rules(i).value, value) + rule = rules(i); + return; + end + end +end + + +function clr = applyEffect(clr, effect, amount) +% Apply a color modifier effect + switch effect + case 'lighten' + clr = clr + (1 - clr) * amount; + case 'darken' + clr = clr * (1 - amount); + case 'saturate' + hsv = rgb2hsv(clr); + hsv(2) = min(1, hsv(2) + amount * (1 - hsv(2))); + clr = hsv2rgb(hsv); + case 'desaturate' + hsv = rgb2hsv(clr); + hsv(2) = hsv(2) * (1 - amount); + clr = hsv2rgb(hsv); + end + clr = max(0, min(1, clr)); +end + + +function [groups, labels] = buildSyntheticGroups(rules, prio) +% Build synthetic groups struct from ColorScheme rules for preview + if isempty(rules) + groups = []; + labels = {}; + return; + end + + if isempty(prio) + % Fallback: extract from rules + prio = unique({rules.factor}, 'stable'); + end + + factorValues = cell(length(prio), 1); + for f = 1:length(prio) + vals = {}; + for r = 1:length(rules) + if strcmp(rules(r).factor, prio{f}) + if ~ismember(rules(r).value, vals) + vals{end+1} = rules(r).value; %#ok + end + end + end + factorValues{f} = vals; + end + + % Remove factors with no values + hasValues = ~cellfun(@isempty, factorValues); + prio = prio(hasValues); + factorValues = factorValues(hasValues); + + if isempty(prio) + groups = []; + labels = {}; + return; + end + + % Cartesian product of all factor values + nFactors = length(prio); + nValues = cellfun(@length, factorValues); + nCombinations = prod(nValues); + + groups = struct('gbyTables', cell(1, nCombinations)); + labels = cell(nCombinations, 1); + + for c = 1:nCombinations + % Compute indices into each factor's values + idx = c - 1; + T = table(); + parts = cell(nFactors, 1); + for f = nFactors:-1:1 + fi = mod(idx, nValues(f)) + 1; + idx = floor(idx / nValues(f)); + T.(prio{f}) = categorical({factorValues{f}{fi}}); + parts{f} = factorValues{f}{fi}; + end + groups(c).gbyTables = T; + labels{c} = strjoin(parts, ' | '); + end +end diff --git a/+exploreFNIRS/+core/Experiment.m b/+exploreFNIRS/+core/Experiment.m new file mode 100644 index 00000000..064624d0 --- /dev/null +++ b/+exploreFNIRS/+core/Experiment.m @@ -0,0 +1,4096 @@ +classdef Experiment < handle +% EXPERIMENT Scriptable container for multi-subject fNIRS group analysis +% +% The Experiment class provides a CLI-friendly interface to exploreFNIRS +% group analysis operations. It wraps data organization, filtering, +% grouping, hierarchical averaging, and export into a chainable API +% that doesn't require the GUI. +% +% Syntax: +% ex = exploreFNIRS.core.Experiment(data) +% ex = exploreFNIRS.core.Experiment(data, 'Hierarchy', {'SubjectID','Session'}) +% ex2 = exploreFNIRS.core.Experiment(ex) % copy data, settings, hierarchy +% +% Inputs: +% data - Cell array of processed fNIRS structs (from processFNIRS2), +% or another Experiment object (copies data, settings, hierarchy) +% +% Name-Value Parameters: +% Hierarchy - Cell array of hierarchy level names (default: +% {'SubjectID','Session','Condition','Trial','Block'}) +% Column 1 = highest level (Subject), last = lowest (Trial). +% Used for within-subject averaging to prevent pseudoreplication. +% +% Example: +% % Load processed data +% files = dir('processed/*.mat'); +% data = cell(length(files), 1); +% for i = 1:length(files) +% tmp = load(fullfile(files(i).folder, files(i).name)); +% data{i} = tmp.processed; +% end +% +% % Create experiment and run analysis +% ex = exploreFNIRS.core.Experiment(data); +% ex.select('Group', 'Control', 'Condition', {'Natural','Synthetic'}); +% ex.groupby({'Group', 'Condition'}); +% ex.aggregate(); +% +% % View results +% ex.summary(); +% +% % Export +% T = ex.toLongTable({'HbO','HbR'}); +% writetable(T, 'results.csv'); +% +% See also: exploreFNIRS, processFNIRS2, grandAvgFNIRS + + properties + % Cell array of processed fNIRS structs (immutable source data) + data + + % Metadata table built from data.info fields + dataTable + + % Hierarchy levels for within-subject averaging + % Default: {'SubjectID','Session','Condition','Trial','Block'} + hierarchy + + % Analysis settings + settings + + % Hierarchical color scheme for plots (optional) + % See also: exploreFNIRS.core.ColorScheme + colorScheme + + % Named color scheme presets (struct of ColorScheme objects) + % Use addColorScheme/useColorScheme to manage. + % See also: exploreFNIRS.core.ColorScheme + colorSchemes + end + + properties (SetAccess = private) + % Logical index into data for current selection + selectedIdx + + % Variable names used for current grouping + groupByVars + + % Struct array of grouped data (matches ExFNIRS.gby format) + groups + + % State flags + isGrouped + isAggregated + + % Transient state snapshot (used by PlotProxy for save/restore) + stateSnapshot + + % Preserved order from select() for each variable + % struct with field names = variable names, values = ordered cell arrays + selectOrder_ + + % Per-segment preprocessing cache that survives reset() + % Cell array same size as obj.data; each element is [] or + % struct('pp', preprocessedSeg, 'bar', barSeg) + ppCache_ + + % ppKey string for which ppCache_ is valid + ppCacheKey_ + end + + properties (Dependent, SetAccess = private) + % PlotProxy for grammar-of-graphics style plotting + plot + end + + methods + + function obj = Experiment(data, varargin) + % EXPERIMENT Create a new Experiment from processed fNIRS data + % + % ex = Experiment(data) + % ex = Experiment(data, 'Hierarchy', {'SubjectID','Condition'}) + % ex2 = Experiment(ex) % copy data, settings, hierarchy + + % Copy from another Experiment (source hierarchy as default, + % caller's varargin appended last so it takes priority) + if isa(data, 'exploreFNIRS.core.Experiment') + src = data; + data = src.data; + varargin = [{'Hierarchy', src.hierarchy}, varargin]; + end + + if ~iscell(data) || isempty(data) + error('exploreFNIRS:core:Experiment', ... + 'Input must be a non-empty cell array of fNIRS structs'); + end + + % Force column cell array + obj.data = data(:); + + % Build metadata table + obj.dataTable = exploreFNIRS.dataset.buildSegmentInfoTable(obj.data); + + % Add missingFNIRS column (required by export functions) + % All data passed to Experiment is assumed valid + obj.dataTable.missingFNIRS = zeros(height(obj.dataTable), 1); + + % Parse options + p = inputParser; + addParameter(p, 'Hierarchy', ... + {'SubjectID','Session','Condition','Trial','Block'}, @iscell); + parse(p, varargin{:}); + obj.hierarchy = p.Results.Hierarchy; + + % Default settings + obj.settings = struct( ... + 'baseline', [-5, 0], ... % [start, end] seconds for baseline window + 'taskStart', 0, ... % task onset time (for bin alignment) + 'taskEnd', Inf, ... % task end time (Inf = use full segment) + 'resampleRate', 0.5, ... % seconds per bin for temporal (0 = no resample) + 'barBinSize', 0, ... % seconds per bin for bar (0 = full task window, 1 bar) + 'useBaseline', true, ... % apply baseline correction + 'avgMode', 'hierarchy', ... % 'hierarchy', 'flat', or 'none' + 'rawMethod', '', ... % Raw processing method name ('' = no reprocessing) + 'oxyMethod', '', ... % Oxy processing method name ('' = no reprocessing) + 'statWindow', [], ... % [start, end] for bar/LME stats ([] = full range) + 'viewPad', [5, 5], ... % Plot-only padding (seconds) around the + ... % baseline-start / task-end edges. Affects what + ... % plotTemporal/plotHeatmap show only; bar values + ... % stay pinned to [taskStart, taskEnd]. + ... % [] = strict trim to [taskStart, taskEnd] + ... % scalar n = symmetric pad [n, n] + ... % [pre, post] = asymmetric pad + ... % Pre extends below baseline(1) (or taskStart if + ... % useBaseline=false). Post extends above taskEnd + ... % (or segment max if taskEnd=Inf). + 'timeModel', '', ... % TimeModel for LME: 'polynomial','discrete','continuous','none' ('' = fitLME default) + 'polyOrder', 2 ... % Polynomial order for GCA (1-5, default: 2) + ); + + % Copy settings from source Experiment + if exist('src', 'var') + obj.settings = src.settings; + end + + % Initialize color schemes + obj.colorSchemes = struct(); + + % Initialize state + obj.selectedIdx = true(length(obj.data), 1); + obj.groupByVars = {}; + obj.groups = []; + obj.isGrouped = false; + obj.isAggregated = false; + obj.ppCache_ = cell(length(obj.data), 1); + obj.ppCacheKey_ = ''; + end + + + function proxy = get.plot(obj) + % GET.PLOT Return a PlotProxy linked to this Experiment + % + % fig = ex.plot.bar('X', 'Condition', 'Color', 'Group', 'Channels', 5) + % fig = ex.plot.temporal('Color', 'Group', 'Channels', 1:5) + % + % See also: exploreFNIRS.core.PlotProxy + proxy = exploreFNIRS.core.PlotProxy(obj); + end + + + function obj = addColorScheme(obj, name, cs) + % ADDCOLORSCHEME Register a named color scheme preset + % + % ex.addColorScheme('byGroup', csGroup) + % ex.addColorScheme('byCondition', csCond) + % + % Stores the ColorScheme under the given name for later use with + % useColorScheme() or per-plot 'ColorScheme' parameter. + % + % See also: useColorScheme, removeColorScheme, ColorScheme + + if ~isvarname(name) + error('exploreFNIRS:core:Experiment:addColorScheme', ... + 'Name "%s" is not a valid MATLAB identifier.', name); + end + if ~isa(cs, 'exploreFNIRS.core.ColorScheme') + error('exploreFNIRS:core:Experiment:addColorScheme', ... + 'Value must be an exploreFNIRS.core.ColorScheme object.'); + end + obj.colorSchemes.(name) = cs; + end + + + function obj = removeColorScheme(obj, name) + % REMOVECOLORSCHEME Remove a named color scheme preset + % + % ex.removeColorScheme('byGroup') + % + % See also: addColorScheme, useColorScheme + + if ~isfield(obj.colorSchemes, name) + error('exploreFNIRS:core:Experiment:removeColorScheme', ... + 'Color scheme "%s" not found.', name); + end + obj.colorSchemes = rmfield(obj.colorSchemes, name); + end + + + function obj = useColorScheme(obj, name) + % USECOLORSCHEME Set the active color scheme from a named preset + % + % ex.useColorScheme('byGroup') + % + % Looks up the named preset and assigns it to ex.colorScheme. + % + % See also: addColorScheme, removeColorScheme + + if ~isfield(obj.colorSchemes, name) + error('exploreFNIRS:core:Experiment:useColorScheme', ... + 'Color scheme "%s" not found. Available: %s', ... + name, strjoin(fieldnames(obj.colorSchemes), ', ')); + end + obj.colorScheme = obj.colorSchemes.(name); + end + + + function out = select(obj, varargin) + % SELECT Filter data by metadata criteria + % + % ex.select('VarName', value, ...) % mutate in place + % ex2 = ex.select('VarName', value, ...) % return independent copy + % + % When an output is captured, select() returns a NEW Experiment + % containing only the matching segments (the original is unchanged). + % When called without capturing an output, it filters in place. + % + % Values can be: + % - String/char: exact match (e.g., 'Group', 'Control') + % - Cell/string array: match any (e.g., 'Condition', {'A','B'}) + % - Numeric scalar: exact match + % - Numeric vector: match any + % + % Calling select() again narrows the current selection (AND logic). + % Use reset() first to start fresh. + % + % Example: + % ex.select('Group', 'Control'); % in-place + % ex2 = ex.select('Condition', {'Task1','Task2'}); % new copy + + if mod(length(varargin), 2) ~= 0 + error('exploreFNIRS:core:Experiment:select', ... + 'Arguments must be name-value pairs'); + end + + idx = obj.selectedIdx; + + selectOrder = struct(); + + for i = 1:2:length(varargin) + varName = varargin{i}; + varVal = varargin{i+1}; + + if ~ismember(varName, obj.dataTable.Properties.VariableNames) + error('exploreFNIRS:core:Experiment:select', ... + 'Variable "%s" not found in dataTable. Available: %s', ... + varName, strjoin(obj.dataTable.Properties.VariableNames, ', ')); + end + + col = obj.dataTable.(varName); + + if ischar(varVal) + varVal = string(varVal); + end + + if isstring(varVal) || iscell(varVal) + % String matching + varVal = string(varVal); + if isstring(col) || iscategorical(col) || iscell(col) + idx = idx & ismember(string(col), varVal); + else + idx = idx & ismember(col, varVal); + end + % Store the user-specified order for this variable + selectOrder.(varName) = cellstr(varVal); + elseif isnumeric(varVal) + idx = idx & ismember(col, varVal); + if numel(varVal) > 1 + selectOrder.(varName) = varVal; + end + else + error('exploreFNIRS:core:Experiment:select', ... + 'Unsupported value type for "%s"', varName); + end + end + + nSel = sum(idx); + nTot = length(idx); + + if nargout > 0 + % Return a new independent Experiment with selected data + out = exploreFNIRS.core.Experiment(obj.data(idx), ... + 'Hierarchy', obj.hierarchy); + out.settings = obj.settings; + out.selectOrder_ = selectOrder; + if ~isempty(obj.colorScheme) + out.colorScheme = obj.colorScheme; + end + out.colorSchemes = obj.colorSchemes; + fprintf('Selected %d of %d segments (new Experiment)\n', nSel, nTot); + else + % Mutate in place + obj.selectedIdx = idx; + obj.isGrouped = false; + obj.isAggregated = false; + obj.groups = []; + % Merge selectOrder into existing + if isempty(obj.selectOrder_) + obj.selectOrder_ = selectOrder; + else + fns = fieldnames(selectOrder); + for fi = 1:numel(fns) + obj.selectOrder_.(fns{fi}) = selectOrder.(fns{fi}); + end + end + fprintf('Selected %d of %d segments\n', nSel, nTot); + end + end + + + function obj = reset(obj) + % RESET Clear selection and grouping, return to full dataset + % + % ex.reset(); + + obj.selectedIdx = true(length(obj.data), 1); + obj.groupByVars = {}; + obj.groups = []; + obj.isGrouped = false; + obj.isAggregated = false; + end + + + function selData = getSelectedData(obj) + % GETSELECTEDDATA Return cell array of currently selected fNIRS structs + selData = obj.data(obj.selectedIdx); + end + + + function selTable = getSelectedTable(obj) + % GETSELECTEDTABLE Return metadata table for current selection + selTable = obj.dataTable(obj.selectedIdx, :); + end + + + function obj = groupby(obj, vars) + % GROUPBY Group selected data by metadata variables + % + % ex.groupby({'Group', 'Condition'}) + % ex.groupby('Group') + % + % Creates groups based on unique combinations of the specified + % variables. Must be called after select() (or on full dataset). + % Must be called before aggregate(). + + if ischar(vars) || isstring(vars) + vars = cellstr(vars); + end + + % Validate variable names exist + selTable = obj.getSelectedTable(); + for i = 1:length(vars) + if ~ismember(vars{i}, selTable.Properties.VariableNames) + error('exploreFNIRS:core:Experiment:groupby', ... + 'Variable "%s" not found. Available: %s', ... + vars{i}, strjoin(selTable.Properties.VariableNames, ', ')); + end + end + + obj.groupByVars = vars; + selData = obj.getSelectedData(); + selIdx = find(obj.selectedIdx); % original data indices + + [groupRows, ~, gbyIdx] = unique(selTable(:, vars), 'rows'); + nGroups = max(gbyIdx); + + % Reorder groups to match select() order when available + if ~isempty(obj.selectOrder_) && length(vars) == 1 && ... + isfield(obj.selectOrder_, vars{1}) + desiredOrder = obj.selectOrder_.(vars{1}); + if iscell(desiredOrder) + currentOrder = cellstr(string(groupRows.(vars{1}))); + [~, newIdx] = ismember(desiredOrder, currentOrder); + newIdx = newIdx(newIdx > 0); + % Append any groups not in the desired order + remaining = setdiff(1:nGroups, newIdx, 'stable'); + newIdx = [newIdx, remaining]; + if length(newIdx) == nGroups + % Remap gbyIdx to new order + invMap = zeros(1, nGroups); + invMap(newIdx) = 1:nGroups; + gbyIdx = invMap(gbyIdx)'; + groupRows = groupRows(newIdx, :); + end + end + end + + obj.groups = []; + for g = 1:nGroups + mask = gbyIdx == g; + obj.groups(g).gbyTables = selTable(mask, :); + obj.groups(g).gbyFNIRS = selData(mask); + obj.groups(g).dataIdx = selIdx(mask); % original indices into obj.data + obj.groups(g).gbyGrand = []; + obj.groups(g).gbyGrandBarFlat = []; + obj.groups(g).gbyFNIRS_pp = {}; + obj.groups(g).cache = struct('ppData', {{}}, 'barData', {{}}, 'ppKey', ''); + + % Build human-readable label + rowVals = cell(1, length(vars)); + for v = 1:length(vars) + val = groupRows.(vars{v})(g); + if isnumeric(val) + rowVals{v} = num2str(val); + else + rowVals{v} = char(string(val)); + end + end + obj.groups(g).label = strjoin(rowVals, ' | '); + end + + obj.isGrouped = true; + obj.isAggregated = false; + + fprintf('Created %d groups:\n', nGroups); + for g = 1:nGroups + fprintf(' [%d] %s (%d segments)\n', ... + g, obj.groups(g).label, size(obj.groups(g).gbyTables, 1)); + end + end + + + function obj = aggregate(obj, mode) + % AGGREGATE Preprocess segments and compute grand averages + % + % ex.aggregate() % Uses settings.avgMode (default: 'hierarchy') + % ex.aggregate('hierarchy') % Full hierarchical averaging + % ex.aggregate('flat') % Average within subject only + % ex.aggregate('none') % No within-subject averaging + % + % Preprocessing (controlled by settings): + % If settings.resampleRate > 0, each segment is resampled to that + % bin size (in seconds). If settings.useBaseline is true, the + % baseline window (settings.baseline) is extracted and subtracted. + % + % Two grand averages are produced per group: + % + % gbyGrand - Temporal grand average. Time vector is widened + % by settings.viewPad (default [5,5]) so that + % plotTemporal/plotHeatmap can show samples + % before baseline and after task end. Used by + % plotTemporal, plotHeatmap, and the time-mask + % in plotBar (see settings.statWindow). + % + % gbyGrandBarFlat - Bar grand average. Time vector is strictly + % [0, taskDuration) regardless of viewPad. Used + % by toLongTable / toWideTable, writeCSV, + % statsFitLME, plotScatter, and + % plotNeuralEfficiency. Bar values stay pinned + % to the task window even when viewPad widens + % the temporal view. + % + % Averaging Modes: + % 'hierarchy' - Averages bottom-up through hierarchy levels + % (Trial -> Condition -> Session -> Subject) + % Prevents pseudoreplication. + % 'flat' - Average all observations per subject (one value each) + % 'none' - Each observation treated independently + + if ~obj.isGrouped + error('exploreFNIRS:core:Experiment:aggregate', ... + 'Call groupby() before aggregate()'); + end + + % Clear prior aggregation results to allow safe re-aggregation + for g = 1:length(obj.groups) + obj.groups(g).gbyGrand = []; + obj.groups(g).gbyGrandBarFlat = []; + obj.groups(g).gbyFNIRS_pp = []; + end + obj.isAggregated = false; + + if nargin < 2 + mode = obj.settings.avgMode; + end + + s = obj.settings; + doResample = s.resampleRate > 0; + doBaseline = s.useBaseline && ~isempty(s.baseline); + + % Find which hierarchy columns actually exist in the data + availableVars = obj.dataTable.Properties.VariableNames; + validHierarchy = intersect(obj.hierarchy, availableVars, 'stable'); + + if doResample || doBaseline + if isfinite(s.taskEnd) + fprintf('Preprocessing: resample=%.2fs, baseline=[%.1f, %.1f]s, task=[%.1f, %.1f]s', ... + s.resampleRate, s.baseline(1), s.baseline(2), s.taskStart, s.taskEnd); + else + fprintf('Preprocessing: resample=%.2fs, baseline=[%.1f, %.1f]s, taskStart=%.1fs', ... + s.resampleRate, s.baseline(1), s.baseline(2), s.taskStart); + end + if ~isempty(s.viewPad) + pad = s.viewPad; + if isscalar(pad), pad = [pad, pad]; end + fprintf(', viewPad=[%.1f, %.1f]s', pad(1), pad(2)); + end + fprintf('\n'); + end + fprintf('Aggregating %d groups (mode: %s)...\n', length(obj.groups), mode); + + % --- Build reprocessing args if methods are specified --- + hasMethodSet = ~isempty(s.rawMethod) || ~isempty(s.oxyMethod); + reprocessArgs = {}; + if hasMethodSet + if ~isempty(s.rawMethod) && ~isempty(s.oxyMethod) + reprocessArgs = {s.rawMethod, s.oxyMethod}; + elseif ~isempty(s.rawMethod) + reprocessArgs = {s.rawMethod}; + elseif ~isempty(s.oxyMethod) + % Only oxyMethod set: must pass both positional args + % to avoid oxyMethod being interpreted as rawMethod. + % Look up current rawMethod from the first segment. + curRaw = 'None'; + firstData = obj.groups(1).gbyFNIRS; + if ~isempty(firstData) && isfield(firstData{1}, 'processingInfo') ... + && isfield(firstData{1}.processingInfo, 'rawMethod') + curRaw = firstData{1}.processingInfo.rawMethod; + end + reprocessArgs = {curRaw, s.oxyMethod}; + end + end + + % --- Stage A: Reprocessing + Preprocessing (sequential) --- + nGroups = length(obj.groups); + ppKey = buildPPKey(s); + allPPData = cell(1, nGroups); + allBarData = cell(1, nGroups); + allHVars = cell(1, nGroups); + allFlatH = cell(1, nGroups); + allBarBins = nan(1, nGroups); + skipGroup = false(1, nGroups); + + % Invalidate per-segment cache if preprocessing settings changed + if ~strcmp(ppKey, obj.ppCacheKey_) + obj.ppCache_ = cell(length(obj.data), 1); + obj.ppCacheKey_ = ppKey; + end + + for g = 1:nGroups + curData = obj.groups(g).gbyFNIRS; + curTable = obj.groups(g).gbyTables; + dataIdx = obj.groups(g).dataIdx; + + % Skip empty groups + if isempty(curData) + warning('Group %d (%s) is empty, skipping', g, obj.groups(g).label); + skipGroup(g) = true; + continue; + end + + % --- Reprocess only if methods changed since last aggregate --- + if hasMethodSet + cachedRaw = ''; + cachedOxy = ''; + if isfield(obj.groups(g), 'cache') && ~isempty(obj.groups(g).cache) + if isfield(obj.groups(g).cache, 'rawMethod') + cachedRaw = obj.groups(g).cache.rawMethod; + end + if isfield(obj.groups(g).cache, 'oxyMethod') + cachedOxy = obj.groups(g).cache.oxyMethod; + end + end + + methodChanged = ~strcmp(cachedRaw, s.rawMethod) || ... + ~strcmp(cachedOxy, s.oxyMethod); + + if methodChanged + curData = processFNIRS2(curData, reprocessArgs{:}); + obj.groups(g).gbyFNIRS = curData; % persist reprocessed data + obj.groups(g).cache.rawMethod = s.rawMethod; + obj.groups(g).cache.oxyMethod = s.oxyMethod; + % Invalidate per-segment cache for reprocessed segments + for ri = 1:length(dataIdx) + obj.ppCache_{dataIdx(ri)} = []; + end + fprintf(' [%d] %s: reprocessed %d segments (raw=%s, oxy=%s)\n', ... + g, obj.groups(g).label, length(curData), s.rawMethod, s.oxyMethod); + end + end + + % --- Preprocessing (cached per-segment) --- + allCached = all(~cellfun('isempty', obj.ppCache_(dataIdx))); + + if allCached + ppData = cell(size(curData)); + barData = cell(size(curData)); + for i = 1:length(curData) + cached = obj.ppCache_{dataIdx(i)}; + ppData{i} = cached.pp; + barData{i} = cached.bar; + end + allPPData{g} = ppData; + allBarData{g} = barData; + fprintf(' [%d] %s: using cached preprocessing (%d segments)\n', ... + g, obj.groups(g).label, length(curData)); + else + [allPPData{g}, allBarData{g}] = preprocessGroup(curData, s, doResample, doBaseline); + % Store in per-segment cache + for i = 1:length(curData) + obj.ppCache_{dataIdx(i)} = struct('pp', allPPData{g}{i}, 'bar', allBarData{g}{i}); + end + end + + % Build hierarchy args (cheap, needed for grandAvgFNIRS) + allHVars{g} = buildHierarchyVars(curTable, validHierarchy, mode); + allFlatH{g} = buildHierarchyVars(curTable, validHierarchy, 'flat'); + allBarBins(g) = computeBarBin(s, curData); + end + + % --- Stage B: Grand averaging (parallel when pool available) --- + activeIdx = find(~skipGroup); + nActive = length(activeIdx); + + gaResults = cell(1, nGroups); + gaBarResults = cell(1, nGroups); + + [canUse, poolRunning] = pf2_base.accel.canParfor(); + useParfor = canUse && poolRunning && nActive > 2; + + if useParfor + % Extract loop variables for parfor compatibility + ppCells = allPPData(activeIdx); + barCells = allBarData(activeIdx); + hVCells = allHVars(activeIdx); + fhCells = allFlatH(activeIdx); + bbVec = allBarBins(activeIdx); + + tmpGA = cell(1, nActive); + tmpBar = cell(1, nActive); + + parfor k = 1:nActive + tmpGA{k} = grandAvgFNIRS(ppCells{k}, false, [], false, hVCells{k}, false, true); + tmpBar{k} = grandAvgFNIRS(barCells{k}, false, bbVec(k), false, fhCells{k}, false, true); + end + + for k = 1:nActive + gaResults{activeIdx(k)} = tmpGA{k}; + gaBarResults{activeIdx(k)} = tmpBar{k}; + end + else + for k = 1:nActive + g = activeIdx(k); + gaResults{g} = grandAvgFNIRS(allPPData{g}, false, [], false, allHVars{g}, false, true); + gaBarResults{g} = grandAvgFNIRS(allBarData{g}, false, allBarBins(g), false, allFlatH{g}, false, true); + end + end + + % --- Write results back to obj --- + for g = 1:nGroups + if skipGroup(g), continue; end + + obj.groups(g).gbyGrand = gaResults{g}; + obj.groups(g).gbyGrandBarFlat = gaBarResults{g}; + obj.groups(g).gbyFNIRS_pp = allPPData{g}; + + hasCachedPP = ~isempty(obj.groups(g).cache) && ... + isfield(obj.groups(g).cache, 'ppKey') && ... + strcmp(obj.groups(g).cache.ppKey, ppKey); + + % Persist the preprocessing cache on the group so that + % subsequent aggregate() calls that only change the averaging + % mode (not a preprocessing setting) can detect a cache hit. + obj.groups(g).cache.ppKey = ppKey; + obj.groups(g).cache.ppData = allPPData{g}; + obj.groups(g).cache.barData = allBarData{g}; + + if hasCachedPP + fprintf(' [%d] %s: re-averaged (%s mode)\n', ... + g, obj.groups(g).label, mode); + else + fprintf(' [%d] %s: %d segments -> grand average\n', ... + g, obj.groups(g).label, length(obj.groups(g).gbyFNIRS)); + end + end + + obj.isAggregated = true; + fprintf('Done.\n'); + end + + + function T = toLongTable(obj, bioMarkers, channels, times, varargin) + % TOLONGTABLE Export grouped data to long format table + % + % T = ex.toLongTable() + % T = ex.toLongTable({'HbO','HbR'}) + % T = ex.toLongTable({'HbO'}, 1:10, [0 5 10]) + % T = ex.toLongTable({'HbO'}, 1:5, [], 'IncludeAux', true) + % T = ex.toLongTable({'HbO'}, [], [], 'IncludeROI', true) + % + % Name-Value Parameters: + % IncludeAux - Include auxiliary data columns (default: false) + % IncludeROI - Include ROI-averaged data columns (default: false) + % + % See also: mergeGbyTablesLong + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:toLongTable', ... + 'Call aggregate() before exporting'); + end + if nargin < 2, bioMarkers = {'HbO','HbR','HbDiff','HbTotal','CBSI'}; end + if nargin < 3, channels = []; end + if nargin < 4, times = []; end + + ip = inputParser; + addParameter(ip, 'IncludeAux', false, @islogical); + addParameter(ip, 'IncludeROI', false, @islogical); + parse(ip, varargin{:}); + + % Build channel labels as cell array + if ~isempty(channels) + chLabels = cellstr(num2str(channels(:))); + else + chLabels = {}; + end + + T = exploreFNIRS.export.mergeGbyTablesLong( ... + obj.groups, bioMarkers, channels, times, ... + ip.Results.IncludeAux, ip.Results.IncludeROI, chLabels); + end + + + function T = toWideTable(obj, bioMarkers, channels, times, varargin) + % TOWIDETABLE Export grouped data to wide format table + % + % T = ex.toWideTable() + % T = ex.toWideTable({'HbO','HbR'}) + % T = ex.toWideTable({'HbO'}, 1:10, [0 5 10]) + % T = ex.toWideTable({'HbO'}, 1:5, [], 'IncludeAux', true) + % T = ex.toWideTable({'HbO'}, [], [], 'IncludeROI', true) + % + % Name-Value Parameters: + % IncludeAux - Include auxiliary data columns (default: false) + % IncludeROI - Include ROI-averaged data columns (default: false) + % + % See also: mergeGbyTablesWide + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:toWideTable', ... + 'Call aggregate() before exporting'); + end + if nargin < 2, bioMarkers = {'HbO','HbR','HbDiff','HbTotal','CBSI'}; end + if nargin < 3, channels = []; end + if nargin < 4, times = []; end + + ip = inputParser; + addParameter(ip, 'IncludeAux', false, @islogical); + addParameter(ip, 'IncludeROI', false, @islogical); + parse(ip, varargin{:}); + + % Build channel labels as cell array + if ~isempty(channels) + chLabels = cellstr(num2str(channels(:))); + else + chLabels = {}; + end + + T = exploreFNIRS.export.mergeGbyTablesWide( ... + obj.groups, bioMarkers, channels, times, ... + ip.Results.IncludeAux, ip.Results.IncludeROI, chLabels); + end + + + function T = writeCSV(obj, filepath, varargin) + % WRITECSV Export aggregated data to a CSV file + % + % ex.writeCSV('results.csv') + % ex.writeCSV('results.csv', 'Biomarkers', {'HbO','HbR'}) + % ex.writeCSV('results.csv', 'Format', 'wide') + % T = ex.writeCSV('results.csv') % also returns the table + % + % Name-Value Parameters: + % Format - 'long' (default) or 'wide' + % Biomarkers - Cell array of biomarker names (default: all) + % Channels - Channel indices (default: all) + % Times - Time points (default: all) + % IncludeAux - Include auxiliary data (default: false) + % IncludeROI - Include ROI-averaged data (default: false) + % + % See also: toLongTable, toWideTable + + ip = inputParser; + addRequired(ip, 'filepath', @ischar); + addParameter(ip, 'Format', 'long', @ischar); + addParameter(ip, 'Biomarkers', {'HbO','HbR','HbDiff','HbTotal','CBSI'}, @iscell); + addParameter(ip, 'Channels', [], @isnumeric); + addParameter(ip, 'Times', [], @isnumeric); + addParameter(ip, 'IncludeAux', false, @islogical); + addParameter(ip, 'IncludeROI', false, @islogical); + parse(ip, filepath, varargin{:}); + opts = ip.Results; + + if strcmpi(opts.Format, 'wide') + T = obj.toWideTable(opts.Biomarkers, opts.Channels, opts.Times, ... + 'IncludeAux', opts.IncludeAux, 'IncludeROI', opts.IncludeROI); + else + T = obj.toLongTable(opts.Biomarkers, opts.Channels, opts.Times, ... + 'IncludeAux', opts.IncludeAux, 'IncludeROI', opts.IncludeROI); + end + + writetable(T, filepath); + fprintf('Wrote %d rows x %d columns to %s\n', ... + height(T), width(T), filepath); + end + + + function fig = plotExperimentTimeline(obj, varargin) + % PLOTEXPERIMENTTIMELINE Visualize experiment time settings as a diagram + % + % fig = ex.plotExperimentTimeline() + % fig = ex.plotExperimentTimeline('SavePath', 'timeline.png') + % + % Shows baseline, task block, temporal resample, and bar resample + % settings. Does not require aggregate(). Useful for verifying + % configuration before processing. + % + % See also: exploreFNIRS.core.plotExperimentTimeline + + % Infer data time range from first selected segment + selData = obj.data(obj.selectedIdx); + if ~isempty(selData) + seg = selData{1}; + dataRange = [min(seg.time), max(seg.time)]; + else + dataRange = []; + end + fig = exploreFNIRS.core.plotExperimentTimeline(obj.settings, ... + 'DataRange', dataRange, varargin{:}); + end + + + function fig = plotTemporal(obj, varargin) + % PLOTTEMPORAL Headless temporal (time-series) plot + % + % fig = ex.plotTemporal() + % fig = ex.plotTemporal('Biomarkers', {'HbO'}, 'Channels', 1:5) + % ex.plotTemporal('SavePath', 'temporal.png') + % + % The visible time range is set at aggregate() time by + % settings.viewPad (default [5,5] seconds, padding around + % baseline-start and task-end). For visual cropping without + % re-aggregating, pass 'XLim', [tmin tmax]. Bar values produced + % by plotBar are auto-pinned to [taskStart, taskEnd] regardless + % of viewPad — widening the view never changes a bar value. + % + % All name-value arguments are forwarded to + % exploreFNIRS.core.plotTemporal. See help for that function. + % + % See also: exploreFNIRS.core.plotTemporal, plotHeatmap + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotTemporal', ... + 'Call aggregate() before plotting'); + end + varargin = obj.injectColorScheme(varargin); + % Inject Device from data if not explicitly provided + if ~any(strcmpi(varargin(1:2:end), 'Device')) + dev = obj.resolveDevice(); + if ~isempty(dev) + varargin = [varargin, {'Device', dev}]; + end + end + fig = exploreFNIRS.core.plotTemporal(obj.groups, varargin{:}); + end + + + function fig = plotBar(obj, varargin) + % PLOTBAR Headless bar chart plot + % + % fig = ex.plotBar() + % fig = ex.plotBar('Biomarker', 'HbO', 'Channels', 1:5) + % ex.plotBar('TimeWindow', [5, 20], 'SavePath', 'bar.png') + % + % All name-value arguments are forwarded to + % exploreFNIRS.core.plotBar. See help for that function. + % + % See also: exploreFNIRS.core.plotBar + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotBar', ... + 'Call aggregate() before plotting'); + end + varargin = obj.injectColorScheme(varargin); + varargin = obj.injectStatWindow(varargin); + % Inject Device from data if not explicitly provided + if ~any(strcmpi(varargin(1:2:end), 'Device')) + dev = obj.resolveDevice(); + if ~isempty(dev) + varargin = [varargin, {'Device', dev}]; + end + end + fig = exploreFNIRS.core.plotBar(obj.groups, ... + 'GroupByVars', obj.groupByVars, varargin{:}); + end + + + function fig = plotAux(obj, auxField, varargin) + % PLOTAUX Plot auxiliary signal timeseries by group + % + % fig = ex.plotAux('accelerometer') + % fig = ex.plotAux('heartRate', 'AuxChannels', 1) + % fig = ex.plotAux('accelerometer', 'Layout', 'grid', 'SavePath', 'accel.png') + % + % Plots multichannel auxiliary data (accelerometer, heart rate, + % respiration, etc.) as time-series with error bands per group. + % Requires aggregate() to have been called first. + % + % Use ex.auxFields() to see available Aux fields. + % + % See also: exploreFNIRS.core.plotAux, auxFields + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotAux', ... + 'Call aggregate() before plotting'); + end + varargin = obj.injectColorScheme(varargin); + fig = exploreFNIRS.core.plotAux(obj.groups, auxField, varargin{:}); + end + + + function fig = plotAuxBar(obj, auxField, varargin) + % PLOTAUXBAR Bar chart for auxiliary signal data by group + % + % fig = ex.plotAuxBar('heartRate') + % fig = ex.plotAuxBar('accelerometer', 'TimeWindow', [5, 20]) + % fig = ex.plotAuxBar('heartRate', 'ShowIndividual', true, 'SavePath', 'hr.png') + % + % Plots mean auxiliary variable values per group as bar charts. + % Each aux channel gets its own subplot. Requires aggregate() first. + % + % Use ex.auxFields() to see available Aux fields. + % + % See also: exploreFNIRS.core.plotAuxBar, plotAux, auxFields + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotAuxBar', ... + 'Call aggregate() before plotting'); + end + varargin = obj.injectColorScheme(varargin); + fig = exploreFNIRS.core.plotAuxBar(obj.groups, auxField, ... + 'GroupByVars', obj.groupByVars, varargin{:}); + end + + + function [fig, stats] = plotAuxScatter(obj, auxField, infoVar, varargin) + % PLOTAUXSCATTER Scatter plot correlating info variable vs auxiliary data + % + % [fig, stats] = ex.plotAuxScatter('heartRate', 'Age') + % [fig, stats] = ex.plotAuxScatter('heartRate', 'reactionTime', ... + % 'AuxChannels', 1, 'FitLine', true) + % + % Correlates an info/behavioral variable (X) with auxiliary signal + % channel data (Y). Requires aggregate() first. + % + % See also: exploreFNIRS.core.plotAuxScatter, plotAuxBar, auxFields + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotAuxScatter', ... + 'Call aggregate() before plotAuxScatter()'); + end + varargin = obj.injectColorScheme(varargin); + [fig, stats] = exploreFNIRS.core.plotAuxScatter(obj.groups, ... + auxField, 'InfoVar', infoVar, varargin{:}); + end + + + function flds = auxFields(obj) + % AUXFIELDS List available auxiliary data fields after aggregation + % + % flds = ex.auxFields() + % + % Returns a cell array of Aux field names from the first group's + % grand average. These names can be passed to plotAux(). + + if ~obj.isAggregated || isempty(obj.groups) + flds = {}; + fprintf('No aggregated data. Call aggregate() first.\n'); + return; + end + + ga = obj.groups(1).gbyGrand; + if ~isfield(ga, 'Aux') || ~isstruct(ga.Aux) + flds = {}; + fprintf('No Aux data in aggregated results.\n'); + return; + end + + % Get clean field names (handles flattened _data/_time/_unit suffixes) + allFlds = fieldnames(ga.Aux); + allFlds = allFlds(~ismember(allFlds, {'flattened'})); + + % Deduplicate: strip _data/_time/_unit, keep only fields with .Mean + baseNames = {}; + for i = 1:length(allFlds) + f = allFlds{i}; + base = regexprep(f, '_(data|time|unit)$', ''); + if ~ismember(base, baseNames) + % Check if this or _data version has .Mean + resolved = f; + if isfield(ga.Aux, [base '_data']) + resolved = [base '_data']; + end + if isstruct(ga.Aux.(resolved)) && isfield(ga.Aux.(resolved), 'Mean') + baseNames{end+1} = base; %#ok + end + end + end + flds = unique(baseNames, 'stable'); + + if nargout == 0 + % Print to console + fprintf('Available Aux fields:\n'); + for i = 1:length(flds) + % Resolve to actual field name + if isfield(ga.Aux, flds{i}) + actualField = flds{i}; + elseif isfield(ga.Aux, [flds{i} '_data']) + actualField = [flds{i} '_data']; + else + continue; + end + auxData = ga.Aux.(actualField); + if isfield(auxData, 'Mean') + nCh = size(auxData.Mean, 2); + unitStr = ''; + if isfield(auxData, 'unit') + unitStr = sprintf(' (%s)', auxData.unit); + end + nameStr = ''; + if isfield(auxData, 'varNames') && ~isempty(auxData.varNames) + nameStr = sprintf(' [%s]', strjoin(auxData.varNames, ', ')); + end + fprintf(' %s: %d channels%s%s\n', flds{i}, nCh, unitStr, nameStr); + else + fprintf(' %s\n', flds{i}); + end + end + clear flds; + end + end + + + function fig = plotInfoBar(obj, varName, varargin) + % PLOTINFOBAR Plot a numeric info variable grouped by current groupby + % + % fig = ex.plotInfoBar('reactionTime') + % fig = ex.plotInfoBar('accuracy', 'ErrorType', 'SD') + % fig = ex.plotInfoBar('Age', 'SavePath', 'age_by_group.png') + % + % Plots a bar chart of any numeric variable from the metadata table, + % grouped by the current groupby variables. Does NOT require + % aggregate() - works directly from the dataTable. + % + % Requires groupby() to have been called first. + % + % Name-Value Parameters: + % Averaging - 'hierarchy' (default), 'flat', or 'none' + % 'hierarchy' averages within SubjectID first + % 'flat' uses raw values directly + % 'none' same as flat (raw block-level data) + % ErrorType - 'SEM' (default), 'SD', or 'none' + % ShowIndividual - Show individual data points (default: true) + % Title - Figure title (default: auto) + % YLabel - Y-axis label (default: varName) + % Visible - 'on' (default) or 'off' + % SavePath - File path to save figure + % SaveWidth - Width in pixels (default: 600) + % SaveHeight - Height in pixels (default: 400) + % SaveDPI - Resolution (default: 150) + % + % See also: plotBar, plotTemporal + + if ~obj.isGrouped + error('exploreFNIRS:core:Experiment:plotInfoBar', ... + 'Call groupby() before plotInfoBar()'); + end + + p = inputParser; + addRequired(p, 'varName', @ischar); + addParameter(p, 'Averaging', 'hierarchy', @(x) ismember(lower(x), {'hierarchy','flat','none'})); + addParameter(p, 'ErrorType', 'SEM', @ischar); + addParameter(p, 'ShowIndividual', true, @islogical); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'YLabel', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle') || isa(x, 'exploreFNIRS.core.ColorScheme')); + addParameter(p, 'ColorScheme', [], @(x) isempty(x) || ischar(x) || isstring(x) || isa(x, 'exploreFNIRS.core.ColorScheme')); + parse(p, varName, varargin{:}); + opts = p.Results; + + % Resolve named ColorScheme + if ~isempty(opts.ColorScheme) + csVal = opts.ColorScheme; + if ischar(csVal) || isstring(csVal) + name = char(csVal); + if ~isfield(obj.colorSchemes, name) + error('exploreFNIRS:core:Experiment:plotInfoBar', ... + 'Unknown color scheme: "%s". Available: %s', ... + name, strjoin(fieldnames(obj.colorSchemes), ', ')); + end + csVal = obj.colorSchemes.(name); + end + if isempty(opts.Colors) + opts.Colors = csVal; + end + end + + % Auto-inject colorScheme if not explicitly set + if isempty(opts.Colors) && ~isempty(obj.colorScheme) + opts.Colors = obj.colorScheme; + end + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + % Validate variable exists and is numeric + selTable = obj.getSelectedTable(); + if ~ismember(varName, selTable.Properties.VariableNames) + error('exploreFNIRS:core:Experiment:plotInfoBar', ... + 'Variable "%s" not found. Available: %s', ... + varName, strjoin(selTable.Properties.VariableNames, ', ')); + end + + testCol = selTable.(varName); + if ~isnumeric(testCol) + error('exploreFNIRS:core:Experiment:plotInfoBar', ... + 'Variable "%s" must be numeric (got %s)', varName, class(testCol)); + end + + nGroups = length(obj.groups); + groupMeans = nan(1, nGroups); + groupErrors = nan(1, nGroups); + groupN = nan(1, nGroups); + groupLabels = cell(1, nGroups); + individualData = cell(1, nGroups); + + for g = 1:nGroups + gTable = obj.groups(g).gbyTables; + vals = gTable.(varName); + if strcmpi(opts.Averaging, 'hierarchy') && ... + ismember('SubjectID', gTable.Properties.VariableNames) + vals = pf2_base.hierarchicalAverage(vals, ... + gTable(:, 'SubjectID'), @nanmean); + end + vals = vals(~isnan(vals)); + individualData{g} = vals; + groupN(g) = length(vals); + groupMeans(g) = mean(vals, 'omitnan'); + groupLabels{g} = obj.groups(g).label; + + switch upper(opts.ErrorType) + case 'SEM' + groupErrors(g) = std(vals, 'omitnan') / sqrt(groupN(g)); + case 'SD' + groupErrors(g) = std(vals, 'omitnan'); + case 'NONE' + groupErrors(g) = 0; + end + end + + % Colors + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + colors = opts.Colors.resolve(obj.groups); + else + colors = exploreFNIRS.core.getGroupColors(nGroups, opts.Colors); + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + ax = axes('Parent', fig); + hold(ax, 'on'); + + % Build barweb inputs + meanMatrix = groupMeans(:); % [nGroups x 1] + if strcmpi(opts.ErrorType, 'none') + errInput = []; + else + errInput = groupErrors(:); + end + + if ~isempty(opts.YLabel) + ylabelStr = opts.YLabel; + else + ylabelStr = varName; + end + + barwebArgs = {'Axes', ax, ... + 'ColorMap', colors(1,:), ... + 'YLabel', ylabelStr}; + + if opts.ShowIndividual + indivData = cell(nGroups, 1); + for g = 1:nGroups + indivData{g, 1} = individualData{g}; + end + barwebArgs = [barwebArgs, {'DataPoints', indivData}]; + end + + bwHandles = pf2_base.external.barweb(meanMatrix, errInput, ... + 0.8, groupLabels, barwebArgs{:}, 'ErrorColor', sty.ForegroundColor); + hold(ax, 'on'); + + % Color each bar individually + if ~isempty(bwHandles.bars) + bwHandles.bars(1).FaceColor = 'flat'; + bwHandles.bars(1).CData = colors(1:nGroups, :); + end + + % Add x-axis margin so bars don't touch the edges + xlim(ax, [0.25, nGroups + 0.75]); + + % Legend identifies bars — replace tick labels with xlabel + set(ax, 'XTickLabel', {}); + xlabel(ax, pf2_base.plot.escapeTeX(strjoin(obj.groupByVars, ' x '))); + + % Legend with colored patches + lh = gobjects(nGroups, 1); + for g = 1:nGroups + lh(g) = patch(ax, NaN, NaN, colors(g,:), ... + 'EdgeColor', 'k', 'LineWidth', 2); + end + lg = legend(ax, lh, pf2_base.plot.escapeTeX(groupLabels), 'Location', 'best'); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + + % N labels + for g = 1:nGroups + if ~isnan(groupN(g)) + yPos = groupMeans(g) + groupErrors(g); + if isnan(yPos), yPos = groupMeans(g); end + text(ax, g, yPos, sprintf('n=%d', groupN(g)), ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'bottom', 'FontSize', 8); + end + end + + if ~isempty(opts.Title) + title(ax, pf2_base.plot.escapeTeX(opts.Title)); + else + title(ax, pf2_base.plot.escapeTeX(sprintf('%s by %s', varName, strjoin(obj.groupByVars, ', ')))); + end + + box(ax, 'on'); + grid(ax, 'on'); + + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); + end + + + function fig = plotInfoScatter(obj, xVar, yVar, varargin) + % PLOTINFOSCATTER Scatter plot of two info variables, colored by group + % + % fig = ex.plotInfoScatter('Age', 'reactionTime') + % fig = ex.plotInfoScatter('taskLoad', 'accuracy', 'FitLine', true) + % + % Plots xVar vs yVar from the metadata table. If groupby() has been + % called, points are colored by group. Does NOT require aggregate(). + % + % Name-Value Parameters: + % Averaging - 'hierarchy' (default), 'flat', or 'none' + % 'hierarchy' averages within SubjectID first + % 'flat' uses raw values directly + % 'none' same as flat (raw block-level data) + % FitLine - Add linear fit per group (default: false) + % Title - Figure title (default: auto) + % XLabel - X-axis label (default: xVar) + % YLabel - Y-axis label (default: yVar) + % MarkerSize - Point size (default: 40) + % Visible - 'on' (default) or 'off' + % SavePath - File path to save figure + % SaveWidth - Width in pixels (default: 600) + % SaveHeight - Height in pixels (default: 400) + % SaveDPI - Resolution (default: 150) + % + % See also: plotInfoBar, plotBar + + p = inputParser; + addRequired(p, 'xVar', @ischar); + addRequired(p, 'yVar', @ischar); + addParameter(p, 'Averaging', 'hierarchy', @(x) ismember(lower(x), {'hierarchy','flat','none'})); + addParameter(p, 'FitLine', false, @islogical); + addParameter(p, 'ErrorBand', false, @islogical); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'XLabel', '', @ischar); + addParameter(p, 'YLabel', '', @ischar); + addParameter(p, 'MarkerSize', 40, @isnumeric); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle') || isa(x, 'exploreFNIRS.core.ColorScheme')); + addParameter(p, 'ColorScheme', [], @(x) isempty(x) || ischar(x) || isstring(x) || isa(x, 'exploreFNIRS.core.ColorScheme')); + parse(p, xVar, yVar, varargin{:}); + opts = p.Results; + + % Resolve named ColorScheme + if ~isempty(opts.ColorScheme) + csVal = opts.ColorScheme; + if ischar(csVal) || isstring(csVal) + name = char(csVal); + if ~isfield(obj.colorSchemes, name) + error('exploreFNIRS:core:Experiment:plotInfoScatter', ... + 'Unknown color scheme: "%s". Available: %s', ... + name, strjoin(fieldnames(obj.colorSchemes), ', ')); + end + csVal = obj.colorSchemes.(name); + end + if isempty(opts.Colors) + opts.Colors = csVal; + end + end + + % Auto-inject colorScheme if not explicitly set + if isempty(opts.Colors) && ~isempty(obj.colorScheme) + opts.Colors = obj.colorScheme; + end + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + selTable = obj.getSelectedTable(); + + % Validate variables + for v = {xVar, yVar} + vn = v{1}; + if ~ismember(vn, selTable.Properties.VariableNames) + error('exploreFNIRS:core:Experiment:plotInfoScatter', ... + 'Variable "%s" not found. Available: %s', ... + vn, strjoin(selTable.Properties.VariableNames, ', ')); + end + if ~isnumeric(selTable.(vn)) + error('exploreFNIRS:core:Experiment:plotInfoScatter', ... + 'Variable "%s" must be numeric (got %s)', vn, class(selTable.(vn))); + end + end + + xData = selTable.(xVar); + yData = selTable.(yVar); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + ax = axes('Parent', fig); + hold(ax, 'on'); + + if obj.isGrouped && ~isempty(obj.groups) + nGroups = length(obj.groups); + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + colors = opts.Colors.resolve(obj.groups); + else + colors = exploreFNIRS.core.getGroupColors(nGroups, opts.Colors); + end + + legendHandles = gobjects(nGroups, 1); + legendLabels = cell(nGroups, 1); + + for g = 1:nGroups + gTable = obj.groups(g).gbyTables; + gx = gTable.(xVar); + gy = gTable.(yVar); + + % Apply averaging + if strcmpi(opts.Averaging, 'hierarchy') && ... + ismember('SubjectID', gTable.Properties.VariableNames) + gx = pf2_base.hierarchicalAverage(gx, ... + gTable(:, 'SubjectID'), @nanmean); + gy = pf2_base.hierarchicalAverage(gy, ... + gTable(:, 'SubjectID'), @nanmean); + end + + valid = ~isnan(gx) & ~isnan(gy); + gx = gx(valid); + gy = gy(valid); + + legendHandles(g) = scatter(ax, gx, gy, opts.MarkerSize, ... + colors(g,:), 'filled', 'MarkerFaceAlpha', 0.7); + legendLabels{g} = sprintf('%s (n=%d)', obj.groups(g).label, sum(valid)); + + if opts.FitLine && sum(valid) >= 2 + coeffs = polyfit(gx, gy, 1); + xFit = linspace(min(gx), max(gx), 50); + yFit = polyval(coeffs, xFit); + plot(ax, xFit, yFit, '-', 'Color', colors(g,:), ... + 'LineWidth', 1.5, 'HandleVisibility', 'off'); + + if opts.ErrorBand && sum(valid) >= 3 + yResid = gy - polyval(coeffs, gx); + se = std(yResid) * sqrt(1/sum(valid) + ... + (xFit - mean(gx)).^2 / sum((gx - mean(gx)).^2)); + fill(ax, [xFit, fliplr(xFit)], ... + [yFit + 1.96*se, fliplr(yFit - 1.96*se)], ... + colors(g,:), 'FaceAlpha', 0.15, 'EdgeColor', 'none', ... + 'HandleVisibility', 'off'); + end + end + end + + legend(ax, legendHandles, legendLabels, 'Location', 'best'); + else + % No grouping - single color + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + singleColor = exploreFNIRS.core.getGroupColors(1); + else + singleColor = exploreFNIRS.core.getGroupColors(1, opts.Colors); + end + % Apply averaging + xPlot = xData; + yPlot = yData; + if strcmpi(opts.Averaging, 'hierarchy') && ... + ismember('SubjectID', selTable.Properties.VariableNames) + xPlot = pf2_base.hierarchicalAverage(xData, ... + selTable(:, 'SubjectID'), @nanmean); + yPlot = pf2_base.hierarchicalAverage(yData, ... + selTable(:, 'SubjectID'), @nanmean); + end + + valid = ~isnan(xPlot) & ~isnan(yPlot); + scatter(ax, xPlot(valid), yPlot(valid), opts.MarkerSize, ... + singleColor, 'filled', 'MarkerFaceAlpha', 0.7); + + if opts.FitLine && sum(valid) >= 2 + xv = xPlot(valid); + yv = yPlot(valid); + coeffs = polyfit(xv, yv, 1); + xFit = linspace(min(xv), max(xv), 50); + yFit = polyval(coeffs, xFit); + plot(ax, xFit, yFit, '-', 'Color', singleColor, ... + 'LineWidth', 1.5, 'HandleVisibility', 'off'); + + if opts.ErrorBand && sum(valid) >= 3 + yResid = yv - polyval(coeffs, xv); + se = std(yResid) * sqrt(1/sum(valid) + ... + (xFit - mean(xv)).^2 / sum((xv - mean(xv)).^2)); + fill(ax, [xFit, fliplr(xFit)], ... + [yFit + 1.96*se, fliplr(yFit - 1.96*se)], ... + singleColor, 'FaceAlpha', 0.15, 'EdgeColor', 'none', ... + 'HandleVisibility', 'off'); + end + end + end + + if ~isempty(opts.XLabel), xlabel(ax, pf2_base.plot.escapeTeX(opts.XLabel)); + else, xlabel(ax, pf2_base.plot.escapeTeX(xVar)); end + if ~isempty(opts.YLabel), ylabel(ax, pf2_base.plot.escapeTeX(opts.YLabel)); + else, ylabel(ax, pf2_base.plot.escapeTeX(yVar)); end + + if ~isempty(opts.Title) + title(ax, pf2_base.plot.escapeTeX(opts.Title)); + else + title(ax, pf2_base.plot.escapeTeX(sprintf('%s vs %s', yVar, xVar))); + end + + box(ax, 'on'); + grid(ax, 'on'); + + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); + end + + + function [fig, stats] = plotScatter(obj, infoVar, varargin) + % PLOTSCATTER Scatter plot correlating info variable vs fNIRS biomarker + % + % [fig, stats] = ex.plotScatter('reactionTime') + % [fig, stats] = ex.plotScatter('Age', 'Biomarkers', {'HbO'}, ... + % 'Channels', 5, 'FitLine', true) + % [fig, stats] = ex.plotScatter('Age', 'PlotTopo', true) + % + % Correlates an info/behavioral variable (X) with fNIRS biomarker + % channel data (Y). Requires aggregate() first. + % + % See also: exploreFNIRS.core.plotScatter + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotScatter', ... + 'Call aggregate() before plotScatter()'); + end + varargin = obj.injectColorScheme(varargin); + % Inject Device from data if not explicitly provided + if ~any(strcmpi(varargin(1:2:end), 'Device')) + dev = obj.resolveDevice(); + if ~isempty(dev) + varargin = [varargin, {'Device', dev}]; + end + end + [fig, stats] = exploreFNIRS.core.plotScatter(obj.groups, ... + 'InfoVar', infoVar, varargin{:}); + end + + + function [fig, stats, neTable] = plotNeuralEfficiency(obj, infoVar, varargin) + % PLOTNEURALEFFICIENCY Neural efficiency scatter plot + % + % [fig, stats] = ex.plotNeuralEfficiency('accuracy') + % [fig, stats, neTable] = ex.plotNeuralEfficiency('RT', ... + % 'Channels', 1:5, 'FitLine', true) + % [fig, stats] = ex.plotNeuralEfficiency('accuracy', ... + % 'ZScoreMode', 'pergroup') + % + % Activation (X) vs performance (Y), z-scored. Identity line + % separates efficient (above) from inefficient (below). + % Third output neTable has per-point zX, zY, NE values. + % Requires aggregate() first. + % + % See also: exploreFNIRS.core.plotNeuralEfficiency + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotNeuralEfficiency', ... + 'Call aggregate() before plotNeuralEfficiency()'); + end + varargin = obj.injectColorScheme(varargin); + % Inject Device from data if not explicitly provided + if ~any(strcmpi(varargin(1:2:end), 'Device')) + dev = obj.resolveDevice(); + if ~isempty(dev) + varargin = [varargin, {'Device', dev}]; + end + end + [fig, stats, neTable] = exploreFNIRS.core.plotNeuralEfficiency( ... + obj.groups, 'InfoVar', infoVar, varargin{:}); + end + + + function [fig, results] = plotLME(obj, varargin) + % PLOTLME Linear Mixed Effects analysis with bar chart and topo + % + % [fig, results] = ex.plotLME() + % [fig, results] = ex.plotLME('Biomarkers', {'HbO'}, 'Channels', 1:5) + % [fig, results] = ex.plotLME('ShowTopo', true) + % + % Fits LME models per channel using the current groupby variables + % as fixed effects. Requires aggregate() first. + % + % See also: exploreFNIRS.core.plotLME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotLME', ... + 'Call aggregate() before plotLME()'); + end + varargin = obj.injectColorScheme(varargin); + varargin = obj.injectStatWindow(varargin); + varargin = obj.injectTimeModel(varargin); + if ~any(strcmpi(varargin(1:2:end), 'Device')) + dev = obj.resolveDevice(); + if ~isempty(dev) + varargin = [varargin, {'Device', dev}]; + end + end + [fig, results] = exploreFNIRS.core.plotLME(obj.groups, ... + obj.groupByVars, varargin{:}); + end + + + function [fig, results] = plotAuxLME(obj, auxField, varargin) + % PLOTAUXLME LME analysis with bar chart for auxiliary data + % + % [fig, results] = ex.plotAuxLME('heartRate') + % [fig, results] = ex.plotAuxLME('accelerometer', 'Channels', 1:2) + % + % Convenience wrapper for plotLME with DataType='Aux'. + % Requires aggregate() first. + % + % See also: exploreFNIRS.core.plotLME, statsAuxLME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotAuxLME', ... + 'Call aggregate() before plotAuxLME()'); + end + varargin = obj.injectColorScheme(varargin); + varargin = obj.injectTimeModel(varargin); + [fig, results] = exploreFNIRS.core.plotLME(obj.groups, ... + obj.groupByVars, 'DataType', 'Aux', 'AuxField', auxField, ... + varargin{:}); + end + + + function [fig, results] = plotInfoLME(obj, infoVar, varargin) + % PLOTINFOLME LME analysis with bar chart for info/behavioral variables + % + % [fig, results] = ex.plotInfoLME('reactionTime') + % [fig, results] = ex.plotInfoLME('accuracy', 'AllInteractions', true) + % + % Fits a single LME model for the specified info variable and + % renders a bar chart of F-statistics per ANOVA term. + % Requires groupby() first (does NOT require aggregate). + % + % See also: exploreFNIRS.core.plotInfoLME, statsInfoLME + + if ~obj.isGrouped + error('exploreFNIRS:core:Experiment:plotInfoLME', ... + 'Call groupby() before plotInfoLME()'); + end + selTable = obj.getSelectedTable(); + varargin = obj.injectColorScheme(varargin); + [fig, results] = exploreFNIRS.core.plotInfoLME(selTable, ... + infoVar, obj.groupByVars, varargin{:}); + end + + + function [fig, results] = plotTopoLME(obj, varargin) + % PLOTTOPOLME Topographic map of LME ANOVA statistics + % + % [fig, results] = ex.plotTopoLME() + % [fig, results] = ex.plotTopoLME('SigType', 'q', 'Biomarkers', {'HbO'}) + % [fig, results] = ex.plotTopoLME('Projection', '2D') + % + % Renders significant statistics from LME ANOVA onto a 3D brain + % surface (default) or 2D probe layout. Use 'Projection','2D' + % for flat probe plots. Requires aggregate() first. + % + % See also: exploreFNIRS.core.plotTopoLME, plotLME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotTopoLME', ... + 'Call aggregate() before plotTopoLME()'); + end + [fig, results] = exploreFNIRS.core.plotTopoLME(obj.groups, ... + obj.groupByVars, varargin{:}); + end + + + function [fig, results] = plotTopoROILME(obj, varargin) + % PLOTTOPOROILME ROI-level LME topo (2D or 3D) + % + % [fig, results] = ex.plotTopoROILME() + % [fig, results] = ex.plotTopoROILME('Biomarkers', {'HbO'}) + % [fig, results] = ex.plotTopoROILME('Projection', '2D') + % + % Convenience wrapper for plotTopoLME with DataType='ROI'. + % Broadcasts each ROI's statistic to constituent channels. + % Use 'Projection','2D' for flat probe plots with ROI labels. + % Requires aggregate() first. + % + % See also: exploreFNIRS.core.plotTopoLME, plotLME, statsROILME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotTopoROILME', ... + 'Call aggregate() before plotTopoROILME()'); + end + varargin = obj.injectColorScheme(varargin); + [fig, results] = exploreFNIRS.core.plotTopoLME(obj.groups, ... + obj.groupByVars, 'DataType', 'ROI', varargin{:}); + end + + + function results = statsFitLME(obj, varargin) + % STATSFITLME Fit LME models (statistics only, no visualization) + % + % results = ex.statsFitLME() + % results = ex.statsFitLME('Biomarkers', {'HbO'}, 'Channels', 1:5) + % + % Fits LME models per channel using the current groupby variables + % as fixed effects. Returns statistical results without any plots. + % Requires aggregate() first. + % + % For combined analysis + visualization, use plotLME() instead. + % + % See also: exploreFNIRS.stats.fitLME, plotLME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:statsFitLME', ... + 'Call aggregate() before statsFitLME()'); + end + varargin = obj.injectStatWindow(varargin); + varargin = obj.injectTimeModel(varargin); + % Diagnose between-subjects confounds once, up front, and silence + % the per-channel "Hessian"/rank-deficiency spam during the fit. + obj.warnBetweenSubjectConfound(obj.groupByVars, varargin); + % With 2+ non-time grouping factors the default model is ADDITIVE + % (main effects only). 'Group + Condition' is easily mistaken for + % 'Group x Condition', so say so and point to the interaction flag. + % Scan NAME positions only; stay silent when the interaction was + % requested or an explicit CustomFormula overrides the auto-formula. + nonTime = setdiff(obj.groupByVars, {'Time','time'}, 'stable'); + keys = varargin(1:2:end); + allIntOn = false; + ki = find(strcmpi(keys, 'AllInteractions'), 1); + if ~isempty(ki) && numel(varargin) >= 2*ki + v = varargin{2*ki}; + allIntOn = (islogical(v) || isnumeric(v)) && ~isempty(v) && all(logical(v(:))); + end + hasCustom = any(strcmpi(keys, 'CustomFormula')); + if numel(nonTime) >= 2 && ~allIntOn && ~hasCustom + warning('exploreFNIRS:statsLME:additiveModel', ... + ['Fitting an ADDITIVE model %s (main effects only). For the ', ... + 'Group x Condition interaction pass ''AllInteractions'', true. ', ... + 'If a between-subjects confound note also appeared, the ', ... + 'interaction may still be inestimable.'], ... + strjoin(nonTime, ' + ')); + end + cleanupObj = exploreFNIRS.core.Experiment.suppressFitWarnings(); %#ok + results = exploreFNIRS.stats.fitLME(obj.groups, ... + obj.groupByVars, varargin{:}); + end + + + function results = statsInfoLME(obj, infoVar, varargin) + % STATSINFOLME Fit LME model for an info/behavioral variable + % + % results = ex.statsInfoLME('reactionTime') + % results = ex.statsInfoLME('accuracy', 'AllInteractions', true) + % + % Fits a single LME model using the specified info variable as the + % response and the current groupby variables as fixed effects. + % Does NOT require aggregate() - works directly from the dataTable. + % Requires groupby() first. + % + % Results are compatible with statsRunContrasts() and statsSummarize(). + % + % See also: exploreFNIRS.stats.fitInfoLME, statsFitLME + + if ~obj.isGrouped + error('exploreFNIRS:core:Experiment:statsInfoLME', ... + 'Call groupby() before statsInfoLME()'); + end + selTable = obj.getSelectedTable(); + % Diagnose between-subjects confounds once, up front, and silence + % the per-channel "Hessian"/rank-deficiency spam during the fit. + obj.warnBetweenSubjectConfound(obj.groupByVars, varargin); + cleanupObj = exploreFNIRS.core.Experiment.suppressFitWarnings(); %#ok + results = exploreFNIRS.stats.fitInfoLME(selTable, infoVar, ... + obj.groupByVars, varargin{:}); + end + + + function results = statsAuxLME(obj, auxField, varargin) + % STATSAUXLME Fit LME models for auxiliary data channels + % + % results = ex.statsAuxLME('heartRate') + % results = ex.statsAuxLME('accelerometer', 'Channels', 1:2) + % + % Convenience wrapper for statsFitLME with DataType='Aux'. + % Fits LME models per aux channel using the current groupby + % variables as fixed effects. Requires aggregate() first. + % + % Results are compatible with statsRunContrasts() and statsSummarize(). + % + % See also: exploreFNIRS.stats.fitLME, statsFitLME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:statsAuxLME', ... + 'Call aggregate() before statsAuxLME()'); + end + varargin = obj.injectStatWindow(varargin); + varargin = obj.injectTimeModel(varargin); + results = exploreFNIRS.stats.fitLME(obj.groups, ... + obj.groupByVars, 'DataType', 'Aux', 'AuxField', auxField, ... + varargin{:}); + end + + + function results = statsROILME(obj, varargin) + % STATSROILME Fit LME models for ROI-level data + % + % results = ex.statsROILME() + % results = ex.statsROILME('Biomarkers', {'HbO'}, 'Channels', 1:3) + % + % Convenience wrapper for statsFitLME with DataType='ROI'. + % Fits LME models per ROI using the current groupby variables + % as fixed effects. Requires aggregate() first. Use 'Channels' + % to select specific ROI indices. + % + % See also: exploreFNIRS.stats.fitLME, statsFitLME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:statsROILME', ... + 'Call aggregate() before statsROILME()'); + end + varargin = obj.injectStatWindow(varargin); + varargin = obj.injectTimeModel(varargin); + results = exploreFNIRS.stats.fitLME(obj.groups, ... + obj.groupByVars, 'DataType', 'ROI', varargin{:}); + end + + + function results = statsAutoLME(obj, varargin) + % STATSAUTOLME Automatic per-channel LME model selection + % + % results = ex.statsAutoLME() + % results = ex.statsAutoLME('Biomarkers', {'HbO'}, 'Channels', 1:5) + % results = ex.statsAutoLME('Criterion', 'BIC', 'DeltaThreshold', 4) + % + % Forward stepwise LME model selection per channel using AIC/BIC. + % Auto-discovers which factors matter for each channel independently. + % Results are compatible with statsSummarize() and statsRunContrasts(). + % Requires aggregate() first. + % + % % Force a per-trial info variable as a fixed-effect covariate: + % results = ex.statsAutoLME('Covariates', {'RT'}) + % + % Continuous covariates are entered UNCENTERED; consider mean-centering + % (e.g. zscore) before passing so the group intercepts stay + % interpretable. A forced covariate that is constant within every + % subject triggers the between-subjects confound warning. + % + % See also: exploreFNIRS.stats.autoModelLME, statsFitLME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:statsAutoLME', ... + 'Call aggregate() before statsAutoLME()'); + end + varargin = obj.injectStatWindow(varargin); + varargin = obj.injectTimeModel(varargin); + % Forward selection guards auto-discovered factors against a + % between-subjects confound, but FORCED terms (Covariates / + % ForcedTerms) bypass selection - so check them up front: a forced + % covariate that is constant within every subject is confounded + % with the (1|SubjectID) random intercept and yields NaN rows. + forced = {}; + keys = varargin(1:2:end); + for nm = {'Covariates', 'ForcedTerms'} + fi = find(strcmpi(keys, nm{1}), 1); + if ~isempty(fi) && numel(varargin) >= 2*fi + v = varargin{2*fi}; + if iscell(v) + forced = [forced, v(:)']; %#ok + elseif ischar(v) || isstring(v) + forced = [forced, {char(v)}]; %#ok + end + end + end + if ~isempty(forced) + obj.warnBetweenSubjectConfound(unique(forced, 'stable'), varargin); + end + % autoModelLME fits fitlme repeatedly per channel/candidate model; + % suppress the raw MATLAB Hessian/rank-deficiency spam here (as + % statsFitLME/statsInfoLME do) so the consolidated diagnostic above + % is the user-facing message. + cleanupObj = exploreFNIRS.core.Experiment.suppressFitWarnings(); %#ok + results = exploreFNIRS.stats.autoModelLME(obj.groups, ... + obj.groupByVars, varargin{:}); + end + + + function results = statsAutoROILME(obj, varargin) + % STATSAUTOROILME Automatic per-ROI LME model selection + % + % results = ex.statsAutoROILME() + % results = ex.statsAutoROILME('Biomarkers', {'HbO'}, 'Channels', 1:3) + % + % Convenience wrapper for statsAutoLME with DataType='ROI'. + % Requires aggregate() first and ROIs defined. + % + % See also: exploreFNIRS.stats.autoModelLME, statsAutoLME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:statsAutoROILME', ... + 'Call aggregate() before statsAutoROILME()'); + end + varargin = obj.injectStatWindow(varargin); + varargin = obj.injectTimeModel(varargin); + results = exploreFNIRS.stats.autoModelLME(obj.groups, ... + obj.groupByVars, 'DataType', 'ROI', varargin{:}); + end + + + function results = statsAutoInfoLME(obj, infoVar, varargin) + % STATSAUTOINFOLME Auto model selection with behavioral response + % + % results = ex.statsAutoInfoLME('reactionTime') + % results = ex.statsAutoInfoLME('accuracy', 'Biomarkers', {'HbO'}) + % + % Forward stepwise selection per channel where the info variable is + % the response and each channel's biomarker value is a candidate + % predictor. Discovers whether brain activation predicts the + % behavioral outcome. Requires aggregate() first. + % + % See also: exploreFNIRS.stats.autoModelLME, statsAutoLME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:statsAutoInfoLME', ... + 'Call aggregate() before statsAutoInfoLME()'); + end + varargin = obj.injectStatWindow(varargin); + varargin = obj.injectTimeModel(varargin); + results = exploreFNIRS.stats.autoModelLME(obj.groups, ... + obj.groupByVars, 'ResponseVar', infoVar, varargin{:}); + end + + + function contrastResults = statsRunContrasts(obj, lmeResults, varargin) + % STATSRUNCONTRASTS Post-hoc contrasts with FDR correction + % + % results = ex.statsFitLME('Biomarkers', {'HbO'}); + % cr = ex.statsRunContrasts(results); + % cr = ex.statsRunContrasts(results, 'FDRThreshold', 0.01) + % + % Takes output from statsFitLME and runs post-hoc contrasts per + % channel, then applies FDR correction across channels. + % + % See also: exploreFNIRS.stats.runContrasts, statsFitLME + + contrastResults = exploreFNIRS.stats.runContrasts( ... + lmeResults, varargin{:}); + end + + + function T = statsSummarize(obj, lmeResults, varargin) %#ok + % STATSSUMMARIZE Publication-ready summary table from results + % + % results = ex.statsFitLME('Biomarkers', {'HbO'}); + % T = ex.statsSummarize(results) + % T = ex.statsSummarize(results, 'Type', 'anova', 'Format', 'apa') + % T = ex.statsSummarize(results, 'Type', 'contrasts') + % T = ex.statsSummarize(results, 'Type', 'fit') + % + % % Correlation stats from plotScatter + % [fig, stats] = ex.plotScatter('reactionTime', 'Biomarkers', {'HbO'}); + % T = ex.statsSummarize(stats, 'Type', 'correlations') + % + % Formats LME or correlation results into publication-ready tables. + % + % See also: exploreFNIRS.stats.summarize, statsFitLME, plotScatter + + T = exploreFNIRS.stats.summarize(lmeResults, varargin{:}); + end + + + function results = statsClusterPermutation(obj, lmeResults, varargin) + % STATSCLUSTERPERMUTATION Cluster-based permutation testing + % + % results = ex.statsFitLME('Biomarkers', {'HbO'}); + % cp = ex.statsClusterPermutation(results) + % cp = ex.statsClusterPermutation(results, 'Permutations', 500) + % + % Performs nonparametric cluster-based permutation testing using + % spatial adjacency to identify significant channel clusters. + % Controls family-wise error rate at the cluster level. + % + % See also: exploreFNIRS.stats.clusterPermutation, statsFitLME + + results = exploreFNIRS.stats.clusterPermutation( ... + lmeResults, obj.data, varargin{:}); + end + + + function results = statsPermTest(obj, varargin) + % STATSPERMTEST Non-parametric permutation test for paired comparisons + % + % results = ex.statsPermTest() + % results = ex.statsPermTest('Biomarkers', {'HbO'}, 'NumPerm', 1000) + % + % Performs sign-flip permutation testing for 2-condition + % within-subject comparisons. Requires aggregate() with exactly + % 2 groups. + % + % See also: exploreFNIRS.stats.permTest, statsFitLME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:statsPermTest', ... + 'Call aggregate() before statsPermTest()'); + end + varargin = obj.injectStatWindow(varargin); + results = exploreFNIRS.stats.permTest(obj.groups, ... + obj.groupByVars, varargin{:}); + end + + + function results = statsEffectSize(obj, varargin) + % STATSEFFECTSIZE Effect size with bootstrap confidence intervals + % + % results = ex.statsEffectSize() + % results = ex.statsEffectSize('Method', 'hedges_g', 'NumBoot', 2000) + % + % Computes effect sizes between 2 conditions with bootstrap CIs. + % Requires aggregate() with exactly 2 groups. + % + % See also: exploreFNIRS.stats.effectSize, statsFitLME + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:statsEffectSize', ... + 'Call aggregate() before statsEffectSize()'); + end + varargin = obj.injectStatWindow(varargin); + results = exploreFNIRS.stats.effectSize(obj.groups, ... + obj.groupByVars, varargin{:}); + end + + + function results = statsROIPermTest(obj, varargin) + % STATSROIPERMTEST Non-parametric permutation test for ROI-level data + % + % results = ex.statsROIPermTest() + % results = ex.statsROIPermTest('Biomarkers', {'HbO'}, 'NumPerm', 1000) + % + % Convenience wrapper for statsPermTest with DataType='ROI'. + % Performs sign-flip permutation testing per ROI. Requires + % aggregate() with exactly 2 groups and ROIs defined. + % + % See also: exploreFNIRS.stats.permTest, statsPermTest + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:statsROIPermTest', ... + 'Call aggregate() before statsROIPermTest()'); + end + varargin = obj.injectStatWindow(varargin); + results = exploreFNIRS.stats.permTest(obj.groups, ... + obj.groupByVars, 'DataType', 'ROI', varargin{:}); + end + + + function results = statsROIEffectSize(obj, varargin) + % STATSROIEFFECTSIZE Effect size with bootstrap CIs for ROI-level data + % + % results = ex.statsROIEffectSize() + % results = ex.statsROIEffectSize('Method', 'hedges_g', 'NumBoot', 2000) + % + % Convenience wrapper for statsEffectSize with DataType='ROI'. + % Computes effect sizes per ROI. Requires aggregate() with + % exactly 2 groups and ROIs defined. + % + % See also: exploreFNIRS.stats.effectSize, statsEffectSize + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:statsROIEffectSize', ... + 'Call aggregate() before statsROIEffectSize()'); + end + varargin = obj.injectStatWindow(varargin); + results = exploreFNIRS.stats.effectSize(obj.groups, ... + obj.groupByVars, 'DataType', 'ROI', varargin{:}); + end + + + function [T, stats] = brainBehavior(obj, infoVar, varargin) + % BRAINBEHAVIOR Brain-behavior correlation table (one call) + % + % T = ex.brainBehavior('reactionTime') + % T = ex.brainBehavior('Age', 'Biomarkers', {'HbO'}, 'CorrType', 'Spearman') + % T = ex.brainBehavior('Score', 'Format', 'latex') + % [T, stats] = ex.brainBehavior('RT', 'Channels', 1:5) + % + % Computes per-channel correlations between a behavioral/info + % variable and fNIRS biomarker data. Returns a publication-ready + % table via summarize(stats, 'Type', 'correlations'). + % + % All plotScatter name-value parameters are accepted (Biomarkers, + % Channels, CorrType, etc.) plus summarize parameters (Format). + % + % Requires aggregate() first. + % + % See also: plotScatter, statsSummarize + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:brainBehavior', ... + 'Call aggregate() before brainBehavior()'); + end + + % Separate summarize params from plotScatter params + summarizeKeys = {'Format', 'SigThreshold'}; + summarizeArgs = {}; + scatterArgs = {}; + i = 1; + while i <= length(varargin) + if ischar(varargin{i}) && any(strcmpi(varargin{i}, summarizeKeys)) + summarizeArgs = [summarizeArgs, varargin(i:i+1)]; %#ok + i = i + 2; + else + scatterArgs = [scatterArgs, varargin(i)]; %#ok + i = i + 1; + end + end + + % Run scatter headlessly (PlotTopo for per-channel, Visible off) + scatterArgs = [scatterArgs, {'PlotTopo', true, 'SavePath', ''}]; + [fig, stats] = obj.plotScatter(infoVar, scatterArgs{:}); + if ~isempty(fig) && isvalid(fig) + close(fig); + end + + % Extract metadata for summarize + bioArgs = {}; + chArgs = {}; + corrArgs = {}; + for k = 1:2:length(scatterArgs) + key = scatterArgs{k}; + if strcmpi(key, 'Biomarkers') + bioArgs = {'Biomarkers', scatterArgs{k+1}}; + elseif strcmpi(key, 'Channels') + chArgs = {'Channels', scatterArgs{k+1}}; + elseif strcmpi(key, 'CorrType') + corrArgs = {'CorrType', scatterArgs{k+1}}; + end + end + + T = exploreFNIRS.stats.summarize(stats, ... + 'Type', 'correlations', ... + 'InfoVar', infoVar, ... + bioArgs{:}, chArgs{:}, corrArgs{:}, ... + summarizeArgs{:}); + + % Fail loud on too few observations instead of returning a silent + % blank/NaN table. brainBehavior correlates ONE value per + % observation unit (subject / hierarchy leaf after averaging) + % against the biomarker, so a single subject (or single group leaf) + % yields N<3 and nothing to correlate. Tell the user why and where + % to go for a within-subject, trial-level relationship. + maxN = 0; + haveN = false; + if isstruct(stats) && isfield(stats, 'N') + for si = 1:numel(stats) + Nsi = stats(si).N; + if ~isempty(Nsi) && isnumeric(Nsi) + haveN = true; + maxN = max(maxN, max(double(Nsi(:)))); + end + end + end + % Only flag the genuine "nothing to correlate" case (matches the + % N>=3 gate inside plotScatter, where the table is otherwise + % blank/NaN). Skip when stats carried no N at all - that is a + % different failure and a low-N message would mislead. + if haveN && maxN < 3 + warning('exploreFNIRS:core:Experiment:brainBehaviorLowN', ... + ['brainBehavior(''%s'') has at most N=%d observation(s) per ', ... + 'channel - too few to correlate. Brain-behavior correlation ', ... + 'is computed ACROSS observation units (subjects / hierarchy ', ... + 'leaves after averaging), not across trials; with one ', ... + 'subject there is nothing to correlate. For a within-subject ', ... + 'trial-level relationship, aggregate with avgMode ''none'' ', ... + '(per-trial rows) then model the table directly - e.g. ', ... + 'ex.statsAutoLME(''Covariates'', {''%s''}) or fitlme on the ', ... + 'per-trial info+biomarker values.'], ... + infoVar, maxN, infoVar); + end + end + + + function T = infoTable(obj) + % INFOTABLE Return the selected metadata as a plain table + % + % T = ex.infoTable() + % + % Useful for behavioral analysis, summary statistics, or exporting + % metadata without running the fNIRS aggregation pipeline. + + T = obj.getSelectedTable(); + end + + + function summary(obj) + % SUMMARY Display experiment overview + % + % ex.summary() + + fprintf('\n=== Experiment Summary ===\n'); + fprintf('Total segments: %d\n', length(obj.data)); + fprintf('Selected: %d\n', sum(obj.selectedIdx)); + + % Show available metadata variables + vars = obj.dataTable.Properties.VariableNames; + fprintf('Metadata variables: %s\n', strjoin(vars, ', ')); + + % Show unique values for key variables + keyVars = intersect({'SubjectID','Group','Condition','Session'}, vars, 'stable'); + for i = 1:length(keyVars) + v = keyVars{i}; + vals = unique(obj.dataTable.(v)(obj.selectedIdx)); + if isnumeric(vals) + valStr = strjoin(arrayfun(@num2str, vals, 'UniformOutput', false), ', '); + else + valStr = strjoin(string(vals), ', '); + end + fprintf(' %s: [%s] (%d unique)\n', v, valStr, length(vals)); + end + + % Show hierarchy + validH = intersect(obj.hierarchy, vars, 'stable'); + fprintf('Hierarchy: %s\n', strjoin(validH, ' > ')); + + % Show preprocessing settings + s = obj.settings; + fprintf('Settings:\n'); + fprintf(' Baseline: [%.1f, %.1f]s (enabled: %s)\n', ... + s.baseline(1), s.baseline(2), mat2str(s.useBaseline)); + fprintf(' Resample: %.2fs bins, task start: %.1fs\n', ... + s.resampleRate, s.taskStart); + + % Show grouping state + if obj.isGrouped + fprintf('Grouped by: %s (%d groups)\n', ... + strjoin(obj.groupByVars, ', '), length(obj.groups)); + for g = 1:length(obj.groups) + nSeg = size(obj.groups(g).gbyTables, 1); + aggStr = ''; + if obj.isAggregated && ~isempty(obj.groups(g).gbyGrand) + nObs = size(obj.groups(g).gbyGrand.HbO.data, 3); + aggStr = sprintf(' -> %d observations after averaging', nObs); + end + fprintf(' [%d] %s: %d segments%s\n', ... + g, obj.groups(g).label, nSeg, aggStr); + end + else + fprintf('Not grouped (call groupby() to define groups)\n'); + end + + if obj.isAggregated + fprintf('Status: Aggregated\n'); + elseif obj.isGrouped + fprintf('Status: Grouped (call aggregate() to compute averages)\n'); + else + fprintf('Status: Ready (call select() and/or groupby())\n'); + end + fprintf('\n'); + end + + + function T = demographicsTable(obj, varargin) + % DEMOGRAPHICSTABLE Publication-style Table 1 demographics summary + % + % T = ex.demographicsTable() + % T = ex.demographicsTable('Variables', {'Age','Sex'}) + % T = ex.demographicsTable('GroupBy', 'Group') + % T = ex.demographicsTable('GroupBy', 'Group', 'Format', 'console') + % + % Summarizes participant characteristics at the subject level. + % No preconditions — works on selected data at any stage. + % + % See also: exploreFNIRS.report.demographicsTable + + T = exploreFNIRS.report.demographicsTable(obj, varargin{:}); + end + + + function T = behavioralTable(obj, variables, varargin) + % BEHAVIORALTABLE Descriptive stats, comparisons, or correlations for behavioral data + % + % T = ex.behavioralTable({'RT','Accuracy'}) + % T = ex.behavioralTable({'RT'}, 'Type', 'comparisons', 'GroupBy', 'Condition') + % T = ex.behavioralTable({'RT','WM'}, 'Type', 'correlations', 'Format', 'latex') + % + % See also: exploreFNIRS.stats.behavioralTable + + T = exploreFNIRS.stats.behavioralTable(obj, variables, varargin{:}); + end + + + function result = connectivity(obj, varargin) + % CONNECTIVITY Compute within-subject connectivity matrices per group + % + % result = ex.connectivity() + % result = ex.connectivity('Method', 'pearson', 'Biomarker', 'HbO') + % result = ex.connectivity('TimeWindow', [5, 25], 'Channels', 1:16) + % result = ex.connectivity('Blocks', blocks) + % result = ex.connectivity('Align', 'union') + % + % For each group, computes a connectivity matrix per subject, then + % averages across subjects. Requires groupby() first but does NOT + % require aggregate() (works directly on selected fNIRS data). + % + % When 'Blocks' is provided, computes connectivity for each block's + % time window and returns a struct array with one element per block. + % + % Name-Value Parameters: + % Method - 'pearson' (default), 'spearman', 'xcorr', 'coherence', 'wcoherence' + % Biomarker - 'HbO' (default), 'HbR', 'HbTotal', 'HbDiff', 'CBSI' + % Channels - Channel indices (default: all good channels) + % TimeWindow - [start, end] seconds (default: full range) + % CouplingArgs - Extra args for coupling function (default: {}) + % UseROI - Use ROI-level data instead of channels (default: false) + % Blocks - Block definition struct array from pf2.data.defineBlocks + % When provided, computes connectivity per block. + % Align - Channel alignment mode for group aggregation: + % 'union' (default) - all channels, NaN where missing + % 'intersection' - only channels in all subjects + % numeric 0-1 - channels in >= threshold fraction + % + % Outputs (without Blocks): + % result - Struct array (one per group) with fields: + % .Mean - [C x C] mean connectivity matrix + % .SD - [C x C] standard deviation + % .SEM - [C x C] standard error + % .N - Number of subjects + % .matrices - {N x 1} cell of individual matrices + % .label - Group label + % .method - Coupling method + % .biomarker - Biomarker used + % .channels - Channel indices + % + % Outputs (with Blocks): + % result - Struct array (one per block) with fields: + % .blockNumber - Block index + % .startTime - Block start time + % .endTime - Block end time + % .blockInfo - Block .info struct + % .groups - Per-group struct array (same format as above) + % + % See also: exploreFNIRS.connectivity.computeMatrix, + % exploreFNIRS.connectivity.plotMatrix, pf2.data.defineBlocks + + if ~obj.isGrouped + error('exploreFNIRS:core:Experiment:connectivity', ... + 'Call groupby() before connectivity()'); + end + + % Extract Blocks and Align parameters (not forwarded to computeMatrix) + [blocks, fwdArgs] = extractBlocksArg(varargin); + [align, fwdArgs] = extractAlignArg(fwdArgs); + + if isempty(blocks) + % Standard: compute for full time range + result = computeConnectivityGroups(obj.groups, fwdArgs, align); + else + % Block-wise: compute per block + nBlocks = length(blocks); + result = struct([]); + for b = 1:nBlocks + tw = [blocks(b).startTime, blocks(b).endTime]; + blockArgs = [fwdArgs, 'TimeWindow', tw]; + + result(b).blockNumber = b; + result(b).startTime = blocks(b).startTime; + result(b).endTime = blocks(b).endTime; + result(b).blockInfo = blocks(b).info; + result(b).groups = computeConnectivityGroups( ... + obj.groups, blockArgs, align); + end + fprintf('Computed connectivity for %d blocks across %d groups.\n', ... + nBlocks, length(obj.groups)); + end + end + + + function result = graphMetrics(obj, varargin) + % GRAPHMETRICS Graph theory metrics from within-group connectivity + % + % result = ex.graphMetrics() + % result = ex.graphMetrics('Method', 'pearson', 'Threshold', 0.3) + % result = ex.graphMetrics('Metrics', {'degree', 'modularity'}) + % result = ex.graphMetrics('Blocks', blocks) + % + % For each group, computes a connectivity matrix (via connectivity()), + % thresholds it, and computes selected graph theory metrics. Requires + % groupby() first. + % + % Name-Value Parameters: + % Method - Coupling method for connectivity (default: 'pearson') + % Biomarker - 'HbO' (default), 'HbR', etc. + % Threshold - Threshold value (default: 0.3) + % ThresholdMethod - 'absolute' (default), 'proportional', 'significance' + % Binarize - Binarize graph (default: false) + % Metrics - Cell array of metric names (default: all except smallWorld) + % Gamma - Modularity resolution (default: 1) + % NReplicates - Modularity replicates (default: 100) + % NRandom - Small-world null count (default: 100) + % Blocks - Block struct array for block-wise analysis + % Align - Channel alignment: 'union' (default), 'intersection' + % Channels - Channel indices to include + % TimeWindow - [start, end] seconds + % CouplingArgs - Extra coupling args + % UseROI - Use ROI data (default: false) + % + % Outputs: + % result - Struct array (one per group) with fields from computeMetrics + % plus .label (group name). + % When Blocks provided: struct array (one per block) with + % .blockNumber, .startTime, .endTime, .blockInfo, .groups. + % + % See also: exploreFNIRS.graph.computeMetrics, + % exploreFNIRS.graph.plotNetwork, exploreFNIRS.graph.metricsToTable + + if ~obj.isGrouped + error('exploreFNIRS:core:Experiment:graphMetrics', ... + 'Call groupby() before graphMetrics()'); + end + + % Separate graph-specific params from connectivity params + graphParamNames = {'Threshold', 'ThresholdMethod', 'Binarize', ... + 'Metrics', 'Gamma', 'NReplicates', 'NRandom'}; + graphArgs = {}; + connArgs = {}; + + i = 1; + while i <= length(varargin) + if ischar(varargin{i}) && any(strcmpi(varargin{i}, graphParamNames)) + graphArgs = [graphArgs, varargin(i:i+1)]; %#ok + i = i + 2; + else + connArgs = [connArgs, varargin(i)]; %#ok + i = i + 1; + end + end + + % Compute connectivity (handles Blocks internally) + connResult = obj.connectivity(connArgs{:}); + + % Check if block-wise + if ~isempty(connResult) && isfield(connResult, 'groups') + % Block-wise: connResult is struct array with .groups per block + nBlocks = length(connResult); + result = connResult; % preserve block metadata + for b = 1:nBlocks + nGrp = length(connResult(b).groups); + grpMetrics = struct([]); + for g = 1:nGrp + m = exploreFNIRS.graph.computeMetrics( ... + connResult(b).groups(g), graphArgs{:}); + if isfield(connResult(b).groups(g), 'label') + m.label = connResult(b).groups(g).label; + end + if isempty(grpMetrics) + grpMetrics = m; + else + grpMetrics(g) = m; + end + end + result(b).groups = grpMetrics; + end + else + % Standard: connResult is struct array (one per group) + nGrp = length(connResult); + result = struct([]); + for g = 1:nGrp + m = exploreFNIRS.graph.computeMetrics( ... + connResult(g), graphArgs{:}); + if isfield(connResult(g), 'label') + m.label = connResult(g).label; + end + if isempty(result) + result = m; + else + result(g) = m; + end + end + end + end + + + function result = hyperscanning(obj, varargin) + % HYPERSCANNING Inter-brain synchrony analysis across paired subjects + % + % result = ex.hyperscanning() + % result = ex.hyperscanning('Method', 'pearson', 'Permutations', 500) + % result = ex.hyperscanning('ManualPairs', {{1,2},{3,4}}) + % result = ex.hyperscanning('Blocks', blocks) + % + % Pairs subjects using .info.DyadID metadata, computes cross-brain + % coupling for each dyad, and aggregates into group statistics. + % Operates on selected data directly (no aggregate needed). + % + % When 'Blocks' is provided, computes hyperscanning for each block's + % time window and returns a struct array with one element per block. + % + % Name-Value Parameters: + % Method - 'pearson' (default), 'spearman', 'xcorr', 'coherence', 'wcoherence' + % Biomarker - 'HbO' (default), 'HbR', 'HbTotal', 'HbDiff', 'CBSI' + % ChannelPairing - 'same' (default) or 'all' + % Channels - Channel indices (default: intersection of good channels) + % TimeWindow - [start, end] seconds + % Permutations - Number of permutations for significance (default: 0, none) + % PThreshold - Significance threshold (default: 0.05) + % ManualPairs - Manual pairing override (see pairSubjects) + % DyadField - Info field for dyad ID (default: 'DyadID') + % RoleField - Info field for role (default: 'Role') + % CouplingArgs - Extra args for coupling function (default: {}) + % Blocks - Block definition struct array from pf2.data.defineBlocks + % When provided, computes hyperscanning per block. + % Align - Channel alignment mode for group aggregation: + % 'union' (default) - all channels, NaN where missing + % 'intersection' - only channels in all subjects + % numeric 0-1 - channels in >= threshold fraction + % + % Outputs (without Blocks): + % result - Struct with fields from computeGroup, plus: + % .pairs - Pairs struct from pairSubjects + % .permutation - Permutation test result (if Permutations > 0) + % + % Outputs (with Blocks): + % result - Struct array (one per block) with fields: + % .blockNumber - Block index + % .startTime - Block start time + % .endTime - Block end time + % .blockInfo - Block .info struct + % .coupling - Hyperscanning result (same format as above) + % + % See also: exploreFNIRS.hyperscanning.pairSubjects, + % exploreFNIRS.hyperscanning.computeGroup, + % exploreFNIRS.hyperscanning.permutationTest, pf2.data.defineBlocks + + ip = inputParser; + addParameter(ip, 'Method', 'pearson', @ischar); + addParameter(ip, 'Biomarker', 'HbO', @ischar); + addParameter(ip, 'ChannelPairing', 'same', @ischar); + addParameter(ip, 'Channels', [], @isnumeric); + addParameter(ip, 'TimeWindow', [], @(v) isnumeric(v) && (isempty(v) || length(v) == 2)); + addParameter(ip, 'Permutations', 0, @(v) isnumeric(v) && isscalar(v)); + addParameter(ip, 'PThreshold', 0.05, @isnumeric); + addParameter(ip, 'ManualPairs', {}, @iscell); + addParameter(ip, 'DyadField', 'DyadID', @ischar); + addParameter(ip, 'RoleField', 'Role', @ischar); + addParameter(ip, 'CouplingArgs', {}, @iscell); + addParameter(ip, 'UseROI', false, @islogical); + addParameter(ip, 'Blocks', [], @(x) isempty(x) || isstruct(x)); + addParameter(ip, 'Align', 'union', @(x) (ischar(x) || isstring(x)) || (isnumeric(x) && isscalar(x))); + parse(ip, varargin{:}); + opts = ip.Results; + + selData = obj.getSelectedData(); + + % Pair subjects (same pairs for all blocks) + pairArgs = {}; + if ~isempty(opts.ManualPairs) + pairArgs = [pairArgs, 'ManualPairs', {opts.ManualPairs}]; + end + pairArgs = [pairArgs, 'DyadField', opts.DyadField, 'RoleField', opts.RoleField]; + + pairs = exploreFNIRS.hyperscanning.pairSubjects(selData, pairArgs{:}); + + if isempty(pairs) + error('exploreFNIRS:core:Experiment:hyperscanning', ... + 'No valid pairs found. Check .info.%s or use ManualPairs.', opts.DyadField); + end + + % Build base args for computeGroup/computeDyad + groupArgs = {'Method', opts.Method, 'Biomarker', opts.Biomarker, ... + 'ChannelPairing', opts.ChannelPairing}; + if ~isempty(opts.Channels) + groupArgs = [groupArgs, 'Channels', opts.Channels]; + end + if ~isempty(opts.CouplingArgs) + groupArgs = [groupArgs, 'CouplingArgs', {opts.CouplingArgs}]; + end + if opts.UseROI + groupArgs = [groupArgs, 'UseROI', true]; + end + + alignMode = opts.Align; + + if isempty(opts.Blocks) + % Standard: single time window + coreArgs = groupArgs; + if ~isempty(opts.TimeWindow) + coreArgs = [coreArgs, 'TimeWindow', opts.TimeWindow]; + end + result = computeHyperscanningCore(selData, pairs, coreArgs, ... + opts.Permutations, opts.PThreshold, alignMode); + else + % Block-wise: iterate over blocks + blocks = opts.Blocks; + nBlocks = length(blocks); + result = struct([]); + for b = 1:nBlocks + tw = [blocks(b).startTime, blocks(b).endTime]; + coreArgs = [groupArgs, 'TimeWindow', tw]; + + result(b).blockNumber = b; + result(b).startTime = blocks(b).startTime; + result(b).endTime = blocks(b).endTime; + result(b).blockInfo = blocks(b).info; + result(b).coupling = computeHyperscanningCore( ... + selData, pairs, coreArgs, ... + opts.Permutations, opts.PThreshold, alignMode); + end + fprintf('Computed hyperscanning for %d blocks across %d dyads.\n', ... + nBlocks, length(pairs)); + end + end + + + function result = hbica(obj, varargin) + % HBICA Hyper-Brain ICA for inter-brain network detection + % + % result = ex.hbica() + % result = ex.hbica('Biomarker', 'HbR', 'GOFThreshold', -0.5) + % result = ex.hbica('ManualPairs', {{1,2},{3,4}}) + % result = ex.hbica('Blocks', blocks) + % + % Pairs subjects using .info.DyadID metadata, runs HB-ICA + % decomposition for each dyad, and aggregates results. + % + % Name-Value Parameters: + % Biomarker - 'HbO' (default), 'HbR', 'HbTotal', 'HbDiff', 'CBSI' + % Channels - Channel indices (default: intersection of good channels) + % TimeWindow - [start, end] seconds + % NumComponents - ICA components (default: auto) + % VarianceRetained - PCA threshold (default: 0.99) + % Lags - TDSEP lags (default: auto) + % GOFThreshold - Inter-brain classification threshold (default: 0) + % Detrend - Polynomial detrend order (default: 1) + % ZScore - Z-score channels before concat (default: true) + % UseROI - Use ROI-level data instead of channels (default: false) + % ManualPairs - Manual pairing override (see pairSubjects) + % DyadField - Info field for dyad ID (default: 'DyadID') + % RoleField - Info field for role (default: 'Role') + % Blocks - Block struct array from pf2.data.defineBlocks + % + % Outputs (without Blocks): + % result - Struct with fields: + % .dyads - Cell array of per-dyad hbica results + % .dyadIDs - Cell array of dyad ID strings + % .pairs - Pairs struct from pairSubjects + % .summary - Struct with .meanGOF, .nInterBrain per dyad + % + % Outputs (with Blocks): + % result - Struct array (one per block) with fields: + % .blockNumber, .startTime, .endTime, .blockInfo, .hbica + % + % See also: exploreFNIRS.hyperscanning.hbica, + % exploreFNIRS.hyperscanning.plotHBICA, + % exploreFNIRS.hyperscanning.pairSubjects + + ip = inputParser; + addParameter(ip, 'Biomarker', 'HbO', @ischar); + addParameter(ip, 'Channels', [], @isnumeric); + addParameter(ip, 'TimeWindow', [], @(v) isnumeric(v) && (isempty(v) || length(v) == 2)); + addParameter(ip, 'NumComponents', 0, @(v) isnumeric(v) && isscalar(v)); + addParameter(ip, 'VarianceRetained', 0.99, @(v) isnumeric(v) && isscalar(v)); + addParameter(ip, 'Lags', [], @(v) isnumeric(v)); + addParameter(ip, 'GOFThreshold', 0, @(v) isnumeric(v) && isscalar(v)); + addParameter(ip, 'Detrend', 1, @(v) isnumeric(v) && isscalar(v)); + addParameter(ip, 'ZScore', true, @islogical); + addParameter(ip, 'UseROI', false, @islogical); + addParameter(ip, 'ManualPairs', {}, @iscell); + addParameter(ip, 'DyadField', 'DyadID', @ischar); + addParameter(ip, 'RoleField', 'Role', @ischar); + addParameter(ip, 'Blocks', [], @(x) isempty(x) || isstruct(x)); + parse(ip, varargin{:}); + opts = ip.Results; + + selData = obj.getSelectedData(); + + % Pair subjects + pairArgs = {}; + if ~isempty(opts.ManualPairs) + pairArgs = [pairArgs, 'ManualPairs', {opts.ManualPairs}]; + end + pairArgs = [pairArgs, 'DyadField', opts.DyadField, 'RoleField', opts.RoleField]; + pairs = exploreFNIRS.hyperscanning.pairSubjects(selData, pairArgs{:}); + + if isempty(pairs) + error('exploreFNIRS:core:Experiment:hbica', ... + 'No valid pairs found. Check .info.%s or use ManualPairs.', opts.DyadField); + end + + % Build HB-ICA args + hbicaArgs = {'Biomarker', opts.Biomarker, ... + 'NumComponents', opts.NumComponents, ... + 'VarianceRetained', opts.VarianceRetained, ... + 'GOFThreshold', opts.GOFThreshold, ... + 'Detrend', opts.Detrend, ... + 'ZScore', opts.ZScore}; + if ~isempty(opts.Channels) + hbicaArgs = [hbicaArgs, 'Channels', opts.Channels]; + end + if ~isempty(opts.Lags) + hbicaArgs = [hbicaArgs, 'Lags', opts.Lags]; + end + if opts.UseROI + hbicaArgs = [hbicaArgs, 'UseROI', true]; + end + + if isempty(opts.Blocks) + result = computeHBICAcore(selData, pairs, hbicaArgs, opts.TimeWindow); + else + blocks = opts.Blocks; + nBlocks = length(blocks); + result = struct([]); + for b = 1:nBlocks + tw = [blocks(b).startTime, blocks(b).endTime]; + result(b).blockNumber = b; + result(b).startTime = blocks(b).startTime; + result(b).endTime = blocks(b).endTime; + result(b).blockInfo = blocks(b).info; + result(b).hbica = computeHBICAcore(selData, pairs, hbicaArgs, tw); + end + fprintf('Computed HB-ICA for %d blocks across %d dyads.\n', ... + nBlocks, length(pairs)); + end + end + + + function fig = plotTopo(obj, varargin) + % PLOTTOPO Group-level 2D topographic map + % + % fig = ex.plotTopo() + % fig = ex.plotTopo('Biomarker', 'HbO', 'Time', 10) + % fig = ex.plotTopo('Layout', 'pergroup', 'SavePath', 'topo.png') + % + % See also: exploreFNIRS.core.plotTopo + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotTopo', ... + 'Call aggregate() before plotting'); + end + varargin = obj.injectColorScheme(varargin); + % Inject Device from data if not explicitly provided + if ~any(strcmpi(varargin(1:2:end), 'Device')) + dev = obj.resolveDevice(); + if ~isempty(dev) + varargin = [varargin, {'Device', dev}]; + end + end + fig = exploreFNIRS.core.plotTopo(obj.groups, varargin{:}); + end + + + function fig = plotHeatmap(obj, varargin) + % PLOTHEATMAP Channel x time heatmap + % + % fig = ex.plotHeatmap() + % fig = ex.plotHeatmap('Biomarker', 'HbO', 'SortChannels', 'amplitude') + % fig = ex.plotHeatmap('XLim', [-5 35], 'SavePath', 'heatmap.png') + % + % The visible time range is set at aggregate() time by + % settings.viewPad (default [5,5] seconds, padding around + % baseline-start and task-end). For visual cropping of an + % already-wide view, pass 'XLim', [tmin tmax]. + % + % See also: exploreFNIRS.core.plotHeatmap, plotTemporal + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotHeatmap', ... + 'Call aggregate() before plotting'); + end + varargin = obj.injectColorScheme(varargin); + % Inject Device from data if not explicitly provided + if ~any(strcmpi(varargin(1:2:end), 'Device')) + dev = obj.resolveDevice(); + if ~isempty(dev) + varargin = [varargin, {'Device', dev}]; + end + end + fig = exploreFNIRS.core.plotHeatmap(obj.groups, varargin{:}); + end + + + function fig = plotComposite(obj, panels, varargin) + % PLOTCOMPOSITE Multi-panel publication figure + % + % panels = {struct('type','temporal','args',{{'Biomarkers',{'HbO'}}}), ... + % struct('type','bar','args',{{'Biomarker','HbO'}})}; + % fig = ex.plotComposite(panels, 'Layout', [1,2]) + % + % See also: exploreFNIRS.core.plotComposite + + if ~obj.isAggregated + error('exploreFNIRS:core:Experiment:plotComposite', ... + 'Call aggregate() before plotting'); + end + % Inject Device into topo panels that don't already have one + dev = obj.resolveDevice(); + if ~isempty(dev) + for pi = 1:length(panels) + if strcmpi(panels{pi}.type, 'topo') && isfield(panels{pi}, 'args') + pArgs = panels{pi}.args; + if ~any(strcmpi(pArgs(1:2:end), 'Device')) + panels{pi}.args = [pArgs, {'Device', dev}]; + end + end + end + end + fig = exploreFNIRS.core.plotComposite(obj.groups, panels, varargin{:}); + end + + + function result = intraROI(obj, varargin) + % INTRAROI Within-ROI connectivity analysis per group + % + % result = ex.intraROI() + % result = ex.intraROI('Method', 'pearson', 'Biomarker', 'HbO') + % + % For each group, computes pairwise coupling between channels within + % each ROI and summarizes. Requires groupby() and ROI definitions. + % + % See also: exploreFNIRS.connectivity.computeIntraROI + + if ~obj.isGrouped + error('exploreFNIRS:core:Experiment:intraROI', ... + 'Call groupby() before intraROI()'); + end + + nGroups = length(obj.groups); + result = struct([]); + + for g = 1:nGroups + curData = obj.groups(g).gbyFNIRS; + nSubjects = length(curData); + + fprintf('Group [%d] %s: computing intra-ROI for %d subjects...\n', ... + g, obj.groups(g).label, nSubjects); + + subResults = cell(nSubjects, 1); + useParfor = false; + if nSubjects > 2 + [canUse, poolRunning] = pf2_base.accel.canParfor(); + useParfor = canUse && poolRunning; + end + if useParfor + parfor s = 1:nSubjects + subResults{s} = exploreFNIRS.connectivity.computeIntraROI( ... + curData{s}, varargin{:}); + end + else + for s = 1:nSubjects + subResults{s} = exploreFNIRS.connectivity.computeIntraROI( ... + curData{s}, varargin{:}); + end + end + + result(g).subjectResults = subResults; + result(g).label = obj.groups(g).label; + result(g).N = nSubjects; + + % Aggregate across subjects + nROIs = length(subResults{1}.roiMetrics); + roiMetrics = subResults{1}.roiMetrics; + for r = 1:nROIs + allMean = zeros(nSubjects, 1); + for s = 1:nSubjects + allMean(s) = subResults{s}.roiMetrics(r).meanCoupling; + end + roiMetrics(r).groupMean = mean(allMean, 'omitnan'); + roiMetrics(r).groupSEM = std(allMean, 'omitnan') / sqrt(nSubjects); + roiMetrics(r).groupSD = std(allMean, 'omitnan'); + end + result(g).roiMetrics = roiMetrics; + result(g).method = subResults{1}.method; + end + end + + + function result = interROI(obj, varargin) + % INTERROI Between-ROI connectivity analysis per group + % + % result = ex.interROI() + % result = ex.interROI('Method', 'pearson', 'Biomarker', 'HbO') + % result = ex.interROI('Align', 'intersection') + % + % For each group, computes connectivity between ROI pairs using + % ROI-level data. Requires groupby() and ROI definitions. + % + % Name-Value Parameters: + % Align - Channel alignment mode (default: 'union') + % + % See also: exploreFNIRS.connectivity.computeMatrix + + if ~obj.isGrouped + error('exploreFNIRS:core:Experiment:interROI', ... + 'Call groupby() before interROI()'); + end + + [align, fwdArgs] = extractAlignArg(varargin); + result = computeConnectivityGroups(obj.groups, ... + [{'UseROI', true}, fwdArgs], align); + end + + end + + methods (Static) + + function ex = fromConfig(cfg) + % FROMCONFIG Build an Experiment from a declarative config struct + % + % Collapses the import -> metadata -> process -> blocks -> + % Experiment pipeline into a single call. Each config section is + % optional except one of cfg.import or cfg.data. + % + % Syntax: + % ex = exploreFNIRS.core.Experiment.fromConfig(cfg) + % + % Config Struct Sections: + % cfg.import.dir - Root directory for data files + % cfg.import.pattern - File pattern ('*.snirf', '*.nir', etc.) + % cfg.import.dirMapping - Cell array for importDirectory mapping + % e.g. {'Dir1','Group','Dir2','SubjectID'} + % + % cfg.data - Pre-loaded cell array (alternative to import) + % + % cfg.metadata.file - CSV/Excel path for metadata merge + % cfg.metadata.key - Key field(s) for matching + % + % cfg.process.rawMethod - Named raw processing method + % cfg.process.oxyMethod - Named oxy processing method + % cfg.process.options - Additional NV pairs for processFNIRS2 + % + % cfg.blocks.markerCodes - Marker code(s) for defineBlocks + % cfg.blocks.duration - Block duration in seconds + % cfg.blocks.conditionMap - Cell array mapping codes to labels + % cfg.blocks.preTime - Time before first marker to keep (default: 5) + % cfg.blocks.postTime - Time after last marker to keep (default: 15) + % + % cfg.experiment.baseline - [start, end] baseline window + % cfg.experiment.taskEnd - Task end time + % cfg.experiment.resampleRate - Temporal resample rate + % cfg.experiment.barBinSize - Bar chart bin size + % cfg.experiment.avgMode - 'hierarchy', 'flat', or 'none' + % cfg.experiment.statWindow - [start, end] stat analysis window + % cfg.experiment.hierarchy - Cell array of hierarchy levels + % + % Example: + % cfg.import.dir = 'data/'; + % cfg.import.pattern = '*.snirf'; + % cfg.import.dirMapping = {'Dir1','Group','Dir2','SubjectID'}; + % cfg.metadata.file = 'demographics.csv'; + % cfg.metadata.key = 'SubjectID'; + % cfg.blocks.markerCodes = [10, 20]; + % cfg.blocks.duration = 30; + % cfg.blocks.conditionMap = {'Easy','Hard'}; + % cfg.experiment.baseline = [-5, 0]; + % cfg.experiment.taskEnd = 30; + % cfg.experiment.hierarchy = {'SubjectID','Condition'}; + % + % ex = exploreFNIRS.core.Experiment.fromConfig(cfg); + % ex.select('Condition', {'Easy','Hard'}); + % ex.groupby('Condition'); + % ex.aggregate(); + % + % See also: exploreFNIRS.core.Experiment, pf2.import.importDirectory, + % processFNIRS2, pf2.data.defineBlocks + + % --- Validate --- + validateConfig(cfg); + + % --- Stage 1: Import --- + if isfield(cfg, 'data') && ~isempty(cfg.data) + allData = cfg.data; + if ~iscell(allData) + allData = {allData}; + end + fprintf('fromConfig: Using %d pre-loaded data segments.\n', length(allData)); + else + imp = cfg.import; + dirArgs = {}; + if isfield(imp, 'dirMapping') && ~isempty(imp.dirMapping) + dirArgs = imp.dirMapping; + end + try + allData = pf2.import.importDirectory(imp.dir, imp.pattern, dirArgs{:}); + catch ME + error('exploreFNIRS:core:Experiment:fromConfig:importFailed', ... + 'Import failed: %s', ME.message); + end + fprintf('fromConfig: Imported %d files from %s\n', length(allData), imp.dir); + end + + % --- Stage 2: Metadata --- + if isfield(cfg, 'metadata') && ~isempty(cfg.metadata) + meta = cfg.metadata; + if isfield(meta, 'file') && ~isempty(meta.file) + key = 'SubjectID'; + if isfield(meta, 'key') && ~isempty(meta.key) + key = meta.key; + end + try + allData = pf2.data.importInfo(allData, meta.file, key); + catch ME + error('exploreFNIRS:core:Experiment:fromConfig:metadataFailed', ... + 'Metadata import failed: %s', ME.message); + end + fprintf('fromConfig: Merged metadata from %s\n', meta.file); + end + end + + % --- Stage 3: Process --- + if isfield(cfg, 'process') && ~isempty(cfg.process) + proc = cfg.process; + rawMethod = ''; + oxyMethod = ''; + procOpts = {}; + if isfield(proc, 'rawMethod'), rawMethod = proc.rawMethod; end + if isfield(proc, 'oxyMethod'), oxyMethod = proc.oxyMethod; end + if isfield(proc, 'options'), procOpts = proc.options; end + + procArgs = {}; + if ~isempty(rawMethod) || ~isempty(oxyMethod) + procArgs = {rawMethod, oxyMethod}; + end + try + allData = processFNIRS2(allData, procArgs{:}, procOpts{:}); + catch ME + error('exploreFNIRS:core:Experiment:fromConfig:processFailed', ... + 'Processing failed: %s', ME.message); + end + fprintf('fromConfig: Processed %d datasets.\n', length(allData)); + end + + % --- Stage 4: Blocks --- + if isfield(cfg, 'blocks') && ~isempty(cfg.blocks) + blk = cfg.blocks; + if ~isfield(blk, 'markerCodes') || isempty(blk.markerCodes) + error('exploreFNIRS:core:Experiment:fromConfig:noMarkerCodes', ... + 'cfg.blocks.markerCodes is required for block extraction.'); + end + + blockArgs = {}; + if isfield(blk, 'conditionMap') && ~isempty(blk.conditionMap) + blockArgs = [blockArgs, {'ConditionMap', blk.conditionMap}]; + end + % Resolve an explicit extraction window. extractBlocks now + % defaults to a 5 s Buffer (and prints a one-time note) when no + % window is given; pass PreTime/PostTime explicitly to the + % extractBlocks call below so this internal path stays silent and + % reproducible. These are extractBlocks parameters only -- they + % are NOT appended to blockArgs, which is forwarded to + % defineBlocks (which does not recognize PreTime/PostTime). + preTime = 5; + postTime = 15; + if isfield(blk, 'preTime') && ~isempty(blk.preTime) + preTime = blk.preTime; + end + if isfield(blk, 'postTime') && ~isempty(blk.postTime) + postTime = blk.postTime; + end + + dur = 0; + if isfield(blk, 'duration'), dur = blk.duration; end + + try + allData = pf2.data.defineBlocks(allData, blk.markerCodes, dur, ... + blockArgs{:}, 'Embed', true); + allData = pf2.data.extractBlocks(allData, ... + 'PreTime', preTime, 'PostTime', postTime); + catch ME + error('exploreFNIRS:core:Experiment:fromConfig:blocksFailed', ... + 'Block extraction failed: %s', ME.message); + end + fprintf('fromConfig: Extracted blocks (%d marker codes, %.0fs duration).\n', ... + length(blk.markerCodes), dur); + end + + % --- Stage 5: Build Experiment --- + expArgs = {}; + if isfield(cfg, 'experiment') && ~isempty(cfg.experiment) + expCfg = cfg.experiment; + if isfield(expCfg, 'hierarchy') && ~isempty(expCfg.hierarchy) + expArgs = {'Hierarchy', expCfg.hierarchy}; + end + end + + ex = exploreFNIRS.core.Experiment(allData, expArgs{:}); + + % Apply experiment settings + if isfield(cfg, 'experiment') && ~isempty(cfg.experiment) + expCfg = cfg.experiment; + s = ex.settings; + + if isfield(expCfg, 'baseline'), s.baseline = expCfg.baseline; end + if isfield(expCfg, 'taskEnd'), s.taskEnd = expCfg.taskEnd; end + if isfield(expCfg, 'resampleRate'), s.resampleRate = expCfg.resampleRate; end + if isfield(expCfg, 'barBinSize'), s.barBinSize = expCfg.barBinSize; end + if isfield(expCfg, 'avgMode'), s.avgMode = expCfg.avgMode; end + if isfield(expCfg, 'statWindow') + sw = expCfg.statWindow; + if ~isnumeric(sw) || numel(sw) ~= 2 + error('exploreFNIRS:core:Experiment:fromConfig:invalidStatWindow', ... + 'statWindow must be a 2-element numeric vector [start, end].'); + end + s.statWindow = sw; + end + if isfield(expCfg, 'taskStart'), s.taskStart = expCfg.taskStart; end + + ex.settings = s; + end + + fprintf('fromConfig: Experiment created with %d segments.\n', length(allData)); + end + + end + + methods (Hidden) + % These methods support PlotProxy's transactional state management. + % They are Hidden so they don't clutter tab-completion, but are + % accessible from PlotProxy (same package). + + function saveState(obj) + % SAVESTATE Snapshot lightweight state for later restore + % + % ex.saveState() % called by PlotProxy before modifying state + + obj.stateSnapshot = struct( ... + 'selectedIdx', obj.selectedIdx, ... + 'groupByVars', {obj.groupByVars}, ... + 'groups', obj.groups, ... + 'isGrouped', obj.isGrouped, ... + 'isAggregated', obj.isAggregated, ... + 'settings', obj.settings); + end + + + function restoreState(obj) + % RESTORESTATE Restore state from snapshot + % + % ex.restoreState() % called by PlotProxy after rendering + + if isempty(obj.stateSnapshot), return; end + + s = obj.stateSnapshot; + obj.selectedIdx = s.selectedIdx; + obj.groupByVars = s.groupByVars; + obj.groups = s.groups; + obj.isGrouped = s.isGrouped; + obj.isAggregated = s.isAggregated; + obj.settings = s.settings; + obj.stateSnapshot = []; + end + + + function narrowSelection(obj, logicalIdx) + % NARROWSELECTION AND a logical index with current selection + % + % ex.narrowSelection(idx) % called by PlotProxy for filter + + obj.selectedIdx = obj.selectedIdx & logicalIdx(:); + end + + + function g = getGroups(obj) + % GETGROUPS Return current groups struct array + g = obj.groups; + end + + + function tf = getIsAggregated(obj) + % GETISAGGREGATED Check if experiment is aggregated + tf = obj.isAggregated; + end + + + function vars = getGroupByVars(obj) + % GETGROUPBYVARS Return current groupby variable names + vars = obj.groupByVars; + end + + + function dev = resolveDevice(obj) + % RESOLVEDEVICE Extract or load Device, propagate to all data + % + % Mirrors the GUI device resolution: first looks for an existing + % pf2.Device on any data element, then tries Device.load() from + % the first element. Once resolved, attaches the Device to all + % elements that lack one so subsequent calls are instant. + + dev = []; + if isempty(obj.data), return; end + + % 1. Find first element that already has a Device + for i = 1:length(obj.data) + if isfield(obj.data{i}, 'device') ... + && isa(obj.data{i}.device, 'pf2.Device') + dev = obj.data{i}.device; + break; + end + end + + % 2. Try loading from first element if none found + if isempty(dev) + try + dev = pf2.Device.load(obj.data{1}); + catch + return; + end + end + + % 3. Propagate to all elements that lack one + for i = 1:length(obj.data) + if ~isfield(obj.data{i}, 'device') ... + || ~isa(obj.data{i}.device, 'pf2.Device') + obj.data{i}.device = dev; + end + end + end + + + function args = injectColorScheme(obj, args) + % INJECTCOLORSCHEME Auto-inject colorScheme as Colors if not set + % + % Priority: explicit 'ColorScheme' param > explicit 'Colors' > default + + keys = args(1:2:end); + + % Check for explicit 'ColorScheme' param + csIdx = find(strcmpi(keys, 'ColorScheme'), 1); + if ~isempty(csIdx) + valIdx = csIdx * 2; + csVal = args{valIdx}; + % Resolve name to object + if ischar(csVal) || isstring(csVal) + name = char(csVal); + if ~isfield(obj.colorSchemes, name) + error('exploreFNIRS:core:Experiment:injectColorScheme', ... + 'Unknown color scheme: "%s". Available: %s', ... + name, strjoin(fieldnames(obj.colorSchemes), ', ')); + end + csVal = obj.colorSchemes.(name); + end + % Remove 'ColorScheme' pair, inject as 'Colors' + args([csIdx * 2 - 1, valIdx]) = []; + % Only inject if no explicit Colors already + if ~any(strcmpi(args(1:2:end), 'Colors')) + args = [args, {'Colors', csVal}]; + end + return; + end + + % Fallback: inject default colorScheme if no Colors set + if isempty(obj.colorScheme), return; end + if ~any(strcmpi(keys, 'Colors')) + args = [args, {'Colors', obj.colorScheme}]; + end + end + + + function args = injectStatWindow(obj, args) + % INJECTSTATWINDOW Auto-inject statWindow setting as StatWindow param + % + % Precedence: + % 1. User-supplied StatWindow (left untouched) + % 2. settings.statWindow (when set) + % 3. [taskStart, taskEnd] when viewPad is set — pins bar stats + % so widening the view never silently changes a bar value + keys = args(1:2:end); + if any(strcmpi(keys, 'StatWindow')), return; end + + s = obj.settings; + if ~isempty(s.statWindow) + args = [args, {'StatWindow', s.statWindow}]; + elseif ~isempty(s.viewPad) + % Match the exclusive upper bound of trimToTaskWindow + % (t < tEnd) so the boundary bin at taskEnd — which is + % a post-task sample — is not included in the bar average. + if isfinite(s.taskEnd) + pinEnd = s.taskEnd - 1e-9; + else + pinEnd = inf; + end + args = [args, {'StatWindow', [s.taskStart, pinEnd]}]; + end + end + + + function args = injectTimeModel(obj, args) + % INJECTTIMEMODEL Auto-inject timeModel/polyOrder settings + keys = args(1:2:end); + if ~any(strcmpi(keys, 'TimeModel')) && ~isempty(obj.settings.timeModel) + args = [args, {'TimeModel', obj.settings.timeModel}]; + end + if ~any(strcmpi(keys, 'PolynomialOrder')) && obj.settings.polyOrder ~= 2 + args = [args, {'PolynomialOrder', obj.settings.polyOrder}]; + end + end + + + function warnBetweenSubjectConfound(obj, fixedVars, args) + % WARNBETWEENSUBJECTCONFOUND Flag inestimable between-grouping factors + % + % Summary: + % When an LME model carries a random grouping variable (e.g. the + % (1|SubjectID) random intercept), any fixed-effect factor that is + % constant within every level of that grouping variable is + % confounded with the random intercept and cannot be estimated. Such + % terms produce repeated MATLAB "Hessian not positive definite" / + % rank-deficiency warnings and all-NaN ANOVA rows. This helper + % detects those factors up front and emits ONE consolidated, plain- + % language diagnostic naming the offending factor(s), the grouping + % variable, and concrete fixes — so the outcome reads as a design + % limitation rather than a toolbox failure. + % + % Inputs: + % fixedVars - Cell array of candidate fixed-effect variable names + % (the current groupby variables). + % args - The varargin name/value cell forwarded to the fit, used + % to recover the 'RandomEffects' formula (default + % '1|SubjectID'). + % + % Outputs: + % (none) - Emits at most one warning with id + % 'exploreFNIRS:statsLME:betweenSubjectConfound'. + % + % Notes: + % - Conservative: only inspects; never alters the user's model. + % - Emits a single message per call, not per channel/term. + + % Recover the effective random-effects spec. A CustomFormula + % overrides RandomEffects entirely, so its grouping structure + % (not the default '1|SubjectID') is what governs the confound. + % Default mirrors fitLME. + randomFx = '1|SubjectID'; + keys = args(1:2:end); + cfIdx = find(strcmpi(keys, 'CustomFormula'), 1); + haveCustom = false; + if ~isempty(cfIdx) + cfVal = args{cfIdx * 2}; + if (ischar(cfVal) || isstring(cfVal)) && ~isempty(char(cfVal)) + randomFx = char(cfVal); + haveCustom = true; + end + end + if ~haveCustom + reIdx = find(strcmpi(keys, 'RandomEffects'), 1); + if ~isempty(reIdx) + val = args{reIdx * 2}; + if ischar(val) || isstring(val) + randomFx = char(val); + end + end + end + + % No '|' anywhere -> no random intercept -> no between-subject + % confound to flag (e.g. a CustomFormula like 'HbO~Group'). + if ~any(randomFx == '|'), return; end + + % Robustly collect EVERY grouping variable that appears after a + % '|'. regexp tolerates parentheses, surrounding whitespace, and + % multiple random terms, e.g. '(1|A)+(1|B)' -> {'A','B'}. + tok = regexp(randomFx, '\|\s*([A-Za-z]\w*)', 'tokens'); + groupVars = unique(cellfun(@(c) c{1}, tok, 'UniformOutput', false), ... + 'stable'); + if isempty(groupVars), return; end + + % Need the metadata table to test against. + tbl = obj.getSelectedTable(); + + % The confound only BITES when the fitted model carries within- + % subject replication: a between-subjects factor and (1|grp) are + % aliased only if grp has more than one observation. With exactly + % one observation per grouping level the random intercept is + % confounded with the residual, not with the fixed effect, and the + % between-subjects term IS estimable (fitlme handles it). Replication + % enters from two sources: multiple SEGMENTS per level, or multiple + % TIME BINS per segment (barBinSize>0 over a finite window). Estimate + % the time-bin count from settings; barBinSize<=0 collapses to a + % single bar (one time point), so time adds no replication. + s = obj.settings; + if isfield(s, 'barBinSize') && s.barBinSize > 0 + ts = 0; if isfield(s, 'taskStart'), ts = s.taskStart; end + span = NaN; + if isfield(s, 'taskEnd') && isfinite(s.taskEnd) + span = s.taskEnd - ts; + else + % taskEnd = Inf (the default) means "use the full segment". + % Derive the span from the actual selected data instead of + % assuming infinite replication, otherwise a clean one- + % segment-per-subject design with binning enabled is wrongly + % flagged. Use the longest selected segment as an upper bound. + selData = obj.getSelectedData(); + maxT = 0; + for d = 1:numel(selData) + if isstruct(selData{d}) && isfield(selData{d}, 'time') && ... + ~isempty(selData{d}.time) + maxT = max(maxT, max(selData{d}.time)); + end + end + if maxT > ts, span = maxT - ts; end + end + if ~isfinite(span) || span <= 0 + nTimeBins = 1; % truly unknown window -> assume no time replication + else + nTimeBins = max(1, floor(span / s.barBinSize + 1e-9)); + end + else + nTimeBins = 1; % single bar -> one time point per segment + end + + % Test each fixed factor against each real grouping column; a + % factor confounded with ANY grouping variable is flagged, paired + % with that grouping variable for the message. + confounded = {}; + confoundGroups = {}; + for g = 1:numel(groupVars) + groupVar = groupVars{g}; + if ~ismember(groupVar, tbl.Properties.VariableNames), continue; end + % Skip this grouping variable if the model will have no within- + % level replication (<=1 segment per level AND <=1 time bin): + % the between-subjects factor is then estimable, so flagging it + % would mislead (the reviewer's one-row-per-subject case). + [~, ~, lvlIdx] = unique(tbl.(groupVar), 'stable'); + maxSegPerLevel = max(accumarray(lvlIdx(:), 1)); + if maxSegPerLevel <= 1 && nTimeBins <= 1, continue; end + for i = 1:numel(fixedVars) + fv = fixedVars{i}; + if strcmp(fv, groupVar), continue; end + if ismember(fv, confounded), continue; end + if ~ismember(fv, tbl.Properties.VariableNames), continue; end + if exploreFNIRS.core.Experiment.isConstantWithinGroup( ... + tbl.(fv), tbl.(groupVar)) + confounded{end+1} = fv; %#ok + confoundGroups{end+1} = groupVar; %#ok + end + end + end + + if isempty(confounded), return; end + + quoted = cellfun(@(s) ['"' s '"'], confounded, ... + 'UniformOutput', false); + factorStr = strjoin(quoted, ', '); + % Name the relevant grouping variable(s) the factors are nested in. + groupVar = strjoin(unique(confoundGroups, 'stable'), '", "'); + if isscalar(confounded) + subjVerb = 'is a between-subjects factor'; + pronoun = 'it is'; + else + subjVerb = 'are between-subjects factors'; + pronoun = 'they are'; + end + + warning('exploreFNIRS:statsLME:betweenSubjectConfound', ... + ['LME design note: %s %s (constant within every level of the ' ... + 'random grouping variable "%s"), so %s confounded with the ' ... + '(1|%s) random intercept and cannot be estimated — the ' ... + 'corresponding ANOVA rows will be NaN or unreliable. This ' ... + 'reflects the design, not a failure: within-subject terms ' ... + '(e.g. Condition) still estimate normally. To estimate %s, ' ... + 'fit a between-subjects model without the (1|%s) random ' ... + 'intercept (pass ''RandomEffects'' / ''CustomFormula'' ' ... + 'accordingly), or collapse to one row per %s first (a flat ' ... + 'avgMode).'], ... + factorStr, subjVerb, groupVar, pronoun, groupVar, ... + factorStr, groupVar, groupVar); + end + end + + + methods (Static, Access = private) + function cleanupObj = suppressFitWarnings() + % SUPPRESSFITWARNINGS Scope-suppress fitlme rank/Hessian spam + % + % Summary: + % Turns off the specific MATLAB LinearMixedModel warning ids that + % fitlme repeats per channel/term when a design is rank-deficient or + % has more covariance parameters than the data support (the typical + % symptom of a between-subjects confound). The previous warning + % state is restored automatically when the returned onCleanup object + % goes out of scope, so suppression is strictly scoped to the fit and + % no unrelated warnings are affected. + % + % Outputs: + % cleanupObj - onCleanup handle that restores the prior warning state. + % + % Notes: + % The clean, consolidated explanation comes from + % warnBetweenSubjectConfound; this only mutes the raw spam. + % Delegates to exploreFNIRS.stats.suppressLMEWarnings so the + % suppressed identifier set lives in one place and cannot drift + % from the fitLME/fitInfoLME path. + cleanupObj = exploreFNIRS.stats.suppressLMEWarnings(); + end + + + function tf = isConstantWithinGroup(factorCol, groupCol) + % ISCONSTANTWITHINGROUP True if factorCol is constant within every + % unique value of groupCol (i.e. the factor is nested in / between + % the grouping variable). NaN/empty entries are ignored per group. + tf = false; + % Coerce both columns to string keys for robust comparison + fkey = exploreFNIRS.core.Experiment.colToStringKey(factorCol); + gkey = exploreFNIRS.core.Experiment.colToStringKey(groupCol); + if numel(fkey) ~= numel(gkey) || isempty(gkey), return; end + + ug = unique(gkey); + anyValid = false; % did any group contribute a real (non-missing) value? + for i = 1:numel(ug) + sel = strcmp(gkey, ug{i}); + vals = fkey(sel); + % Ignore missing markers so partially-missing groups don't + % falsely look variable. + vals = vals(~strcmp(vals, '')); + % A group with no valid values says nothing about constancy; + % skip it rather than letting its empty set imply "constant". + if isempty(vals), continue; end + anyValid = true; + if numel(unique(vals)) > 1 + return; % varies within this group -> not confounded + end + end + % If NO group had a single valid value, there is no evidence the + % factor is constant -> do not flag it. + tf = anyValid; + end + + + function keys = colToStringKey(col) + % COLTOSTRINGKEY Normalize a table column to a cellstr of keys for + % equality comparison, with a sentinel for missing values. + if iscell(col) + keys = cellfun(@(x) localToKey(x), col, 'UniformOutput', false); + elseif iscategorical(col) + keys = cellstr(col); + keys(ismissing(col)) = {''}; + elseif isstring(col) + keys = cellstr(col); + keys(ismissing(col)) = {''}; + elseif isnumeric(col) || islogical(col) + keys = cell(numel(col), 1); + for k = 1:numel(col) + if isnan(double(col(k))) + keys{k} = ''; + else + % %.15g is precision/locale stable (vs num2str). + keys{k} = sprintf('%.15g', double(col(k))); + end + end + else + keys = cellstr(string(col)); + end + + function key = localToKey(x) + if isnumeric(x) || islogical(x) + if isempty(x) || any(isnan(double(x(:)))) + key = ''; + else + % %.15g is precision/locale stable (vs num2str). + key = strjoin(arrayfun(@(v) sprintf('%.15g', v), ... + double(x(:)'), 'UniformOutput', false), ' '); + end + elseif ischar(x) + if isempty(x), key = ''; else, key = x; end + elseif isstring(x) + if ismissing(x) || strlength(x) == 0 + key = ''; + else + key = char(x); + end + else + key = char(string(x)); + end + end + end + end +end + + +%% Local helper functions + +function validateConfig(cfg) +% VALIDATECONFIG Check cfg struct for required fields and valid paths + + errors = {}; + + hasImport = isfield(cfg, 'import') && ~isempty(cfg.import); + hasData = isfield(cfg, 'data') && ~isempty(cfg.data); + + if ~hasImport && ~hasData + errors{end+1} = 'Either cfg.import or cfg.data is required.'; + end + + if hasImport + if ~isfield(cfg.import, 'dir') || isempty(cfg.import.dir) + errors{end+1} = 'cfg.import.dir is required.'; + elseif ~isfolder(cfg.import.dir) + errors{end+1} = sprintf('cfg.import.dir does not exist: %s', cfg.import.dir); + end + if ~isfield(cfg.import, 'pattern') || isempty(cfg.import.pattern) + errors{end+1} = 'cfg.import.pattern is required (e.g. ''*.snirf'').'; + end + end + + if isfield(cfg, 'metadata') && ~isempty(cfg.metadata) + if isfield(cfg.metadata, 'file') && ~isempty(cfg.metadata.file) ... + && ~isfile(cfg.metadata.file) + errors{end+1} = sprintf('cfg.metadata.file does not exist: %s', cfg.metadata.file); + end + end + + if isfield(cfg, 'blocks') && ~isempty(cfg.blocks) + if ~isfield(cfg.blocks, 'markerCodes') || isempty(cfg.blocks.markerCodes) + errors{end+1} = 'cfg.blocks.markerCodes is required when cfg.blocks is set.'; + end + end + + if ~isempty(errors) + error('exploreFNIRS:core:Experiment:fromConfig:invalidConfig', ... + 'Config validation failed:\n - %s', strjoin(errors, '\n - ')); + end +end + + +function [ppData, barData] = preprocessGroup(curData, s, doResample, doBaseline) +% PREPROCESSGROUP Preprocess segments: baseline extraction + resampling +% +% Extracted from aggregate() to enable caching. + +if doResample || doBaseline + ppData = cell(size(curData)); + barData = cell(size(curData)); + + % Determine effective task end + if isfinite(s.taskEnd) + effectiveTaskEnd = s.taskEnd; + else + effectiveTaskEnd = max(curData{1}.time); + end + taskDuration = effectiveTaskEnd - s.taskStart; + if taskDuration <= 0 + taskDuration = max(curData{1}.time) - s.taskStart; + end + + % Determine bar bin size: 0 = full task window (1 bar) + barBin = s.barBinSize; + if barBin <= 0 + barBin = taskDuration; + end + + % View bounds for ppData (display only). When viewPad=[], falls back + % to the legacy [taskStart, taskEnd) trim. When viewPad is set, the + % window is widened relative to baseline-start / task-end edges. + [viewStart, viewEnd, viewActive] = computeViewBounds(s, curData{1}); + + % For the non-resample baseline-correction path, the segment is split + % rather than resampled. Push the lower split bound earlier so that + % requested pre-baseline samples survive into ppData. + if doBaseline && viewActive + splitLow = min(s.baseline(2), viewStart); + else + splitLow = []; % use legacy s.baseline(2) + end + + for i = 1:length(curData) + seg = curData{i}; + + if doBaseline + bl = pf2.data.split(seg, s.baseline(1), s.baseline(2)); + + if doResample + ppData{i} = pf2.data.resample(seg, s.resampleRate, ... + 'centerOnTime', s.taskStart, ... + 'timeOutMode', 'start', ... + 'blfNIR', bl, ... + 'averageAux', true, 'flattenAux', true, 'trimAux', false); + ppData{i}.time = ppData{i}.time + s.taskStart; + + barData{i} = pf2.data.resample(seg, barBin, ... + 'centerOnTime', s.taskStart, ... + 'timeOutMode', 'start', ... + 'blfNIR', bl, ... + 'averageAux', true, 'flattenAux', true, 'trimAux', false); + else + if isempty(splitLow) + ppData{i} = pf2.data.split(seg, s.baseline(2), inf, ... + 'blfNIR', bl); + else + ppData{i} = pf2.data.split(seg, splitLow, inf, ... + 'blfNIR', bl); + end + % barData stays strictly post-baseline regardless of view + barData{i} = pf2.data.split(seg, s.baseline(2), inf, ... + 'blfNIR', bl); + end + else + ppData{i} = pf2.data.resample(seg, s.resampleRate, ... + 'centerOnTime', s.taskStart, ... + 'timeOutMode', 'start', ... + 'averageAux', true, 'flattenAux', true, 'trimAux', false); + ppData{i}.time = ppData{i}.time + s.taskStart; + barData{i} = pf2.data.resample(seg, barBin, ... + 'centerOnTime', s.taskStart, ... + 'timeOutMode', 'start', ... + 'averageAux', true, 'flattenAux', true, 'trimAux', false); + end + + % barData is always trimmed to the strict task window — bar values + % and downstream stats (LME, exports) must remain pinned regardless + % of the view setting. + barData{i} = trimToTaskWindow(barData{i}, 0, taskDuration); + + % ppData uses the view bounds. When viewPad is empty this matches + % the legacy trim (and is a no-op when taskEnd is Inf). + if viewActive || isfinite(viewEnd) + ppData{i} = trimToTaskWindow(ppData{i}, viewStart, viewEnd); + end + end +else + ppData = curData; + barData = curData; +end + +end + + +function [viewStart, viewEnd, viewActive] = computeViewBounds(s, refSeg) +% COMPUTEVIEWBOUNDS Compute the [start, end] trim window for ppData. +% +% When s.viewPad is empty, returns the legacy task window +% [taskStart, taskEnd). When s.viewPad is set, widens the window relative +% to the baseline-start / task-end edges. +% +% viewActive is true when viewPad is set (signals callers that the +% non-resample split path should also widen its lower bound). + +doBaseline = s.useBaseline && ~isempty(s.baseline); + +if isempty(s.viewPad) + viewStart = s.taskStart; + if isfinite(s.taskEnd) + viewEnd = s.taskEnd; + else + viewEnd = inf; + end + viewActive = false; + return; +end + +pad = s.viewPad; +if isscalar(pad) + pad = [pad, pad]; +elseif numel(pad) ~= 2 + error('exploreFNIRS:core:Experiment:viewPad', ... + 'settings.viewPad must be empty, scalar, or [pre, post]'); +end + +if doBaseline + lowerEdge = s.baseline(1); +else + lowerEdge = s.taskStart; +end + +if isfinite(s.taskEnd) + upperEdge = s.taskEnd; +else + upperEdge = max(refSeg.time); +end + +viewStart = lowerEdge - pad(1); +viewEnd = upperEdge + pad(2); +viewActive = true; +end + + +function barBin = computeBarBin(s, curData) +% COMPUTEBARBIN Compute bar bin size for grandAvgFNIRS +% +% When barBinSize=0 (single bar), each segment has one time point after +% resampling. grandAvgFNIRS cannot auto-detect sample rate from +% single-point data (median(diff([])) = NaN), so we pass it explicitly. + +barBin = s.barBinSize; +if barBin <= 0 + if isfinite(s.taskEnd) + barBin = s.taskEnd - s.taskStart; + else + barBin = max(curData{1}.time) - s.taskStart; + end + if barBin <= 0 + barBin = max(curData{1}.time); + end +end + +end + + +function ppKey = buildPPKey(s) +% BUILDPPKEY Build a string key from preprocessing settings for cache lookup +% +% The key encodes settings that affect Stage A (preprocessing). Changing +% any of these values produces a different key, invalidating the cache. + +if isempty(s.viewPad) + vpStr = 'none'; +else + vp = s.viewPad; + if isscalar(vp), vp = [vp, vp]; end + vpStr = sprintf('[%.4f,%.4f]', vp(1), vp(2)); +end +ppKey = sprintf('bl=[%.4f,%.4f]_rs=%.4f_bb=%.4f_ts=%.4f_te=%.4f_ub=%d_rm=%s_om=%s_vp=%s', ... + s.baseline(1), s.baseline(2), ... + s.resampleRate, s.barBinSize, ... + s.taskStart, s.taskEnd, s.useBaseline, ... + s.rawMethod, s.oxyMethod, vpStr); + +end + + +function data = trimToTaskWindow(data, tStart, tEnd) +% TRIMTOTASKWINDOW Remove time points outside the task window [tStart, tEnd) + +if ~isfield(data, 'time'), return; end +t = data.time; +keep = t >= tStart & t < tEnd; +if all(keep), return; end + +nT = length(t); +data.time = t(keep); + +% Trim segmentTimes row-wise so [start, mid, end] tuples stay aligned +% with the trimmed time vector. Required for downstream consumers +% (e.g. mergeGbyTablesLong) that index segmentTimes by time row. +if isfield(data, 'segmentTimes') && size(data.segmentTimes, 1) == nT + data.segmentTimes = data.segmentTimes(keep, :); +end + +% Trim biomarker arrays (channel-level) +bioFields = {'HbO','HbR','HbTotal','HbDiff','CBSI','raw','od'}; +for f = 1:length(bioFields) + fn = bioFields{f}; + if isfield(data, fn) && isnumeric(data.(fn)) && size(data.(fn),1) == nT + data.(fn) = data.(fn)(keep, :); + end +end + +% Trim ROI biomarker arrays so they stay row-aligned with data.time. +% Without this, downstream consumers (grandAvgFNIRS line 400, which +% indexes ROI by the trimmed time's row positions) hit out-of-bounds +% or silently align the wrong samples — producing a non-zero group mean +% in the baseline window even though each segment was baseline-corrected. +if isfield(data, 'ROI') && isstruct(data.ROI) + roiFields = fieldnames(data.ROI); + for f = 1:length(roiFields) + rf = roiFields{f}; + if isnumeric(data.ROI.(rf)) && size(data.ROI.(rf), 1) == nT + data.ROI.(rf) = data.ROI.(rf)(keep, :); + end + end +end + +% Trim aux data (tables from flattenAux or structs) +if isfield(data, 'Aux') && isstruct(data.Aux) + auxFields = fieldnames(data.Aux); + for f = 1:length(auxFields) + af = data.Aux.(auxFields{f}); + if istable(af) && height(af) == nT + data.Aux.(auxFields{f}) = af(keep, :); + elseif isstruct(af) && isfield(af, 'data') && size(af.data,1) == nT + data.Aux.(auxFields{f}).data = af.data(keep, :); + if isfield(af, 'time') && length(af.time) == nT + data.Aux.(auxFields{f}).time = af.time(keep); + end + end + end +end + +end + + +function hVars = buildHierarchyVars(curTable, validHierarchy, mode) +% Build the hierarchy argument for grandAvgFNIRS based on averaging mode + + switch lower(mode) + case 'hierarchy' + tableVars = curTable.Properties.VariableNames; + useVars = intersect(validHierarchy, tableVars, 'stable'); + if ~isempty(useVars) + hVars = curTable(:, useVars); + else + hVars = (1:size(curTable, 1))'; + end + + case 'flat' + if ismember('SubjectID', curTable.Properties.VariableNames) + hVars = curTable(:, 'SubjectID'); + else + hVars = (1:size(curTable, 1))'; + end + + case 'none' + hVars = (1:size(curTable, 1))'; + + otherwise + error('exploreFNIRS:core:Experiment:buildHierarchyVars', 'Unknown averaging mode: %s. Use ''hierarchy'', ''flat'', or ''none''.', mode); + end +end + + +function [blocks, fwdArgs] = extractBlocksArg(args) +% EXTRACTBLOCKSARG Extract 'Blocks' parameter from name-value argument list +% +% Returns the blocks struct array and the remaining args without 'Blocks'. + +blocks = []; +fwdArgs = args; + +for k = 1:2:length(args)-1 + if ischar(args{k}) && strcmpi(args{k}, 'Blocks') + blocks = args{k+1}; + fwdArgs = [args(1:k-1), args(k+2:end)]; + return; + end +end + +end + + +function [align, fwdArgs] = extractAlignArg(args) +% EXTRACTALIGNARG Extract 'Align' parameter from name-value argument list +% +% Returns the alignment mode and the remaining args without 'Align'. + +align = 'union'; +fwdArgs = args; + +for k = 1:2:length(args)-1 + if ischar(args{k}) && strcmpi(args{k}, 'Align') + align = args{k+1}; + fwdArgs = [args(1:k-1), args(k+2:end)]; + return; + end +end + +end + + +function result = computeConnectivityGroups(groups, args, align) +% COMPUTECONNECTIVITYGROUPS Core connectivity computation across groups +% +% Computes per-subject connectivity matrices for each group and aggregates. +% Uses alignMatrices to handle subjects with different valid channels. + +if nargin < 3 + align = 'union'; +end + +nGroups = length(groups); +result = struct([]); + +for g = 1:nGroups + curData = groups(g).gbyFNIRS; + nSubjects = length(curData); + + fprintf('Group [%d] %s: computing connectivity for %d subjects...\n', ... + g, groups(g).label, nSubjects); + + subResults = cell(nSubjects, 1); + useParfor = false; + if nSubjects > 2 + [canUse, poolRunning] = pf2_base.accel.canParfor(); + useParfor = canUse && poolRunning; + end + if useParfor + parfor s = 1:nSubjects + subResults{s} = exploreFNIRS.connectivity.computeMatrix(curData{s}, args{:}); + end + else + for s = 1:nSubjects + subResults{s} = exploreFNIRS.connectivity.computeMatrix(curData{s}, args{:}); + end + end + + % Align and aggregate using channel-identity-aware stacking + [allMat, masterCh, masterLabels, nValidMat] = ... + exploreFNIRS.connectivity.alignMatrices(subResults, align); + + result(g).Mean = mean(allMat, 3, 'omitnan'); + result(g).SD = std(allMat, 0, 3, 'omitnan'); + result(g).SEM = result(g).SD ./ sqrt(max(nValidMat, 1)); + result(g).nValid = nValidMat; + result(g).N = nSubjects; + result(g).matrices = cellfun(@(r) r.matrix, subResults, 'UniformOutput', false); + result(g).label = groups(g).label; + result(g).method = subResults{1}.method; + result(g).biomarker = subResults{1}.biomarker; + result(g).channels = masterCh; + result(g).labels = masterLabels; + result(g).useROI = subResults{1}.useROI; + + mask = triu(true(size(result(g).Mean)), 1); + result(g).globalMean = mean(result(g).Mean(mask), 'omitnan'); +end + +end + + +function result = computeHyperscanningCore(selData, pairs, groupArgs, nPerms, pThreshold, align) +% COMPUTEHYPERSCANNINGCORE Core hyperscanning computation +% +% Computes group coupling and optional permutation test. + +if nargin < 6 + align = 'union'; +end + +result = exploreFNIRS.hyperscanning.computeGroup(selData, pairs, ... + 'Align', align, groupArgs{:}); +result.pairs = pairs; + +if nPerms > 0 + fprintf('Running permutation test (%d iterations)...\n', nPerms); + result.permutation = exploreFNIRS.hyperscanning.permutationTest( ... + selData, pairs, ... + 'Permutations', nPerms, ... + 'PThreshold', pThreshold, ... + 'Align', align, ... + groupArgs{:}); +end + +end + + +function result = computeHBICAcore(selData, pairs, hbicaArgs, timeWindow) +% COMPUTEHBICACORE Core HB-ICA computation across dyads + +nDyads = length(pairs); +dyads = cell(nDyads, 1); +dyadIDs = cell(nDyads, 1); + +for d = 1:nDyads + % pairSubjects emits an .indices vector (not indexA/indexB). HB-ICA is a + % pairwise decomposition, so require exactly two members per group. + idx = pairs(d).indices; + if numel(idx) ~= 2 + if isfield(pairs(d), 'dyadID') && ~isempty(pairs(d).dyadID) + gid = char(string(pairs(d).dyadID)); + else + gid = sprintf('group %d', d); + end + error('exploreFNIRS:core:Experiment:hbicaNotDyad', ... + ['HB-ICA operates on dyads, but %s has %d members. Provide ' ... + '2-member pairs (e.g. ManualPairs {{1,2}}); triad/N-way HB-ICA ' ... + 'is not supported.'], gid, numel(idx)); + end + idxA = idx(1); + idxB = idx(2); + dataA = selData{idxA}; + dataB = selData{idxB}; + + args = hbicaArgs; + if ~isempty(timeWindow) + args = [args, 'TimeWindow', timeWindow]; %#ok + end + + dyads{d} = exploreFNIRS.hyperscanning.hbica(dataA, dataB, args{:}); + + if isfield(pairs(d), 'dyadID') + dyadIDs{d} = pairs(d).dyadID; + else + dyadIDs{d} = sprintf('Dyad%d', d); + end +end + +% Summary statistics +meanGOF = zeros(nDyads, 1); +nInterBrain = zeros(nDyads, 1); +for d = 1:nDyads + meanGOF(d) = mean(dyads{d}.GOF); + nInterBrain(d) = sum(dyads{d}.isInterBrain); +end + +result.dyads = dyads; +result.dyadIDs = dyadIDs; +result.pairs = pairs; +result.summary.meanGOF = meanGOF; +result.summary.nInterBrain = nInterBrain; +result.summary.nDyads = nDyads; + +end diff --git a/+exploreFNIRS/+core/Filter.m b/+exploreFNIRS/+core/Filter.m new file mode 100644 index 00000000..fe4f9a00 --- /dev/null +++ b/+exploreFNIRS/+core/Filter.m @@ -0,0 +1,273 @@ +classdef Filter +% FILTER Immutable, combinable data filter for Experiment queries +% +% Value class for building up selection criteria that can be applied to +% an Experiment's dataTable. Filters are immutable — each method returns +% a new Filter with the criterion added. +% +% Syntax: +% f = exploreFNIRS.core.Filter() +% f = f.include('Group', 'Control') +% f = f.include('Condition', {'Task1','Task2'}) +% f = f.exclude('SubjectID', 'S003') +% f = f.ch([1, 5, 10]) +% f = f.bio({'HbO'}) +% f = f.time([5, 20]) +% f = f.mask(logicalVector) +% f3 = f1.and(f2) +% idx = f.apply(dataTable) +% +% Example: +% f = exploreFNIRS.core.Filter(); +% f = f.include('Group', 'Control').include('Condition', {'Task1','Task2'}); +% f = f.ch(1:10).bio({'HbO','HbR'}).time([5, 20]); +% +% % Apply to experiment's dataTable +% idx = f.apply(ex.dataTable); +% +% % Combine filters +% f1 = exploreFNIRS.core.Filter().include('Group', 'Control'); +% f2 = exploreFNIRS.core.Filter().exclude('SubjectID', 'S003'); +% f3 = f1.and(f2); +% +% See also: exploreFNIRS.core.Experiment, exploreFNIRS.core.PlotProxy + + properties (SetAccess = private) + % Cell array of include criteria: each is {varName, values} + includes = {} + + % Cell array of exclude criteria: each is {varName, values} + excludes = {} + + % Channel indices (empty = all) + channels = [] + + % Biomarker names (empty = default) + biomarkers = {} + + % Time window [start, end] in seconds (empty = full range) + timeWindow = [] + + % Arbitrary logical mask (empty = no mask) + logicalMask = [] + end + + methods + + function obj = Filter() + % FILTER Create empty filter + end + + + function obj = include(obj, varName, values) + % INCLUDE Keep only rows where varName matches values + % + % f = f.include('Group', 'Control') + % f = f.include('Condition', {'Task1','Task2'}) + + validateattributes(varName, {'char','string'}, {'scalartext'}); + if ischar(values), values = {values}; end + if isstring(values), values = cellstr(values); end + obj.includes{end+1} = {char(varName), values}; + end + + + function obj = exclude(obj, varName, values) + % EXCLUDE Remove rows where varName matches values + % + % f = f.exclude('SubjectID', 'S003') + % f = f.exclude('Condition', {'Rest'}) + + validateattributes(varName, {'char','string'}, {'scalartext'}); + if ischar(values), values = {values}; end + if isstring(values), values = cellstr(values); end + obj.excludes{end+1} = {char(varName), values}; + end + + + function obj = ch(obj, chIdx) + % CH Select specific channels + % + % f = f.ch([1, 5, 10]) + + validateattributes(chIdx, {'numeric'}, {'vector','positive','integer'}); + obj.channels = chIdx(:)'; + end + + + function obj = bio(obj, bioNames) + % BIO Select specific biomarkers + % + % f = f.bio({'HbO'}) + % f = f.bio({'HbO','HbR'}) + + if ischar(bioNames), bioNames = {bioNames}; end + if isstring(bioNames), bioNames = cellstr(bioNames); end + obj.biomarkers = bioNames; + end + + + function obj = time(obj, tw) + % TIME Set time window [start, end] in seconds + % + % f = f.time([5, 20]) + + validateattributes(tw, {'numeric'}, {'vector','numel',2}); + obj.timeWindow = sort(tw(:)'); + end + + + function obj = mask(obj, logMask) + % MASK Apply arbitrary logical mask + % + % f = f.mask(logicalVector) + + validateattributes(logMask, {'logical'}, {'vector'}); + obj.logicalMask = logMask(:); + end + + + function obj = and(obj, other) + % AND Combine two filters (intersection) + % + % f3 = f1.and(f2) + + if ~isa(other, 'exploreFNIRS.core.Filter') + error('exploreFNIRS:core:Filter:and', ... + 'Argument must be a Filter object'); + end + + % Merge includes + obj.includes = [obj.includes, other.includes]; + + % Merge excludes + obj.excludes = [obj.excludes, other.excludes]; + + % Channels: intersect if both specified + if ~isempty(other.channels) + if isempty(obj.channels) + obj.channels = other.channels; + else + obj.channels = intersect(obj.channels, other.channels); + end + end + + % Biomarkers: intersect if both specified + if ~isempty(other.biomarkers) + if isempty(obj.biomarkers) + obj.biomarkers = other.biomarkers; + else + obj.biomarkers = intersect(obj.biomarkers, other.biomarkers); + end + end + + % Time window: intersect (max start, min end) + if ~isempty(other.timeWindow) + if isempty(obj.timeWindow) + obj.timeWindow = other.timeWindow; + else + obj.timeWindow = [max(obj.timeWindow(1), other.timeWindow(1)), ... + min(obj.timeWindow(2), other.timeWindow(2))]; + end + end + + % Logical mask: AND + if ~isempty(other.logicalMask) + if isempty(obj.logicalMask) + obj.logicalMask = other.logicalMask; + else + n = min(length(obj.logicalMask), length(other.logicalMask)); + obj.logicalMask = obj.logicalMask(1:n) & other.logicalMask(1:n); + end + end + end + + + function idx = apply(obj, dataTable) + % APPLY Return logical index into dataTable matching all criteria + % + % idx = f.apply(dataTable) + + n = height(dataTable); + idx = true(n, 1); + + % Apply includes + for i = 1:length(obj.includes) + varName = obj.includes{i}{1}; + values = obj.includes{i}{2}; + if ~ismember(varName, dataTable.Properties.VariableNames) + warning('exploreFNIRS:core:Filter:apply', ... + 'Variable "%s" not found in dataTable, skipping', varName); + continue; + end + col = dataTable.(varName); + idx = idx & matchColumn(col, values); + end + + % Apply excludes + for i = 1:length(obj.excludes) + varName = obj.excludes{i}{1}; + values = obj.excludes{i}{2}; + if ~ismember(varName, dataTable.Properties.VariableNames) + continue; + end + col = dataTable.(varName); + idx = idx & ~matchColumn(col, values); + end + + % Apply logical mask + if ~isempty(obj.logicalMask) + maskLen = length(obj.logicalMask); + if maskLen >= n + idx = idx & obj.logicalMask(1:n); + else + % Pad with false + padded = false(n, 1); + padded(1:maskLen) = obj.logicalMask; + idx = idx & padded; + end + end + end + + + function tf = hasChannels(obj) + % HASCHANNELS True if channels are specified + tf = ~isempty(obj.channels); + end + + function tf = hasBiomarkers(obj) + % HASBIOMARKERS True if biomarkers are specified + tf = ~isempty(obj.biomarkers); + end + + function tf = hasTimeWindow(obj) + % HASTIMEWINDOW True if time window is specified + tf = ~isempty(obj.timeWindow); + end + + function tf = isEmpty(obj) + % ISEMPTY True if no criteria are set + tf = isempty(obj.includes) && isempty(obj.excludes) && ... + isempty(obj.channels) && isempty(obj.biomarkers) && ... + isempty(obj.timeWindow) && isempty(obj.logicalMask); + end + + end +end + + +function idx = matchColumn(col, values) +% Match column values against a set of target values + if iscell(values) || isstring(values) + values = string(values); + if isstring(col) || iscategorical(col) || iscell(col) + idx = ismember(string(col), values); + else + idx = ismember(col, double(values)); + end + elseif isnumeric(values) + idx = ismember(col, values); + else + idx = true(size(col, 1), 1); + end +end diff --git a/+exploreFNIRS/+core/GLMExperiment.m b/+exploreFNIRS/+core/GLMExperiment.m new file mode 100644 index 00000000..dc994e55 --- /dev/null +++ b/+exploreFNIRS/+core/GLMExperiment.m @@ -0,0 +1,1377 @@ +classdef GLMExperiment < exploreFNIRS.core.Experiment +% GLMEXPERIMENT Scriptable GLM wrapper extending Experiment +% +% Encapsulates the full first-level GLM workflow: processing continuous +% recordings, building design matrices, fitting per-subject GLMs, and +% packaging betas into pseudo-segments for group analysis. All Experiment +% methods (plot, stats, export, connectivity) operate on beta data after +% fit() is called. +% +% Syntax: +% gx = exploreFNIRS.core.GLMExperiment(subjects, blockDefs) +% gx = exploreFNIRS.core.GLMExperiment(subjects, blockDefs, 'Hierarchy', {...}) +% gx = exploreFNIRS.core.GLMExperiment(subjects) % uses subjects{i}.blocks +% +% Inputs: +% subjects - {1 x S} cell array of continuous fNIRS structs +% blockDefs - (Optional) {1 x S} cell array of block struct arrays from +% defineBlocks. If omitted, extracted from subjects{i}.blocks. +% +% Example: +% [subjects, blockDefs] = pf2.import.sampleData.experiment('blocks'); +% gx = exploreFNIRS.core.GLMExperiment(subjects, blockDefs); +% gx.glm.conditions = {'Easy', 'Hard'}; +% gx.fit(); +% +% gx.groupby({'Condition'}); +% gx.aggregate(); +% fig = gx.plotBar('Biomarker', 'HbO', 'ShowIndividual', true); +% +% See also: exploreFNIRS.core.Experiment, pf2.data.blocksToEvents, +% pf2_base.fnirs.buildDesignMatrix, pf2_base.fnirs.fitGLM, +% pf2.data.betasToSegments + + properties + % Source data (immutable after construction) + subjects % {S x 1} continuous fNIRS structs + blockDefs % {S x 1} block struct arrays from defineBlocks + + % GLM model settings (modify before calling fit()) + glm % struct with GLM configuration + + % Per-subject first-level results (populated by fit()) + subjectResults % {1 x S} struct array + end + + properties (SetAccess = private) + isFitted % bool — true after successful fit() + fitHash % char — hash of settings at last fit() for invalidation + end + + methods + + function obj = GLMExperiment(subjects, blockDefs, varargin) + % GLMEXPERIMENT Create a GLMExperiment from continuous recordings + % + % gx = GLMExperiment(subjects, blockDefs) + % gx = GLMExperiment(subjects, blockDefs, 'Hierarchy', {...}) + % gx = GLMExperiment(subjects) % uses subjects{i}.blocks + + % Validate subjects + if ~iscell(subjects) + error('exploreFNIRS:core:GLMExperiment', ... + 'subjects must be a cell array'); + end + + % Handle optional blockDefs: if missing or if second arg is a + % name-value string, extract .blocks from each subject + if nargin < 2 || ischar(blockDefs) || isstring(blockDefs) + if nargin >= 2 + varargin = [{blockDefs}, varargin]; + end + blockDefs = cell(size(subjects)); + for si = 1:numel(subjects) + if ~isfield(subjects{si}, 'blocks') || isempty(subjects{si}.blocks) + error('exploreFNIRS:core:GLMExperiment', ... + 'Subject %d has no .blocks field. Call defineBlocks with ''Embed'', true first.', si); + end + blockDefs{si} = subjects{si}.blocks; + end + end + + if ~iscell(blockDefs) + error('exploreFNIRS:core:GLMExperiment', ... + 'blockDefs must be a cell array'); + end + if length(subjects) ~= length(blockDefs) + error('exploreFNIRS:core:GLMExperiment', ... + 'subjects and blockDefs must have the same length'); + end + + % Pass subjects to Experiment superclass (valid fNIRS structs) + obj@exploreFNIRS.core.Experiment(subjects, varargin{:}); + + % Store source data + obj.subjects = subjects(:); + obj.blockDefs = blockDefs(:); + + % Default GLM settings + obj.glm = struct( ... + 'driftOrder', 3, ... + 'driftType', 'legendre', ... + 'driftCutoff', 128, ... + 'includeDerivative', false, ... + 'includeDispersion', false, ... + 'hrf', [], ... + 'fitMethod', 'OLS', ... + 'biomarkers', {{'HbO', 'HbR'}}, ... + 'auxFields', {{}}, ... + 'auxNuisance', {{}}, ... + 'conditions', {{}}, ... + 'conditionMap', {{}}, ... + 'groupBy', 'Condition', ... + 'units', '\beta' ... + ); + + % Beta-appropriate Experiment defaults + obj.settings.useBaseline = false; + obj.settings.resampleRate = 0; + obj.settings.barBinSize = 0; + + % State + obj.isFitted = false; + obj.fitHash = ''; + end + + + function obj = fit(obj) + % FIT Run first-level GLM pipeline on all subjects + % + % gx.fit() + % + % Pipeline per subject: + % 1. Reprocess if rawMethod/oxyMethod specified + % 2. Convert blocks to GLM events + % 3. Build design matrix (HRF convolution, drift, derivatives) + % 4. Fit GLM per biomarker (and optionally per aux field) + % 5. Package betas into Experiment-compatible pseudo-segments + % 6. Aggregate block-level behavioral data onto segments + % + % After fit(), obj.data contains beta pseudo-segments and all + % inherited Experiment methods operate on beta data. + + nSubjects = length(obj.subjects); + fprintf('=== GLMExperiment.fit(): %d subjects ===\n', nSubjects); + + % --- 1. Reprocess if methods are specified --- + processedSubjects = obj.subjects; + hasMethodSet = ~isempty(obj.settings.rawMethod) || ... + ~isempty(obj.settings.oxyMethod); + if hasMethodSet + % processFNIRS2 takes positional args: data, rawMethod, oxyMethod + % Method names must come before name-value pairs + positionalArgs = {}; + if ~isempty(obj.settings.rawMethod) + positionalArgs{end+1} = obj.settings.rawMethod; + end + if ~isempty(obj.settings.oxyMethod) + positionalArgs{end+1} = obj.settings.oxyMethod; + end + for s = 1:nSubjects + processedSubjects{s} = processFNIRS2( ... + obj.subjects{s}, positionalArgs{:}); + fprintf(' Reprocessed %s\n', ... + processedSubjects{s}.info.SubjectID); + end + end + + % --- 2-5. Per-subject GLM fitting --- + allSegments = {}; + results = cell(1, nSubjects); + + for s = 1:nSubjects + d = processedSubjects{s}; + + % Convert blocks -> events + events = pf2.data.blocksToEvents(obj.blockDefs{s}, ... + 'GroupBy', obj.glm.groupBy); + + % Build design matrix + dmArgs = {d.time, d.fs, events, ... + 'DriftOrder', obj.glm.driftOrder, ... + 'DriftType', obj.glm.driftType, ... + 'DriftCutoff', obj.glm.driftCutoff, ... + 'IncludeDerivative', obj.glm.includeDerivative, ... + 'IncludeDispersion', obj.glm.includeDispersion, ... + 'IncludeConstant', true}; + if ~isempty(obj.glm.hrf) + dmArgs = [dmArgs, {'HRF', obj.glm.hrf}]; %#ok + end + + % Auxiliary nuisance regressors: align each named Aux signal to + % the fNIRS time base and append as confound columns (not + % HRF-convolved). Used to regress out systemic physiology + % (respiration, cardiac) or motion. + if ~isempty(obj.glm.auxNuisance) + [nuis, nuisNames] = collectAuxNuisance(d, obj.glm.auxNuisance); + if ~isempty(nuis) + dmArgs = [dmArgs, {'Nuisance', nuis, ... + 'NuisanceNames', nuisNames}]; %#ok + end + end + + [X, names] = pf2_base.fnirs.buildDesignMatrix(dmArgs{:}); + + % Fit each biomarker + bioResults = struct(); + for b = 1:length(obj.glm.biomarkers) + bio = obj.glm.biomarkers{b}; + if isfield(d, bio) + bioResults.(bio) = pf2_base.fnirs.fitGLM( ... + d.(bio), X, names, ... + 'Method', obj.glm.fitMethod); + end + end + + % Fit auxiliary fields if requested + for a = 1:length(obj.glm.auxFields) + auxName = obj.glm.auxFields{a}; + if ~isfield(d, 'Aux') || ~isfield(d.Aux, auxName) + continue; + end + auxStruct = d.Aux.(auxName); + auxFs = 1 / median(diff(auxStruct.time)); + + % Build design matrix at Aux sampling rate + auxDmArgs = {auxStruct.time, auxFs, events, ... + 'DriftOrder', obj.glm.driftOrder, ... + 'DriftType', obj.glm.driftType, ... + 'DriftCutoff', obj.glm.driftCutoff, ... + 'IncludeDerivative', obj.glm.includeDerivative, ... + 'IncludeDispersion', obj.glm.includeDispersion, ... + 'IncludeConstant', true}; + if ~isempty(obj.glm.hrf) + auxDmArgs = [auxDmArgs, {'HRF', obj.glm.hrf}]; %#ok + end + [Xaux, auxNames] = pf2_base.fnirs.buildDesignMatrix(auxDmArgs{:}); + + bioResults.(['Aux_' auxName]) = pf2_base.fnirs.fitGLM( ... + auxStruct.data, Xaux, auxNames, ... + 'Method', obj.glm.fitMethod); + end + + % Store per-subject results + results{s} = struct( ... + 'results', bioResults, ... + 'designMatrix', X, ... + 'regressorNames', {names}, ... + 'events', events, ... + 'subjectID', d.info.SubjectID); + + % Package betas -> pseudo-segments + primaryBio = obj.glm.biomarkers{1}; + segs = pf2.data.betasToSegments( ... + bioResults.(primaryBio), d, ... + 'BiomarkerResults', bioResults, ... + 'Conditions', obj.glm.conditions, ... + 'ConditionMap', obj.glm.conditionMap, ... + 'Units', obj.glm.units); + + % Build ROI betas if ROI definitions present + for k = 1:length(segs) + if isfield(segs{k}, 'ROI') && isfield(segs{k}.ROI, 'info') + segs{k} = pf2_build_nanmean_ROI(segs{k}); + end + end + + % Attach Aux betas to pseudo-segments + for a = 1:length(obj.glm.auxFields) + auxName = obj.glm.auxFields{a}; + auxKey = ['Aux_' auxName]; + if ~isfield(bioResults, auxKey), continue; end + if ~isfield(d.Aux, auxName), continue; end + + auxResult = bioResults.(auxKey); + auxStruct = d.Aux.(auxName); + + for k = 1:length(segs) + condName = segs{k}.info.Condition; + regIdx = find(strcmp(auxResult.regressorNames, condName), 1); + if isempty(regIdx), continue; end + + betaRow = auxResult.beta(regIdx, :); + if ~isfield(segs{k}, 'Aux') + segs{k}.Aux = struct(); + end + + % Build table format that grandAvgFNIRS expects + % (table with 'time' column + data columns) + auxTable = table([0; 1], 'VariableNames', {'time'}); + if isfield(auxStruct, 'varNames') && ... + ~isempty(auxStruct.varNames) + vNames = auxStruct.varNames; + else + nAuxCh = length(betaRow); + vNames = arrayfun(@(i) sprintf('Ch%d', i), ... + 1:nAuxCh, 'UniformOutput', false); + end + for vc = 1:length(betaRow) + auxTable.(vNames{vc}) = [betaRow(vc); betaRow(vc)]; + end + segs{k}.Aux.(auxName) = auxTable; + end + end + + % --- Aggregate block-level behavioral data --- + for k = 1:length(segs) + segs{k} = aggregateBlockInfo(segs{k}, ... + obj.blockDefs{s}, obj.glm.groupBy); + end + + allSegments = [allSegments, segs]; %#ok + + % Report fit quality + r2 = bioResults.(primaryBio).R2; + fprintf(' %s: mean R2=%.3f (%s), %d regressors\n', ... + d.info.SubjectID, mean(r2), primaryBio, length(names)); + end + + % --- 6. Replace Experiment data with beta segments --- + obj.subjectResults = results; + obj.data = allSegments(:); + obj.dataTable = exploreFNIRS.dataset.buildSegmentInfoTable(obj.data); + obj.dataTable.missingFNIRS = zeros(height(obj.dataTable), 1); + + % Reset selection/grouping state via public reset() + obj.reset(); + + % Mark as fitted + obj.isFitted = true; + obj.fitHash = buildFitHash(obj); + + fprintf('Fit complete: %d segments (%d subjects x %d conditions)\n', ... + length(obj.data), nSubjects, ... + length(obj.data) / max(nSubjects, 1)); + end + + + function obj = aggregate(obj, mode) + % AGGREGATE Auto-fit if needed, then delegate to parent + % + % gx.aggregate() + % gx.aggregate('hierarchy') + % + % If GLM has not been fitted, or if settings have changed since + % the last fit, automatically calls fit() before aggregating. + % Reprocessing methods are temporarily cleared so the parent + % aggregate() does not try to reprocess the beta pseudo-segments. + + needsRefit = ~obj.isFitted || ... + ~strcmp(obj.fitHash, buildFitHash(obj)); + if needsRefit + % Save groupby state — fit() calls reset() which clears it + savedGroupByVars = obj.getGroupByVars(); + obj.fit(); + % Re-apply groupby if it was set before + if ~isempty(savedGroupByVars) + obj.groupby(savedGroupByVars); + end + end + + % Save and clear methods — fit() already reprocessed the raw data; + % parent aggregate() must not try to reprocess beta segments. + savedRaw = obj.settings.rawMethod; + savedOxy = obj.settings.oxyMethod; + savedBaseline = obj.settings.baseline; + savedTaskStart = obj.settings.taskStart; + savedTaskEnd = obj.settings.taskEnd; + savedUseBaseline = obj.settings.useBaseline; + savedResampleRate = obj.settings.resampleRate; + + obj.settings.rawMethod = ''; + obj.settings.oxyMethod = ''; + % Beta pseudo-segments have time=[0,1]. Force single time bin + % so aggregate() doesn't create multiple meaningless time bins. + obj.settings.baseline = [-1, 0]; + obj.settings.taskStart = 0; + obj.settings.taskEnd = 0.5; + obj.settings.useBaseline = false; + obj.settings.resampleRate = 0; + + try + if nargin < 2 + aggregate@exploreFNIRS.core.Experiment(obj); + else + aggregate@exploreFNIRS.core.Experiment(obj, mode); + end + catch ME + % Restore before rethrowing + obj.settings.rawMethod = savedRaw; + obj.settings.oxyMethod = savedOxy; + obj.settings.baseline = savedBaseline; + obj.settings.taskStart = savedTaskStart; + obj.settings.taskEnd = savedTaskEnd; + obj.settings.useBaseline = savedUseBaseline; + obj.settings.resampleRate = savedResampleRate; + rethrow(ME); + end + + obj.settings.rawMethod = savedRaw; + obj.settings.oxyMethod = savedOxy; + obj.settings.baseline = savedBaseline; + obj.settings.taskStart = savedTaskStart; + obj.settings.taskEnd = savedTaskEnd; + obj.settings.useBaseline = savedUseBaseline; + obj.settings.resampleRate = savedResampleRate; + end + + + function results = statsFitLME(obj, varargin) + % STATSFITLME Override to skip auto Time factor for GLM betas + results = statsFitLME@exploreFNIRS.core.Experiment(obj, ... + 'SkipTimeFactor', true, varargin{:}); + end + + function [fig, results] = plotLME(obj, varargin) + % PLOTLME Override to skip auto Time factor for GLM betas + if ~obj.isAggregated + error('exploreFNIRS:core:GLMExperiment:plotLME', ... + 'Call aggregate() before plotLME()'); + end + varargin = obj.injectColorScheme(varargin); + [fig, results] = exploreFNIRS.core.plotLME(obj.groups, ... + obj.groupByVars, 'SkipTimeFactor', true, varargin{:}); + end + + function [fig, results] = plotTopoLME(obj, varargin) + % PLOTTOPOLME Override to skip auto Time factor for GLM betas + if ~obj.isAggregated + error('exploreFNIRS:core:GLMExperiment:plotTopoLME', ... + 'Call aggregate() before plotTopoLME()'); + end + [fig, results] = exploreFNIRS.core.plotTopoLME(obj.groups, ... + obj.groupByVars, 'SkipTimeFactor', true, varargin{:}); + end + + + function r = getSubjectResult(obj, idx) + % GETSUBJECTRESULT Return per-subject GLM result struct + % + % r = gx.getSubjectResult(1) + % + % Returns struct with fields: results, designMatrix, + % regressorNames, events, subjectID + + if ~obj.isFitted + error('exploreFNIRS:core:GLMExperiment:getSubjectResult', ... + 'Call fit() before accessing subject results'); + end + r = obj.subjectResults{idx}; + end + + + function fig = plotDesignMatrix(obj, subjectIdx, varargin) + % PLOTDESIGNMATRIX Visualize a subject's GLM design matrix + % + % fig = gx.plotDesignMatrix(1) + % fig = gx.plotDesignMatrix(1, 'Visible', 'off') + % fig = gx.plotDesignMatrix(1, 'SavePath', 'dm.png') + + if ~obj.isFitted + error('exploreFNIRS:core:GLMExperiment:plotDesignMatrix', ... + 'Call fit() before plotting design matrix'); + end + + p = inputParser; + addRequired(p, 'subjectIdx', @(x) isnumeric(x) && isscalar(x)); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 800, @isnumeric); + addParameter(p, 'SaveHeight', 500, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, subjectIdx, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + r = obj.subjectResults{subjectIdx}; + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + ax = axes('Parent', fig); + + imagesc(ax, r.designMatrix); + colormap(ax, parula); + colorbar(ax); + set(ax, 'XTick', 1:length(r.regressorNames), ... + 'XTickLabel', r.regressorNames, 'XTickLabelRotation', 45, ... + 'TickLabelInterpreter', 'none'); + xlabel(ax, 'Regressors'); + ylabel(ax, 'Time (samples)'); + title(ax, sprintf('Design Matrix: %s', pf2_base.plot.escapeTeX(r.subjectID))); + + sty = pf2_base.plot.PlotStyle.getDefault(); + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); + end + + + function T = betaTable(obj, varargin) + % BETATABLE Export beta weights as a flat table + % + % T = gx.betaTable() + % T = gx.betaTable('Channels', 1:4) + % T = gx.betaTable('IncludeStats', true) + % + % Builds a table with one row per subject x condition x channel, + % containing beta weights and optionally t-stats/p-values. + % + % Name-Value Parameters: + % Channels - Channel indices (default: all) + % IncludeStats - Include tstat/pval columns (default: false) + + if ~obj.isFitted + error('exploreFNIRS:core:GLMExperiment:betaTable', ... + 'Call fit() before exporting beta table'); + end + + ip = inputParser; + addParameter(ip, 'Channels', [], @isnumeric); + addParameter(ip, 'IncludeStats', false, @islogical); + parse(ip, varargin{:}); + channels = ip.Results.Channels; + includeStats = ip.Results.IncludeStats; + + % Determine conditions + conds = obj.glm.conditions; + if isempty(conds) && ~isempty(obj.subjectResults) + % Auto-detect from first subject's regressor names + r1 = obj.subjectResults{1}; + bio1 = obj.glm.biomarkers{1}; + allNames = r1.results.(bio1).regressorNames; + conds = detectStimulusRegressors(allNames); + end + + rows = {}; + for s = 1:length(obj.subjectResults) + sr = obj.subjectResults{s}; + d = obj.subjects{s}; + bio1 = obj.glm.biomarkers{1}; + nCh = size(sr.results.(bio1).beta, 2); + + if isempty(channels) + chList = 1:nCh; + else + chList = channels; + end + + for c = 1:length(conds) + condName = conds{c}; + regIdx = find(strcmp(sr.regressorNames, condName), 1); + if isempty(regIdx), continue; end + + for ch = chList + row = struct(); + row.SubjectID = string(sr.subjectID); + + % Copy info fields + if isfield(d, 'info') + infoFields = fieldnames(d.info); + for f = 1:length(infoFields) + fn = infoFields{f}; + if strcmp(fn, 'SubjectID'), continue; end + val = d.info.(fn); + if isnumeric(val) && isscalar(val) + row.(fn) = val; + elseif ischar(val) || isstring(val) + row.(fn) = string(val); + end + end + end + + row.Condition = string(condName); + row.Channel = ch; + + % Beta per biomarker + for b = 1:length(obj.glm.biomarkers) + bio = obj.glm.biomarkers{b}; + if isfield(sr.results, bio) + row.(['beta_' bio]) = ... + sr.results.(bio).beta(regIdx, ch); + if includeStats + row.(['tstat_' bio]) = ... + sr.results.(bio).tstat(regIdx, ch); + row.(['pval_' bio]) = ... + sr.results.(bio).pval(regIdx, ch); + end + end + end + + rows{end+1} = row; %#ok + end + end + end + + T = struct2table([rows{:}]); + end + + + function result = betaSeriesConnectivity(obj, varargin) + % BETASERIESCONNECTIVITY Trial-by-trial beta-series correlation + % + % result = gx.betaSeriesConnectivity() + % result = gx.betaSeriesConnectivity('Method', 'LSS') + % result = gx.betaSeriesConnectivity('Condition', {'Easy','Hard'}) + % result = gx.betaSeriesConnectivity('Align', 'union') + % + % Computes beta-series correlation connectivity for each subject, + % then aggregates across subjects using Fisher z-transform. Does + % NOT require fit() — works directly on continuous data and blocks. + % + % All name-value parameters from computeBetaSeries are forwarded. + % + % Name-Value Parameters: + % Align - Channel alignment mode for group aggregation: + % 'union' (default) - all channels, NaN where missing + % 'intersection' - only channels in all subjects + % numeric 0-1 - channels in >= threshold fraction of subjects + % + % Outputs: + % result - Struct with fields: + % .Mean - [C x C] mean connectivity (back-transformed from z) + % .SD - [C x C] standard deviation of z-scores + % .SEM - [C x C] standard error of z-scores + % .N - Number of subjects + % .nValid - [C x C] per-cell count of contributing subjects + % .matrices - {N x 1} cell of per-subject matrices + % .method - Method string + % .biomarker - Biomarker used + % .channels - Channel indices + % + % See also: exploreFNIRS.connectivity.computeBetaSeries, + % exploreFNIRS.connectivity.alignMatrices + + % Extract Align before forwarding rest to computeBetaSeries + [align, fwdArgs] = extractAlignArg(varargin); + + nSubjects = length(obj.subjects); + + % Reprocess subjects if methods are set + processedSubjects = reprocessIfNeeded(obj); + + % Build ROI averages on continuous subjects if ROI info exists + % but ROI biomarker data hasn't been computed yet + for s = 1:nSubjects + d = processedSubjects{s}; + if isfield(d, 'ROI') && isfield(d.ROI, 'info') && ... + ~isfield(d.ROI, 'HbO') + processedSubjects{s} = pf2_build_nanmean_ROI(d); + end + end + + % Compute per-subject beta-series connectivity + subResults = cell(nSubjects, 1); + for s = 1:nSubjects + d = processedSubjects{s}; + subResults{s} = exploreFNIRS.connectivity.computeBetaSeries( ... + d, obj.blockDefs{s}, fwdArgs{:}); + fprintf(' %s: %d trials, beta-series computed (%d channels)\n', ... + d.info.SubjectID, subResults{s}.nTrials, length(subResults{s}.channels)); + end + + % Align matrices across subjects (handles different channel sets) + [allValues, masterCh, masterLabels, nValid] = ... + exploreFNIRS.connectivity.alignMatrices(subResults, align); + + % Fisher z-transform and aggregate across subjects (dim 3) + clamped = max(min(allValues, 0.9999), -0.9999); + zStack = atanh(clamped); + nVals = sum(~isnan(allValues), 3); + + zMean = mean(zStack, 3, 'omitnan'); + zSD = std(zStack, 0, 3, 'omitnan'); + zSEM = zSD ./ sqrt(max(nVals, 1)); + + result.Mean = tanh(zMean); + result.SD = zSD; + result.SEM = zSEM; + result.N = nSubjects; + result.nValid = nValid; + result.matrices = cellfun(@(r) r.matrix, subResults, 'UniformOutput', false); + result.method = subResults{1}.method; + result.biomarker = subResults{1}.biomarker; + result.useROI = subResults{1}.useROI; + + if iscell(masterCh) + result.channels = masterCh{1}; + else + result.channels = masterCh; + end + result.labels = masterLabels; + + % Plot-compatible + result.matrix = result.Mean; + result.pmatrix = nan(size(result.Mean)); + end + + + function result = ppi(obj, seedChannels, varargin) + % PPI Psychophysiological interaction analysis across subjects + % + % result = gx.ppi([1 2 3]) + % result = gx.ppi(1, 'Contrast', {'Hard', 'Easy'}) + % result = gx.ppi(1, 'Align', 'union') + % + % Computes PPI for each subject and aggregates betas/p-values + % across subjects. Does NOT require fit(). + % + % All name-value parameters from computePPI are forwarded. + % + % Name-Value Parameters: + % Align - Channel alignment mode for group aggregation: + % 'union' (default) - all channels, NaN where missing + % 'intersection' - only channels in all subjects + % numeric 0-1 - channels in >= threshold fraction of subjects + % + % Outputs: + % result - Struct with fields: + % .Mean_beta - [1 x nTargets] mean PPI beta + % .SD_beta - [1 x nTargets] SD of PPI betas + % .SEM_beta - [1 x nTargets] SEM of PPI betas + % .Mean_tstat - [1 x nTargets] mean PPI t-stat + % .N - Number of subjects + % .nValid - [nTargets x 1] per-channel count of contributing subjects + % .ppi_betas - [N x nTargets] per-subject PPI betas + % .ppi_pvals - [N x nTargets] per-subject PPI p-values + % .matrix - [1 x nTargets] mean PPI beta (plot compat) + % .pmatrix - [1 x nTargets] group p-value (t-test) + % .channels - Target channel indices + % .seedChannels - Seed channels used + % .method - 'PPI' + % .biomarker - Biomarker used + % + % See also: exploreFNIRS.connectivity.computePPI, + % exploreFNIRS.connectivity.alignMatrices + + % Extract Align before forwarding rest to computePPI + [align, fwdArgs] = extractAlignArg(varargin); + + nSubjects = length(obj.subjects); + + % Reprocess subjects if methods are set + processedSubjects = reprocessIfNeeded(obj); + + % Build ROI averages on continuous subjects if ROI info exists + % but ROI biomarker data hasn't been computed yet + for s = 1:nSubjects + d = processedSubjects{s}; + if isfield(d, 'ROI') && isfield(d.ROI, 'info') && ... + ~isfield(d.ROI, 'HbO') + processedSubjects{s} = pf2_build_nanmean_ROI(d); + end + end + + % Compute per-subject PPI + perSubjectBetas = cell(nSubjects, 1); + perSubjectTstats = cell(nSubjects, 1); + perSubjectPvals = cell(nSubjects, 1); + perSubjectChannels = cell(nSubjects, 1); + bioStr = ''; + lastResult = []; + + for s = 1:nSubjects + d = processedSubjects{s}; + r = exploreFNIRS.connectivity.computePPI( ... + d, obj.blockDefs{s}, seedChannels, fwdArgs{:}); + + perSubjectBetas{s} = r.ppi_beta; + perSubjectTstats{s} = r.ppi_tstat; + perSubjectPvals{s} = r.ppi_pval; + perSubjectChannels{s} = r.channels; + if s == 1 + bioStr = r.biomarker; + end + lastResult = r; + fprintf(' %s: PPI computed (%d targets)\n', ... + d.info.SubjectID, length(r.channels)); + end + + % Use alignMatrices to determine master channels and align betas + wrappers = cell(nSubjects, 1); + for s = 1:nSubjects + wrappers{s}.values = perSubjectBetas{s}(:); + wrappers{s}.channelsA = perSubjectChannels{s}(:)'; + wrappers{s}.method = 'PPI'; + wrappers{s}.biomarker = bioStr; + wrappers{s}.pairing = 'same'; + end + + [alignedBetas3D, masterCh, ~, nValid] = ... + exploreFNIRS.connectivity.alignMatrices(wrappers, align); + + % alignedBetas3D is [nTargets x 1 x nSubjects] + if iscell(masterCh) + masterChVec = masterCh{1}; + else + masterChVec = masterCh; + end + nTargets = length(masterChVec); + alignedBetas = reshape(alignedBetas3D, nTargets, nSubjects); + + % Align tstat and pval to same master channels + alignedTstats = nan(nTargets, nSubjects); + alignedPvals = nan(nTargets, nSubjects); + for s = 1:nSubjects + [~, mIdx, sIdx] = intersect(masterChVec, perSubjectChannels{s}(:)'); + alignedTstats(mIdx, s) = perSubjectTstats{s}(sIdx); + alignedPvals(mIdx, s) = perSubjectPvals{s}(sIdx); + end + + % Aggregate across subjects + nVals = sum(~isnan(alignedBetas), 2); + + result.Mean_beta = mean(alignedBetas, 2, 'omitnan')'; + result.SD_beta = std(alignedBetas, 0, 2, 'omitnan')'; + result.SEM_beta = result.SD_beta ./ sqrt(max(nVals', 1)); + result.Mean_tstat = mean(alignedTstats, 2, 'omitnan')'; + result.N = nSubjects; + result.nValid = squeeze(nValid); + result.ppi_betas = alignedBetas'; % [N x nTargets] + result.ppi_pvals = alignedPvals'; % [N x nTargets] + + % Group-level significance: one-sample t-test on betas + if nSubjects > 1 + [~, pGroup] = pf2_base.compat.ttest(alignedBetas'); + else + pGroup = alignedPvals'; + end + + result.matrix = result.Mean_beta; + result.pmatrix = pGroup; + result.channels = masterChVec; + result.seedChannels = seedChannels; + result.method = 'PPI'; + result.biomarker = bioStr; + result.useROI = lastResult.useROI; + + % Rebuild labels for master channel set + if lastResult.useROI && ~isempty(lastResult.labels) + newLabels = arrayfun(@(c) sprintf('Ch%d', c), masterChVec, ... + 'UniformOutput', false); + [~, mIdx, sIdx] = intersect(masterChVec, perSubjectChannels{end}(:)'); + validMask = sIdx <= length(lastResult.labels); + newLabels(mIdx(validMask)) = lastResult.labels(sIdx(validMask)); + result.labels = newLabels; + else + result.labels = arrayfun(@(c) sprintf('Ch%d', c), ... + masterChVec, 'UniformOutput', false); + end + end + + function T = ppiTable(obj, seedChannels, varargin) + % PPITABLE Long-format table of per-subject PPI contrast betas + % + % T = gx.ppiTable(seedChannels) + % T = gx.ppiTable(seedChannels, 'Covariates', {'Group','Age'}) + % T = gx.ppiTable(1, 'Contrast', {'Hard','Easy'}, 'Covariates', {'Group'}) + % + % Runs gx.ppi (per-subject PPI contrast) and reshapes the result into a + % tidy long table with one row per subject x target channel. This is the + % bridge artifact for group-level modeling: feed it to gx.ppiLME, to + % fitlme directly, or export it to CSV/R. Subject-level covariates named + % in 'Covariates' are pulled from each subject's .info and broadcast + % across that subject's channels. + % + % Inputs: + % seedChannels - Seed channel indices (forwarded to computePPI; may be + % [] when 'SeedSignal' is supplied as a forwarded arg) + % + % Name-Value Parameters: + % Covariates - Cell array of .info field names to attach as columns + % (default: {}). All other name-value pairs are forwarded + % to gx.ppi / computePPI (e.g. Contrast, Biomarker, Align, + % SeedData, SeedSignal). + % + % Outputs: + % T - Table with variables: + % SubjectID (categorical), Channel (categorical), PPI (double, the + % contrast beta), plus one column per requested covariate. + % + % Example: + % gx = exploreFNIRS.core.GLMExperiment(subjects, blockDefs); + % T = gx.ppiTable(1, 'Contrast', {'Hard','Easy'}, 'Covariates', {'Group'}); + % lme = fitlme(T, 'PPI ~ Group + Channel + (1|SubjectID)'); + % + % See also: exploreFNIRS.core.GLMExperiment.ppi, + % exploreFNIRS.core.GLMExperiment.ppiLME + + [covars, fwdArgs] = extractCovariatesArg(varargin); + + res = obj.ppi(seedChannels, fwdArgs{:}); + betas = res.ppi_betas; % [N x nCh] + chans = res.channels(:)'; % [1 x nCh] + [N, nCh] = size(betas); + + % Subject IDs and covariate values (subject-level) + subjID = strings(N, 1); + covRaw = cell(1, numel(covars)); + for k = 1:numel(covars) + covRaw{k} = cell(N, 1); + end + for s = 1:N + info = struct(); + if isfield(obj.subjects{s}, 'info') + info = obj.subjects{s}.info; + end + if isfield(info, 'SubjectID') && ~isempty(info.SubjectID) + subjID(s) = string(info.SubjectID); + else + subjID(s) = "S" + s; + end + for k = 1:numel(covars) + if isfield(info, covars{k}) + covRaw{k}{s} = info.(covars{k}); + else + covRaw{k}{s} = NaN; + end + end + end + + % Stack subject x channel (column-major: subject varies fastest, to + % match betas(:) which walks channel-by-channel) + subjAll = repmat(subjID, nCh, 1); + chanAll = reshape(repmat(chans, N, 1), [], 1); + ppiAll = betas(:); + + T = table(categorical(subjAll), categorical(chanAll), ppiAll, ... + 'VariableNames', {'SubjectID', 'Channel', 'PPI'}); + + for k = 1:numel(covars) + T.(covars{k}) = expandCovariate(covRaw{k}, nCh); + end + end + + function results = ppiLME(obj, seedChannels, varargin) + % PPILME Group-level linear mixed-effects model of PPI contrast betas + % + % results = gx.ppiLME(seedChannels) + % results = gx.ppiLME(1, 'Predictors', {'Group'}, 'Contrast', {'Hard','Easy'}) + % results = gx.ppiLME(1, 'Predictors', {'Age'}) + % + % Carries the first-level PPI interaction estimate to a defensible group + % model. Two complementary results are returned: + % (1) A POOLED linear mixed-effects model across all subject x channel + % betas: PPI ~ [+ Channel] + (1|SubjectID). The + % subject random intercept (identifiable because each subject + % contributes one row per channel) accounts for within-subject + % correlation across channels. This answers omnibus questions such + % as "does the PPI differ between groups?" or "does age moderate + % seed->target coupling?". + % (2) A PER-CHANNEL second-level map. For each channel a model is fit + % across subjects (ordinary least squares -- one beta per subject + % per channel, so no random effect is identifiable at the channel + % level). With no predictors this is a one-sample test of the PPI + % against zero; with predictors it is a between-subject regression. + % The per-term p-values and F-statistics are returned as + % [channels x terms] tables matching exploreFNIRS.stats.fitLME, so + % they feed pf2.probe.project.pvalues / .fstats directly. + % + % Inputs: + % seedChannels - Seed channel indices (forwarded to computePPI; may be + % [] when 'SeedSignal' is supplied as a forwarded arg) + % + % Name-Value Parameters: + % Predictors - Cell array of subject-level .info fields used as + % fixed effects (default: {} -> one-sample vs zero) + % IncludeChannel - Add Channel as a fixed factor in the pooled model + % (default: true when more than one channel) + % RandomEffects - Random-effects formula for the pooled model + % (default: '1|SubjectID') + % Verbose - Print a short summary (default: true). All other + % name-value pairs are forwarded to gx.ppi / computePPI. + % + % Outputs: + % results - Struct with fields: + % .model - Pooled LinearMixedModel object + % .anova - ANOVA table of the pooled model + % .formula - Pooled model formula string + % .anova_pval - [channels x terms] table of per-channel p-values + % (UNCORRECTED -- one test per channel; threshold with + % care or use .anova_qval below) + % .anova_qval - [channels x terms] table of Benjamini-Hochberg + % FDR-corrected q-values (per term, across channels). + % Prefer this for thresholded maps / project.pvalues. + % .anova_Fstat - [channels x terms] table of per-channel F-statistics + % .channels - Target channel indices (map columns/rows order) + % .predictors - Predictors used + % .biomarker - Biomarker used + % .table - The long-format table (from ppiTable) + % + % Example: + % results = gx.ppiLME(1, 'Predictors', {'Group'}, 'Contrast', {'Hard','Easy'}); + % disp(results.anova); % omnibus group effect + % qvec = results.anova_qval.Group'; % FDR-corrected per-channel Group q + % pf2.probe.project.pvalues(qvec, gx.subjects{1}, 'savePath', 'ppi_group.png'); + % + % See also: exploreFNIRS.core.GLMExperiment.ppi, + % exploreFNIRS.core.GLMExperiment.ppiTable, exploreFNIRS.stats.fitLME + + ip = inputParser; + ip.KeepUnmatched = true; + addParameter(ip, 'Predictors', {}, @iscell); + addParameter(ip, 'IncludeChannel', [], @(x) isempty(x) || islogical(x)); + addParameter(ip, 'RandomEffects', '1|SubjectID', @ischar); + addParameter(ip, 'Verbose', true, @islogical); + parse(ip, varargin{:}); + predictors = ip.Results.Predictors; + includeChannel = ip.Results.IncludeChannel; + randomEffects = ip.Results.RandomEffects; + verbose = ip.Results.Verbose; + fwdArgs = reconstructNameValue(ip.Unmatched); + + % Long table with predictors attached as covariates + T = obj.ppiTable(seedChannels, 'Covariates', predictors, fwdArgs{:}); + + % Channel axis. Numeric channel indices are sorted ascending and get + % "Ch%d" row names; non-numeric labels (e.g. ROI names) are kept in + % their category order with the label used verbatim as the row name. + chanLabels = string(categories(T.Channel)); % [nCh x 1] string + chanNum = str2double(chanLabels); + if all(~isnan(chanNum)) + [chanNum, ord] = sort(chanNum); + chanLabels = chanLabels(ord); + chans = chanNum(:)'; % numeric indices + rowNames = arrayfun(@(c) sprintf('Ch%d', c), chans, 'uni', 0); + else + chans = cellstr(chanLabels(:)'); % string labels + rowNames = cellstr(chanLabels); + end + nCh = numel(chanLabels); + + if isempty(includeChannel) + includeChannel = nCh > 1; + end + + % --- (1) Pooled mixed-effects model --- + rhsTerms = predictors; + if includeChannel + rhsTerms = [rhsTerms, {'Channel'}]; + end + if isempty(rhsTerms) + rhs = '1'; + else + rhs = strjoin(rhsTerms, ' + '); + end + formula = sprintf('PPI ~ %s + (%s)', rhs, randomEffects); + + cleanupObj = exploreFNIRS.stats.suppressLMEWarnings(); %#ok + model = fitlme(T, formula); + + results.model = model; + results.anova = anova(model); + results.formula = formula; + + % --- (2) Per-channel second-level map --- + [anovaPval, anovaFstat, termNames] = ... + perChannelPPImap(T, predictors, chanLabels); + + % Benjamini-Hochberg FDR across channels, per term. anova_pval stays + % UNCORRECTED (one test per channel); use anova_qval for thresholded + % maps / the bridge to pf2.probe.project.pvalues. + anovaQval = nan(size(anovaPval)); + for tIdx = 1:size(anovaPval, 2) + anovaQval(:, tIdx) = exploreFNIRS.fx.performFDR(anovaPval(:, tIdx)); + end + + results.anova_pval = array2table(anovaPval, ... + 'VariableNames', termNames, 'RowNames', rowNames); + results.anova_qval = array2table(anovaQval, ... + 'VariableNames', termNames, 'RowNames', rowNames); + results.anova_Fstat = array2table(anovaFstat, ... + 'VariableNames', termNames, 'RowNames', rowNames); + results.channels = chans; + results.predictors = predictors; + results.biomarker = obj.glm.biomarkers{1}; + results.table = T; + + if verbose + fprintf('PPI group LME: %s\n', formula); + fprintf(' %d subjects x %d channels; per-channel terms: %s\n', ... + numel(categories(T.SubjectID)), nCh, strjoin(termNames, ', ')); + end + end + + end +end + + +%% Local helper functions (PPI -> LME bridge) + +function [covars, fwd] = extractCovariatesArg(args) +% Pull the 'Covariates' name-value pair out of a varargin list, forwarding +% the rest unchanged. + covars = {}; + fwd = {}; + k = 1; + while k <= numel(args) + if (ischar(args{k}) || isstring(args{k})) && strcmpi(args{k}, 'Covariates') + covars = args{k+1}; + k = k + 2; + else + fwd = [fwd, args(k)]; %#ok + k = k + 1; + end + end + if isempty(covars) + covars = {}; + elseif ischar(covars) || isstring(covars) + covars = cellstr(covars); % accept 'Group' or "Group" as a single covariate + end +end + +function fwd = reconstructNameValue(unmatched) +% Turn an inputParser .Unmatched struct back into a name-value cell array. + names = fieldnames(unmatched); + fwd = cell(1, 2 * numel(names)); + for i = 1:numel(names) + fwd{2*i-1} = names{i}; + fwd{2*i} = unmatched.(names{i}); + end +end + +function col = expandCovariate(rawCells, nCh) +% Broadcast a subject-level covariate (Nx1 cell) across channels and coerce to +% a numeric or string column suitable for a table / model. + N = numel(rawCells); + isNum = all(cellfun(@(v) isnumeric(v) && isscalar(v), rawCells)); + if isNum + base = cell2mat(rawCells(:)); + else + base = strings(N, 1); + for s = 1:N + base(s) = string(rawCells{s}); + end + end + col = repmat(base, nCh, 1); +end + +function [pvalMat, fstatMat, termNames] = perChannelPPImap(T, predictors, chanLabels) +% Fit a per-channel second-level model and return [nCh x nTerms] p/F matrices. +% No predictors -> one-sample test of PPI vs zero. Predictors -> OLS regression +% with one ANOVA term per predictor. chanLabels is a string array of channel +% labels (numeric indices or named/ROI labels) addressing T.Channel. + chanLabels = string(chanLabels); + nCh = numel(chanLabels); + + if isempty(predictors) + termNames = {'Intercept'}; + pvalMat = nan(nCh, 1); + fstatMat = nan(nCh, 1); + for c = 1:nCh + b = T.PPI(string(T.Channel) == chanLabels(c)); + b = b(~isnan(b)); + if numel(b) < 2 + continue; + end + [~, pp, ~, st] = pf2_base.compat.ttest(b); + pvalMat(c) = pp; + fstatMat(c) = st.tstat^2; + end + return; + end + + termNames = predictors(:)'; + nTerms = numel(termNames); + pvalMat = nan(nCh, nTerms); + fstatMat = nan(nCh, nTerms); + rhs = strjoin(predictors, ' + '); + + for c = 1:nCh + Tc = T(string(T.Channel) == chanLabels(c), :); + Tc = Tc(~isnan(Tc.PPI), :); + if height(Tc) <= numel(predictors) + 1 + continue; % not enough subjects to fit + end + try + lm = fitlm(Tc, sprintf('PPI ~ %s', rhs)); + a = anova(lm); % rows: each term + Error + for tIdx = 1:nTerms + rn = a.Properties.RowNames; + hit = find(strcmp(rn, termNames{tIdx}), 1); + if ~isempty(hit) + pvalMat(c, tIdx) = a.pValue(hit); + fstatMat(c, tIdx) = a.F(hit); + end + end + catch + % leave NaN for this channel + end + end +end + + +%% Local helper functions + +function h = buildFitHash(obj) +% BUILDFITHASH Create a string hash of all settings that affect fit results + + key = sprintf( ... + 'raw=%s_oxy=%s_drift=%d_%s_%d_deriv=%d_disp=%d_method=%s_bios=%s_conds=%s_group=%s_aux=%s', ... + obj.settings.rawMethod, obj.settings.oxyMethod, ... + obj.glm.driftOrder, obj.glm.driftType, obj.glm.driftCutoff, ... + obj.glm.includeDerivative, obj.glm.includeDispersion, ... + obj.glm.fitMethod, ... + strjoin(obj.glm.biomarkers, '+'), ... + strjoin(obj.glm.conditions, '+'), ... + obj.glm.groupBy, ... + strjoin(obj.glm.auxFields, '+')); + + if ~isempty(obj.glm.auxNuisance) + key = [key '_auxnuis=' strjoin(obj.glm.auxNuisance, '+')]; + end + + if ~isempty(obj.glm.hrf) + key = [key '_hrf=' mat2str(obj.glm.hrf(:)')]; + end + if ~isempty(obj.glm.conditionMap) + for k = 1:size(obj.glm.conditionMap, 1) + key = [key '_cm=' char(obj.glm.conditionMap{k,1}) ... + '>' char(obj.glm.conditionMap{k,2})]; %#ok + end + end + + h = key; +end + + +function [nuis, names] = collectAuxNuisance(d, auxNuisance) +% COLLECTAUXNUISANCE Align named Aux signals to the fNIRS grid as nuisance cols +% +% Pulls each requested Aux signal onto d.time via pf2.data.auxOnGrid, expanding +% multichannel signals into one column per channel. Columns are mean-centered; +% missing signals are skipped with a warning. Returns the [T x K] matrix and +% matching {1 x K} regressor names (aux__). + + nuis = []; + names = {}; + if ~isfield(d, 'Aux') || isempty(d.Aux) + return; + end + for a = 1:numel(auxNuisance) + nm = auxNuisance{a}; + if ~isfield(d.Aux, nm) + warning('exploreFNIRS:GLMExperiment:auxNuisanceMissing', ... + 'Aux nuisance signal "%s" not found; skipping.', nm); + continue; + end + [vals, info] = pf2.data.auxOnGrid(d, nm); + for c = 1:size(vals, 2) + col = vals(:, c); + col = col - mean(col, 'omitnan'); + col(isnan(col)) = 0; % keep design matrix finite + nuis = [nuis, col]; %#ok + chName = sprintf('ch%d', c); + if numel(info.channels) >= c && ~isempty(info.channels{c}) + chName = info.channels{c}; + end + names{end+1} = sprintf('aux_%s_%s', nm, chName); %#ok + end + end +end + + +function seg = aggregateBlockInfo(seg, blocks, groupField) +% AGGREGATEBLOCKINFO Average numeric block-level info fields onto segment +% +% For a beta segment (one per condition), finds matching blocks and +% averages their numeric .info fields (reactionTime, accuracy, etc.). + + condLabel = seg.info.Condition; + + % Find blocks matching this condition + condBlocks = []; + for b = 1:length(blocks) + blk = blocks(b); + if isfield(blk, 'info') && isfield(blk.info, groupField) + blkCond = blk.info.(groupField); + if isnumeric(blkCond) + blkCond = num2str(blkCond); + else + blkCond = char(blkCond); + end + if strcmp(blkCond, condLabel) + condBlocks = [condBlocks, blk]; %#ok + end + end + end + + if isempty(condBlocks), return; end + + % Average numeric block-level info fields + blockFields = fieldnames(condBlocks(1).info); + for f = 1:length(blockFields) + fname = blockFields{f}; + % Skip grouping field and metadata + if strcmp(fname, groupField) || strcmp(fname, 'BlockNumber') + continue; + end + % Already present from source data — skip SubjectID, Group, etc. + if isfield(seg.info, fname), continue; end + + vals = arrayfun(@(blk) blk.info.(fname), condBlocks, ... + 'UniformOutput', false); + if all(cellfun(@(v) isnumeric(v) && isscalar(v), vals)) + % Average numeric fields across blocks + numVals = cell2mat(vals); + seg.info.(fname) = mean(numVals, 'omitnan'); + elseif all(cellfun(@(v) ischar(v) || isstring(v), vals)) + % For string fields, use the first value if all are identical + charVals = cellfun(@char, vals, 'UniformOutput', false); + if numel(unique(charVals)) == 1 + seg.info.(fname) = charVals{1}; + end + end + end +end + + +function stimRegs = detectStimulusRegressors(regressorNames) +% DETECTSTIMULUSREGRESSORS Identify stimulus regressors by excluding nuisance + + nuisancePatterns = { + '^constant$' + '^drift_' + '^dct_' + '^short_ch' + '_deriv$' + '_disp$' + }; + + isNuisance = false(size(regressorNames)); + for k = 1:length(nuisancePatterns) + isNuisance = isNuisance | ~cellfun(@isempty, ... + regexp(regressorNames, nuisancePatterns{k})); + end + + stimRegs = regressorNames(~isNuisance); +end + + +function processedSubjects = reprocessIfNeeded(obj) +% REPROCESSIFNEEDED Reprocess subjects if rawMethod/oxyMethod are set + + processedSubjects = obj.subjects; + hasMethodSet = ~isempty(obj.settings.rawMethod) || ... + ~isempty(obj.settings.oxyMethod); + if hasMethodSet + positionalArgs = {}; + if ~isempty(obj.settings.rawMethod) + positionalArgs{end+1} = obj.settings.rawMethod; + end + if ~isempty(obj.settings.oxyMethod) + positionalArgs{end+1} = obj.settings.oxyMethod; + end + for s = 1:length(obj.subjects) + processedSubjects{s} = processFNIRS2( ... + obj.subjects{s}, positionalArgs{:}); + end + end +end + + +function [align, fwdArgs] = extractAlignArg(args) +% EXTRACTALIGNARG Extract 'Align' name-value pair from varargin + align = 'union'; + fwdArgs = args; + for k = 1:2:length(args)-1 + if ischar(args{k}) && strcmpi(args{k}, 'Align') + align = args{k+1}; + fwdArgs = [args(1:k-1), args(k+2:end)]; + return; + end + end +end diff --git a/+exploreFNIRS/+core/PlotProxy.m b/+exploreFNIRS/+core/PlotProxy.m new file mode 100644 index 00000000..618337c7 --- /dev/null +++ b/+exploreFNIRS/+core/PlotProxy.m @@ -0,0 +1,696 @@ +classdef PlotProxy +% PLOTPROXY Grammar-of-graphics style plot API for Experiment +% +% Returned by Experiment.plot, provides .bar(), .temporal(), .scatter() +% methods that accept dimension mapping parameters (X, Color, SubplotRows, +% SubplotCols) with support for interaction terms (e.g., 'Condition:Group'). +% +% The proxy holds a reference to the Experiment and orchestrates: +% filter -> groupby -> aggregate -> layout -> render -> restore +% +% Syntax: +% fig = ex.plot.bar('X', 'Condition', 'Color', 'Group', 'Channels', 5) +% fig = ex.plot.temporal('Color', 'Group', 'Channels', 1:5) +% [fig, stats] = ex.plot.scatter('X', 'reactionTime', 'Color', 'Group') +% +% Dimension Mapping: +% X - Variable for X-axis categories (bar/scatter) +% Supports interaction terms: 'Condition:Group' creates +% combined xtick labels like 'TaskA:Control' +% Color - Variable mapped to line/bar color (legend entries) +% Supports interaction terms: 'Condition:Group' +% SubplotRows - Variable for faceting into subplot rows +% SubplotCols - Variable for faceting into subplot columns +% +% Common Parameters: +% Channels - Channel indices (default: all) +% Biomarkers - Cell array of biomarker names (default: {'HbO','HbR'}) +% Biomarker - Single biomarker (for bar/scatter; default: 'HbO') +% Filter - exploreFNIRS.core.Filter object for data selection +% ErrorType - 'SEM' (default), 'SD', or 'none' +% SharedYAxis - true (default) or false +% TimeWindow - [start, end] seconds (bar only) +% ShowIndividual - Show individual data points (bar only) +% FitLine - Show regression line (scatter only) +% InfoVar - X-axis info variable (scatter only) +% Title - Figure title +% Stats - Return stats struct (default: false) +% AvgMode - Override averaging mode: 'hierarchy', 'flat', 'none' +% Baseline - Override baseline window: [start, end] seconds +% UseBaseline - Override baseline correction: true or false +% ResampleRate - Override resample rate: seconds per bin +% TaskStart - Override task onset time: seconds +% Visible - 'on' or 'off' +% SavePath - File path to save +% SaveWidth - Width in pixels +% SaveHeight - Height in pixels +% SaveDPI - Resolution +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% fig = ex.plot.bar('X', 'Condition:Group', 'Channels', 1:5); +% fig = ex.plot.temporal('Color', 'Group', 'SubplotRows', 'Condition', ... +% 'Channels', 5, 'Biomarkers', {'HbO','HbR'}); +% [fig, stats] = ex.plot.scatter('X', 'reactionTime', 'Color', 'Group', ... +% 'Channels', 5, 'FitLine', true); +% +% See also: exploreFNIRS.core.Experiment, exploreFNIRS.core.Filter + + properties (SetAccess = private) + experiment % Handle to parent Experiment + end + + methods + + function obj = PlotProxy(experiment) + % PLOTPROXY Create proxy linked to an Experiment + obj.experiment = experiment; + end + + + function [figs, stats] = bar(obj, varargin) + % BAR Dimension-mapped bar chart + % + % fig = ex.plot.bar('X', 'Condition', 'Color', 'Group') + % fig = ex.plot.bar('X', 'Condition:Group', 'Channels', 5) + % figs = ex.plot.bar('X', 'Condition', 'Figure', 'Group') + + [dimMap, plotOpts, filterObj] = parseDimArgs('bar', varargin{:}); + bio = plotOpts.Biomarker; + + [groups, plotOpts] = obj.orchestrate(dimMap, filterObj, plotOpts); + cleanup = onCleanup(@() obj.restoreExperiment()); %#ok + + % Auto-expand groups by time bins + if ~isempty(groups) && ~isempty(groups(1).gbyGrandBarFlat) && ... + length(groups(1).gbyGrandBarFlat.time) > 1 + groups = exploreFNIRS.core.expandGroupsByTime(groups); + if isempty(dimMap.X) + dimMap.X = 'Time'; + elseif ~contains(dimMap.X, 'Time') + dimMap.X = ['Time:' dimMap.X]; + end + end + + figSplits = splitByFigure(groups, dimMap.Figure); + + figs = gobjects(length(figSplits), 1); + stats = []; + sty = pf2_base.plot.PlotStyle.getDefault(); + + for fIdx = 1:length(figSplits) + fs = figSplits(fIdx); + fig = createPlotFigure(fs.groups, dimMap, plotOpts); + + layout = exploreFNIRS.core.buildLayout( ... + fs.groups, dimMap, plotOpts.Channels, {bio}); + axHandles = gobjects(layout.nRows, layout.nCols); + + for r = 1:layout.nRows + for c = 1:layout.nCols + cellIdx = sub2ind([layout.nCols, layout.nRows], c, r); + cl = layout.cells(cellIdx); + spIdx = (r - 1) * layout.nCols + c; + if layout.nRows * layout.nCols > 1 + ax = subplot(layout.nRows, layout.nCols, spIdx, 'Parent', fig); + else + ax = axes('Parent', fig); + end + axHandles(r, c) = ax; + + renderOpts = struct( ... + 'ErrorType', plotOpts.ErrorType, ... + 'ShowIndividual', plotOpts.ShowIndividual, ... + 'TimeWindow', plotOpts.TimeWindow, ... + 'Colors', plotOpts.Colors); + exploreFNIRS.core.renderBar(ax, fs.groups, cl.groupIdx, ... + dimMap.X, dimMap.Color, bio, plotOpts.Channels, renderOpts); + + titleParts = {}; + if ~isempty(cl.rowLabel), titleParts{end+1} = cl.rowLabel; end + if ~isempty(cl.colLabel), titleParts{end+1} = cl.colLabel; end + if ~isempty(titleParts), title(ax, pf2_base.plot.escapeTeX(strjoin(titleParts, ' | '))); end + ylabel(ax, sprintf('%s (%s)', bio, getUnitsLabel(fs.groups))); + sty.applyToAxes(ax); + end + end + + if plotOpts.SharedYAxis, enforceSharedYAxis(axHandles); end + figTitle = buildFigureTitle(plotOpts.Title, fs.label); + if ~isempty(figTitle), pf2_base.external.suptitle(fig, figTitle); end + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, plotOpts); + figs(fIdx) = fig; + end + + if length(figs) == 1, figs = figs(1); end + end + + + function [figs, stats] = temporal(obj, varargin) + % TEMPORAL Dimension-mapped temporal plot + % + % fig = ex.plot.temporal('Color', 'Group', 'Channels', 5) + % figs = ex.plot.temporal('Color', 'Condition', 'Figure', 'Group') + + [dimMap, plotOpts, filterObj] = parseDimArgs('temporal', varargin{:}); + biomarkers = plotOpts.Biomarkers; + nBioM = length(biomarkers); + + [groups, plotOpts] = obj.orchestrate(dimMap, filterObj, plotOpts); + cleanup = onCleanup(@() obj.restoreExperiment()); %#ok + figSplits = splitByFigure(groups, dimMap.Figure); + + figs = gobjects(length(figSplits), 1); + stats = []; + sty = pf2_base.plot.PlotStyle.getDefault(); + + for fIdx = 1:length(figSplits) + fs = figSplits(fIdx); + fig = createPlotFigure(fs.groups, dimMap, plotOpts); + + layout = exploreFNIRS.core.buildLayout( ... + fs.groups, dimMap, plotOpts.Channels, biomarkers); + totalCols = layout.nCols * nBioM; + totalRows = layout.nRows; + + allHandles = []; + allEntries = {}; + axHandles = gobjects(totalRows, totalCols); + + for r = 1:layout.nRows + for c = 1:layout.nCols + cellIdx = sub2ind([layout.nCols, layout.nRows], c, r); + cl = layout.cells(cellIdx); + + for bIdx = 1:nBioM + col = (c - 1) * nBioM + bIdx; + spIdx = (r - 1) * totalCols + col; + if totalRows * totalCols > 1 + ax = subplot(totalRows, totalCols, spIdx, 'Parent', fig); + else + ax = axes('Parent', fig); + end + axHandles(r, col) = ax; + + renderOpts = struct( ... + 'ErrorType', plotOpts.ErrorType, ... + 'XLim', plotOpts.XLim, ... + 'YLim', plotOpts.YLim, ... + 'Colors', plotOpts.Colors); + [lh, le] = exploreFNIRS.core.renderTemporal( ... + ax, fs.groups, cl.groupIdx, dimMap.Color, ... + biomarkers{bIdx}, plotOpts.Channels, renderOpts); + + titleParts = {biomarkers{bIdx}}; + if ~isempty(cl.rowLabel), titleParts{end+1} = cl.rowLabel; end + if ~isempty(cl.colLabel), titleParts{end+1} = cl.colLabel; end + title(ax, pf2_base.plot.escapeTeX(strjoin(titleParts, ' | '))); + + if r == layout.nRows && c == layout.nCols && bIdx == nBioM + allHandles = lh; + allEntries = le; + end + end + end + end + + if plotOpts.SharedYAxis, enforceSharedYAxis(axHandles); end + if ~isempty(allHandles) + validAx = axHandles(end); + if isvalid(validAx) + legend(validAx, allHandles, allEntries, ... + 'Location', 'best', 'FontSize', sty.LegendFontSize); + end + end + figTitle = buildFigureTitle(plotOpts.Title, fs.label); + if ~isempty(figTitle), pf2_base.external.suptitle(fig, figTitle); end + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, plotOpts); + figs(fIdx) = fig; + end + + if length(figs) == 1, figs = figs(1); end + end + + + function [figs, stats] = scatter(obj, varargin) + % SCATTER Dimension-mapped scatter plot + % + % [fig, stats] = ex.plot.scatter('X', 'reactionTime', ... + % 'Color', 'Group', 'Channels', 5, 'FitLine', true) + % figs = ex.plot.scatter('X', 'reactionTime', 'Figure', 'Group') + + [dimMap, plotOpts, filterObj] = parseDimArgs('scatter', varargin{:}); + infoVar = plotOpts.InfoVar; + if isempty(infoVar) + error('exploreFNIRS:core:PlotProxy:scatter', ... + 'InfoVar (or X) is required for scatter plots.'); + end + bio = plotOpts.Biomarker; + + [groups, plotOpts] = obj.orchestrate(dimMap, filterObj, plotOpts); + cleanup = onCleanup(@() obj.restoreExperiment()); %#ok + + % Auto-expand groups by time bins + if ~isempty(groups) && ~isempty(groups(1).gbyGrandBarFlat) && ... + length(groups(1).gbyGrandBarFlat.time) > 1 + groups = exploreFNIRS.core.expandGroupsByTime(groups); + if isempty(dimMap.Color) + dimMap.Color = 'Time'; + elseif ~contains(dimMap.Color, 'Time') + dimMap.Color = ['Time:' dimMap.Color]; + end + end + + figSplits = splitByFigure(groups, dimMap.Figure); + + figs = gobjects(length(figSplits), 1); + allStats = struct([]); + sty = pf2_base.plot.PlotStyle.getDefault(); + + for fIdx = 1:length(figSplits) + fs = figSplits(fIdx); + fig = createPlotFigure(fs.groups, dimMap, plotOpts); + + layout = exploreFNIRS.core.buildLayout( ... + fs.groups, dimMap, plotOpts.Channels, {bio}); + axHandles = gobjects(layout.nRows, layout.nCols); + + for r = 1:layout.nRows + for c = 1:layout.nCols + cellIdx = sub2ind([layout.nCols, layout.nRows], c, r); + cl = layout.cells(cellIdx); + spIdx = (r - 1) * layout.nCols + c; + if layout.nRows * layout.nCols > 1 + ax = subplot(layout.nRows, layout.nCols, spIdx, 'Parent', fig); + else + ax = axes('Parent', fig); + end + axHandles(r, c) = ax; + + renderOpts = struct( ... + 'FitLine', plotOpts.FitLine, ... + 'CorrType', plotOpts.CorrType, ... + 'Colors', plotOpts.Colors); + [lh, le, cellStats] = exploreFNIRS.core.renderScatter( ... + ax, fs.groups, cl.groupIdx, dimMap.Color, ... + bio, plotOpts.Channels, infoVar, renderOpts); + + titleParts = {}; + if ~isempty(cl.rowLabel), titleParts{end+1} = cl.rowLabel; end + if ~isempty(cl.colLabel), titleParts{end+1} = cl.colLabel; end + if ~isempty(titleParts), title(ax, pf2_base.plot.escapeTeX(strjoin(titleParts, ' | '))); end + if ~isempty(lh), legend(ax, lh, le, 'Location', 'best', 'FontSize', 8); end + + if ~isempty(cellStats) + if isempty(allStats) + allStats = cellStats; + else + allStats = [allStats, cellStats]; %#ok + end + end + end + end + + if plotOpts.SharedYAxis, enforceSharedYAxis(axHandles); end + figTitle = buildFigureTitle(plotOpts.Title, fs.label); + if ~isempty(figTitle), pf2_base.external.suptitle(fig, figTitle); end + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, plotOpts); + figs(fIdx) = fig; + end + + if length(figs) == 1, figs = figs(1); end + stats = allStats; + end + + end + + methods (Access = private) + + function [groups, plotOpts] = orchestrate(obj, dimMap, filterObj, plotOpts) + % ORCHESTRATE Save state, apply filter, groupby, aggregate, set defaults + + ex = obj.experiment; + + % Save state for restore + ex.saveState(); + + % Apply aggregation overrides to settings (restored on cleanup) + if ~isempty(plotOpts.AvgMode) + ex.settings.avgMode = plotOpts.AvgMode; + end + if ~isempty(plotOpts.Baseline) + ex.settings.baseline = plotOpts.Baseline; + end + if ~isempty(plotOpts.UseBaseline) + ex.settings.useBaseline = plotOpts.UseBaseline; + end + if ~isempty(plotOpts.ResampleRate) + ex.settings.resampleRate = plotOpts.ResampleRate; + end + if ~isempty(plotOpts.TaskStart) + ex.settings.taskStart = plotOpts.TaskStart; + end + if ~isempty(plotOpts.TaskEnd) + ex.settings.taskEnd = plotOpts.TaskEnd; + end + if ~isempty(plotOpts.StatWindow) + ex.settings.statWindow = plotOpts.StatWindow; + end + + % Apply filter if provided + if ~isempty(filterObj) && ~filterObj.isEmpty() + idx = filterObj.apply(ex.dataTable); + ex.narrowSelection(idx); + end + + % Derive groupby vars from dimension mapping + gbyVars = deriveGroupByVars(dimMap); + + % Only re-groupby if vars differ from current state + if ~isempty(gbyVars) + ex.groupby(gbyVars); + ex.aggregate(); + elseif ~ex.getIsAggregated() + error('exploreFNIRS:core:PlotProxy', ... + ['No dimension variables specified and Experiment is not ' ... + 'aggregated. Either map variables (X, Color, SubplotRows, ' ... + 'SubplotCols) or call groupby() and aggregate() first.']); + end + + groups = ex.getGroups(); + + % Resolve named ColorScheme to object + if ~isempty(plotOpts.ColorScheme) + csVal = plotOpts.ColorScheme; + if ischar(csVal) || isstring(csVal) + name = char(csVal); + if ~isfield(ex.colorSchemes, name) + error('exploreFNIRS:core:PlotProxy:orchestrate', ... + 'Unknown color scheme: "%s". Available: %s', ... + name, strjoin(fieldnames(ex.colorSchemes), ', ')); + end + csVal = ex.colorSchemes.(name); + end + if isempty(plotOpts.Colors) + plotOpts.Colors = csVal; + end + end + + % Auto-inject colorScheme from Experiment if Colors not set + if isempty(plotOpts.Colors) && ~isempty(ex.colorScheme) + plotOpts.Colors = ex.colorScheme; + end + + % Default channels if empty + if isempty(plotOpts.Channels) && ~isempty(groups) && ... + ~isempty(groups(1).gbyGrand) + nCh = size(groups(1).gbyGrand.HbO.Mean, 2); + plotOpts.Channels = 1:nCh; + end + end + + + function restoreExperiment(obj) + % Restore experiment state from snapshot + obj.experiment.restoreState(); + end + + end +end + + +%% Package-level helpers + +function [dimMap, plotOpts, filterObj] = parseDimArgs(plotType, varargin) +% Parse dimension mapping and common plot options + + p = inputParser; + p.KeepUnmatched = false; + + % Dimension mapping + addParameter(p, 'X', '', @ischar); + addParameter(p, 'Color', '', @ischar); + addParameter(p, 'SubplotRows', '', @ischar); + addParameter(p, 'SubplotCols', '', @ischar); + addParameter(p, 'Figure', '', @ischar); + + % Data dimensions + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'Biomarkers', {'HbO','HbR'}, @iscell); + addParameter(p, 'Biomarker', 'HbO', @ischar); + + % Filter + addParameter(p, 'Filter', [], @(x) isempty(x) || isa(x, 'exploreFNIRS.core.Filter')); + + % Common options + addParameter(p, 'ErrorType', 'SEM', @ischar); + addParameter(p, 'SharedYAxis', true, @islogical); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + + % Colors + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle') || isa(x, 'exploreFNIRS.core.ColorScheme')); + + % Named color scheme (string name or ColorScheme object) + addParameter(p, 'ColorScheme', [], @(x) isempty(x) || ischar(x) || isstring(x) || isa(x, 'exploreFNIRS.core.ColorScheme')); + + % Bar/stats-specific + addParameter(p, 'TimeWindow', [], @isnumeric); + addParameter(p, 'StatWindow', [], @isnumeric); + addParameter(p, 'ShowIndividual', false, @islogical); + + % Temporal-specific + addParameter(p, 'XLim', [], @isnumeric); + addParameter(p, 'YLim', [], @isnumeric); + + % Scatter-specific + addParameter(p, 'InfoVar', '', @ischar); + addParameter(p, 'FitLine', false, @islogical); + addParameter(p, 'CorrType', 'Pearson', @ischar); + addParameter(p, 'Stats', false, @islogical); + + % Aggregation overrides (forwarded to Experiment.settings temporarily) + addParameter(p, 'AvgMode', '', @ischar); % 'hierarchy', 'flat', 'none' + addParameter(p, 'Baseline', [], @isnumeric); % [start, end] seconds + addParameter(p, 'UseBaseline', [], @(x) isempty(x) || islogical(x)); + addParameter(p, 'ResampleRate', [], @isnumeric); % seconds per bin + addParameter(p, 'TaskStart', [], @isnumeric); % task onset time + addParameter(p, 'TaskEnd', [], @isnumeric); % task end time + + parse(p, varargin{:}); + r = p.Results; + + % For scatter, X maps to InfoVar + if strcmp(plotType, 'scatter') && ~isempty(r.X) && isempty(r.InfoVar) + r.InfoVar = r.X; + r.X = ''; % X is not a groupby var for scatter + end + + dimMap = struct( ... + 'X', r.X, ... + 'Color', r.Color, ... + 'SubplotRows', r.SubplotRows, ... + 'SubplotCols', r.SubplotCols, ... + 'Figure', r.Figure); + + % Force headless when saving + vis = r.Visible; + if ~isempty(r.SavePath) + vis = 'off'; + end + + plotOpts = struct( ... + 'Channels', r.Channels, ... + 'Biomarkers', {r.Biomarkers}, ... + 'Biomarker', r.Biomarker, ... + 'ErrorType', r.ErrorType, ... + 'SharedYAxis', r.SharedYAxis, ... + 'Title', r.Title, ... + 'Visible', vis, ... + 'SavePath', r.SavePath, ... + 'SaveWidth', r.SaveWidth, ... + 'SaveHeight', r.SaveHeight, ... + 'SaveDPI', r.SaveDPI, ... + 'TimeWindow', r.TimeWindow, ... + 'StatWindow', r.StatWindow, ... + 'ShowIndividual', r.ShowIndividual, ... + 'XLim', r.XLim, ... + 'YLim', r.YLim, ... + 'InfoVar', r.InfoVar, ... + 'FitLine', r.FitLine, ... + 'CorrType', r.CorrType, ... + 'Stats', r.Stats, ... + 'AvgMode', r.AvgMode, ... + 'Baseline', r.Baseline, ... + 'UseBaseline', r.UseBaseline, ... + 'ResampleRate', r.ResampleRate, ... + 'TaskStart', r.TaskStart, ... + 'TaskEnd', r.TaskEnd, ... + 'Colors', r.Colors, ... + 'ColorScheme', r.ColorScheme, ... + 'TightLayout', r.TightLayout); + + filterObj = r.Filter; + if isempty(filterObj) + filterObj = exploreFNIRS.core.Filter(); + end + + % Apply filter's channel/biomarker/time to plotOpts + if filterObj.hasChannels() && isempty(r.Channels) + plotOpts.Channels = filterObj.channels; + end + if filterObj.hasBiomarkers() + if strcmp(plotType, 'temporal') + plotOpts.Biomarkers = filterObj.biomarkers; + else + plotOpts.Biomarker = filterObj.biomarkers{1}; + end + end + if filterObj.hasTimeWindow() && isempty(r.TimeWindow) + plotOpts.TimeWindow = filterObj.timeWindow; + end +end + + +function gbyVars = deriveGroupByVars(dimMap) +% Derive the set of groupby variables from the dimension mapping + gbyVars = {}; + + dims = {dimMap.X, dimMap.Color, dimMap.SubplotRows, dimMap.SubplotCols, dimMap.Figure}; + + for i = 1:length(dims) + varSpec = dims{i}; + if isempty(varSpec), continue; end + + if contains(varSpec, ':') + % Interaction term: split into individual variables + parts = strsplit(varSpec, ':'); + for j = 1:length(parts) + if ~ismember(parts{j}, gbyVars) + gbyVars{end+1} = parts{j}; %#ok + end + end + else + if ~ismember(varSpec, gbyVars) + gbyVars{end+1} = varSpec; %#ok + end + end + end +end + + +function enforceSharedYAxis(axHandles) +% Set all axes to the same Y limits + allLims = []; + for i = 1:numel(axHandles) + if isvalid(axHandles(i)) && ~isempty(get(axHandles(i), 'Children')) + yl = ylim(axHandles(i)); + allLims = [allLims; yl]; %#ok + end + end + if ~isempty(allLims) + globalYLim = [min(allLims(:, 1)), max(allLims(:, 2))]; + for i = 1:numel(axHandles) + if isvalid(axHandles(i)) + ylim(axHandles(i), globalYLim); + end + end + end +end + + +function lbl = getUnitsLabel(groups) + if ~isempty(groups) && ~isempty(groups(1).gbyGrand) && ... + isfield(groups(1).gbyGrand, 'units') + lbl = groups(1).gbyGrand.units; + else + lbl = '\DeltaHb'; + end +end + + +function figSplits = splitByFigure(groups, figVar) +% Split groups by Figure variable; returns struct array with .groups, .label + if isempty(figVar) + figSplits.groups = groups; + figSplits.label = ''; + return; + end + + nGroups = length(groups); + vals = cell(1, nGroups); + for g = 1:nGroups + T = groups(g).gbyTables; + if contains(figVar, ':') + parts = strsplit(figVar, ':'); + subVals = cell(1, length(parts)); + for p = 1:length(parts) + v = T.(parts{p})(1); + if isnumeric(v) + subVals{p} = num2str(v); + else + subVals{p} = char(string(v)); + end + end + vals{g} = strjoin(subVals, ':'); + else + if ~ismember(figVar, T.Properties.VariableNames) + vals{g} = ''; + continue; + end + v = T.(figVar)(1); + if isnumeric(v) + vals{g} = num2str(v); + else + vals{g} = char(string(v)); + end + end + end + + uniqueVals = unique(vals, 'stable'); + nFigs = length(uniqueVals); + figSplits = struct([]); + for f = 1:nFigs + mask = strcmp(vals, uniqueVals{f}); + figSplits(f).groups = groups(mask); + figSplits(f).label = uniqueVals{f}; + end +end + + +function fig = createPlotFigure(groups, dimMap, plotOpts) +% Create a figure with size scaled by subplot layout + layout = exploreFNIRS.core.buildLayout( ... + groups, dimMap, plotOpts.Channels, {}); + figW = plotOpts.SaveWidth * max(1, layout.nCols); + figH = plotOpts.SaveHeight * max(1, layout.nRows * 0.7); + fig = pf2_base.plot.createFigure( ... + 'Visible', plotOpts.Visible, ... + 'Width', figW, 'Height', figH, ... + 'SavePath', plotOpts.SavePath); +end + + +function t = buildFigureTitle(userTitle, figLabel) +% Build figure title with optional figure-split label + if ~isempty(userTitle) && ~isempty(figLabel) + t = sprintf('%s (%s)', userTitle, figLabel); + elseif ~isempty(userTitle) + t = userTitle; + elseif ~isempty(figLabel) + t = figLabel; + else + t = ''; + end + if ~isempty(t) + t = pf2_base.plot.escapeTeX(t); + end +end diff --git a/+exploreFNIRS/+core/buildLayout.m b/+exploreFNIRS/+core/buildLayout.m new file mode 100644 index 00000000..7387f394 --- /dev/null +++ b/+exploreFNIRS/+core/buildLayout.m @@ -0,0 +1,232 @@ +function layout = buildLayout(groups, dimMap, channels, biomarkers) +% BUILDLAYOUT Compute subplot grid from dimension mapping +% +% Given a groups struct array and a dimension mapping (which variable maps +% to X, Color, SubplotRows, SubplotCols), computes the subplot grid and +% returns a struct array describing each cell. +% +% Syntax: +% layout = buildLayout(groups, dimMap, channels, biomarkers) +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% dimMap - Struct with fields: +% .X - Variable for X-axis (bar/scatter) or '' (temporal) +% .Color - Variable for color/legend or '' +% .SubplotRows - Variable for facet rows or '' +% .SubplotCols - Variable for facet cols or '' +% channels - Numeric vector of channel indices +% biomarkers - Cell array of biomarker names +% +% Outputs: +% layout - Struct with fields: +% .nRows - Number of subplot rows +% .nCols - Number of subplot columns +% .cells - [nRows x nCols] struct array, each with: +% .groupIdx - Indices into groups for this cell +% .channel - Channel index (or [] for all) +% .biomarker - Biomarker name (or '' for default) +% .rowLabel - Row facet label +% .colLabel - Column facet label +% .xValues - Unique X-axis values for this cell +% .colorValues - Unique Color values for this cell +% .rowValues - Cell array of row facet labels +% .colValues - Cell array of column facet labels +% .xVar - X variable name (may be interaction 'A:B') +% .colorVar - Color variable name +% +% See also: exploreFNIRS.core.PlotProxy + + nGroups = length(groups); + + % Extract factor values from group tables + factorCache = struct(); + + % Parse interaction terms (e.g., 'Condition:Group' -> {'Condition','Group'}) + xVar = dimMap.X; + colorVar = dimMap.Color; + rowVar = dimMap.SubplotRows; + colVar = dimMap.SubplotCols; + + % Get unique values for each dimension + rowValues = getFactorValues(groups, rowVar, factorCache); + colValues = getFactorValues(groups, colVar, factorCache); + xValues = getFactorValues(groups, xVar, factorCache); + colorValues = getFactorValues(groups, colorVar, factorCache); + + % Determine subplot grid dimensions + nRowFacets = max(1, length(rowValues)); + nColFacets = max(1, length(colValues)); + + % Build cells + cells = struct([]); + for r = 1:nRowFacets + for c = 1:nColFacets + % Find groups matching this cell's facet values + gIdx = 1:nGroups; + if ~isempty(rowValues) + gIdx = filterByFactor(groups, rowVar, rowValues{r}, gIdx); + end + if ~isempty(colValues) + gIdx = filterByFactor(groups, colVar, colValues{c}, gIdx); + end + + cellIdx = sub2ind([nColFacets, nRowFacets], c, r); + cells(cellIdx).groupIdx = gIdx; + cells(cellIdx).channel = channels; + cells(cellIdx).biomarker = ''; + if ~isempty(rowValues) + cells(cellIdx).rowLabel = rowValues{r}; + else + cells(cellIdx).rowLabel = ''; + end + if ~isempty(colValues) + cells(cellIdx).colLabel = colValues{c}; + else + cells(cellIdx).colLabel = ''; + end + + % X and Color values for this cell's groups + cells(cellIdx).xValues = getFactorValuesForGroups(groups, xVar, gIdx); + cells(cellIdx).colorValues = getFactorValuesForGroups(groups, colorVar, gIdx); + end + end + + layout.nRows = nRowFacets; + layout.nCols = nColFacets; + layout.cells = cells; + layout.rowValues = rowValues; + layout.colValues = colValues; + layout.xVar = xVar; + layout.colorVar = colorVar; + layout.xValues = xValues; + layout.colorValues = colorValues; +end + + +function values = getFactorValues(groups, varSpec, ~) +% Get unique values for a variable (or interaction term) across all groups + if isempty(varSpec) + values = {}; + return; + end + + nGroups = length(groups); + vals = cell(1, nGroups); + + if contains(varSpec, ':') + % Interaction term + parts = strsplit(varSpec, ':'); + for g = 1:nGroups + T = groups(g).gbyTables; + subVals = cell(1, length(parts)); + for p = 1:length(parts) + v = T.(parts{p})(1); + if isnumeric(v) + subVals{p} = num2str(v); + else + subVals{p} = char(string(v)); + end + end + vals{g} = strjoin(subVals, ':'); + end + else + for g = 1:nGroups + T = groups(g).gbyTables; + if ~ismember(varSpec, T.Properties.VariableNames) + vals{g} = ''; + continue; + end + v = T.(varSpec)(1); + if isnumeric(v) + vals{g} = num2str(v); + else + vals{g} = char(string(v)); + end + end + end + + values = unique(vals, 'stable'); +end + + +function values = getFactorValuesForGroups(groups, varSpec, gIdx) +% Get unique factor values for a subset of groups + if isempty(varSpec) || isempty(gIdx) + values = {}; + return; + end + + vals = cell(1, length(gIdx)); + for i = 1:length(gIdx) + g = gIdx(i); + T = groups(g).gbyTables; + + if contains(varSpec, ':') + parts = strsplit(varSpec, ':'); + subVals = cell(1, length(parts)); + for p = 1:length(parts) + v = T.(parts{p})(1); + if isnumeric(v) + subVals{p} = num2str(v); + else + subVals{p} = char(string(v)); + end + end + vals{i} = strjoin(subVals, ':'); + else + if ~ismember(varSpec, T.Properties.VariableNames) + vals{i} = ''; + continue; + end + v = T.(varSpec)(1); + if isnumeric(v) + vals{i} = num2str(v); + else + vals{i} = char(string(v)); + end + end + end + + values = unique(vals, 'stable'); +end + + +function gIdx = filterByFactor(groups, varSpec, targetValue, candidates) +% Filter group indices to those matching a specific factor value + gIdx = []; + for i = 1:length(candidates) + g = candidates(i); + T = groups(g).gbyTables; + + if contains(varSpec, ':') + parts = strsplit(varSpec, ':'); + subVals = cell(1, length(parts)); + for p = 1:length(parts) + v = T.(parts{p})(1); + if isnumeric(v) + subVals{p} = num2str(v); + else + subVals{p} = char(string(v)); + end + end + val = strjoin(subVals, ':'); + else + if ~ismember(varSpec, T.Properties.VariableNames) + continue; + end + v = T.(varSpec)(1); + if isnumeric(v) + val = num2str(v); + else + val = char(string(v)); + end + end + + if strcmp(val, targetValue) + gIdx(end+1) = g; %#ok + end + end +end + + diff --git a/+exploreFNIRS/+core/expandGroupsByTime.m b/+exploreFNIRS/+core/expandGroupsByTime.m new file mode 100644 index 00000000..022c658e --- /dev/null +++ b/+exploreFNIRS/+core/expandGroupsByTime.m @@ -0,0 +1,186 @@ +function expandedGroups = expandGroupsByTime(groups) +% EXPANDGROUPSBYTIME Expand groups by time bins for bar/scatter plotting +% +% When gbyGrandBarFlat has multiple time bins, splits each group into +% N sub-groups (one per time bin). Labels are appended with the bin +% start time (e.g., "Older" becomes "Older [0s]", "Older [10s]"). +% +% Creates matching single-timepoint gbyGrand so plotBar/plotAuxBar +% can read data correctly. Returns groups unchanged if only 1 time bin. +% +% Syntax: +% expanded = exploreFNIRS.core.expandGroupsByTime(groups) +% +% Input: +% groups - Struct array from Experiment.groups (after aggregate) +% +% Output: +% expandedGroups - Expanded struct array with one time bin per group + + % Check if expansion needed + if isempty(groups) || isempty(groups(1).gbyGrandBarFlat) || ... + ~isfield(groups(1).gbyGrandBarFlat, 'time') + expandedGroups = groups; + return; + end + + barTimes = groups(1).gbyGrandBarFlat.time; + nTimes = length(barTimes); + + if nTimes <= 1 + expandedGroups = groups; + return; + end + + nGroups = length(groups); + biomarkers = {'HbO', 'HbR', 'HbTotal', 'HbDiff', 'CBSI'}; + + % Compute bin size from bar times + binSize = barTimes(2) - barTimes(1); + + % Build expanded groups: group-major ordering + % (all time bins within each group, matching GUI layout) + expandedGroups = repmat(groups(1), 1, nTimes * nGroups); + idx = 0; + + for g = 1:nGroups + ga = groups(g).gbyGrand; + barFlat = groups(g).gbyGrandBarFlat; + origTime = ga.time; + + for t = 1:nTimes + idx = idx + 1; + expandedGroups(idx) = groups(g); + expandedGroups(idx).label = sprintf('%s [%gs]', ... + groups(g).label, barTimes(t)); + + % Time mask for slicing gbyGrand temporal data + binStart = barTimes(t); + binEnd = barTimes(t) + binSize; + tMask = origTime >= binStart & origTime < binEnd; + if ~any(tMask) + [~, ci] = min(abs(origTime - barTimes(t))); + tMask = false(size(origTime)); + tMask(ci) = true; + end + + % --- Slice gbyGrandBarFlat to single time bin --- + newBarFlat = barFlat; + newBarFlat.time = barFlat.time(t); + + for b = 1:length(biomarkers) + bio = biomarkers{b}; + if isfield(barFlat, bio) && ~isempty(barFlat.(bio)) && ... + isfield(barFlat.(bio), 'data') + newBarFlat.(bio).data = barFlat.(bio).data(t, :, :); + end + end + + % Slice ROI in barFlat if present + if isfield(barFlat, 'ROI') && isstruct(barFlat.ROI) + rfs = fieldnames(barFlat.ROI); + for r = 1:length(rfs) + rf = rfs{r}; + if strcmp(rf, 'info'), continue; end + if isstruct(barFlat.ROI.(rf)) && ... + isfield(barFlat.ROI.(rf), 'data') + newBarFlat.ROI.(rf).data = ... + barFlat.ROI.(rf).data(t, :, :); + end + end + end + + % Slice Aux in barFlat if present + if isfield(barFlat, 'Aux') && isstruct(barFlat.Aux) + afs = fieldnames(barFlat.Aux); + for a = 1:length(afs) + if isstruct(barFlat.Aux.(afs{a})) && ... + isfield(barFlat.Aux.(afs{a}), 'data') + newBarFlat.Aux.(afs{a}).data = ... + barFlat.Aux.(afs{a}).data(t, :, :); + end + end + end + + expandedGroups(idx).gbyGrandBarFlat = newBarFlat; + + % --- Create matching single-timepoint gbyGrand --- + newGrand = ga; + newGrand.time = barFlat.time(t); + + % Biomarkers: compute stats from barFlat slice + for b = 1:length(biomarkers) + bio = biomarkers{b}; + if isfield(barFlat, bio) && ~isempty(barFlat.(bio)) && ... + isfield(barFlat.(bio), 'data') + sd = barFlat.(bio).data(t, :, :); + newGrand.(bio).Mean = mean(sd, 3, 'omitnan'); + nV = sum(~isnan(sd), 3); + newGrand.(bio).SEM = std(sd, 0, 3, 'omitnan') ./ ... + sqrt(max(nV, 1)); + newGrand.(bio).N = nV; + newGrand.(bio).data = sd; + end + end + + % Aux: slice from gbyGrand temporal data + if isfield(ga, 'Aux') && isstruct(ga.Aux) + afs = fieldnames(ga.Aux); + for a = 1:length(afs) + af = afs{a}; + if ~isstruct(ga.Aux.(af)) || ~isfield(ga.Aux.(af), 'Mean') + continue; + end + newGrand.Aux.(af) = sliceTemporalStruct( ... + ga.Aux.(af), tMask); + end + end + + % ROI: slice from gbyGrand temporal data + if isfield(ga, 'ROI') && isstruct(ga.ROI) + rfs = fieldnames(ga.ROI); + for r = 1:length(rfs) + rf = rfs{r}; + if strcmp(rf, 'info'), continue; end + if ~isstruct(ga.ROI.(rf)) || ~isfield(ga.ROI.(rf), 'Mean') + continue; + end + newGrand.ROI.(rf) = sliceTemporalStruct( ... + ga.ROI.(rf), tMask); + end + end + + expandedGroups(idx).gbyGrand = newGrand; + + % Add Time column to gbyTables + T = groups(g).gbyTables; + T.Time = repmat(barTimes(t), height(T), 1); + expandedGroups(idx).gbyTables = T; + end + end +end + + +function sliced = sliceTemporalStruct(src, tMask) +% Slice a temporal data struct (Mean/SEM/N/data) to a single time bin + sliced = src; + if isfield(src, 'data') && ~isempty(src.data) + sliced.data = mean(src.data(tMask, :, :), 1, 'omitnan'); + sliced.Mean = mean(sliced.data, 3, 'omitnan'); + nV = sum(~isnan(sliced.data), 3); + sliced.SEM = std(sliced.data, 0, 3, 'omitnan') ./ sqrt(max(nV, 1)); + sliced.N = nV; + else + sliced.Mean = mean(src.Mean(tMask, :), 1, 'omitnan'); + if isfield(src, 'SEM') && isfield(src, 'N') + % Pool SEM across time points: SEM_pooled = sqrt(mean(SEM^2)) + % (root-mean-square, not arithmetic mean of SEMs) + sliced.SEM = sqrt(mean(src.SEM(tMask, :).^2, 1, 'omitnan')); + elseif isfield(src, 'SEM') + sliced.SEM = sqrt(mean(src.SEM(tMask, :).^2, 1, 'omitnan')); + end + if isfield(src, 'N') + sliced.N = round(mean(src.N(tMask, :), 1, 'omitnan')); + end + end +end diff --git a/+exploreFNIRS/+core/getGroupColors.m b/+exploreFNIRS/+core/getGroupColors.m new file mode 100644 index 00000000..6e3e4bd8 --- /dev/null +++ b/+exploreFNIRS/+core/getGroupColors.m @@ -0,0 +1,83 @@ +function colors = getGroupColors(n, colorSpec) +% GETGROUPCOLORS Return distinguishable colors for group plotting +% +% Returns an [n x 3] matrix of RGB colors for use in grouped plots. +% Uses a perceptually distinct 8-color palette by default for n <= 8, +% and falls back to MATLAB's lines() colormap for larger n. +% +% Optionally accepts a color specification to override the default palette: +% an [N x 3] RGB matrix, a colormap name string (resolved via +% exploreFNIRS.helper.getColormap), or a function handle @(N) -> [N x 3]. +% +% Syntax: +% colors = exploreFNIRS.core.getGroupColors(n) +% colors = exploreFNIRS.core.getGroupColors(n, [1 0 0; 0 0 1]) +% colors = exploreFNIRS.core.getGroupColors(n, 'Set1') +% colors = exploreFNIRS.core.getGroupColors(n, @parula) +% +% Inputs: +% n - Number of groups (positive integer) +% colorSpec - (optional) Color specification: +% [] or omitted — use default palette +% [M x 3] matrix — use directly (rows cycled if M < n) +% char/string — colormap name (e.g. 'Set1', 'tab10') +% function_handle — @(N) returning [N x 3] +% +% Outputs: +% colors - [n x 3] matrix of RGB values in [0,1] +% +% Example: +% colors = exploreFNIRS.core.getGroupColors(4); +% % colors is [4 x 3] with blue, red-orange, green, purple +% +% colors = exploreFNIRS.core.getGroupColors(4, 'Dark2'); +% % colors is [4 x 3] from the Brewer Dark2 palette +% +% See also: exploreFNIRS.core.plotBar, exploreFNIRS.core.plotTemporal, +% exploreFNIRS.helper.getColormap + + if nargin >= 2 && ~isempty(colorSpec) + colors = resolveColorSpec(colorSpec, n); + return; + end + + baseColors = [ + 0.0000, 0.4470, 0.7410; % blue + 0.8500, 0.3250, 0.0980; % red-orange + 0.4660, 0.6740, 0.1880; % green + 0.4940, 0.1840, 0.5560; % purple + 0.9290, 0.6940, 0.1250; % yellow + 0.3010, 0.7450, 0.9330; % cyan + 0.6350, 0.0780, 0.1840; % dark red + 0.0000, 0.0000, 0.0000; % black + ]; + if n <= size(baseColors, 1) + colors = baseColors(1:n, :); + else + colors = lines(n); + end +end + + +function colors = resolveColorSpec(colorSpec, n) +% Resolve a color specification to an [n x 3] RGB matrix + if isnumeric(colorSpec) && size(colorSpec, 2) == 3 + % Direct RGB matrix — cycle if fewer rows than n + m = size(colorSpec, 1); + if m >= n + colors = colorSpec(1:n, :); + else + idx = mod((0:n-1)', m) + 1; + colors = colorSpec(idx, :); + end + elseif ischar(colorSpec) || isstring(colorSpec) + % Colormap name — resolve via getColormap + cmapFn = exploreFNIRS.helper.getColormap(char(colorSpec)); + colors = cmapFn(n); + elseif isa(colorSpec, 'function_handle') + colors = colorSpec(n); + else + error('exploreFNIRS:core:getGroupColors', ... + 'colorSpec must be an [N x 3] RGB matrix, colormap name, or function handle'); + end +end diff --git a/+exploreFNIRS/+core/plotAux.m b/+exploreFNIRS/+core/plotAux.m new file mode 100644 index 00000000..8cae090e --- /dev/null +++ b/+exploreFNIRS/+core/plotAux.m @@ -0,0 +1,382 @@ +function fig = plotAux(groups, auxField, varargin) +% PLOTAUX Headless temporal plot for auxiliary signal channels +% +% Creates time-series plots for multichannel auxiliary data (e.g., +% accelerometer, heart rate, respiration) from grouped/aggregated +% experiment data, with shaded error bands per group. +% +% Syntax: +% fig = plotAux(groups, 'accelerometer') +% fig = plotAux(groups, 'heartRate', 'AuxChannels', 1) +% fig = plotAux(groups, 'accelerometer', 'Layout', 'grid', ...) +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% Each element must have .gbyGrand.Aux.(auxField) +% auxField - Name of the Aux field to plot (e.g., 'accelerometer') +% +% Name-Value Parameters: +% AuxChannels - Vector of Aux channel indices to plot (default: all) +% ErrorType - 'SEM' (default), 'SD', or 'none' +% Layout - 'grid' (default) or 'overlay' +% 'grid': one subplot per Aux channel +% 'overlay': all channels on one axes +% YLim - [min max] y-axis limits (default: auto) +% XLim - [min max] x-axis limits (default: auto) +% Title - Figure title (default: auto-generated) +% Visible - 'on' (default) or 'off' for headless mode +% SavePath - File path to save figure (triggers headless) +% SaveWidth - Width in pixels (default: 800) +% SaveHeight - Height in pixels (default: 500) +% SaveDPI - Resolution (default: 150) +% Colors - Group color palette override (default: [] = auto) +% +% Outputs: +% fig - Figure handle +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group','Condition'}); +% ex.aggregate(); +% +% % Plot all accelerometer channels in grid +% fig = exploreFNIRS.core.plotAux(ex.groups, 'accelerometer'); +% +% % Save single heart rate channel +% exploreFNIRS.core.plotAux(ex.groups, 'heartRate', ... +% 'AuxChannels', 1, 'SavePath', 'hr.png'); +% +% See also: exploreFNIRS.core.plotTemporal, exploreFNIRS.core.Experiment + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addRequired(p, 'auxField', @ischar); + addParameter(p, 'AuxChannels', [], @isnumeric); + addParameter(p, 'ErrorType', 'SEM', @ischar); + addParameter(p, 'Layout', 'grid', @ischar); + addParameter(p, 'YLim', [], @isnumeric); + addParameter(p, 'XLim', [], @isnumeric); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 800, @isnumeric); + addParameter(p, 'SaveHeight', 500, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle') || isa(x, 'exploreFNIRS.core.ColorScheme')); + parse(p, groups, auxField, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + nGroups = length(groups); + + % Resolve Aux field name (handle flattened naming: 'accel' -> 'accel_data') + auxField = resolveAuxField(groups(1).gbyGrand, auxField); + + % Validate Aux field exists in all groups + for g = 1:nGroups + ga = groups(g).gbyGrand; + if isempty(ga) + error('exploreFNIRS:core:plotAux', ... + 'Group %d has no grand average. Call aggregate() first.', g); + end + if ~isfield(ga, 'Aux') || ~isfield(ga.Aux, auxField) + error('exploreFNIRS:core:plotAux', ... + 'Aux field "%s" not found in group %d. Available: %s', ... + auxField, g, getAuxFieldList(ga)); + end + end + + % Determine number of Aux channels from first group + refAux = groups(1).gbyGrand.Aux.(auxField); + nTotalCh = size(refAux.Mean, 2); + + if isempty(opts.AuxChannels) + auxCh = 1:nTotalCh; + else + auxCh = opts.AuxChannels(opts.AuxChannels <= nTotalCh); + end + nCh = length(auxCh); + + if nCh == 0 + error('exploreFNIRS:core:plotAux', 'No valid Aux channels to plot'); + end + + % Get channel labels + if isfield(refAux, 'varNames') && ~isempty(refAux.varNames) + allLabels = refAux.varNames; + chLabels = cell(1, nCh); + for c = 1:nCh + if auxCh(c) <= length(allLabels) + chLabels{c} = allLabels{auxCh(c)}; + else + chLabels{c} = sprintf('ch%d', auxCh(c)); + end + end + else + chLabels = arrayfun(@(x) sprintf('ch%d', x), auxCh, 'UniformOutput', false); + end + + % Determine layout + if strcmpi(opts.Layout, 'grid') && nCh > 1 + nRows = ceil(sqrt(nCh)); + nCols = ceil(nCh / nRows); + figW = opts.SaveWidth; + figH = opts.SaveHeight * min(nRows, 3) / max(nRows, 1) * 1.5; + else + nRows = 1; + nCols = 1; + figW = opts.SaveWidth; + figH = opts.SaveHeight; + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', figW, 'Height', figH, ... + 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + groupColors = opts.Colors.resolve(groups); + else + groupColors = exploreFNIRS.core.getGroupColors(nGroups, opts.Colors); + end + + if strcmpi(opts.Layout, 'grid') && nCh > 1 + % Grid: one subplot per Aux channel + for cIdx = 1:nCh + ch = auxCh(cIdx); + ax = subplot(nRows, nCols, cIdx, 'Parent', fig); + hold(ax, 'on'); + + for g = 1:nGroups + auxData = groups(g).gbyGrand.Aux.(auxField); + if ch > size(auxData.Mean, 2), continue; end + + timeVec = groups(g).gbyGrand.time; + mLine = auxData.Mean(:, ch); + clr = groupColors(g, :); + + % Error band + eLine = getErrorData(auxData, ch, opts.ErrorType); + if ~strcmpi(opts.ErrorType, 'none') && any(eLine > 0) + plotErrorBand(ax, timeVec, mLine, eLine, clr); + end + + plot(ax, timeVec, mLine, '-', 'Color', clr, 'LineWidth', 1.2); + end + + plot(ax, xlim(ax), [0 0], 'k-', 'LineWidth', 0.5, 'HandleVisibility', 'off'); + title(ax, pf2_base.plot.escapeTeX(chLabels{cIdx})); + xlabel(ax, 'Time (s)'); + if cIdx == 1 || mod(cIdx-1, nCols) == 0 + ylabel(ax, getAuxUnit(refAux)); + end + if ~isempty(opts.YLim), ylim(ax, opts.YLim); end + if ~isempty(opts.XLim), xlim(ax, opts.XLim); end + grid(ax, 'on'); + box(ax, 'on'); + end + + % Shared legend + addSharedLegend(fig, groups, groupColors); + else + % Overlay: all channels on one axes + ax = axes('Parent', fig); + hold(ax, 'on'); + + legendHandles = []; + legendEntries = {}; + lineStyles = {'-', '--', ':', '-.'}; + + for g = 1:nGroups + auxData = groups(g).gbyGrand.Aux.(auxField); + timeVec = groups(g).gbyGrand.time; + clr = groupColors(g, :); + + for cIdx = 1:nCh + ch = auxCh(cIdx); + if ch > size(auxData.Mean, 2), continue; end + + mLine = auxData.Mean(:, ch); + style = lineStyles{mod(cIdx-1, length(lineStyles)) + 1}; + + % Error band (only for single channel to avoid clutter) + if nCh == 1 + eLine = getErrorData(auxData, ch, opts.ErrorType); + if ~strcmpi(opts.ErrorType, 'none') && any(eLine > 0) + plotErrorBand(ax, timeVec, mLine, eLine, clr); + end + end + + h = plot(ax, timeVec, mLine, style, 'Color', clr, 'LineWidth', 1.2); + legendHandles(end+1) = h; %#ok + + if nCh > 1 + legendEntries{end+1} = pf2_base.plot.escapeTeX(sprintf('%s: %s', groups(g).label, chLabels{cIdx})); %#ok + else + legendEntries{end+1} = pf2_base.plot.escapeTeX(groups(g).label); %#ok + end + end + end + + plot(ax, xlim(ax), [0 0], 'k-', 'LineWidth', 0.5, 'HandleVisibility', 'off'); + xlabel(ax, 'Time (s)'); + ylabel(ax, getAuxUnit(refAux)); + if ~isempty(opts.YLim), ylim(ax, opts.YLim); end + if ~isempty(opts.XLim), xlim(ax, opts.XLim); end + + if ~isempty(legendHandles) + legend(ax, legendHandles, legendEntries, 'Location', 'best', 'FontSize', 8); + end + grid(ax, 'on'); + box(ax, 'on'); + end + + % Figure title + if ~isempty(opts.Title) + pf2_base.external.suptitle(fig, opts.Title); + else + pf2_base.external.suptitle(fig, pf2_base.plot.escapeTeX(auxField)); + end + + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); +end + + +%% Local helpers + +function eLine = getErrorData(auxData, ch, errorType) + switch upper(errorType) + case 'SEM' + if isfield(auxData, 'SEM') + eLine = auxData.SEM(:, ch); + else + eLine = zeros(size(auxData.Mean(:, ch))); + end + case 'SD' + if isfield(auxData, 'SD') + eLine = auxData.SD(:, ch); + else + eLine = zeros(size(auxData.Mean(:, ch))); + end + case 'NONE' + eLine = zeros(size(auxData.Mean(:, ch))); + otherwise + eLine = zeros(size(auxData.Mean(:, ch))); + end +end + + +function plotErrorBand(ax, timeVec, mLine, eLine, clr) + upperBound = mLine + eLine; + lowerBound = mLine - eLine; + validIdx = ~isnan(mLine) & ~isnan(upperBound); + if any(validIdx) + tV = timeVec(validIdx); + fill(ax, [tV; flipud(tV)], ... + [upperBound(validIdx); flipud(lowerBound(validIdx))], ... + clr, 'FaceAlpha', 0.2, 'EdgeColor', 'none', ... + 'HandleVisibility', 'off'); + end +end + + + +function lbl = getAuxUnit(auxStruct) + if isfield(auxStruct, 'unit') + lbl = auxStruct.unit; + else + lbl = 'a.u.'; + end +end + + +function str = getAuxFieldList(ga) +% List available Aux fields, showing clean names for flattened fields + if isfield(ga, 'Aux') && isstruct(ga.Aux) + flds = getCleanAuxFields(ga); + if ~isempty(flds) + str = strjoin(flds, ', '); + else + str = '(none)'; + end + else + str = '(no Aux data)'; + end +end + + +function resolved = resolveAuxField(ga, name) +% Resolve user-facing Aux field name to actual field in grand average +% Handles flattened naming: 'accelerometer' -> 'accelerometer_data' + if ~isfield(ga, 'Aux') + resolved = name; + return; + end + + % Exact match + if isfield(ga.Aux, name) + resolved = name; + return; + end + + % Try _data suffix (from flattenAux) + dataName = [name, '_data']; + if isfield(ga.Aux, dataName) + resolved = dataName; + return; + end + + % No match - return original (will produce helpful error later) + resolved = name; +end + + +function cleanNames = getCleanAuxFields(ga) +% Get deduplicated, clean Aux field names (strip _data/_time/_unit suffixes) + if ~isfield(ga, 'Aux') || ~isstruct(ga.Aux) + cleanNames = {}; + return; + end + + flds = fieldnames(ga.Aux); + flds = flds(~ismember(flds, {'flattened'})); + + % Collect unique base names + baseNames = {}; + for i = 1:length(flds) + f = flds{i}; + % Strip known suffixes from flattening + base = regexprep(f, '_(data|time|unit)$', ''); + if ~ismember(base, baseNames) + % Only include if the _data version (or exact) has .Mean (i.e., was averaged) + if isfield(ga.Aux, f) && isstruct(ga.Aux.(f)) && isfield(ga.Aux.(f), 'Mean') + baseNames{end+1} = base; %#ok + elseif isfield(ga.Aux, [base '_data']) && isstruct(ga.Aux.([base '_data'])) && isfield(ga.Aux.([base '_data']), 'Mean') + baseNames{end+1} = base; %#ok + end + end + end + cleanNames = unique(baseNames, 'stable'); +end + + +function addSharedLegend(fig, groups, groupColors) + nGroups = length(groups); + axLeg = axes('Parent', fig, 'Visible', 'off', 'Position', [0 0 0.01 0.01]); + hold(axLeg, 'on'); + + handles = gobjects(nGroups, 1); + entries = cell(nGroups, 1); + for g = 1:nGroups + handles(g) = plot(axLeg, NaN, NaN, '-', 'Color', groupColors(g,:), 'LineWidth', 1.2); + entries{g} = pf2_base.plot.escapeTeX(groups(g).label); + end + + legend(handles, entries, 'Location', 'southoutside', ... + 'Orientation', 'horizontal', 'FontSize', 8); +end diff --git a/+exploreFNIRS/+core/plotAuxBar.m b/+exploreFNIRS/+core/plotAuxBar.m new file mode 100644 index 00000000..de97dc13 --- /dev/null +++ b/+exploreFNIRS/+core/plotAuxBar.m @@ -0,0 +1,508 @@ +function fig = plotAuxBar(groups, auxField, varargin) +% PLOTAUXBAR Headless bar chart for auxiliary signal data +% +% Creates bar charts showing mean auxiliary variable values per group for +% each aux channel, with error bars. Each aux channel gets its own subplot. +% Groups are shown as separate bars within each subplot. +% +% Syntax: +% fig = plotAuxBar(groups, 'heartRate') +% fig = plotAuxBar(groups, 'accelerometer', 'AuxChannels', 1:2) +% fig = plotAuxBar(groups, 'heartRate', 'TimeWindow', [5, 20]) +% fig = plotAuxBar(groups, 'heartRate', 'SavePath', 'hr_bar.png') +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% Each element must have .gbyGrand.Aux.(auxField) +% auxField - Name of the Aux field to plot (e.g., 'heartRate') +% +% Name-Value Parameters: +% AuxChannels - Vector of Aux channel indices (default: all) +% TimeWindow - [start, end] in seconds to average over (default: full) +% ErrorType - 'SEM' (default), 'SD', or 'none' +% ShowIndividual - Show individual subject means (default: false) +% ShowN - Show subject count (n=X) above bars (default: true) +% PlotBy - Groupby variable for clustered bars (default: '') +% GroupByVars - Injected by Experiment wrapper (default: {}) +% Legend - 'last' (default), 'first', 'all', or 'none' +% YLim - [min max] y-axis limits (default: auto) +% XLim - [min max] x-axis limits (default: auto) +% Title - Figure title (default: auto from auxField) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 600) +% SaveHeight - Height in pixels (default: 400) +% SaveDPI - Resolution (default: 150) +% Colors - Group color palette override (default: [] = auto) +% +% Outputs: +% fig - Figure handle +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group','Condition'}); +% ex.aggregate(); +% +% % Bar chart for heart rate, averaged over 5-20s +% fig = exploreFNIRS.core.plotAuxBar(ex.groups, 'heartRate', ... +% 'TimeWindow', [5, 20], 'ShowIndividual', true); +% +% See also: exploreFNIRS.core.plotAux, exploreFNIRS.core.plotBar, +% exploreFNIRS.core.Experiment + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addRequired(p, 'auxField', @ischar); + addParameter(p, 'AuxChannels', [], @isnumeric); + addParameter(p, 'TimeWindow', [], @isnumeric); + addParameter(p, 'ErrorType', 'SEM', @ischar); + addParameter(p, 'ShowIndividual', false, @islogical); + addParameter(p, 'ShowN', true, @islogical); + addParameter(p, 'PlotBy', '', @ischar); + addParameter(p, 'GroupByVars', {}, @iscell); + addParameter(p, 'Legend', 'last', @ischar); + addParameter(p, 'YLim', [], @isnumeric); + addParameter(p, 'XLim', [], @isnumeric); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle') || isa(x, 'exploreFNIRS.core.ColorScheme')); + parse(p, groups, auxField, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + nGroups = length(groups); + + % Resolve Aux field name (handle flattened naming) + auxField = resolveAuxField(groups(1).gbyGrand, auxField); + + % Validate Aux field exists in all groups + for g = 1:nGroups + ga = groups(g).gbyGrand; + if isempty(ga) + error('exploreFNIRS:core:plotAuxBar', ... + 'Group %d has no grand average. Call aggregate() first.', g); + end + if ~isfield(ga, 'Aux') || ~isfield(ga.Aux, auxField) + error('exploreFNIRS:core:plotAuxBar', ... + 'Aux field "%s" not found in group %d. Available: %s', ... + auxField, g, getAuxFieldList(ga)); + end + end + + % Auto-expand groups by time bins when multiple bars exist + if ~isempty(groups(1).gbyGrandBarFlat) && ... + length(groups(1).gbyGrandBarFlat.time) > 1 + groups = exploreFNIRS.core.expandGroupsByTime(groups); + nGroups = length(groups); + end + + % Determine number of Aux channels from first group + refAux = groups(1).gbyGrand.Aux.(auxField); + nTotalCh = size(refAux.Mean, 2); + + if isempty(opts.AuxChannels) + auxCh = 1:nTotalCh; + else + auxCh = opts.AuxChannels(opts.AuxChannels <= nTotalCh); + end + nCh = length(auxCh); + + if nCh == 0 + error('exploreFNIRS:core:plotAuxBar', 'No valid Aux channels to plot'); + end + + % Get channel labels + if isfield(refAux, 'varNames') && ~isempty(refAux.varNames) + allLabels = refAux.varNames; + chLabels = cell(1, nCh); + for c = 1:nCh + if auxCh(c) <= length(allLabels) + chLabels{c} = allLabels{auxCh(c)}; + else + chLabels{c} = sprintf('ch%d', auxCh(c)); + end + end + else + chLabels = arrayfun(@(x) sprintf('ch%d', x), auxCh, 'UniformOutput', false); + end + + % PlotBy setup + hasPB = ~isempty(opts.PlotBy); + if hasPB + [plotByValues, ~, withinLabels, plotByIdx] = ... + exploreFNIRS.core.splitGroupsByFactor(groups, opts.PlotBy); + end + + % Layout + nCols = ceil(sqrt(nCh)); + nRows = ceil(nCh / nCols); + + figW = opts.SaveWidth * min(nCols, 5); + figH = opts.SaveHeight * max(nRows, 1); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', figW, 'Height', figH, 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + + allAxes = gobjects(nCh, 1); + + for chI = 1:nCh + ax = subplot(nRows, nCols, chI, 'Parent', fig); + hold(ax, 'on'); + allAxes(chI) = ax; + + ch = auxCh(chI); + + % Compute per-group means/errors for this aux channel + [groupMeans, groupErrors, groupN, groupLabels, individualData] = ... + computeAuxChannelStats(groups, auxField, ch, opts); + + if hasPB + % --- Clustered bar chart (barweb) --- + nSeries = length(plotByValues); + uniqueWithin = unique(withinLabels, 'stable'); + nX = length(uniqueWithin); + + meanMatrix = nan(nX, nSeries); + errorMatrix = nan(nX, nSeries); + indivData = cell(nX, nSeries); + + for g = 1:nGroups + si = plotByIdx(g); + xi = find(strcmp(uniqueWithin, withinLabels{g}), 1); + meanMatrix(xi, si) = groupMeans(g); + errorMatrix(xi, si) = groupErrors(g); + indivData{xi, si} = individualData{g}; + end + + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + seriesColors = opts.Colors.resolve(groups); + seriesColors = seriesColors(1:nSeries, :); + else + seriesColors = exploreFNIRS.core.getGroupColors(nSeries, opts.Colors); + end + + if strcmpi(opts.ErrorType, 'none') + errInput = []; + else + errInput = errorMatrix; + end + + barwebArgs = {'Axes', ax, ... + 'ColorMap', seriesColors, ... + 'YLabel', getAuxUnit(refAux)}; + + if showLegend(opts.Legend, chI, nCh) && ~strcmpi(opts.Legend, 'none') + barwebArgs = [barwebArgs, {'Legend', pf2_base.plot.escapeTeX(plotByValues)}]; + end + + if opts.ShowIndividual + barwebArgs = [barwebArgs, {'DataPoints', indivData}]; + end + + bwHandles = pf2_base.external.barweb(meanMatrix, errInput, 0.8, pf2_base.plot.escapeTeX(uniqueWithin), ... + barwebArgs{:}, 'ErrorColor', sty.ForegroundColor); + hold(ax, 'on'); + + % Style legend if barweb created one + if ~isempty(bwHandles.legend) && isvalid(bwHandles.legend) + bwHandles.legend.TextColor = sty.LegendTextColor; + bwHandles.legend.Color = sty.LegendBgColor; + bwHandles.legend.EdgeColor = sty.LegendEdgeColor; + bwHandles.legend.Box = 'on'; + end + + % X-axis label: within factor name(s) - bottom row only + isBottomRow = ceil(chI / nCols) == nRows; + if isBottomRow && ~isempty(opts.GroupByVars) + withinVars = setdiff(opts.GroupByVars, {opts.PlotBy}, 'stable'); + if ~isempty(withinVars) + xlabel(ax, pf2_base.plot.escapeTeX(strjoin(withinVars, ' x '))); + end + end + + else + % --- Standard flat bar chart (via barweb) --- + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + colors = opts.Colors.resolve(groups); + else + colors = exploreFNIRS.core.getGroupColors(nGroups, opts.Colors); + end + + meanMatrix = groupMeans(:); + if strcmpi(opts.ErrorType, 'none') + errInput = []; + else + errInput = groupErrors(:); + end + + indivData = cell(nGroups, 1); + for g = 1:nGroups + indivData{g, 1} = individualData{g}; + end + + barwebArgs = {'Axes', ax, ... + 'ColorMap', colors(1:nGroups,:), ... + 'YLabel', getAuxUnit(refAux)}; + + if opts.ShowIndividual + barwebArgs = [barwebArgs, {'DataPoints', indivData}]; + end + + bwHandles = pf2_base.external.barweb(meanMatrix, errInput, 0.8, pf2_base.plot.escapeTeX(groupLabels), ... + barwebArgs{:}, 'ErrorColor', sty.ForegroundColor); + hold(ax, 'on'); + + % Color each bar individually + if ~isempty(bwHandles.bars) + bwHandles.bars(1).FaceColor = 'flat'; + bwHandles.bars(1).CData = colors(1:nGroups, :); + end + + % Add x-axis margin + xlim(ax, [0.25, nGroups + 0.75]); + + % Replace tick labels with xlabel on bottom row + isBottomRow = ceil(chI / nCols) == nRows; + if ~isempty(opts.GroupByVars) + set(ax, 'XTickLabel', {}); + if isBottomRow + xlabel(ax, pf2_base.plot.escapeTeX(strjoin(opts.GroupByVars, ' x '))); + end + end + + % Legend with colored patches + if showLegend(opts.Legend, chI, nCh) + lh = gobjects(nGroups, 1); + for g = 1:nGroups + lh(g) = patch(ax, NaN, NaN, colors(g,:), ... + 'EdgeColor', sty.ForegroundColor, 'LineWidth', 2); + end + lg = legend(ax, lh, pf2_base.plot.escapeTeX(groupLabels), 'Location', 'best'); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + end + end + + % N-labels above bars + if opts.ShowN && ~all(isnan(groupN)) + yl = ylim(ax); + yRange = yl(2) - yl(1); + for g = 1:nGroups + if ~isnan(groupN(g)) + if hasPB + si = plotByIdx(g); + xi = find(strcmp(uniqueWithin, withinLabels{g}), 1); + % Read actual bar x-position from barweb handles + xPos = bwHandles.bars(si).XData(xi) + ... + bwHandles.bars(si).XOffset; + else + xPos = g; + end + yPos = groupMeans(g) + groupErrors(g) + yRange * 0.02; + text(ax, xPos, yPos, sprintf('n=%d', groupN(g)), ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'bottom', ... + 'FontSize', 7, 'Color', sty.DimColor, ... + 'HandleVisibility', 'off'); + end + end + end + + % Zero line + plot(ax, xlim(ax), [0 0], '-', 'Color', sty.ZeroLineColor, ... + 'LineWidth', 0.5, 'HandleVisibility', 'off'); + + title(ax, pf2_base.plot.escapeTeX(chLabels{chI})); + box(ax, 'on'); + grid(ax, 'on'); + end + + % Shared axes + linkaxes(allAxes, 'y'); + if ~isempty(opts.YLim), arrayfun(@(a) ylim(a, opts.YLim), allAxes); end + if ~isempty(opts.XLim), arrayfun(@(a) xlim(a, opts.XLim), allAxes); end + + % Figure title + if ~isempty(opts.Title) + pf2_base.external.suptitle(fig, opts.Title); + else + tStr = pf2_base.plot.escapeTeX(auxField); + if ~isempty(opts.TimeWindow) + tStr = sprintf('%s (%g-%gs)', tStr, opts.TimeWindow(1), opts.TimeWindow(2)); + end + pf2_base.external.suptitle(fig, tStr); + end + + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); +end + + +%% Local helpers + +function [groupMeans, groupErrors, groupN, groupLabels, individualData] = ... + computeAuxChannelStats(groups, auxField, ch, opts) +% Compute per-group mean, error, N for a single aux channel + nGroups = length(groups); + groupMeans = nan(1, nGroups); + groupErrors = nan(1, nGroups); + groupN = nan(1, nGroups); + groupLabels = cell(1, nGroups); + individualData = cell(1, nGroups); + + % Pre-fill labels so escapeTeX/legend code never sees an empty + % numeric [] slot when a group is skipped for lack of data. + for g = 1:nGroups + lbl = groups(g).label; + if isempty(lbl) || ~(ischar(lbl) || isstring(lbl)) + lbl = sprintf('Group%d', g); + end + groupLabels{g} = char(lbl); + end + + for g = 1:nGroups + ga = groups(g).gbyGrand; + if ~isfield(ga, 'Aux') || ~isfield(ga.Aux, auxField) + continue; + end + src = ga.Aux.(auxField); + + if ch > size(src.Mean, 2), continue; end + + timeVec = ga.time; + + % Time window selection + if ~isempty(opts.TimeWindow) + tMask = timeVec >= opts.TimeWindow(1) & timeVec <= opts.TimeWindow(2); + else + tMask = true(size(timeVec)); + end + if ~any(tMask), continue; end + + % Mean: average over time window for this single channel + groupMeans(g) = mean(src.Mean(tMask, ch), 'omitnan'); + + % Error: use per-subject data when available + if isfield(src, 'data') && ~isempty(src.data) + % data is [T x C x N] + subjectData = src.data(tMask, ch, :); + perSubject = squeeze(mean(subjectData, 1, 'omitnan')); + perSubject = perSubject(:); + perSubject(isnan(perSubject)) = []; + + groupN(g) = length(perSubject); + individualData{g} = perSubject; + + switch upper(opts.ErrorType) + case 'SEM' + groupErrors(g) = std(perSubject, 'omitnan') / sqrt(groupN(g)); + case 'SD' + groupErrors(g) = std(perSubject, 'omitnan'); + case 'NONE' + groupErrors(g) = 0; + end + else + groupErrors(g) = mean(src.SEM(tMask, ch), 'omitnan'); + groupN(g) = round(mean(src.N(tMask, ch), 'omitnan')); + end + + groupLabels{g} = groups(g).label; + end +end + + +function lbl = getAuxUnit(auxStruct) + if isfield(auxStruct, 'unit') + lbl = auxStruct.unit; + else + lbl = 'a.u.'; + end +end + + +function tf = showLegend(mode, idx, total) +% Determine whether to show legend on this subplot + switch lower(mode) + case 'last', tf = (idx == total); + case 'first', tf = (idx == 1); + case 'all', tf = true; + case 'none', tf = false; + otherwise, tf = (idx == total); + end +end + + +function resolved = resolveAuxField(ga, name) +% Resolve user-facing Aux field name to actual field in grand average + if ~isfield(ga, 'Aux') + resolved = name; + return; + end + + % Exact match + if isfield(ga.Aux, name) + resolved = name; + return; + end + + % Try _data suffix (from flattenAux) + dataName = [name, '_data']; + if isfield(ga.Aux, dataName) + resolved = dataName; + return; + end + + % No match - return original (will produce helpful error later) + resolved = name; +end + + +function str = getAuxFieldList(ga) +% List available Aux fields + if isfield(ga, 'Aux') && isstruct(ga.Aux) + flds = getCleanAuxFields(ga); + if ~isempty(flds) + str = strjoin(flds, ', '); + else + str = '(none)'; + end + else + str = '(no Aux data)'; + end +end + + +function cleanNames = getCleanAuxFields(ga) +% Get deduplicated, clean Aux field names + if ~isfield(ga, 'Aux') || ~isstruct(ga.Aux) + cleanNames = {}; + return; + end + + flds = fieldnames(ga.Aux); + flds = flds(~ismember(flds, {'flattened'})); + + baseNames = {}; + for i = 1:length(flds) + f = flds{i}; + base = regexprep(f, '_(data|time|unit)$', ''); + if ~ismember(base, baseNames) + if isfield(ga.Aux, f) && isstruct(ga.Aux.(f)) && isfield(ga.Aux.(f), 'Mean') + baseNames{end+1} = base; %#ok + elseif isfield(ga.Aux, [base '_data']) && isstruct(ga.Aux.([base '_data'])) && isfield(ga.Aux.([base '_data']), 'Mean') + baseNames{end+1} = base; %#ok + end + end + end + cleanNames = unique(baseNames, 'stable'); +end diff --git a/+exploreFNIRS/+core/plotAuxScatter.m b/+exploreFNIRS/+core/plotAuxScatter.m new file mode 100644 index 00000000..e8e0fa7b --- /dev/null +++ b/+exploreFNIRS/+core/plotAuxScatter.m @@ -0,0 +1,522 @@ +function [fig, stats] = plotAuxScatter(groups, auxField, varargin) +% PLOTAUXSCATTER Scatter plot correlating info variable vs auxiliary data +% +% Creates scatter plots showing the relationship between an info/behavioral +% variable (X-axis) and auxiliary signal channel data (Y-axis). Each aux +% channel gets its own subplot. Supports Pearson/Spearman correlation, +% regression lines, and error bands. +% +% Syntax: +% [fig, stats] = plotAuxScatter(groups, 'heartRate', 'InfoVar', 'Age') +% [fig, stats] = plotAuxScatter(groups, 'accelerometer', ... +% 'InfoVar', 'reactionTime', 'AuxChannels', 1:2) +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% Each element must have .gbyGrandBarFlat.Aux.(auxField) +% auxField - Name of the Aux field to plot (e.g., 'heartRate') +% +% Name-Value Parameters: +% InfoVar - (required) X-axis variable name from info fields +% AuxChannels - Vector of Aux channel indices (default: all) +% Averaging - 'hierarchy' (default), 'flat', or 'none' +% 'hierarchy' averages within SubjectID first +% 'flat'/'none' uses raw block-level data +% CorrType - 'Pearson' (default) or 'Spearman' +% FitLine - Show regression line (default: true) +% ErrorBand - Show error band (default: false) +% ErrorBandType - '95%PI' (default), 'SEM', 'SD', '95%CI' +% ErrorBandStyle - 'Shaded' (default), 'Dashed', 'Fine' +% FlipXY - Swap X and Y axes (default: false) +% PlotBy - Groupby variable to split subplots by +% Legend - 'last' (default), 'first', 'all', or 'none' +% YLim - [min max] y-axis limits (default: auto) +% XLim - [min max] x-axis limits (default: auto) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 600) +% SaveHeight - Height in pixels (default: 400) +% SaveDPI - Resolution (default: 150) +% Colors - Group color palette override (default: [] = auto) +% +% Outputs: +% fig - Figure handle +% stats - Struct with correlation statistics per group: +% .r, .p - Pearson correlation and p-value +% .rho, .pval - Spearman correlation and p-value +% .N - Sample size +% .coefficients - [slope, intercept] from polyfit +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group','Condition'}); +% ex.aggregate(); +% +% [fig, stats] = ex.plotAuxScatter('heartRate', 'Age', ... +% 'FitLine', true, 'CorrType', 'Spearman'); +% +% See also: exploreFNIRS.core.plotScatter, exploreFNIRS.core.plotAuxBar + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addRequired(p, 'auxField', @ischar); + addParameter(p, 'InfoVar', '', @ischar); + addParameter(p, 'AuxChannels', [], @isnumeric); + addParameter(p, 'Averaging', 'hierarchy', @(x) ismember(lower(x), {'hierarchy','flat','none'})); + addParameter(p, 'CorrType', 'Pearson', @ischar); + addParameter(p, 'FitLine', true, @islogical); + addParameter(p, 'ErrorBand', false, @islogical); + addParameter(p, 'ErrorBandType', '95%PI', @ischar); + addParameter(p, 'ErrorBandStyle', 'Shaded', @ischar); + addParameter(p, 'FlipXY', false, @islogical); + addParameter(p, 'PlotBy', '', @ischar); + addParameter(p, 'Legend', 'last', @ischar); + addParameter(p, 'YLim', [], @isnumeric); + addParameter(p, 'XLim', [], @isnumeric); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle') || isa(x, 'exploreFNIRS.core.ColorScheme')); + parse(p, groups, auxField, varargin{:}); + opts = p.Results; + + if isempty(opts.InfoVar) + error('exploreFNIRS:core:plotAuxScatter', ... + 'InfoVar is required. Specify the X-axis variable name.'); + end + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + nGroups = length(groups); + + % Validate groups have bar-flat grand averages with Aux data + for g = 1:nGroups + if isempty(groups(g).gbyGrandBarFlat) + error('exploreFNIRS:core:plotAuxScatter', ... + 'Group %d has no bar-flat grand average. Call aggregate() first.', g); + end + end + + % Resolve Aux field name (handle flattened naming) + auxField = resolveAuxField(groups(1).gbyGrandBarFlat, auxField); + + % Validate Aux field exists + for g = 1:nGroups + ga = groups(g).gbyGrandBarFlat; + if ~isfield(ga, 'Aux') || ~isfield(ga.Aux, auxField) + error('exploreFNIRS:core:plotAuxScatter', ... + 'Aux field "%s" not found in group %d.', auxField, g); + end + end + + % Auto-expand groups by time bins when multiple bars exist + if ~isempty(groups(1).gbyGrandBarFlat) && ... + length(groups(1).gbyGrandBarFlat.time) > 1 + groups = exploreFNIRS.core.expandGroupsByTime(groups); + nGroups = length(groups); + end + tIdx = 1; + + % Determine aux channels + refAux = groups(1).gbyGrandBarFlat.Aux.(auxField); + nTotalCh = size(refAux.data, 2); + + if isempty(opts.AuxChannels) + auxCh = 1:nTotalCh; + else + auxCh = opts.AuxChannels(opts.AuxChannels <= nTotalCh); + end + nCh = length(auxCh); + + % Get channel labels + if isfield(refAux, 'varNames') && ~isempty(refAux.varNames) + allLabels = refAux.varNames; + chLabels = cell(1, nCh); + for c = 1:nCh + if auxCh(c) <= length(allLabels) + chLabels{c} = allLabels{auxCh(c)}; + else + chLabels{c} = sprintf('ch%d', auxCh(c)); + end + end + else + chLabels = arrayfun(@(x) sprintf('ch%d', x), auxCh, ... + 'UniformOutput', false); + end + + % Y-axis label + if isfield(refAux, 'unit') && ~isempty(refAux.unit) + yUnit = refAux.unit; + else + yUnit = 'a.u.'; + end + + % Initialize stats output + stats = repmat(emptyStats(), nGroups, nCh); + + sty = pf2_base.plot.PlotStyle.getDefault(); + + % --- Determine layout --- + hasPB = ~isempty(opts.PlotBy); + if hasPB + [plotByValues, subGroups, withinLabels, plotByIdx] = ... + exploreFNIRS.core.splitGroupsByFactor(groups, opts.PlotBy); + nPlotBy = length(plotByValues); + nRows = nPlotBy; + nCols = nCh; + else + nRows = ceil(sqrt(nCh)); + nCols = ceil(nCh / nRows); + end + + figW = opts.SaveWidth * min(nCols, 4); + figH = opts.SaveHeight * max(nRows * 0.7, 1); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', figW, 'Height', figH, 'SavePath', opts.SavePath); + + allAxes = gobjects(nRows * nCols, 1); + axCount = 0; + + if hasPB + % rows = PlotBy values, cols = aux channels + for pIdx = 1:nPlotBy + curGroups = subGroups{pIdx}; + nCurGroups = length(curGroups); + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + curColors = opts.Colors.resolve(curGroups); + else + curColors = exploreFNIRS.core.getGroupColors(nCurGroups, opts.Colors); + end + curWithin = withinLabels(plotByIdx == pIdx); + + for chI = 1:nCh + ch = auxCh(chI); + spIdx = (pIdx - 1) * nCols + chI; + ax = subplot(nRows, nCols, spIdx, 'Parent', fig); + hold(ax, 'on'); + axCount = axCount + 1; + allAxes(axCount) = ax; + + for g = 1:nCurGroups + curStats = plotGroupAuxScatter(ax, curGroups(g), ... + auxField, ch, tIdx, opts, curColors(g,:), g); + stats(g, chI) = curStats; + end + + if pIdx == 1 + title(ax, pf2_base.plot.escapeTeX(chLabels{chI})); + end + if chI == 1 + ylabel(ax, pf2_base.plot.escapeTeX(sprintf('%s: %s', opts.PlotBy, plotByValues{pIdx}))); + end + + if opts.FlipXY + xlabel(ax, sprintf('%s (%s)', auxField, yUnit)); + else + xlabel(ax, pf2_base.plot.escapeTeX(opts.InfoVar)); + end + + spTotal = nPlotBy * nCh; + if nCurGroups > 1 && showLegend(opts.Legend, spIdx, spTotal) + lg = legend(ax, curWithin, 'Location', 'best', 'FontSize', 8); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + end + + grid(ax, 'on'); + box(ax, 'on'); + end + end + else + % Square grid of aux channels + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + colors = opts.Colors.resolve(groups); + else + colors = exploreFNIRS.core.getGroupColors(nGroups, opts.Colors); + end + + for chI = 1:nCh + ch = auxCh(chI); + + if nCh > 1 + ax = subplot(nRows, nCols, chI, 'Parent', fig); + else + ax = axes('Parent', fig); + end + hold(ax, 'on'); + axCount = axCount + 1; + allAxes(axCount) = ax; + + for g = 1:nGroups + curStats = plotGroupAuxScatter(ax, groups(g), ... + auxField, ch, tIdx, opts, colors(g,:), g); + stats(g, chI) = curStats; + end + + title(ax, pf2_base.plot.escapeTeX(chLabels{chI})); + if opts.FlipXY + xlabel(ax, sprintf('%s (%s)', auxField, yUnit)); + if chI == 1 || mod(chI - 1, nCols) == 0 + ylabel(ax, pf2_base.plot.escapeTeX(opts.InfoVar)); + end + else + xlabel(ax, pf2_base.plot.escapeTeX(opts.InfoVar)); + if chI == 1 || mod(chI - 1, nCols) == 0 + ylabel(ax, sprintf('%s (%s)', auxField, yUnit)); + end + end + + if nGroups > 1 && showLegend(opts.Legend, chI, nCh) + legendLabels = arrayfun(@(g) groups(g).label, ... + 1:nGroups, 'UniformOutput', false); + lg = legend(ax, legendLabels, 'Location', 'best', 'FontSize', 8); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + end + + grid(ax, 'on'); + box(ax, 'on'); + end + end + + % Shared axes + allAxes = allAxes(1:axCount); + if axCount > 1 + linkaxes(allAxes, 'xy'); + end + if ~isempty(opts.YLim), arrayfun(@(a) ylim(a, opts.YLim), allAxes); end + if ~isempty(opts.XLim), arrayfun(@(a) xlim(a, opts.XLim), allAxes); end + + % Title + if ~isempty(opts.Title) + pf2_base.external.suptitle(fig, opts.Title); + else + pf2_base.external.suptitle(fig, sprintf('%s vs %s (%s)', ... + pf2_base.plot.escapeTeX(opts.InfoVar), auxField, opts.CorrType)); + end + + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); +end + + +%% Local helpers + +function curStats = plotGroupAuxScatter(ax, group, auxField, ch, tIdx, opts, clr, gIdx) +% Plot scatter for one group, one aux channel + + curGrand = group.gbyGrandBarFlat; + curTable = group.gbyTables; + + % Extract Y: per-subject aux value at this channel and time bin + if ~isfield(curGrand, 'Aux') || ~isfield(curGrand.Aux, auxField) + curStats = emptyStats(); + return; + end + + auxData = curGrand.Aux.(auxField); + if ~isfield(auxData, 'data') || ch > size(auxData.data, 2) + curStats = emptyStats(); + return; + end + + yVals = permute(auxData.data(tIdx, ch, :), [3, 1, 2]); + + % Hierarchical averaging of Y values + if strcmpi(opts.Averaging, 'hierarchy') && ... + isfield(curGrand, 'info') && isfield(curGrand.info, 'Hierarchy') + [yVals, ~] = pf2_base.hierarchicalAverage(yVals, ... + curGrand.info.Hierarchy, @nanmean); + end + + % Extract X: info variable from table + if ~ismember(opts.InfoVar, curTable.Properties.VariableNames) + warning('Variable "%s" not found in group table', opts.InfoVar); + curStats = emptyStats(); + return; + end + + xData = curTable.(opts.InfoVar); + if ~isnumeric(xData) + xData = double(string(xData)); + end + xData(xData == -9999) = NaN; + + % Apply averaging to X values + if strcmpi(opts.Averaging, 'hierarchy') && ... + ismember('SubjectID', curTable.Properties.VariableNames) + [xVals] = pf2_base.hierarchicalAverage(xData, ... + curTable(:, 'SubjectID'), @nanmean); + else + xVals = xData; + end + + % Align lengths + n = min(length(xVals), length(yVals)); + xVals = xVals(1:n); + yVals = yVals(1:n); + + % Remove NaN pairs + validIdx = ~isnan(xVals) & ~isnan(yVals); + xVals = xVals(validIdx); + yVals = yVals(validIdx); + N = length(xVals); + + % Compute correlations + curStats = emptyStats(); + curStats.N = N; + + if N >= 3 + [curStats.r, curStats.p] = pf2_base.compat.corr(xVals, yVals, 'Type', 'Pearson'); + [curStats.rho, curStats.pval] = pf2_base.compat.corr(xVals, yVals, 'Type', 'Spearman'); + end + + % Apply Spearman rank transform if requested + if strcmpi(opts.CorrType, 'Spearman') + [~, p2] = sort(xVals, 'descend'); + r2 = 1:length(xVals); + r2(p2) = r2; + xVals = r2(:); + + [~, p2] = sort(yVals, 'descend'); + r2 = 1:length(yVals); + r2(p2) = r2; + yVals = r2(:); + end + + % Flip axes + if opts.FlipXY + temp = xVals; + xVals = yVals; + yVals = temp; + end + + % Scatter points + scatter(ax, xVals, yVals, 25, clr, 'filled', 'MarkerFaceAlpha', 0.7); + + % Regression line and error band + if (opts.FitLine || opts.ErrorBand) && N > 2 + [coefficients, PolyS] = polyfit(xVals, yVals, 1); + curStats.coefficients = coefficients; + xFit = linspace(min(xVals), max(xVals), 200); + [yFit, deltaY] = polyval(coefficients, xFit, PolyS); + + % Error band + if opts.ErrorBand + yEst = polyval(coefficients, xVals); + yDiff = yVals - yEst; + SD = std(yDiff); + SEM = SD / sqrt(N); + + switch opts.ErrorBandType + case 'SEM' + yUpper = yFit + SEM; + yLower = yFit - SEM; + case 'SD' + yUpper = yFit + SD; + yLower = yFit - SD; + case '95%CI' + CI = pf2_base.external.polyparci(coefficients, PolyS); + yUpper = polyval(CI(1,:), xFit); + yLower = polyval(CI(2,:), xFit); + case '95%PI' + yUpper = yFit + deltaY * tinv(0.95, N - 1); + yLower = yFit - deltaY * tinv(0.95, N - 1); + otherwise + yUpper = yFit + deltaY * tinv(0.95, N - 1); + yLower = yFit - deltaY * tinv(0.95, N - 1); + end + + plotBand(ax, xFit, yUpper, yLower, clr, opts.ErrorBandStyle); + end + + % Regression line + if opts.FitLine + h = plot(ax, xFit, yFit, '-', 'Color', clr, 'LineWidth', 1.5); + set(h.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + + % Annotation with stats + if strcmpi(opts.CorrType, 'Spearman') + statStr = sprintf('rho=%.3f, p=%.4f', curStats.rho, curStats.pval); + else + statStr = sprintf('r=%.3f, p=%.4f', curStats.r, curStats.p); + end + yOff = 0.98 - (gIdx - 1) * 0.08; + text(ax, 0.02, yOff, ... + sprintf('N=%d, %s', N, statStr), ... + 'Units', 'normalized', 'FontSize', 7, 'Color', clr, ... + 'VerticalAlignment', 'top'); + end + end +end + + +function plotBand(ax, xFit, yUpper, yLower, clr, style) +% Plot error band around regression line + errColor = clr + (1 - clr) * 0.55; + + switch style + case 'Shaded' + xPatch = [xFit, fliplr(xFit)]; + yPatch = [yLower, fliplr(yUpper)]; + h = patch(ax, xPatch, yPatch, -1, ... + 'FaceColor', errColor, 'EdgeColor', 'none', 'FaceAlpha', 0.15); + set(h, 'HandleVisibility', 'off'); + case 'Dashed' + h1 = plot(ax, xFit, yUpper, '--', 'Color', errColor, 'LineWidth', 1.5); + h2 = plot(ax, xFit, yLower, '--', 'Color', errColor, 'LineWidth', 1.5); + set(h1.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + set(h2.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + case 'Fine' + h1 = plot(ax, xFit, yUpper, '-', 'Color', errColor, 'LineWidth', 0.5); + h2 = plot(ax, xFit, yLower, '-', 'Color', errColor, 'LineWidth', 0.5); + set(h1.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + set(h2.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + end +end + + +function tf = showLegend(mode, idx, total) + switch lower(mode) + case 'last', tf = (idx == total); + case 'first', tf = (idx == 1); + case 'all', tf = true; + case 'none', tf = false; + otherwise, tf = (idx == total); + end +end + + +function s = emptyStats() + s = struct('r', NaN, 'p', NaN, 'rho', NaN, 'pval', NaN, ... + 'N', 0, 'coefficients', []); +end + + +function resolved = resolveAuxField(ga, name) +% Resolve user-facing Aux field name to actual field in data + if ~isfield(ga, 'Aux') + resolved = name; + return; + end + if isfield(ga.Aux, name) + resolved = name; + return; + end + dataName = [name, '_data']; + if isfield(ga.Aux, dataName) + resolved = dataName; + return; + end + resolved = name; +end diff --git a/+exploreFNIRS/+core/plotBar.m b/+exploreFNIRS/+core/plotBar.m new file mode 100644 index 00000000..991f733c --- /dev/null +++ b/+exploreFNIRS/+core/plotBar.m @@ -0,0 +1,517 @@ +function fig = plotBar(groups, varargin) +% PLOTBAR Headless bar chart from grouped/aggregated experiment data +% +% Creates bar charts showing mean biomarker values per group for each +% channel, with error bars. Each channel gets its own subplot — channels +% are never averaged. Groups are shown as separate bars within each subplot. +% +% Syntax: +% fig = plotBar(groups) +% fig = plotBar(groups, 'Biomarker', 'HbO', 'Channels', 1:5) +% fig = plotBar(groups, 'ROIs', 'all', 'Biomarker', 'HbO') +% fig = plotBar(groups, 'SavePath', 'barchart.png') +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% Each element must have .gbyGrand with .HbO, .HbR, etc. +% +% Name-Value Parameters: +% Biomarker - Single biomarker name (default: 'HbO') +% Channels - Vector of channel indices (default: all channels) +% ROIs - ROI indices, names, or 'all' (default: []) +% When provided, data is read from gbyGrand.ROI instead of +% gbyGrand. Mutually exclusive with Channels. +% TimeWindow - [start, end] in seconds to average over (default: full range) +% ErrorType - 'SEM' (default), 'SD', or 'none' +% ShowIndividual - Show individual data points (default: false) +% ShowN - Show subject count (n=X) above bars (default: true) +% Legend - 'last' (default), 'first', 'all', or 'none' +% Controls which subplot(s) show the legend. +% YLim - [min max] y-axis limits (default: auto, shared across subplots) +% XLim - [min max] x-axis limits (default: auto, shared across subplots) +% PlotBy - Groupby variable to use as series in clustered bars +% (e.g., 'Condition'). Creates grouped bar chart instead +% of flat bars. The PlotBy factor becomes the legend, +% remaining factors become X-axis categories. +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 600) +% SaveHeight - Height in pixels (default: 400) +% SaveDPI - Resolution (default: 150) +% Colors - Group color palette override (default: [] = auto) +% [N x 3] RGB matrix, colormap name (e.g. 'Set1', 'tab10'), +% or function handle @(N) returning [N x 3]. +% +% Outputs: +% fig - Figure handle +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group','Condition'}); +% ex.aggregate(); +% +% % Bar chart for HbO, channels 1-5, averaged over 5-20s +% fig = exploreFNIRS.core.plotBar(ex.groups, ... +% 'Biomarker', 'HbO', 'Channels', 1:5, ... +% 'TimeWindow', [5, 20], 'SavePath', 'bar.png'); +% +% See also: exploreFNIRS.core.Experiment, exploreFNIRS.core.plotTemporal + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addParameter(p, 'Biomarker', 'HbO', @ischar); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'ROIs', [], @(x) isnumeric(x) || islogical(x) || ischar(x) || isstring(x) || iscell(x)); + addParameter(p, 'Device', [], @(v) isempty(v) || isa(v, 'pf2.Device')); + addParameter(p, 'ExcludeShortSeparation', true, @islogical); + addParameter(p, 'TimeWindow', [], @isnumeric); + addParameter(p, 'StatWindow', [], @isnumeric); + addParameter(p, 'ErrorType', 'SEM', @ischar); + addParameter(p, 'ShowIndividual', false, @islogical); + addParameter(p, 'ShowN', true, @islogical); + addParameter(p, 'Legend', 'last', @ischar); + addParameter(p, 'YLim', [], @isnumeric); + addParameter(p, 'XLim', [], @isnumeric); + addParameter(p, 'PlotBy', '', @ischar); + addParameter(p, 'GroupByVars', {}, @iscell); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle') || isa(x, 'exploreFNIRS.core.ColorScheme')); + parse(p, groups, varargin{:}); + opts = p.Results; + + % StatWindow is an alias for TimeWindow (for API consistency) + if isempty(opts.TimeWindow) && ~isempty(opts.StatWindow) + opts.TimeWindow = opts.StatWindow; + end + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + bioM = opts.Biomarker; + nGroups = length(groups); + + % Validate + for g = 1:nGroups + if isempty(groups(g).gbyGrand) + error('exploreFNIRS:core:plotBar', ... + 'Group %d has no grand average. Call aggregate() first.', g); + end + end + + % Auto-expand groups by time bins when multiple bars exist + if ~isempty(groups(1).gbyGrandBarFlat) && ... + length(groups(1).gbyGrandBarFlat.time) > 1 + groups = exploreFNIRS.core.expandGroupsByTime(groups); + nGroups = length(groups); + end + + % Resolve channels/ROIs (default = all) + if ~isempty(opts.ROIs) + if ~isempty(opts.Channels) + error('exploreFNIRS:core:plotBar', ... + 'ROIs and Channels are mutually exclusive.'); + end + if ~isfield(groups(1).gbyGrand, 'ROI') + error('exploreFNIRS:core:plotBar', ... + 'No ROI data in grand average. Define ROIs before aggregating.'); + end + [roiIdx, roiNames] = resolveROIs(groups, opts.ROIs); + plotItems = roiIdx; + itemNames = roiNames; + useROI = true; + else + useROI = false; + if isempty(opts.Channels) + nTotalCh = size(groups(1).gbyGrand.(bioM).Mean, 2); + plotItems = 1:nTotalCh; + else + plotItems = opts.Channels; + end + % Exclude short-separation channels + if opts.ExcludeShortSeparation + ssIdx = getShortSeparationIdx(opts.Device, groups); + if ~isempty(ssIdx) + plotItems = plotItems(~ismember(plotItems, ssIdx)); + end + end + itemNames = arrayfun(@(c) sprintf('Ch %d', c), plotItems, ... + 'UniformOutput', false); + end + nItems = length(plotItems); + + % PlotBy setup + hasPB = ~isempty(opts.PlotBy); + if hasPB + [plotByValues, ~, withinLabels, plotByIdx] = ... + exploreFNIRS.core.splitGroupsByFactor(groups, opts.PlotBy); + end + + % Layout: prefer columns (bar subplots are taller than wide) + nCols = ceil(sqrt(nItems)); + nRows = ceil(nItems / nCols); + + figW = opts.SaveWidth * min(nCols, 5); + figH = opts.SaveHeight * max(nRows, 1); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', figW, 'Height', figH, 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + + allAxes = gobjects(nItems, 1); + + for chI = 1:nItems + ax = subplot(nRows, nCols, chI, 'Parent', fig); + hold(ax, 'on'); + allAxes(chI) = ax; + + ch = plotItems(chI); + + % Compute per-group means/errors for this channel + [groupMeans, groupErrors, groupN, groupLabels, individualData] = ... + computeChannelStats(groups, bioM, ch, opts, useROI); + + if hasPB + % --- Clustered bar chart (barweb) --- + nSeries = length(plotByValues); + uniqueWithin = unique(withinLabels, 'stable'); + nX = length(uniqueWithin); + + meanMatrix = nan(nX, nSeries); + errorMatrix = nan(nX, nSeries); + indivData = cell(nX, nSeries); + + for g = 1:nGroups + si = plotByIdx(g); + xi = find(strcmp(uniqueWithin, withinLabels{g}), 1); + meanMatrix(xi, si) = groupMeans(g); + errorMatrix(xi, si) = groupErrors(g); + indivData{xi, si} = individualData{g}; + end + + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + seriesColors = opts.Colors.resolve(groups); + % Remap to series-level: take one color per unique series value + seriesColors = seriesColors(1:nSeries, :); + else + seriesColors = exploreFNIRS.core.getGroupColors(nSeries, opts.Colors); + end + + if strcmpi(opts.ErrorType, 'none') + errInput = []; + else + errInput = errorMatrix; + end + + barwebArgs = {'Axes', ax, ... + 'ColorMap', seriesColors, ... + 'ErrorColor', sty.ForegroundColor, ... + 'YLabel', sprintf('%s (%s)', bioM, getUnitsLabel(groups(1)))}; + + if showLegend(opts.Legend, chI, nItems) && ~strcmpi(opts.Legend, 'none') + barwebArgs = [barwebArgs, {'Legend', pf2_base.plot.escapeTeX(plotByValues)}]; + end + + if opts.ShowIndividual + barwebArgs = [barwebArgs, {'DataPoints', indivData}]; + end + + bwHandles = pf2_base.external.barweb(meanMatrix, errInput, 0.8, pf2_base.plot.escapeTeX(uniqueWithin), ... + barwebArgs{:}); + hold(ax, 'on'); + + % Style legend if barweb created one + if ~isempty(bwHandles.legend) && isvalid(bwHandles.legend) + bwHandles.legend.TextColor = sty.LegendTextColor; + bwHandles.legend.Color = sty.LegendBgColor; + bwHandles.legend.EdgeColor = sty.LegendEdgeColor; + bwHandles.legend.Box = 'on'; + end + + % X-axis label: within factor name(s) — bottom row only + isBottomRow = ceil(chI / nCols) == nRows; + if isBottomRow && ~isempty(opts.GroupByVars) + withinVars = setdiff(opts.GroupByVars, {opts.PlotBy}, 'stable'); + if ~isempty(withinVars) + xlabel(ax, pf2_base.plot.escapeTeX(strjoin(withinVars, ' x '))); + end + end + + else + % --- Standard flat bar chart (via barweb) --- + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + colors = opts.Colors.resolve(groups); + else + colors = exploreFNIRS.core.getGroupColors(nGroups, opts.Colors); + end + + meanMatrix = groupMeans(:); % [nGroups x 1] — each group is X category + if strcmpi(opts.ErrorType, 'none') + errInput = []; + else + errInput = groupErrors(:); + end + + indivData = cell(nGroups, 1); + for g = 1:nGroups + indivData{g, 1} = individualData{g}; + end + + barwebArgs = {'Axes', ax, ... + 'ColorMap', colors(1,:), ... + 'ErrorColor', sty.ForegroundColor, ... + 'YLabel', sprintf('%s (%s)', bioM, getUnitsLabel(groups(1)))}; + + if opts.ShowIndividual + barwebArgs = [barwebArgs, {'DataPoints', indivData}]; + end + + bwHandles = pf2_base.external.barweb(meanMatrix, errInput, 0.8, pf2_base.plot.escapeTeX(groupLabels), ... + barwebArgs{:}); + hold(ax, 'on'); + + % Color each bar individually (single series = single color by default) + if ~isempty(bwHandles.bars) + bwHandles.bars(1).FaceColor = 'flat'; + bwHandles.bars(1).CData = colors(1:nGroups, :); + end + + % Add x-axis margin so bars don't touch the edges + xlim(ax, [0.25, nGroups + 0.75]); + + % Legend identifies bars, so replace tick labels with xlabel + % Only show xlabel on bottom row to avoid overlapping with row below + isBottomRow = ceil(chI / nCols) == nRows; + if ~isempty(opts.GroupByVars) + set(ax, 'XTickLabel', {}); + if isBottomRow + xlabel(ax, pf2_base.plot.escapeTeX(strjoin(opts.GroupByVars, ' x '))); + end + end + + % Legend with colored patches (always show on designated subplot) + if showLegend(opts.Legend, chI, nItems) + lh = gobjects(nGroups, 1); + for g = 1:nGroups + lh(g) = patch(ax, NaN, NaN, colors(g,:), ... + 'EdgeColor', sty.ForegroundColor, 'LineWidth', 2); + end + lg = legend(ax, lh, pf2_base.plot.escapeTeX(groupLabels), 'Location', 'best'); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + end + end + + % N-labels above bars + if opts.ShowN && ~all(isnan(groupN)) + yl = ylim(ax); + yRange = yl(2) - yl(1); + for g = 1:nGroups + if ~isnan(groupN(g)) + if hasPB + % barweb positions categories at integer x values + si = plotByIdx(g); + xi = find(strcmp(uniqueWithin, withinLabels{g}), 1); + xPos = xi + bwHandles.bars(si).XOffset; + else + xPos = g; + end + yPos = groupMeans(g) + groupErrors(g) + yRange * 0.02; + text(ax, xPos, yPos, sprintf('n=%d', groupN(g)), ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'bottom', ... + 'FontSize', 7, 'Color', sty.DimColor, ... + 'HandleVisibility', 'off'); + end + end + end + + % Zero line + plot(ax, xlim(ax), [0 0], '-', 'Color', sty.ZeroLineColor, ... + 'LineWidth', 0.5, 'HandleVisibility', 'off'); + + title(ax, pf2_base.plot.escapeTeX(itemNames{chI})); + box(ax, 'on'); + grid(ax, 'on'); + end + + % Shared axes + linkaxes(allAxes, 'y'); + if ~isempty(opts.YLim), arrayfun(@(a) ylim(a, opts.YLim), allAxes); end + if ~isempty(opts.XLim), arrayfun(@(a) xlim(a, opts.XLim), allAxes); end + + % Figure title (suptitle auto-rescales subplots to avoid overlap) + if ~isempty(opts.Title) + pf2_base.external.suptitle(fig, opts.Title); + else + tStr = bioM; + if ~isempty(opts.TimeWindow) + tStr = sprintf('%s (%g-%gs)', tStr, round(opts.TimeWindow(1), 4), round(opts.TimeWindow(2), 4)); + end + pf2_base.external.suptitle(fig, tStr); + end + + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); +end + + +%% Local helpers + + +function [groupMeans, groupErrors, groupN, groupLabels, individualData] = ... + computeChannelStats(groups, bioM, ch, opts, useROI) +% Compute per-group mean, error, N for a single channel + nGroups = length(groups); + groupMeans = nan(1, nGroups); + groupErrors = nan(1, nGroups); + groupN = nan(1, nGroups); + groupLabels = cell(1, nGroups); + individualData = cell(1, nGroups); + + % Pre-fill labels so downstream escapeTeX/legend code never sees an + % empty numeric [] (which trips cellfun) when a group is skipped for + % lack of data. Labels are otherwise only set at the bottom of the + % loop, so any early 'continue' leaves the slot as []. + for g = 1:nGroups + lbl = groups(g).label; + if isempty(lbl) || ~(ischar(lbl) || isstring(lbl)) + lbl = sprintf('Group%d', g); + end + groupLabels{g} = char(lbl); + end + + for g = 1:nGroups + ga = groups(g).gbyGrand; + if useROI + if ~isfield(ga.ROI, bioM) || isempty(ga.ROI.(bioM)) + continue; + end + src = ga.ROI.(bioM); + else + if ~isfield(ga, bioM) || isempty(ga.(bioM)) + continue; + end + src = ga.(bioM); + end + + if ch > size(src.Mean, 2), continue; end + + timeVec = ga.time; + + % Time window selection + if ~isempty(opts.TimeWindow) + tMask = timeVec >= opts.TimeWindow(1) & timeVec <= opts.TimeWindow(2); + else + tMask = true(size(timeVec)); + end + if ~any(tMask), continue; end + + % Mean: average over time window for this single channel + groupMeans(g) = mean(src.Mean(tMask, ch), 'omitnan'); + + % Error: use per-subject data when available + if isfield(src, 'data') && ~isempty(src.data) + % data is [T x C x N] + subjectData = src.data(tMask, ch, :); + perSubject = squeeze(mean(subjectData, 1, 'omitnan')); + perSubject = perSubject(:); + perSubject(isnan(perSubject)) = []; + + groupN(g) = length(perSubject); + individualData{g} = perSubject; + + switch upper(opts.ErrorType) + case 'SEM' + groupErrors(g) = std(perSubject, 'omitnan') / sqrt(groupN(g)); + case 'SD' + groupErrors(g) = std(perSubject, 'omitnan'); + case 'NONE' + groupErrors(g) = 0; + end + else + groupErrors(g) = mean(src.SEM(tMask, ch), 'omitnan'); + groupN(g) = round(mean(src.N(tMask, ch), 'omitnan')); + end + + groupLabels{g} = groups(g).label; + end +end + + +function lbl = getUnitsLabel(group) + if ~isempty(group.gbyGrand) && isfield(group.gbyGrand, 'units') + lbl = group.gbyGrand.units; + else + lbl = '\DeltaHb'; + end +end + + +function tf = showLegend(mode, idx, total) +% Determine whether to show legend on this subplot + switch lower(mode) + case 'last', tf = (idx == total); + case 'first', tf = (idx == 1); + case 'all', tf = true; + case 'none', tf = false; + otherwise, tf = (idx == total); + end +end + + +function [roiIdx, roiNames] = resolveROIs(groups, rois) +% Convert ROI input to numeric indices and name strings + roiInfo = groups(1).gbyGrand.ROI.info; + allNames = roiInfo.Properties.RowNames; + + if ischar(rois) || isstring(rois) + if strcmpi(rois, 'all') + roiIdx = 1:length(allNames); + else + roiIdx = find(ismember(allNames, {char(rois)})); + end + elseif iscell(rois) + roiIdx = find(ismember(allNames, rois)); + elseif islogical(rois) + roiIdx = find(rois); + else + roiIdx = rois; % numeric + end + + roiIdx = roiIdx(roiIdx <= length(allNames)); + roiNames = allNames(roiIdx); +end + + +function ssIdx = getShortSeparationIdx(dev, groups) +% Get short-separation channel indices from Device or probe info + ssIdx = []; + if ~isempty(dev) && isa(dev, 'pf2.Device') + ssIdx = find(dev.isShortSep()); + return; + end + for g = 1:length(groups) + ga = groups(g).gbyGrand; + if isfield(ga, 'probeInfo') && isstruct(ga.probeInfo) + pi = ga.probeInfo; + if isfield(pi, 'TableOpt') && istable(pi.TableOpt) ... + && ismember('IsShortSeparation', pi.TableOpt.Properties.VariableNames) + ssIdx = find(pi.TableOpt.IsShortSeparation); + return; + end + if isfield(pi, 'SD') && isstruct(pi.SD) && isfield(pi.SD, 'distances') + ssIdx = find(pi.SD.distances < 2); + return; + end + end + end +end diff --git a/+exploreFNIRS/+core/plotComposite.m b/+exploreFNIRS/+core/plotComposite.m new file mode 100644 index 00000000..982e9bb2 --- /dev/null +++ b/+exploreFNIRS/+core/plotComposite.m @@ -0,0 +1,178 @@ +function fig = plotComposite(groups, panels, varargin) +% PLOTCOMPOSITE Multi-panel publication figure from grouped data +% +% Creates a composite figure with multiple sub-panels arranged in a grid, +% each rendering a different plot type. Panel labels (A, B, C...) are +% automatically added. +% +% Syntax: +% fig = exploreFNIRS.core.plotComposite(groups, panels) +% fig = exploreFNIRS.core.plotComposite(groups, panels, 'Layout', [2, 2]) +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% panels - Cell array of panel definition structs. Each struct has: +% .type - 'temporal', 'bar', 'topo', or 'heatmap' +% .args - Cell array of name-value args for that plot function +% (default: {}) +% .position - [row, col] in the grid (default: auto-assigned) +% +% Name-Value Parameters: +% Layout - [nRows, nCols] grid size (default: auto from panels) +% PanelLabels - 'auto' (A,B,C...), 'none', or cell array of strings +% Title - Figure super-title (default: '') +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 1200) +% SaveHeight - Height in pixels (default: 800) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% Example: +% panels = { ... +% struct('type', 'temporal', 'args', {{'Biomarkers', {'HbO'}}}), ... +% struct('type', 'bar', 'args', {{'Biomarker', 'HbO'}}) ... +% }; +% fig = exploreFNIRS.core.plotComposite(ex.groups, panels, ... +% 'Layout', [1, 2], 'SavePath', 'composite.png'); +% +% See also: exploreFNIRS.core.plotTemporal, exploreFNIRS.core.plotBar, +% exploreFNIRS.core.plotTopo, exploreFNIRS.core.plotHeatmap + + ip = inputParser; + addRequired(ip, 'groups', @isstruct); + addRequired(ip, 'panels', @iscell); + addParameter(ip, 'Layout', [], @(v) isempty(v) || (isnumeric(v) && numel(v) == 2)); + addParameter(ip, 'PanelLabels', 'auto', @(v) ischar(v) || iscell(v)); + addParameter(ip, 'Title', '', @ischar); + addParameter(ip, 'Visible', 'on', @ischar); + addParameter(ip, 'SavePath', '', @ischar); + addParameter(ip, 'SaveWidth', 1200, @isnumeric); + addParameter(ip, 'SaveHeight', 800, @isnumeric); + addParameter(ip, 'SaveDPI', 150, @isnumeric); + addParameter(ip, 'TightLayout', false, @islogical); + parse(ip, groups, panels, varargin{:}); + opts = ip.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + nPanels = length(panels); + + % Determine layout + if isempty(opts.Layout) + nCols = ceil(sqrt(nPanels)); + nRows = ceil(nPanels / nCols); + else + nRows = opts.Layout(1); + nCols = opts.Layout(2); + end + + % Panel labels + if ischar(opts.PanelLabels) && strcmpi(opts.PanelLabels, 'auto') + labels = cell(1, nPanels); + for k = 1:nPanels + labels{k} = sprintf('(%c)', char('A' + k - 1)); + end + elseif ischar(opts.PanelLabels) && strcmpi(opts.PanelLabels, 'none') + labels = {}; + else + labels = opts.PanelLabels; + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + + tl = tiledlayout(fig, nRows, nCols, 'TileSpacing', 'compact', ... + 'Padding', 'compact'); + + for k = 1:nPanels + pDef = panels{k}; + + % Get position + if isfield(pDef, 'position') && ~isempty(pDef.position) + tileIdx = (pDef.position(1) - 1) * nCols + pDef.position(2); + else + tileIdx = k; + end + + ax = nexttile(tl, tileIdx); + + % Get args + if isfield(pDef, 'args') && ~isempty(pDef.args) + panelArgs = pDef.args; + else + panelArgs = {}; + end + + % Render panel into the axes + renderPanel(ax, groups, pDef.type, panelArgs); + + sty = pf2_base.plot.PlotStyle.getDefault(); + + % Panel label + if ~isempty(labels) && k <= length(labels) + text(ax, -0.1, 1.05, labels{k}, 'Units', 'normalized', ... + 'FontSize', 14, 'FontWeight', 'bold', ... + 'Color', sty.ForegroundColor); + end + sty.applyToAxes(ax); + end + + if ~isempty(opts.Title) + title(tl, pf2_base.plot.escapeTeX(opts.Title)); + end + + pf2_base.plot.handleSave(fig, opts); +end + + +function renderPanel(ax, groups, panelType, panelArgs) +% Render a single panel type into the given axes + + % Create a temporary invisible figure, plot into it, then copy children + % to the target axes. This avoids each plot function creating its own fig. + switch lower(panelType) + case 'temporal' + tmpFig = exploreFNIRS.core.plotTemporal(groups, ... + 'Visible', 'off', panelArgs{:}); + case 'bar' + tmpFig = exploreFNIRS.core.plotBar(groups, ... + 'Visible', 'off', panelArgs{:}); + case 'topo' + tmpFig = exploreFNIRS.core.plotTopo(groups, ... + 'Visible', 'off', panelArgs{:}); + case 'heatmap' + tmpFig = exploreFNIRS.core.plotHeatmap(groups, ... + 'Visible', 'off', panelArgs{:}); + otherwise + warning('exploreFNIRS:core:plotComposite', ... + 'Unknown panel type "%s". Skipping.', panelType); + return; + end + + % Copy content from temp figure axes to target axes + tmpAxes = findobj(tmpFig, 'Type', 'Axes'); + if ~isempty(tmpAxes) + srcAx = tmpAxes(1); % use first axes + children = get(srcAx, 'Children'); + copyobj(children, ax); + + % Copy axis properties + ax.XLim = srcAx.XLim; + ax.YLim = srcAx.YLim; + ax.XLabel.String = srcAx.XLabel.String; + ax.YLabel.String = srcAx.YLabel.String; + ax.Title.String = srcAx.Title.String; + + if ~isempty(srcAx.CLim) + ax.CLim = srcAx.CLim; + end + end + + close(tmpFig); +end diff --git a/+exploreFNIRS/+core/plotExperimentTimeline.m b/+exploreFNIRS/+core/plotExperimentTimeline.m new file mode 100644 index 00000000..cd1395fb --- /dev/null +++ b/+exploreFNIRS/+core/plotExperimentTimeline.m @@ -0,0 +1,247 @@ +function fig = plotExperimentTimeline(settings, varargin) +% PLOTEXPERIMENTTIMELINE Visualize experiment time settings as a timeline diagram +% +% Shows the relationship between baseline, task block, temporal resample, +% and bar chart resample settings. Useful for verifying configuration +% before running aggregate(). +% +% Syntax: +% fig = plotExperimentTimeline(settings) +% fig = plotExperimentTimeline(settings, 'DataRange', [-10, 40]) +% fig = plotExperimentTimeline(settings, 'SavePath', 'timeline.png') +% +% Inputs: +% settings - Experiment.settings struct with fields: +% baseline, taskStart, taskEnd, resampleRate, barBinSize, +% useBaseline +% +% Name-Value Parameters: +% DataRange - [min, max] time range of actual data (default: inferred) +% Title - Figure title (default: 'Experiment Time Settings') +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 800) +% SaveHeight - Height in pixels (default: 350) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.core.Experiment + + p = inputParser; + addRequired(p, 'settings', @isstruct); + addParameter(p, 'DataRange', [], @isnumeric); + addParameter(p, 'Title', 'Experiment Time Settings', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 800, @isnumeric); + addParameter(p, 'SaveHeight', 350, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, settings, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + s = settings; + + % Resolve task end + if isfinite(s.taskEnd) + taskEnd = s.taskEnd; + elseif ~isempty(opts.DataRange) + taskEnd = opts.DataRange(2); + else + taskEnd = 30; % reasonable default + end + + % Resolve data range + if ~isempty(opts.DataRange) + dataMin = opts.DataRange(1); + dataMax = opts.DataRange(2); + else + dataMin = min([s.baseline(1), s.taskStart]) - 2; + dataMax = taskEnd + 2; + end + + % Resolve bar bin size + taskDuration = taskEnd - s.taskStart; + barBin = s.barBinSize; + if barBin <= 0 + barBin = taskDuration; + end + + % Axis padding + pad = (dataMax - dataMin) * 0.1; + xMin = dataMin - pad; + xMax = dataMax + pad; + + % --- Create figure --- + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + ax = axes('Parent', fig); + hold(ax, 'on'); + + % Row positions (bottom to top) + yBar = 0.15; + yTemporal = 0.35; + yBaseline = 0.55; + yTask = 0.75; + + barH = 0.04; % half-height of bracket bars + sigAmp = 0.04; % amplitude of square wave signals + lwBracket = 3; + lwSig = 2; + + sty = pf2_base.plot.PlotStyle.getDefault(); + + % Colors + cTask = sty.ForegroundColor; + cBaseline = [0.85 0.2 0.2]; + cTemporal = [0.2 0.7 0.3]; + cBar = [0.55 0.15 0.7]; + cDim = sty.DimColor; + + % --- Task block bracket --- + drawBracket(ax, [s.taskStart, taskEnd], yTask, barH, lwBracket, cTask); + text(ax, mean([s.taskStart, taskEnd]), yTask + barH + 0.03, ... + sprintf('Task [%.1f, %.1f]s', s.taskStart, taskEnd), ... + 'HorizontalAlignment', 'center', 'FontSize', 9, ... + 'FontWeight', 'bold', 'Color', cTask); + + % --- Baseline bracket --- + if s.useBaseline + drawBracket(ax, s.baseline, yBaseline, barH, lwBracket, cBaseline); + text(ax, mean(s.baseline), yBaseline + barH + 0.03, ... + sprintf('Baseline [%.1f, %.1f]s', s.baseline(1), s.baseline(2)), ... + 'HorizontalAlignment', 'center', 'FontSize', 9, ... + 'FontWeight', 'bold', 'Color', cBaseline); + end + + % --- Temporal resample (square wave) --- + if s.resampleRate > 0 + % Dim: outside task + drawSquareWave(ax, xMin, xMax, s.taskStart, yTemporal, ... + sigAmp, s.resampleRate, lwSig * 0.4, cDim); + % Active: within task + drawSquareWave(ax, s.taskStart, taskEnd, s.taskStart, yTemporal, ... + sigAmp, s.resampleRate, lwSig, cTemporal); + text(ax, xMax, yTemporal, ... + sprintf(' Temporal (%.2fs)', s.resampleRate), ... + 'FontSize', 8, 'Color', cTemporal, ... + 'VerticalAlignment', 'middle'); + end + + % --- Bar resample (square wave, dashed outside) --- + if barBin > 0 + % Dim: outside task + drawSquareWave(ax, xMin, xMax, s.taskStart, yBar, ... + sigAmp, barBin, lwSig * 0.4, cDim); + % Active: within task + drawSquareWave(ax, s.taskStart, taskEnd, s.taskStart, yBar, ... + sigAmp, barBin, lwSig, cBar); + if s.barBinSize <= 0 + binLabel = sprintf(' Bar (full window: %.1fs)', barBin); + else + binLabel = sprintf(' Bar (%.1fs bins)', barBin); + end + text(ax, xMax, yBar, binLabel, ... + 'FontSize', 8, 'Color', cBar, ... + 'VerticalAlignment', 'middle'); + end + + % --- Vertical reference lines at task boundaries --- + for xv = [s.taskStart, taskEnd] + plot(ax, [xv xv], [0 1], '--', 'Color', [sty.GridColor 0.4], ... + 'LineWidth', 0.8, 'HandleVisibility', 'off'); + end + + % --- Vertical lines at baseline boundaries --- + if s.useBaseline + for xv = s.baseline + plot(ax, [xv xv], [0 1], '--', 'Color', [cBaseline 0.3], ... + 'LineWidth', 0.8, 'HandleVisibility', 'off'); + end + end + + % --- Zero line --- + plot(ax, [0 0], [0 1], '-', 'Color', [sty.ZeroLineColor 0.3], ... + 'LineWidth', 1.2, 'HandleVisibility', 'off'); + text(ax, 0, 0.95, ' t=0', 'FontSize', 8, 'Color', sty.DimColor); + + % --- Formatting --- + xlim(ax, [xMin, xMax]); + ylim(ax, [0, 1]); + xlabel(ax, 'Time (s)'); + set(ax, 'YTick', []); + set(ax, 'Box', 'on'); + title(ax, pf2_base.plot.escapeTeX(opts.Title)); + + pf2_base.plot.PlotStyle.getDefault().applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); +end + + +%% Local helpers + +function drawBracket(ax, points, y, halfH, lw, color) +% Draw an I-beam bracket: |------| + left = min(points); + right = max(points); + % Horizontal bar + plot(ax, [left, right], [y, y], '-', 'Color', color, ... + 'LineWidth', lw, 'HandleVisibility', 'off'); + % Left end cap + plot(ax, [left, left], [y - halfH, y + halfH], '-', 'Color', color, ... + 'LineWidth', lw, 'HandleVisibility', 'off'); + % Right end cap + plot(ax, [right, right], [y - halfH, y + halfH], '-', 'Color', color, ... + 'LineWidth', lw, 'HandleVisibility', 'off'); +end + + +function drawSquareWave(ax, startT, endT, alignT, y, amp, binSize, lw, color) +% Draw a square wave signal between startT and endT, aligned to alignT + if binSize <= 0 || startT >= endT + return; + end + + % Compute first sample point aligned to alignT + firstSample = alignT + floor((startT - alignT) / binSize) * binSize; + if firstSample < startT + firstSample = firstSample + binSize; + end + + nPts = ceil((endT - firstSample) / binSize) + 1; + if nPts < 1 + return; + end + + xSamples = firstSample + (0:nPts-1) * binSize; + xSamples = xSamples(xSamples <= endT + binSize * 0.01); + + % Build square wave: alternate high/low + alignIdx = find(abs(xSamples - alignT) < binSize * 0.001, 1); + if isempty(alignIdx) + offset = 0; + else + offset = mod(alignIdx + 1, 2); + end + + nS = length(xSamples); + yVals = y - amp/2 + amp * (mod((1:nS) + offset, 2) == 0); + + % Duplicate points for step plot + yStep = repelem(yVals, 2); + if ~isempty(yStep) + yStep(1) = []; + yStep(end+1) = yStep(1); + end + xStep = repelem(xSamples, 2); + + plot(ax, xStep, yStep, '-', 'Color', color, 'LineWidth', lw, ... + 'HandleVisibility', 'off'); +end diff --git a/+exploreFNIRS/+core/plotHeatmap.m b/+exploreFNIRS/+core/plotHeatmap.m new file mode 100644 index 00000000..d81be4e9 --- /dev/null +++ b/+exploreFNIRS/+core/plotHeatmap.m @@ -0,0 +1,343 @@ +function fig = plotHeatmap(groups, varargin) +% PLOTHEATMAP Channel x time heatmap for grouped fNIRS data +% +% Renders a channel-by-time heatmap showing biomarker amplitude as color, +% with channels on Y-axis and time on X-axis. Useful for identifying +% spatial-temporal activation patterns. +% +% Syntax: +% fig = exploreFNIRS.core.plotHeatmap(groups) +% fig = exploreFNIRS.core.plotHeatmap(groups, 'Biomarker', 'HbR') +% fig = exploreFNIRS.core.plotHeatmap(groups, 'SortChannels', 'amplitude') +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% +% Name-Value Parameters: +% Biomarker - Biomarker to plot (default: 'HbO') +% Channels - Channel indices to include (default: all) +% Device - pf2.Device object for short-sep detection (default: []) +% ExcludeShortSeparation - Exclude short-sep channels (default: true) +% GroupIndex - Which group to plot (default: 1) +% SortChannels - Channel ordering: 'index' (default), 'amplitude' +% Colormap - Colormap name or [N x 3] matrix (default: blue-white-red) +% Supports MATLAB builtins, Brewer (e.g. 'RdBu', 'Spectral'), +% and matplotlib (e.g. 'viridis', 'plasma') names. +% CLim - Color limits [cmin cmax] (default: auto symmetric) +% XLim - [min max] x-axis (time) limits for visual cropping +% (default: full data range) +% VLines - Vertical annotation lines (e.g. task start/end). +% Numeric vector of time positions (default dashed gray), or +% struct array with fields: +% .time - (required) scalar time position +% .label - (optional) text label string +% .color - (optional) color spec (default: [0.5 0.5 0.5]) +% .style - (optional) line style (default: '--') +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 800) +% SaveHeight - Height in pixels (default: 500) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.core.plotTopo, exploreFNIRS.core.plotTemporal + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addParameter(p, 'Biomarker', 'HbO', @ischar); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'ROIs', [], @(x) isnumeric(x) || islogical(x) || ischar(x) || isstring(x) || iscell(x)); + addParameter(p, 'Device', [], @(v) isempty(v) || isa(v, 'pf2.Device')); + addParameter(p, 'ExcludeShortSeparation', true, @islogical); + addParameter(p, 'GroupIndex', 1, @(v) isnumeric(v) && isscalar(v)); + addParameter(p, 'SortChannels', 'index', @ischar); + addParameter(p, 'Colormap', '', @(v) ischar(v) || isnumeric(v)); + addParameter(p, 'CLim', [], @(v) isempty(v) || (isnumeric(v) && numel(v) == 2)); + addParameter(p, 'XLim', [], @(v) isempty(v) || (isnumeric(v) && numel(v) == 2)); + addParameter(p, 'VLines', [], @(x) isempty(x) || isnumeric(x) || isstruct(x)); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 800, @isnumeric); + addParameter(p, 'SaveHeight', 500, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colors', [], @(x) true); % Accepted for API consistency, unused (heatmaps use Colormap) + parse(p, groups, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + bioM = opts.Biomarker; + gi = opts.GroupIndex; + + if gi > length(groups) + error('exploreFNIRS:core:plotHeatmap', ... + 'GroupIndex %d exceeds number of groups (%d)', gi, length(groups)); + end + + ga = groups(gi).gbyGrand; + if isempty(ga) + error('exploreFNIRS:core:plotHeatmap', ... + 'Group %d has no grand average. Call aggregate() first.', gi); + end + + % Resolve ROIs vs Channels + useROI = ~isempty(opts.ROIs); + if useROI + if ~isempty(opts.Channels) + error('exploreFNIRS:core:plotHeatmap', ... + 'ROIs and Channels are mutually exclusive.'); + end + if ~isfield(ga, 'ROI') + error('exploreFNIRS:core:plotHeatmap', ... + 'No ROI data in grand average. Define ROIs before aggregating.'); + end + [roiIdx, roiNames] = resolveROIs(groups(gi:gi), opts.ROIs); + if ~isfield(ga.ROI, bioM) || isempty(ga.ROI.(bioM)) + error('exploreFNIRS:core:plotHeatmap', ... + 'Biomarker "%s" not found in ROI data', bioM); + end + timeVec = ga.time; + meanData = ga.ROI.(bioM).Mean; % [T x nROI] + channels = roiIdx; + chLabelsCustom = roiNames; + else + if ~isfield(ga, bioM) || isempty(ga.(bioM)) + error('exploreFNIRS:core:plotHeatmap', ... + 'Biomarker "%s" not found in group %d', bioM, gi); + end + timeVec = ga.time; + meanData = ga.(bioM).Mean; % [T x C] + if isempty(opts.Channels) + channels = 1:size(meanData, 2); + else + channels = opts.Channels(opts.Channels <= size(meanData, 2)); + end + % Exclude short-separation channels + if opts.ExcludeShortSeparation + ssIdx = getShortSeparationIdx(opts.Device, groups); + if ~isempty(ssIdx) + channels = channels(~ismember(channels, ssIdx)); + end + end + chLabelsCustom = {}; + end + nCh = length(channels); + + plotData = meanData(:, channels)'; % [C x T] + + % Sort channels + switch lower(opts.SortChannels) + case 'amplitude' + chMean = mean(plotData, 2, 'omitnan'); + [~, sortIdx] = sort(chMean, 'descend'); + plotData = plotData(sortIdx, :); + channels = channels(sortIdx); + if ~isempty(chLabelsCustom) + chLabelsCustom = chLabelsCustom(sortIdx); + end + case 'index' + % keep as-is + end + + % CLim + if isempty(opts.CLim) + maxAbs = max(abs(plotData(:))); + if maxAbs > 0 + cLim = [-maxAbs, maxAbs]; + else + cLim = [-1, 1]; + end + else + cLim = opts.CLim; + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + ax = axes('Parent', fig); + + imagesc(ax, timeVec, 1:nCh, plotData, cLim); + set(ax, 'YDir', 'normal'); + + % Channel/ROI labels + if ~isempty(chLabelsCustom) + chLabels = chLabelsCustom; + else + chLabels = arrayfun(@(c) sprintf('Ch%d', c), channels, 'UniformOutput', false); + end + chLabels = pf2_base.plot.escapeTeX(chLabels); + if nCh <= 30 + set(ax, 'YTick', 1:nCh, 'YTickLabel', chLabels); + else + tickStep = ceil(nCh / 20); + ticks = 1:tickStep:nCh; + set(ax, 'YTick', ticks, 'YTickLabel', chLabels(ticks)); + end + + xlabel(ax, 'Time (s)'); + if useROI + ylabel(ax, 'ROI'); + else + ylabel(ax, 'Channel'); + end + + % Colormap + if isempty(opts.Colormap) + colormap(ax, divergingColormap(256)); + elseif ischar(opts.Colormap) + colormap(ax, resolveColormapName(opts.Colormap, 256)); + else + colormap(ax, opts.Colormap); + end + cb = colorbar(ax); + cb.Label.String = bioM; + + sty = pf2_base.plot.PlotStyle.getDefault(); + sty.applyToAxes(ax); + + % Title + if ~isempty(opts.Title) + title(ax, opts.Title); + else + title(ax, sprintf('%s Heatmap - %s', bioM, pf2_base.plot.escapeTeX(groups(gi).label))); + end + + % Colorbar styling + set(cb, 'Color', sty.ForegroundColor); + set(cb.Label, 'Color', sty.ForegroundColor); + + % Visual x-axis cropping (does not change underlying data) + if ~isempty(opts.XLim) + xlim(ax, opts.XLim); + end + + % Vertical annotation lines (task start/end etc.) + if ~isempty(opts.VLines) + drawVLines(ax, opts.VLines); + end + + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); +end + + +function drawVLines(ax, vlines) +% Draw vertical annotation lines on heatmap axes + if isnumeric(vlines) + vlines = vlines(:); + tmp = struct('time', num2cell(vlines), ... + 'label', repmat({''}, numel(vlines), 1), ... + 'color', repmat({[0.5 0.5 0.5]}, numel(vlines), 1), ... + 'style', repmat({'--'}, numel(vlines), 1)); + vlines = tmp; + end + + for vi = 1:numel(vlines) + v = vlines(vi); + xPos = v.time; + + if isfield(v, 'color') && ~isempty(v.color) + clr = v.color; + else + clr = [0.5 0.5 0.5]; + end + + if isfield(v, 'style') && ~isempty(v.style) + sty = v.style; + else + sty = '--'; + end + + if isfield(v, 'label') && ~isempty(v.label) + lbl = {v.label}; + else + lbl = {}; + end + + hasLabel = ~isempty(lbl); + lineArgs = {'Color', clr, 'LineStyle', sty, 'LineWidth', 1}; + pf2_base.external.vline(ax, xPos, lineArgs, lbl, ... + 'handleVisibility', hasLabel); + end +end + + +function cmap = divergingColormap(n) +% Blue-white-red diverging colormap + half = floor(n / 2); + r1 = linspace(0.2, 1, half)'; + g1 = linspace(0.3, 1, half)'; + b1 = linspace(0.8, 1, half)'; + r2 = linspace(1, 0.8, n - half)'; + g2 = linspace(1, 0.2, n - half)'; + b2 = linspace(1, 0.2, n - half)'; + cmap = [r1 g1 b1; r2 g2 b2]; +end + + +function cmap = resolveColormapName(name, n) +% Resolve a colormap name to an [n x 3] RGB matrix via getColormap + cmapFn = exploreFNIRS.helper.getColormap(name); + cmap = cmapFn(n); +end + + +function [roiIdx, roiNames] = resolveROIs(groups, rois) +% Convert ROI input to numeric indices and name strings + roiInfo = groups(1).gbyGrand.ROI.info; + allNames = roiInfo.Properties.RowNames; + + if ischar(rois) || isstring(rois) + if strcmpi(rois, 'all') + roiIdx = 1:length(allNames); + else + roiIdx = find(ismember(allNames, {char(rois)})); + end + elseif iscell(rois) + roiIdx = find(ismember(allNames, rois)); + elseif islogical(rois) + roiIdx = find(rois); + else + roiIdx = rois; % numeric + end + + roiIdx = roiIdx(roiIdx <= length(allNames)); + roiNames = allNames(roiIdx); +end + + +function ssIdx = getShortSeparationIdx(dev, groups) +% Get short-separation channel indices from Device or probe info + ssIdx = []; + + % Method 1: Device object + if ~isempty(dev) && isa(dev, 'pf2.Device') + ssMask = dev.isShortSep(); + ssIdx = find(ssMask); + return; + end + + % Method 2: Probe info in group data + for g = 1:length(groups) + ga = groups(g).gbyGrand; + if isfield(ga, 'probeInfo') && isstruct(ga.probeInfo) + pi = ga.probeInfo; + if isfield(pi, 'TableOpt') && istable(pi.TableOpt) ... + && ismember('IsShortSeparation', pi.TableOpt.Properties.VariableNames) + ssIdx = find(pi.TableOpt.IsShortSeparation); + return; + end + if isfield(pi, 'SD') && isstruct(pi.SD) && isfield(pi.SD, 'distances') + ssIdx = find(pi.SD.distances < 2); + return; + end + end + end +end diff --git a/+exploreFNIRS/+core/plotInfoLME.m b/+exploreFNIRS/+core/plotInfoLME.m new file mode 100644 index 00000000..eff56578 --- /dev/null +++ b/+exploreFNIRS/+core/plotInfoLME.m @@ -0,0 +1,194 @@ +function [fig, results] = plotInfoLME(dataTable, infoVar, groupByVars, varargin) +% PLOTINFOLME Linear Mixed Effects analysis for info/behavioral variables +% +% Fits a single LME model using an info variable as the response and +% groupby variables as fixed effects. Renders a bar chart of F-statistics +% per ANOVA term. Unlike plotLME, this fits one model (no channel +% iteration) since the response is a scalar info variable per observation. +% +% Delegates model fitting to exploreFNIRS.stats.fitInfoLME and adds +% visualization on top. +% +% Syntax: +% [fig, results] = plotInfoLME(dataTable, 'reactionTime', {'Condition'}) +% [fig, results] = plotInfoLME(dataTable, 'accuracy', {'Group','Condition'}) +% [fig, results] = plotInfoLME(dataTable, 'score', {'Group'}, ... +% 'AllInteractions', true, 'SavePath', 'info_lme.png') +% +% Inputs: +% dataTable - Table from Experiment.getSelectedTable() (one row per segment) +% infoVar - Response variable name (must be numeric column in dataTable) +% groupByVars - Cell array of fixed-effect variable names +% +% Name-Value Parameters: +% RandomEffects - Random effects formula (default: '1|SubjectID') +% UseIntercept - Include intercept (default: true) +% AllInteractions - Use full interaction model (default: false) +% InfoCovariate - Additional numeric covariate (default: '') +% CustomFormula - Override auto-built formula (default: '') +% ShowBar - Show bar chart visualization (default: true) +% SigThreshold - Significance threshold (default: 0.05) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 600) +% SaveHeight - Height in pixels (default: 400) +% SaveDPI - Resolution (default: 150) +% +% Layout: +% Single row of bars: one bar per ANOVA term showing F-statistics. +% Significant terms (p < SigThreshold) are marked with *. +% +% Outputs: +% fig - Figure handle (empty if ShowBar=false) +% results - Struct from fitInfoLME with: +% .model - LinearMixedModel object +% .models - {1x1} cell (for pipeline compatibility) +% .anova - {1x1} cell of ANOVA table +% .anova_pval - Table of ANOVA p-values +% .anova_Fstat - Table of ANOVA F-statistics +% .contrasts - {1x1} cell of contrast table +% .AIC - Scalar AIC value +% .formula - Formula string used +% .mergedTable - The dataTable used for fitting +% .responseVar - infoVar +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group', 'Condition'}); +% [fig, results] = ex.plotInfoLME('reactionTime'); +% +% See also: exploreFNIRS.stats.fitInfoLME, exploreFNIRS.core.plotLME, +% exploreFNIRS.core.Experiment + + p = inputParser; + addRequired(p, 'dataTable', @istable); + addRequired(p, 'infoVar', @ischar); + addRequired(p, 'groupByVars', @iscell); + addParameter(p, 'RandomEffects', '1|SubjectID', @ischar); + addParameter(p, 'UseIntercept', true, @islogical); + addParameter(p, 'AllInteractions', false, @islogical); + addParameter(p, 'InfoCovariate', '', @ischar); + addParameter(p, 'CustomFormula', '', @ischar); + addParameter(p, 'ShowBar', true, @islogical); + addParameter(p, 'SigThreshold', 0.05, @isnumeric); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colors', [], @(x) true); % Accepted for API consistency, unused + parse(p, dataTable, infoVar, groupByVars, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + % Delegate model fitting to stats module + statsArgs = { ... + 'RandomEffects', opts.RandomEffects, ... + 'UseIntercept', opts.UseIntercept, ... + 'AllInteractions', opts.AllInteractions, ... + 'InfoCovariate', opts.InfoCovariate, ... + 'CustomFormula', opts.CustomFormula}; + results = exploreFNIRS.stats.fitInfoLME(dataTable, infoVar, ... + groupByVars, statsArgs{:}); + + fig = []; + + if ~opts.ShowBar + return; + end + + % Extract ANOVA terms + anv = results.anova{1, 1}; + if isempty(anv) + return; + end + + termNames = anv.Term; + fVals = anv.FStat; + pVals = anv.pValue; + nTerms = length(termNames); + + sty = pf2_base.plot.PlotStyle.getDefault(); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + + ax = axes('Parent', fig); + hold(ax, 'on'); + + % Bar chart of F-values per term + colors = getTermColors(nTerms); + for t = 1:nTerms + bar(ax, t, fVals(t), 0.6, ... + 'FaceColor', colors(t,:), 'EdgeColor', 'k', ... + 'FaceAlpha', 0.7); + end + + % Mark significant terms + for t = 1:nTerms + if ~isnan(pVals(t)) && pVals(t) < opts.SigThreshold + text(ax, t, fVals(t), sprintf('*\np=%.3f', pVals(t)), ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'bottom', ... + 'FontSize', 12, 'FontWeight', 'bold', 'Color', 'r'); + else + text(ax, t, fVals(t), sprintf('\np=%.3f', pVals(t)), ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'bottom', ... + 'FontSize', 9, 'Color', sty.DimColor); + end + end + + % Clean term labels + cleanLabels = pf2_base.plot.escapeTeX(termNames); + set(ax, 'XTick', 1:nTerms, 'XTickLabel', cleanLabels); + if nTerms > 3 + set(ax, 'XTickLabelRotation', 30); + end + + ylabel(ax, 'F-statistic'); + xlabel(ax, 'ANOVA Term'); + grid(ax, 'on'); + box(ax, 'on'); + + % Title + if ~isempty(opts.Title) + pf2_base.external.suptitle(fig, pf2_base.plot.escapeTeX(opts.Title)); + else + formulaStr = regexprep(results.formula, '^[^~]+~', ... + [infoVar ' ~ ']); + formulaStr = strrep(formulaStr, '+', ' + '); + formulaStr = regexprep(formulaStr, '\s+', ' '); + pf2_base.external.suptitle(fig, pf2_base.plot.escapeTeX(formulaStr)); + end + + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); +end + + +function colors = getTermColors(nTerms) +% Distinct colors for ANOVA terms + + palette = [ + 0.3 0.5 0.7 % blue-grey + 0.8 0.4 0.3 % warm red + 0.4 0.7 0.5 % green + 0.7 0.5 0.7 % purple + 0.8 0.7 0.3 % gold + 0.5 0.6 0.8 % light blue + ]; + + if nTerms <= size(palette, 1) + colors = palette(1:nTerms, :); + else + colors = lines(nTerms); + end +end diff --git a/+exploreFNIRS/+core/plotLME.m b/+exploreFNIRS/+core/plotLME.m new file mode 100644 index 00000000..84b43d8f --- /dev/null +++ b/+exploreFNIRS/+core/plotLME.m @@ -0,0 +1,524 @@ +function [fig, results] = plotLME(groups, groupByVars, varargin) +% PLOTLME Linear Mixed Effects analysis for grouped fNIRS data +% +% Fits LME models per channel and biomarker using groupby variables as +% fixed effects. Returns fitted models, ANOVA tables, auto-generated +% contrasts, and renders bar charts of F-statistics per channel. Each +% biomarker gets its own row of subplots — biomarkers are never combined. +% +% Delegates model fitting to exploreFNIRS.stats.fitLME and adds +% visualization on top. Supports fNIRS, ROI, and Aux data types. +% +% Syntax: +% [fig, results] = plotLME(groups, groupByVars) +% [fig, results] = plotLME(groups, groupByVars, 'Biomarkers', {'HbO'}) +% [fig, results] = plotLME(groups, groupByVars, 'ShowTopo', true) +% [fig, results] = plotLME(groups, groupByVars, 'DataType', 'Aux', ... +% 'AuxField', 'heartRate') +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% groupByVars - Cell array of grouping variable names used in groupby() +% +% Name-Value Parameters: +% Biomarkers - Cell array (default: {'HbO','HbR','HbTotal','CBSI'}) +% Biomarkers not found in data are silently skipped. +% Ignored when DataType='Aux'. +% Channels - Vector of channel indices (default: all) +% ROIs - ROI indices, names, or 'all' (default: []) +% Mutually exclusive with Channels. Automatically sets +% DataType to 'ROI'. +% AuxField - Aux field name (required when DataType='Aux') +% RandomEffects - Random effects formula (default: '1|SubjectID') +% UseIntercept - Include intercept (default: true) +% AllInteractions - Use full interaction model (default: false) +% InfoCovariate - Info variable as covariate (default: '') +% CustomFormula - Override auto-built formula (default: '') +% ShowBar - Show bar chart visualization (default: true) +% ShowTopo - Show ANOVA F-stat topo map (default: false) +% Not available for Aux data type. +% SigThreshold - Significance threshold (default: 0.05) +% SigType - 'p' (default), 'q', 'q-twostep' +% ErrorType - 'SEM' (default), 'SD', 'none' +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 800) +% SaveHeight - Height in pixels (default: 500) +% SaveDPI - Resolution (default: 150) +% Colors - Bar color palette override (default: [] = biomarker palette) +% [N x 3] RGB matrix, colormap name (e.g. 'Set1'), +% or function handle @(N) returning [N x 3]. +% +% Layout: +% rows = biomarkers (or 1 row for Aux), columns = ANOVA terms +% Each subplot shows F-statistics across channels as a bar chart. +% Significant channels (p < SigThreshold) are marked with *. +% +% Outputs: +% fig - Figure handle (empty if no visualization) +% results - Struct with: +% .models - Cell array of LinearMixedModel objects [nBio x nCh] +% .anova - Cell array of ANOVA tables [nBio x nCh] +% .anova_pval - Table of ANOVA p-values [channels x terms] +% .anova_Fstat - Table of ANOVA F-statistics [channels x terms] +% .coefficients - Cell array of random effects per channel +% .contrasts - Cell array of auto-generated contrast tables +% .AIC - Matrix of AIC values [nBio x nCh] +% .formula - The formula string used +% .mergedTable - Long-format merged data table +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group', 'Condition'}); +% ex.aggregate(); +% +% % Default: all 4 biomarkers +% [fig, results] = ex.plotLME(); +% disp(results.anova_pval); +% +% % Specific biomarkers and channels +% [fig, results] = ex.plotLME('Biomarkers', {'HbO','HbR'}, 'Channels', 1:5); +% +% % Auxiliary data +% [fig, results] = ex.plotAuxLME('heartRate'); +% +% See also: exploreFNIRS.stats.fitLME, exploreFNIRS.core.Experiment, +% exploreFNIRS.fx.autoContrast, fitlme, anova + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addRequired(p, 'groupByVars', @iscell); + addParameter(p, 'Biomarkers', {'HbO','HbR','HbTotal','CBSI'}, @iscell); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'ROIs', [], @(x) isnumeric(x) || islogical(x) || ischar(x) || isstring(x) || iscell(x)); + addParameter(p, 'AuxField', '', @ischar); + addParameter(p, 'RandomEffects', '1|SubjectID', @ischar); + addParameter(p, 'UseIntercept', true, @islogical); + addParameter(p, 'AllInteractions', false, @islogical); + addParameter(p, 'InfoCovariate', '', @ischar); + addParameter(p, 'CustomFormula', '', @ischar); + addParameter(p, 'ShowBar', true, @islogical); + addParameter(p, 'ShowTopo', false, @islogical); + addParameter(p, 'SigThreshold', 0.05, @isnumeric); + addParameter(p, 'SigType', 'p', @ischar); + addParameter(p, 'ErrorType', 'SEM', @ischar); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 800, @isnumeric); + addParameter(p, 'SaveHeight', 500, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'ExcludeShortSeparation', true, @islogical); + addParameter(p, 'Device', [], @(x) isempty(x) || isa(x, 'pf2.Device') || ischar(x) || isstring(x)); + addParameter(p, 'DataType', 'fNIRS', @ischar); + addParameter(p, 'SkipTimeFactor', false, @islogical); + addParameter(p, 'TimeModel', '', @ischar); + addParameter(p, 'PolynomialOrder', 2, @(x) isnumeric(x) && isscalar(x) && x >= 1 && x <= 5); + addParameter(p, 'StatWindow', [], @isnumeric); + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle') || isa(x, 'exploreFNIRS.core.ColorScheme')); + parse(p, groups, groupByVars, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + % ROIs parameter: resolve to channel indices and switch to ROI mode + if ~isempty(opts.ROIs) + if ~isempty(opts.Channels) + error('exploreFNIRS:core:plotLME', ... + 'ROIs and Channels are mutually exclusive.'); + end + if ~isfield(groups(1).gbyGrand, 'ROI') + error('exploreFNIRS:core:plotLME', ... + 'No ROI data in grand average. Define ROIs before aggregating.'); + end + [roiIdx, roiNames] = resolveROIs(groups, opts.ROIs); + opts.Channels = roiIdx; + opts.ROINames = roiNames; + opts.DataType = 'ROI'; + end + + isROIMode = strcmpi(opts.DataType, 'ROI'); + isAuxMode = strcmpi(opts.DataType, 'Aux'); + + % Resolve ROI names when DataType='ROI' set directly (without ROIs param) + if isROIMode && ~isfield(opts, 'ROINames') + if isfield(groups(1).gbyGrand, 'ROI') && isfield(groups(1).gbyGrand.ROI, 'info') + allNames = groups(1).gbyGrand.ROI.info.Properties.RowNames; + roiCh = opts.Channels; + if isempty(roiCh) + roiCh = 1:length(allNames); + end + opts.ROINames = allNames(roiCh(roiCh <= length(allNames))); + else + opts.ROINames = {}; + end + end + + % Aux mode: topo not available (no probe geometry) + if isAuxMode && opts.ShowTopo + warning('exploreFNIRS:core:plotLME', ... + 'ShowTopo not available for Aux data. Using ShowBar instead.'); + opts.ShowTopo = false; + opts.ShowBar = true; + end + + ga = groups(1).gbyGrandBarFlat; + + if isAuxMode + % Aux mode: validate aux field exists + if isempty(opts.AuxField) + error('exploreFNIRS:core:plotLME', ... + 'AuxField is required when DataType is ''Aux'''); + end + if ~isfield(ga, 'Aux') || ~isstruct(ga.Aux) + error('exploreFNIRS:core:plotLME', ... + 'No Aux data in grand average.'); + end + % Check field exists (with _data suffix fallback) + af = opts.AuxField; + if ~isfield(ga.Aux, af) && ~isfield(ga.Aux, [af '_data']) + error('exploreFNIRS:core:plotLME', ... + 'Aux field "%s" not found in grand average data.', af); + end + % For Aux, biomarkers list is just the aux field name (1 row) + opts.Biomarkers = {opts.AuxField}; + else + % Filter biomarkers to those that exist in the data + validBio = {}; + for i = 1:length(opts.Biomarkers) + if isROIMode + if pf2_base.isnestedfield(ga, ['ROI.' opts.Biomarkers{i}]) + validBio{end+1} = opts.Biomarkers{i}; %#ok + end + else + if isfield(ga, opts.Biomarkers{i}) && ~isempty(ga.(opts.Biomarkers{i})) + validBio{end+1} = opts.Biomarkers{i}; %#ok + end + end + end + if isempty(validBio) + error('exploreFNIRS:core:plotLME', ... + 'None of the requested biomarkers found in data.'); + end + opts.Biomarkers = validBio; + end + + % Delegate model fitting to stats module + statsArgs = { ... + 'Biomarkers', opts.Biomarkers, ... + 'Channels', opts.Channels, ... + 'RandomEffects', opts.RandomEffects, ... + 'UseIntercept', opts.UseIntercept, ... + 'AllInteractions', opts.AllInteractions, ... + 'InfoCovariate', opts.InfoCovariate, ... + 'CustomFormula', opts.CustomFormula, ... + 'ExcludeShortSeparation', opts.ExcludeShortSeparation, ... + 'SkipTimeFactor', opts.SkipTimeFactor, ... + 'DataType', opts.DataType}; + if ~isempty(opts.TimeModel) + statsArgs = [statsArgs, {'TimeModel', opts.TimeModel}]; + end + if opts.PolynomialOrder ~= 2 + statsArgs = [statsArgs, {'PolynomialOrder', opts.PolynomialOrder}]; + end + if isAuxMode + statsArgs = [statsArgs, {'AuxField', opts.AuxField}]; + end + if ~isempty(opts.StatWindow) + statsArgs = [statsArgs, {'StatWindow', opts.StatWindow}]; + end + results = exploreFNIRS.stats.fitLME(groups, groupByVars, statsArgs{:}); + + channels = results.channels; + nCh = length(channels); + nBioM = length(opts.Biomarkers); + + fig = []; + + % Visualization + if ~(opts.ShowBar || opts.ShowTopo) + return; + end + + sty = pf2_base.plot.PlotStyle.getDefault(); + + if opts.ShowBar + fig = plotBarSummary(results, opts, channels, nBioM, nCh, sty); + elseif opts.ShowTopo + fig = plotTopoFstat(results, opts, channels, nBioM, nCh, sty); + end + + % Title: show full model formula (matching plotTopoLME) + if ~isempty(fig) + if ~isempty(opts.Title) + pf2_base.external.suptitle(fig, opts.Title); + else + formulaStr = regexprep(results.formula, '^[^~]+~', 'biom ~ '); + formulaStr = strrep(formulaStr, '+', ' + '); + formulaStr = regexprep(formulaStr, '\s+', ' '); + pf2_base.external.suptitle(fig, pf2_base.plot.escapeTeX(formulaStr)); + end + + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); + end +end + + +%% Local helpers + + +function fig = plotBarSummary(results, opts, channels, nBioM, nCh, sty) +% Render bar chart: rows = biomarkers, columns = ANOVA terms +% Each subplot shows F-statistics across channels + + % Extract ANOVA terms from the first fitted model (exclude Intercept) + termNames = getTermNames(results, nBioM, nCh); + if isempty(termNames) + fig = []; + return; + end + + nTerms = length(termNames); + + % Layout: rows = biomarkers, cols = ANOVA terms + nRows = nBioM; + nCols = nTerms; + + figW = opts.SaveWidth * min(nCols, 4); + figH = opts.SaveHeight * max(nRows * 0.6, 1); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', figW, 'Height', figH, 'SavePath', opts.SavePath); + + if ~isempty(opts.Colors) && ~isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + bioColors = exploreFNIRS.core.getGroupColors(nBioM, opts.Colors); + else + bioColors = getBarColors(nBioM); + end + + for bIdx = 1:nBioM + bioM = opts.Biomarkers{bIdx}; + + % Extract F-stats and p-values for this biomarker across channels + [fMatrix, pMatrix] = extractBiomarkerAnova(results, bIdx, nCh, termNames); + + for t = 1:nTerms + spIdx = (bIdx - 1) * nCols + t; + ax = subplot(nRows, nCols, spIdx, 'Parent', fig); + hold(ax, 'on'); + + fVals = fMatrix(:, t); + pVals = pMatrix(:, t); + + % Bar chart of F-values using actual channel numbers as x + bar(ax, channels, fVals, 0.6, ... + 'FaceColor', bioColors(bIdx,:), 'EdgeColor', 'k', ... + 'FaceAlpha', 0.7); + + % Mark significant channels + for i = 1:nCh + if ~isnan(pVals(i)) && pVals(i) < opts.SigThreshold + text(ax, channels(i), fVals(i), '*', ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'bottom', ... + 'FontSize', 14, 'FontWeight', 'bold', 'Color', 'r'); + end + end + + % Labels + if bIdx == 1 + title(ax, pf2_base.plot.escapeTeX(termNames{t})); + end + if bIdx == nBioM + if strcmpi(opts.DataType, 'ROI') + xlabel(ax, 'ROI'); + elseif strcmpi(opts.DataType, 'Aux') + xlabel(ax, 'Aux Channel'); + else + xlabel(ax, 'Channel'); + end + end + if t == 1 + ylabel(ax, sprintf('%s F-stat', bioM)); + end + + set(ax, 'XTick', channels); + if strcmpi(opts.DataType, 'ROI') && isfield(opts, 'ROINames') && ... + ~isempty(opts.ROINames) && length(opts.ROINames) == nCh + escapedNames = cellfun(@(s) strrep(s, '_', '\_'), opts.ROINames, 'UniformOutput', false); + set(ax, 'XTickLabel', escapedNames, 'XTickLabelRotation', 45); + end + grid(ax, 'on'); + box(ax, 'on'); + end + end +end + + +function fig = plotTopoFstat(results, opts, channels, nBioM, nCh, sty) +% Render topographic F-statistic maps: rows = biomarkers, cols = terms + + termNames = getTermNames(results, nBioM, nCh); + if isempty(termNames) + fig = []; + return; + end + + nTerms = length(termNames); + nRows = nBioM; + nCols = nTerms; + + figW = opts.SaveWidth * min(nCols, 4); + figH = opts.SaveHeight * max(nRows * 0.6, 1); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', figW, 'Height', figH, 'SavePath', opts.SavePath); + + for bIdx = 1:nBioM + bioM = opts.Biomarkers{bIdx}; + [fMatrix, pMatrix] = extractBiomarkerAnova(results, bIdx, nCh, termNames); + + for t = 1:nTerms + spIdx = (bIdx - 1) * nCols + t; + ax = subplot(nRows, nCols, spIdx, 'Parent', fig); + + fVals = fMatrix(:, t)'; + pVals = pMatrix(:, t)'; + + % FDR correction + [curQ, ~] = exploreFNIRS.fx.performFDR(pVals, opts.SigThreshold); + + switch opts.SigType + case 'q' + sigP = curQ; + case 'q-twostep' + sigP = exploreFNIRS.fx.performFDR_twostep(pVals, opts.SigThreshold); + otherwise + sigP = pVals; + end + + sigMask = sigP <= opts.SigThreshold; + + if any(sigMask) + minF = min(fVals(sigMask)); + if ~isempty(which('pf2.probe.plot.interpolateValues3D')) + axes(ax); %#ok + probeArg = []; + if ~isempty(opts.Device) + if isa(opts.Device, 'pf2.Device') + probeArg = opts.Device.name; + else + probeArg = opts.Device; + end + end + pf2.probe.plot.interpolateValues3D(fVals, probeArg, minF, [], ... + sprintf('%s: %s', bioM, pf2_base.plot.escapeTeX(termNames{t})), 'F-val', ... + 'bufferDistance', 1); + else + bar(ax, channels, fVals, 'FaceColor', 'flat'); + set(ax, 'XTick', channels); + ylabel(ax, 'F-stat'); + title(ax, sprintf('%s: %s', bioM, pf2_base.plot.escapeTeX(termNames{t}))); + end + else + text(ax, 0.5, 0.5, sprintf('%s: %s\nn.s.', bioM, pf2_base.plot.escapeTeX(termNames{t})), ... + 'HorizontalAlignment', 'center', 'Units', 'normalized'); + axis(ax, 'off'); + end + end + end + + sigStr = sprintf('Thresholded at %s=%.2f', opts.SigType, opts.SigThreshold); + annotation(fig, 'textbox', [0, 0.97, 0.3, 0.03], 'String', sigStr, ... + 'FitBoxToText', 'on', 'EdgeColor', 'none', 'FontSize', 7, ... + 'Color', sty.DimColor); +end + + +function termNames = getTermNames(results, nBioM, nCh) +% Extract ANOVA term names from the first fitted model (including Intercept) + + termNames = {}; + + for bIdx = 1:nBioM + for chI = 1:nCh + anv = results.anova{bIdx, chI}; + if ~isempty(anv) + termNames = anv.Term; + return; + end + end + end +end + + +function [fMatrix, pMatrix] = extractBiomarkerAnova(results, bIdx, nCh, termNames) +% Extract F-stats and p-values for one biomarker across all channels +% Returns [nCh x nTerms] matrices + + nTerms = length(termNames); + fMatrix = nan(nCh, nTerms); + pMatrix = nan(nCh, nTerms); + + for chI = 1:nCh + anv = results.anova{bIdx, chI}; + if isempty(anv) + continue; + end + + for t = 1:nTerms + tIdx = find(strcmpi(anv.Term, termNames{t}), 1); + if ~isempty(tIdx) + fMatrix(chI, t) = anv.FStat(tIdx); + pMatrix(chI, t) = anv.pValue(tIdx); + end + end + end +end + + +function colors = getBarColors(nBioM) +% Distinct colors for each biomarker row + + palette = [ + 0.2 0.6 0.9 % HbO - blue + 0.9 0.3 0.3 % HbR - red + 0.5 0.7 0.4 % HbTotal - green + 0.7 0.5 0.8 % CBSI - purple + ]; + + if nBioM <= size(palette, 1) + colors = palette(1:nBioM, :); + else + colors = exploreFNIRS.core.getGroupColors(nBioM); + end +end + + +function [roiIdx, roiNames] = resolveROIs(groups, rois) +% Convert ROI input to numeric indices and name strings + roiInfo = groups(1).gbyGrand.ROI.info; + allNames = roiInfo.Properties.RowNames; + + if ischar(rois) || isstring(rois) + if strcmpi(rois, 'all') + roiIdx = 1:length(allNames); + else + roiIdx = find(ismember(allNames, {char(rois)})); + end + elseif iscell(rois) + roiIdx = find(ismember(allNames, rois)); + elseif islogical(rois) + roiIdx = find(rois); + else + roiIdx = rois; % numeric + end + + roiIdx = roiIdx(roiIdx <= length(allNames)); + roiNames = allNames(roiIdx); +end diff --git a/+exploreFNIRS/+core/plotNeuralEfficiency.m b/+exploreFNIRS/+core/plotNeuralEfficiency.m new file mode 100644 index 00000000..a8309c51 --- /dev/null +++ b/+exploreFNIRS/+core/plotNeuralEfficiency.m @@ -0,0 +1,358 @@ +function [fig, stats, neTable] = plotNeuralEfficiency(groups, varargin) +% PLOTNEURALEFFICIENCY Neural efficiency scatter plot (Experiment groups) +% +% Visualizes the relationship between brain activation (X-axis) and +% behavioral/cognitive performance (Y-axis). Both axes are z-scored so +% the y=x identity line separates "efficient" subjects (high performance +% with low activation — above the line) from "inefficient" subjects +% (high activation relative to performance — below the line). +% +% Default axes: +% X = biomarker (brain activation, averaged across channels) +% Y = InfoVar (behavioral performance) +% Set FlipXY=true to swap them. +% +% This wrapper extracts per-subject data from Experiment groups and +% delegates rendering to plotNeuralEfficiencyCore. +% +% Syntax: +% [fig, stats] = plotNeuralEfficiency(groups, 'InfoVar', 'accuracy') +% [fig, stats] = plotNeuralEfficiency(groups, 'InfoVar', 'RT', ... +% 'Channels', 1:5, 'FitLine', true) +% [fig, stats] = plotNeuralEfficiency(groups, 'InfoVar', 'RT', ... +% 'FlipXY', true, 'ZScoreMode', 'pergroup') +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% Each element must have .gbyGrandBarFlat and .gbyTables +% +% Name-Value Parameters: +% InfoVar - (required) Behavioral/performance variable from info +% Biomarker - Single biomarker string (default: 'HbO') +% Channels - Channel indices to average over (default: all) +% ROIs - Alternative to Channels — use ROI indices/names +% Averaging - 'hierarchy' (default), 'flat', or 'none' +% FlipXY - Swap axes so X=performance, Y=activation (default: false) +% Colors - Group color palette override (default: [] = auto) +% [N x 3] RGB, colormap name, function handle, or +% ColorScheme object +% +% All additional parameters (ZScoreMode, InvertX, ReverseAxes, +% ShowIdentity, ShowLabels, FitLine, ShowArrows, ArrowColor, Legend, +% Title, XLabel, YLabel, Visible, SavePath, SaveWidth, SaveHeight, +% SaveDPI) are passed through to plotNeuralEfficiencyCore. +% +% Outputs: +% fig - Figure handle +% stats - Struct array [nGroups x 1] with per-group statistics: +% .r, .p - Pearson correlation (on z-scored data) +% .rho, .pval - Spearman correlation +% .N - Sample size +% .zX, .zY - Z-scored values per subject +% .NE - Neural efficiency per subject (zY - zX) +% .centroid - [meanX, meanY] of z-scored values +% .meanNE - Mean neural efficiency +% .label - Group label +% neTable - Table with columns: Group, zX, zY, NE +% One row per data point across all groups +% +% Example: +% ex = exploreFNIRS.core.Experiment(allData); +% ex.groupby({'Group'}); +% ex.aggregate(); +% [fig, stats] = ex.plotNeuralEfficiency('accuracy', ... +% 'Channels', 1:5, 'FitLine', true); +% +% References: +% Neubauer & Fink (2009). Intelligence and neural efficiency. +% Neuroscience & Biobehavioral Reviews, 33(7), 1004-1023. +% +% See also: plotNeuralEfficiencyCore, plotNeuralEfficiencyFromTable, +% exploreFNIRS.core.Experiment + + p = inputParser; + p.KeepUnmatched = true; + addRequired(p, 'groups', @isstruct); + addParameter(p, 'InfoVar', '', @ischar); + addParameter(p, 'Biomarker', 'HbO', @ischar); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'ROIs', [], @(x) isnumeric(x) || islogical(x) || ischar(x) || isstring(x) || iscell(x)); + addParameter(p, 'Device', [], @(v) isempty(v) || isa(v, 'pf2.Device')); + addParameter(p, 'ExcludeShortSeparation', true, @islogical); + addParameter(p, 'Averaging', 'hierarchy', @(x) ismember(lower(x), {'hierarchy','flat','none'})); + addParameter(p, 'FlipXY', false, @islogical); + addParameter(p, 'ShowLabels', false, @islogical); + addParameter(p, 'InvertX', false, @islogical); + addParameter(p, 'XLabel', '', @ischar); + addParameter(p, 'YLabel', '', @ischar); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle') || isa(x, 'exploreFNIRS.core.ColorScheme')); + parse(p, groups, varargin{:}); + opts = p.Results; + + if isempty(opts.InfoVar) + error('exploreFNIRS:core:plotNeuralEfficiency', ... + 'InfoVar is required. Specify the behavioral/performance variable.'); + end + + nGroups = length(groups); + bioM = opts.Biomarker; + + % Validate groups + for g = 1:nGroups + if isempty(groups(g).gbyGrandBarFlat) + error('exploreFNIRS:core:plotNeuralEfficiency', ... + 'Group %d has no bar-flat grand average. Call aggregate() first.', g); + end + end + + % Resolve ROIs vs Channels + useROI = ~isempty(opts.ROIs); + if useROI + if ~isempty(opts.Channels) + error('exploreFNIRS:core:plotNeuralEfficiency', ... + 'ROIs and Channels are mutually exclusive.'); + end + if ~isfield(groups(1).gbyGrandBarFlat, 'ROI') + error('exploreFNIRS:core:plotNeuralEfficiency', ... + 'No ROI data in grand average. Define ROIs before aggregating.'); + end + roiIdx = resolveROIs(groups, opts.ROIs); + allChannels = roiIdx; + else + if isempty(opts.Channels) + nCh = size(groups(1).gbyGrandBarFlat.(bioM).data, 2); + allChannels = 1:nCh; + else + allChannels = opts.Channels; + end + % Exclude short-separation channels + if opts.ExcludeShortSeparation + ssIdx = getShortSeparationIdx(opts.Device, groups); + if ~isempty(ssIdx) + allChannels = allChannels(~ismember(allChannels, ssIdx)); + end + end + end + + tIdx = 1; + + % --- Resolve colors --- + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + colors = opts.Colors.resolve(groups); + else + colors = exploreFNIRS.core.getGroupColors(nGroups, opts.Colors); + end + + % --- Build plotGroups struct array --- + pgTemplate = struct('x', [], 'y', [], 'label', '', 'color', [], ... + 'subjectIDs', {{}}, 'arrowChain', 1); + plotGroups = repmat(pgTemplate, 1, nGroups); + + for g = 1:nGroups + curGrand = groups(g).gbyGrandBarFlat; + curTable = groups(g).gbyTables; + + % --- Biomarker values, averaged across selected channels --- + if useROI + bioData = curGrand.ROI.(bioM); + else + bioData = curGrand.(bioM); + end + + % bioData.data is [T x C x N] + chVals = nan(length(allChannels), size(bioData.data, 3)); + for ci = 1:length(allChannels) + ch = allChannels(ci); + if ch <= size(bioData.data, 2) + chVals(ci, :) = permute(bioData.data(tIdx, ch, :), [3, 1, 2]); + end + end + bioVals = mean(chVals, 1, 'omitnan')'; + + % Hierarchical averaging of biomarker + if strcmpi(opts.Averaging, 'hierarchy') && ... + isfield(curGrand, 'info') && isfield(curGrand.info, 'Hierarchy') + [bioVals, ~] = pf2_base.hierarchicalAverage(bioVals, ... + curGrand.info.Hierarchy, @nanmean); + end + + % --- Info variable (performance) --- + if ~ismember(opts.InfoVar, curTable.Properties.VariableNames) + error('exploreFNIRS:core:plotNeuralEfficiency', ... + 'Variable "%s" not found in group %d table.', opts.InfoVar, g); + end + perfData = curTable.(opts.InfoVar); + if ~isnumeric(perfData) + perfData = double(string(perfData)); + end + perfData(perfData == -9999) = NaN; + + % Hierarchical averaging of performance + if strcmpi(opts.Averaging, 'hierarchy') && ... + ismember('SubjectID', curTable.Properties.VariableNames) + [perfVals] = pf2_base.hierarchicalAverage(perfData, ... + curTable(:, 'SubjectID'), @nanmean); + else + perfVals = perfData; + end + + % Align lengths and remove NaN pairs + n = min(length(perfVals), length(bioVals)); + perfVals = perfVals(1:n); + bioVals = bioVals(1:n); + validIdx = ~isnan(perfVals) & ~isnan(bioVals); + perfVals = perfVals(validIdx); + bioVals = bioVals(validIdx); + + % Default: X = biomarker (activation), Y = performance + % FlipXY: X = performance, Y = biomarker + if opts.FlipXY + plotGroups(g).x = perfVals; + plotGroups(g).y = bioVals; + else + plotGroups(g).x = bioVals; + plotGroups(g).y = perfVals; + end + plotGroups(g).label = groups(g).label; + plotGroups(g).color = colors(g, :); + plotGroups(g).arrowChain = 1; + + % Extract SubjectIDs for labels + if opts.ShowLabels && ... + ismember('SubjectID', curTable.Properties.VariableNames) + sids = curTable.SubjectID; + if strcmpi(opts.Averaging, 'hierarchy') + [~, ia] = unique(curTable.SubjectID, 'stable'); + sids = sids(ia); + end + sids = sids(1:n); + sids = sids(validIdx); + if ~iscell(sids) + sids = cellstr(string(sids)); + end + plotGroups(g).subjectIDs = sids; + end + end + + % --- Generate default labels --- + infoLabel = pf2_base.plot.escapeTeX(opts.InfoVar); + if isscalar(allChannels) + bioLabel = sprintf('%s Ch %d', bioM, allChannels(1)); + else + bioLabel = sprintf('%s mean', bioM); + end + + if opts.FlipXY + % Flipped: X = performance, Y = activation + defaultXLabel = sprintf('%s (z-scored)', infoLabel); + defaultYLabel = sprintf('%s (z-scored)', bioLabel); + else + % Default: X = activation, Y = performance + defaultXLabel = sprintf('%s (z-scored)', bioLabel); + defaultYLabel = sprintf('%s (z-scored)', infoLabel); + end + if opts.InvertX + defaultXLabel = [defaultXLabel(1:end-1) ', inverted)']; + end + + if isempty(opts.XLabel), opts.XLabel = defaultXLabel; end + if isempty(opts.YLabel), opts.YLabel = defaultYLabel; end + if isempty(opts.Title) + opts.Title = sprintf('Neural Efficiency: %s vs %s', bioLabel, infoLabel); + end + + % --- Delegate to core --- + if opts.FlipXY + highCorner = 'bottomright'; + else + highCorner = 'topleft'; + end + + passthrough = unmatchedToCell(p.Unmatched); + [fig, stats] = exploreFNIRS.core.plotNeuralEfficiencyCore(plotGroups, ... + 'InvertX', opts.InvertX, ... + 'ShowLabels', opts.ShowLabels, ... + 'HighCorner', highCorner, ... + 'XLabel', opts.XLabel, ... + 'YLabel', opts.YLabel, ... + 'Title', opts.Title, ... + passthrough{:}); + + % --- Build neTable: per-data-point NE values --- + if nargout >= 3 + grpLabels = {}; + allZX = []; + allZY = []; + allNE = []; + for k = 1:length(stats) + n = stats(k).N; + grpLabels = [grpLabels; repmat({stats(k).label}, n, 1)]; %#ok + allZX = [allZX; stats(k).zX]; %#ok + allZY = [allZY; stats(k).zY]; %#ok + allNE = [allNE; stats(k).NE]; %#ok + end + neTable = table(grpLabels, allZX, allZY, allNE, ... + 'VariableNames', {'Group', 'zX', 'zY', 'NE'}); + end +end + + +%% Local helpers + +function args = unmatchedToCell(s) +% Convert inputParser Unmatched struct to name-value cell array + fn = fieldnames(s); + args = cell(1, 2 * numel(fn)); + for i = 1:numel(fn) + args{2*i - 1} = fn{i}; + args{2*i} = s.(fn{i}); + end +end + + +function roiIdx = resolveROIs(groups, rois) +% Convert ROI input to numeric indices + roiInfo = groups(1).gbyGrandBarFlat.ROI.info; + allNames = roiInfo.Properties.RowNames; + + if ischar(rois) || isstring(rois) + if strcmpi(rois, 'all') + roiIdx = 1:length(allNames); + else + roiIdx = find(ismember(allNames, {char(rois)})); + end + elseif iscell(rois) + roiIdx = find(ismember(allNames, rois)); + elseif islogical(rois) + roiIdx = find(rois); + else + roiIdx = rois; + end + roiIdx = roiIdx(roiIdx <= length(allNames)); +end + + +function ssIdx = getShortSeparationIdx(dev, groups) +% Get short-separation channel indices from Device or probe info + ssIdx = []; + if ~isempty(dev) && isa(dev, 'pf2.Device') + ssIdx = find(dev.isShortSep()); + return; + end + for g = 1:length(groups) + ga = groups(g).gbyGrand; + if isfield(ga, 'probeInfo') && isstruct(ga.probeInfo) + pi = ga.probeInfo; + if isfield(pi, 'TableOpt') && istable(pi.TableOpt) ... + && ismember('IsShortSeparation', pi.TableOpt.Properties.VariableNames) + ssIdx = find(pi.TableOpt.IsShortSeparation); + return; + end + if isfield(pi, 'SD') && isstruct(pi.SD) && isfield(pi.SD, 'distances') + ssIdx = find(pi.SD.distances < 2); + return; + end + end + end +end diff --git a/+exploreFNIRS/+core/plotNeuralEfficiencyCore.m b/+exploreFNIRS/+core/plotNeuralEfficiencyCore.m new file mode 100644 index 00000000..79c7da7b --- /dev/null +++ b/+exploreFNIRS/+core/plotNeuralEfficiencyCore.m @@ -0,0 +1,345 @@ +function [fig, stats] = plotNeuralEfficiencyCore(plotGroups, varargin) +% PLOTNEURALEFFICIENCYCORE Core renderer for neural efficiency plots +% +% Internal rendering function used by plotNeuralEfficiency and +% plotNeuralEfficiencyFromTable. Takes a struct array of pre-extracted +% data groups and handles z-scoring, centroid+error-bar rendering, +% optional scatter points, identity line, centroid arrows, regression +% lines, and stat annotations. +% +% By convention, the calling wrapper places activation on X and +% performance on Y (so efficient subjects appear above the identity +% line). The core is axis-agnostic — it plots .x on X and .y on Y. +% +% Inputs: +% plotGroups - struct array with fields: +% .x [N x 1] raw values (z-scored internally) +% .y [N x 1] raw values (z-scored internally) +% .label Display name for stats annotation +% .color [1 x 3] RGB +% .subjectIDs Cell array for point labels (optional) +% .arrowChain Integer — items with same value get arrow-connected +% in their array order. NaN = no chain. (optional) +% +% Name-Value Parameters: +% ZScoreMode - 'pooled' (default) or 'pergroup' +% InvertX - Negate z-scored X values (default: false) +% ReverseAxes - Reverse X-axis direction (default: false) +% ShowIdentity - Show y=x identity line (default: true) +% ShowPoints - Show individual data points (default: true) +% ShowLabels - Label points with subjectIDs (default: false) +% ErrorType - 'sem' (default), 'std', or 'none' +% CentroidSize - Marker size for centroid dot (default: 120) +% FitLine - Per-group regression line (default: false) +% ShowArrows - Arrows between same-chain centroids (default: false) +% ArrowColor - Arrow RGB color (default: [1 1 1] white) +% ShowQuadrantLabels - Show "High/Low Efficiency" labels (default: true) +% HighCorner - Corner for "High Efficiency": 'topleft' (default) or +% 'bottomright'. Set automatically by wrappers via FlipXY. +% Legend - Legend location (default: 'best'), 'none' to hide +% Title/XLabel/YLabel - Text overrides +% Visible/SavePath/SaveWidth/SaveHeight/SaveDPI - Standard plot params +% +% Outputs: +% fig - Figure handle +% stats - Struct array [nItems x 1]: +% .r, .p, .rho, .pval, .N, .zX, .zY, .NE, .centroid, +% .semX, .semY, .stdX, .stdY, .meanNE, .label +% NE = zY - zX (positive = above identity line = efficient) +% +% See also: plotNeuralEfficiency, plotNeuralEfficiencyFromTable + + % --- Ensure optional struct fields exist --- + if ~isfield(plotGroups, 'subjectIDs') + [plotGroups.subjectIDs] = deal({}); + end + if ~isfield(plotGroups, 'arrowChain') + [plotGroups.arrowChain] = deal(NaN); + end + + p = inputParser; + addRequired(p, 'plotGroups', @isstruct); + addParameter(p, 'ZScoreMode', 'pooled', @(x) ismember(lower(x), {'pooled','pergroup'})); + addParameter(p, 'InvertX', false, @islogical); + addParameter(p, 'ReverseAxes', false, @islogical); + addParameter(p, 'ShowIdentity', true, @islogical); + addParameter(p, 'ShowPoints', true, @islogical); + addParameter(p, 'ShowLabels', false, @islogical); + addParameter(p, 'ErrorType', 'sem', @(x) ismember(lower(x), {'sem','std','none'})); + addParameter(p, 'CentroidSize', 120, @isnumeric); + addParameter(p, 'FitLine', false, @islogical); + addParameter(p, 'ShowArrows', false, @islogical); + addParameter(p, 'ArrowColor', [1 1 1], @(x) isnumeric(x) && numel(x)==3); + addParameter(p, 'ShowQuadrantLabels', true, @islogical); + addParameter(p, 'HighCorner', 'topleft', @(x) ismember(lower(x), {'topleft','bottomright'})); + addParameter(p, 'Legend', 'best', @ischar); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'XLabel', '', @ischar); + addParameter(p, 'YLabel', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 500, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, plotGroups, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + nItems = length(plotGroups); + + % --- Z-score --- + if strcmpi(opts.ZScoreMode, 'pooled') + allX = vertcat(plotGroups.x); + allY = vertcat(plotGroups.y); + muX = mean(allX, 'omitnan'); + sdX = std(allX, 'omitnan'); + muY = mean(allY, 'omitnan'); + sdY = std(allY, 'omitnan'); + if sdX == 0, sdX = 1; end + if sdY == 0, sdY = 1; end + for i = 1:nItems + plotGroups(i).x = (plotGroups(i).x - muX) ./ sdX; + plotGroups(i).y = (plotGroups(i).y - muY) ./ sdY; + end + else + for i = 1:nItems + muX = mean(plotGroups(i).x, 'omitnan'); + sdX = std(plotGroups(i).x, 'omitnan'); + muY = mean(plotGroups(i).y, 'omitnan'); + sdY = std(plotGroups(i).y, 'omitnan'); + if sdX == 0, sdX = 1; end + if sdY == 0, sdY = 1; end + plotGroups(i).x = (plotGroups(i).x - muX) ./ sdX; + plotGroups(i).y = (plotGroups(i).y - muY) ./ sdY; + end + end + + % Invert X + if opts.InvertX + for i = 1:nItems + plotGroups(i).x = -plotGroups(i).x; + end + end + + % --- Create figure --- + sty = pf2_base.plot.PlotStyle.getDefault(); + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + ax = axes('Parent', fig); + hold(ax, 'on'); + + % --- Identity line (y = x) --- + if opts.ShowIdentity + allZ = [vertcat(plotGroups.x); vertcat(plotGroups.y)]; + zRange = [min(allZ) - 0.5, max(allZ) + 0.5]; + hId = plot(ax, zRange, zRange, '--', 'Color', [0.6 0.6 0.6], ... + 'LineWidth', 1); + set(hId.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + end + + % --- Quadrant labels --- + if opts.ShowQuadrantLabels + if strcmpi(opts.HighCorner, 'topleft') + highPos = [0.03, 0.97]; + lowPos = [0.97, 0.03]; + highHA = 'left'; lowHA = 'right'; + highVA = 'top'; lowVA = 'bottom'; + else + highPos = [0.97, 0.03]; + lowPos = [0.03, 0.97]; + highHA = 'right'; lowHA = 'left'; + highVA = 'bottom'; lowVA = 'top'; + end + text(ax, highPos(1), highPos(2), 'High Efficiency', ... + 'Units', 'normalized', 'FontSize', 12, 'FontWeight', 'bold', ... + 'Color', [0.4 0.7 0.4], 'HorizontalAlignment', highHA, ... + 'VerticalAlignment', highVA); + text(ax, lowPos(1), lowPos(2), 'Low Efficiency', ... + 'Units', 'normalized', 'FontSize', 12, 'FontWeight', 'bold', ... + 'Color', [0.8 0.4 0.4], 'HorizontalAlignment', lowHA, ... + 'VerticalAlignment', lowVA); + end + + % --- Render per item --- + stats = struct('r', {}, 'p', {}, 'rho', {}, 'pval', {}, ... + 'N', {}, 'zX', {}, 'zY', {}, 'centroid', {}, ... + 'semX', {}, 'semY', {}, 'stdX', {}, 'stdY', {}, 'label', {}); + + legendHandles = gobjects(0); + legendLabels = {}; + seenLabels = {}; + + showErr = ~strcmpi(opts.ErrorType, 'none'); + + for i = 1:nItems + xZ = plotGroups(i).x; + yZ = plotGroups(i).y; + N = length(xZ); + clr = plotGroups(i).color; + lbl = plotGroups(i).label; + + % Legend deduplication: one entry per unique label + isNewLabel = ~any(strcmp(seenLabels, lbl)); + + % --- Individual scatter points (behind centroid) --- + if opts.ShowPoints && N > 0 + hPts = scatter(ax, xZ, yZ, 25, clr, 'filled', ... + 'MarkerFaceAlpha', 0.35); + set(hPts.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + end + + % --- Per-item stats --- + cx = mean(xZ, 'omitnan'); + cy = mean(yZ, 'omitnan'); + semXval = std(xZ, 'omitnan') ./ sqrt(sum(~isnan(xZ))); + semYval = std(yZ, 'omitnan') ./ sqrt(sum(~isnan(yZ))); + stdXval = std(xZ, 'omitnan'); + stdYval = std(yZ, 'omitnan'); + + % Neural efficiency: NE = zY - zX (positive = above identity line) + neVals = yZ - xZ; + + s = struct('r', NaN, 'p', NaN, 'rho', NaN, 'pval', NaN, ... + 'N', N, 'zX', xZ, 'zY', yZ, 'NE', neVals, ... + 'centroid', [cx, cy], ... + 'semX', semXval, 'semY', semYval, ... + 'stdX', stdXval, 'stdY', stdYval, ... + 'meanNE', mean(neVals, 'omitnan'), ... + 'label', lbl); + if N >= 3 + [s.r, s.p] = pf2_base.compat.corr(xZ, yZ, 'Type', 'Pearson'); + [s.rho, s.pval] = pf2_base.compat.corr(xZ, yZ, 'Type', 'Spearman'); + end + + % --- Error crosshairs --- + if showErr && N > 1 + if strcmpi(opts.ErrorType, 'sem') + errX = semXval; + errY = semYval; + else + errX = stdXval; + errY = stdYval; + end + % Horizontal error bar + hErrH = plot(ax, [cx - errX, cx + errX], [cy, cy], '-', ... + 'Color', clr, 'LineWidth', 2); + set(hErrH.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + % Vertical error bar + hErrV = plot(ax, [cx, cx], [cy - errY, cy + errY], '-', ... + 'Color', clr, 'LineWidth', 2); + set(hErrV.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + end + + % --- Centroid marker (primary visual element, used for legend) --- + hCent = scatter(ax, cx, cy, opts.CentroidSize, clr, 'filled', ... + 'MarkerEdgeColor', clr, 'LineWidth', 1.5); + if isNewLabel + set(hCent, 'DisplayName', lbl); + legendHandles(end+1) = hCent; %#ok + legendLabels{end+1} = lbl; %#ok + seenLabels{end+1} = lbl; %#ok + else + set(hCent.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + end + + % --- Regression line --- + if opts.FitLine && N > 2 + coeffs = polyfit(xZ, yZ, 1); + xFit = linspace(min(xZ), max(xZ), 200); + yFit = polyval(coeffs, xFit); + hLine = plot(ax, xFit, yFit, '-', 'Color', clr, 'LineWidth', 1.5); + set(hLine.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + end + + % --- Stat annotation (bottom-left, avoids quadrant labels) --- + yOff = 0.02 + (nItems - i) * 0.07; + text(ax, 0.02, yOff, sprintf('%s: r=%.2f, p=%.3f, N=%d', ... + lbl, s.r, s.p, N), ... + 'Units', 'normalized', 'FontSize', 8, 'Color', clr, ... + 'VerticalAlignment', 'bottom'); + + % --- Subject labels --- + if opts.ShowPoints && opts.ShowLabels && ~isempty(plotGroups(i).subjectIDs) + sids = plotGroups(i).subjectIDs; + for j = 1:min(N, length(sids)) + text(ax, xZ(j), yZ(j), [' ' sids{j}], ... + 'FontSize', 6, 'Color', clr, 'Clipping', 'on'); + end + end + + stats(i) = s; + end + + % --- Arrows between same-chain centroids --- + if opts.ShowArrows + trimFrac = 0.12; % trim 12% from each end + chains = [plotGroups.arrowChain]; + uniqueChains = unique(chains(~isnan(chains))); + for c = uniqueChains(:)' + idx = find(chains == c); + if length(idx) < 2, continue; end + centroids = vertcat(stats(idx).centroid); + for j = 1:(length(idx) - 1) + x0 = centroids(j, 1); + y0 = centroids(j, 2); + x1 = centroids(j+1, 1); + y1 = centroids(j+1, 2); + % Trim start and end so arrow doesn't overlap centroids + startX = x0 + trimFrac * (x1 - x0); + startY = y0 + trimFrac * (y1 - y0); + endX = x1 - trimFrac * (x1 - x0); + endY = y1 - trimFrac * (y1 - y0); + dx = endX - startX; + dy = endY - startY; + hArrow = quiver(ax, startX, startY, dx, dy, 0, ... + 'Color', opts.ArrowColor, 'LineWidth', 2, ... + 'MaxHeadSize', 0.5); + set(hArrow.Annotation.LegendInformation, ... + 'IconDisplayStyle', 'off'); + end + end + end + + % --- Labels --- + if ~isempty(opts.XLabel) + xlabel(ax, opts.XLabel); + else + xlabel(ax, 'X (z-scored)'); + end + if ~isempty(opts.YLabel) + ylabel(ax, opts.YLabel); + else + ylabel(ax, 'Y (z-scored)'); + end + + % --- Legend --- + nLeg = length(legendHandles); + if nLeg > 1 && ~strcmpi(opts.Legend, 'none') + lg = legend(ax, legendHandles, legendLabels, ... + 'Location', opts.Legend, 'FontSize', 9); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + end + + % --- Title --- + if ~isempty(opts.Title) + title(ax, opts.Title); + end + + grid(ax, 'on'); + box(ax, 'on'); + axis(ax, 'equal'); + + if opts.ReverseAxes + set(ax, 'XDir', 'reverse'); + end + + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); +end diff --git a/+exploreFNIRS/+core/plotNeuralEfficiencyFromTable.m b/+exploreFNIRS/+core/plotNeuralEfficiencyFromTable.m new file mode 100644 index 00000000..234fbe3c --- /dev/null +++ b/+exploreFNIRS/+core/plotNeuralEfficiencyFromTable.m @@ -0,0 +1,355 @@ +function [fig, stats, neTable] = plotNeuralEfficiencyFromTable(T, varargin) +% PLOTNEURALEFFICIENCYFROMTABLE Neural efficiency plot from a table +% +% Creates a neural efficiency scatter plot from a MATLAB table with +% columns for X values, Y values, and optional group/subgroup assignments. +% Useful when data comes from external sources or when you want to +% manually control the grouping and subgroup ordering. +% +% Convention: for neural efficiency plots, pass activation as XVar and +% performance as YVar so that efficient subjects (high performance, +% low activation) appear above the y=x identity line. +% +% Syntax: +% [fig, stats] = plotNeuralEfficiencyFromTable(T, ... +% 'XVar', 'HbO_mean', 'YVar', 'accuracy') +% [fig, stats] = plotNeuralEfficiencyFromTable(T, ... +% 'XVar', 'activation', 'YVar', 'RT', ... +% 'GroupVar', 'Diagnosis', 'SubgroupVar', 'Difficulty', ... +% 'ShowArrows', true) +% +% Inputs: +% T - MATLAB table. Each row is one observation (subject/trial). +% +% Name-Value Parameters: +% XVar - (required) Column name for X-axis values +% YVar - (required) Column name for Y-axis values +% GroupVar - Column name for group assignment (color grouping). +% (default: '' = all data in one group) +% SubgroupVar - Column name for subgroup/condition within each group. +% Each unique (Group, Subgroup) combo becomes one scatter +% cloud. Same-group subgroups share a color and get +% arrow-connected when ShowArrows is true. Categorical +% columns use their defined category order; otherwise +% order is by first appearance in the table. +% (default: '' = no subgroups) +% SubjectVar - Column name for subject IDs (for ShowLabels). +% (default: '' = no labels) +% SubgroupShading - How to color subgroups within a group: +% 'gradient' (default): light-to-dark shading per subgroup, +% each subgroup gets its own legend entry. +% 'uniform': all subgroups share the group's base color +% and a single legend entry. +% Colors - Color palette override. [N x 3] RGB matrix (one row +% per unique group), colormap name, or function handle. +% (default: [] = auto palette) +% +% All additional parameters (ZScoreMode, InvertX, ReverseAxes, +% ShowIdentity, ShowLabels, FitLine, ShowArrows, ArrowColor, Legend, +% Title, XLabel, YLabel, Visible, SavePath, SaveWidth, SaveHeight, +% SaveDPI) are passed through to plotNeuralEfficiencyCore. +% +% Outputs: +% fig - Figure handle +% stats - Struct array [nItems x 1] with per-scatter-cloud statistics: +% .r, .p, .rho, .pval, .N, .zX, .zY, .NE, .centroid, +% .semX, .semY, .stdX, .stdY, .meanNE, .label +% neTable - Input table T with columns appended: zX, zY, NE +% NE = zY - zX (positive = above identity line = efficient) +% +% Example: +% % Table with diagnosis groups and difficulty levels +% T = table(hbo_mean, accuracy, diagnosis, difficulty, subjectID, ... +% 'VariableNames', {'HbO','Accuracy','Group','Difficulty','SID'}); +% [fig, stats] = exploreFNIRS.core.plotNeuralEfficiencyFromTable(T, ... +% 'XVar', 'HbO', 'YVar', 'Accuracy', ... +% 'GroupVar', 'Group', 'SubgroupVar', 'Difficulty', ... +% 'ShowArrows', true, 'FitLine', true); +% +% See also: plotNeuralEfficiencyCore, plotNeuralEfficiency + + p = inputParser; + p.KeepUnmatched = true; + addRequired(p, 'T', @istable); + addParameter(p, 'XVar', '', @ischar); + addParameter(p, 'YVar', '', @ischar); + addParameter(p, 'GroupVar', '', @ischar); + addParameter(p, 'SubgroupVar', '', @ischar); + addParameter(p, 'SubjectVar', '', @ischar); + addParameter(p, 'InvertX', false, @islogical); + addParameter(p, 'XLabel', '', @ischar); + addParameter(p, 'YLabel', '', @ischar); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'SubgroupShading', 'gradient', @(x) ismember(lower(x), {'gradient','uniform'})); + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle')); + parse(p, T, varargin{:}); + opts = p.Results; + + if isempty(opts.XVar) + error('exploreFNIRS:core:plotNeuralEfficiencyFromTable', ... + 'XVar is required.'); + end + if isempty(opts.YVar) + error('exploreFNIRS:core:plotNeuralEfficiencyFromTable', ... + 'YVar is required.'); + end + + % Validate columns exist + requiredCols = {opts.XVar, opts.YVar}; + optionalCols = {opts.GroupVar, opts.SubgroupVar, opts.SubjectVar}; + for i = 1:length(requiredCols) + if ~ismember(requiredCols{i}, T.Properties.VariableNames) + error('exploreFNIRS:core:plotNeuralEfficiencyFromTable', ... + 'Column "%s" not found in table.', requiredCols{i}); + end + end + for i = 1:length(optionalCols) + if ~isempty(optionalCols{i}) && ... + ~ismember(optionalCols{i}, T.Properties.VariableNames) + error('exploreFNIRS:core:plotNeuralEfficiencyFromTable', ... + 'Column "%s" not found in table.', optionalCols{i}); + end + end + + % Extract X and Y columns + xAll = T.(opts.XVar); + yAll = T.(opts.YVar); + if ~isnumeric(xAll), xAll = double(string(xAll)); end + if ~isnumeric(yAll), yAll = double(string(yAll)); end + allRowIdx = (1:height(T))'; + + % Extract grouping columns + hasGroup = ~isempty(opts.GroupVar); + hasSub = ~isempty(opts.SubgroupVar); + hasSubject = ~isempty(opts.SubjectVar); + + if hasGroup + grpCol = T.(opts.GroupVar); + grpOrder = extractOrder(grpCol); + if isnumeric(grpCol), grpCol = string(grpCol); end + if ~iscell(grpCol), grpCol = cellstr(grpCol); end + end + if hasSub + subCol = T.(opts.SubgroupVar); + subOrder = extractOrder(subCol); + if isnumeric(subCol), subCol = string(subCol); end + if ~iscell(subCol), subCol = cellstr(subCol); end + end + if hasSubject + sidCol = T.(opts.SubjectVar); + if ~iscell(sidCol), sidCol = cellstr(string(sidCol)); end + end + + % --- Build plotGroups --- + useGradient = strcmpi(opts.SubgroupShading, 'gradient'); + rowMap = {}; % cell array parallel to plotGroups, tracks original row indices + + if hasGroup && hasSub + % One plotGroup per (Group, Subgroup) combo + uniqueGroups = grpOrder; + uniqueSubs = subOrder; + nGrp = length(uniqueGroups); + nSub = length(uniqueSubs); + nItems = nGrp * nSub; + + colors = exploreFNIRS.core.getGroupColors(nGrp, opts.Colors); + + % Build per-subgroup shaded colors + if useGradient && nSub > 1 + subColors = cell(nGrp, 1); + for gi = 1:nGrp + subColors{gi} = shadeColor(colors(gi, :), nSub); + end + end + + pgTemplate = struct('x', [], 'y', [], 'label', '', ... + 'color', [], 'subjectIDs', {{}}, 'arrowChain', NaN); + plotGroups = repmat(pgTemplate, 1, nItems); + rowMap = cell(1, nItems); + + idx = 0; + for gi = 1:nGrp + for si = 1:nSub + idx = idx + 1; + mask = strcmp(grpCol, uniqueGroups{gi}) & ... + strcmp(subCol, uniqueSubs{si}); + plotGroups(idx).x = xAll(mask); + plotGroups(idx).y = yAll(mask); + plotGroups(idx).arrowChain = gi; + rowMap{idx} = allRowIdx(mask); + if hasSubject + plotGroups(idx).subjectIDs = sidCol(mask); + end + + if useGradient && nSub > 1 + plotGroups(idx).color = subColors{gi}(si, :); + if nGrp > 1 + plotGroups(idx).label = sprintf('%s: %s', ... + uniqueGroups{gi}, uniqueSubs{si}); + else + plotGroups(idx).label = uniqueSubs{si}; + end + else + plotGroups(idx).color = colors(gi, :); + plotGroups(idx).label = uniqueGroups{gi}; + end + end + end + + % Remove empty combos + empty = arrayfun(@(pg) isempty(pg.x), plotGroups); + plotGroups(empty) = []; + rowMap(empty) = []; + + elseif hasGroup + % One plotGroup per Group + uniqueGroups = grpOrder; + nGrp = length(uniqueGroups); + colors = exploreFNIRS.core.getGroupColors(nGrp, opts.Colors); + + pgTemplate = struct('x', [], 'y', [], 'label', '', ... + 'color', [], 'subjectIDs', {{}}, 'arrowChain', 1); + plotGroups = repmat(pgTemplate, 1, nGrp); + rowMap = cell(1, nGrp); + + for gi = 1:nGrp + mask = strcmp(grpCol, uniqueGroups{gi}); + rowMap{gi} = allRowIdx(mask); + plotGroups(gi).x = xAll(mask); + plotGroups(gi).y = yAll(mask); + plotGroups(gi).label = uniqueGroups{gi}; + plotGroups(gi).color = colors(gi, :); + plotGroups(gi).arrowChain = 1; + if hasSubject + plotGroups(gi).subjectIDs = sidCol(mask); + end + end + + elseif hasSub + % Subgroups only (single group color, arrow-connected) + uniqueSubs = subOrder; + nSub = length(uniqueSubs); + colors = exploreFNIRS.core.getGroupColors(1, opts.Colors); + baseClr = colors(1, :); + + if useGradient && nSub > 1 + subClrs = shadeColor(baseClr, nSub); + end + + pgTemplate = struct('x', [], 'y', [], 'label', '', ... + 'color', [], 'subjectIDs', {{}}, 'arrowChain', 1); + plotGroups = repmat(pgTemplate, 1, nSub); + rowMap = cell(1, nSub); + + for si = 1:nSub + mask = strcmp(subCol, uniqueSubs{si}); + rowMap{si} = allRowIdx(mask); + plotGroups(si).x = xAll(mask); + plotGroups(si).y = yAll(mask); + plotGroups(si).label = uniqueSubs{si}; + plotGroups(si).arrowChain = 1; + if useGradient && nSub > 1 + plotGroups(si).color = subClrs(si, :); + else + plotGroups(si).color = baseClr; + end + if hasSubject + plotGroups(si).subjectIDs = sidCol(mask); + end + end + + else + % Single group: all data + colors = exploreFNIRS.core.getGroupColors(1, opts.Colors); + plotGroups = struct('x', xAll, 'y', yAll, 'label', 'All', ... + 'color', colors(1,:), 'subjectIDs', {{}}, 'arrowChain', NaN); + rowMap = {allRowIdx}; + if hasSubject + plotGroups.subjectIDs = sidCol; + end + end + + % --- Generate default labels --- + if isempty(opts.XLabel) + lbl = pf2_base.plot.escapeTeX(opts.XVar); + if opts.InvertX + opts.XLabel = sprintf('%s (z-scored, inverted)', lbl); + else + opts.XLabel = sprintf('%s (z-scored)', lbl); + end + end + if isempty(opts.YLabel) + opts.YLabel = sprintf('%s (z-scored)', pf2_base.plot.escapeTeX(opts.YVar)); + end + if isempty(opts.Title) + opts.Title = sprintf('Neural Efficiency: %s vs %s', ... + pf2_base.plot.escapeTeX(opts.XVar), ... + pf2_base.plot.escapeTeX(opts.YVar)); + end + + % --- Delegate to core --- + passthrough = unmatchedToCell(p.Unmatched); + [fig, stats] = exploreFNIRS.core.plotNeuralEfficiencyCore(plotGroups, ... + 'InvertX', opts.InvertX, ... + 'XLabel', opts.XLabel, ... + 'YLabel', opts.YLabel, ... + 'Title', opts.Title, ... + passthrough{:}); + + % --- Build neTable: input table + zX, zY, NE columns --- + if nargout >= 3 + nRows = height(T); + zXcol = nan(nRows, 1); + zYcol = nan(nRows, 1); + NEcol = nan(nRows, 1); + for k = 1:length(stats) + rows = rowMap{k}; + zXcol(rows) = stats(k).zX; + zYcol(rows) = stats(k).zY; + NEcol(rows) = stats(k).NE; + end + neTable = T; + neTable.zX = zXcol; + neTable.zY = zYcol; + neTable.NE = NEcol; + end +end + + +%% Local helpers + +function order = extractOrder(col) +% Get unique values preserving categorical order when available + if iscategorical(col) + order = categories(col); + else + if isnumeric(col), col = string(col); end + if ~iscell(col), col = cellstr(col); end + order = unique(col, 'stable'); + end +end + +function colors = shadeColor(baseRGB, n) +% Generate n shades from light to dark for a base color +% First shade is lighter (blended toward white), last is the base color +% or slightly darker. Returns [n x 3] RGB matrix. + colors = zeros(n, 3); + for i = 1:n + t = (i - 1) / max(n - 1, 1); % 0 = lightest, 1 = darkest + % Blend from 60% toward white (light) to 15% darker than base + lightened = baseRGB + 0.6 * (1 - t) * (1 - baseRGB); + darkened = baseRGB * (1 - 0.15 * t); + colors(i, :) = (1 - t) * lightened + t * darkened; + end + colors = min(max(colors, 0), 1); +end + +function args = unmatchedToCell(s) +% Convert inputParser Unmatched struct to name-value cell array + fn = fieldnames(s); + args = cell(1, 2 * numel(fn)); + for i = 1:numel(fn) + args{2*i - 1} = fn{i}; + args{2*i} = s.(fn{i}); + end +end diff --git a/+exploreFNIRS/+core/plotScatter.m b/+exploreFNIRS/+core/plotScatter.m new file mode 100644 index 00000000..dcfc16df --- /dev/null +++ b/+exploreFNIRS/+core/plotScatter.m @@ -0,0 +1,873 @@ +function [fig, stats] = plotScatter(groups, varargin) +% PLOTSCATTER Scatter plot correlating info variable vs fNIRS biomarker +% +% Creates scatter plots showing the relationship between an info/behavioral +% variable (X-axis) and fNIRS biomarker channel data (Y-axis). Each channel +% and biomarker gets its own subplot — channels and biomarkers are never +% averaged. Supports Pearson/Spearman correlation, regression lines, error +% bands, and topographic correlation maps. +% +% Syntax: +% [fig, stats] = plotScatter(groups, 'InfoVar', 'reactionTime') +% [fig, stats] = plotScatter(groups, 'InfoVar', 'Age', ... +% 'Biomarkers', {'HbO'}, 'Channels', 1:5) +% [fig, stats] = plotScatter(groups, 'InfoVar', 'Age', ... +% 'PlotTopo', true, 'SigThreshold', 0.05) +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% Each element must have .gbyGrandBarFlat and .gbyTables +% +% Name-Value Parameters: +% InfoVar - (required) X-axis variable name from info fields +% Biomarkers - Cell array of biomarkers (default: {'HbO'}) +% Channels - Vector of channel indices (default: all channels) +% Averaging - 'hierarchy' (default), 'flat', or 'none' +% 'hierarchy' averages within SubjectID first +% 'flat'/'none' uses raw block-level data +% CorrType - 'Pearson' (default) or 'Spearman' +% FitLine - Show regression line (default: true) +% ErrorBand - Show error band (default: false) +% ErrorBandType - '95%PI' (default), 'SEM', 'SD', '95%CI' +% ErrorBandStyle - 'Shaded' (default), 'Dashed', 'Fine' +% FlipXY - Swap X and Y axes (default: false) +% PlotTopo - Generate topo correlation map (default: false) +% SigThreshold - Significance threshold for topo (default: 0.05) +% SigType - 'p' (default), 'q', 'q-twostep' +% PlotBy - Groupby variable to split subplots by (e.g., 'Condition'). +% Creates one subplot row per PlotBy value. When combined +% with multiple biomarkers, a separate figure is created +% per biomarker. Only for scatter mode (not topo). +% Legend - 'last' (default), 'first', 'all', or 'none' +% Controls which subplot(s) show the legend. +% YLim - [min max] y-axis limits (default: auto, shared across subplots) +% XLim - [min max] x-axis limits (default: auto, shared across subplots) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 600) +% SaveHeight - Height in pixels (default: 400) +% SaveDPI - Resolution (default: 150) +% Colors - Group color palette override (default: [] = auto) +% [N x 3] RGB matrix, colormap name (e.g. 'Set1'), +% or function handle @(N) returning [N x 3]. +% +% Layout: +% Each channel and biomarker combination gets its own subplot. +% Groups are overlaid as differently colored scatter points. +% +% - 1 biomarker: channels in a square-ish grid +% - Multiple biomarkers, no PlotBy: rows = biomarkers, columns = channels +% - With PlotBy, 1 biomarker: rows = PlotBy values, columns = channels +% - With PlotBy, multiple biomarkers: separate figure per biomarker, +% rows = PlotBy values, columns = channels +% +% Outputs: +% fig - Figure handle (or array of handles if multiple figures created) +% stats - Struct with correlation statistics per group: +% .r, .p - Pearson correlation and p-value +% .rho, .pval - Spearman correlation and p-value +% .N - Sample size +% .coefficients - [slope, intercept] from polyfit +% .q - FDR-corrected p-values (topo mode only) +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group','Condition'}); +% ex.aggregate(); +% +% % Scatter: reaction time vs HbO, all channels +% [fig, stats] = exploreFNIRS.core.plotScatter(ex.groups, ... +% 'InfoVar', 'reactionTime', 'Biomarkers', {'HbO'}); +% +% % Topographic correlation map +% [fig, stats] = exploreFNIRS.core.plotScatter(ex.groups, ... +% 'InfoVar', 'Age', 'PlotTopo', true, 'SigThreshold', 0.05); +% +% See also: exploreFNIRS.core.Experiment, exploreFNIRS.core.plotBar + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addParameter(p, 'InfoVar', '', @ischar); + addParameter(p, 'Biomarkers', {'HbO'}, @iscell); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'ROIs', [], @(x) isnumeric(x) || islogical(x) || ischar(x) || isstring(x) || iscell(x)); + addParameter(p, 'Device', [], @(v) isempty(v) || isa(v, 'pf2.Device')); + addParameter(p, 'ExcludeShortSeparation', true, @islogical); + addParameter(p, 'Averaging', 'hierarchy', @(x) ismember(lower(x), {'hierarchy','flat','none'})); + addParameter(p, 'CorrType', 'Pearson', @ischar); + addParameter(p, 'FitLine', true, @islogical); + addParameter(p, 'ErrorBand', false, @islogical); + addParameter(p, 'ErrorBandType', '95%PI', @ischar); + addParameter(p, 'ErrorBandStyle', 'Shaded', @ischar); + addParameter(p, 'FlipXY', false, @islogical); + addParameter(p, 'PlotTopo', false, @islogical); + addParameter(p, 'SigThreshold', 0.05, @isnumeric); + addParameter(p, 'SigType', 'p', @ischar); + addParameter(p, 'PlotBy', '', @ischar); + addParameter(p, 'Legend', 'last', @ischar); + addParameter(p, 'YLim', [], @isnumeric); + addParameter(p, 'XLim', [], @isnumeric); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle') || isa(x, 'exploreFNIRS.core.ColorScheme')); + parse(p, groups, varargin{:}); + opts = p.Results; + + if isempty(opts.InfoVar) + error('exploreFNIRS:core:plotScatter', ... + 'InfoVar is required. Specify the X-axis variable name.'); + end + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + nGroups = length(groups); + nBioM = length(opts.Biomarkers); + + % Validate groups have bar-flat grand averages + for g = 1:nGroups + if isempty(groups(g).gbyGrandBarFlat) + error('exploreFNIRS:core:plotScatter', ... + 'Group %d has no bar-flat grand average. Call aggregate() first.', g); + end + end + + % Resolve ROIs vs Channels + useROI = ~isempty(opts.ROIs); + if useROI + if ~isempty(opts.Channels) + error('exploreFNIRS:core:plotScatter', ... + 'ROIs and Channels are mutually exclusive.'); + end + if ~isfield(groups(1).gbyGrandBarFlat, 'ROI') + error('exploreFNIRS:core:plotScatter', ... + 'No ROI data in grand average. Define ROIs before aggregating.'); + end + [roiIdx, roiNames] = resolveROIs(groups, opts.ROIs); + allChannels = roiIdx; + nCh = length(allChannels); + itemNames = roiNames; + else + % Determine channels (default = all) + if isempty(opts.Channels) + nCh = size(groups(1).gbyGrandBarFlat.(opts.Biomarkers{1}).data, 2); + allChannels = 1:nCh; + else + allChannels = opts.Channels; + nCh = length(allChannels); + end + % Exclude short-separation channels + if opts.ExcludeShortSeparation + ssIdx = getShortSeparationIdx(opts.Device, groups); + if ~isempty(ssIdx) + allChannels = allChannels(~ismember(allChannels, ssIdx)); + nCh = length(allChannels); + end + end + itemNames = arrayfun(@(c) sprintf('Ch %d', c), allChannels, ... + 'UniformOutput', false); + end + + % Auto-expand groups by time bins when multiple bars exist + if ~isempty(groups(1).gbyGrandBarFlat) && ... + length(groups(1).gbyGrandBarFlat.time) > 1 + groups = exploreFNIRS.core.expandGroupsByTime(groups); + nGroups = length(groups); + end + tIdx = 1; + + % Initialize stats output + stats = repmat(emptyStats(), nGroups, nBioM, nCh); + + sty = pf2_base.plot.PlotStyle.getDefault(); + + if opts.PlotTopo && useROI + warning('exploreFNIRS:core:plotScatter', ... + 'PlotTopo is not supported with ROIs (topo is inherently spatial). Using scatter mode.'); + end + + if opts.PlotTopo && ~useROI + % --- Topo mode (unchanged) --- + [fig, stats] = plotTopoCorrelation(groups, opts, allChannels, tIdx); + sty.applyToFigure(fig); + pf2_base.plot.handleSave(fig, opts); + return; + end + + % --- Determine layout --- + hasPB = ~isempty(opts.PlotBy); + if hasPB + [plotByValues, subGroups, withinLabels, plotByIdx] = ... + exploreFNIRS.core.splitGroupsByFactor(groups, opts.PlotBy); + nPlotBy = length(plotByValues); + end + + if hasPB && nBioM > 1 + nFigs = nBioM; + nRows = nPlotBy; + nCols = nCh; + layoutType = 'plotby'; + elseif hasPB + nFigs = 1; + nRows = nPlotBy; + nCols = nCh; + layoutType = 'plotby'; + elseif nBioM > 1 + nFigs = 1; + nRows = nBioM; + nCols = nCh; + layoutType = 'biomarker'; + else + nFigs = 1; + nRows = ceil(sqrt(nCh)); + nCols = ceil(nCh / nRows); + layoutType = 'channel_grid'; + end + + figs = gobjects(nFigs, 1); + + for fIdx = 1:nFigs + figW = opts.SaveWidth * min(nCols, 4); + figH = opts.SaveHeight * max(nRows * 0.7, 1); + + curFig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', figW, 'Height', figH, 'SavePath', opts.SavePath); + figs(fIdx) = curFig; + + allAxes = gobjects(nRows * nCols, 1); + axCount = 0; + + switch layoutType + case 'plotby' + % rows = PlotBy values, cols = channels + if nBioM > 1 + bioM = opts.Biomarkers{fIdx}; + bIdx = fIdx; + else + bioM = opts.Biomarkers{1}; + bIdx = 1; + end + + for pIdx = 1:nPlotBy + curGroups = subGroups{pIdx}; + nCurGroups = length(curGroups); + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + curColors = opts.Colors.resolve(curGroups); + else + curColors = exploreFNIRS.core.getGroupColors(nCurGroups, opts.Colors); + end + + curWithin = pf2_base.plot.escapeTeX(withinLabels(plotByIdx == pIdx)); + + for chI = 1:nCh + ch = allChannels(chI); + spIdx = (pIdx - 1) * nCols + chI; + ax = subplot(nRows, nCols, spIdx, 'Parent', curFig); + hold(ax, 'on'); + axCount = axCount + 1; + allAxes(axCount) = ax; + + for g = 1:nCurGroups + curStats = plotGroupScatter(ax, curGroups(g), ... + bioM, ch, tIdx, opts, curColors(g,:), g, useROI); + stats(g, bIdx, chI) = curStats; + end + + if pIdx == 1 + title(ax, pf2_base.plot.escapeTeX(itemNames{chI})); + end + if chI == 1 + ylabel(ax, sprintf('%s: %s', ... + pf2_base.plot.escapeTeX(opts.PlotBy), ... + pf2_base.plot.escapeTeX(plotByValues{pIdx}))); + end + + if opts.FlipXY + xlabel(ax, sprintf('\\Delta[%s]', bioM)); + else + xlabel(ax, pf2_base.plot.escapeTeX(opts.InfoVar)); + end + + spTotal = nPlotBy * nCh; + if nCurGroups > 1 && showLegend(opts.Legend, spIdx, spTotal) + lg = legend(ax, curWithin, 'Location', 'best', 'FontSize', 8); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + end + + grid(ax, 'on'); + box(ax, 'on'); + end + end + + case 'biomarker' + % rows = biomarkers, cols = channels + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + colors = opts.Colors.resolve(groups); + else + colors = exploreFNIRS.core.getGroupColors(nGroups, opts.Colors); + end + + for bIdx = 1:nBioM + bioM = opts.Biomarkers{bIdx}; + for chI = 1:nCh + ch = allChannels(chI); + spIdx = (bIdx - 1) * nCols + chI; + ax = subplot(nRows, nCols, spIdx, 'Parent', curFig); + hold(ax, 'on'); + axCount = axCount + 1; + allAxes(axCount) = ax; + + for g = 1:nGroups + curStats = plotGroupScatter(ax, groups(g), bioM, ch, ... + tIdx, opts, colors(g,:), g); + stats(g, bIdx, chI) = curStats; + end + + if bIdx == 1 + title(ax, pf2_base.plot.escapeTeX(itemNames{chI})); + end + if chI == 1 + if opts.FlipXY + ylabel(ax, pf2_base.plot.escapeTeX(opts.InfoVar)); + else + ylabel(ax, sprintf('\\Delta[%s]', bioM)); + end + end + if opts.FlipXY + xlabel(ax, sprintf('\\Delta[%s]', bioM)); + else + xlabel(ax, pf2_base.plot.escapeTeX(opts.InfoVar)); + end + + spTotal = nBioM * nCh; + if nGroups > 1 && showLegend(opts.Legend, spIdx, spTotal) + legendLabels = pf2_base.plot.escapeTeX(arrayfun(@(g) groups(g).label, ... + 1:nGroups, 'UniformOutput', false)); + lg = legend(ax, legendLabels, 'Location', 'best', 'FontSize', 8); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + end + + grid(ax, 'on'); + box(ax, 'on'); + end + end + + case 'channel_grid' + % Square grid of channels, 1 biomarker + bioM = opts.Biomarkers{1}; + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + colors = opts.Colors.resolve(groups); + else + colors = exploreFNIRS.core.getGroupColors(nGroups, opts.Colors); + end + + for chI = 1:nCh + ch = allChannels(chI); + + if nCh > 1 + ax = subplot(nRows, nCols, chI, 'Parent', curFig); + else + ax = axes('Parent', curFig); + end + hold(ax, 'on'); + axCount = axCount + 1; + allAxes(axCount) = ax; + + for g = 1:nGroups + curStats = plotGroupScatter(ax, groups(g), bioM, ch, ... + tIdx, opts, colors(g,:), g, useROI); + stats(g, 1, chI) = curStats; + end + + title(ax, pf2_base.plot.escapeTeX(itemNames{chI})); + if opts.FlipXY + xlabel(ax, sprintf('\\Delta[%s]', bioM)); + if chI == 1 || mod(chI - 1, nCols) == 0 + ylabel(ax, pf2_base.plot.escapeTeX(opts.InfoVar)); + end + else + xlabel(ax, pf2_base.plot.escapeTeX(opts.InfoVar)); + if chI == 1 || mod(chI - 1, nCols) == 0 + ylabel(ax, sprintf('\\Delta[%s]', bioM)); + end + end + + if nGroups > 1 && showLegend(opts.Legend, chI, nCh) + legendLabels = pf2_base.plot.escapeTeX(arrayfun(@(g) groups(g).label, ... + 1:nGroups, 'UniformOutput', false)); + lg = legend(ax, legendLabels, 'Location', 'best', 'FontSize', 8); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + end + + grid(ax, 'on'); + box(ax, 'on'); + end + end + + % Shared axes + allAxes = allAxes(1:axCount); + if strcmp(layoutType, 'biomarker') + % Link within each biomarker row + for bIdx = 1:nBioM + rowStart = (bIdx - 1) * nCh + 1; + rowEnd = min(bIdx * nCh, axCount); + linkaxes(allAxes(rowStart:rowEnd), 'xy'); + end + elseif axCount > 1 + linkaxes(allAxes, 'xy'); + end + if ~isempty(opts.YLim), arrayfun(@(a) ylim(a, opts.YLim), allAxes); end + if ~isempty(opts.XLim), arrayfun(@(a) xlim(a, opts.XLim), allAxes); end + + % Title + if ~isempty(opts.Title) + if nFigs > 1 + pf2_base.external.suptitle(curFig, sprintf('%s — %s', ... + pf2_base.plot.escapeTeX(opts.Title), opts.Biomarkers{fIdx})); + else + pf2_base.external.suptitle(curFig, pf2_base.plot.escapeTeX(opts.Title)); + end + elseif nFigs > 1 + pf2_base.external.suptitle(curFig, sprintf('%s vs %s (%s)', ... + pf2_base.plot.escapeTeX(opts.InfoVar), opts.Biomarkers{fIdx}, ... + opts.CorrType)); + else + pf2_base.external.suptitle(curFig, sprintf('%s vs fNIRS (%s)', ... + pf2_base.plot.escapeTeX(opts.InfoVar), opts.CorrType)); + end + + sty.applyToFigure(curFig); + + % Save + if nFigs > 1 && ~isempty(opts.SavePath) + [fPath, fName, fExt] = fileparts(opts.SavePath); + figOpts = opts; + figOpts.SavePath = fullfile(fPath, ... + sprintf('%s_%s%s', fName, opts.Biomarkers{fIdx}, fExt)); + pf2_base.plot.handleSave(curFig, figOpts); + else + pf2_base.plot.handleSave(curFig, opts); + end + end + + % Return figure handle(s) + if nFigs == 1 + fig = figs(1); + else + fig = figs; + end +end + + +%% Local helpers + +function curStats = plotGroupScatter(ax, group, bioM, ch, tIdx, opts, clr, gIdx, useROI) +% Plot scatter for one group, one channel/ROI, one biomarker +% gIdx is the 1-based group index for stacking stat annotations +% useROI: if true, read from gbyGrandBarFlat.ROI.(bioM) instead + + if nargin < 9, useROI = false; end + + curGrand = group.gbyGrandBarFlat; + curTable = group.gbyTables; + + % Extract Y: per-subject biomarker value at this channel/ROI + if useROI + if ~isfield(curGrand, 'ROI') || ~isfield(curGrand.ROI, bioM) || ... + isempty(curGrand.ROI.(bioM)) + curStats = emptyStats(); + return; + end + bioData = curGrand.ROI.(bioM); + else + if ~isfield(curGrand, bioM) || isempty(curGrand.(bioM)) + curStats = emptyStats(); + return; + end + bioData = curGrand.(bioM); + end + if ch > size(bioData.data, 2) + curStats = emptyStats(); + return; + end + + % Extract Y: per-subject biomarker value at this channel/ROI and time bin + yVals = permute(bioData.data(tIdx, ch, :), [3, 1, 2]); + + % Hierarchical averaging of Y values + if strcmpi(opts.Averaging, 'hierarchy') && ... + isfield(curGrand, 'info') && isfield(curGrand.info, 'Hierarchy') + [yVals, ~] = pf2_base.hierarchicalAverage(yVals, ... + curGrand.info.Hierarchy, @nanmean); + end + + % Extract X: info variable from table + if ~ismember(opts.InfoVar, curTable.Properties.VariableNames) + warning('Variable "%s" not found in group table', opts.InfoVar); + curStats = emptyStats(); + return; + end + + xData = curTable.(opts.InfoVar); + if ~isnumeric(xData) + xData = double(string(xData)); + end + xData(xData == -9999) = NaN; + + % Apply averaging to X values + if strcmpi(opts.Averaging, 'hierarchy') && ... + ismember('SubjectID', curTable.Properties.VariableNames) + [xVals] = pf2_base.hierarchicalAverage(xData, ... + curTable(:, 'SubjectID'), @nanmean); + else + xVals = xData; + end + + % Align lengths (X and Y may differ after hierarchical averaging) + n = min(length(xVals), length(yVals)); + xVals = xVals(1:n); + yVals = yVals(1:n); + + % Remove NaN pairs + validIdx = ~isnan(xVals) & ~isnan(yVals); + xVals = xVals(validIdx); + yVals = yVals(validIdx); + N = length(xVals); + + % Compute correlations + curStats = emptyStats(); + curStats.N = N; + + if N >= 3 + [curStats.r, curStats.p] = pf2_base.compat.corr(xVals, yVals, 'Type', 'Pearson'); + [curStats.rho, curStats.pval] = pf2_base.compat.corr(xVals, yVals, 'Type', 'Spearman'); + end + + % Apply Spearman rank transform if requested + if strcmpi(opts.CorrType, 'Spearman') + [~, p2] = sort(xVals, 'descend'); + r2 = 1:length(xVals); + r2(p2) = r2; + xVals = r2(:); + + [~, p2] = sort(yVals, 'descend'); + r2 = 1:length(yVals); + r2(p2) = r2; + yVals = r2(:); + end + + % Flip axes + if opts.FlipXY + temp = xVals; + xVals = yVals; + yVals = temp; + end + + % Scatter points + scatter(ax, xVals, yVals, 25, clr, 'filled', 'MarkerFaceAlpha', 0.7); + + % Regression line and error band + if (opts.FitLine || opts.ErrorBand) && N > 2 + [coefficients, PolyS] = polyfit(xVals, yVals, 1); + curStats.coefficients = coefficients; + xFit = linspace(min(xVals), max(xVals), 200); + [yFit, deltaY] = polyval(coefficients, xFit, PolyS); + + % Error band + if opts.ErrorBand + yEst = polyval(coefficients, xVals); + yDiff = yVals - yEst; + SD = std(yDiff); + SEM = SD / sqrt(N); + + switch opts.ErrorBandType + case 'SEM' + yUpper = yFit + SEM; + yLower = yFit - SEM; + case 'SD' + yUpper = yFit + SD; + yLower = yFit - SD; + case '95%CI' + CI = pf2_base.external.polyparci(coefficients, PolyS); + yUpper = polyval(CI(1,:), xFit); + yLower = polyval(CI(2,:), xFit); + case '95%PI' + yUpper = yFit + deltaY * tinv(0.95, N - 1); + yLower = yFit - deltaY * tinv(0.95, N - 1); + otherwise + yUpper = yFit + deltaY * tinv(0.95, N - 1); + yLower = yFit - deltaY * tinv(0.95, N - 1); + end + + plotBand(ax, xFit, yUpper, yLower, clr, opts.ErrorBandStyle); + end + + % Regression line + if opts.FitLine + h = plot(ax, xFit, yFit, '-', 'Color', clr, 'LineWidth', 1.5); + set(h.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + + % Annotation with stats (stacked by group index) + if strcmpi(opts.CorrType, 'Spearman') + statStr = sprintf('rho=%.3f, p=%.4f', curStats.rho, curStats.pval); + else + statStr = sprintf('r=%.3f, p=%.4f', curStats.r, curStats.p); + end + yOff = 0.98 - (gIdx - 1) * 0.08; + text(ax, 0.02, yOff, ... + sprintf('N=%d, %s', N, statStr), ... + 'Units', 'normalized', 'FontSize', 7, 'Color', clr, ... + 'VerticalAlignment', 'top'); + end + end +end + + +function [fig, stats] = plotTopoCorrelation(groups, opts, allChannels, tIdx) +% Compute per-channel correlations and render topo map + nGroups = length(groups); + nBioM = length(opts.Biomarkers); + nCh = length(allChannels); + + nCols = nGroups; + nRows = nBioM; + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth * min(nCols, 3), ... + 'Height', opts.SaveHeight * min(nRows, 3), ... + 'SavePath', opts.SavePath); + + stats = struct(); + + for bIdx = 1:nBioM + bioM = opts.Biomarkers{bIdx}; + + for g = 1:nGroups + curGrand = groups(g).gbyGrandBarFlat; + curTable = groups(g).gbyTables; + + rVals = nan(1, nCh); + pVals = nan(1, nCh); + rhoVals = nan(1, nCh); + pvalVals = nan(1, nCh); + nVals = nan(1, nCh); + + for chI = 1:nCh + ch = allChannels(chI); + + if ~isfield(curGrand, bioM) || ch > size(curGrand.(bioM).data, 2) + continue; + end + + % Y: biomarker at channel for this time bin + yVals = permute(curGrand.(bioM).data(tIdx, ch, :), [3, 1, 2]); + if strcmpi(opts.Averaging, 'hierarchy') && ... + isfield(curGrand, 'info') && isfield(curGrand.info, 'Hierarchy') + yVals = pf2_base.hierarchicalAverage(yVals, ... + curGrand.info.Hierarchy, @nanmean); + end + + % X: info variable + xData = curTable.(opts.InfoVar); + if ~isnumeric(xData) + xData = double(string(xData)); + end + xData(xData == -9999) = NaN; + if strcmpi(opts.Averaging, 'hierarchy') && ... + ismember('SubjectID', curTable.Properties.VariableNames) + xVals = pf2_base.hierarchicalAverage(xData, ... + curTable(:, 'SubjectID'), @nanmean); + else + xVals = xData; + end + + n = min(length(xVals), length(yVals)); + xV = xVals(1:n); + yV = yVals(1:n); + valid = ~isnan(xV) & ~isnan(yV); + xV = xV(valid); + yV = yV(valid); + + nVals(chI) = length(xV); + if nVals(chI) >= 3 + [rVals(chI), pVals(chI)] = pf2_base.compat.corr(xV, yV, 'Type', 'Pearson'); + [rhoVals(chI), pvalVals(chI)] = pf2_base.compat.corr(xV, yV, 'Type', 'Spearman'); + end + end + + % Select correlation type + if strcmpi(opts.CorrType, 'Spearman') + curR = rhoVals; + curP = pvalVals; + clrBarTitle = 'rho'; + else + curR = rVals; + curP = pVals; + clrBarTitle = 'r'; + end + + % FDR correction + [curQ, ~] = exploreFNIRS.fx.performFDR(curP, opts.SigThreshold); + + % Store stats + stats(g, bIdx).r = rVals; + stats(g, bIdx).p = pVals; + stats(g, bIdx).rho = rhoVals; + stats(g, bIdx).pval = pvalVals; + stats(g, bIdx).N = nVals; + stats(g, bIdx).q = curQ; + + % Plot topo + spIdx = (bIdx - 1) * nGroups + g; + ax = subplot(nRows, nCols, spIdx, 'Parent', fig); + + % Determine significance threshold + switch opts.SigType + case 'q' + sigP = curQ; + case 'q-twostep' + [curQ2] = exploreFNIRS.fx.performFDR_twostep(curP, opts.SigThreshold); + sigP = curQ2; + stats(g, bIdx).q = curQ2; + otherwise + sigP = curP; + end + + % Find significant channels and threshold + sigMask = sigP <= opts.SigThreshold; + if any(sigMask) + minR = min(abs(curR(sigMask))); + if ~isempty(which('pf2.probe.plot.interpolateValues')) + axes(ax); %#ok + pf2.probe.plot.interpolateValues(curR, [], ... + [minR, -minR], [], groups(g).label, clrBarTitle, ... + 'bufferDistance', 1); + else + bar(ax, allChannels, curR, 'FaceColor', 'flat'); + ylabel(ax, clrBarTitle); + xlabel(ax, 'Channel'); + title(ax, pf2_base.plot.escapeTeX(groups(g).label)); + end + else + text(ax, 0.5, 0.5, sprintf('%s\nn.s.', pf2_base.plot.escapeTeX(groups(g).label)), ... + 'HorizontalAlignment', 'center', 'Units', 'normalized'); + axis(ax, 'off'); + end + end + end + + % Title + if ~isempty(opts.Title) + pf2_base.external.suptitle(fig, pf2_base.plot.escapeTeX(opts.Title)); + else + pf2_base.external.suptitle(fig, sprintf('Topo: %s (%s, %s=%.2f)', ... + pf2_base.plot.escapeTeX(opts.InfoVar), opts.CorrType, ... + opts.SigType, opts.SigThreshold)); + end +end + + +function plotBand(ax, xFit, yUpper, yLower, clr, style) +% Plot error band around regression line + errColor = clr + (1 - clr) * 0.55; + + switch style + case 'Shaded' + xPatch = [xFit, fliplr(xFit)]; + yPatch = [yLower, fliplr(yUpper)]; + h = patch(ax, xPatch, yPatch, -1, ... + 'FaceColor', errColor, 'EdgeColor', 'none', 'FaceAlpha', 0.15); + set(h, 'HandleVisibility', 'off'); + case 'Dashed' + h1 = plot(ax, xFit, yUpper, '--', 'Color', errColor, 'LineWidth', 1.5); + h2 = plot(ax, xFit, yLower, '--', 'Color', errColor, 'LineWidth', 1.5); + set(h1.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + set(h2.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + case 'Fine' + h1 = plot(ax, xFit, yUpper, '-', 'Color', errColor, 'LineWidth', 0.5); + h2 = plot(ax, xFit, yLower, '-', 'Color', errColor, 'LineWidth', 0.5); + set(h1.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + set(h2.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + end +end + + +function tf = showLegend(mode, idx, total) +% Determine whether to show legend on this subplot + switch lower(mode) + case 'last', tf = (idx == total); + case 'first', tf = (idx == 1); + case 'all', tf = true; + case 'none', tf = false; + otherwise, tf = (idx == total); + end +end + + +function s = emptyStats() +% Return empty stats struct + s = struct('r', NaN, 'p', NaN, 'rho', NaN, 'pval', NaN, ... + 'N', 0, 'coefficients', [], 'q', []); +end + + +function [roiIdx, roiNames] = resolveROIs(groups, rois) +% Convert ROI input to numeric indices and name strings + roiInfo = groups(1).gbyGrandBarFlat.ROI.info; + allNames = roiInfo.Properties.RowNames; + + if ischar(rois) || isstring(rois) + if strcmpi(rois, 'all') + roiIdx = 1:length(allNames); + else + roiIdx = find(ismember(allNames, {char(rois)})); + end + elseif iscell(rois) + roiIdx = find(ismember(allNames, rois)); + elseif islogical(rois) + roiIdx = find(rois); + else + roiIdx = rois; % numeric + end + + roiIdx = roiIdx(roiIdx <= length(allNames)); + roiNames = allNames(roiIdx); +end + + +function ssIdx = getShortSeparationIdx(dev, groups) +% Get short-separation channel indices from Device or probe info + ssIdx = []; + if ~isempty(dev) && isa(dev, 'pf2.Device') + ssIdx = find(dev.isShortSep()); + return; + end + for g = 1:length(groups) + ga = groups(g).gbyGrand; + if isfield(ga, 'probeInfo') && isstruct(ga.probeInfo) + pi = ga.probeInfo; + if isfield(pi, 'TableOpt') && istable(pi.TableOpt) ... + && ismember('IsShortSeparation', pi.TableOpt.Properties.VariableNames) + ssIdx = find(pi.TableOpt.IsShortSeparation); + return; + end + if isfield(pi, 'SD') && isstruct(pi.SD) && isfield(pi.SD, 'distances') + ssIdx = find(pi.SD.distances < 2); + return; + end + end + end +end diff --git a/+exploreFNIRS/+core/plotTemporal.m b/+exploreFNIRS/+core/plotTemporal.m new file mode 100644 index 00000000..e2c1de5a --- /dev/null +++ b/+exploreFNIRS/+core/plotTemporal.m @@ -0,0 +1,672 @@ +function fig = plotTemporal(groups, varargin) +% PLOTTEMPORAL Headless temporal plot from grouped/aggregated experiment data +% +% Creates publication-ready time-series plots showing the hemodynamic +% response for each group, with shaded error bands. Each channel and +% biomarker gets its own subplot — channels and biomarkers are never +% averaged. Groups are overlaid as traces within each subplot. +% +% Syntax: +% fig = plotTemporal(groups) +% fig = plotTemporal(groups, 'Biomarkers', {'HbO'}, 'Channels', 1:5) +% fig = plotTemporal(groups, 'ROIs', 'all', 'Biomarkers', {'HbO'}) +% fig = plotTemporal(groups, 'SavePath', 'temporal.png') +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% Each element must have .gbyGrand with .HbO, .HbR, etc. +% +% Name-Value Parameters: +% Biomarkers - Cell array of biomarkers to plot (default: {'HbO','HbR'}) +% Channels - Vector of channel indices (default: all channels) +% ROIs - ROI indices, names, or 'all' (default: []) +% When provided, data is read from gbyGrand.ROI instead of +% gbyGrand. Mutually exclusive with Channels. +% ErrorType - 'SEM' (default), 'SD', or 'none' +% ShowN - Show subject count (n=X) in legend labels (default: true) +% Legend - 'last' (default), 'first', 'all', or 'none' +% Controls which subplot(s) show the legend. +% YLim - [min max] y-axis limits (default: auto, shared across subplots) +% XLim - [min max] x-axis limits (default: auto, shared across subplots) +% PlotBy - Groupby variable to split subplots by (e.g., 'Condition'). +% Creates one subplot row per PlotBy value, with within-group +% traces overlaid. When combined with multiple biomarkers, a +% separate figure is created per biomarker. +% Title - Figure title (default: auto-generated) +% Visible - 'on' (default) or 'off' for headless mode +% SavePath - File path to save figure (triggers headless) +% SaveWidth - Width in pixels (default: 800) +% SaveHeight - Height in pixels (default: 500) +% SaveDPI - Resolution (default: 150) +% Colors - Group color palette override (default: [] = auto) +% [N x 3] RGB matrix, colormap name (e.g. 'Set1', 'tab10'), +% or function handle @(N) returning [N x 3]. +% VLines - Vertical annotation lines drawn on all subplots. +% Numeric vector of time positions (default dashed gray), or +% struct array with fields: +% .time - (required) scalar time position +% .label - (optional) text label string +% .color - (optional) color spec (default: [0.5 0.5 0.5]) +% .style - (optional) line style (default: '--') +% +% Layout: +% Each channel/ROI and biomarker combination gets its own subplot. +% Groups are overlaid as colored traces within each subplot. +% +% - 1 biomarker: channels arranged in a square-ish grid +% - Multiple biomarkers, no PlotBy: rows = biomarkers, columns = channels +% - With PlotBy, 1 biomarker: rows = PlotBy values, columns = channels +% - With PlotBy, multiple biomarkers: separate figure per biomarker, +% rows = PlotBy values, columns = channels +% +% Outputs: +% fig - Figure handle (or array of handles if multiple figures created) +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group','Condition'}); +% ex.aggregate(); +% +% % All channels, HbO only +% fig = exploreFNIRS.core.plotTemporal(ex.groups, 'Biomarkers', {'HbO'}); +% +% % Specific channels +% fig = exploreFNIRS.core.plotTemporal(ex.groups, ... +% 'Biomarkers', {'HbO'}, 'Channels', [5, 10]); +% +% % Multiple biomarkers with PlotBy (one figure per biomarker) +% figs = exploreFNIRS.core.plotTemporal(ex.groups, ... +% 'Biomarkers', {'HbO','HbR'}, 'PlotBy', 'Condition'); +% +% % Vertical annotation lines (numeric = dashed gray) +% fig = exploreFNIRS.core.plotTemporal(ex.groups, 'VLines', [0, 30]); +% +% % Labeled VLines with custom colors and styles +% vl = struct('time', {0, 30}, 'label', {'Onset','Offset'}, ... +% 'color', {'r','b'}, 'style', {'-','--'}); +% fig = exploreFNIRS.core.plotTemporal(ex.groups, 'VLines', vl); +% +% See also: exploreFNIRS.core.Experiment, exploreFNIRS.core.plotBar + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addParameter(p, 'Biomarkers', {'HbO','HbR'}, @iscell); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'ROIs', [], @(x) isnumeric(x) || islogical(x) || ischar(x) || isstring(x) || iscell(x)); + addParameter(p, 'Device', [], @(v) isempty(v) || isa(v, 'pf2.Device')); + addParameter(p, 'ExcludeShortSeparation', true, @islogical); + addParameter(p, 'ErrorType', 'SEM', @ischar); + addParameter(p, 'ShowN', true, @islogical); + addParameter(p, 'Legend', 'last', @ischar); + addParameter(p, 'PlotBy', '', @ischar); + addParameter(p, 'YLim', [], @isnumeric); + addParameter(p, 'XLim', [], @isnumeric); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 800, @isnumeric); + addParameter(p, 'SaveHeight', 500, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colors', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x) || isa(x, 'function_handle') || isa(x, 'exploreFNIRS.core.ColorScheme')); + addParameter(p, 'VLines', [], @(x) isempty(x) || isnumeric(x) || isstruct(x)); + % Overlay trial-averaged auxiliary signal(s) on a right y-axis, time-locked + % to the same epoch grid (e.g. {'heartRate'}). Requires aggregate() to have + % averaged the Aux (AverageAux). Drawn as dashed lines per group. + addParameter(p, 'AuxOverlay', {}, @(x) ischar(x) || isstring(x) || iscell(x)); + parse(p, groups, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + nGroups = length(groups); + nBioM = length(opts.Biomarkers); + + % Validate groups + for g = 1:nGroups + if isempty(groups(g).gbyGrand) + error('exploreFNIRS:core:plotTemporal', ... + 'Group %d has no grand average. Call aggregate() first.', g); + end + end + + % Resolve channels/ROIs (default = all) + if ~isempty(opts.ROIs) + if ~isempty(opts.Channels) + error('exploreFNIRS:core:plotTemporal', ... + 'ROIs and Channels are mutually exclusive.'); + end + if ~isfield(groups(1).gbyGrand, 'ROI') + error('exploreFNIRS:core:plotTemporal', ... + 'No ROI data in grand average. Define ROIs before aggregating.'); + end + [roiIdx, roiNames] = resolveROIs(groups, opts.ROIs); + plotItems = roiIdx; + itemNames = roiNames; + useROI = true; + else + useROI = false; + if isempty(opts.Channels) + nTotalCh = size(groups(1).gbyGrand.(opts.Biomarkers{1}).Mean, 2); + plotItems = 1:nTotalCh; + else + plotItems = opts.Channels; + end + % Exclude short-separation channels + if opts.ExcludeShortSeparation + ssIdx = getShortSeparationIdx(opts.Device, groups); + if ~isempty(ssIdx) + plotItems = plotItems(~ismember(plotItems, ssIdx)); + end + end + itemNames = arrayfun(@(c) sprintf('Ch %d', c), plotItems, ... + 'UniformOutput', false); + end + nItems = length(plotItems); + + sty = pf2_base.plot.PlotStyle.getDefault(); + + % --- Determine layout --- + hasPB = ~isempty(opts.PlotBy); + if hasPB + [plotByValues, subGroups, withinLabels, plotByIdx] = ... + exploreFNIRS.core.splitGroupsByFactor(groups, opts.PlotBy); + nPlotBy = length(plotByValues); + end + + if hasPB && nBioM > 1 + % Separate figure per biomarker; rows = PlotBy, cols = channels + nFigs = nBioM; + nRows = nPlotBy; + nCols = nItems; + layoutType = 'plotby'; + elseif hasPB + % Single figure; rows = PlotBy, cols = channels + nFigs = 1; + nRows = nPlotBy; + nCols = nItems; + layoutType = 'plotby'; + elseif nBioM > 1 + % Single figure; rows = biomarkers, cols = channels + nFigs = 1; + nRows = nBioM; + nCols = nItems; + layoutType = 'biomarker'; + else + % Single figure; square grid of channels + nFigs = 1; + nRows = ceil(sqrt(nItems)); + nCols = ceil(nItems / nRows); + layoutType = 'channel_grid'; + end + + figs = gobjects(nFigs, 1); + + for fIdx = 1:nFigs + figW = opts.SaveWidth * min(nCols, 4); + figH = opts.SaveHeight * max(nRows * 0.6, 1); + + curFig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', figW, 'Height', figH, 'SavePath', opts.SavePath); + figs(fIdx) = curFig; + + allAxes = gobjects(nRows * nCols, 1); + axCount = 0; + + switch layoutType + case 'plotby' + % rows = PlotBy values, cols = channels + if nBioM > 1 + bioM = opts.Biomarkers{fIdx}; + else + bioM = opts.Biomarkers{1}; + end + + for pIdx = 1:nPlotBy + curGroups = subGroups{pIdx}; + nCurGroups = length(curGroups); + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + curColors = opts.Colors.resolve(curGroups); + else + curColors = exploreFNIRS.core.getGroupColors(nCurGroups, opts.Colors); + end + + curWithin = withinLabels(plotByIdx == pIdx); + for gi = 1:nCurGroups + curGroups(gi).label = curWithin{gi}; + end + + for chI = 1:nItems + spIdx = (pIdx - 1) * nCols + chI; + ax = subplot(nRows, nCols, spIdx, 'Parent', curFig); + hold(ax, 'on'); + axCount = axCount + 1; + allAxes(axCount) = ax; + + [lh, le] = plotChannelOnAxes(ax, curGroups, bioM, ... + opts, curColors, useROI, plotItems(chI), sty); + + plot(ax, xlim(ax), [0 0], '-', 'Color', sty.ZeroLineColor, ... + 'LineWidth', 0.5, 'HandleVisibility', 'off'); + + if pIdx == 1 + title(ax, pf2_base.plot.escapeTeX(itemNames{chI})); + end + if chI == 1 + ylabel(ax, pf2_base.plot.escapeTeX(sprintf('%s: %s', opts.PlotBy, plotByValues{pIdx}))); + end + xlabel(ax, 'Time (s)'); + + spTotal = nPlotBy * nItems; + if ~isempty(lh) && showLegend(opts.Legend, spIdx, spTotal) + lg = legend(ax, lh, le, 'Location', 'best', ... + 'FontSize', sty.LegendFontSize); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + end + box(ax, 'on'); + grid(ax, 'on'); + end + end + + case 'biomarker' + % rows = biomarkers, cols = channels + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + groupColors = opts.Colors.resolve(groups); + else + groupColors = exploreFNIRS.core.getGroupColors(nGroups, opts.Colors); + end + + for bIdx = 1:nBioM + bioM = opts.Biomarkers{bIdx}; + for chI = 1:nItems + spIdx = (bIdx - 1) * nCols + chI; + ax = subplot(nRows, nCols, spIdx, 'Parent', curFig); + hold(ax, 'on'); + axCount = axCount + 1; + allAxes(axCount) = ax; + + [lh, le] = plotChannelOnAxes(ax, groups, bioM, ... + opts, groupColors, useROI, plotItems(chI), sty); + + plot(ax, xlim(ax), [0 0], '-', 'Color', sty.ZeroLineColor, ... + 'LineWidth', 0.5, 'HandleVisibility', 'off'); + + if bIdx == 1 + title(ax, pf2_base.plot.escapeTeX(itemNames{chI})); + end + if chI == 1 + ylabel(ax, sprintf('%s (%s)', bioM, getUnitsLabel(groups(1)))); + end + xlabel(ax, 'Time (s)'); + + spTotal = nBioM * nItems; + if ~isempty(lh) && showLegend(opts.Legend, spIdx, spTotal) + lg = legend(ax, lh, le, 'Location', 'best', ... + 'FontSize', sty.LegendFontSize); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + end + box(ax, 'on'); + grid(ax, 'on'); + end + end + + case 'channel_grid' + % Square grid of channels, 1 biomarker + bioM = opts.Biomarkers{1}; + if isa(opts.Colors, 'exploreFNIRS.core.ColorScheme') + groupColors = opts.Colors.resolve(groups); + else + groupColors = exploreFNIRS.core.getGroupColors(nGroups, opts.Colors); + end + + for chI = 1:nItems + ax = subplot(nRows, nCols, chI, 'Parent', curFig); + hold(ax, 'on'); + axCount = axCount + 1; + allAxes(axCount) = ax; + + [lh, le] = plotChannelOnAxes(ax, groups, bioM, ... + opts, groupColors, useROI, plotItems(chI), sty); + + plot(ax, xlim(ax), [0 0], '-', 'Color', sty.ZeroLineColor, ... + 'LineWidth', 0.5, 'HandleVisibility', 'off'); + + title(ax, pf2_base.plot.escapeTeX(itemNames{chI})); + xlabel(ax, 'Time (s)'); + if chI == 1 || mod(chI - 1, nCols) == 0 + ylabel(ax, getUnitsLabel(groups(1))); + end + + if ~isempty(lh) && showLegend(opts.Legend, chI, nItems) + lg = legend(ax, lh, le, 'Location', 'best', ... + 'FontSize', sty.LegendFontSize); + lg.TextColor = sty.LegendTextColor; + lg.Color = sty.LegendBgColor; + lg.EdgeColor = sty.LegendEdgeColor; + end + box(ax, 'on'); + grid(ax, 'on'); + end + end + + % Shared axes across subplots + allAxes = allAxes(1:axCount); + if strcmp(layoutType, 'biomarker') + % Link within each biomarker row (different biomarkers have + % different Y scales, e.g. HbO vs HbR) + for bIdx = 1:nBioM + rowStart = (bIdx - 1) * nItems + 1; + rowEnd = min(bIdx * nItems, axCount); + linkaxes(allAxes(rowStart:rowEnd), 'xy'); + end + else + linkaxes(allAxes, 'xy'); + end + if ~isempty(opts.YLim), arrayfun(@(a) ylim(a, opts.YLim), allAxes); end + if ~isempty(opts.XLim), arrayfun(@(a) xlim(a, opts.XLim), allAxes); end + + % Vertical annotation lines + if ~isempty(opts.VLines) + drawVLines(allAxes, opts.VLines); + end + + % Figure title + if ~isempty(opts.Title) + if nFigs > 1 + pf2_base.external.suptitle(curFig, sprintf('%s — %s', opts.Title, ... + opts.Biomarkers{fIdx})); + else + pf2_base.external.suptitle(curFig, opts.Title); + end + elseif nFigs > 1 + pf2_base.external.suptitle(curFig, opts.Biomarkers{fIdx}); + end + + sty.applyToFigure(curFig); + + % Save + if nFigs > 1 && ~isempty(opts.SavePath) + [fPath, fName, fExt] = fileparts(opts.SavePath); + figOpts = opts; + figOpts.SavePath = fullfile(fPath, ... + sprintf('%s_%s%s', fName, opts.Biomarkers{fIdx}, fExt)); + pf2_base.plot.handleSave(curFig, figOpts); + else + pf2_base.plot.handleSave(curFig, opts); + end + end + + % Return figure handle(s) + if nFigs == 1 + fig = figs(1); + else + fig = figs; + end +end + + +%% Local helpers + + +function [legendHandles, legendEntries] = plotChannelOnAxes(ax, curGroups, bioM, opts, groupColors, useROI, chIdx, sty) +% Plot biomarker trace for a single channel across groups on one axes + nCurGroups = length(curGroups); + legendEntries = {}; + legendHandles = []; + + for g = 1:nCurGroups + ga = curGroups(g).gbyGrand; + + if useROI + if ~isfield(ga.ROI, bioM) || isempty(ga.ROI.(bioM)) + continue; + end + src = ga.ROI.(bioM); + else + if ~isfield(ga, bioM) || isempty(ga.(bioM)) + continue; + end + src = ga.(bioM); + end + + timeVec = ga.time; + meanData = src.Mean; + nData = src.N; + + switch upper(opts.ErrorType) + case 'SEM' + errData = src.SEM; + case 'SD' + errData = src.SD; + case 'NONE' + errData = zeros(size(meanData)); + otherwise + errData = src.SEM; + end + + if chIdx > size(meanData, 2), continue; end + + mLine = meanData(:, chIdx); + eLine = errData(:, chIdx); + + clr = groupColors(g, :); + + % Error band + if ~strcmpi(opts.ErrorType, 'none') && any(eLine > 0) + upperBound = mLine + eLine; + lowerBound = mLine - eLine; + validIdx = ~isnan(mLine) & ~isnan(upperBound); + if any(validIdx) + tV = timeVec(validIdx); + fill(ax, [tV; flipud(tV)], ... + [upperBound(validIdx); flipud(lowerBound(validIdx))], ... + clr, 'FaceAlpha', sty.ErrorAlpha, 'EdgeColor', 'none', ... + 'HandleVisibility', 'off'); + end + end + + % Mean line + h = plot(ax, timeVec, mLine, '-', ... + 'Color', clr, 'LineWidth', sty.LineWidth); + + legendHandles(end+1) = h; %#ok + + % Legend label + lbl = pf2_base.plot.escapeTeX(curGroups(g).label); + if opts.ShowN && mean(nData(:, chIdx), 'all', 'omitnan') > 0 + nStr = sprintf(' (n=%d)', round(mean(nData(:, chIdx), 'all', 'omitnan'))); + lbl = [lbl, nStr]; %#ok + end + legendEntries{end+1} = lbl; %#ok + end + + % Optional: overlay trial-averaged auxiliary signal(s) on a right y-axis + if isfield(opts, 'AuxOverlay') && ~isempty(opts.AuxOverlay) + overlayAuxOnAxes(ax, curGroups, opts.AuxOverlay, groupColors, sty); + end +end + + +function overlayAuxOnAxes(ax, curGroups, auxOverlay, groupColors, sty) +% OVERLAYAUXONAXES Draw trial-averaged aux signal(s) on the right y-axis + if ischar(auxOverlay) || isstring(auxOverlay) + auxOverlay = cellstr(auxOverlay); + end + + % Gather drawable series first; only switch the axes into dual-y mode if + % there is something to draw (otherwise leave the axes untouched so + % linkaxes/YLim behave identically to a plain plot). + series = struct('t', {}, 'm', {}, 'clr', {}); + drawnNames = {}; + for a = 1:numel(auxOverlay) + auxName = auxOverlay{a}; + nameDrawn = false; + for g = 1:numel(curGroups) + ga = curGroups(g).gbyGrand; + if ~isfield(ga, 'Aux') || isempty(ga.Aux) || ~isstruct(ga.Aux) + continue; + end + src = resolveAuxAvg(ga.Aux, auxName); + if isempty(src) || ~isfield(src, 'Mean') || isempty(src.Mean) + continue; + end + series(end+1) = struct('t', ga.time(:), 'm', src.Mean(:, 1), ... + 'clr', groupColors(g, :)); %#ok + nameDrawn = true; + end + if nameDrawn + drawnNames{end+1} = auxName; %#ok + end + end + + if isempty(series) + return; % nothing resolvable: do not alter the axes + end + + yyaxis(ax, 'right'); + for s = 1:numel(series) + plot(ax, series(s).t, series(s).m, '--', 'Color', series(s).clr, ... + 'LineWidth', sty.LineWidth, 'HandleVisibility', 'off'); + end + ylabel(ax, pf2_base.plot.escapeTeX(strjoin(drawnNames, ', '))); + yyaxis(ax, 'left'); % restore so subsequent left-axis ops are correct +end + + +function src = resolveAuxAvg(auxStruct, name) +% RESOLVEAUXAVG Find the averaged aux struct (Mean/SEM/N) for a base name +% Tries the flattened '_data' field first, then ''. + src = []; + fn = fieldnames(auxStruct); + cand = {[lower(name) '_data'], lower(name)}; + for c = 1:numel(cand) + hit = find(strcmpi(fn, cand{c}), 1); + if ~isempty(hit) && isstruct(auxStruct.(fn{hit})) + src = auxStruct.(fn{hit}); + return; + end + end +end + + +function lbl = getUnitsLabel(group) +% Get units string from a group's grand average + if ~isempty(group.gbyGrand) && isfield(group.gbyGrand, 'units') + lbl = group.gbyGrand.units; + else + lbl = '\DeltaHb'; + end +end + + +function tf = showLegend(mode, idx, total) +% Determine whether to show legend on this subplot + switch lower(mode) + case 'last', tf = (idx == total); + case 'first', tf = (idx == 1); + case 'all', tf = true; + case 'none', tf = false; + otherwise, tf = (idx == total); + end +end + + +function drawVLines(allAxes, vlines) +% Draw vertical annotation lines on all axes + if isnumeric(vlines) + % Simple numeric vector — convert to struct array + vlines = vlines(:); + tmp = struct('time', num2cell(vlines), ... + 'label', repmat({''}, numel(vlines), 1), ... + 'color', repmat({[0.5 0.5 0.5]}, numel(vlines), 1), ... + 'style', repmat({'--'}, numel(vlines), 1)); + vlines = tmp; + end + + for vi = 1:numel(vlines) + v = vlines(vi); + xPos = v.time; + + if isfield(v, 'color') && ~isempty(v.color) + clr = v.color; + else + clr = [0.5 0.5 0.5]; + end + + if isfield(v, 'style') && ~isempty(v.style) + sty = v.style; + else + sty = '--'; + end + + if isfield(v, 'label') && ~isempty(v.label) + lbl = {v.label}; + else + lbl = {}; + end + + hasLabel = ~isempty(lbl); + lineArgs = {'Color', clr, 'LineStyle', sty, 'LineWidth', 1}; + + for ai = 1:numel(allAxes) + ax = allAxes(ai); + pf2_base.external.vline(ax, xPos, lineArgs, lbl, ... + 'handleVisibility', hasLabel); + end + end +end + + +function [roiIdx, roiNames] = resolveROIs(groups, rois) +% Convert ROI input to numeric indices and name strings + roiInfo = groups(1).gbyGrand.ROI.info; + allNames = roiInfo.Properties.RowNames; + + if ischar(rois) || isstring(rois) + if strcmpi(rois, 'all') + roiIdx = 1:length(allNames); + else + roiIdx = find(ismember(allNames, {char(rois)})); + end + elseif iscell(rois) + roiIdx = find(ismember(allNames, rois)); + elseif islogical(rois) + roiIdx = find(rois); + else + roiIdx = rois; % numeric + end + + roiIdx = roiIdx(roiIdx <= length(allNames)); + roiNames = allNames(roiIdx); +end + + +function ssIdx = getShortSeparationIdx(dev, groups) +% Get short-separation channel indices from Device or probe info + ssIdx = []; + if ~isempty(dev) && isa(dev, 'pf2.Device') + ssIdx = find(dev.isShortSep()); + return; + end + for g = 1:length(groups) + ga = groups(g).gbyGrand; + if isfield(ga, 'probeInfo') && isstruct(ga.probeInfo) + pi = ga.probeInfo; + if isfield(pi, 'TableOpt') && istable(pi.TableOpt) ... + && ismember('IsShortSeparation', pi.TableOpt.Properties.VariableNames) + ssIdx = find(pi.TableOpt.IsShortSeparation); + return; + end + if isfield(pi, 'SD') && isstruct(pi.SD) && isfield(pi.SD, 'distances') + ssIdx = find(pi.SD.distances < 2); + return; + end + end + end +end diff --git a/+exploreFNIRS/+core/plotTopo.m b/+exploreFNIRS/+core/plotTopo.m new file mode 100644 index 00000000..1c339f0d --- /dev/null +++ b/+exploreFNIRS/+core/plotTopo.m @@ -0,0 +1,345 @@ +function fig = plotTopo(groups, varargin) +% PLOTTOPO Group-level 2D topographic maps of biomarker amplitude +% +% Creates topographic headplots showing spatial distribution of mean +% biomarker values across channels for each group. Supports time-point +% snapshots and time-window averages. When a Device is provided, channels +% are positioned according to the probe geometry and short-separation +% channels are excluded. +% +% Syntax: +% fig = exploreFNIRS.core.plotTopo(groups) +% fig = exploreFNIRS.core.plotTopo(groups, 'Time', 10) +% fig = exploreFNIRS.core.plotTopo(groups, 'TimeWindow', [5, 15]) +% fig = exploreFNIRS.core.plotTopo(groups, 'Layout', 'pergroup') +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% +% Name-Value Parameters: +% Biomarker - Biomarker to plot (default: 'HbO') +% Device - pf2.Device object for probe layout (default: []) +% Time - Single time point for snapshot (default: []) +% TimeWindow - [start, end] seconds to average over (default: full) +% Colormap - Colormap name or matrix (default: blue-white-red) +% CLim - Color limits [cmin cmax] (default: auto) +% Layout - 'single' (average groups) or 'pergroup' (side-by-side) +% Interpolation - 'none' (default) or 'natural' +% ChannelLabels - Cell array of custom labels per channel (default: channel numbers) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 600) +% SaveHeight - Height in pixels (default: 500) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.core.plotTemporal, exploreFNIRS.core.plotBar + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addParameter(p, 'Biomarker', 'HbO', @ischar); + addParameter(p, 'Device', [], @(v) isempty(v) || isa(v, 'pf2.Device')); + addParameter(p, 'Time', [], @(v) isempty(v) || (isnumeric(v) && isscalar(v))); + addParameter(p, 'TimeWindow', [], @(v) isempty(v) || (isnumeric(v) && numel(v) == 2)); + addParameter(p, 'Colormap', '', @(v) ischar(v) || isnumeric(v)); + addParameter(p, 'CLim', [], @(v) isempty(v) || (isnumeric(v) && numel(v) == 2)); + addParameter(p, 'Layout', 'single', @ischar); + addParameter(p, 'Interpolation', 'none', @ischar); + addParameter(p, 'ChannelLabels', {}, @(v) iscell(v) || isstring(v)); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 500, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colors', [], @(x) true); % Accepted for API consistency, unused (topo uses Colormap) + parse(p, groups, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + bioM = opts.Biomarker; + nGroups = length(groups); + + % Validate + for g = 1:nGroups + if isempty(groups(g).gbyGrand) + error('exploreFNIRS:core:plotTopo', ... + 'Group %d has no grand average. Call aggregate() first.', g); + end + end + + % Resolve probe layout from Device + [probeXY, chMask, chNums] = resolveProbeLayout(opts.Device); + + % Determine layout + if strcmpi(opts.Layout, 'pergroup') && nGroups > 1 + nPanels = nGroups; + figW = opts.SaveWidth * min(nPanels, 4); + else + nPanels = 1; + figW = opts.SaveWidth; + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', figW, 'Height', opts.SaveHeight, 'SavePath', opts.SavePath); + + % Extract channel values per group + groupValues = cell(1, nGroups); + for g = 1:nGroups + ga = groups(g).gbyGrand; + if ~isfield(ga, bioM) || isempty(ga.(bioM)) + continue; + end + + timeVec = ga.time; + meanData = ga.(bioM).Mean; % [T x C] + + % Time selection + if ~isempty(opts.Time) + [~, tIdx] = min(abs(timeVec - opts.Time)); + vals = meanData(tIdx, :); + elseif ~isempty(opts.TimeWindow) + tMask = timeVec >= opts.TimeWindow(1) & timeVec <= opts.TimeWindow(2); + vals = mean(meanData(tMask, :), 1, 'omitnan'); + else + vals = mean(meanData, 1, 'omitnan'); + end + + % Filter to non-short-sep channels + if ~isempty(chMask) && length(vals) >= length(chMask) + vals = vals(chMask); + end + + groupValues{g} = vals(:)'; + end + + % Determine CLim + if isempty(opts.CLim) + allVals = cell2mat(groupValues(~cellfun(@isempty, groupValues))); + if ~isempty(allVals) + maxAbs = max(abs(allVals(:))); + if maxAbs > 0 + cLim = [-maxAbs, maxAbs]; + else + cLim = [-1, 1]; + end + else + cLim = [-1, 1]; + end + else + cLim = opts.CLim; + end + + % Plot + if nPanels == 1 + % Average across groups or single group + validVals = groupValues(~cellfun(@isempty, groupValues)); + if isempty(validVals) + return; + end + allMat = cell2mat(validVals'); + avgVals = mean(allMat, 1, 'omitnan'); + + ax = axes('Parent', fig); + plotTopoOnAxes(ax, avgVals, probeXY, chNums, opts.ChannelLabels, opts, cLim); + else + % Per-group panels + for g = 1:nPanels + ax = subplot(1, nPanels, g, 'Parent', fig); + if ~isempty(groupValues{g}) + plotTopoOnAxes(ax, groupValues{g}, probeXY, chNums, opts.ChannelLabels, opts, cLim); + end + title(ax, pf2_base.plot.escapeTeX(groups(g).label), 'FontSize', 11); + end + end + + % Apply theme to all axes and colorbars + sty = pf2_base.plot.PlotStyle.getDefault(); + sty.applyToFigure(fig); + + % Title + if ~isempty(opts.Title) + pf2_base.external.suptitle(fig, opts.Title); + else + tStr = bioM; + if ~isempty(opts.Time) + tStr = sprintf('%s at t=%.1fs', tStr, opts.Time); + elseif ~isempty(opts.TimeWindow) + tStr = sprintf('%s [%.1f-%.1f]s', tStr, opts.TimeWindow(1), opts.TimeWindow(2)); + end + pf2_base.external.suptitle(fig, tStr); + end + + pf2_base.plot.handleSave(fig, opts); +end + + +function [probeXY, chMask, chNums] = resolveProbeLayout(dev) +% Extract 2D spatial positions and short-sep mask from Device +% probeXY - [nStd x 2] (x,y) positions for standard channels +% chMask - [1 x nTotal] logical, true for standard channels +% chNums - [1 x nStd] channel numbers for labels +% +% Priority: MNI 3D (projected to 2D) > optode Pos2D > subplot layout grid + + probeXY = []; + chMask = []; + chNums = []; + + if isempty(dev) + return; + end + + % Get short-sep mask + ssMask = dev.isShortSep(); + chMask = ~ssMask; + stdIdx = find(chMask); + chNums = stdIdx(:)'; + + % Try MNI 3D positions first (project X,Z to 2D: X=left-right, Z=up-down) + if dev.hasMNI() + mni = dev.mniPositions(); % [nCh x 3] + probeXY = [mni(stdIdx, 1), mni(stdIdx, 3)]; % X, Z + return; + end + + % Try 2D optode positions from config + tbl = dev.optodeTable(); + if ismember('Pos2D_x', tbl.Properties.VariableNames) && ... + ismember('Pos2D_y', tbl.Properties.VariableNames) + px = tbl.Pos2D_x(stdIdx); + py = tbl.Pos2D_y(stdIdx); + if any(px ~= 0) || any(py ~= 0) + probeXY = [px(:), py(:)]; + % Flip Y so top of head is at top of plot + probeXY(:, 2) = max(probeXY(:, 2)) - probeXY(:, 2) + min(probeXY(:, 2)); + return; + end + end + + % Fallback: subplot layout grid + lay = dev.layout2D(); + if isempty(lay) + % No positions — probeXY stays empty (triggers grid in plotTopoOnAxes) + % but keep chMask/chNums so short-sep channels are still excluded + return; + end + + probeXY = zeros(length(stdIdx), 2); + for i = 1:length(stdIdx) + pos = lay{stdIdx(i)}; + if isempty(pos) + probeXY(i, :) = [i, 1]; + else + probeXY(i, 1) = pos(1) + pos(3) / 2; + probeXY(i, 2) = pos(2) + pos(4) / 2; + end + end + probeXY(:, 2) = 1 - probeXY(:, 2); +end + + +function plotTopoOnAxes(ax, vals, probeXY, chNums, customLabels, opts, cLim) +% Plot topographic map on a single axes + nCh = length(vals); + + % Determine channel positions + if ~isempty(probeXY) && size(probeXY, 1) == nCh + xPos = probeXY(:, 1)'; + yPos = probeXY(:, 2)'; + labels = chNums; + else + % Fallback: grid layout + nCols = ceil(sqrt(nCh)); + nRows = ceil(nCh / nCols); + xPos = zeros(1, nCh); + yPos = zeros(1, nCh); + for c = 1:nCh + row = ceil(c / nCols); + col = mod(c - 1, nCols) + 1; + xPos(c) = col; + yPos(c) = nRows - row + 1; + end + if ~isempty(chNums) && length(chNums) == nCh + labels = chNums; + else + labels = 1:nCh; + end + end + + % Override with custom labels if provided + if ~isempty(customLabels) && length(customLabels) == nCh + labels = customLabels; + end + + if strcmpi(opts.Interpolation, 'natural') && nCh > 3 + % Interpolated surface + padX = 0.05 * (max(xPos) - min(xPos) + eps); + padY = 0.05 * (max(yPos) - min(yPos) + eps); + xq = linspace(min(xPos) - padX, max(xPos) + padX, 80); + yq = linspace(min(yPos) - padY, max(yPos) + padY, 80); + [XQ, YQ] = meshgrid(xq, yq); + + F = scatteredInterpolant(xPos(:), yPos(:), vals(:), 'natural', 'none'); + ZQ = F(XQ, YQ); + + imagesc(ax, xq, yq, ZQ, cLim); + set(ax, 'YDir', 'normal'); + hold(ax, 'on'); + scatter(ax, xPos, yPos, 30, vals, 'filled', 'MarkerEdgeColor', 'k'); + hold(ax, 'off'); + else + % Discrete circles per channel + hold(ax, 'on'); + scatter(ax, xPos, yPos, 200, vals, 'filled', 'MarkerEdgeColor', 'k'); + + for c = 1:nCh + if iscell(labels) || isstring(labels) + lbl = char(labels{c}); + else + lbl = sprintf('%d', labels(c)); + end + text(ax, xPos(c), yPos(c), lbl, ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'Color', 'k'); + end + hold(ax, 'off'); + set(ax, 'CLim', cLim); + end + + axis(ax, 'equal'); + padX = 0.08 * (max(xPos) - min(xPos) + eps); + padY = 0.08 * (max(yPos) - min(yPos) + eps); + xlim(ax, [min(xPos) - padX, max(xPos) + padX]); + ylim(ax, [min(yPos) - padY, max(yPos) + padY]); + set(ax, 'XTick', [], 'YTick', []); + + if isempty(opts.Colormap) + colormap(ax, divergingColormap(256)); + elseif ischar(opts.Colormap) + cmapFn = exploreFNIRS.helper.getColormap(opts.Colormap); + colormap(ax, cmapFn(256)); + else + colormap(ax, opts.Colormap); + end + colorbar(ax); +end + + +function cmap = divergingColormap(n) +% Blue-white-red diverging colormap centered on zero + half = floor(n / 2); + r1 = linspace(0.2, 1, half)'; + g1 = linspace(0.3, 1, half)'; + b1 = linspace(0.8, 1, half)'; + r2 = linspace(1, 0.8, n - half)'; + g2 = linspace(1, 0.2, n - half)'; + b2 = linspace(1, 0.2, n - half)'; + cmap = [r1 g1 b1; r2 g2 b2]; +end diff --git a/+exploreFNIRS/+core/plotTopoLME.m b/+exploreFNIRS/+core/plotTopoLME.m new file mode 100644 index 00000000..bb54e5ad --- /dev/null +++ b/+exploreFNIRS/+core/plotTopoLME.m @@ -0,0 +1,931 @@ +function [fig, results] = plotTopoLME(groups, groupByVars, varargin) +% PLOTTOPOLME Topographic map of LME ANOVA statistics (2D or 3D) +% +% Fits LME models per channel and biomarker, then renders significant +% statistics onto a 3D brain surface or 2D probe layout. Each biomarker +% gets its own row of subplots — biomarkers are never combined. One column +% per ANOVA term (including Intercept by default). +% +% Non-significant channels are always NaN-masked so they render as brain +% color (3D) or are hidden (2D). Terms with zero significant channels +% show "n.s." instead. +% +% Two visualization metrics are available via PlotMetric: +% 'F' (default) - F-statistic. Color floor = critical F from inverse CDF. +% 'p' - -log10(p). Higher values = more significant. +% Floor = -log10(SigThreshold) (e.g. 1.3 for alpha=0.05). +% +% Syntax: +% [fig, results] = plotTopoLME(groups, groupByVars) +% [fig, results] = plotTopoLME(groups, groupByVars, 'SigType', 'q') +% [fig, results] = plotTopoLME(groups, groupByVars, 'Projection', '2D') +% [fig, results] = plotTopoLME(groups, groupByVars, 'SavePath', 'out.png') +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% groupByVars - Cell array of grouping variable names used in groupby() +% +% Name-Value Parameters: +% Projection - '3D' (default) or '2D'. When '2D', renders on a flat +% probe layout instead of a 3D brain surface. +% Biomarkers - Cell array (default: {'HbO','HbR','HbTotal','CBSI'}) +% Biomarkers not found in data are silently skipped. +% Channels - Vector of channel indices (default: all) +% DataType - 'fNIRS' (default) or 'ROI'. When 'ROI', fits per-ROI +% LME models and broadcasts each ROI's statistic to all +% its constituent channels for visualization. +% PlotMetric - 'F' (default) or 'p'. When 'F', renders F-statistics. +% When 'p', renders -log10(p) values (higher = more +% significant; 1.3 ~ p<0.05, 2 ~ p<0.01, 3 ~ p<0.001). +% Interpolation - 'none' (default) or 'natural'. 2D mode only: when +% 'natural', interpolates a smooth surface between +% channels. Ignored in 3D mode. +% ROILabels - Show ROI names at spatial centroids (default: true). +% Only applies in 2D + ROI mode. +% ROILabelSize - Font size for ROI centroid labels (default: 9). +% RandomEffects - Random effects formula (default: '1|SubjectID') +% UseIntercept - Include intercept (default: true) +% AllInteractions - Use full interaction model (default: false) +% InfoCovariate - Info variable as covariate (default: '') +% CustomFormula - Override auto-built formula (default: '') +% ExcludeShortSeparation - Skip short separation channels (default: true) +% SigThreshold - Significance threshold (default: 0.05) +% SigType - 'p' (default), 'q', or 'q-twostep' +% ShowIntercept - Include (Intercept) term column (default: true) +% ChannelLabels - Show channel numbers on brain (default: true) +% ChannelLabelSize - Font size for channel labels (default: 6) +% ChannelLabelColor - Color for channel labels (default: 'k') +% ChannelLabelStyle - 'numbers' (default) or 'circles' +% CameraPosition - Camera angle for 3D mode (default: 'auto') +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 900) +% SaveHeight - Height in pixels (default: 500) +% SaveDPI - Resolution (default: 150) +% +% Layout: +% rows = biomarkers, columns = ANOVA terms (Intercept included by default) +% Each subplot shows significant statistics projected onto the brain +% surface (3D) or probe layout (2D). Non-significant channels are hidden. +% +% Outputs: +% fig - Figure handle +% results - Struct from exploreFNIRS.stats.fitLME with added field: +% .sigMasks - Cell array of logical [nCh x nTerms] per biomarker +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group', 'Condition'}); +% ex.aggregate(); +% +% % Default: all biomarkers, F-statistic (3D) +% [fig, results] = ex.plotTopoLME(); +% +% % 2D probe layout +% [fig, results] = ex.plotTopoLME('Projection', '2D'); +% +% % 2D with interpolated surface +% [fig, results] = ex.plotTopoLME('Projection', '2D', ... +% 'Interpolation', 'natural'); +% +% % P-value visualization (-log10 scale) +% [fig, results] = ex.plotTopoLME('Biomarkers', {'HbO'}, ... +% 'PlotMetric', 'p'); +% +% % ROI-level with 2D labels +% [fig, results] = ex.plotTopoLME('DataType', 'ROI', ... +% 'Projection', '2D', 'Biomarkers', {'HbO'}); +% +% See also: exploreFNIRS.stats.fitLME, exploreFNIRS.core.plotLME, +% exploreFNIRS.core.plotTopo, pf2.probe.plot.interpolateValues3D + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addRequired(p, 'groupByVars', @iscell); + addParameter(p, 'Projection', '3D', @(x) ismember(upper(x), {'2D', '3D'})); + addParameter(p, 'Biomarkers', {'HbO','HbR','HbTotal','CBSI'}, @iscell); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'RandomEffects', '1|SubjectID', @ischar); + addParameter(p, 'UseIntercept', true, @islogical); + addParameter(p, 'AllInteractions', false, @islogical); + addParameter(p, 'InfoCovariate', '', @ischar); + addParameter(p, 'CustomFormula', '', @ischar); + addParameter(p, 'SigThreshold', 0.05, @isnumeric); + addParameter(p, 'SigType', 'p', @ischar); + addParameter(p, 'ShowIntercept', true, @islogical); + addParameter(p, 'ChannelLabels', true, @islogical); + addParameter(p, 'ChannelLabelSize', 6, @isnumeric); + addParameter(p, 'ChannelLabelColor', ''); + addParameter(p, 'ChannelLabelStyle', 'numbers', ... + @(x) ismember(x, {'numbers', 'circles'})); + addParameter(p, 'ExcludeShortSeparation', true, @islogical); + addParameter(p, 'DataType', 'fNIRS', @ischar); + addParameter(p, 'SkipTimeFactor', false, @islogical); + addParameter(p, 'PlotMetric', 'F', @(x) ismember(lower(x), {'f', 'p'})); + addParameter(p, 'Interpolation', 'none', @ischar); + addParameter(p, 'ROILabels', true, @islogical); + addParameter(p, 'ROILabelSize', 9, @isnumeric); + addParameter(p, 'CameraPosition', 'auto'); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 900, @isnumeric); + addParameter(p, 'SaveHeight', 500, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + addParameter(p, 'Colormap', '', @(v) ischar(v) || isnumeric(v)); + addParameter(p, 'Colors', [], @(x) true); % Accepted for API consistency, unused + parse(p, groups, groupByVars, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + sty = pf2_base.plot.PlotStyle.getDefault(); + fgColor = sty.ForegroundColor; + bgColor = sty.FigureColor; + + % Default channel label color to match theme if not explicitly set + if isempty(opts.ChannelLabelColor) + opts.ChannelLabelColor = fgColor; + end + + isROIMode = strcmpi(opts.DataType, 'ROI'); + usePMetric = strcmpi(opts.PlotMetric, 'p'); + + % Filter biomarkers to those that exist in the data + ga = groups(1).gbyGrandBarFlat; + validBio = {}; + for i = 1:length(opts.Biomarkers) + if isROIMode + if pf2_base.isnestedfield(ga, ['ROI.' opts.Biomarkers{i}]) + validBio{end+1} = opts.Biomarkers{i}; %#ok + end + else + if isfield(ga, opts.Biomarkers{i}) && ~isempty(ga.(opts.Biomarkers{i})) + validBio{end+1} = opts.Biomarkers{i}; %#ok + end + end + end + if isempty(validBio) + error('exploreFNIRS:core:plotTopoLME', ... + 'None of the requested biomarkers found in data.'); + end + opts.Biomarkers = validBio; + nBioM = length(opts.Biomarkers); + + % Delegate model fitting to stats module + statsArgs = { ... + 'Biomarkers', opts.Biomarkers, ... + 'Channels', opts.Channels, ... + 'RandomEffects', opts.RandomEffects, ... + 'UseIntercept', opts.UseIntercept, ... + 'AllInteractions', opts.AllInteractions, ... + 'InfoCovariate', opts.InfoCovariate, ... + 'CustomFormula', opts.CustomFormula, ... + 'ExcludeShortSeparation', opts.ExcludeShortSeparation, ... + 'SkipTimeFactor', opts.SkipTimeFactor, ... + 'DataType', opts.DataType}; + results = exploreFNIRS.stats.fitLME(groups, groupByVars, statsArgs{:}); + + channels = results.channels; + nCh = length(channels); + + % Get probe struct from first subject in first group + probeSeg = groups(1).gbyFNIRS{1}; + + % Total channels in the probe (may differ from fitted channels) + nProbeCh = size(probeSeg.HbO, 2); + + % In ROI mode, extract ROI info for broadcasting values to channels + roiInfo = []; + if isROIMode + if pf2_base.isnestedfield(ga, 'ROI.info') + roiInfo = ga.ROI.info; + else + error('exploreFNIRS:core:plotTopoLME', ... + 'ROI mode requires ROI.info in grand average data.'); + end + end + + % Extract ANOVA terms + termNames = getTermNames(results, nBioM, nCh, opts.ShowIntercept); + if isempty(termNames) + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + ax = axes('Parent', fig); + text(ax, 0.5, 0.5, 'No models fitted', ... + 'HorizontalAlignment', 'center', 'Units', 'normalized'); + axis(ax, 'off'); + pf2_base.plot.handleSave(fig, opts); + return; + end + nTerms = length(termNames); + + % Compute significance masks per biomarker + results.sigMasks = cell(nBioM, 1); + for bIdx = 1:nBioM + [~, pMatrix] = extractBiomarkerAnova(results, bIdx, nCh, termNames); + sigMask = false(nCh, nTerms); + + for t = 1:nTerms + pVals = pMatrix(:, t)'; + switch opts.SigType + case 'q' + [corrP, ~] = exploreFNIRS.fx.performFDR(pVals, opts.SigThreshold); + case 'q-twostep' + corrP = exploreFNIRS.fx.performFDR_twostep(pVals, opts.SigThreshold); + otherwise + corrP = pVals; + end + sigMask(:, t) = corrP(:) <= opts.SigThreshold; + end + results.sigMasks{bIdx} = sigMask; + end + + % Branch: 2D probe layout vs 3D brain surface + if strcmpi(opts.Projection, '2D') + fig = render2D(opts, results, termNames, nBioM, nCh, nProbeCh, ... + channels, probeSeg, roiInfo, isROIMode, usePMetric, sty); + else + fig = render3D(opts, results, termNames, nBioM, nCh, nProbeCh, ... + channels, probeSeg, roiInfo, isROIMode, usePMetric, sty); + end + + pf2_base.plot.handleSave(fig, opts); +end + + +%% 3D rendering (original path) + + +function fig = render3D(opts, results, termNames, nBioM, nCh, nProbeCh, ... + channels, probeSeg, roiInfo, isROIMode, usePMetric, sty) + + fgColor = sty.ForegroundColor; + bgColor = sty.FigureColor; + + nRows = nBioM; + nCols = length(termNames); + nTerms = nCols; + + figW = opts.SaveWidth * min(nCols, 4); + figH = opts.SaveHeight * max(nRows, 1); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', figW, 'Height', figH, 'SavePath', opts.SavePath); + set(fig, 'Color', bgColor); + + gridLeft = 0.06; + gridTop = 0.10; + gridRight = 0.08; + gridBottom = 0.06; + cellW = (1 - gridLeft - gridRight) / nCols; + cellH = (1 - gridTop - gridBottom) / nRows; + cellPad = 0.02; + + cbarW = 0.015; + cbarGap = 0.008; + cbarTickSpace = 0.045; + cbarSpace = cbarW + cbarGap + cbarTickSpace; + + hotCroppedMap = resolveColormap(opts); + + cbarHFrac = 0.75; + + subAxes = gobjects(nBioM, nTerms); + cellCbs = cell(nBioM, nTerms); + cellCbAxes = cell(nBioM, nTerms); + cellCbParams = cell(nBioM, nTerms); + + for bIdx = 1:nBioM + [fMatrix, pMatrix] = extractBiomarkerAnova(results, bIdx, nCh, termNames); + sigMask = results.sigMasks{bIdx}; + + for t = 1:nTerms + xPos = gridLeft + (t - 1) * cellW + cellPad; + yPos = gridBottom + (nRows - bIdx) * cellH + cellPad; + w = cellW - 2 * cellPad - cbarSpace; + h = cellH - 2 * cellPad; + + ax = axes('Parent', fig, 'Position', [xPos, yPos, w, h], ... + 'PositionConstraint', 'innerposition'); + subAxes(bIdx, t) = ax; + + fVals = fMatrix(:, t); + mask = sigMask(:, t); + nSig = sum(mask); + + if nSig == 0 + set(ax, 'Color', bgColor); + title(ax, pf2_base.plot.escapeTeX(termNames{t}), ... + 'FontSize', 11, 'FontWeight', 'bold', 'Color', fgColor); + text(ax, 0.5, 0.45, 'n.s.', ... + 'HorizontalAlignment', 'center', 'Units', 'normalized', ... + 'FontSize', 12, 'Color', [0.5 0.5 0.5]); + set(ax, 'XTick', [], 'YTick', [], 'Box', 'off', ... + 'XColor', 'none', 'YColor', 'none'); + disableDefaultInteractivity(ax); + ax.Toolbar.Visible = 'off'; + ax.HitTest = 'off'; + ax.PickableParts = 'none'; + continue; + end + + titleStr = pf2_base.plot.escapeTeX(termNames{t}); + + [plotVals, colorFloor, colorCeil, cbTitle] = computeCellValues( ... + fVals, pMatrix(:, t), mask, channels, nProbeCh, ... + isROIMode, roiInfo, usePMetric, opts, results, bIdx, nCh, termNames{t}); + + % Build channel label display options + labelArgs = {'ChannelLabels', opts.ChannelLabels, ... + 'labelfontsize', opts.ChannelLabelSize, ... + 'labelfontcolor', opts.ChannelLabelColor}; + if strcmp(opts.ChannelLabelStyle, 'numbers') + noSpheres = [NaN NaN NaN; NaN NaN NaN; NaN NaN NaN]; + labelArgs = [labelArgs, {'labelspherecolors', noSpheres}]; + end + + pf2.probe.plot.interpolateValues3D(ax, plotVals, probeSeg, ... + colorFloor, colorCeil, titleStr, cbTitle, ... + 'initCamPosition', opts.CameraPosition, ... + labelArgs{:}, ... + 'showColorbar', false); + + title(ax, titleStr, 'Color', fgColor); + cellCbParams{bIdx, t} = {[colorFloor, colorCeil], cbTitle}; + + xlabel(ax, ''); ylabel(ax, ''); zlabel(ax, ''); + set(ax, 'XTick', [], 'YTick', [], 'ZTick', []); + set(ax, 'XColor', 'none', 'YColor', 'none', 'ZColor', 'none'); + end + end + + % Create colorbars on separate axes (deferred to avoid layout fighting) + for bIdx = 1:nBioM + for t = 1:nTerms + if ~isvalid(subAxes(bIdx, t)) + continue; + end + xPos = gridLeft + (t - 1) * cellW + cellPad; + yPos = gridBottom + (nRows - bIdx) * cellH + cellPad; + w = cellW - 2 * cellPad - cbarSpace; + h = cellH - 2 * cellPad; + set(subAxes(bIdx, t), 'Position', [xPos, yPos, w, h]); + + if ~isempty(cellCbParams{bIdx, t}) + cbLims = cellCbParams{bIdx, t}{1}; + cbTitleStr = cellCbParams{bIdx, t}{2}; + cbH = h * cbarHFrac; + cbY = yPos + (h - cbH) / 2; + cbAx = axes('Parent', fig, ... + 'Position', [xPos + w + cbarGap, cbY, cbarW, cbH], ... + 'Visible', 'off', 'PositionConstraint', 'innerposition'); + colormap(cbAx, hotCroppedMap); + caxis(cbAx, cbLims); + cb = colorbar(cbAx); + cb.Position = [xPos + w + cbarGap, cbY, cbarW, cbH]; + cb.AxisLocation = 'out'; + title(cb, cbTitleStr); + cellCbs{bIdx, t} = cb; + cellCbAxes{bIdx, t} = cbAx; + end + end + end + + % Figure annotations + addFigureAnnotations(fig, opts, results, isROIMode, nBioM, ... + gridBottom, cellH, nRows, fgColor, bgColor, sty); + + % Final positioning pass + for bIdx = 1:nBioM + for t = 1:nTerms + if ~isvalid(subAxes(bIdx, t)) + continue; + end + xPos = gridLeft + (t - 1) * cellW + cellPad; + yPos = gridBottom + (nRows - bIdx) * cellH + cellPad; + w = cellW - 2 * cellPad - cbarSpace; + h = cellH - 2 * cellPad; + set(subAxes(bIdx, t), 'Position', [xPos, yPos, w, h], ... + 'PositionConstraint', 'innerposition'); + + if ~isempty(cellCbs{bIdx, t}) && isvalid(cellCbs{bIdx, t}) + cbH = h * cbarHFrac; + cbY = yPos + (h - cbH) / 2; + cellCbs{bIdx, t}.Position = [xPos + w + cbarGap, cbY, cbarW, cbH]; + cellCbs{bIdx, t}.AxisLocation = 'out'; + set(cellCbs{bIdx, t}, 'Color', fgColor); + set(cellCbs{bIdx, t}.Label, 'Color', fgColor); + set(cellCbs{bIdx, t}.Title, 'Color', fgColor); + if ~isempty(cellCbAxes{bIdx, t}) && isvalid(cellCbAxes{bIdx, t}) + set(cellCbAxes{bIdx, t}, 'PositionConstraint', 'innerposition'); + end + end + end + end + + annotation(fig, 'line', [0 1], [0.001 0.001], 'Color', fig.Color); + annotation(fig, 'line', [0.999 0.999], [0 1], 'Color', fig.Color); +end + + +%% 2D rendering + + +function fig = render2D(opts, results, termNames, nBioM, nCh, nProbeCh, ... + channels, probeSeg, roiInfo, isROIMode, usePMetric, sty) + + fgColor = sty.ForegroundColor; + bgColor = sty.FigureColor; + + nTerms = length(termNames); + nRows = nBioM; + nCols = nTerms; + + % Resolve probe 2D layout + dev = []; + if isfield(probeSeg, 'device') && isa(probeSeg.device, 'pf2.Device') + dev = probeSeg.device; + else + try + dev = pf2_base.resolveDeviceFromData(probeSeg); + catch + end + end + [probeXY, chMask, chNums] = resolveProbeLayout(dev); + + hotCroppedMap = resolveColormap(opts); + + figW = opts.SaveWidth * min(nCols, 4); + figH = opts.SaveHeight * max(nRows, 1); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', figW, 'Height', figH, 'SavePath', opts.SavePath); + set(fig, 'Color', bgColor); + + gridLeft = 0.06; + gridTop = 0.10; + gridRight = 0.02; + gridBottom = 0.06; + cellW = (1 - gridLeft - gridRight) / nCols; + cellH = (1 - gridTop - gridBottom) / nRows; + cellPad = 0.02; + + for bIdx = 1:nBioM + [fMatrix, pMatrix] = extractBiomarkerAnova(results, bIdx, nCh, termNames); + sigMask = results.sigMasks{bIdx}; + + for t = 1:nTerms + xPos = gridLeft + (t - 1) * cellW + cellPad; + yPos = gridBottom + (nRows - bIdx) * cellH + cellPad; + w = cellW - 2 * cellPad; + h = cellH - 2 * cellPad; + + ax = axes('Parent', fig, 'Position', [xPos, yPos, w, h]); + mask = sigMask(:, t); + nSig = sum(mask); + + if nSig == 0 + set(ax, 'Color', bgColor); + title(ax, pf2_base.plot.escapeTeX(termNames{t}), ... + 'FontSize', 11, 'FontWeight', 'bold', 'Color', fgColor); + text(ax, 0.5, 0.45, 'n.s.', ... + 'HorizontalAlignment', 'center', 'Units', 'normalized', ... + 'FontSize', 12, 'Color', [0.5 0.5 0.5]); + set(ax, 'XTick', [], 'YTick', [], 'Box', 'off', ... + 'XColor', 'none', 'YColor', 'none'); + continue; + end + + [plotVals, colorFloor, colorCeil, cbTitle] = computeCellValues( ... + fMatrix(:, t), pMatrix(:, t), mask, channels, nProbeCh, ... + isROIMode, roiInfo, usePMetric, opts, results, bIdx, nCh, termNames{t}); + + % Filter to standard channels (exclude short-sep) + if ~isempty(chMask) + stdVals = plotVals(chMask); + else + stdVals = plotVals; + end + + renderCell2D(ax, stdVals, probeXY, chNums, ... + [colorFloor, colorCeil], hotCroppedMap, opts, fgColor); + + title(ax, pf2_base.plot.escapeTeX(termNames{t}), ... + 'FontSize', 11, 'FontWeight', 'bold', 'Color', fgColor); + + % ROI labels + if isROIMode && opts.ROILabels && ~isempty(roiInfo) + addROILabels2D(ax, roiInfo, channels, mask, ... + probeXY, chMask, opts, fgColor); + end + end + end + + % Figure annotations + addFigureAnnotations(fig, opts, results, isROIMode, nBioM, ... + gridBottom, cellH, nRows, fgColor, bgColor, sty); +end + + +%% Shared helpers + + +function [plotVals, colorFloor, colorCeil, cbTitle] = computeCellValues( ... + fVals, pVals, mask, channels, nProbeCh, ... + isROIMode, roiInfo, usePMetric, opts, results, bIdx, nCh, termName) +% Compute plot values and color range for one grid cell (shared by 2D/3D) + + if usePMetric + logpData = -log10(pVals); + + plotVals = nan(1, nProbeCh); + if isROIMode + sigIdx = find(mask); + for sI = 1:length(sigIdx) + roiIdx = channels(sigIdx(sI)); + memberCh = roiInfo.Optodes{roiIdx}; + plotVals(memberCh) = logpData(sigIdx(sI)); + end + else + plotVals(channels(mask)) = logpData(mask); + end + + colorFloor = -log10(opts.SigThreshold); + colorCeil = max(logpData(mask)); + if colorFloor >= colorCeil + colorCeil = colorFloor + 1; + end + cbTitle = '-log_{10}(p)'; + else + plotVals = nan(1, nProbeCh); + if isROIMode + sigIdx = find(mask); + for sI = 1:length(sigIdx) + roiIdx = channels(sigIdx(sI)); + memberCh = roiInfo.Optodes{roiIdx}; + plotVals(memberCh) = fVals(sigIdx(sI)); + end + else + plotVals(channels(mask)) = fVals(mask); + end + + minF = min(fVals(mask)); + maxF = max(fVals(mask)); + if minF == maxF + maxF = minF + 1; + end + + validP = pVals(~isnan(pVals)); + if ~isempty(validP) + [df1, df2] = getTermDF(results, bIdx, nCh, termName); + if ~isnan(df1) && ~isnan(df2) + fCrit = finv(1 - opts.SigThreshold, df1, df2); + else + fCrit = minF; + end + else + fCrit = minF; + end + + if fCrit >= maxF + fCrit = minF; + end + if fCrit == maxF + maxF = fCrit + 1; + end + + colorFloor = fCrit; + colorCeil = maxF; + cbTitle = 'F-stat'; + end +end + + +function cmap = resolveColormap(opts) +% Resolve colormap from options (hot-cropped default for LME stats) + if ~isempty(opts.Colormap) + if ischar(opts.Colormap) + cmapFn = exploreFNIRS.helper.getColormap(opts.Colormap); + cmap = cmapFn(256); + else + cmap = opts.Colormap; + end + else + cropFn = @(var,n) var(end-n+1:end,:); + cmap = cropFn(hot(ceil(256*1.25)), 256); + end +end + + +function addFigureAnnotations(fig, opts, results, isROIMode, nBioM, ... + gridBottom, cellH, nRows, fgColor, bgColor, sty) +% Add formula title, significance annotation, and biomarker row labels + + formulaStr = regexprep(results.formula, '^[^~]+~', 'biom ~ '); + formulaStr = strrep(formulaStr, '+', ' + '); + formulaStr = regexprep(formulaStr, '\s+', ' '); + if isROIMode + formulaStr = [formulaStr ' (ROI-level)']; + end + sgtitle(fig, pf2_base.plot.escapeTeX(formulaStr), 'Color', fgColor); + + sigStr = sprintf('Thresholded at %s <= %.2f', opts.SigType, opts.SigThreshold); + annotation(fig, 'textbox', [0, 0.97, 0.3, 0.03], 'String', sigStr, ... + 'FitBoxToText', 'on', 'EdgeColor', 'none', 'FontSize', 7, 'Color', fgColor); + + labelAx = axes('Parent', fig, 'Position', [0 0 1 1], 'Visible', 'off', ... + 'HandleVisibility', 'off', 'PickableParts', 'none'); + set(labelAx, 'XLim', [0 1], 'YLim', [0 1]); + for bIdx = 1:nBioM + yCenter = gridBottom + (nRows - bIdx + 0.5) * cellH; + text(labelAx, 0.02, yCenter, opts.Biomarkers{bIdx}, ... + 'Units', 'normalized', 'Rotation', 90, ... + 'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', ... + 'FontSize', 13, 'FontWeight', 'bold', 'Color', fgColor, ... + 'PickableParts', 'none', 'HitTest', 'off'); + end + + sty.applyToFigure(fig); + set(fig, 'Color', bgColor); +end + + +%% 2D-specific helpers + + +function renderCell2D(ax, vals, probeXY, chNums, cLim, cmap, opts, fgColor) +% Render a single 2D topo cell with scatter or interpolated surface + + nCh = length(vals); + + % Determine channel positions + if ~isempty(probeXY) && size(probeXY, 1) == nCh + xPos = probeXY(:, 1)'; + yPos = probeXY(:, 2)'; + labels = chNums; + else + nGridCols = ceil(sqrt(nCh)); + nGridRows = ceil(nCh / nGridCols); + xPos = zeros(1, nCh); + yPos = zeros(1, nCh); + for c = 1:nCh + row = ceil(c / nGridCols); + col = mod(c - 1, nGridCols) + 1; + xPos(c) = col; + yPos(c) = nGridRows - row + 1; + end + if ~isempty(chNums) && length(chNums) == nCh + labels = chNums; + else + labels = 1:nCh; + end + end + + % Separate valid (non-NaN) and NaN channels + validMask = ~isnan(vals); + + if strcmpi(opts.Interpolation, 'natural') && sum(validMask) > 3 + % Interpolated surface + padX = 0.05 * (max(xPos) - min(xPos) + eps); + padY = 0.05 * (max(yPos) - min(yPos) + eps); + xq = linspace(min(xPos) - padX, max(xPos) + padX, 80); + yq = linspace(min(yPos) - padY, max(yPos) + padY, 80); + [XQ, YQ] = meshgrid(xq, yq); + + F = scatteredInterpolant(xPos(validMask)', yPos(validMask)', ... + vals(validMask)', 'natural', 'none'); + ZQ = F(XQ, YQ); + + imagesc(ax, xq, yq, ZQ, cLim); + set(ax, 'YDir', 'normal'); + hold(ax, 'on'); + % Significant channels: filled markers + scatter(ax, xPos(validMask), yPos(validMask), 30, ... + vals(validMask), 'filled', 'MarkerEdgeColor', 'k'); + % Non-significant channels: hollow gray + scatter(ax, xPos(~validMask), yPos(~validMask), 20, ... + 'MarkerEdgeColor', [0.7 0.7 0.7], 'LineWidth', 0.5); + hold(ax, 'off'); + else + % Discrete circles + hold(ax, 'on'); + % Non-significant channels: hollow gray circles (plot first, behind) + scatter(ax, xPos(~validMask), yPos(~validMask), 80, ... + 'MarkerEdgeColor', [0.7 0.7 0.7], 'LineWidth', 0.5); + % Significant channels: filled colored circles + if any(validMask) + scatter(ax, xPos(validMask), yPos(validMask), 200, ... + vals(validMask), 'filled', 'MarkerEdgeColor', 'k'); + end + set(ax, 'CLim', cLim); + + % Channel labels + if opts.ChannelLabels + for c = 1:nCh + if iscell(labels) + lbl = char(labels{c}); + else + lbl = sprintf('%d', labels(c)); + end + text(ax, xPos(c), yPos(c), lbl, ... + 'HorizontalAlignment', 'center', ... + 'FontSize', opts.ChannelLabelSize, 'Color', fgColor); + end + end + hold(ax, 'off'); + end + + axis(ax, 'equal'); + padX = 0.08 * (max(xPos) - min(xPos) + eps); + padY = 0.08 * (max(yPos) - min(yPos) + eps); + xlim(ax, [min(xPos) - padX, max(xPos) + padX]); + ylim(ax, [min(yPos) - padY, max(yPos) + padY]); + set(ax, 'XTick', [], 'YTick', [], 'Box', 'off', ... + 'XColor', 'none', 'YColor', 'none'); + + colormap(ax, cmap); + cb = colorbar(ax); + set(cb, 'Color', fgColor); + if ~isempty(cb.Title) + set(cb.Title, 'Color', fgColor); + end +end + + +function addROILabels2D(ax, roiInfo, channels, mask, probeXY, chMask, opts, fgColor) +% Add ROI name labels at spatial centroids for significant ROIs + + if isempty(probeXY) + return; + end + + sigIdx = find(mask); + if isempty(sigIdx) + return; + end + + hold(ax, 'on'); + for sI = 1:length(sigIdx) + roiIdx = channels(sigIdx(sI)); + if roiIdx > length(roiInfo.Names) + continue; + end + roiName = roiInfo.Names{roiIdx}; + memberCh = roiInfo.Optodes{roiIdx}; + + % Map member channels to standard-channel indices + if ~isempty(chMask) + stdIdx = find(chMask); + [~, posIdx] = ismember(memberCh, stdIdx); + posIdx = posIdx(posIdx > 0); + else + posIdx = memberCh; + posIdx = posIdx(posIdx <= size(probeXY, 1)); + end + + if isempty(posIdx) + continue; + end + + cx = mean(probeXY(posIdx, 1)); + cy = mean(probeXY(posIdx, 2)); + + text(ax, cx, cy, roiName, ... + 'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', ... + 'FontSize', opts.ROILabelSize, 'FontWeight', 'bold', ... + 'Color', fgColor, 'BackgroundColor', [1 1 1 0.7], ... + 'EdgeColor', [0.5 0.5 0.5], 'Margin', 2); + end + hold(ax, 'off'); +end + + +function [probeXY, chMask, chNums] = resolveProbeLayout(dev) +% Extract 2D spatial positions and short-sep mask from Device +% probeXY - [nStd x 2] (x,y) positions for standard channels +% chMask - [1 x nTotal] logical, true for standard channels +% chNums - [1 x nStd] channel numbers for labels + + probeXY = []; + chMask = []; + chNums = []; + + if isempty(dev) + return; + end + + ssMask = dev.isShortSep(); + chMask = ~ssMask; + stdIdx = find(chMask); + chNums = stdIdx(:)'; + + if dev.hasMNI() + mni = dev.mniPositions(); + probeXY = [mni(stdIdx, 1), mni(stdIdx, 3)]; + return; + end + + tbl = dev.optodeTable(); + if ismember('Pos2D_x', tbl.Properties.VariableNames) && ... + ismember('Pos2D_y', tbl.Properties.VariableNames) + px = tbl.Pos2D_x(stdIdx); + py = tbl.Pos2D_y(stdIdx); + if any(px ~= 0) || any(py ~= 0) + probeXY = [px(:), py(:)]; + probeXY(:, 2) = max(probeXY(:, 2)) - probeXY(:, 2) + min(probeXY(:, 2)); + return; + end + end + + lay = dev.layout2D(); + if isempty(lay) + return; + end + + probeXY = zeros(length(stdIdx), 2); + for i = 1:length(stdIdx) + pos = lay{stdIdx(i)}; + if isempty(pos) + probeXY(i, :) = [i, 1]; + else + probeXY(i, 1) = pos(1) + pos(3) / 2; + probeXY(i, 2) = pos(2) + pos(4) / 2; + end + end + probeXY(:, 2) = 1 - probeXY(:, 2); +end + + +%% ANOVA extraction helpers + + +function termNames = getTermNames(results, nBioM, nCh, includeIntercept) +% Extract ANOVA term names from the first fitted model + + termNames = {}; + + for bIdx = 1:nBioM + for chI = 1:nCh + anv = results.anova{bIdx, chI}; + if ~isempty(anv) + allTerms = anv.Term; + if ~includeIntercept + allTerms = allTerms(~strcmpi(allTerms, '(Intercept)')); + end + termNames = allTerms; + return; + end + end + end +end + + +function [df1, df2] = getTermDF(results, bIdx, nCh, termName) +% Get degrees of freedom for a specific ANOVA term from the first valid model + + df1 = NaN; + df2 = NaN; + + for chI = 1:nCh + anv = results.anova{bIdx, chI}; + if ~isempty(anv) + tIdx = find(strcmpi(anv.Term, termName), 1); + if ~isempty(tIdx) + df1 = anv.DF1(tIdx); + df2 = anv.DF2(tIdx); + return; + end + end + end +end + + +function [fMatrix, pMatrix] = extractBiomarkerAnova(results, bIdx, nCh, termNames) +% Extract F-stats and p-values for one biomarker across all channels +% Returns [nCh x nTerms] matrices + + nTerms = length(termNames); + fMatrix = nan(nCh, nTerms); + pMatrix = nan(nCh, nTerms); + + for chI = 1:nCh + anv = results.anova{bIdx, chI}; + if isempty(anv) + continue; + end + + for t = 1:nTerms + tIdx = find(strcmpi(anv.Term, termNames{t}), 1); + if ~isempty(tIdx) + fMatrix(chI, t) = anv.FStat(tIdx); + pMatrix(chI, t) = anv.pValue(tIdx); + end + end + end +end + + diff --git a/+exploreFNIRS/+core/renderBar.m b/+exploreFNIRS/+core/renderBar.m new file mode 100644 index 00000000..bccd9be3 --- /dev/null +++ b/+exploreFNIRS/+core/renderBar.m @@ -0,0 +1,426 @@ +function renderBar(ax, groups, groupIdx, xVar, colorVar, biomarker, channels, opts) +% RENDERBAR Render a bar chart into a single axes +% +% Draws grouped or flat bars for the specified groups, with X-axis and +% Color dimension mapping. Supports interaction terms on X (e.g., 'A:B'). +% +% Syntax: +% renderBar(ax, groups, groupIdx, xVar, colorVar, biomarker, channels, opts) +% +% Inputs: +% ax - Axes handle +% groups - Full groups struct array (after aggregate) +% groupIdx - Indices into groups to render in this cell +% xVar - X-axis variable name (or interaction 'A:B', or '') +% colorVar - Color/legend variable name (or '') +% biomarker - Biomarker field name (e.g., 'HbO') +% channels - Channel indices to average over +% opts - Struct with fields: ErrorType, ShowIndividual, TimeWindow +% +% See also: exploreFNIRS.core.PlotProxy, exploreFNIRS.core.buildLayout + + hold(ax, 'on'); + + if isempty(groupIdx), return; end + + nSel = length(groupIdx); + selGroups = groups(groupIdx); + + % Extract mean/error for each selected group + [groupMeans, groupErrors, groupN, individualData] = ... + extractBarData(selGroups, biomarker, channels, opts); + + % Determine X and Color factor values per group + xVals = getFactorPerGroup(selGroups, xVar); + colorVals = getFactorPerGroup(selGroups, colorVar); + + hasX = ~isempty(xVar); + hasColor = ~isempty(colorVar); + + % Check for ColorScheme + colorSpec = getColorSpec(opts); + useColorScheme = isa(colorSpec, 'exploreFNIRS.core.ColorScheme'); + + if hasX && hasColor + % --- Clustered bar (X categories, Color series) --- + uniqueX = unique(xVals, 'stable'); + uniqueColor = unique(colorVals, 'stable'); + nX = length(uniqueX); + nColor = length(uniqueColor); + + meanMatrix = nan(nX, nColor); + errorMatrix = nan(nX, nColor); + indivData = cell(nX, nColor); + + for i = 1:nSel + xi = find(strcmp(uniqueX, xVals{i}), 1); + ci = find(strcmp(uniqueColor, colorVals{i}), 1); + if ~isempty(xi) && ~isempty(ci) + meanMatrix(xi, ci) = groupMeans(i); + errorMatrix(xi, ci) = groupErrors(i); + indivData{xi, ci} = individualData{i}; + end + end + + if useColorScheme + % Resolve per-group colors, then map to X x Color grid + allColors = colorSpec.resolve(selGroups); + gridColors = nan(nX, nColor, 3); + for i = 1:nSel + xi = find(strcmp(uniqueX, xVals{i}), 1); + ci = find(strcmp(uniqueColor, colorVals{i}), 1); + if ~isempty(xi) && ~isempty(ci) + gridColors(xi, ci, :) = allColors(i, :); + end + end + drawClusteredBars(ax, meanMatrix, errorMatrix, indivData, ... + uniqueX, uniqueColor, gridColors, opts, ... + sprintf('%s (%s)', biomarker, getUnitsLabel(selGroups(1)))); + else + seriesColors = exploreFNIRS.core.getGroupColors(nColor, colorSpec); + + if strcmpi(opts.ErrorType, 'none') + errInput = []; + else + errInput = errorMatrix; + end + + barwebArgs = {'Axes', ax, ... + 'ColorMap', seriesColors, ... + 'Legend', uniqueColor, ... + 'YLabel', sprintf('%s (%s)', biomarker, getUnitsLabel(selGroups(1)))}; + if opts.ShowIndividual + barwebArgs = [barwebArgs, {'DataPoints', indivData}]; + end + + sty = pf2_base.plot.PlotStyle.getDefault(); + pf2_base.external.barweb(meanMatrix, errInput, 0.8, uniqueX, barwebArgs{:}, ... + 'ErrorColor', sty.ForegroundColor); + hold(ax, 'on'); + end + + elseif hasX && ~hasColor + % --- Flat bars with X categories --- + uniqueX = unique(xVals, 'stable'); + nX = length(uniqueX); + + if useColorScheme + colors = colorSpec.resolve(selGroups); + else + colors = exploreFNIRS.core.getGroupColors(nX, colorSpec); + end + + orderedMeans = nan(1, nX); + orderedErrors = nan(1, nX); + orderedIndiv = cell(1, nX); + orderedN = nan(1, nX); + orderedColors = nan(nX, 3); + + for i = 1:nSel + xi = find(strcmp(uniqueX, xVals{i}), 1); + if ~isempty(xi) + orderedMeans(xi) = groupMeans(i); + orderedErrors(xi) = groupErrors(i); + orderedIndiv{xi} = individualData{i}; + orderedN(xi) = groupN(i); + if useColorScheme + orderedColors(xi, :) = colors(i, :); + end + end + end + + if ~useColorScheme + orderedColors = colors; + end + + drawFlatBars(ax, orderedMeans, orderedErrors, orderedN, ... + orderedIndiv, uniqueX, orderedColors, opts); + + elseif ~hasX && hasColor + % --- Flat bars colored by Color variable --- + uniqueColor = unique(colorVals, 'stable'); + nColor = length(uniqueColor); + + if useColorScheme + allColors = colorSpec.resolve(selGroups); + else + allColors = exploreFNIRS.core.getGroupColors(nColor, colorSpec); + end + + orderedMeans = nan(1, nColor); + orderedErrors = nan(1, nColor); + orderedIndiv = cell(1, nColor); + orderedN = nan(1, nColor); + orderedColors = nan(nColor, 3); + + for i = 1:nSel + ci = find(strcmp(uniqueColor, colorVals{i}), 1); + if ~isempty(ci) + orderedMeans(ci) = groupMeans(i); + orderedErrors(ci) = groupErrors(i); + orderedIndiv{ci} = individualData{i}; + orderedN(ci) = groupN(i); + if useColorScheme + orderedColors(ci, :) = allColors(i, :); + else + orderedColors(ci, :) = allColors(ci, :); + end + end + end + + drawFlatBars(ax, orderedMeans, orderedErrors, orderedN, ... + orderedIndiv, uniqueColor, orderedColors, opts); + + else + % --- No X or Color: one bar per group --- + labels = cell(1, nSel); + for i = 1:nSel + labels{i} = pf2_base.plot.escapeTeX(selGroups(i).label); + end + if useColorScheme + colors = colorSpec.resolve(selGroups); + else + colors = exploreFNIRS.core.getGroupColors(nSel, colorSpec); + end + drawFlatBars(ax, groupMeans, groupErrors, groupN, ... + individualData, labels, colors, opts); + end + + % Zero line + plot(ax, xlim(ax), [0 0], 'k-', 'LineWidth', 0.5, 'HandleVisibility', 'off'); + box(ax, 'on'); + grid(ax, 'on'); +end + + +%% Local helpers + +function [means, errors, ns, indiv] = extractBarData(selGroups, biomarker, channels, opts) +% Extract mean and error for each group + nSel = length(selGroups); + means = nan(1, nSel); + errors = nan(1, nSel); + ns = nan(1, nSel); + indiv = cell(1, nSel); + + for i = 1:nSel + ga = selGroups(i).gbyGrand; + if isempty(ga) || ~isfield(ga, biomarker) || isempty(ga.(biomarker)) + continue; + end + + src = ga.(biomarker); + timeVec = ga.time; + + % Time window + if isfield(opts, 'TimeWindow') && ~isempty(opts.TimeWindow) + tMask = timeVec >= opts.TimeWindow(1) & timeVec <= opts.TimeWindow(2); + else + tMask = true(size(timeVec)); + end + if ~any(tMask), continue; end + + chIdx = channels(channels <= size(src.Mean, 2)); + if isempty(chIdx), continue; end + + meanSlice = src.Mean(tMask, chIdx); + means(i) = mean(meanSlice, 'all', 'omitnan'); + + if isfield(src, 'data') && ~isempty(src.data) + subjectData = src.data(tMask, chIdx, :); + perSubject = squeeze(mean(mean(subjectData, 1, 'omitnan'), 2, 'omitnan')); + perSubject = perSubject(:); + perSubject(isnan(perSubject)) = []; + ns(i) = length(perSubject); + indiv{i} = perSubject; + + switch upper(opts.ErrorType) + case 'SEM' + errors(i) = std(perSubject, 'omitnan') / sqrt(ns(i)); + case 'SD' + errors(i) = std(perSubject, 'omitnan'); + case 'NONE' + errors(i) = 0; + end + else + semSlice = src.SEM(tMask, chIdx); + errors(i) = mean(semSlice, 'all', 'omitnan'); + nSlice = src.N(tMask, chIdx); + ns(i) = round(mean(nSlice, 'all', 'omitnan')); + end + end +end + + +function vals = getFactorPerGroup(selGroups, varSpec) +% Get factor value string per group + nSel = length(selGroups); + vals = cell(1, nSel); + + if isempty(varSpec) + vals = {}; + return; + end + + for i = 1:nSel + T = selGroups(i).gbyTables; + if contains(varSpec, ':') + parts = strsplit(varSpec, ':'); + subVals = cell(1, length(parts)); + for p = 1:length(parts) + v = T.(parts{p})(1); + if isnumeric(v) + subVals{p} = num2str(v); + else + subVals{p} = char(string(v)); + end + end + vals{i} = strjoin(subVals, ':'); + else + if ~ismember(varSpec, T.Properties.VariableNames) + vals{i} = ''; + continue; + end + v = T.(varSpec)(1); + if isnumeric(v) + vals{i} = num2str(v); + else + vals{i} = char(string(v)); + end + end + end +end + + +function drawFlatBars(ax, means, errors, ns, indiv, labels, colors, opts) +% Draw simple flat bar chart + nBars = length(means); + sty = pf2_base.plot.PlotStyle.getDefault(); + barX = 1:nBars; + + for i = 1:nBars + bar(ax, barX(i), means(i), 0.6, ... + 'FaceColor', colors(i,:), 'EdgeColor', 'k', 'FaceAlpha', 0.7); + end + + if ~strcmpi(opts.ErrorType, 'none') + errorbar(ax, barX, means, errors, 'k.', ... + 'LineWidth', sty.AxisLineWidth, 'CapSize', 8); + end + + if opts.ShowIndividual + for i = 1:nBars + if ~isempty(indiv{i}) + jitter = (rand(size(indiv{i})) - 0.5) * 0.25; + scatter(ax, barX(i) + jitter, indiv{i}, 20, ... + colors(i,:), 'filled', 'MarkerFaceAlpha', 0.5, ... + 'HandleVisibility', 'off'); + end + end + end + + set(ax, 'XTick', barX, 'XTickLabel', pf2_base.plot.escapeTeX(labels), 'XTickLabelRotation', 30); + + % N labels + for i = 1:nBars + if ~isnan(ns(i)) + yPos = means(i) + errors(i); + if isnan(yPos), yPos = means(i); end + text(ax, barX(i), yPos, sprintf('n=%d', ns(i)), ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'bottom', 'FontSize', 8); + end + end +end + + +function lbl = getUnitsLabel(group) + if ~isempty(group.gbyGrand) && isfield(group.gbyGrand, 'units') + lbl = group.gbyGrand.units; + else + lbl = '\DeltaHb'; + end +end + + +function cs = getColorSpec(opts) +% Extract Colors field from opts if present + if isfield(opts, 'Colors') + cs = opts.Colors; + else + cs = []; + end +end + + +function drawClusteredBars(ax, meanMatrix, errorMatrix, indivData, ... + uniqueX, uniqueColor, gridColors, opts, ylabelStr) +% Draw clustered bars with per-bar colors (for ColorScheme) + nX = length(uniqueX); + nColor = length(uniqueColor); + sty = pf2_base.plot.PlotStyle.getDefault(); + + % Bar positioning + groupWidth = 0.8; + barWidth = groupWidth / nColor; + + legendHandles = gobjects(nColor, 1); + legendLabels = uniqueColor; + + for ci = 1:nColor + for xi = 1:nX + xPos = xi + (ci - (nColor + 1)/2) * barWidth; + clr = squeeze(gridColors(xi, ci, :))'; + if any(isnan(clr)) + clr = [0.7 0.7 0.7]; + end + + h = bar(ax, xPos, meanMatrix(xi, ci), barWidth * 0.9, ... + 'FaceColor', clr, 'EdgeColor', 'k', 'FaceAlpha', 0.7); + + if xi == 1 + legendHandles(ci) = h; + end + end + end + + % Error bars + if ~strcmpi(opts.ErrorType, 'none') + for ci = 1:nColor + for xi = 1:nX + xPos = xi + (ci - (nColor + 1)/2) * barWidth; + if ~isnan(errorMatrix(xi, ci)) + errorbar(ax, xPos, meanMatrix(xi, ci), errorMatrix(xi, ci), ... + 'k.', 'LineWidth', sty.AxisLineWidth, 'CapSize', 6); + end + end + end + end + + % Individual data points + if opts.ShowIndividual + for ci = 1:nColor + for xi = 1:nX + xPos = xi + (ci - (nColor + 1)/2) * barWidth; + pts = indivData{xi, ci}; + if ~isempty(pts) + clr = squeeze(gridColors(xi, ci, :))'; + if any(isnan(clr)), clr = [0.7 0.7 0.7]; end + jitter = (rand(size(pts)) - 0.5) * barWidth * 0.5; + scatter(ax, xPos + jitter, pts, 20, clr, 'filled', ... + 'MarkerFaceAlpha', 0.5, 'HandleVisibility', 'off'); + end + end + end + end + + set(ax, 'XTick', 1:nX, 'XTickLabel', pf2_base.plot.escapeTeX(uniqueX), 'XTickLabelRotation', 30); + ylabel(ax, ylabelStr); + + % Legend + validH = isvalid(legendHandles) & legendHandles ~= 0; + if any(validH) + legend(ax, legendHandles(validH), pf2_base.plot.escapeTeX(legendLabels(validH)), ... + 'Location', 'best', 'FontSize', 8); + end +end diff --git a/+exploreFNIRS/+core/renderScatter.m b/+exploreFNIRS/+core/renderScatter.m new file mode 100644 index 00000000..fde3560f --- /dev/null +++ b/+exploreFNIRS/+core/renderScatter.m @@ -0,0 +1,197 @@ +function [legendHandles, legendEntries, stats] = renderScatter(ax, groups, groupIdx, colorVar, biomarker, channels, infoVar, opts) +% RENDERSCATTER Render scatter plot into a single axes +% +% Draws scatter points correlating an info variable (X) with fNIRS +% biomarker data (Y) for the specified groups. Supports fit lines and +% per-group coloring. +% +% Syntax: +% [h, e, s] = renderScatter(ax, groups, groupIdx, colorVar, biomarker, channels, infoVar, opts) +% +% Inputs: +% ax - Axes handle +% groups - Full groups struct array (after aggregate) +% groupIdx - Indices into groups to render in this cell +% colorVar - Color/legend variable name (or '' for auto) +% biomarker - Biomarker field name (e.g., 'HbO') +% channels - Channel indices to average over +% infoVar - Name of the info variable for X-axis +% opts - Struct with fields: FitLine, CorrType, Averaging +% +% Outputs: +% legendHandles - Array of scatter handles for legend +% legendEntries - Cell array of legend label strings +% stats - Struct array with correlation stats per group +% +% See also: exploreFNIRS.core.PlotProxy, exploreFNIRS.core.buildLayout + + hold(ax, 'on'); + + legendHandles = []; + legendEntries = {}; + + if isempty(groupIdx) + stats = struct([]); + return; + end + + nSel = length(groupIdx); + selGroups = groups(groupIdx); + + % Pre-initialize stats with consistent fields + emptyS = struct('r', NaN, 'p', NaN, 'rho', NaN, 'pval', NaN, ... + 'N', 0, 'coefficients', []); + stats = repmat(emptyS, 1, nSel); + + % Determine colors + colorSpec = []; + if isfield(opts, 'Colors'), colorSpec = opts.Colors; end + + if isa(colorSpec, 'exploreFNIRS.core.ColorScheme') + palette = colorSpec.resolve(selGroups); + colorIdx = 1:nSel; + elseif ~isempty(colorVar) + colorVals = getFactorPerGroup(selGroups, colorVar); + uniqueColors = unique(colorVals, 'stable'); + nColors = length(uniqueColors); + palette = exploreFNIRS.core.getGroupColors(nColors, colorSpec); + colorIdx = zeros(1, nSel); + for i = 1:nSel + colorIdx(i) = find(strcmp(uniqueColors, colorVals{i}), 1); + end + else + nColors = nSel; + palette = exploreFNIRS.core.getGroupColors(nColors, colorSpec); + colorIdx = 1:nSel; + end + + for i = 1:nSel + gIdx = groupIdx(i); + curGrand = groups(gIdx).gbyGrandBarFlat; + curTable = groups(gIdx).gbyTables; + + if isempty(curGrand) || ~isfield(curGrand, biomarker) + continue; + end + + bioData = curGrand.(biomarker); + validCh = channels(channels <= size(bioData.data, 2)); + if isempty(validCh) + continue; + end + + % Y: average across channels and first time bin + tIdx = 1; + yVals = squeeze(mean(bioData.data(tIdx, validCh, :), 2, 'omitnan')); + yVals = yVals(:); + + % X: info variable + if ~ismember(infoVar, curTable.Properties.VariableNames) + continue; + end + xData = curTable.(infoVar); + if ~isnumeric(xData), xData = double(string(xData)); end + xData(xData == -9999) = NaN; + + % Apply averaging to X values + avgMode = 'hierarchy'; + if isfield(opts, 'Averaging'), avgMode = opts.Averaging; end + if strcmpi(avgMode, 'hierarchy') && ... + ismember('SubjectID', curTable.Properties.VariableNames) + xVals = pf2_base.hierarchicalAverage(xData, curTable(:, 'SubjectID'), @nanmean); + else + xVals = xData; + end + + % Align + n = min(length(xVals), length(yVals)); + xVals = xVals(1:n); + yVals = yVals(1:n); + valid = ~isnan(xVals) & ~isnan(yVals); + xVals = xVals(valid); + yVals = yVals(valid); + N = length(xVals); + + % Stats + curStats = struct('r', NaN, 'p', NaN, 'rho', NaN, 'pval', NaN, ... + 'N', N, 'coefficients', []); + if N >= 3 + [curStats.r, curStats.p] = pf2_base.compat.corr(xVals, yVals, 'Type', 'Pearson'); + [curStats.rho, curStats.pval] = pf2_base.compat.corr(xVals, yVals, 'Type', 'Spearman'); + end + stats(i) = curStats; + + clr = palette(colorIdx(i), :); + + % Scatter + h = scatter(ax, xVals, yVals, 25, clr, 'filled', 'MarkerFaceAlpha', 0.7); + legendHandles(end+1) = h; %#ok + legendEntries{end+1} = sprintf('%s (n=%d)', pf2_base.plot.escapeTeX(selGroups(i).label), N); %#ok + + % Fit line + fitLine = false; + if isfield(opts, 'FitLine'), fitLine = opts.FitLine; end + if fitLine && N > 2 + coeffs = polyfit(xVals, yVals, 1); + curStats.coefficients = coeffs; + stats(i).coefficients = coeffs; + xFit = linspace(min(xVals), max(xVals), 50); + yFit = polyval(coeffs, xFit); + hLine = plot(ax, xFit, yFit, '-', 'Color', clr, 'LineWidth', 1.5); + set(hLine.Annotation.LegendInformation, 'IconDisplayStyle', 'off'); + + % Stat annotation + corrType = 'Pearson'; + if isfield(opts, 'CorrType'), corrType = opts.CorrType; end + if strcmpi(corrType, 'Spearman') + statStr = sprintf('rho=%.3f, p=%.4f', curStats.rho, curStats.pval); + else + statStr = sprintf('r=%.3f, p=%.4f', curStats.r, curStats.p); + end + text(ax, 0.02, 0.98 - 0.06 * (i - 1), ... + sprintf('N=%d, %s', N, statStr), ... + 'Units', 'normalized', 'FontSize', 7, 'Color', clr, ... + 'VerticalAlignment', 'top'); + end + end + + xlabel(ax, pf2_base.plot.escapeTeX(infoVar)); + ylabel(ax, sprintf('\\Delta[%s]', biomarker)); + box(ax, 'on'); + grid(ax, 'on'); +end + + +function vals = getFactorPerGroup(selGroups, varSpec) + nSel = length(selGroups); + vals = cell(1, nSel); + if isempty(varSpec), vals = {}; return; end + + for i = 1:nSel + T = selGroups(i).gbyTables; + if contains(varSpec, ':') + parts = strsplit(varSpec, ':'); + subVals = cell(1, length(parts)); + for p = 1:length(parts) + v = T.(parts{p})(1); + if isnumeric(v) + subVals{p} = num2str(v); + else + subVals{p} = char(string(v)); + end + end + vals{i} = strjoin(subVals, ':'); + else + if ~ismember(varSpec, T.Properties.VariableNames) + vals{i} = ''; + continue; + end + v = T.(varSpec)(1); + if isnumeric(v) + vals{i} = num2str(v); + else + vals{i} = char(string(v)); + end + end + end +end diff --git a/+exploreFNIRS/+core/renderTemporal.m b/+exploreFNIRS/+core/renderTemporal.m new file mode 100644 index 00000000..38cd2c01 --- /dev/null +++ b/+exploreFNIRS/+core/renderTemporal.m @@ -0,0 +1,187 @@ +function [legendHandles, legendEntries] = renderTemporal(ax, groups, groupIdx, colorVar, biomarker, channels, opts) +% RENDERTEMPORAL Render temporal traces into a single axes +% +% Draws time-series traces with error bands for the specified groups. +% Color dimension maps groups to different line colors. +% +% Syntax: +% [h, e] = renderTemporal(ax, groups, groupIdx, colorVar, biomarker, channels, opts) +% +% Inputs: +% ax - Axes handle +% groups - Full groups struct array (after aggregate) +% groupIdx - Indices into groups to render in this cell +% colorVar - Color/legend variable name (or '' for auto) +% biomarker - Biomarker field name (e.g., 'HbO') +% channels - Channel indices to average over +% opts - Struct with fields: ErrorType, XLim, YLim +% +% Outputs: +% legendHandles - Array of line handles for legend +% legendEntries - Cell array of legend label strings +% +% See also: exploreFNIRS.core.PlotProxy, exploreFNIRS.core.buildLayout + + hold(ax, 'on'); + sty = pf2_base.plot.PlotStyle.getDefault(); + + legendHandles = []; + legendEntries = {}; + + if isempty(groupIdx), return; end + + nSel = length(groupIdx); + selGroups = groups(groupIdx); + + % Determine colors + colorSpec = []; + if isfield(opts, 'Colors'), colorSpec = opts.Colors; end + + if isa(colorSpec, 'exploreFNIRS.core.ColorScheme') + % ColorScheme: resolve per-group colors directly + palette = colorSpec.resolve(selGroups); + colorIdx = 1:nSel; + elseif ~isempty(colorVar) + % Color by the Color variable values + colorVals = getFactorPerGroup(selGroups, colorVar); + uniqueColors = unique(colorVals, 'stable'); + nColors = length(uniqueColors); + palette = exploreFNIRS.core.getGroupColors(nColors, colorSpec); + + colorIdx = zeros(1, nSel); + for i = 1:nSel + colorIdx(i) = find(strcmp(uniqueColors, colorVals{i}), 1); + end + else + nColors = nSel; + palette = exploreFNIRS.core.getGroupColors(nColors, colorSpec); + colorIdx = 1:nSel; + end + + for i = 1:nSel + ga = selGroups(i).gbyGrand; + if isempty(ga) || ~isfield(ga, biomarker) || isempty(ga.(biomarker)) + continue; + end + + src = ga.(biomarker); + timeVec = ga.time; + meanData = src.Mean; + nData = src.N; + + switch upper(opts.ErrorType) + case 'SEM' + errData = src.SEM; + case 'SD' + errData = src.SD; + case 'NONE' + errData = zeros(size(meanData)); + otherwise + errData = src.SEM; + end + + validCh = channels(channels <= size(meanData, 2)); + if isempty(validCh), continue; end + + if length(validCh) > 1 + mLine = mean(meanData(:, validCh), 2, 'omitnan'); + eLine = mean(errData(:, validCh), 2, 'omitnan'); + else + mLine = meanData(:, validCh); + eLine = errData(:, validCh); + end + + clr = palette(colorIdx(i), :); + + % Error band + if ~strcmpi(opts.ErrorType, 'none') && any(eLine > 0) + upperBound = mLine + eLine; + lowerBound = mLine - eLine; + validIdx = ~isnan(mLine) & ~isnan(upperBound); + if any(validIdx) + tV = timeVec(validIdx); + fill(ax, [tV; flipud(tV)], ... + [upperBound(validIdx); flipud(lowerBound(validIdx))], ... + clr, 'FaceAlpha', sty.ErrorAlpha, 'EdgeColor', 'none', ... + 'HandleVisibility', 'off'); + end + end + + % Mean line + h = plot(ax, timeVec, mLine, '-', 'Color', clr, 'LineWidth', sty.LineWidth); + + legendHandles(end+1) = h; %#ok + + % Build label (hide n=1 since it adds no information) + lbl = pf2_base.plot.escapeTeX(selGroups(i).label); + nSubj = round(mean(nData(:, validCh), 'all', 'omitnan')); + if nSubj > 1 + lbl = [lbl, sprintf(' (n=%d)', nSubj)]; %#ok + end + legendEntries{end+1} = lbl; %#ok + end + + % Zero line + plot(ax, xlim(ax), [0 0], 'k-', 'LineWidth', 0.5, 'HandleVisibility', 'off'); + + xlabel(ax, 'Time (s)'); + + if ~isempty(groupIdx) + ga1 = groups(groupIdx(1)).gbyGrand; + if ~isempty(ga1) && isfield(ga1, 'units') + ylabel(ax, ga1.units); + else + ylabel(ax, '\DeltaHb'); + end + end + + if isfield(opts, 'XLim') && ~isempty(opts.XLim) + xlim(ax, opts.XLim); + end + if isfield(opts, 'YLim') && ~isempty(opts.YLim) + ylim(ax, opts.YLim); + end + + box(ax, 'on'); + grid(ax, 'on'); +end + + +function vals = getFactorPerGroup(selGroups, varSpec) +% Get factor value string per group + nSel = length(selGroups); + vals = cell(1, nSel); + + if isempty(varSpec) + vals = {}; + return; + end + + for i = 1:nSel + T = selGroups(i).gbyTables; + if contains(varSpec, ':') + parts = strsplit(varSpec, ':'); + subVals = cell(1, length(parts)); + for p = 1:length(parts) + v = T.(parts{p})(1); + if isnumeric(v) + subVals{p} = num2str(v); + else + subVals{p} = char(string(v)); + end + end + vals{i} = strjoin(subVals, ':'); + else + if ~ismember(varSpec, T.Properties.VariableNames) + vals{i} = ''; + continue; + end + v = T.(varSpec)(1); + if isnumeric(v) + vals{i} = num2str(v); + else + vals{i} = char(string(v)); + end + end + end +end diff --git a/+exploreFNIRS/+core/splitGroupsByFactor.m b/+exploreFNIRS/+core/splitGroupsByFactor.m new file mode 100644 index 00000000..0e5b556d --- /dev/null +++ b/+exploreFNIRS/+core/splitGroupsByFactor.m @@ -0,0 +1,76 @@ +function [plotByValues, subGroups, withinLabels, plotByIdx] = splitGroupsByFactor(groups, plotByVar) +% SPLITGROUPSBYFACTOR Split groups array by a factor for PlotBy visualization +% +% Given a groups struct array and a factor name, splits the groups into +% subsets based on unique values of that factor. Used by plotBar, +% plotTemporal, and plotScatter to implement the PlotBy parameter. +% +% Syntax: +% [vals, subs, labels, idx] = splitGroupsByFactor(groups, plotByVar) +% +% Inputs: +% groups - Struct array from Experiment.groups +% plotByVar - Name of the groupby variable to split on (e.g., 'Condition') +% +% Outputs: +% plotByValues - Cell array of unique values for the PlotBy variable +% subGroups - Cell array, each element is a struct array subset +% withinLabels - Cell array of labels (one per group, PlotBy factor removed) +% plotByIdx - Vector mapping each group index to its PlotBy value index +% +% Example: +% % With groups from groupby({'Group','Condition'}): +% % groups(1).label = 'Control | TaskA' +% % groups(2).label = 'Control | TaskB' +% % groups(3).label = 'Treatment | TaskA' +% % groups(4).label = 'Treatment | TaskB' +% +% [vals, subs, labels, idx] = splitGroupsByFactor(groups, 'Condition'); +% % vals = {'TaskA', 'TaskB'} +% % subs{1} = groups([1,3]) (TaskA groups) +% % subs{2} = groups([2,4]) (TaskB groups) +% % labels = {'Control', 'Control', 'Treatment', 'Treatment'} +% % idx = [1, 2, 1, 2] +% +% See also: exploreFNIRS.core.plotBar, exploreFNIRS.core.plotTemporal + + nGroups = length(groups); + + % Extract the PlotBy factor value from each group + factorValues = cell(1, nGroups); + for g = 1:nGroups + T = groups(g).gbyTables; + if ~ismember(plotByVar, T.Properties.VariableNames) + error('exploreFNIRS:core:splitGroupsByFactor', ... + 'PlotBy variable "%s" not found in group tables. Available: %s', ... + plotByVar, strjoin(T.Properties.VariableNames, ', ')); + end + val = T.(plotByVar)(1); + if isnumeric(val) + factorValues{g} = num2str(val); + else + factorValues{g} = char(string(val)); + end + end + + [plotByValues, ~, plotByIdx] = unique(factorValues, 'stable'); + nSplits = length(plotByValues); + + subGroups = cell(1, nSplits); + for s = 1:nSplits + subGroups{s} = groups(plotByIdx == s); + end + + % Build within-group labels (remove the PlotBy value from label) + withinLabels = cell(1, nGroups); + for g = 1:nGroups + parts = strsplit(groups(g).label, ' | '); + pbVal = factorValues{g}; + keepParts = parts(~strcmp(parts, pbVal)); + if isempty(keepParts) + withinLabels{g} = groups(g).label; + else + withinLabels{g} = strjoin(keepParts, ' | '); + end + end +end diff --git a/+exploreFNIRS/+coupling/coherence.m b/+exploreFNIRS/+coupling/coherence.m new file mode 100644 index 00000000..29d3d110 --- /dev/null +++ b/+exploreFNIRS/+coupling/coherence.m @@ -0,0 +1,134 @@ +function result = coherence(x, y, fs, varargin) +% COHERENCE Magnitude-squared coherence between two time series +% +% Computes magnitude-squared coherence using MATLAB's mscohere, with +% optional frequency-range filtering to focus on hemodynamic frequencies. +% +% Syntax: +% result = exploreFNIRS.coupling.coherence(x, y, fs) +% result = exploreFNIRS.coupling.coherence(x, y, fs, 'FreqRange', [0.01 0.1]) +% +% Inputs: +% x - [T x 1] time series +% y - [T x 1] time series +% fs - Sampling frequency (Hz) +% +% Name-Value Parameters: +% FreqRange - [fLow fHigh] frequency band in Hz (default: [0.01, fs/2]) +% Typical fNIRS: [0.01, 0.1] for hemodynamic, [0.1, 0.5] for Mayer waves +% WindowLength - Welch segment length in seconds (default: auto, ~8 segments) +% Overlap - Fraction of overlap between segments (default: 0.5) +% NFFT - FFT length (default: next power of 2 of window length) +% +% Outputs: +% result - Struct with fields: +% .value - Mean coherence in FreqRange (scalar) +% .pvalue - Approximate p-value (threshold-based) +% .spectrum - [F x 1] full coherence spectrum +% .freqs - [F x 1] frequency vector (Hz) +% .method - 'coherence' +% .windowed - false (spectral method, not time-windowed) +% .freqRange - Frequency band used +% +% Notes: +% Significance threshold approximation: C_thresh = 1 - alpha^(1/(L-1)) +% where L = number of segments. Values above this are significant at alpha. +% +% References: +% Welch, P. D. (1967). The use of fast Fourier transform for the +% estimation of power spectra: a method based on time averaging over +% short, modified periodograms. IEEE Transactions on Audio and +% Electroacoustics, 15(2), 70-73. DOI: 10.1109/TAU.1967.1161901 +% +% Carter, G. C., Knapp, C. H. & Nuttall, A. H. (1973). Estimation of +% the magnitude-squared coherence function via overlapped fast Fourier +% transform processing. IEEE Transactions on Audio and Electroacoustics, +% 21(4), 337-344. +% +% See also: exploreFNIRS.coupling.pearson, exploreFNIRS.coupling.xcorr, mscohere + + p = inputParser; + addRequired(p, 'x', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'y', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'fs', @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(p, 'FreqRange', [0.01, 0], @(v) isnumeric(v) && length(v) == 2); + addParameter(p, 'WindowLength', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'Overlap', 0.5, @(v) isnumeric(v) && isscalar(v) && v >= 0 && v < 1); + addParameter(p, 'NFFT', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + parse(p, x, y, fs, varargin{:}); + opts = p.Results; + + x = x(:); + y = y(:); + if length(x) ~= length(y) + error('exploreFNIRS:coupling:coherence', 'x and y must have equal length'); + end + + % Fill NaNs for spectral analysis + x = fillNaN(x); + y = fillNaN(y); + + T = length(x); + + % Set frequency range + freqRange = opts.FreqRange; + if freqRange(2) <= 0 + freqRange(2) = fs / 2; + end + + % Auto window length: aim for ~8 segments + if opts.WindowLength <= 0 + winLen = round(T / 8); + winLen = max(winLen, round(fs * 4)); % at least 4 seconds + winLen = min(winLen, T); + else + winLen = round(opts.WindowLength * fs); + end + + overlapSamp = round(winLen * opts.Overlap); + + if opts.NFFT <= 0 + nfft = 2^nextpow2(winLen); + else + nfft = opts.NFFT; + end + + % Compute coherence + [cxy, f] = mscohere(x, y, hanning(winLen), overlapSamp, nfft, fs); + + % Filter to frequency range + freqMask = f >= freqRange(1) & f <= freqRange(2); + meanCoherence = mean(cxy(freqMask), 'omitnan'); + + % Approximate significance threshold + % Number of segments (Welch method) + nSegments = floor((T - overlapSamp) / (winLen - overlapSamp)); + nSegments = max(nSegments, 2); + alpha = 0.05; + coherenceThreshold = 1 - alpha^(1 / (nSegments - 1)); + + % P-value from coherence null distribution: P(C >= c) = (1 - c)^(L - 1) + % Note: this formula is exact for single-frequency-bin coherence. + % For band-averaged coherence it is an approximation (anti-conservative). + pval = (1 - min(meanCoherence, 1 - eps))^(nSegments - 1); + + result.value = meanCoherence; + result.pvalue = pval; + result.spectrum = cxy; + result.freqs = f; + result.method = 'coherence'; + result.windowed = false; + result.freqRange = freqRange; + result.coherenceThreshold = coherenceThreshold; + result.nSegments = nSegments; +end + + +function v = fillNaN(v) +% Linear interpolation of NaN values + nanIdx = isnan(v); + if ~any(nanIdx), return; end + if all(nanIdx), v(:) = 0; return; end + t = (1:length(v))'; + v(nanIdx) = interp1(t(~nanIdx), v(~nanIdx), t(nanIdx), 'linear', 'extrap'); +end diff --git a/+exploreFNIRS/+coupling/granger.m b/+exploreFNIRS/+coupling/granger.m new file mode 100644 index 00000000..6dc6e1c5 --- /dev/null +++ b/+exploreFNIRS/+coupling/granger.m @@ -0,0 +1,194 @@ +function result = granger(x, y, fs, varargin) +% GRANGER Bivariate Granger causality between two time series +% +% Tests whether past values of x improve the prediction of y beyond +% what past values of y alone provide. Uses autoregressive modeling +% with an F-test on residual variance reduction. +% +% Syntax: +% result = exploreFNIRS.coupling.granger(x, y, fs) +% result = exploreFNIRS.coupling.granger(x, y, fs, 'ModelOrder', 10) +% result = exploreFNIRS.coupling.granger(x, y, fs, 'WindowSize', 30) +% +% Inputs: +% x - [T x 1] time series (candidate cause) +% y - [T x 1] time series (candidate effect) +% fs - Sampling frequency (Hz) +% +% Name-Value Parameters: +% ModelOrder - Number of lags for AR model (default: 5) +% WindowSize - Sliding window duration in seconds (default: 0, full signal) +% WindowStep - Step size in seconds (default: WindowSize/2, 50% overlap) +% +% Outputs: +% result - Struct with fields: +% .value - F-statistic (scalar, or [W x 1] for windowed) +% .pvalue - p-value from F-distribution +% .direction - 'x->y' +% .method - 'granger' +% .windowed - true if sliding window was used +% .windowTimes - [W x 1] center times (windowed only) +% +% Algorithm: +% Restricted model: y(t) = sum_k a_k * y(t-k) + e_r +% Unrestricted model: y(t) = sum_k a_k * y(t-k) + sum_k b_k * x(t-k) + e_u +% F = ((RSS_r - RSS_u) / p) / (RSS_u / (T - 2p - 1)) +% +% References: +% Granger, C. W. J. (1969). Investigating causal relations by econometric +% models and cross-spectral methods. Econometrica, 37(3), 424-438. +% +% Geweke, J. (1982). Measurement of linear dependence and feedback between +% multiple time series. Journal of the American Statistical Association, +% 77(378), 304-313. +% +% See also: exploreFNIRS.coupling.transferEntropy, exploreFNIRS.coupling.pearson + + p = inputParser; + addRequired(p, 'x', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'y', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'fs', @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(p, 'ModelOrder', 5, @(v) isnumeric(v) && isscalar(v) && v >= 1); + addParameter(p, 'WindowSize', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'WindowStep', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + parse(p, x, y, fs, varargin{:}); + opts = p.Results; + + x = x(:); + y = y(:); + if length(x) ~= length(y) + error('exploreFNIRS:coupling:granger', 'x and y must have equal length'); + end + + winSamples = round(opts.WindowSize * fs); + + if winSamples <= 0 || winSamples >= length(x) + % Full-signal mode + [fStat, pval] = computeGranger(x, y, opts.ModelOrder); + + result.value = fStat; + result.pvalue = pval; + result.direction = 'x->y'; + result.method = 'granger'; + result.windowed = false; + else + % Sliding window mode + stepSamples = round(opts.WindowStep * fs); + if stepSamples <= 0 + stepSamples = max(1, round(winSamples / 2)); + end + + T = length(x); + starts = 1:stepSamples:(T - winSamples + 1); + nWin = length(starts); + + fVals = nan(nWin, 1); + pVals = nan(nWin, 1); + winTimes = nan(nWin, 1); + + for w = 1:nWin + idx = starts(w):(starts(w) + winSamples - 1); + xw = x(idx); + yw = y(idx); + [fVals(w), pVals(w)] = computeGranger(xw, yw, opts.ModelOrder); + winTimes(w) = (starts(w) + winSamples/2 - 1) / fs; + end + + result.value = fVals; + result.pvalue = pVals; + result.direction = 'x->y'; + result.method = 'granger'; + result.windowed = true; + result.windowTimes = winTimes; + end +end + + +function [fStat, pval] = computeGranger(x, y, order) +% Compute Granger F-statistic for x -> y + + T = length(y); + if T <= 2 * order + 1 + fStat = NaN; + pval = NaN; + return; + end + + % Handle NaN: use longest contiguous valid segment (preserves temporal order) + valid = ~isnan(x) & ~isnan(y); + [segStart, segLen] = longestRun(valid); + if segLen == 0 + fStat = NaN; + pval = NaN; + return; + end + x = x(segStart:segStart + segLen - 1); + y = y(segStart:segStart + segLen - 1); + T = length(y); + + if T <= 2 * order + 1 + fStat = NaN; + pval = NaN; + return; + end + + nObs = T - order; + + % Build lag matrices + Y = y((order + 1):T); + + % Restricted model: y lags only + Xr = zeros(nObs, order); + for k = 1:order + Xr(:, k) = y((order + 1 - k):(T - k)); + end + + % Unrestricted model: y lags + x lags + Xu = zeros(nObs, 2 * order); + Xu(:, 1:order) = Xr; + for k = 1:order + Xu(:, order + k) = x((order + 1 - k):(T - k)); + end + + % Solve via backslash + betaR = Xr \ Y; + betaU = Xu \ Y; + + residR = Y - Xr * betaR; + residU = Y - Xu * betaU; + + rssR = sum(residR .^ 2); + rssU = sum(residU .^ 2); + + % F-statistic + dfNum = order; + dfDen = nObs - 2 * order - 1; + + if dfDen <= 0 || rssU <= 0 + fStat = NaN; + pval = NaN; + return; + end + + fStat = ((rssR - rssU) / dfNum) / (rssU / dfDen); + fStat = max(fStat, 0); + + % p-value from F-distribution + pval = 1 - fcdf(fStat, dfNum, dfDen); +end + + +function [start, len] = longestRun(mask) +% Find the start index and length of the longest contiguous run of true values + d = diff([0; mask(:); 0]); + starts = find(d == 1); + ends = find(d == -1) - 1; + if isempty(starts) + start = 1; + len = 0; + return; + end + lengths = ends - starts + 1; + [len, idx] = max(lengths); + start = starts(idx); +end diff --git a/+exploreFNIRS/+coupling/hbica.m b/+exploreFNIRS/+coupling/hbica.m new file mode 100644 index 00000000..78b2423e --- /dev/null +++ b/+exploreFNIRS/+coupling/hbica.m @@ -0,0 +1,105 @@ +function result = hbica(x, y, fs, varargin) +% HBICA Pairwise coupling adapter for HB-ICA +% +% Thin wrapper providing the standard coupling interface (x, y, fs) for +% HB-ICA. For the 2-channel case, GOF degenerates (z-scoring 2 values +% always gives +/-0.707), so this adapter uses product-of-normalized-weights +% as the coupling metric instead. +% +% The recommended path for full HB-ICA analysis is the standalone function +% exploreFNIRS.hyperscanning.hbica() which operates on complete fNIRS +% structs. This adapter is provided for API consistency with the coupling +% dispatch system. +% +% Syntax: +% result = exploreFNIRS.coupling.hbica(x, y, fs) +% +% Inputs: +% x - [T x 1] time series from subject A +% y - [T x 1] time series from subject B +% fs - Sampling frequency (Hz) +% +% Name-Value Parameters: +% NumComponents - ICA components (default: 0, auto) +% VarianceRetained - PCA threshold (default: 0.99) +% Lags - TDSEP lags in samples (default: auto) +% +% Outputs: +% result - Struct with fields: +% .value - Scalar coupling score (0 = one-sided, 0.5 = max inter-brain) +% .pvalue - NaN (no parametric p-value for ICA-based metric) +% .method - 'hbica' +% .windowed - false +% +% Notes: +% The coupling value is: 2 * w1_norm * w2_norm, where w_norm = |w|/sum(|w|). +% This equals 0.5 when both channels load equally (maximum inter-brain +% coupling) and approaches 0 when loading is one-sided. +% +% References: +% Luo, H., Cai, Y., Lin, X. & Duan, L. (2024). Hyper-brain independent +% component analysis (HB-ICA). Biomedical Optics Express, 16(1). +% DOI: 10.1364/BOE.542554 +% +% See also: exploreFNIRS.hyperscanning.hbica, exploreFNIRS.coupling.pearson + + ip = inputParser; + addRequired(ip, 'x', @(v) isnumeric(v) && isvector(v)); + addRequired(ip, 'y', @(v) isnumeric(v) && isvector(v)); + addRequired(ip, 'fs', @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(ip, 'NumComponents', 0, @(v) isnumeric(v) && isscalar(v)); + addParameter(ip, 'VarianceRetained', 0.99, @(v) isnumeric(v) && isscalar(v)); + addParameter(ip, 'Lags', [], @(v) isnumeric(v)); + parse(ip, x, y, fs, varargin{:}); + opts = ip.Results; + + x = x(:); + y = y(:); + if length(x) ~= length(y) + error('exploreFNIRS:coupling:hbica', 'x and y must have equal length'); + end + + T = length(x); + if T < 10 + result.value = NaN; + result.pvalue = NaN; + result.method = 'hbica'; + result.windowed = false; + return; + end + + % Concatenate as 2-channel matrix + X = [x, y]; + + % Run TDSEP + tdsepArgs = {'VarianceRetained', opts.VarianceRetained}; + if opts.NumComponents > 0 + tdsepArgs = [tdsepArgs, 'NumComponents', opts.NumComponents]; + end + if ~isempty(opts.Lags) + tdsepArgs = [tdsepArgs, 'Lags', opts.Lags]; + end + + [~, ~, A] = pf2_base.signal.tdsep(X, tdsepArgs{:}); + + % For 2-channel case, use product-of-normalized-weights + % Pick the component with most balanced loading across the two channels + K = size(A, 2); + couplingValues = zeros(K, 1); + for k = 1:K + w = abs(A(:, k)); + wSum = sum(w); + if wSum < eps + continue; + end + wNorm = w / wSum; + % 2 * w1_norm * w2_norm: max 0.5 at equal loading, 0 at one-sided + couplingValues(k) = 2 * wNorm(1) * wNorm(2); + end + + % Return the maximum coupling across components + result.value = max(couplingValues); + result.pvalue = NaN; + result.method = 'hbica'; + result.windowed = false; +end diff --git a/+exploreFNIRS/+coupling/mutualInfo.m b/+exploreFNIRS/+coupling/mutualInfo.m new file mode 100644 index 00000000..b4770c12 --- /dev/null +++ b/+exploreFNIRS/+coupling/mutualInfo.m @@ -0,0 +1,265 @@ +function result = mutualInfo(x, y, fs, varargin) +% MUTUALINFO Mutual information between two time series +% +% Estimates mutual information (MI) between two equal-length time series +% using histogram-based probability estimation. MI captures both linear and +% nonlinear statistical dependencies. Statistical significance is assessed +% via block-shuffle surrogates. +% +% Syntax: +% result = exploreFNIRS.coupling.mutualInfo(x, y, fs) +% result = exploreFNIRS.coupling.mutualInfo(x, y, fs, 'NBins', 'auto') +% result = exploreFNIRS.coupling.mutualInfo(x, y, fs, 'WindowSize', 30) +% +% Inputs: +% x - [T x 1] time series +% y - [T x 1] time series +% fs - Sampling frequency (Hz) +% +% Name-Value Parameters: +% NBins - Number of histogram bins per dimension, or 'auto' +% (default: 'auto', uses Freedman-Diaconis rule) +% NumSurrogates - Number of block-shuffle surrogates for p-value +% (default: 100; set 0 to skip) +% Normalize - Normalize MI to [0, 1] range (default: true) +% Uses NMI = MI / sqrt(H(x) * H(y)) +% WindowSize - Sliding window duration in seconds (default: 0, full) +% WindowStep - Step size in seconds (default: WindowSize/2, 50% overlap) +% +% Outputs: +% result - Struct with fields: +% .value - Mutual information (scalar, or [W x 1] for windowed) +% In nats if Normalize=false, [0,1] if Normalize=true +% .pvalue - Surrogate-based p-value(s) +% .method - 'mutualInfo' +% .windowed - true if sliding window was used +% .windowTimes - [W x 1] center times (windowed only) +% .normalized - true if NMI was computed +% .nBinsUsed - Actual number of bins used +% +% Algorithm: +% 1. Bin x and y into NBins equal-width histogram bins +% 2. Estimate joint probability p(x,y) and marginals p(x), p(y) +% 3. MI = sum(p(x,y) * log(p(x,y) / (p(x)*p(y)))) +% 4. Optionally normalize: NMI = MI / sqrt(H(x) * H(y)) +% 5. p-value from block-shuffle surrogates of x +% +% References: +% Cover, T. M. & Thomas, J. A. (2006). Elements of Information Theory +% (2nd ed.). Wiley-Interscience. ISBN: 978-0471241959 +% +% Freedman, D. & Diaconis, P. (1981). On the histogram as a density +% estimator: L2 theory. Zeitschrift fur Wahrscheinlichkeitstheorie und +% verwandte Gebiete, 57(4), 453-476. DOI: 10.1007/BF01025868 +% +% See also: exploreFNIRS.coupling.pearson, exploreFNIRS.coupling.transferEntropy + + p = inputParser; + addRequired(p, 'x', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'y', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'fs', @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(p, 'NBins', 'auto', @(v) (isnumeric(v) && isscalar(v) && v >= 2) || ... + (ischar(v) && strcmpi(v, 'auto')) || (isstring(v) && strcmpi(v, 'auto'))); + addParameter(p, 'NumSurrogates', 100, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'Normalize', true, @(v) islogical(v) || (isnumeric(v) && isscalar(v))); + addParameter(p, 'WindowSize', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'WindowStep', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + parse(p, x, y, fs, varargin{:}); + opts = p.Results; + + x = x(:); + y = y(:); + if length(x) ~= length(y) + error('exploreFNIRS:coupling:mutualInfo', 'x and y must have equal length'); + end + + winSamples = round(opts.WindowSize * fs); + + if winSamples <= 0 || winSamples >= length(x) + % Full-signal mode + [miVal, pval, nBinsUsed] = computeMI(x, y, opts); + + result.value = miVal; + result.pvalue = pval; + result.method = 'mutualInfo'; + result.windowed = false; + result.normalized = logical(opts.Normalize); + result.nBinsUsed = nBinsUsed; + else + % Sliding window mode + stepSamples = round(opts.WindowStep * fs); + if stepSamples <= 0 + stepSamples = max(1, round(winSamples / 2)); + end + + T = length(x); + starts = 1:stepSamples:(T - winSamples + 1); + nWin = length(starts); + + miVals = nan(nWin, 1); + pVals = nan(nWin, 1); + winTimes = nan(nWin, 1); + nBinsUsed = 0; + + for w = 1:nWin + idx = starts(w):(starts(w) + winSamples - 1); + xw = x(idx); + yw = y(idx); + [miVals(w), pVals(w), nb] = computeMI(xw, yw, opts); + if w == 1, nBinsUsed = nb; end + winTimes(w) = (starts(w) + winSamples/2 - 1) / fs; + end + + result.value = miVals; + result.pvalue = pVals; + result.method = 'mutualInfo'; + result.windowed = true; + result.windowTimes = winTimes; + result.normalized = logical(opts.Normalize); + result.nBinsUsed = nBinsUsed; + end +end + + +function [miVal, pval, nBins] = computeMI(x, y, opts) +% Compute mutual information with surrogate p-value + + % Handle NaN: use longest contiguous valid segment + valid = ~isnan(x) & ~isnan(y); + [segStart, segLen] = longestRun(valid); + if segLen < 10 + miVal = NaN; + pval = NaN; + nBins = 0; + return; + end + x = x(segStart:segStart + segLen - 1); + y = y(segStart:segStart + segLen - 1); + + T = length(x); + + % Determine number of bins + if ischar(opts.NBins) || isstring(opts.NBins) + % Freedman-Diaconis rule: bin width = 2 * IQR * n^(-1/3) + iqrX = pf2_base.compat.iqr(x); + iqrY = pf2_base.compat.iqr(y); + avgIQR = (iqrX + iqrY) / 2; + if avgIQR > 0 + binWidth = 2 * avgIQR * T^(-1/3); + rangeXY = max(max(x) - min(x), max(y) - min(y)); + nBins = max(3, min(round(rangeXY / binWidth), 100)); + else + nBins = round(sqrt(T)); + end + else + nBins = opts.NBins; + end + + % Compute observed MI + miVal = estimateMI(x, y, nBins, logical(opts.Normalize)); + + % Surrogate p-value via block-shuffle of x + nSurr = opts.NumSurrogates; + if nSurr > 0 + surrMI = zeros(nSurr, 1); + blockLen = max(round(T / 10), 5); + for s = 1:nSurr + xShuff = blockShuffle(x, blockLen); + surrMI(s) = estimateMI(xShuff, y, nBins, logical(opts.Normalize)); + end + pval = (sum(surrMI >= miVal) + 1) / (nSurr + 1); + else + pval = NaN; + end +end + + +function mi = estimateMI(x, y, nBins, doNormalize) +% Histogram-based mutual information estimation +% +% MI(X;Y) = H(X) + H(Y) - H(X,Y) + + T = length(x); + + % Bin each variable to integers 1..nBins + xBin = binData(x, nBins); + yBin = binData(y, nBins); + + % Marginal entropies + Hx = entropy1d(xBin, nBins); + Hy = entropy1d(yBin, nBins); + + % Joint entropy via 2D histogram + jointIdx = (xBin - 1) * nBins + yBin; + Hxy = entropy1d(jointIdx, nBins * nBins); + + % MI = H(X) + H(Y) - H(X,Y) + mi = Hx + Hy - Hxy; + mi = max(mi, 0); % MI is non-negative by definition + + % Normalize to [0, 1] + if doNormalize && Hx > 0 && Hy > 0 + mi = mi / sqrt(Hx * Hy); + mi = min(mi, 1); + end +end + + +function binned = binData(v, nBins) +% Bin a 1D vector into integer bins 1..nBins + vMin = min(v); + vMax = max(v); + if vMax == vMin + binned = ones(size(v)); + else + binned = floor((v - vMin) / (vMax - vMin) * (nBins - 1)) + 1; + binned = min(binned, nBins); + end +end + + +function H = entropy1d(v, maxVal) +% Shannon entropy of discrete vector (in nats) + counts = accumarray(v(:), 1, [maxVal, 1]); + counts = counts(counts > 0); + p = counts / sum(counts); + H = -sum(p .* log(p)); +end + + +function xShuff = blockShuffle(x, blockLen) +% Block-shuffle a time series preserving local autocorrelation + T = length(x); + blockStarts = 1:blockLen:T; + perm = randperm(length(blockStarts)); + xShuff = zeros(T, 1); + pos = 1; + for i = 1:length(perm) + bStart = blockStarts(perm(i)); + bEnd = min(bStart + blockLen - 1, T); + bLen = bEnd - bStart + 1; + endPos = min(pos + bLen - 1, T); + actualLen = endPos - pos + 1; + xShuff(pos:endPos) = x(bStart:(bStart + actualLen - 1)); + pos = endPos + 1; + if pos > T + break; + end + end +end + + +function [start, len] = longestRun(mask) +% Find the start index and length of the longest contiguous run of true + d = diff([0; mask(:); 0]); + starts = find(d == 1); + ends = find(d == -1) - 1; + if isempty(starts) + start = 1; + len = 0; + return; + end + lengths = ends - starts + 1; + [len, idx] = max(lengths); + start = starts(idx); +end diff --git a/+exploreFNIRS/+coupling/partialCoherence.m b/+exploreFNIRS/+coupling/partialCoherence.m new file mode 100644 index 00000000..c869fd98 --- /dev/null +++ b/+exploreFNIRS/+coupling/partialCoherence.m @@ -0,0 +1,166 @@ +function result = partialCoherence(x, y, z, fs, varargin) +% PARTIALCOHERENCE Partial magnitude-squared coherence controlling for signal(s) +% +% Computes the coherence between x and y after removing the linear influence of +% one or more conditioning signals z. In hyperscanning this controls for shared +% physiology (respiration, ~0.1 Hz Mayer waves, heart rate) that can inflate +% apparent inter-brain coherence in the LFO/VLFO band. Both the ordinary and +% partial coherence are returned so the confound's contribution is visible. +% +% Syntax: +% result = exploreFNIRS.coupling.partialCoherence(x, y, z, fs) +% result = exploreFNIRS.coupling.partialCoherence(x, y, z, fs, 'FreqRange', [0.04 0.15]) +% +% Inputs: +% x - [T x 1] time series (e.g. brain A channel) +% y - [T x 1] time series (e.g. brain B channel) +% z - [T x K] conditioning signal(s) to partial out (shared physiology) +% fs - Sampling frequency (Hz) +% +% Name-Value Parameters: +% FreqRange - [fLow fHigh] band in Hz (default: [0.01, fs/2]) +% WindowLength - Welch segment length in seconds (default: auto, ~8 segments) +% Overlap - Fraction of overlap between segments (default: 0.5) +% NFFT - FFT length (default: next power of 2 of window length) +% +% Outputs: +% result - Struct with fields: +% .value - Mean PARTIAL coherence in FreqRange (scalar) +% .ordinary - Mean ORDINARY coherence in FreqRange (scalar) +% .reduction - ordinary - value (drop attributable to z) +% .spectrum - [F x 1] partial coherence spectrum +% .ordinarySpectrum - [F x 1] ordinary coherence spectrum +% .freqs - [F x 1] frequency vector (Hz) +% .freqRange - Frequency band used +% .method - 'partialCoherence' +% +% Algorithm: +% Assembles the cross-spectral density matrix S(f) over [x, y, z...] via +% Welch cross-spectra (cpsd). The partial coherence between x and y given the +% remaining variables is |P_12|^2 / (P_11 P_22) where P = inv(S(f)); the +% ordinary coherence is |S_12|^2 / (S_11 S_22). +% +% References: +% Bendat, J. S. & Piersol, A. G. (2010). Random Data: Analysis and +% Measurement Procedures (4th ed.). Wiley. (partial coherence) +% +% See also: exploreFNIRS.coupling.coherence, exploreFNIRS.coupling.wcoherence, +% exploreFNIRS.hyperscanning.physioConfoundQC, cpsd + + p = inputParser; + addRequired(p, 'x', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'y', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'z', @(v) isnumeric(v) && ~isempty(v)); + addRequired(p, 'fs', @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(p, 'FreqRange', [0.01, 0], @(v) isnumeric(v) && length(v) == 2); + addParameter(p, 'WindowLength', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'Overlap', 0.5, @(v) isnumeric(v) && isscalar(v) && v >= 0 && v < 1); + addParameter(p, 'NFFT', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + parse(p, x, y, z, fs, varargin{:}); + opts = p.Results; + + if ~exist('cpsd', 'file') + error('exploreFNIRS:coupling:partialCoherence:noToolbox', ... + 'partialCoherence requires the Signal Processing Toolbox (cpsd).'); + end + + x = fillNaN(x(:)); + y = fillNaN(y(:)); + if isrow(z), z = z(:); end + for k = 1:size(z, 2) + z(:, k) = fillNaN(z(:, k)); + end + T = length(x); + if length(y) ~= T || size(z, 1) ~= T + error('exploreFNIRS:coupling:partialCoherence', ... + 'x, y, and z must share the same number of samples.'); + end + + freqRange = opts.FreqRange; + if freqRange(2) <= 0 + freqRange(2) = fs / 2; + end + + if opts.WindowLength <= 0 + winLen = round(T / 8); + winLen = max(winLen, round(fs * 4)); + % The window must be long enough to resolve the low edge of the band: + % aim for >= 3 cycles of freqRange(1) (e.g. ~60 s at 0.05 Hz). Without + % this, an LFO/VLFO band [0.04 0.15] cannot be estimated. + if freqRange(1) > 0 + winLen = max(winLen, round(3 / freqRange(1) * fs)); + end + winLen = min(winLen, T); + else + winLen = round(opts.WindowLength * fs); + end + overlapSamp = round(winLen * opts.Overlap); + if opts.NFFT <= 0 + nfft = 2^nextpow2(winLen); + else + nfft = opts.NFFT; + end + + % Channel matrix: [x, y, z1..zK] + chans = [x, y, z]; + m = size(chans, 2); + win = hanning(winLen); + + % Pairwise cross-spectra + S = cell(m, m); + f = []; + for i = 1:m + for j = i:m + [Sij, f] = cpsd(chans(:, i), chans(:, j), win, overlapSamp, nfft, fs); + S{i, j} = Sij; + if i ~= j + S{j, i} = conj(Sij); + end + end + end + + F = numel(f); + partialSpec = nan(F, 1); + ordinarySpec = nan(F, 1); + for fi = 1:F + M = zeros(m, m); + for i = 1:m + for j = 1:m + M(i, j) = S{i, j}(fi); + end + end + % Ordinary coherence x-y + ordinarySpec(fi) = clamp01(abs(M(1, 2))^2 / (real(M(1, 1)) * real(M(2, 2)) + eps)); + % Partial coherence x-y | rest, via the precision matrix. Use a + % diagonal-scaled Tikhonov ridge (not eps-scale) so near-singular + % cross-spectral matrices at low-SNR frequencies invert stably. + reg = 1e-6 * max(real(diag(M))); + P = pinv(M + reg * eye(m)); + partialSpec(fi) = clamp01(abs(P(1, 2))^2 / (real(P(1, 1)) * real(P(2, 2)) + eps)); + end + + freqMask = f >= freqRange(1) & f <= freqRange(2); + result.value = mean(partialSpec(freqMask), 'omitnan'); + result.ordinary = mean(ordinarySpec(freqMask), 'omitnan'); + result.reduction = result.ordinary - result.value; + result.spectrum = partialSpec; + result.ordinarySpectrum = ordinarySpec; + result.freqs = f; + result.freqRange = freqRange; + result.method = 'partialCoherence'; +end + + +function v = fillNaN(v) +% Linear interpolation of NaN values + nanIdx = isnan(v); + if ~any(nanIdx), return; end + if all(nanIdx), v(:) = 0; return; end + t = (1:length(v))'; + v(nanIdx) = interp1(t(~nanIdx), v(~nanIdx), t(nanIdx), 'linear', 'extrap'); +end + + +function c = clamp01(c) + c = max(0, min(1, c)); +end diff --git a/+exploreFNIRS/+coupling/partialCorr.m b/+exploreFNIRS/+coupling/partialCorr.m new file mode 100644 index 00000000..dc874b14 --- /dev/null +++ b/+exploreFNIRS/+coupling/partialCorr.m @@ -0,0 +1,182 @@ +function result = partialCorr(x, y, fs, varargin) +% PARTIALCORR Partial correlation between two time series +% +% Computes partial Pearson correlation between two time series after +% controlling for confounding signals. When confounds are provided, both +% signals are residualized (ordinary least-squares regression) before +% correlation. Without confounds, returns the standard Pearson correlation. +% +% For connectivity matrices, partial correlation is more commonly computed +% via the precision matrix (inverse covariance). When called through +% computeMatrix with Method='partialcorr', a batch precision-matrix path +% is used automatically for efficiency. +% +% Syntax: +% result = exploreFNIRS.coupling.partialCorr(x, y, fs) +% result = exploreFNIRS.coupling.partialCorr(x, y, fs, ... +% 'Confounds', Z) +% result = exploreFNIRS.coupling.partialCorr(x, y, fs, ... +% 'WindowSize', 10) +% +% Inputs: +% x - [T x 1] time series +% y - [T x 1] time series +% fs - Sampling frequency (Hz) +% +% Name-Value Parameters: +% Confounds - [T x K] matrix of confound signals to regress out +% (default: [], no confounds) +% WindowSize - Sliding window duration in seconds (default: 0, full signal) +% WindowStep - Step size in seconds (default: WindowSize/2, 50% overlap) +% +% Outputs: +% result - Struct with fields: +% .value - Partial correlation coefficient (scalar, or [W x 1]) +% .pvalue - Two-tailed p-value(s) +% .method - 'partialCorr' +% .windowed - true if sliding window was used +% .windowTimes - [W x 1] center times (windowed only) +% .nConfounds - Number of confound signals used +% +% Algorithm: +% 1. Regress confounds Z from both x and y: x_res = x - Z*(Z\x) +% 2. Compute Pearson correlation between residuals +% 3. p-value uses t-distribution with df = T - K - 2 +% +% Reference: +% Marrelec, G., Krainik, A., Duffau, H., Pelegrini-Issac, M., +% Lehericy, S., Doyon, J. & Benali, H. (2006). Partial correlation +% for functional brain interactivity investigation in functional MRI. +% NeuroImage, 32(1), 228-237. DOI: 10.1016/j.neuroimage.2005.12.057 +% +% See also: exploreFNIRS.coupling.pearson, exploreFNIRS.coupling.spearman + + p = inputParser; + addRequired(p, 'x', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'y', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'fs', @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(p, 'Confounds', [], @(v) isnumeric(v)); + addParameter(p, 'WindowSize', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'WindowStep', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + parse(p, x, y, fs, varargin{:}); + opts = p.Results; + + x = x(:); + y = y(:); + if length(x) ~= length(y) + error('exploreFNIRS:coupling:partialCorr', 'x and y must have equal length'); + end + + Z = opts.Confounds; + if ~isempty(Z) && size(Z, 1) ~= length(x) + error('exploreFNIRS:coupling:partialCorr', ... + 'Confounds must have the same number of rows as x (%d), got %d', ... + length(x), size(Z, 1)); + end + nConf = size(Z, 2); + + winSamples = round(opts.WindowSize * fs); + + if winSamples <= 0 || winSamples >= length(x) + % Full-signal mode + [r, pval] = computePartialCorr(x, y, Z); + + result.value = r; + result.pvalue = pval; + result.method = 'partialCorr'; + result.windowed = false; + result.nConfounds = nConf; + else + % Sliding window mode + stepSamples = round(opts.WindowStep * fs); + if stepSamples <= 0 + stepSamples = max(1, round(winSamples / 2)); + end + + T = length(x); + starts = 1:stepSamples:(T - winSamples + 1); + nWin = length(starts); + + rVals = nan(nWin, 1); + pVals = nan(nWin, 1); + winTimes = nan(nWin, 1); + + for w = 1:nWin + idx = starts(w):(starts(w) + winSamples - 1); + xw = x(idx); + yw = y(idx); + if isempty(Z) + Zw = []; + else + Zw = Z(idx, :); + end + [rVals(w), pVals(w)] = computePartialCorr(xw, yw, Zw); + winTimes(w) = (starts(w) + winSamples/2 - 1) / fs; + end + + result.value = rVals; + result.pvalue = pVals; + result.method = 'partialCorr'; + result.windowed = true; + result.windowTimes = winTimes; + result.nConfounds = nConf; + end +end + + +function [r, pval] = computePartialCorr(x, y, Z) +% Compute partial correlation between x and y controlling for Z + + % Remove NaN observations + valid = ~isnan(x) & ~isnan(y); + if ~isempty(Z) + valid = valid & ~any(isnan(Z), 2); + end + + if sum(valid) < 3 + r = NaN; + pval = NaN; + return; + end + + xv = x(valid); + yv = y(valid); + n = length(xv); + + if isempty(Z) + % No confounds: standard Pearson + [r, pval] = pf2_base.compat.corr(xv, yv, 'Type', 'Pearson'); + return; + end + + Zv = Z(valid, :); + nConf = size(Zv, 2); + + % Need at least nConf + 3 observations for meaningful partial corr + if n < nConf + 3 + r = NaN; + pval = NaN; + return; + end + + % Residualize x and y by regressing out Z + % Add intercept column + Zint = [ones(n, 1), Zv]; + + % Use QR decomposition for numerical stability + [Q, ~] = qr(Zint, 0); + xRes = xv - Q * (Q' * xv); + yRes = yv - Q * (Q' * yv); + + % Pearson correlation of residuals + r = pf2_base.compat.corr(xRes, yRes, 'Type', 'Pearson'); + + % p-value via t-distribution with df = n - nConf - 2 + df = n - nConf - 2; + if df < 1 + pval = NaN; + else + tStat = r * sqrt(df / (1 - r^2 + eps)); + pval = 2 * (1 - pf2_base.compat.tcdf(abs(tStat), df)); + end +end diff --git a/+exploreFNIRS/+coupling/pearson.m b/+exploreFNIRS/+coupling/pearson.m new file mode 100644 index 00000000..821ea9bc --- /dev/null +++ b/+exploreFNIRS/+coupling/pearson.m @@ -0,0 +1,103 @@ +function result = pearson(x, y, fs, varargin) +% PEARSON Pearson correlation between two time series +% +% Computes Pearson's r between two equal-length time series, with an +% optional sliding-window mode for time-resolved coupling. +% +% Syntax: +% result = exploreFNIRS.coupling.pearson(x, y, fs) +% result = exploreFNIRS.coupling.pearson(x, y, fs, 'WindowSize', 10) +% +% Inputs: +% x - [T x 1] time series +% y - [T x 1] time series +% fs - Sampling frequency (Hz) +% +% Name-Value Parameters: +% WindowSize - Sliding window duration in seconds (default: 0, full signal) +% WindowStep - Step size in seconds (default: WindowSize/2, 50% overlap) +% +% Outputs: +% result - Struct with fields: +% .value - Pearson r (scalar, or [W x 1] for windowed) +% .pvalue - Two-tailed p-value(s) +% .method - 'pearson' +% .windowed - true if sliding window was used +% .windowTimes - [W x 1] center times (windowed only) +% +% Reference: +% Standard Pearson product-moment correlation. For its application in +% fNIRS connectivity see: Scholkmann, F., Holper, L., Wolf, U. & Wolf, +% M. (2013). A new methodical approach in neuroscience: assessing +% inter-personal brain coupling using functional near-infrared imaging +% (fNIRI) hyperscanning. Frontiers in Human Neuroscience, 7, 813. +% DOI: 10.3389/fnhum.2013.00813 +% +% See also: exploreFNIRS.coupling.spearman, exploreFNIRS.coupling.xcorr + + p = inputParser; + addRequired(p, 'x', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'y', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'fs', @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(p, 'WindowSize', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'WindowStep', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + parse(p, x, y, fs, varargin{:}); + opts = p.Results; + + x = x(:); + y = y(:); + if length(x) ~= length(y) + error('exploreFNIRS:coupling:pearson', 'x and y must have equal length'); + end + + winSamples = round(opts.WindowSize * fs); + + if winSamples <= 0 || winSamples >= length(x) + % Full-signal mode + valid = ~isnan(x) & ~isnan(y); + if sum(valid) < 3 + result.value = NaN; + result.pvalue = NaN; + result.method = 'pearson'; + result.windowed = false; + return; + end + [r, pval] = pf2_base.compat.corr(x(valid), y(valid), 'Type', 'Pearson'); + + result.value = r; + result.pvalue = pval; + result.method = 'pearson'; + result.windowed = false; + else + % Sliding window mode + stepSamples = round(opts.WindowStep * fs); + if stepSamples <= 0 + stepSamples = max(1, round(winSamples / 2)); + end + + T = length(x); + starts = 1:stepSamples:(T - winSamples + 1); + nWin = length(starts); + + rVals = nan(nWin, 1); + pVals = nan(nWin, 1); + winTimes = nan(nWin, 1); + + for w = 1:nWin + idx = starts(w):(starts(w) + winSamples - 1); + xw = x(idx); + yw = y(idx); + valid = ~isnan(xw) & ~isnan(yw); + if sum(valid) >= 3 + [rVals(w), pVals(w)] = pf2_base.compat.corr(xw(valid), yw(valid), 'Type', 'Pearson'); + end + winTimes(w) = (starts(w) + winSamples/2 - 1) / fs; + end + + result.value = rVals; + result.pvalue = pVals; + result.method = 'pearson'; + result.windowed = true; + result.windowTimes = winTimes; + end +end diff --git a/+exploreFNIRS/+coupling/plotWcoherence.m b/+exploreFNIRS/+coupling/plotWcoherence.m new file mode 100644 index 00000000..eaa787fa --- /dev/null +++ b/+exploreFNIRS/+coupling/plotWcoherence.m @@ -0,0 +1,170 @@ +function fig = plotWcoherence(result, varargin) +% PLOTWCOHERENCE Time-frequency wavelet coherence visualization +% +% Renders wavelet coherence as a time-frequency heatmap with cone of +% influence overlay and optional phase arrow display. The standard +% visualization for wavelet coherence analysis. +% +% Syntax: +% fig = exploreFNIRS.coupling.plotWcoherence(result) +% fig = exploreFNIRS.coupling.plotWcoherence(result, 'ShowPhase', true) +% fig = exploreFNIRS.coupling.plotWcoherence(result, 'FreqRange', [0.01 0.1]) +% +% Inputs: +% result - Struct from exploreFNIRS.coupling.wcoherence with fields: +% .wcoh, .freqs, .times, .coi, .freqRange +% Optionally .phase for phase arrow display +% +% Name-Value Parameters: +% FreqRange - [fLow fHigh] frequency limits for display (default: from result) +% CLim - Color limits [cmin cmax] (default: [0 1]) +% Colormap - Colormap name or matrix (default: 'jet') +% ShowCOI - Show cone of influence overlay (default: true) +% ShowPhase - Show phase arrows (default: false, requires .phase) +% PhaseStep - Spacing of phase arrows in [freq, time] indices (default: [4, 8]) +% ShowBand - Show frequency band boundaries as dashed lines (default: true) +% LogFreq - Use log scale for frequency axis (default: true) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 800) +% SaveHeight - Height in pixels (default: 400) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.coupling.wcoherence, exploreFNIRS.connectivity.plotMatrix + + p = inputParser; + addRequired(p, 'result', @isstruct); + addParameter(p, 'FreqRange', [], @(v) isempty(v) || (isnumeric(v) && numel(v) == 2)); + addParameter(p, 'CLim', [0, 1], @(v) isnumeric(v) && length(v) == 2); + addParameter(p, 'Colormap', 'jet', @(v) ischar(v) || isnumeric(v)); + addParameter(p, 'ShowCOI', true, @islogical); + addParameter(p, 'ShowPhase', false, @islogical); + addParameter(p, 'PhaseStep', [4, 8], @(v) isnumeric(v) && numel(v) == 2); + addParameter(p, 'ShowBand', true, @islogical); + addParameter(p, 'LogFreq', true, @islogical); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 800, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + wcoh = result.wcoh; + freqs = result.freqs; + times = result.times; + coi = result.coi(:)'; + + % Frequency range for display + if isempty(opts.FreqRange) && isfield(result, 'freqRange') + dispRange = result.freqRange; + elseif ~isempty(opts.FreqRange) + dispRange = opts.FreqRange; + else + dispRange = [min(freqs), max(freqs)]; + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + ax = axes('Parent', fig); + + % Plot coherence + if opts.LogFreq && all(freqs > 0) + surf(ax, times, freqs, wcoh, 'EdgeColor', 'none'); + view(ax, 0, 90); + set(ax, 'YScale', 'log'); + set(ax, 'YDir', 'normal'); + else + imagesc(ax, times, freqs, wcoh); + set(ax, 'YDir', 'normal'); + end + + caxis(ax, opts.CLim); + + % Colormap + if ischar(opts.Colormap) + colormap(ax, opts.Colormap); + else + colormap(ax, opts.Colormap); + end + cb = colorbar(ax); + cb.Label.String = 'Wavelet Coherence'; + + % Frequency axis limits + ylim(ax, dispRange); + xlim(ax, [min(times), max(times)]); + + % Cone of influence overlay + if opts.ShowCOI + hold(ax, 'on'); + % Fill below COI boundary with semi-transparent gray + coiFreqs = min(coi, max(freqs)); + fillX = [times(:)', fliplr(times(:)')]; + fillY = [coiFreqs, ones(1, length(times)) * min(freqs)]; + fill(ax, fillX, fillY, [0.5, 0.5, 0.5], ... + 'FaceAlpha', 0.4, 'EdgeColor', 'none'); + hold(ax, 'off'); + end + + % Frequency band boundaries + if opts.ShowBand && isfield(result, 'freqRange') + hold(ax, 'on'); + tRange = xlim(ax); + plot(ax, tRange, [result.freqRange(1), result.freqRange(1)], ... + '--w', 'LineWidth', 1); + plot(ax, tRange, [result.freqRange(2), result.freqRange(2)], ... + '--w', 'LineWidth', 1); + hold(ax, 'off'); + end + + % Phase arrows + if opts.ShowPhase && isfield(result, 'phase') + hold(ax, 'on'); + phase = result.phase; + fStep = opts.PhaseStep(1); + tStep = opts.PhaseStep(2); + + fIdx = 1:fStep:length(freqs); + tIdx = 1:tStep:length(times); + + for fi = fIdx + for ti = tIdx + if wcoh(fi, ti) > 0.5 % Only show arrows where coherence is notable + ang = phase(fi, ti); + dx = cos(ang) * (times(min(ti+1,end)) - times(ti)) * tStep * 0.3; + dy = sin(ang) * freqs(fi) * 0.1; + quiver(ax, times(ti), freqs(fi), dx, dy, 0, ... + 'k', 'MaxHeadSize', 0.8, 'LineWidth', 0.5); + end + end + end + hold(ax, 'off'); + end + + % Labels + xlabel(ax, 'Time (s)'); + ylabel(ax, 'Frequency (Hz)'); + + if ~isempty(opts.Title) + title(ax, opts.Title); + else + title(ax, sprintf('Wavelet Coherence (mean = %.3f)', result.value)); + end + + sty.applyToAxes(ax); + + pf2_base.plot.handleSave(fig, opts); + +end diff --git a/+exploreFNIRS/+coupling/plotWindowed.m b/+exploreFNIRS/+coupling/plotWindowed.m new file mode 100644 index 00000000..e8188f70 --- /dev/null +++ b/+exploreFNIRS/+coupling/plotWindowed.m @@ -0,0 +1,136 @@ +function fig = plotWindowed(result, varargin) +% PLOTWINDOWED Time series visualization of windowed coupling values +% +% Plots coupling values over time for windowed coupling results (pearson, +% spearman, xcorr with WindowSize). Shows the coupling trajectory with +% optional confidence band and significance threshold. +% +% Syntax: +% fig = exploreFNIRS.coupling.plotWindowed(result) +% fig = exploreFNIRS.coupling.plotWindowed(result, 'ShowThreshold', true) +% fig = exploreFNIRS.coupling.plotWindowed(results) % cell array overlay +% +% Inputs: +% result - Struct from a windowed coupling function with fields: +% .value (vector), .pvalue (vector), .windowTimes, .method +% OR cell array of result structs (one line per result) +% +% Name-Value Parameters: +% Labels - Cell array of labels for legend (default: auto from method) +% ShowThreshold - Show significance threshold line (default: false) +% PThreshold - Significance level for threshold (default: 0.05) +% YLim - Y-axis limits (default: auto) +% LineWidth - Line width (default: 1.5) +% ShowCI - Show confidence band as shaded area (default: false) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 700) +% SaveHeight - Height in pixels (default: 350) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.coupling.pearson, exploreFNIRS.coupling.spearman + + p = inputParser; + addRequired(p, 'result'); + addParameter(p, 'Labels', {}, @iscell); + addParameter(p, 'ShowThreshold', false, @islogical); + addParameter(p, 'PThreshold', 0.05, @isnumeric); + addParameter(p, 'YLim', [], @(v) isempty(v) || (isnumeric(v) && numel(v) == 2)); + addParameter(p, 'LineWidth', 1.5, @isnumeric); + addParameter(p, 'ShowCI', false, @islogical); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 700, @isnumeric); + addParameter(p, 'SaveHeight', 350, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + % Normalize input to cell array + if isstruct(result) + results = {result}; + else + results = result; + end + + nSeries = length(results); + colors = lines(nSeries); + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + ax = axes('Parent', fig); + hold(ax, 'on'); + + legendEntries = {}; + for k = 1:nSeries + r = results{k}; + + if ~r.windowed || ~isfield(r, 'windowTimes') + warning('exploreFNIRS:coupling:plotWindowed', ... + 'Result %d is not windowed. Skipping.', k); + continue; + end + + t = r.windowTimes; + v = r.value; + + plot(ax, t, v, '-', 'Color', colors(k, :), ... + 'LineWidth', opts.LineWidth); + + if ~isempty(opts.Labels) && k <= length(opts.Labels) + legendEntries{end+1} = opts.Labels{k}; %#ok + else + legendEntries{end+1} = r.method; %#ok + end + + % Significance masking: mark significant windows + if opts.ShowThreshold && isfield(r, 'pvalue') + sigMask = r.pvalue < opts.PThreshold; + if any(sigMask) + plot(ax, t(sigMask), v(sigMask), '.', ... + 'Color', colors(k, :), 'MarkerSize', 12); + end + end + end + + % Reference line at zero + plot(ax, xlim(ax), [0, 0], '-', 'Color', [0.7, 0.7, 0.7], 'LineWidth', 0.5); + + hold(ax, 'off'); + + xlabel(ax, 'Time (s)'); + ylabel(ax, 'Coupling'); + + if ~isempty(opts.YLim) + ylim(ax, opts.YLim); + end + + if ~isempty(legendEntries) + legend(ax, legendEntries, 'Location', 'best'); + end + + if ~isempty(opts.Title) + title(ax, opts.Title); + else + title(ax, 'Windowed Coupling'); + end + + box(ax, 'on'); + grid(ax, 'on'); + sty.applyToAxes(ax); + + pf2_base.plot.handleSave(fig, opts); + +end diff --git a/+exploreFNIRS/+coupling/spearman.m b/+exploreFNIRS/+coupling/spearman.m new file mode 100644 index 00000000..c9fc83a6 --- /dev/null +++ b/+exploreFNIRS/+coupling/spearman.m @@ -0,0 +1,104 @@ +function result = spearman(x, y, fs, varargin) +% SPEARMAN Spearman rank correlation between two time series +% +% Computes Spearman's rho between two equal-length time series, with an +% optional sliding-window mode for time-resolved coupling. +% +% Syntax: +% result = exploreFNIRS.coupling.spearman(x, y, fs) +% result = exploreFNIRS.coupling.spearman(x, y, fs, 'WindowSize', 10) +% +% Inputs: +% x - [T x 1] time series +% y - [T x 1] time series +% fs - Sampling frequency (Hz) +% +% Name-Value Parameters: +% WindowSize - Sliding window duration in seconds (default: 0, full signal) +% WindowStep - Step size in seconds (default: WindowSize/2, 50% overlap) +% +% Outputs: +% result - Struct with fields: +% .value - Spearman rho (scalar, or [W x 1] for windowed) +% .pvalue - Two-tailed p-value(s) +% .method - 'spearman' +% .windowed - true if sliding window was used +% .windowTimes - [W x 1] center times (windowed only) +% +% Reference: +% Standard Spearman rank-order correlation. Rank-based coupling is robust +% to outliers and nonlinear monotonic relationships in neuroimaging; see: +% Scholkmann, F., Holper, L., Wolf, U. & Wolf, M. (2013). A new +% methodical approach in neuroscience: assessing inter-personal brain +% coupling using functional near-infrared imaging (fNIRI) hyperscanning. +% Frontiers in Human Neuroscience, 7, 813. +% DOI: 10.3389/fnhum.2013.00813 +% +% See also: exploreFNIRS.coupling.pearson, exploreFNIRS.coupling.xcorr + + p = inputParser; + addRequired(p, 'x', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'y', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'fs', @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(p, 'WindowSize', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'WindowStep', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + parse(p, x, y, fs, varargin{:}); + opts = p.Results; + + x = x(:); + y = y(:); + if length(x) ~= length(y) + error('exploreFNIRS:coupling:spearman', 'x and y must have equal length'); + end + + winSamples = round(opts.WindowSize * fs); + + if winSamples <= 0 || winSamples >= length(x) + % Full-signal mode + valid = ~isnan(x) & ~isnan(y); + if sum(valid) < 3 + result.value = NaN; + result.pvalue = NaN; + result.method = 'spearman'; + result.windowed = false; + return; + end + [r, pval] = pf2_base.compat.corr(x(valid), y(valid), 'Type', 'Spearman'); + + result.value = r; + result.pvalue = pval; + result.method = 'spearman'; + result.windowed = false; + else + % Sliding window mode + stepSamples = round(opts.WindowStep * fs); + if stepSamples <= 0 + stepSamples = max(1, round(winSamples / 2)); + end + + T = length(x); + starts = 1:stepSamples:(T - winSamples + 1); + nWin = length(starts); + + rVals = nan(nWin, 1); + pVals = nan(nWin, 1); + winTimes = nan(nWin, 1); + + for w = 1:nWin + idx = starts(w):(starts(w) + winSamples - 1); + xw = x(idx); + yw = y(idx); + valid = ~isnan(xw) & ~isnan(yw); + if sum(valid) >= 3 + [rVals(w), pVals(w)] = pf2_base.compat.corr(xw(valid), yw(valid), 'Type', 'Spearman'); + end + winTimes(w) = (starts(w) + winSamples/2 - 1) / fs; + end + + result.value = rVals; + result.pvalue = pVals; + result.method = 'spearman'; + result.windowed = true; + result.windowTimes = winTimes; + end +end diff --git a/+exploreFNIRS/+coupling/transferEntropy.m b/+exploreFNIRS/+coupling/transferEntropy.m new file mode 100644 index 00000000..3259f27d --- /dev/null +++ b/+exploreFNIRS/+coupling/transferEntropy.m @@ -0,0 +1,299 @@ +function result = transferEntropy(x, y, fs, varargin) +% TRANSFERENTROPY Transfer entropy from x to y +% +% Estimates the information transferred from time series x to time series y +% using histogram-based probability estimation. Statistical significance is +% determined via block-shuffle surrogates. +% +% Syntax: +% result = exploreFNIRS.coupling.transferEntropy(x, y, fs) +% result = exploreFNIRS.coupling.transferEntropy(x, y, fs, 'NBins', 8) +% result = exploreFNIRS.coupling.transferEntropy(x, y, fs, 'WindowSize', 30) +% +% Inputs: +% x - [T x 1] time series (source) +% y - [T x 1] time series (target) +% fs - Sampling frequency (Hz) +% +% Name-Value Parameters: +% EmbeddingDim - Embedding dimension (default: 3) +% Delay - Embedding delay in samples (default: 1) +% NBins - Number of histogram bins per dimension (default: 10) +% NumSurrogates - Number of block-shuffle surrogates for p-value (default: 100) +% WindowSize - Sliding window duration in seconds (default: 0, full signal) +% WindowStep - Step size in seconds (default: WindowSize/2, 50% overlap) +% +% Outputs: +% result - Struct with fields: +% .value - Transfer entropy in nats (scalar, or [W x 1] for windowed) +% .pvalue - Surrogate-based p-value +% .direction - 'x->y' +% .method - 'transferEntropy' +% .windowed - true if sliding window was used +% .windowTimes - [W x 1] center times (windowed only) +% +% Algorithm: +% TE(x->y) = H(y_future | y_past) - H(y_future | y_past, x_past) +% Computed via histogram-based joint/conditional entropy estimation. +% p-value from block-shuffle surrogates of x. +% +% References: +% Schreiber, T. (2000). Measuring information transfer. Physical Review +% Letters, 85(2), 461-464. DOI: 10.1103/PhysRevLett.85.461 +% +% Theiler, J., Eubank, S., Longtin, A., Galdrikian, B. & Farmer, J. D. +% (1992). Testing for nonlinearity in time series: the method of surrogate +% data. Physica D, 58(1-4), 77-94. DOI: 10.1016/0167-2789(92)90102-S +% +% See also: exploreFNIRS.coupling.granger, exploreFNIRS.coupling.pearson + + p = inputParser; + addRequired(p, 'x', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'y', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'fs', @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(p, 'EmbeddingDim', 3, @(v) isnumeric(v) && isscalar(v) && v >= 1); + addParameter(p, 'Delay', 1, @(v) isnumeric(v) && isscalar(v) && v >= 1); + addParameter(p, 'NBins', 10, @(v) isnumeric(v) && isscalar(v) && v >= 2); + addParameter(p, 'NumSurrogates', 100, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'WindowSize', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'WindowStep', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + parse(p, x, y, fs, varargin{:}); + opts = p.Results; + + % Memory safety check for histogram binning + totalBins = opts.NBins ^ (opts.EmbeddingDim + 1); + if totalBins > 1e7 + error('exploreFNIRS:coupling:transferEntropy', ... + 'NBins=%d with EmbeddingDim=%d creates %.0e histogram bins. Reduce NBins or EmbeddingDim.', ... + opts.NBins, opts.EmbeddingDim, totalBins); + end + + x = x(:); + y = y(:); + if length(x) ~= length(y) + error('exploreFNIRS:coupling:transferEntropy', 'x and y must have equal length'); + end + + winSamples = round(opts.WindowSize * fs); + + if winSamples <= 0 || winSamples >= length(x) + % Full-signal mode + [teVal, pval] = computeTE(x, y, opts); + + result.value = teVal; + result.pvalue = pval; + result.direction = 'x->y'; + result.method = 'transferEntropy'; + result.windowed = false; + else + % Sliding window mode + stepSamples = round(opts.WindowStep * fs); + if stepSamples <= 0 + stepSamples = max(1, round(winSamples / 2)); + end + + T = length(x); + starts = 1:stepSamples:(T - winSamples + 1); + nWin = length(starts); + + teVals = nan(nWin, 1); + pVals = nan(nWin, 1); + winTimes = nan(nWin, 1); + + for w = 1:nWin + idx = starts(w):(starts(w) + winSamples - 1); + xw = x(idx); + yw = y(idx); + [teVals(w), pVals(w)] = computeTE(xw, yw, opts); + winTimes(w) = (starts(w) + winSamples/2 - 1) / fs; + end + + result.value = teVals; + result.pvalue = pVals; + result.direction = 'x->y'; + result.method = 'transferEntropy'; + result.windowed = true; + result.windowTimes = winTimes; + end +end + + +function [teVal, pval] = computeTE(x, y, opts) +% Compute transfer entropy TE(x->y) with surrogate p-value + + dim = opts.EmbeddingDim; + delay = opts.Delay; + nBins = opts.NBins; + nSurr = opts.NumSurrogates; + + % Handle NaN: use longest contiguous valid segment (preserves temporal order) + valid = ~isnan(x) & ~isnan(y); + [segStart, segLen] = longestRun(valid); + if segLen == 0 + teVal = NaN; + pval = NaN; + return; + end + x = x(segStart:segStart + segLen - 1); + y = y(segStart:segStart + segLen - 1); + + T = length(x); + minLen = (dim + 1) * delay + 1; + + if T < minLen + teVal = NaN; + pval = NaN; + return; + end + + % Compute observed TE + teVal = estimateTE(x, y, dim, delay, nBins); + + % Surrogate p-value via block-shuffle of x + if nSurr > 0 + surrTE = zeros(nSurr, 1); + blockLen = max(round(T / 10), dim * delay + 1); + for s = 1:nSurr + xShuff = blockShuffle(x, blockLen); + surrTE(s) = estimateTE(xShuff, y, dim, delay, nBins); + end + pval = (sum(surrTE >= teVal) + 1) / (nSurr + 1); + else + pval = NaN; + end +end + + +function te = estimateTE(x, y, dim, delay, nBins) +% Histogram-based transfer entropy estimation +% +% TE(x->y) = H(y_future, y_past) + H(y_past, x_past) - H(y_past) - H(y_future, y_past, x_past) + + T = length(x); + maxLag = dim * delay; + nObs = T - maxLag; + + if nObs < 10 + te = NaN; + return; + end + + % Build state vectors + yFuture = y((maxLag + 1):T); + + % y_past: embedding of y + yPast = zeros(nObs, dim); + for d = 1:dim + lag = d * delay; + yPast(:, d) = y((maxLag + 1 - lag):(T - lag)); + end + + % x_past: embedding of x + xPast = zeros(nObs, dim); + for d = 1:dim + lag = d * delay; + xPast(:, d) = x((maxLag + 1 - lag):(T - lag)); + end + + % Bin all variables to integers 1..nBins + yFutureBin = binData(yFuture, nBins); + yPastBin = combineBins(yPast, nBins); + xPastBin = combineBins(xPast, nBins); + + % Compute entropies + % TE = H(yFuture, yPast) + H(yPast, xPast) - H(yPast) - H(yFuture, yPast, xPast) + H_yf_yp = jointEntropy(yFutureBin, yPastBin); + H_yp_xp = jointEntropy(yPastBin, xPastBin); + H_yp = entropy1d(yPastBin); + H_yf_yp_xp = jointEntropy3(yFutureBin, yPastBin, xPastBin); + + te = H_yf_yp + H_yp_xp - H_yp - H_yf_yp_xp; + te = max(te, 0); % TE should be non-negative +end + + +function binned = binData(v, nBins) +% Bin a 1D vector into integer bins 1..nBins + vMin = min(v); + vMax = max(v); + if vMax == vMin + binned = ones(size(v)); + else + binned = floor((v - vMin) / (vMax - vMin) * (nBins - 1)) + 1; + binned = min(binned, nBins); + end +end + + +function combined = combineBins(M, nBins) +% Combine multi-column binned data into single integer index + [nObs, nDim] = size(M); + combined = zeros(nObs, 1); + for d = 1:nDim + col = binData(M(:, d), nBins); + combined = combined * nBins + (col - 1); + end + combined = combined + 1; +end + + +function H = entropy1d(v) +% Shannon entropy of discrete vector (in nats) + counts = accumarray(v(:), 1); + counts = counts(counts > 0); + p = counts / sum(counts); + H = -sum(p .* log(p)); +end + + +function H = jointEntropy(a, b) +% Joint entropy of two discrete vectors + combined = (a(:) - 1) * max(b) + b(:); + H = entropy1d(combined); +end + + +function H = jointEntropy3(a, b, c) +% Joint entropy of three discrete vectors + combined = ((a(:) - 1) * max(b) + (b(:) - 1)) * max(c) + c(:); + H = entropy1d(combined); +end + + +function xShuff = blockShuffle(x, blockLen) +% Block-shuffle a time series preserving local autocorrelation + T = length(x); + nBlocks = ceil(T / blockLen); + blockStarts = 1:blockLen:T; + perm = randperm(length(blockStarts)); + xShuff = zeros(T, 1); + pos = 1; + for i = 1:length(perm) + bStart = blockStarts(perm(i)); + bEnd = min(bStart + blockLen - 1, T); + bLen = bEnd - bStart + 1; + endPos = min(pos + bLen - 1, T); + actualLen = endPos - pos + 1; + xShuff(pos:endPos) = x(bStart:(bStart + actualLen - 1)); + pos = endPos + 1; + if pos > T + break; + end + end +end + + +function [start, len] = longestRun(mask) +% Find the start index and length of the longest contiguous run of true values + d = diff([0; mask(:); 0]); + starts = find(d == 1); + ends = find(d == -1) - 1; + if isempty(starts) + start = 1; + len = 0; + return; + end + lengths = ends - starts + 1; + [len, idx] = max(lengths); + start = starts(idx); +end diff --git a/+exploreFNIRS/+coupling/wcoherence.m b/+exploreFNIRS/+coupling/wcoherence.m new file mode 100644 index 00000000..68ebc337 --- /dev/null +++ b/+exploreFNIRS/+coupling/wcoherence.m @@ -0,0 +1,62 @@ +function result = wcoherence(x, y, fs, varargin) +% WCOHERENCE Wavelet coherence (WCT) between two time series +% +% Computes wavelet coherence using the continuous wavelet transform, +% providing time-frequency resolved coupling between two signals. +% Returns mean coherence in a frequency band as the scalar coupling +% value, plus the full time-frequency coherence matrix for visualization. +% +% Syntax: +% result = exploreFNIRS.coupling.wcoherence(x, y, fs) +% result = exploreFNIRS.coupling.wcoherence(x, y, fs, 'FreqRange', [0.01 0.1]) +% result = exploreFNIRS.coupling.wcoherence(x, y, fs, 'PhaseOutput', true) +% +% Inputs: +% x - [T x 1] First time series (column vector) +% y - [T x 1] Second time series (column vector) +% fs - Sampling frequency (Hz), positive scalar +% +% Name-Value Parameters: +% FreqRange - [fLow fHigh] frequency band in Hz (default: [0.01, fs/2]) +% Typical fNIRS: [0.01, 0.1] for hemodynamic +% VoicesPerOctave - Frequency resolution (default: 10, range 1-48) +% ApplyCOI - Exclude cone-of-influence region from scalar value +% (default: true) +% PhaseOutput - Return phase angles from cross-spectrum (default: false) +% CwtX - Pre-computed CWT struct for x (from pf2_base.wavelet.cwt). +% Skips CWT computation for x when provided. +% CwtY - Pre-computed CWT struct for y (from pf2_base.wavelet.cwt). +% Skips CWT computation for y when provided. +% +% Outputs: +% result - Struct with fields: +% .value - Mean WCT magnitude in FreqRange (scalar, COI-masked if enabled) +% .pvalue - NaN (use permutation test for significance) +% .method - 'wcoherence' +% .windowed - false +% .wcoh - [F x T] wavelet coherence matrix (0 to 1) +% .freqs - [F x 1] frequency vector (Hz) +% .times - [T x 1] time vector (seconds) +% .coi - [T x 1] cone of influence boundary (Hz) +% .freqRange - [fLow fHigh] band used for scalar value +% .phase - [F x T] phase angles in radians (if PhaseOutput=true) +% +% Notes: +% No Wavelet Toolbox required. Delegates to pf2_base.wavelet.wcoherence +% which uses an FFT-based Morlet CWT. +% +% The cone of influence marks the region where edge effects are +% significant. By default, these regions are excluded from the scalar +% .value computation. +% +% References: +% Grinsted, A., Moore, J.C. & Jevrejeva, S. (2004). Application of the +% cross wavelet transform and wavelet coherence to geophysical time +% series. Nonlinear Processes in Geophysics, 11, 561-566. +% +% See also: exploreFNIRS.coupling.coherence, exploreFNIRS.coupling.pearson, +% exploreFNIRS.coupling.plotWcoherence, pf2_base.wavelet.wcoherence + + result = pf2_base.wavelet.wcoherence(x, y, fs, varargin{:}); + +end diff --git a/+exploreFNIRS/+coupling/xcorr.m b/+exploreFNIRS/+coupling/xcorr.m new file mode 100644 index 00000000..605805ef --- /dev/null +++ b/+exploreFNIRS/+coupling/xcorr.m @@ -0,0 +1,152 @@ +function result = xcorr(x, y, fs, varargin) +% XCORR Lagged cross-correlation between two time series +% +% Computes cross-correlation using MATLAB's xcorr, returning the peak +% correlation value and its lag. Optionally constrains the maximum lag. +% +% Syntax: +% result = exploreFNIRS.coupling.xcorr(x, y, fs) +% result = exploreFNIRS.coupling.xcorr(x, y, fs, 'MaxLag', 5) +% +% Inputs: +% x - [T x 1] time series +% y - [T x 1] time series +% fs - Sampling frequency (Hz) +% +% Name-Value Parameters: +% MaxLag - Maximum lag in seconds (default: length(x)/fs/4) +% Normalize - Normalization mode: 'coeff' (default), 'none', 'biased', 'unbiased' +% WindowSize - Sliding window duration in seconds (default: 0, full signal) +% WindowStep - Step size in seconds (default: WindowSize/2, 50% overlap) +% +% Outputs: +% result - Struct with fields: +% .value - Peak cross-correlation value +% .pvalue - Approximate p-value (based on Fisher z-transform) +% .lag - Lag in seconds at peak correlation +% .lagSamples - Lag in samples at peak correlation +% .xcorrFull - Full cross-correlation vector +% .lags - Full lag vector in seconds +% .method - 'xcorr' +% .windowed - true if sliding window was used +% +% Reference: +% Standard lagged cross-correlation. For its use in fNIRS hyperscanning +% to estimate inter-brain temporal delays see: Cui, X., Bryant, D. M. & +% Reiss, A. L. (2012). NIRS-based hyperscanning reveals increased +% interpersonal coherence in superior frontal cortex during cooperation. +% NeuroImage, 59(3), 2430-2437. DOI: 10.1016/j.neuroimage.2011.09.003 +% +% See also: exploreFNIRS.coupling.pearson, exploreFNIRS.coupling.coherence + + p = inputParser; + addRequired(p, 'x', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'y', @(v) isnumeric(v) && isvector(v)); + addRequired(p, 'fs', @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(p, 'MaxLag', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'Normalize', 'coeff', @ischar); + addParameter(p, 'WindowSize', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'WindowStep', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + parse(p, x, y, fs, varargin{:}); + opts = p.Results; + + x = x(:); + y = y(:); + if length(x) ~= length(y) + error('exploreFNIRS:coupling:xcorr', 'x and y must have equal length'); + end + + % Remove NaN by interpolation for xcorr (requires continuous signal) + x = fillNaN(x); + y = fillNaN(y); + + T = length(x); + maxLagSec = opts.MaxLag; + if maxLagSec <= 0 + maxLagSec = T / fs / 4; + end + maxLagSamp = round(maxLagSec * fs); + + winSamples = round(opts.WindowSize * fs); + + if winSamples <= 0 || winSamples >= T + % Full-signal mode + result = computeXcorr(x, y, fs, maxLagSamp, opts.Normalize); + result.windowed = false; + else + % Sliding window mode + stepSamples = round(opts.WindowStep * fs); + if stepSamples <= 0 + stepSamples = max(1, round(winSamples / 2)); + end + + starts = 1:stepSamples:(T - winSamples + 1); + nWin = length(starts); + + rVals = nan(nWin, 1); + pVals = nan(nWin, 1); + lagVals = nan(nWin, 1); + winTimes = nan(nWin, 1); + + for w = 1:nWin + idx = starts(w):(starts(w) + winSamples - 1); + res = computeXcorr(x(idx), y(idx), fs, ... + min(maxLagSamp, floor(winSamples/2)), opts.Normalize); + rVals(w) = res.value; + pVals(w) = res.pvalue; + lagVals(w) = res.lag; + winTimes(w) = (starts(w) + winSamples/2 - 1) / fs; + end + + result.value = rVals; + result.pvalue = pVals; + result.lag = lagVals; + result.method = 'xcorr'; + result.windowed = true; + result.windowTimes = winTimes; + end +end + + +function res = computeXcorr(x, y, fs, maxLagSamp, normMode) +% Compute cross-correlation for a single segment + % Use function handle to avoid shadowing by our package function name + xcorrFn = str2func('xcorr'); + [c, lags] = xcorrFn(x - mean(x), y - mean(y), maxLagSamp, normMode); + + [peakVal, peakIdx] = max(abs(c)); + peakLagSamp = lags(peakIdx); + + % Preserve sign at peak + peakVal = c(peakIdx); + + % Approximate p-value using Fisher z-transform (only valid for 'coeff' normalization) + if strcmp(normMode, 'coeff') + n = length(x); + peakVal = max(min(peakVal, 0.9999), -0.9999); + z = atanh(peakVal) * sqrt(n - 3); + pval = 2 * (1 - normcdf(abs(z))); + nLags = 2 * maxLagSamp + 1; + pval = min(pval * nLags, 1); + else + pval = NaN; + end + + res.value = peakVal; + res.pvalue = pval; + res.lag = peakLagSamp / fs; + res.lagSamples = peakLagSamp; + res.xcorrFull = c; + res.lags = lags / fs; + res.method = 'xcorr'; +end + + +function v = fillNaN(v) +% Linear interpolation of NaN values + nanIdx = isnan(v); + if ~any(nanIdx), return; end + if all(nanIdx), v(:) = 0; return; end + t = (1:length(v))'; + v(nanIdx) = interp1(t(~nanIdx), v(~nanIdx), t(nanIdx), 'linear', 'extrap'); +end diff --git a/+exploreFNIRS/+dataset/buildSegmentInfoTable.m b/+exploreFNIRS/+dataset/buildSegmentInfoTable.m index 0b7aca1d..224e25ca 100644 --- a/+exploreFNIRS/+dataset/buildSegmentInfoTable.m +++ b/+exploreFNIRS/+dataset/buildSegmentInfoTable.m @@ -1,104 +1,190 @@ -function outTable=buildSegmentInfoTable(FNIRS_array) +function outTable = buildSegmentInfoTable(FNIRS_array) +% BUILDSEGMENTINFOTABLE Build a summary table from an array of fNIRS data structs +% +% Iterates over a cell array of processed fNIRS structs and extracts the +% .info fields from each into a standardized MATLAB table. Handles type +% mismatches and missing fields across segments by filling with NaN, +% empty strings, or NaT as appropriate. +% +% Uses a two-pass approach for performance: first discovers the complete +% field schema across all segments, then pre-allocates the table and fills +% rows with direct assignment. This avoids O(n*k) ismember calls and +% dynamic column growth. +% +% Syntax: +% outTable = exploreFNIRS.dataset.buildSegmentInfoTable(FNIRS_array) +% +% Inputs: +% FNIRS_array - Cell array of fNIRS data structs, each containing an +% .info sub-struct with metadata fields (e.g., subject ID, +% condition, age). Scalar numeric, string, char, logical, +% categorical, and single-cell table values are extracted. +% +% Outputs: +% outTable - MATLAB table with one row per segment and columns for each +% unique .info field found across all segments. Missing values +% are filled with type-appropriate defaults. +% +% Example: +% data = {seg1, seg2, seg3}; % cell array of fNIRS structs +% infoTable = exploreFNIRS.dataset.buildSegmentInfoTable(data); +% disp(infoTable); +% +% See also: exploreFNIRS.dataset.standardizeROIs, exploreFNIRS -% standardizes all rows and types for provdied array - -warning off MATLAB:table:RowsAddedExistingVars +if isempty(FNIRS_array) + error('exploreFNIRS:dataset:buildSegmentInfoTable:noData', 'No Data to build exploreFNIRS data table!\n') +end + +numF = length(FNIRS_array); + +% ----------------------------------------------------------------------- +% Pass 1: Discover schema — collect all unique field names and their types +% ----------------------------------------------------------------------- +allFieldNames = {}; +fieldTypeMap = struct(); % fieldName -> column type string + +for i = 1:numF + seg = FNIRS_array{i}; + if ~isfield(seg, 'info'), continue; end + + fNames = fieldnames(seg.info); + for j = 1:length(fNames) + fn = fNames{j}; + if isfield(fieldTypeMap, fn), continue; end % already registered + + val = seg.info.(fn); + colType = classifyForTable(val); + if isempty(colType), continue; end % not a valid info value + + allFieldNames{end+1} = fn; %#ok + fieldTypeMap.(fn) = colType; + end +end + +nCols = length(allFieldNames); -if(isempty(FNIRS_array)) - error('No Data to build exploreFNIRS data table!\n') +if nCols == 0 + outTable = table('Size', [numF, 0], 'VariableTypes', {}, 'VariableNames', {}); return; -else - - numF=length(FNIRS_array); - - outTable=table(); - for i=1:numF - fprintf('Row %i of %i\n',i,numF); - - curFNIRseg=FNIRS_array{i}; - - if(~isfield(curFNIRseg,'info')) - warning('All fNIRS segments must have a .info section'); - continue; - end - curFields=fields(curFNIRseg.info); - for j=1:length(curFields) - curFieldName=curFields{j}; - - curField=curFNIRseg.info.(curFieldName); - - if(isempty(curField)|| ... - (isnumeric(curField)&&length(curField)==1)||... %numeric items of 1 - ischar(curField)||isstring(curField)||... %strings or chars - (islogical(curField)&&length(curField)==1)||... %logical values - (iscategorical(curField)&&length(curField)==1)||... %categorical values - (istable(curField)&&size(curField,1)==1&&size(curField,2)==1)) %singular tables - - if(istable(curField)&&size(curField,1)==1&&size(curField,2)==1) - curField=curField{1,1}; - end - - if(isstring(curField)||ischar(curField)) - curField=string(strtrim(curField)); - end - - - if(ismember(curFieldName,outTable.Properties.VariableNames)&&~isempty(curField)) - if(strcmpi(curField,'missing')&&isnumeric(outTable.(curFieldName)(1,1))) - outTable.(curFieldName)(i,1)=nan; - else - outTable.(curFieldName)(i,1)=curField; - end - elseif(~isempty(curField)) - if(ischar(curField)) % adds columns - outTable.(curFieldName)=strings(size(outTable,1),1); - outTable.(curFieldName)(i,1)=nominal(curField); - elseif(isstring(curField)) - outTable.(curFieldName)=strings(size(outTable,1),1); - outTable.(curFieldName)(i,1)=nominal(curField); - elseif(isnumeric(curField)) - outTable.(curFieldName)=nan(size(outTable,1),1); - outTable.(curFieldName)(i,1)=curField; - elseif(islogical(curField)) - outTable.(curFieldName)=strings(size(outTable,1),1); - outTable.(curFieldName)(i,1)=nominal(string(curField)); - elseif(iscategorical(curField)) - outTable.(curFieldName)=strings(size(outTable,1),1); - outTable.(curFieldName)(i,1)=string(curField); - end - - end - end - end - - missingFieldsIdx=~ismember(outTable.Properties.VariableNames,curFields); - - if(any(missingFieldsIdx)) - missingFieldsName=outTable.Properties.VariableNames(missingFieldsIdx); - for f=1:length(missingFieldsName) - switch(class(outTable.(missingFieldsName{f}))) - case 'double' - outTable.(missingFieldsName{f})(i,:)=nan; - case 'string' - outTable.(missingFieldsName{f})(i,:)=""; - case 'char' - outTable.(missingFieldsName{f})(i,:)=''; - case 'cell' - outTable.(missingFieldsName{f})(i,:)={}; - case 'logical' - outTable.(missingFieldsName{f})(i,:)=nan; - case 'duration' - outTable.(missingFieldsName{f})(i,:)=duration(0,0,nan); - case 'datetime' - outTable.(missingFieldsName{f})(i,:)=NaT; - otherwise - error('Unknown type!'); - end - - end - - end +end + +% ----------------------------------------------------------------------- +% Pre-allocate table with correct column types and defaults +% ----------------------------------------------------------------------- +colTypes = cell(1, nCols); +for c = 1:nCols + colTypes{c} = fieldTypeMap.(allFieldNames{c}); +end + +outTable = table('Size', [numF, nCols], ... + 'VariableTypes', colTypes, ... + 'VariableNames', allFieldNames); + +% table() with 'Size' initializes: double→0, string→, etc. +% Override defaults: double→NaN, string→"" +for c = 1:nCols + switch colTypes{c} + case 'double' + outTable.(allFieldNames{c})(:) = NaN; + case 'string' + outTable.(allFieldNames{c})(:) = ""; + case 'datetime' + outTable.(allFieldNames{c})(:) = NaT; + end +end + +% Build O(1) lookup: field name → column index +colIdx = containers.Map(allFieldNames, num2cell(1:nCols)); + +% ----------------------------------------------------------------------- +% Pass 2: Fill rows with direct column assignment +% ----------------------------------------------------------------------- +for i = 1:numF + if mod(i, 500) == 0 || i == numF + fprintf('Row %i of %i\n', i, numF); + end + + seg = FNIRS_array{i}; + if ~isfield(seg, 'info'), continue; end + + fNames = fieldnames(seg.info); + for j = 1:length(fNames) + fn = fNames{j}; + if ~colIdx.isKey(fn), continue; end + + val = seg.info.(fn); + if isempty(val), continue; end + + % Unwrap 1x1 table + if istable(val) && size(val,1) == 1 && size(val,2) == 1 + val = val{1,1}; + end + + targetType = colTypes{colIdx(fn)}; + + % Assign with type coercion + switch targetType + case 'double' + if isnumeric(val) && isscalar(val) + outTable.(fn)(i) = double(val); + elseif (isstring(val) || ischar(val)) && strcmpi(val, 'missing') + outTable.(fn)(i) = NaN; + end + case 'string' + if ischar(val) || isstring(val) + outTable.(fn)(i) = string(strtrim(val)); + elseif islogical(val) + outTable.(fn)(i) = string(val); + elseif iscategorical(val) + outTable.(fn)(i) = string(val); + elseif isnumeric(val) && isscalar(val) + outTable.(fn)(i) = string(val); + end + case 'datetime' + if isdatetime(val) + outTable.(fn)(i) = val; + end + case 'duration' + if isduration(val) + outTable.(fn)(i) = val; + end + end + end +end + +end + + +% ========================================================================= +% Local helper functions +% ========================================================================= + +function colType = classifyForTable(val) +% CLASSIFYFORTABLE Determine the table column type for an info field value. +% Returns '' if the value is not suitable for table storage. + + colType = ''; + + % Unwrap 1x1 table + if istable(val) && size(val,1) == 1 && size(val,2) == 1 + val = val{1,1}; + end + + if isempty(val) + return; % can't determine type from empty + elseif ischar(val) || isstring(val) + colType = 'string'; + elseif isnumeric(val) && isscalar(val) + colType = 'double'; + elseif islogical(val) && isscalar(val) + colType = 'string'; % stored as string (matches original behavior) + elseif iscategorical(val) && isscalar(val) + colType = 'string'; + elseif isdatetime(val) && isscalar(val) + colType = 'datetime'; + elseif isduration(val) && isscalar(val) + colType = 'duration'; end - %close(hF); + % Anything else (arrays, structs, cells, etc.) → not stored end - \ No newline at end of file diff --git a/+exploreFNIRS/+dataset/standardizeROIs.m b/+exploreFNIRS/+dataset/standardizeROIs.m index f1b3d952..844774b9 100644 --- a/+exploreFNIRS/+dataset/standardizeROIs.m +++ b/+exploreFNIRS/+dataset/standardizeROIs.m @@ -1,4 +1,32 @@ function [uROI,uROInames,ExFNIRS_data]=standardizeROIs(ExFNIRS_data) +% STANDARDIZEROIS Unify ROI definitions across multi-device fNIRS datasets +% +% Scans all fNIRS data segments for ROI definitions and creates a +% standardized, device-qualified ROI table. ROI names are made unique per +% device by appending '$deviceName'. All segments are then updated with +% the unified ROI table so that cross-device group analysis can use +% consistent ROI indices. +% +% Syntax: +% [uROI, uROInames, ExFNIRS_data] = exploreFNIRS.dataset.standardizeROIs(ExFNIRS_data) +% +% Inputs: +% ExFNIRS_data - Cell array of fNIRS data structs, each optionally +% containing an ROI.info table with ROI definitions +% +% Outputs: +% uROI - Table of unique ROIs with device-qualified row names, +% an 'index' column for cross-device matching, and a +% 'DeviceCfg' column identifying the source device +% uROInames - Cell array of unique ROI names (without device qualifier) +% ExFNIRS_data - Input data with ROI.info fields overwritten to use the +% standardized, sorted ROI table +% +% Example: +% [uROI, names, data] = exploreFNIRS.dataset.standardizeROIs(data); +% disp(uROI); +% +% See also: exploreFNIRS.dataset.buildSegmentInfoTable, exploreFNIRS fprintf('Scanning ROI fields...\n'); % searches for all unique ROI per device, one name allowed per d @@ -59,7 +87,7 @@ if(height(b)>numDevROI) % This shouldnt be called because we only add ROI names % when they are not members of current or currentDevNames - error('duplicate ROI names present'); + error('exploreFNIRS:dataset:standardizeROIs:duplicateROI', 'duplicate ROI names present'); end % Assign linear index to all ROIs with same original name diff --git a/+exploreFNIRS/+export/connectivityToTable.m b/+exploreFNIRS/+export/connectivityToTable.m new file mode 100644 index 00000000..78cb034f --- /dev/null +++ b/+exploreFNIRS/+export/connectivityToTable.m @@ -0,0 +1,239 @@ +function T = connectivityToTable(result, varargin) +% CONNECTIVITYTOTABLE Export coupling results as long-format table +% +% Converts connectivity or hyperscanning results into a long-format table +% suitable for export to CSV or use with R/lme4 statistical workflows. +% +% Syntax: +% T = exploreFNIRS.export.connectivityToTable(result) +% T = exploreFNIRS.export.connectivityToTable(result, 'IncludeDyads', true) +% +% Inputs: +% result - One of: +% - Connectivity result from computeMatrix (single subject) +% - Group connectivity result from Experiment.connectivity() +% - Hyperscanning result from Experiment.hyperscanning() or computeGroup +% +% Name-Value Parameters: +% IncludeDyads - Include individual dyad-level rows (default: false) +% IncludeGroup - Include group summary rows (default: true) +% +% Outputs: +% T - Table with columns (varies by input type): +% Common: Method, Biomarker, ChannelA, ChannelB, Coupling, PValue +% Hyperscanning: SubjectA, SubjectB, DyadID +% Group: GroupLabel, Mean, SD, SEM, N +% +% Example: +% result = ex.hyperscanning('Method', 'pearson', 'Biomarker', 'HbO'); +% T = exploreFNIRS.export.connectivityToTable(result, 'IncludeDyads', true); +% writetable(T, 'hyperscanning_results.csv'); +% +% See also: exploreFNIRS.connectivity.computeMatrix, +% exploreFNIRS.hyperscanning.computeGroup + + ip = inputParser; + addRequired(ip, 'result', @isstruct); + addParameter(ip, 'IncludeDyads', false, @islogical); + addParameter(ip, 'IncludeGroup', true, @islogical); + parse(ip, result, varargin{:}); + opts = ip.Results; + + T = table(); + + if isfield(result, 'dyads') && isfield(result, 'dyadIDs') + % Hyperscanning group result + T = exportHyperscanning(result, opts); + elseif isfield(result, 'matrix') && isfield(result, 'pmatrix') + % Single connectivity matrix + T = exportConnectivityMatrix(result); + elseif isfield(result, 'Mean') && isfield(result, 'matrices') + % Group connectivity result (from Experiment.connectivity) + T = exportGroupConnectivity(result, opts); + else + error('exploreFNIRS:export:connectivityToTable', ... + 'Unrecognized result format'); + end +end + + +function T = exportHyperscanning(result, opts) +% Export hyperscanning group result + rows = {}; + method = result.method; + bioM = result.biomarker; + channels = result.channels; + + % Group summary + if opts.IncludeGroup + if strcmpi(result.pairing, 'same') + for c = 1:length(channels) + row.Method = {method}; + row.Biomarker = {bioM}; + row.ChannelA = channels(c); + row.ChannelB = channels(c); + row.Level = {'Group'}; + row.DyadID = {''}; + row.SubjectA = {''}; + row.SubjectB = {''}; + row.Coupling = result.Mean(c); + row.PValue = result.pvalue(c); + row.SD = result.SD(c); + row.SEM = result.SEM(c); + row.N = result.N(c); + rows{end+1} = struct2table(row); %#ok + end + else + % 'all' pairing - Ca x Cb matrix + [nA, nB] = size(result.Mean); + chA = result.channels; + chB = result.channels; + if isfield(result, 'dyads') && ~isempty(result.dyads) + if isfield(result.dyads{1}, 'channelsB') + chB = result.dyads{1}.channelsB; + end + end + for a = 1:nA + for b = 1:nB + row.Method = {method}; + row.Biomarker = {bioM}; + row.ChannelA = chA(min(a, length(chA))); + row.ChannelB = chB(min(b, length(chB))); + row.Level = {'Group'}; + row.DyadID = {''}; + row.SubjectA = {''}; + row.SubjectB = {''}; + row.Coupling = result.Mean(a, b); + row.PValue = result.pvalue(a, b); + row.SD = result.SD(a, b); + row.SEM = result.SEM(a, b); + row.N = result.N(a, b); + rows{end+1} = struct2table(row); %#ok + end + end + end + end + + % Individual dyads + if opts.IncludeDyads && isfield(result, 'dyads') + for d = 1:length(result.dyads) + dRes = result.dyads{d}; + dyadID = result.dyadIDs{d}; + + % Get subject IDs from pairs if available + subjA = ''; + subjB = ''; + if isfield(result, 'pairs') && d <= length(result.pairs) + if ~isempty(result.pairs(d).subjectIDs) + subjA = result.pairs(d).subjectIDs{1}; + if length(result.pairs(d).subjectIDs) >= 2 + subjB = result.pairs(d).subjectIDs{2}; + end + end + end + + if strcmpi(dRes.pairing, 'same') + for c = 1:length(dRes.channelsA) + row.Method = {method}; + row.Biomarker = {bioM}; + row.ChannelA = dRes.channelsA(c); + row.ChannelB = dRes.channelsB(c); + row.Level = {'Dyad'}; + row.DyadID = {dyadID}; + row.SubjectA = {subjA}; + row.SubjectB = {subjB}; + row.Coupling = dRes.values(c); + row.PValue = dRes.pvalues(c); + row.SD = NaN; + row.SEM = NaN; + row.N = 1; + rows{end+1} = struct2table(row); %#ok + end + else + for a = 1:length(dRes.channelsA) + for b = 1:length(dRes.channelsB) + row.Method = {method}; + row.Biomarker = {bioM}; + row.ChannelA = dRes.channelsA(a); + row.ChannelB = dRes.channelsB(b); + row.Level = {'Dyad'}; + row.DyadID = {dyadID}; + row.SubjectA = {subjA}; + row.SubjectB = {subjB}; + row.Coupling = dRes.values(a, b); + row.PValue = dRes.pvalues(a, b); + row.SD = NaN; + row.SEM = NaN; + row.N = 1; + rows{end+1} = struct2table(row); %#ok + end + end + end + end + end + + if ~isempty(rows) + T = vertcat(rows{:}); + end +end + + +function T = exportConnectivityMatrix(result) +% Export single connectivity matrix + channels = result.channels; + nCh = length(channels); + rows = {}; + + for i = 1:nCh + for j = (i+1):nCh + row.Method = {result.method}; + row.Biomarker = {result.biomarker}; + row.ChannelA = channels(i); + row.ChannelB = channels(j); + row.Coupling = result.matrix(i, j); + row.PValue = result.pmatrix(i, j); + rows{end+1} = struct2table(row); %#ok + end + end + + if ~isempty(rows) + T = vertcat(rows{:}); + else + T = table(); + end +end + + +function T = exportGroupConnectivity(result, opts) +% Export group connectivity results + rows = {}; + + for g = 1:length(result) + grp = result(g); + channels = grp.channels; + nCh = length(channels); + + if opts.IncludeGroup + for i = 1:nCh + for j = (i+1):nCh + row.GroupLabel = {grp.label}; + row.Method = {grp.method}; + row.Biomarker = {grp.biomarker}; + row.ChannelA = channels(i); + row.ChannelB = channels(j); + row.Mean = grp.Mean(i, j); + row.SD = grp.SD(i, j); + row.SEM = grp.SEM(i, j); + row.N = grp.N; + rows{end+1} = struct2table(row); %#ok + end + end + end + end + + if ~isempty(rows) + T = vertcat(rows{:}); + else + T = table(); + end +end diff --git a/+exploreFNIRS/+export/mergeGbyTablesLong.m b/+exploreFNIRS/+export/mergeGbyTablesLong.m index 98b5df62..d3b9dea3 100644 --- a/+exploreFNIRS/+export/mergeGbyTablesLong.m +++ b/+exploreFNIRS/+export/mergeGbyTablesLong.m @@ -117,10 +117,11 @@ if(emptyChannelFlag&&exportFNIR) numCh=size(curBarGA.HbO.data,2); channelIndexes=1:numCh; + channelLabels=cellstr(num2str(channelIndexes(:))); elseif(exportFNIR) - numCh=length(channelIndexes); + numCh=length(channelIndexes); else - numCH=0; + numCh=0; end if(isempty(times)) numTimes=length(curBarGA.time); @@ -253,9 +254,10 @@ for i=1:length(t1Vars) curVar=t1Vars{i}; if(~ismember(curVar,t2Vars)) - if(ischar(curVar)) + srcCol=table1.(curVar); + if(ischar(srcCol) || isstring(srcCol) || iscellstr(srcCol)) table2.(curVar)=strings(size(table2,1),1); - elseif(isnumeric(curVar)) + else table2.(curVar)=nan(size(table2,1),1); end end @@ -264,9 +266,10 @@ for i=1:length(t2Vars) curVar=t2Vars{i}; if(~ismember(curVar,t1Vars)) - if(ischar(curVar)) + srcCol=table2.(curVar); + if(ischar(srcCol) || isstring(srcCol) || iscellstr(srcCol)) table1.(curVar)=strings(size(table1,1),1); - elseif(isnumeric(curVar)) + else table1.(curVar)=nan(size(table1,1),1); end end diff --git a/+exploreFNIRS/+export/mergeGbyTablesWide.m b/+exploreFNIRS/+export/mergeGbyTablesWide.m index db8ddecd..84287caf 100644 --- a/+exploreFNIRS/+export/mergeGbyTablesWide.m +++ b/+exploreFNIRS/+export/mergeGbyTablesWide.m @@ -106,6 +106,7 @@ if(emptyChannelFlag&&exportFNIR) numCh=size(curBarGA.HbO.data,2); channels=1:numCh; + optodeNames=cellstr(num2str(channels(:))); elseif(exportFNIR) numCh=length(channels); else diff --git a/+exploreFNIRS/+fx/autoContrast.m b/+exploreFNIRS/+fx/autoContrast.m index 12a3ca18..9d4b9f40 100644 --- a/+exploreFNIRS/+fx/autoContrast.m +++ b/+exploreFNIRS/+fx/autoContrast.m @@ -476,11 +476,10 @@ [~,~,mdlCoef]=fixedEffects(mdl,'DFMethod','satterthwaite'); [~,uNameIdx]=unique(cName); +uNameIdx=sort(uNameIdx); -% UDATE vs. 0 index for unqiue names +% Update vs. 0 index to match sorted uNameIdx ordering isV0=isV0(uNameIdx); - -uNameIdx=sort(uNameIdx); for c=1:length(uNameIdx) row_idx=uNameIdx(c); curRow=cRows(row_idx,:); @@ -488,23 +487,22 @@ %df2(c)=mdlCoef.DF(c); %overwrite with satterwaite coefs - mdlCoefAnv=mdlCoef(curRow==1,:); - mdlCoefCompare=mdlCoef(curRow==-1,:); - if(isempty(mdlCoefCompare)) - deltaE(c)=sum(mdlCoefAnv.Estimate); + % Effect size and SE as weighted combinations of coefficients. + % Works for any weights including fractional (Helmert, polynomial, deviation). + curRowVec=curRow(:)'; + deltaE(c)=curRowVec*mdlCoef.Estimate; + + covBeta=mdl.CoefficientCovariance; + SD_p(c)=sqrt(curRowVec*covBeta*curRowVec'); + + % Glass's delta uses the positive-side-only SE as the reference SD + posMask=curRowVec>0; + if any(posMask) + posWeights=curRowVec.*posMask; + SD_anv(c)=sqrt(posWeights*covBeta*posWeights'); else - deltaE(c)=sum(mdlCoefAnv.Estimate)-sum(mdlCoefCompare.Estimate); + SD_anv(c)=SD_p(c); end - - SD_anv_temp=mdlCoefAnv.SE; - SD_cmp=mdlCoefCompare.SE; - - SD_p(c)=sqrt(sum((mdlCoefAnv.DF).*(SD_anv_temp.^2))+... - sum((mdlCoefCompare.DF).*(SD_cmp.^2)))... - /sqrt(sum(mdlCoefAnv.DF)+sum(mdlCoefCompare.DF)); - SD_anv(c)=sqrt(sum((mdlCoefAnv.DF).*(SD_anv_temp.^2)))... - /sqrt(sum(mdlCoefAnv.DF)); - %SE_p(c)=SD_p(c)/sqrt(mean(mdlCoefAnv.DF)); HedgesG(c)=deltaE(c)/SD_p(c); GlassesDelta(c)=deltaE(c)/SD_anv(c); diff --git a/+exploreFNIRS/+fx/performFDR.m b/+exploreFNIRS/+fx/performFDR.m index c19f9750..fb9ca241 100644 --- a/+exploreFNIRS/+fx/performFDR.m +++ b/+exploreFNIRS/+fx/performFDR.m @@ -17,28 +17,29 @@ % Inputs: % pvalues - Matrix or vector of uncorrected p-values % Can be a numeric array or table (tables are converted) -% NaN values are ignored in calculations +% NaN values are excluded from the test count % pThreshold - FDR threshold for significance (default: 0.05) % Typical values: 0.01, 0.05, 0.10 % % Outputs: % qvalues - FDR-corrected q-values, same size as pvalues -% q = p * m / k, where m = total tests, k = rank +% q_i = min_{j>=i}( p_(j) * m / j ), m = number of valid tests % Values > 1 are capped at 1 -% k - Critical rank: largest i where p(i) <= q * i / m -% Used for adjusting q-values +% k - Critical rank: largest i where p(i) <= pThreshold * i / m +% Minimum value of 1 % passed - Logical matrix indicating significant results -% true where qvalues <= pThreshold AND pvalues < 0.05 +% true where qvalues <= pThreshold % % Algorithm (Benjamini-Hochberg procedure): -% 1. Sort p-values in ascending order +% 1. Sort valid (non-NaN) p-values in ascending order % 2. For each p-value at rank i, calculate threshold: q * i / m % 3. Find largest k where p(k) <= threshold -% 4. Reject all hypotheses with rank <= k -% 5. Calculate adjusted q-values: q(i) = p(i) * m / k +% 4. Compute candidate q-values q_i = p_(i) * m / i +% 5. Enforce monotonicity via running min from the tail: +% q_i = min_{j>=i}( p_(j) * m / j ) % % Notes: -% - Results are further constrained to raw p < 0.05 +% - NaN p-values are excluded from the test count and remain NaN in output % - This is the standard (non-adaptive) BH procedure % - For two-step adaptive FDR, see performFDR_twostep % - Assumes tests are independent or positively dependent @@ -53,46 +54,48 @@ % % See also: performFDR_twostep, exploreFNIRS.fx.autoContrast -if(istable(pvalues)) +if istable(pvalues) pvalues=table2array(pvalues); end -if(nargin<2) +if nargin<2 pThreshold=0.05; end -qvalues=nan(size(pvalues)); -kVals=nan(size(pvalues(:))); -[pSorted,pIdx]=sort(pvalues(:)); +% Count valid (non-NaN) tests +m=sum(~isnan(pvalues(:))); -numP=length(pSorted); -kPass=zeros(1,numP); -m=numP; +if m==0 + qvalues=nan(size(pvalues)); + k=1; + passed=false(size(pvalues)); + return; +end -for i=1:numP - qThreshold=pThreshold/m*i; - k=numP-i+1; - qvalues(pIdx(i))=pvalues(pIdx(i))*m/i; +% Sort valid p-values +validIdx=find(~isnan(pvalues(:))); +pVec=pvalues(validIdx); +[pSorted,sortOrd]=sort(pVec(:)); - if(qvalues(pIdx(i))<=pThreshold&&pvalues(pIdx(i))<=0.05) - kPass(pIdx(i))=1; +% Find critical k: largest rank i where p(i) <= pThreshold * i / m +k=0; +for i=1:m + if pSorted(i)<=pThreshold*i/m + k=i; end - kVals(pIdx(i))=i; end +k=max(k,1); -k_ind=find(kPass==1); -if(isempty(k_ind)) - k=1; -else - k=max(k_ind); +% Standard BH adjusted p-values: q_i = min_{j>=i}( p_(j) * m / j ) +qSorted=pSorted.*m./(1:m)'; +for i=(m-1):-1:1 + qSorted(i)=min(qSorted(i),qSorted(i+1)); end +qSorted(qSorted>1)=1; -qvalues=pvalues*m/k; -qvalues(qvalues>1)=1; +% Unsort back to original positions +qVec=zeros(m,1); +qVec(sortOrd)=qSorted; +qvalues=nan(size(pvalues)); +qvalues(validIdx)=qVec; passed=qvalues<=pThreshold; - -if(any(kPass(:))) - k=max(kVals(kPass(:)==1)); - qvalues=pvalues*m/k; - passed=qvalues<=pThreshold&pvalues<0.05; -end diff --git a/+exploreFNIRS/+fx/performFDR_twostep.m b/+exploreFNIRS/+fx/performFDR_twostep.m index d1ebcb5a..09f51cfe 100644 --- a/+exploreFNIRS/+fx/performFDR_twostep.m +++ b/+exploreFNIRS/+fx/performFDR_twostep.m @@ -17,7 +17,7 @@ % Inputs: % pvalues - Matrix or vector of uncorrected p-values % Can be a numeric array or table (tables are converted) -% NaN values are ignored in calculations +% NaN values are excluded from calculations % pThreshold - FDR threshold for significance (default: 0.05) % Typical values: 0.01, 0.05, 0.10 % @@ -27,10 +27,9 @@ % Values > 1 are capped at 1 % k - Critical rank from the FDR procedure % passed - Logical matrix indicating significant results -% true where qvalues <= pThreshold AND pvalues <= 0.05 % % Algorithm (Two-step adaptive BH procedure): -% Step 1: Apply standard BH at level q' = q +% Step 1: Apply standard BH at level q % Get initial set of rejections (r0) % Step 2: If some (but not all) hypotheses rejected: % - Estimate m0 = m - r0 (number of true nulls) @@ -39,7 +38,6 @@ % % Notes: % - More powerful than standard BH when proportion of nulls is high -% - Results are constrained to raw p <= 0.05 % - Falls back to standard FDR if all or none pass step 1 % - Assumes tests are independent or positively dependent % @@ -53,36 +51,24 @@ % % See also: performFDR, exploreFNIRS.fx.autoContrast -if(istable(pvalues)) +if istable(pvalues) pvalues=table2array(pvalues); end -if(nargin<2) +if nargin<2 pThreshold=0.05; end -m=length(pvalues(:)); -q_prime=pThreshold; +% Count valid (non-NaN) tests +m=sum(~isnan(pvalues(:))); -% Step 1: Standard FDR at level q' -[qvalues,k,passed]=exploreFNIRS.fx.performFDR(pvalues,q_prime); +% Step 1: Standard FDR at level q +[qvalues,k,passed]=exploreFNIRS.fx.performFDR(pvalues,pThreshold); % Step 2: If some (but not all) passed, adjust threshold -if(sum(passed(:))>0&&sum(~passed(:))>0) - m=sum(~isnan(pvalues)); - numPassed=sum(passed); - mo=m-numPassed; - q_star=q_prime*m/mo; % Adjusted threshold - +numPassed=sum(passed(:)); +if numPassed>0 && numPassed1)=1; - passed=passed&pvalues<=0.05; -else - qvalues=pvalues; - qvalues(qvalues>1)=1; - passed=qvalues<=pThreshold&pvalues<=0.05; -end diff --git a/+exploreFNIRS/+graph/betweenness.m b/+exploreFNIRS/+graph/betweenness.m new file mode 100644 index 00000000..4a65004a --- /dev/null +++ b/+exploreFNIRS/+graph/betweenness.m @@ -0,0 +1,79 @@ +function result = betweenness(G) +% BETWEENNESS Betweenness centrality for each node +% +% Computes betweenness centrality using MATLAB's built-in graph object. +% Betweenness centrality of a node v is the fraction of all shortest +% paths between pairs of other nodes that pass through v. Values are +% normalized to [0, 1] by dividing by (N-1)*(N-2)/2 for undirected +% or (N-1)*(N-2) for directed graphs. +% +% Reference: +% Brandes, U. (2001). A faster algorithm for betweenness centrality. +% Journal of Mathematical Sociology, 25(2), 163-177. +% DOI: 10.1080/0022250X.2001.9990249 +% +% Syntax: +% result = exploreFNIRS.graph.betweenness(G) +% +% Inputs: +% G - Graph struct from exploreFNIRS.graph.threshold +% +% Outputs: +% result - Struct with fields: +% .BC [1 x N] normalized betweenness centrality +% .BCraw [1 x N] raw (unnormalized) betweenness centrality +% +% Example: +% G = exploreFNIRS.graph.threshold(conn); +% bc = exploreFNIRS.graph.betweenness(G); +% [~, hub] = max(bc.BC); +% fprintf('Most central node: %d (BC = %.3f)\n', hub, bc.BC(hub)); +% +% See also: exploreFNIRS.graph.threshold, exploreFNIRS.graph.degree + + validateGraph(G); + + N = G.N; + W = G.W; + + if N <= 2 + result.BC = zeros(1, N); + result.BCraw = zeros(1, N); + return; + end + + % Convert weights to distances (stronger = shorter) + D = zeros(N); + D(W > 0) = 1 ./ W(W > 0); + + % Build MATLAB graph and compute betweenness + if G.directed + gObj = digraph(D); + BCraw = centrality(gObj, 'betweenness'); + normFactor = (N - 1) * (N - 2); + else + D_sym = max(D, D'); + gObj = graph(D_sym); + BCraw = centrality(gObj, 'betweenness'); + normFactor = (N - 1) * (N - 2) / 2; + end + + BCraw = BCraw'; % column → row + + if normFactor > 0 + BC = BCraw / normFactor; + else + BC = BCraw; + end + + result.BC = BC; + result.BCraw = BCraw; +end + + +function validateGraph(G) + if ~isstruct(G) || ~isfield(G, 'W') || ~isfield(G, 'A') || ~isfield(G, 'N') + error('exploreFNIRS:graph:betweenness', ... + 'Input must be a graph struct from threshold()'); + end +end diff --git a/+exploreFNIRS/+graph/charPathLength.m b/+exploreFNIRS/+graph/charPathLength.m new file mode 100644 index 00000000..536cdb15 --- /dev/null +++ b/+exploreFNIRS/+graph/charPathLength.m @@ -0,0 +1,128 @@ +function result = charPathLength(G) +% CHARPATHLENGTH Characteristic path length and distance matrix +% +% Computes shortest path distances between all node pairs using MATLAB's +% built-in graph/digraph objects. Distance is defined as 1/weight, so +% stronger connections correspond to shorter paths. Returns characteristic +% path length (lambda), eccentricity, radius, and diameter. +% +% Disconnected components are handled gracefully: Inf distances between +% unreachable pairs are excluded from the mean path length calculation. +% +% Syntax: +% result = exploreFNIRS.graph.charPathLength(G) +% +% Inputs: +% G - Graph struct from exploreFNIRS.graph.threshold +% +% Outputs: +% result - Struct with fields: +% .lambda Characteristic path length (mean finite distance) +% .distMatrix [N x N] shortest path distance matrix +% .eccentricity [1 x N] max finite distance from each node +% .radius Min eccentricity across nodes +% .diameter Max eccentricity across nodes +% .nComponents Number of connected components +% +% Reference: +% Rubinov, M. & Sporns, O. (2010). Complex network measures of brain +% connectivity: Uses and interpretations. NeuroImage, 52(3), 1059-1069. +% DOI: 10.1016/j.neuroimage.2009.10.003 +% +% Example: +% G = exploreFNIRS.graph.threshold(conn); +% pl = exploreFNIRS.graph.charPathLength(G); +% fprintf('Lambda = %.3f, Components = %d\n', pl.lambda, pl.nComponents); +% +% See also: exploreFNIRS.graph.threshold, exploreFNIRS.graph.efficiency + + validateGraph(G); + + N = G.N; + W = G.W; + + if N <= 1 + result.lambda = 0; + result.distMatrix = zeros(N); + result.eccentricity = zeros(1, N); + result.radius = 0; + result.diameter = 0; + result.nComponents = N; + return; + end + + % Convert weights to distances: stronger connection = shorter path + % Distance = 1/weight for positive weights + D = zeros(N); + D(W > 0) = 1 ./ W(W > 0); + + % Build MATLAB graph object for shortest paths + if G.directed + gObj = digraph(D); + distMatrix = distances(gObj); + else + % Symmetrize (take upper triangle) + D_sym = max(D, D'); + gObj = graph(D_sym); + distMatrix = distances(gObj); + end + + % Characteristic path length: mean of all finite off-diagonal distances + offDiag = ~eye(N, 'logical'); + finiteD = distMatrix(offDiag); + finiteD = finiteD(isfinite(finiteD)); + + if isempty(finiteD) + lambda = Inf; + else + lambda = mean(finiteD); + end + + % Eccentricity: max finite distance from each node + ecc = zeros(1, N); + for i = 1:N + dists = distMatrix(i, :); + dists(i) = []; + finiteDists = dists(isfinite(dists)); + if isempty(finiteDists) + ecc(i) = Inf; + else + ecc(i) = max(finiteDists); + end + end + + % Radius and diameter (from finite eccentricities) + finiteEcc = ecc(isfinite(ecc)); + if isempty(finiteEcc) + radius = Inf; + diameter = Inf; + else + radius = min(finiteEcc); + diameter = max(finiteEcc); + end + + % Number of connected components + if G.directed + % Use undirected version for component count + gUnd = graph(max(G.A, G.A')); + else + gUnd = graph(G.A); + end + bins = conncomp(gUnd); + nComponents = max(bins); + + result.lambda = lambda; + result.distMatrix = distMatrix; + result.eccentricity = ecc; + result.radius = radius; + result.diameter = diameter; + result.nComponents = nComponents; +end + + +function validateGraph(G) + if ~isstruct(G) || ~isfield(G, 'W') || ~isfield(G, 'A') || ~isfield(G, 'N') + error('exploreFNIRS:graph:charPathLength', ... + 'Input must be a graph struct from threshold()'); + end +end diff --git a/+exploreFNIRS/+graph/clusteringCoefficient.m b/+exploreFNIRS/+graph/clusteringCoefficient.m new file mode 100644 index 00000000..02d07392 --- /dev/null +++ b/+exploreFNIRS/+graph/clusteringCoefficient.m @@ -0,0 +1,97 @@ +function result = clusteringCoefficient(G) +% CLUSTERINGCOEFFICIENT Weighted clustering coefficient and transitivity +% +% Computes the weighted clustering coefficient for each node using the +% Onnela et al. (2005) formula. Weights are normalized to [0, 1] before +% computation. Also returns the network-level transitivity (ratio of +% triangles to triples). +% +% For binary graphs (or binarized graph structs), reduces to the standard +% binary clustering coefficient. +% +% Reference: +% Onnela, J.-P., Saramaki, J., Kertesz, J. & Kaski, K. (2005). +% Intensity and coherence of motifs in weighted complex networks. +% Physical Review E, 71(6), 065103. DOI: 10.1103/PhysRevE.71.065103 +% +% Syntax: +% result = exploreFNIRS.graph.clusteringCoefficient(G) +% +% Inputs: +% G - Graph struct from exploreFNIRS.graph.threshold +% +% Outputs: +% result - Struct with fields: +% .C [1 x N] weighted clustering coefficient per node +% .meanC Scalar mean clustering coefficient +% .transitivity Network transitivity (3*triangles / triples) +% +% Example: +% G = exploreFNIRS.graph.threshold(conn); +% cc = exploreFNIRS.graph.clusteringCoefficient(G); +% fprintf('Mean clustering = %.3f, Transitivity = %.3f\n', cc.meanC, cc.transitivity); +% +% See also: exploreFNIRS.graph.threshold, exploreFNIRS.graph.efficiency + + validateGraph(G); + + if isfield(G, 'directed') && G.directed + warning('exploreFNIRS:graph:clusteringCoefficient:directed', ... + 'Clustering coefficient uses the undirected Onnela formula. Input graph is directed; results may be inaccurate.'); + % Symmetrize for undirected formula + G.A = double(G.A | G.A'); + G.W = (G.W + G.W') / 2; + end + + N = G.N; + A = G.A; + W = G.W; + + % Normalize weights to [0, 1] + maxW = max(W(:)); + if maxW > 0 + Wn = W / maxW; + else + Wn = W; + end + + % Onnela clustering coefficient: + % C_i = 1/(k_i*(k_i-1)) * sum_{j,h} (w_ij * w_ih * w_jh)^(1/3) + % + % Matrix form: C_i = (W^(1/3) * W^(1/3) * W^(1/3))_ii / (k_i*(k_i-1)) + % where W^(1/3) is element-wise cube root + + W_third = Wn .^ (1/3); + triCount = diag(W_third * W_third * W_third); % [N x 1] + + k = sum(A, 2); % degree of each node [N x 1] + denom = k .* (k - 1); + + C = zeros(N, 1); + valid = denom > 0; + C(valid) = triCount(valid) ./ denom(valid); + + % Transitivity: 3 * triangles / triples + % triangles = trace(A^3) / 6 (each triangle counted 6 times) + % triples = sum_i k_i*(k_i-1) / 2 + nTriangles = trace(A * A * A) / 6; + nTriples = sum(k .* (k - 1)) / 2; + + if nTriples > 0 + transitivity = 3 * nTriangles / nTriples; + else + transitivity = 0; + end + + result.C = C'; + result.meanC = mean(C); + result.transitivity = transitivity; +end + + +function validateGraph(G) + if ~isstruct(G) || ~isfield(G, 'W') || ~isfield(G, 'A') || ~isfield(G, 'N') + error('exploreFNIRS:graph:clusteringCoefficient', ... + 'Input must be a graph struct from threshold()'); + end +end diff --git a/+exploreFNIRS/+graph/computeMetrics.m b/+exploreFNIRS/+graph/computeMetrics.m new file mode 100644 index 00000000..571f0286 --- /dev/null +++ b/+exploreFNIRS/+graph/computeMetrics.m @@ -0,0 +1,157 @@ +function result = computeMetrics(input, varargin) +% COMPUTEMETRICS Compute graph theory metrics from a connectivity matrix +% +% Convenience dispatcher that thresholds a connectivity matrix and computes +% selected graph theory metrics. Accepts a connectivity result struct, a +% group result, or a raw matrix. +% +% By default computes all metrics except smallWorld (which is slow). +% Use 'Metrics', {'all'} to include smallWorld, or select specific metrics +% with 'Metrics', {'degree', 'clustering', 'modularity'}. +% +% Reference: +% Rubinov, M. & Sporns, O. (2010). Complex network measures of brain +% connectivity: Uses and interpretations. NeuroImage, 52(3), 1059-1069. +% DOI: 10.1016/j.neuroimage.2009.10.003 +% +% Syntax: +% result = exploreFNIRS.graph.computeMetrics(connResult) +% result = exploreFNIRS.graph.computeMetrics(connResult, 'Threshold', 0.3) +% result = exploreFNIRS.graph.computeMetrics(connResult, 'Metrics', {'degree', 'modularity'}) +% +% Inputs: +% input - One of: +% - Connectivity result struct from computeMatrix +% - Group result struct from Experiment.connectivity() (with .Mean) +% - Graph struct from threshold() (if already thresholded) +% - Raw [N x N] numeric matrix +% +% Name-Value Parameters: +% Threshold - Threshold value (default: 0.3). Ignored if input is a graph struct. +% ThresholdMethod - 'absolute' (default), 'proportional', 'significance' +% Binarize - Binarize graph (default: false) +% AbsoluteWeight - Use |w| (default: true) +% Metrics - Cell array of metric names to compute: +% 'degree', 'clustering', 'betweenness', 'efficiency', +% 'pathLength', 'modularity', 'smallWorld', 'hubs' +% Use {'all'} for everything including smallWorld. +% Default: all except 'smallWorld'. +% Gamma - Modularity resolution parameter (default: 1) +% NReplicates - Modularity replicates (default: 100) +% NRandom - Small-world null networks (default: 100) +% +% Outputs: +% result - Struct with fields: +% .graph Graph struct from threshold() +% .degree Struct from degree() +% .clustering Struct from clusteringCoefficient() +% .betweenness Struct from betweenness() +% .efficiency Struct from efficiency() +% .pathLength Struct from charPathLength() +% .modularity Struct from modularity() +% .smallWorld Struct from smallWorld() (only if requested) +% .hubs Struct from detectHubs() +% .channels [1 x N] channel indices +% .labels {N x 1} labels +% +% Example: +% conn = exploreFNIRS.connectivity.computeMatrix(processed, 'Method', 'pearson'); +% metrics = exploreFNIRS.graph.computeMetrics(conn, ... +% 'ThresholdMethod', 'proportional', 'Threshold', 0.15); +% disp(metrics.modularity.Q); +% disp(metrics.efficiency.globalEfficiency); +% +% See also: exploreFNIRS.graph.threshold, exploreFNIRS.graph.plotNetwork, +% exploreFNIRS.graph.metricsToTable + + allMetricNames = {'degree','clustering','betweenness','efficiency', ... + 'pathLength','modularity','smallWorld','hubs'}; + defaultMetrics = setdiff(allMetricNames, {'smallWorld'}); + + p = inputParser; + addRequired(p, 'input'); + addParameter(p, 'Threshold', 0.3, @(x) isnumeric(x) && isscalar(x)); + addParameter(p, 'ThresholdMethod', 'absolute', @ischar); + addParameter(p, 'Binarize', false, @islogical); + addParameter(p, 'AbsoluteWeight', true, @islogical); + addParameter(p, 'Metrics', defaultMetrics, @(x) iscell(x) || ischar(x)); + addParameter(p, 'Gamma', 1, @isnumeric); + addParameter(p, 'NReplicates', 100, @isnumeric); + addParameter(p, 'NRandom', 100, @isnumeric); + parse(p, input, varargin{:}); + opts = p.Results; + + % Resolve metric list + if ischar(opts.Metrics) + opts.Metrics = {opts.Metrics}; + end + if any(strcmpi(opts.Metrics, 'all')) + metrics = allMetricNames; + else + metrics = lower(opts.Metrics); + allLower = lower(allMetricNames); + invalid = setdiff(metrics, allLower); + if ~isempty(invalid) + error('exploreFNIRS:graph:computeMetrics', ... + 'Unknown metric(s): %s. Valid: %s', ... + strjoin(invalid, ', '), strjoin(allMetricNames, ', ')); + end + end + doMetric = @(name) any(strcmpi(metrics, name)); + + % Threshold if needed (skip if already a graph struct) + if isstruct(input) && isfield(input, 'W') && isfield(input, 'A') && isfield(input, 'N') + G = input; + else + G = exploreFNIRS.graph.threshold(input, ... + 'Method', opts.ThresholdMethod, ... + 'Value', opts.Threshold, ... + 'Binarize', opts.Binarize, ... + 'AbsoluteWeight', opts.AbsoluteWeight); + end + + result.graph = G; + result.channels = G.channels; + result.labels = G.labels; + + % Compute requested metrics + if doMetric('degree') + result.degree = exploreFNIRS.graph.degree(G); + end + + if doMetric('clustering') + result.clustering = exploreFNIRS.graph.clusteringCoefficient(G); + end + + if doMetric('betweenness') + result.betweenness = exploreFNIRS.graph.betweenness(G); + end + + if doMetric('efficiency') + result.efficiency = exploreFNIRS.graph.efficiency(G); + end + + if doMetric('pathlength') + result.pathLength = exploreFNIRS.graph.charPathLength(G); + end + + if doMetric('modularity') + result.modularity = exploreFNIRS.graph.modularity(G, ... + 'Gamma', opts.Gamma, 'NReplicates', opts.NReplicates); + end + + if doMetric('smallworld') + result.smallWorld = exploreFNIRS.graph.smallWorld(G, ... + 'NRandom', opts.NRandom); + end + + if doMetric('hubs') + % Ensure prerequisite metrics are available + if ~isfield(result, 'modularity') + modArg = []; + else + modArg = result.modularity; + end + result.hubs = exploreFNIRS.graph.detectHubs(G, 'Modularity', modArg); + end +end diff --git a/+exploreFNIRS/+graph/degree.m b/+exploreFNIRS/+graph/degree.m new file mode 100644 index 00000000..97b31856 --- /dev/null +++ b/+exploreFNIRS/+graph/degree.m @@ -0,0 +1,63 @@ +function result = degree(G) +% DEGREE Compute node degree and strength from a graph struct +% +% Calculates binary degree (number of connections) and weighted strength +% (sum of connection weights) for each node. For directed graphs, computes +% separate in-degree/in-strength and out-degree/out-strength. +% +% Syntax: +% result = exploreFNIRS.graph.degree(G) +% +% Inputs: +% G - Graph struct from exploreFNIRS.graph.threshold +% +% Outputs: +% result - Struct with fields: +% .degree [1 x N] binary degree (total for directed) +% .strength [1 x N] weighted strength (total for directed) +% For directed graphs, additionally: +% .inDegree [1 x N] in-degree (column sum of A) +% .outDegree [1 x N] out-degree (row sum of A) +% .inStrength [1 x N] in-strength (column sum of W) +% .outStrength[1 x N] out-strength (row sum of W) +% +% Reference: +% Rubinov, M. & Sporns, O. (2010). Complex network measures of brain +% connectivity: Uses and interpretations. NeuroImage, 52(3), 1059-1069. +% DOI: 10.1016/j.neuroimage.2009.10.003 +% +% Example: +% G = exploreFNIRS.graph.threshold(conn); +% d = exploreFNIRS.graph.degree(G); +% disp(d.degree); +% +% See also: exploreFNIRS.graph.threshold, exploreFNIRS.graph.betweenness + + validateGraph(G); + + if G.directed + % In-degree: connections coming in (column sums) + result.inDegree = sum(G.A, 1); + % Out-degree: connections going out (row sums) + result.outDegree = sum(G.A, 2)'; + % Total degree + result.degree = result.inDegree + result.outDegree; + + % Weighted strength + result.inStrength = sum(G.W, 1); + result.outStrength = sum(G.W, 2)'; + result.strength = result.inStrength + result.outStrength; + else + % Undirected: row sum = column sum + result.degree = sum(G.A, 2)'; + result.strength = sum(G.W, 2)'; + end +end + + +function validateGraph(G) + if ~isstruct(G) || ~isfield(G, 'W') || ~isfield(G, 'A') || ~isfield(G, 'N') + error('exploreFNIRS:graph:degree', ... + 'Input must be a graph struct from threshold()'); + end +end diff --git a/+exploreFNIRS/+graph/detectHubs.m b/+exploreFNIRS/+graph/detectHubs.m new file mode 100644 index 00000000..333162bb --- /dev/null +++ b/+exploreFNIRS/+graph/detectHubs.m @@ -0,0 +1,135 @@ +function result = detectHubs(G, varargin) +% DETECTHUBS Identify hub nodes via composite z-score +% +% Computes a composite hub score from normalized degree, betweenness, +% and clustering (inverted). Nodes with z-score above a threshold are +% classified as hubs. When modularity results are provided, hubs are +% further classified as provincial (high within-module degree) or +% connector (high participation coefficient). +% +% Hub score = z(degree) + z(betweenness) - z(clustering) +% +% Reference: +% Rubinov, M. & Sporns, O. (2010). Complex network measures of brain +% connectivity: Uses and interpretations. NeuroImage, 52(3), 1059-1069. +% DOI: 10.1016/j.neuroimage.2009.10.003 +% +% Syntax: +% result = exploreFNIRS.graph.detectHubs(G) +% result = exploreFNIRS.graph.detectHubs(G, 'Threshold', 1.5) +% result = exploreFNIRS.graph.detectHubs(G, 'Modularity', modResult) +% +% Inputs: +% G - Graph struct from exploreFNIRS.graph.threshold +% +% Name-Value Parameters: +% Threshold - Z-score threshold for hub classification (default: 1) +% Modularity - Modularity result struct from exploreFNIRS.graph.modularity +% When provided, classifies hubs as 'provincial' or 'connector' +% +% Outputs: +% result - Struct with fields: +% .hubScore [1 x N] composite hub z-score +% .isHub [1 x N] logical hub classification +% .hubType {1 x N} cell: 'provincial', 'connector', or '' (non-hub) +% .degree [1 x N] degree values used +% .betweenness [1 x N] betweenness values used +% .clustering [1 x N] clustering values used +% +% Example: +% G = exploreFNIRS.graph.threshold(conn); +% hubs = exploreFNIRS.graph.detectHubs(G); +% fprintf('Hubs: %s\n', strjoin(G.labels(hubs.isHub), ', ')); +% +% See also: exploreFNIRS.graph.degree, exploreFNIRS.graph.betweenness, +% exploreFNIRS.graph.modularity + + p = inputParser; + addRequired(p, 'G', @isstruct); + addParameter(p, 'Threshold', 1, @(x) isnumeric(x) && isscalar(x)); + addParameter(p, 'Modularity', [], @(x) isempty(x) || isstruct(x)); + parse(p, G, varargin{:}); + thresh = p.Results.Threshold; + modResult = p.Results.Modularity; + + validateGraph(G); + + N = G.N; + + if N <= 2 + result.hubScore = zeros(1, N); + result.isHub = false(1, N); + result.hubType = repmat({''}, 1, N); + result.degree = zeros(1, N); + result.betweenness = zeros(1, N); + result.clustering = zeros(1, N); + return; + end + + % Compute component metrics + deg = exploreFNIRS.graph.degree(G); + bc = exploreFNIRS.graph.betweenness(G); + cc = exploreFNIRS.graph.clusteringCoefficient(G); + + degVals = deg.strength; + bcVals = bc.BC; + ccVals = cc.C; + + % Z-score each metric + zDeg = zscore_safe(degVals); + zBC = zscore_safe(bcVals); + zCC = zscore_safe(ccVals); + + % Composite score: high degree + high betweenness - high clustering + hubScore = zDeg + zBC - zCC; + + % Classify hubs + isHub = hubScore > thresh; + + % Hub type classification (requires modularity) + hubType = repmat({''}, 1, N); + if ~isempty(modResult) && isfield(modResult, 'participationCoeff') + pc = modResult.participationCoeff; + for i = 1:N + if isHub(i) + if pc(i) > 0.3 + hubType{i} = 'connector'; + else + hubType{i} = 'provincial'; + end + end + end + else + for i = 1:N + if isHub(i) + hubType{i} = 'hub'; + end + end + end + + result.hubScore = hubScore; + result.isHub = isHub; + result.hubType = hubType; + result.degree = degVals; + result.betweenness = bcVals; + result.clustering = ccVals; +end + + +function z = zscore_safe(x) +% Z-score that handles constant vectors (returns zeros) + s = std(x); + if s < eps + z = zeros(size(x)); + else + z = (x - mean(x)) / s; + end +end + + +function validateGraph(G) + if ~isstruct(G) || ~isfield(G, 'W') || ~isfield(G, 'A') || ~isfield(G, 'N') + error('exploreFNIRS:graph:detectHubs', ... + 'Input must be a graph struct from threshold()'); + end +end diff --git a/+exploreFNIRS/+graph/efficiency.m b/+exploreFNIRS/+graph/efficiency.m new file mode 100644 index 00000000..bfb5c886 --- /dev/null +++ b/+exploreFNIRS/+graph/efficiency.m @@ -0,0 +1,116 @@ +function result = efficiency(G) +% EFFICIENCY Global and local network efficiency +% +% Global efficiency is the average inverse shortest path distance across +% all node pairs, providing a measure of how efficiently information can +% be exchanged across the whole network. Disconnected pairs contribute +% zero (since 1/Inf = 0), making this metric robust to fragmented graphs. +% +% Local efficiency of a node is the global efficiency of its neighborhood +% subgraph, measuring fault tolerance and local integration. +% +% Reference: +% Latora, V. & Marchiori, M. (2001). Efficient behavior of small-world +% networks. Physical Review Letters, 87(19), 198701. +% DOI: 10.1103/PhysRevLett.87.198701 +% +% Syntax: +% result = exploreFNIRS.graph.efficiency(G) +% +% Inputs: +% G - Graph struct from exploreFNIRS.graph.threshold +% +% Outputs: +% result - Struct with fields: +% .globalEfficiency Scalar global efficiency +% .localEfficiency [1 x N] local efficiency per node +% .meanLocalEff Mean local efficiency +% +% Example: +% G = exploreFNIRS.graph.threshold(conn); +% eff = exploreFNIRS.graph.efficiency(G); +% fprintf('Global = %.3f, Mean local = %.3f\n', eff.globalEfficiency, eff.meanLocalEff); +% +% See also: exploreFNIRS.graph.threshold, exploreFNIRS.graph.charPathLength + + validateGraph(G); + + N = G.N; + W = G.W; + + if N <= 1 + result.globalEfficiency = 0; + result.localEfficiency = zeros(1, N); + result.meanLocalEff = 0; + return; + end + + % Compute distance matrix (1/weight) + D = zeros(N); + D(W > 0) = 1 ./ W(W > 0); + + % Shortest path distances via MATLAB graph + if G.directed + gObj = digraph(D); + else + D = max(D, D'); + gObj = graph(D); + end + distMatrix = distances(gObj); + + % Global efficiency: mean of 1/d_ij for all i ≠ j + offDiag = ~eye(N, 'logical'); + invD = zeros(N); + finiteMask = isfinite(distMatrix) & offDiag; + invD(finiteMask) = 1 ./ distMatrix(finiteMask); + + globalEff = sum(invD(:)) / (N * (N - 1)); + + % Local efficiency: efficiency of each node's neighborhood subgraph + localEff = zeros(1, N); + for i = 1:N + % Find neighbors of node i + neighbors = find(G.A(i, :) | G.A(:, i)'); + nNeighbors = length(neighbors); + + if nNeighbors < 2 + localEff(i) = 0; + continue; + end + + % Extract subgraph of neighbors + Wsub = W(neighbors, neighbors); + + % Compute distances in subgraph + Dsub = zeros(nNeighbors); + Dsub(Wsub > 0) = 1 ./ Wsub(Wsub > 0); + + if G.directed + gSub = digraph(Dsub); + else + Dsub = max(Dsub, Dsub'); + gSub = graph(Dsub); + end + distSub = distances(gSub); + + % Efficiency of subgraph + offDiagSub = ~eye(nNeighbors, 'logical'); + invDsub = zeros(nNeighbors); + finiteSub = isfinite(distSub) & offDiagSub; + invDsub(finiteSub) = 1 ./ distSub(finiteSub); + + localEff(i) = sum(invDsub(:)) / (nNeighbors * (nNeighbors - 1)); + end + + result.globalEfficiency = globalEff; + result.localEfficiency = localEff; + result.meanLocalEff = mean(localEff); +end + + +function validateGraph(G) + if ~isstruct(G) || ~isfield(G, 'W') || ~isfield(G, 'A') || ~isfield(G, 'N') + error('exploreFNIRS:graph:efficiency', ... + 'Input must be a graph struct from threshold()'); + end +end diff --git a/+exploreFNIRS/+graph/metricsToTable.m b/+exploreFNIRS/+graph/metricsToTable.m new file mode 100644 index 00000000..e266a2df --- /dev/null +++ b/+exploreFNIRS/+graph/metricsToTable.m @@ -0,0 +1,118 @@ +function T = metricsToTable(results, varargin) +% METRICSTOTABLE Export graph metrics to a long-format MATLAB table +% +% Converts computeMetrics output to a MATLAB table with one row per node. +% For struct arrays (multiple groups), adds a Group column. Useful for +% downstream statistical analysis or CSV export. +% +% Syntax: +% T = exploreFNIRS.graph.metricsToTable(result) +% T = exploreFNIRS.graph.metricsToTable(results, 'GroupLabels', {'Rest','Task'}) +% T = exploreFNIRS.graph.metricsToTable(result, 'SavePath', 'metrics.csv') +% +% Inputs: +% results - Single computeMetrics result struct, or struct array +% +% Name-Value Parameters: +% GroupLabels - Cell array of group names (default: 'Group 1', ...) +% SavePath - File path to save as CSV (default: '') +% +% Outputs: +% T - MATLAB table with columns: +% Channel, Label, Degree, Strength, ClusteringCoeff, Betweenness, +% LocalEfficiency, CommunityID, HubScore, IsHub +% (Group column added for multi-element struct array) +% +% Example: +% metrics = exploreFNIRS.graph.computeMetrics(conn); +% T = exploreFNIRS.graph.metricsToTable(metrics, 'SavePath', 'metrics.csv'); +% disp(T); +% +% See also: exploreFNIRS.graph.computeMetrics, exploreFNIRS.graph.plotMetrics + + p = inputParser; + addRequired(p, 'results'); + addParameter(p, 'GroupLabels', {}, @iscell); + addParameter(p, 'SavePath', '', @ischar); + parse(p, results, varargin{:}); + opts = p.Results; + + if ~isstruct(results) + error('exploreFNIRS:graph:metricsToTable', 'Input must be a struct or struct array'); + end + + nGroups = length(results); + multiGroup = nGroups > 1; + + if isempty(opts.GroupLabels) + groupLabels = arrayfun(@(g) sprintf('Group %d', g), 1:nGroups, ... + 'UniformOutput', false); + else + groupLabels = opts.GroupLabels; + end + + tables = cell(1, nGroups); + for g = 1:nGroups + r = results(g); + N = length(r.channels); + + channels = r.channels(:); + labels = r.labels(:); + + % Initialize with NaN/defaults + degreeVals = nan(N, 1); + strengthVals = nan(N, 1); + ccVals = nan(N, 1); + bcVals = nan(N, 1); + localEffVals = nan(N, 1); + commVals = nan(N, 1); + hubScoreVals = nan(N, 1); + isHubVals = false(N, 1); + + if isfield(r, 'degree') + degreeVals = r.degree.degree(:); + strengthVals = r.degree.strength(:); + end + if isfield(r, 'clustering') + ccVals = r.clustering.C(:); + end + if isfield(r, 'betweenness') + bcVals = r.betweenness.BC(:); + end + if isfield(r, 'efficiency') + localEffVals = r.efficiency.localEfficiency(:); + end + if isfield(r, 'modularity') + commVals = r.modularity.communityID(:); + end + if isfield(r, 'hubs') + hubScoreVals = r.hubs.hubScore(:); + isHubVals = r.hubs.isHub(:); + end + + Tg = table(channels, labels, degreeVals, strengthVals, ... + ccVals, bcVals, localEffVals, commVals, hubScoreVals, isHubVals, ... + 'VariableNames', {'Channel', 'Label', 'Degree', 'Strength', ... + 'ClusteringCoeff', 'Betweenness', 'LocalEfficiency', ... + 'CommunityID', 'HubScore', 'IsHub'}); + + if multiGroup + Tg.Group = repmat(groupLabels(g), N, 1); + end + + tables{g} = Tg; + end + + T = vertcat(tables{:}); + + % Move Group to first column if present + if multiGroup + T = T(:, ['Group', setdiff(T.Properties.VariableNames, 'Group', 'stable')]); + end + + % Save if requested + if ~isempty(opts.SavePath) + writetable(T, opts.SavePath); + fprintf('Saved metrics table to %s\n', opts.SavePath); + end +end diff --git a/+exploreFNIRS/+graph/modularity.m b/+exploreFNIRS/+graph/modularity.m new file mode 100644 index 00000000..227fec42 --- /dev/null +++ b/+exploreFNIRS/+graph/modularity.m @@ -0,0 +1,249 @@ +function result = modularity(G, varargin) +% MODULARITY Community detection via the Louvain algorithm +% +% Detects network communities by optimizing the modularity quality function +% Q using the Louvain algorithm (Blondel et al., 2008). Runs multiple +% random restarts and returns the partition with highest Q. Also computes +% the participation coefficient for each node, measuring the diversity of +% inter-community connections. +% +% Reference: +% Blondel, V. D., Guillaume, J.-L., Lambiotte, R. & Lefebvre, E. (2008). +% Fast unfolding of communities in large networks. +% Journal of Statistical Mechanics, P10008. +% DOI: 10.1088/1742-5468/2008/10/P10008 +% +% Syntax: +% result = exploreFNIRS.graph.modularity(G) +% result = exploreFNIRS.graph.modularity(G, 'Gamma', 1.5, 'NReplicates', 200) +% +% Inputs: +% G - Graph struct from exploreFNIRS.graph.threshold +% +% Name-Value Parameters: +% Gamma - Resolution parameter (default: 1). Higher = more/smaller communities +% NReplicates - Number of random restarts (default: 100) +% +% Outputs: +% result - Struct with fields: +% .communityID [1 x N] community assignment (1-indexed) +% .Q Modularity value Q +% .nCommunities Number of communities detected +% .participationCoeff [1 x N] participation coefficient per node +% +% Example: +% G = exploreFNIRS.graph.threshold(conn, 'Method', 'proportional', 'Value', 0.2); +% mod = exploreFNIRS.graph.modularity(G, 'Gamma', 1); +% fprintf('Q = %.3f, %d communities\n', mod.Q, mod.nCommunities); +% +% See also: exploreFNIRS.graph.threshold, exploreFNIRS.graph.smallWorld, +% exploreFNIRS.graph.detectHubs + + p = inputParser; + addRequired(p, 'G', @isstruct); + addParameter(p, 'Gamma', 1, @(x) isnumeric(x) && isscalar(x) && x > 0); + addParameter(p, 'NReplicates', 100, @(x) isnumeric(x) && isscalar(x) && x >= 1); + parse(p, G, varargin{:}); + gamma = p.Results.Gamma; + nReps = round(p.Results.NReplicates); + + validateGraph(G); + + N = G.N; + W = G.W; + + if N <= 1 + result.communityID = ones(1, N); + result.Q = 0; + result.nCommunities = 1; + result.participationCoeff = zeros(1, N); + return; + end + + % Run Louvain multiple times, keep best Q + bestQ = -Inf; + bestCI = []; + + for rep = 1:nReps + [ci, Q] = louvain(W, gamma); + if Q > bestQ + bestQ = Q; + bestCI = ci; + end + end + + % Relabel communities to be contiguous 1:K + [~, ~, bestCI] = unique(bestCI); + bestCI = bestCI'; + + nComm = max(bestCI); + + % Participation coefficient + pc = participationCoefficient(G.A, bestCI); + + result.communityID = bestCI; + result.Q = bestQ; + result.nCommunities = nComm; + result.participationCoeff = pc; +end + + +function [ciOrig, Q] = louvain(W, gamma) +% LOUVAIN Single run of the Louvain community detection algorithm +% +% Phase 1: Greedily move nodes to neighboring communities to maximize dQ. +% Phase 2: Aggregate communities into super-nodes and repeat. + + Norig = size(W, 1); + N = Norig; + m = sum(W(:)) / 2; + + if m == 0 + ciOrig = (1:Norig)'; + Q = 0; + return; + end + + k = sum(W, 2); + ci = (1:N)'; + + % Track mapping from original nodes to current super-nodes + origMap = (1:Norig)'; + + nodeOrder = randperm(N); + + improved = true; + while improved + improved = false; + + % Phase 1: local moves + localImproved = true; + while localImproved + localImproved = false; + for idx = 1:N + i = nodeOrder(idx); + ci_i = ci(i); + + neighbors = find(W(i, :) > 0); + neighborComms = unique(ci(neighbors)); + + if ~ismember(ci_i, neighborComms) + neighborComms = [ci_i; neighborComms(:)]; %#ok + end + + bestDQ = 0; + bestComm = ci_i; + + for c = neighborComms' + if c == ci_i, continue; end + dQ = moveDeltaQ(W, k, m, gamma, i, ci, ci_i, c); + if dQ > bestDQ + bestDQ = dQ; + bestComm = c; + end + end + + if bestComm ~= ci_i + ci(i) = bestComm; + localImproved = true; + improved = true; + end + end + end + + % Phase 2: aggregate communities into super-nodes + [~, ~, ciNew] = unique(ci); + nComm = max(ciNew); + + if nComm == N + break; + end + + % Update original-to-supernode mapping + origMap = ciNew(origMap); + + % Build super-node weight matrix + Wnew = zeros(nComm); + for a = 1:nComm + for b = a:nComm + w = sum(sum(W(ciNew == a, ciNew == b))); + Wnew(a, b) = w; + Wnew(b, a) = w; + end + end + + N = nComm; + W = Wnew; + k = sum(W, 2); + m = sum(W(:)) / 2; + nodeOrder = randperm(N); + ci = (1:N)'; + end + + % Map final super-node communities back to original nodes + ciOrig = ci(origMap); + + Q = computeModularity(W, k, m, gamma, ci); +end + + +function dQ = moveDeltaQ(W, k, m, gamma, i, ci, fromComm, toComm) +% Compute the change in modularity from moving node i from fromComm to toComm + + ki = k(i); + + inTo = ci == toComm; + ki_to = sum(W(i, inTo)); + Sigma_to = sum(k(inTo)); + + inFrom = ci == fromComm; + inFrom(i) = false; + ki_from = sum(W(i, inFrom)); + Sigma_from = sum(k(inFrom)) + ki; + + dQ = (ki_to - ki_from) / m + ... + gamma * ki * (Sigma_from - ki - Sigma_to) / (2 * m^2); +end + + +function Q = computeModularity(W, k, m, gamma, ci) +% Compute Newman-Girvan modularity Q + N = length(ci); + Q = 0; + for i = 1:N + for j = 1:N + if ci(i) == ci(j) + Q = Q + W(i, j) - gamma * k(i) * k(j) / (2 * m); + end + end + end + Q = Q / (2 * m); +end + + +function pc = participationCoefficient(A, ci) +% Participation coefficient: diversity of inter-community connections +% PC_i = 1 - sum_s (k_is / k_i)^2 +% where k_is is the number of connections from i to community s + + N = size(A, 1); + k = sum(A, 2)'; + comms = unique(ci); + pc = ones(1, N); + + for s = comms(:)' + inComm = ci == s; + ks = sum(A(:, inComm), 2)'; + pc = pc - (ks ./ max(k, eps)) .^ 2; + end + + pc(k == 0) = 0; +end + + +function validateGraph(G) + if ~isstruct(G) || ~isfield(G, 'W') || ~isfield(G, 'A') || ~isfield(G, 'N') + error('exploreFNIRS:graph:modularity', ... + 'Input must be a graph struct from threshold()'); + end +end diff --git a/+exploreFNIRS/+graph/plotMetrics.m b/+exploreFNIRS/+graph/plotMetrics.m new file mode 100644 index 00000000..fe94736b --- /dev/null +++ b/+exploreFNIRS/+graph/plotMetrics.m @@ -0,0 +1,193 @@ +function fig = plotMetrics(results, varargin) +% PLOTMETRICS Grouped bar chart comparing node-level graph metrics +% +% Displays per-node graph metrics as a grouped bar chart. Accepts a single +% computeMetrics result or a struct array (one per group/condition) for +% between-group comparison. Respects MATLAB dark mode and PlotStyle settings. +% +% Syntax: +% fig = exploreFNIRS.graph.plotMetrics(result) +% fig = exploreFNIRS.graph.plotMetrics(results, 'Metric', 'betweenness') +% fig = exploreFNIRS.graph.plotMetrics(results, 'GroupLabels', {'Rest','Task'}) +% +% Inputs: +% results - Single computeMetrics result struct, or struct array for +% multi-group comparison +% +% Name-Value Parameters: +% Metric - Which metric to plot: 'degree' (default), 'strength', +% 'clustering', 'betweenness', 'localEfficiency', 'hubScore' +% GroupLabels - Cell array of group names (default: 'Group 1', 'Group 2', ...) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 800) +% SaveHeight - Height in pixels (default: 400) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% Example: +% metrics = exploreFNIRS.graph.computeMetrics(conn); +% fig = exploreFNIRS.graph.plotMetrics(metrics, 'Metric', 'betweenness'); +% +% See also: exploreFNIRS.graph.computeMetrics, exploreFNIRS.graph.plotNetwork + + validMetrics = {'degree','strength','clustering','betweenness', ... + 'localEfficiency','hubScore'}; + + p = inputParser; + addRequired(p, 'results'); + addParameter(p, 'Metric', 'degree', ... + @(x) ischar(x) && ismember(lower(x), validMetrics)); + addParameter(p, 'GroupLabels', {}, @iscell); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 800, @isnumeric); + addParameter(p, 'SaveHeight', 400, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, results, varargin{:}); + opts = p.Results; + metricName = lower(opts.Metric); + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + % Ensure struct array + if ~isstruct(results) + error('exploreFNIRS:graph:plotMetrics', 'Input must be a struct or struct array'); + end + nGroups = length(results); + + % Extract metric values for each group + vals = cell(1, nGroups); + for g = 1:nGroups + vals{g} = extractMetricValues(results(g), metricName); + end + + % Labels from first result + if isfield(results(1), 'labels') && ~isempty(results(1).labels) + nodeLabels = pf2_base.plot.escapeTeX(results(1).labels); + else + N = length(vals{1}); + nodeLabels = arrayfun(@(c) sprintf('Ch%d', c), 1:N, 'UniformOutput', false); + end + + % Group labels + if isempty(opts.GroupLabels) + groupLabels = arrayfun(@(g) sprintf('Group %d', g), 1:nGroups, ... + 'UniformOutput', false); + else + groupLabels = opts.GroupLabels; + end + + % Build data matrix [nNodes x nGroups] + nNodes = length(vals{1}); + dataMat = zeros(nNodes, nGroups); + for g = 1:nGroups + v = vals{g}; + dataMat(1:length(v), g) = v(:); + end + + % Create figure + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + ax = axes('Parent', fig); + + if nGroups == 1 + % Visible accent color for single-group bars + defaultAccent = [0.0, 0.447, 0.741]; % MATLAB default blue + bar(ax, dataMat, 'FaceColor', defaultAccent); + else + bar(ax, dataMat); + end + + set(ax, 'XTick', 1:nNodes, 'XTickLabel', nodeLabels, ... + 'FontSize', sty.FontSize - 1); + if nNodes > 10 + set(ax, 'XTickLabelRotation', 45); + end + + ylabel(ax, formatMetricName(metricName), 'FontSize', sty.FontSize); + + if nGroups > 1 + lg = legend(ax, pf2_base.plot.escapeTeX(groupLabels), 'Location', 'best', 'FontSize', sty.FontSize - 1); + set(lg, 'TextColor', sty.LegendTextColor, ... + 'Color', sty.LegendBgColor, 'EdgeColor', sty.LegendEdgeColor); + end + + if ~isempty(opts.Title) + title(ax, pf2_base.plot.escapeTeX(opts.Title), 'FontSize', sty.FontSize + 1); + else + title(ax, formatMetricName(metricName), 'FontSize', sty.FontSize + 1); + end + + % Apply theme colors to axes (foreground, background, grid, etc.) + sty.applyToAxes(ax); + + pf2_base.plot.handleSave(fig, opts); +end + + +function vals = extractMetricValues(result, metricName) +% Extract the appropriate vector from a computeMetrics result struct + + switch metricName + case 'degree' + if isfield(result, 'degree') + vals = result.degree.degree; + else + error('exploreFNIRS:graph:plotMetrics', 'degree not computed'); + end + case 'strength' + if isfield(result, 'degree') + vals = result.degree.strength; + else + error('exploreFNIRS:graph:plotMetrics', 'degree not computed'); + end + case 'clustering' + if isfield(result, 'clustering') + vals = result.clustering.C; + else + error('exploreFNIRS:graph:plotMetrics', 'clustering not computed'); + end + case 'betweenness' + if isfield(result, 'betweenness') + vals = result.betweenness.BC; + else + error('exploreFNIRS:graph:plotMetrics', 'betweenness not computed'); + end + case 'localefficiency' + if isfield(result, 'efficiency') + vals = result.efficiency.localEfficiency; + else + error('exploreFNIRS:graph:plotMetrics', 'efficiency not computed'); + end + case 'hubscore' + if isfield(result, 'hubs') + vals = result.hubs.hubScore; + else + error('exploreFNIRS:graph:plotMetrics', 'hubs not computed'); + end + end +end + + +function name = formatMetricName(metricName) +% Format metric name for display + switch metricName + case 'degree', name = 'Degree'; + case 'strength', name = 'Strength'; + case 'clustering', name = 'Clustering Coefficient'; + case 'betweenness', name = 'Betweenness Centrality'; + case 'localefficiency', name = 'Local Efficiency'; + case 'hubscore', name = 'Hub Score'; + otherwise, name = metricName; + end +end diff --git a/+exploreFNIRS/+graph/plotNetwork.m b/+exploreFNIRS/+graph/plotNetwork.m new file mode 100644 index 00000000..fa1e4389 --- /dev/null +++ b/+exploreFNIRS/+graph/plotNetwork.m @@ -0,0 +1,281 @@ +function fig = plotNetwork(G, varargin) +% PLOTNETWORK Node-link diagram with metric-based sizing and community coloring +% +% Renders a graph as a node-link diagram. Node size scales with a chosen +% metric (default: degree). When community assignments are provided, nodes +% are colored by community. Supports force-directed, circle, and probe-based +% 2D layouts. Respects MATLAB dark mode and PlotStyle settings. +% +% Syntax: +% fig = exploreFNIRS.graph.plotNetwork(G) +% fig = exploreFNIRS.graph.plotNetwork(G, 'NodeMetric', degreeVals) +% fig = exploreFNIRS.graph.plotNetwork(G, 'Layout', 'circle', 'CommunityID', ci) +% fig = exploreFNIRS.graph.plotNetwork(G, 'Layout', 'probe', 'Device', dev) +% +% Inputs: +% G - Graph struct from exploreFNIRS.graph.threshold +% +% Name-Value Parameters: +% Layout - 'force' (default), 'circle', 'probe' +% NodeMetric - [1 x N] values for node sizing (default: degree) +% CommunityID - [1 x N] community labels for coloring +% Device - pf2.Device object for 'probe' layout +% EdgeAlpha - Edge transparency (default: 0.4) +% MinNodeSize - Minimum node marker size (default: 30) +% MaxNodeSize - Maximum node marker size (default: 300) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 600) +% SaveHeight - Height in pixels (default: 600) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% Example: +% metrics = exploreFNIRS.graph.computeMetrics(conn); +% G = metrics.graph; +% fig = exploreFNIRS.graph.plotNetwork(G, ... +% 'NodeMetric', metrics.degree.strength, ... +% 'CommunityID', metrics.modularity.communityID); +% +% See also: exploreFNIRS.graph.threshold, exploreFNIRS.graph.plotMetrics, +% exploreFNIRS.graph.computeMetrics + + p = inputParser; + addRequired(p, 'G', @isstruct); + addParameter(p, 'Layout', 'force', ... + @(x) ischar(x) && ismember(lower(x), {'force','circle','probe'})); + addParameter(p, 'NodeMetric', [], @(x) isnumeric(x)); + addParameter(p, 'CommunityID', [], @(x) isnumeric(x)); + addParameter(p, 'Device', [], @(x) isempty(x) || isobject(x)); + addParameter(p, 'EdgeAlpha', 0.4, @isnumeric); + addParameter(p, 'MinNodeSize', 30, @isnumeric); + addParameter(p, 'MaxNodeSize', 300, @isnumeric); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 600, @isnumeric); + addParameter(p, 'SaveHeight', 600, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, G, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + N = G.N; + W = G.W; + sty = pf2_base.plot.PlotStyle.getDefault(); + + % Default node metric: degree + if isempty(opts.NodeMetric) + nodeMetric = sum(G.A, 2)'; + else + nodeMetric = opts.NodeMetric; + end + + % Scale node sizes + nodeSizes = scaleMetric(nodeMetric, opts.MinNodeSize, opts.MaxNodeSize); + + % Node colors: by community or default accent + if ~isempty(opts.CommunityID) + ci = opts.CommunityID; + nComm = max(ci); + cmap = lines(nComm); + nodeColors = cmap(ci, :); + else + % Use a visible accent color (not foreground, which is black/white) + defaultAccent = [0.0, 0.447, 0.741]; % MATLAB default blue + nodeColors = repmat(defaultAccent, N, 1); + end + + % Compute layout positions + [xPos, yPos] = computeLayout(G, lower(opts.Layout), opts.Device); + + % Create figure + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + ax = axes('Parent', fig); + hold(ax, 'on'); + axis(ax, 'equal'); + axis(ax, 'off'); + + % Edge color from PlotStyle (dim/muted) + edgeRGB = sty.DimColor; + + % Draw edges + maxW = max(abs(W(:))); + if maxW == 0, maxW = 1; end + + if G.directed + edgePairs = find(W); + else + edgePairs = find(triu(W)); + end + + for idx = 1:length(edgePairs) + [i, j] = ind2sub([N, N], edgePairs(idx)); + w = abs(W(i, j)); + lineW = 0.5 + 2.5 * (w / maxW); + plot(ax, [xPos(i), xPos(j)], [yPos(i), yPos(j)], '-', ... + 'Color', [edgeRGB, opts.EdgeAlpha], ... + 'LineWidth', lineW); + end + + % Draw nodes with theme-aware edge color + for i = 1:N + scatter(ax, xPos(i), yPos(i), nodeSizes(i), nodeColors(i, :), ... + 'filled', 'MarkerEdgeColor', sty.ForegroundColor, 'LineWidth', 0.5); + end + + % Labels with theme-aware text color + labels = G.labels; + if ~isempty(labels) + labels = pf2_base.plot.escapeTeX(labels); + yRange = range(yPos); + if yRange == 0, yRange = 1; end + for i = 1:N + text(ax, xPos(i), yPos(i) - 0.06 * yRange - 0.02, labels{i}, ... + 'HorizontalAlignment', 'center', 'FontSize', sty.FontSize - 2, ... + 'VerticalAlignment', 'top', 'Color', sty.ForegroundColor); + end + end + + % Title with theme-aware color + if ~isempty(opts.Title) + title(ax, opts.Title, 'FontSize', sty.FontSize + 1, ... + 'Color', sty.ForegroundColor); + else + title(ax, 'Network Graph', 'FontSize', sty.FontSize + 1, ... + 'Color', sty.ForegroundColor); + end + + hold(ax, 'off'); + + % Apply theme to figure (handles axis off case) + sty.applyToFigure(fig); + + pf2_base.plot.handleSave(fig, opts); +end + + +function [x, y] = computeLayout(G, layout, dev) +% Compute 2D node positions based on layout type + + N = G.N; + + switch layout + case 'circle' + angles = linspace(0, 2*pi, N + 1); + angles = angles(1:N); + x = cos(angles); + y = sin(angles); + + case 'probe' + if ~isempty(dev) && isobject(dev) + try + lay = dev.layout2D(); + chIdx = G.channels; + if max(chIdx) <= size(lay, 1) + x = lay(chIdx, 1)'; + y = lay(chIdx, 2)'; + return; + end + catch + % Fall through to force layout + end + end + warning('exploreFNIRS:graph:plotNetwork', ... + 'Probe layout unavailable, falling back to force layout'); + [x, y] = forceLayout(G); + + case 'force' + [x, y] = forceLayout(G); + end +end + + +function [x, y] = forceLayout(G) +% Simple force-directed layout (Fruchterman-Reingold style) + + N = G.N; + if N <= 1 + x = 0; y = 0; + return; + end + + % Initialize random positions + rng_state = rng; + rng(42); % reproducible layout + pos = rand(N, 2) * 2 - 1; + rng(rng_state); + + area = 4; + k = sqrt(area / N); + temp = 1; + nIter = 50; + + for iter = 1:nIter + % Repulsive forces + dx = zeros(N, 1); + dy = zeros(N, 1); + + for i = 1:N + for j = (i+1):N + delta = pos(i, :) - pos(j, :); + dist = max(norm(delta), 0.01); + force = k^2 / dist; + fVec = (delta / dist) * force; + dx(i) = dx(i) + fVec(1); + dy(i) = dy(i) + fVec(2); + dx(j) = dx(j) - fVec(1); + dy(j) = dy(j) - fVec(2); + end + end + + % Attractive forces (edges) + for i = 1:N + for j = (i+1):N + if G.A(i, j) || G.A(j, i) + delta = pos(i, :) - pos(j, :); + dist = max(norm(delta), 0.01); + force = dist^2 / k; + fVec = (delta / dist) * force; + dx(i) = dx(i) - fVec(1); + dy(i) = dy(i) - fVec(2); + dx(j) = dx(j) + fVec(1); + dy(j) = dy(j) + fVec(2); + end + end + end + + % Apply with temperature cooling + for i = 1:N + disp_len = max(sqrt(dx(i)^2 + dy(i)^2), 0.01); + pos(i, 1) = pos(i, 1) + (dx(i) / disp_len) * min(disp_len, temp); + pos(i, 2) = pos(i, 2) + (dy(i) / disp_len) * min(disp_len, temp); + end + + temp = temp * (1 - iter / nIter); + end + + x = pos(:, 1)'; + y = pos(:, 2)'; +end + + +function sizes = scaleMetric(metric, minSize, maxSize) +% Scale metric values to [minSize, maxSize] range + mn = min(metric); + mx = max(metric); + if mx - mn < eps + sizes = repmat((minSize + maxSize) / 2, size(metric)); + else + sizes = minSize + (maxSize - minSize) * (metric - mn) / (mx - mn); + end +end diff --git a/+exploreFNIRS/+graph/smallWorld.m b/+exploreFNIRS/+graph/smallWorld.m new file mode 100644 index 00000000..1201e5fe --- /dev/null +++ b/+exploreFNIRS/+graph/smallWorld.m @@ -0,0 +1,233 @@ +function result = smallWorld(G, varargin) +% SMALLWORLD Small-world indices via comparison to random null networks +% +% Computes the sigma and omega small-world indices by comparing the +% network's clustering coefficient and path length to ensembles of +% degree-preserving random networks (Maslov & Sneppen rewiring). +% +% Sigma = (C/C_rand) / (L/L_rand) [Humphries & Gurney 2008] +% sigma > 1 indicates small-world organization +% +% Omega = L_rand/L - C/C_lattice [Telesford et al. 2011] +% omega near 0 = small-world, near -1 = lattice-like, near +1 = random +% +% This function is computationally expensive due to null network generation +% and is excluded from computeMetrics by default. Users must opt in. +% +% Reference: +% Humphries, M. D. & Gurney, K. (2008). Network 'small-world-ness': +% a quantitative method for determining canonical network equivalence. +% PLoS One, 3(4), e0002051. DOI: 10.1371/journal.pone.0002051 +% +% Maslov, S. & Sneppen, K. (2002). Specificity and stability in +% topology of protein networks. Science, 296(5569), 910-913. +% DOI: 10.1126/science.1065103 +% +% Syntax: +% result = exploreFNIRS.graph.smallWorld(G) +% result = exploreFNIRS.graph.smallWorld(G, 'NRandom', 50) +% +% Inputs: +% G - Graph struct from exploreFNIRS.graph.threshold +% +% Name-Value Parameters: +% NRandom - Number of random null networks (default: 100) +% +% Outputs: +% result - Struct with fields: +% .sigma Small-world sigma index +% .omega Small-world omega index +% .C Network clustering coefficient +% .C_rand Mean clustering of random nulls +% .C_lattice Clustering of equivalent ring lattice +% .L Network characteristic path length +% .L_rand Mean path length of random nulls +% +% Example: +% G = exploreFNIRS.graph.threshold(conn, 'Method', 'proportional', 'Value', 0.2); +% sw = exploreFNIRS.graph.smallWorld(G, 'NRandom', 50); +% fprintf('Sigma = %.3f, Omega = %.3f\n', sw.sigma, sw.omega); +% +% See also: exploreFNIRS.graph.threshold, exploreFNIRS.graph.clusteringCoefficient, +% exploreFNIRS.graph.charPathLength + + p = inputParser; + addRequired(p, 'G', @isstruct); + addParameter(p, 'NRandom', 100, @(x) isnumeric(x) && isscalar(x) && x >= 1); + parse(p, G, varargin{:}); + nRand = round(p.Results.NRandom); + + validateGraph(G); + + N = G.N; + + % Check connectivity + pl = exploreFNIRS.graph.charPathLength(G); + if pl.nComponents > 1 + warning('exploreFNIRS:graph:smallWorld:disconnected', ... + 'Graph has %d components. Small-world indices may be unreliable.', ... + pl.nComponents); + end + + % Network clustering and path length + cc = exploreFNIRS.graph.clusteringCoefficient(G); + C = cc.meanC; + L = pl.lambda; + + if ~isfinite(L) || L == 0 + result.sigma = NaN; + result.omega = NaN; + result.C = C; + result.C_rand = NaN; + result.C_lattice = NaN; + result.L = L; + result.L_rand = NaN; + return; + end + + % Generate random null networks via Maslov-Sneppen rewiring + C_rands = zeros(1, nRand); + L_rands = zeros(1, nRand); + + useParfor = false; + if nRand > 10 + [canUse, poolRunning] = pf2_base.accel.canParfor(); + useParfor = canUse && poolRunning; + end + + Wref = G.W; + isDir = G.directed; + + if useParfor + parfor r = 1:nRand + Wrand = maslovSneppen(Wref, isDir); + Grand = struct('W', Wrand, 'A', double(Wrand > 0), 'N', N, 'directed', isDir); + ccRand = exploreFNIRS.graph.clusteringCoefficient(Grand); + plRand = exploreFNIRS.graph.charPathLength(Grand); + C_rands(r) = ccRand.meanC; + L_rands(r) = plRand.lambda; + end + else + for r = 1:nRand + Wrand = maslovSneppen(Wref, isDir); + Grand = struct('W', Wrand, 'A', double(Wrand > 0), 'N', N, 'directed', isDir); + ccRand = exploreFNIRS.graph.clusteringCoefficient(Grand); + plRand = exploreFNIRS.graph.charPathLength(Grand); + C_rands(r) = ccRand.meanC; + L_rands(r) = plRand.lambda; + end + end + + C_rand = mean(C_rands); + L_rand = mean(L_rands); + + % Sigma = (C/C_rand) / (L/L_rand) + if C_rand > 0 && L_rand > 0 + sigma = (C / C_rand) / (L / L_rand); + else + sigma = NaN; + end + + % Ring lattice clustering for omega + C_lattice = ringLatticeClustering(G); + + % Omega = L_rand/L - C/C_lattice + if L > 0 && C_lattice > 0 + omega = L_rand / L - C / C_lattice; + else + omega = NaN; + end + + result.sigma = sigma; + result.omega = omega; + result.C = C; + result.C_rand = C_rand; + result.C_lattice = C_lattice; + result.L = L; + result.L_rand = L_rand; +end + + +function Wrand = maslovSneppen(W, isDirected) +% MASLOVSNEPPEN Degree-preserving random rewiring +% +% Pick two random edges (a-b, c-d), swap to (a-d, c-b) if no multi-edge +% or self-loop is created. Repeat for nSwaps = 5 * nEdges iterations. + + N = size(W, 1); + Wrand = W; + + if isDirected + [rows, cols] = find(Wrand); + else + [rows, cols] = find(triu(Wrand)); + end + nEdges = length(rows); + + if nEdges < 2 + return; + end + + nSwaps = 5 * nEdges; + weights = zeros(nEdges, 1); + for e = 1:nEdges + weights(e) = Wrand(rows(e), cols(e)); + end + + for s = 1:nSwaps + % Pick two random edges + e1 = randi(nEdges); + e2 = randi(nEdges); + if e1 == e2, continue; end + + a = rows(e1); b = cols(e1); + c = rows(e2); d = cols(e2); + + % Check no self-loops + if a == d || c == b, continue; end + + % Check no multi-edges + if Wrand(a, d) > 0 || Wrand(c, b) > 0, continue; end + + % Perform swap + w1 = weights(e1); + w2 = weights(e2); + + Wrand(a, b) = 0; Wrand(c, d) = 0; + Wrand(a, d) = w1; Wrand(c, b) = w2; + + if ~isDirected + Wrand(b, a) = 0; Wrand(d, c) = 0; + Wrand(d, a) = w1; Wrand(b, c) = w2; + end + + rows(e1) = a; cols(e1) = d; + rows(e2) = c; cols(e2) = b; + end +end + + +function C_lat = ringLatticeClustering(G) +% Approximate clustering coefficient of a ring lattice with same N and mean degree + N = G.N; + k = mean(sum(G.A, 2)); + halfK = floor(k / 2); + + if halfK < 2 || N < 4 + C_lat = 0; + return; + end + + % For a regular ring lattice with N nodes and each connected to K nearest: + % C = 3*(K-2) / (4*(K-1)) for even K + C_lat = 3 * (2 * halfK - 2) / (4 * (2 * halfK - 1)); + C_lat = max(C_lat, 0); +end + + +function validateGraph(G) + if ~isstruct(G) || ~isfield(G, 'W') || ~isfield(G, 'A') || ~isfield(G, 'N') + error('exploreFNIRS:graph:smallWorld', ... + 'Input must be a graph struct from threshold()'); + end +end diff --git a/+exploreFNIRS/+graph/threshold.m b/+exploreFNIRS/+graph/threshold.m new file mode 100644 index 00000000..1316b46e --- /dev/null +++ b/+exploreFNIRS/+graph/threshold.m @@ -0,0 +1,238 @@ +function G = threshold(input, varargin) +% THRESHOLD Convert connectivity matrix to graph struct for graph theory analysis +% +% Thresholds a continuous coupling matrix to create a sparse adjacency +% representation. Supports absolute, proportional (density-based), and +% significance-based thresholding. The output graph struct is the standard +% input to all graph metric functions in this package. +% +% Syntax: +% G = exploreFNIRS.graph.threshold(connResult) +% G = exploreFNIRS.graph.threshold(connResult, 'Method', 'proportional', 'Value', 0.15) +% G = exploreFNIRS.graph.threshold(matrix) +% G = exploreFNIRS.graph.threshold(connResult, 'Binarize', true) +% +% Inputs: +% input - One of: +% - Connectivity result struct from computeMatrix (has .matrix field) +% - Group result struct from Experiment.connectivity() (has .Mean field) +% - Raw [N x N] numeric matrix +% +% Name-Value Parameters: +% Method - 'absolute' (default), 'proportional', 'significance' +% Value - Threshold value (default: 0.3) +% absolute: keep edges with |w| >= Value +% proportional: keep top Value fraction of edges (0-1) +% significance: keep edges with p < Value +% Binarize - Convert to binary adjacency (default: false) +% AbsoluteWeight - Use |w| for weights (default: true) +% ZeroDiagonal - Set self-connections to zero (default: true) +% +% Outputs: +% G - Graph struct with fields: +% .W [N x N] weighted adjacency (0 where below threshold) +% .A [N x N] binary adjacency +% .N Number of nodes +% .channels [1 x N] channel indices +% .labels {N x 1} labels +% .directed logical +% .method Threshold method used +% .threshold Threshold value +% .binarized logical +% .density Edge density (fraction of possible edges present) +% .source Struct with source connectivity metadata +% +% Example: +% conn = exploreFNIRS.connectivity.computeMatrix(processed, 'Method', 'pearson'); +% G = exploreFNIRS.graph.threshold(conn, 'Method', 'proportional', 'Value', 0.15); +% disp(G.density); +% +% See also: exploreFNIRS.connectivity.computeMatrix, +% exploreFNIRS.graph.computeMetrics, exploreFNIRS.graph.degree + + p = inputParser; + addRequired(p, 'input'); + addParameter(p, 'Method', 'absolute', ... + @(x) ischar(x) && ismember(lower(x), {'absolute','proportional','significance'})); + addParameter(p, 'Value', 0.3, @(x) isnumeric(x) && isscalar(x)); + addParameter(p, 'Binarize', false, @islogical); + addParameter(p, 'AbsoluteWeight', true, @islogical); + addParameter(p, 'ZeroDiagonal', true, @islogical); + parse(p, input, varargin{:}); + opts = p.Results; + threshMethod = lower(opts.Method); + + % Extract matrix and metadata from input + [W, pmat, channels, labels, srcMeta, isDir] = parseInput(input); + N = size(W, 1); + + % Take absolute values of weights if requested + if opts.AbsoluteWeight + W = abs(W); + end + + % Zero diagonal + if opts.ZeroDiagonal + W(1:N+1:end) = 0; + end + + % Replace NaN with 0 + W(isnan(W)) = 0; + + % Apply threshold + switch threshMethod + case 'absolute' + mask = abs(W) >= opts.Value; + + case 'proportional' + frac = opts.Value; + if frac < 0 || frac > 1 + error('exploreFNIRS:graph:threshold', ... + 'Proportional threshold must be between 0 and 1, got %.2f', frac); + end + % Get all unique edge weights (upper triangle for undirected) + if isDir + offDiag = ~eye(N, 'logical'); + edgeWeights = sort(abs(W(offDiag)), 'descend'); + else + triMask = triu(true(N), 1); + edgeWeights = sort(abs(W(triMask)), 'descend'); + end + nEdges = length(edgeWeights); + nKeep = max(1, round(frac * nEdges)); + if nKeep <= nEdges && nKeep >= 1 + cutoff = edgeWeights(nKeep); + else + cutoff = 0; + end + mask = abs(W) >= cutoff; + + case 'significance' + if isempty(pmat) + error('exploreFNIRS:graph:threshold', ... + 'Significance thresholding requires p-values (.pmatrix field)'); + end + mask = pmat < opts.Value & pmat > 0; + end + + % Zero diagonal in mask + if opts.ZeroDiagonal + mask(1:N+1:end) = false; + end + + % Apply mask + W(~mask) = 0; + + % Binary adjacency + A = double(mask); + + % Binarize weights if requested + if opts.Binarize + W = A; + end + + % Compute density + if isDir + nPossible = N * (N - 1); + else + nPossible = N * (N - 1) / 2; + end + if nPossible > 0 + nEdges = nnz(A); + if ~isDir + nEdges = nEdges / 2; % each edge counted twice + end + density = nEdges / nPossible; + else + density = 0; + end + + % Build output struct + G.W = W; + G.A = A; + G.N = N; + G.channels = channels; + G.labels = labels; + G.directed = isDir; + G.method = threshMethod; + G.threshold = opts.Value; + G.binarized = opts.Binarize; + G.density = density; + G.source = srcMeta; +end + + +function [W, pmat, channels, labels, srcMeta, isDirected] = parseInput(input) +% Parse various input formats into a consistent representation + + pmat = []; + channels = []; + labels = {}; + srcMeta = struct(); + isDirected = false; + + if isnumeric(input) + % Raw matrix + W = double(input); + N = size(W, 1); + channels = 1:N; + labels = arrayfun(@(c) sprintf('Ch%d', c), 1:N, 'UniformOutput', false); + isDirected = checkDirected(W); + elseif isstruct(input) + % Normalize group results (.Mean → .matrix) + if ~isfield(input, 'matrix') && isfield(input, 'Mean') + input.matrix = input.Mean; + end + + if isfield(input, 'matrix') + W = double(input.matrix); + else + error('exploreFNIRS:graph:threshold', ... + 'Input struct must have .matrix or .Mean field'); + end + + N = size(W, 1); + + if isfield(input, 'pmatrix') && ~isempty(input.pmatrix) + pmat = input.pmatrix; + end + if isfield(input, 'channels') && ~isempty(input.channels) + channels = input.channels; + else + channels = 1:N; + end + if isfield(input, 'labels') && ~isempty(input.labels) + labels = input.labels; + else + labels = arrayfun(@(c) sprintf('Ch%d', c), channels, ... + 'UniformOutput', false); + end + + % Source metadata + if isfield(input, 'method') + srcMeta.method = input.method; + end + if isfield(input, 'biomarker') + srcMeta.biomarker = input.biomarker; + end + + % Detect directed by asymmetry + isDirected = checkDirected(W); + else + error('exploreFNIRS:graph:threshold', ... + 'Input must be a numeric matrix or connectivity result struct'); + end + + if size(W, 1) ~= size(W, 2) + error('exploreFNIRS:graph:threshold', ... + 'Input matrix must be square, got [%d x %d]', size(W, 1), size(W, 2)); + end +end + + +function tf = checkDirected(W) +% Check if matrix is directed (asymmetric) + W_clean = W; + W_clean(isnan(W_clean)) = 0; + tf = ~isequal(W_clean, W_clean'); +end diff --git a/+exploreFNIRS/+helper/getColormap.m b/+exploreFNIRS/+helper/getColormap.m index e1b37cf5..77b7c84b 100644 --- a/+exploreFNIRS/+helper/getColormap.m +++ b/+exploreFNIRS/+helper/getColormap.m @@ -1,7 +1,31 @@ function [colormapOut] = getColormap(colormapString) -%GETCOLORMAP given an input string, returns a string to the appropriate -% allows extensible plotting for colormaps -% ex: 'lines' will +% GETCOLORMAP Return a colormap function handle from a name string +% +% Resolves a colormap name to a function handle, supporting MATLAB builtin +% colormaps, Brewer colormaps (via brewermap), and matplotlib-style +% colormaps. Falls back to 'lines' if the name is not recognized. +% +% Syntax: +% colormapOut = exploreFNIRS.helper.getColormap(colormapString) +% +% Inputs: +% colormapString - Name of the colormap (char or string) +% MATLAB builtins: 'parula', 'jet', 'hot', 'cool', etc. +% Brewer: 'Set1', 'Paired', 'RdBu', 'Spectral', etc. +% Matplotlib: 'viridis', 'plasma', 'inferno', 'tab10', etc. +% +% Outputs: +% colormapOut - Function handle that accepts N (number of colors) +% and returns an [N x 3] RGB colormap matrix. +% +% Example: +% cmap = exploreFNIRS.helper.getColormap('viridis'); +% colors = cmap(10); % Get 10 colors +% +% cmap = exploreFNIRS.helper.getColormap('Set1'); +% colormap(cmap(8)); +% +% See also: exploreFNIRS.helper.listColormaps, colormap matlabBuiltin={'parula','turbo','hsv','hot','cool','spring','summer','autumn','winter','gray','bone','copper','pink','jet','lines','colorcube','prism','flag','white'}; if(contains(colormapString,matlabBuiltin)) diff --git a/+exploreFNIRS/+helper/listColormaps.m b/+exploreFNIRS/+helper/listColormaps.m index f51a863c..153aee59 100644 --- a/+exploreFNIRS/+helper/listColormaps.m +++ b/+exploreFNIRS/+helper/listColormaps.m @@ -1,8 +1,31 @@ function [colormapNames] = listColormaps(colormapType) +% LISTCOLORMAPS Return colormap names filtered by type +% +% Returns a cell array of colormap names from MATLAB built-in, Brewer, +% and Matplotlib families, filtered by perceptual category. +% +% Syntax: +% colormapNames = exploreFNIRS.helper.listColormaps() +% colormapNames = exploreFNIRS.helper.listColormaps(colormapType) +% +% Inputs: +% colormapType - (optional) Category filter string (default: 'qualitative') +% 'qualitative' - Distinct-color maps for categorical data +% 'sequential' - Single-hue gradient maps +% 'diverging' - Two-hue maps centered on a midpoint +% 'all' - All available colormaps +% +% Outputs: +% colormapNames - Cell array of colormap name strings +% +% Example: +% names = exploreFNIRS.helper.listColormaps('diverging'); +% colormap(names{1}); +% +% See also: colormap, brewermap if(nargin<1) colormapType='qualitative'; end -%LISTCOLORMAPS Given a colormap type, lists colormaps brewerBuiltin={'BrBG','Accent','BluesPuBuGn','PiYG','Dark2','BuGnPuRd','PRGn','Paired','BuPuPurples','PuOr','Pastel1','GnBuRdPu','RdBu','Pastel2','GreensReds','RdGy','Set1','GreysYlGn','RdYlBu','Set2','OrRdYlGnBu','RdYlGn','Set3','OrangesYlOrBr','Spectral','PuBuYlOrRd'}; matlabBuiltin={'parula','turbo','hsv','hot','cool','spring','summer','autumn','winter','gray','bone','copper','pink','jet','lines','colorcube','prism','flag','white'}; @@ -15,7 +38,7 @@ qualitativeTypes=[matlabQualitativeTypes(1),brewerQualitative,matplotlibQualitative,matlabQualitativeTypes(2:end)]; -matlabSequentialTypes={'parula','turbo','hsv','hot','cool','spring','summer','autumn','winter','gray','bone','copper','pink'}; +matlabSequentialTypes={'parula','turbo','hsv','hot','cool','spring','summer','autumn','winter','gray','bone','copper','pink','jet','white'}; brewerSequential={'Blues','BuGn','BuPu','GnBu','Greens','Greus','OrRd','Oranges','PuBu','PuBuGn','PuRd','Purples','RdPu','Reds','YlGn','YlGnBu','YlOrBr','YlOrRd'}; matplotlibSequential={'viridis','inferno','plasma','cividis'}; sequentialTypes=[matlabSequentialTypes,brewerSequential,matplotlibSequential]; @@ -35,6 +58,6 @@ case 'all' colormapNames=[qualitativeTypes,divergingTypes,sequentialTypes]; otherwise - error('Please specify: qualitative,diverging,sequantial, or all'); + error('exploreFNIRS:helper:listColormaps:invalidCategory', 'Please specify: qualitative, diverging, sequential, or all'); end diff --git a/+exploreFNIRS/+hyperscanning/computeDyad.m b/+exploreFNIRS/+hyperscanning/computeDyad.m new file mode 100644 index 00000000..740637fc --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/computeDyad.m @@ -0,0 +1,511 @@ +function result = computeDyad(dataA, dataB, varargin) +% COMPUTEDYAD Cross-brain coupling for one pair of subjects +% +% Computes inter-brain synchrony between two fNIRS datasets by calculating +% coupling between corresponding (or all) channel/ROI pairs across subjects. +% +% Syntax: +% result = exploreFNIRS.hyperscanning.computeDyad(dataA, dataB) +% result = exploreFNIRS.hyperscanning.computeDyad(dataA, dataB, ... +% 'Method', 'pearson', 'ChannelPairing', 'same') +% result = exploreFNIRS.hyperscanning.computeDyad(dataA, dataB, 'UseROI', true) +% +% Inputs: +% dataA - Processed fNIRS struct for subject A +% dataB - Processed fNIRS struct for subject B +% +% Name-Value Parameters: +% Method - Coupling method: 'pearson' (default), 'spearman', 'xcorr', +% 'coherence', 'wcoherence', 'granger', 'transferentropy', +% 'partialcorr', 'mutualinfo' +% Biomarker - 'HbO' (default), 'HbR', 'HbTotal', 'HbDiff', 'CBSI' +% ChannelPairing - How to pair channels/ROIs across subjects: +% 'same' (default) - same index (Ca=Cb) +% 'all' - all Ca x Cb combinations (full cross-brain matrix) +% Channels - Channel/ROI indices to use (default: intersection of good channels or all ROIs) +% TimeWindow - [start, end] in seconds (default: full overlap) +% CouplingArgs - Extra args passed to coupling function (default: {}) +% UseROI - Use ROI-level data instead of channels (default: false) +% Accelerate - Acceleration mode: 'auto' (default), 'gpu', 'parfor', 'none' +% For 'all' pairing with parfor available, parallelizes pairwise loop. +% PhysioQC - Assess shared-physiology confound risk for the dyad +% (default: false). When true, runs +% exploreFNIRS.hyperscanning.physioConfoundQC and stores the +% result in result.physioQC. +% PhysioQCArgs - Extra args forwarded to physioConfoundQC, e.g. +% {'Aux','heartRate','Band',[0.04 0.15]} (default: {}). +% +% Outputs: +% result - Struct with fields: +% .values - [N x 1] coupling values for 'same', [Na x Nb] for 'all' +% .pvalues - Same size as .values, p-values +% .channelsA - Channel/ROI indices for subject A +% .channelsB - Channel/ROI indices for subject B +% .labels - Cell array of labels (ROI names when UseROI=true) +% .method - Coupling method used +% .biomarker - Biomarker used +% .pairing - 'same' or 'all' +% .nSamples - Number of time samples used +% .useROI - Whether ROI mode was used +% .physioQC - (only when PhysioQC=true) shared-physiology confound report +% from exploreFNIRS.hyperscanning.physioConfoundQC +% +% References: +% Czeszumski, A., Ebers, S., Greshake Tzovaras, B., Gianotti, L. R. R., +% Kosonogov, V., et al. (2020). Hyperscanning: A Valid Method to Study +% Neural Inter-brain Underpinnings of Social Interaction. Frontiers in +% Human Neuroscience, 14, 39. DOI: 10.3389/fnhum.2020.00039 +% +% Cui, X., Bryant, D. M. & Reiss, A. L. (2012). NIRS-based +% hyperscanning reveals increased interpersonal coherence in superior +% frontal cortex during cooperation. NeuroImage, 59(3), 2430-2437. +% DOI: 10.1016/j.neuroimage.2011.09.003 +% +% See also: exploreFNIRS.hyperscanning.pairSubjects, exploreFNIRS.hyperscanning.computeGroup + + p = inputParser; + addRequired(p, 'dataA', @isstruct); + addRequired(p, 'dataB', @isstruct); + addParameter(p, 'Method', 'pearson', @ischar); + addParameter(p, 'Biomarker', 'HbO', @ischar); + addParameter(p, 'ChannelPairing', 'same', @ischar); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'TimeWindow', [], @(v) isnumeric(v) && (isempty(v) || length(v) == 2)); + addParameter(p, 'CouplingArgs', {}, @iscell); + addParameter(p, 'UseROI', false, @islogical); + addParameter(p, 'Accelerate', 'auto', @(x) ischar(x) && ismember(lower(x), {'auto','gpu','parfor','none'})); + addParameter(p, 'PhysioQC', false, @(x) islogical(x) && isscalar(x)); + addParameter(p, 'PhysioQCArgs', {}, @iscell); + parse(p, dataA, dataB, varargin{:}); + opts = p.Results; + + bioM = opts.Biomarker; + + if opts.UseROI + % ROI mode + if ~isfield(dataA, 'ROI') || ~isfield(dataA.ROI, bioM) + error('exploreFNIRS:hyperscanning:computeDyad', ... + 'ROI data not found in subject A. Run defineROI + buildROI first.'); + end + if ~isfield(dataB, 'ROI') || ~isfield(dataB.ROI, bioM) + error('exploreFNIRS:hyperscanning:computeDyad', ... + 'ROI data not found in subject B. Run defineROI + buildROI first.'); + end + sigA = dataA.ROI.(bioM); + sigB = dataB.ROI.(bioM); + else + % Channel mode + if ~isfield(dataA, bioM) || ~isfield(dataB, bioM) + error('exploreFNIRS:hyperscanning:computeDyad', ... + 'Biomarker "%s" not found in one or both subjects', bioM); + end + sigA = dataA.(bioM); + sigB = dataB.(bioM); + end + + % Determine channels/ROIs + if ~isempty(opts.Channels) + channelsA = opts.Channels; + channelsB = opts.Channels; + elseif opts.UseROI + % Use all ROIs + channelsA = 1:size(sigA, 2); + channelsB = 1:size(sigB, 2); + if strcmpi(opts.ChannelPairing, 'same') + nCommon = min(length(channelsA), length(channelsB)); + channelsA = 1:nCommon; + channelsB = 1:nCommon; + end + else + % Intersection of good channels + if isfield(dataA, 'fchMask') + goodA = find(dataA.fchMask); + else + goodA = 1:size(sigA, 2); + end + if isfield(dataB, 'fchMask') + goodB = find(dataB.fchMask); + else + goodB = 1:size(sigB, 2); + end + channelsA = intersect(goodA, 1:size(sigA, 2)); + channelsB = intersect(goodB, 1:size(sigB, 2)); + + if strcmpi(opts.ChannelPairing, 'same') + common = intersect(channelsA, channelsB); + channelsA = common; + channelsB = common; + end + end + + % Time alignment: use overlapping time range + timeA = dataA.time(:); + timeB = dataB.time(:); + fsA = dataA.fs; + fsB = dataB.fs; + + if abs(fsA - fsB) > 0.01 + error('exploreFNIRS:hyperscanning:computeDyad', ... + 'Sampling rates differ (%.2f vs %.2f Hz). Resample data to matching rates before computing dyad coupling.', fsA, fsB); + end + fs = fsA; + + % Find overlapping time range + tStart = max(timeA(1), timeB(1)); + tEnd = min(timeA(end), timeB(end)); + + if ~isempty(opts.TimeWindow) + tStart = max(tStart, opts.TimeWindow(1)); + tEnd = min(tEnd, opts.TimeWindow(2)); + end + + maskA = timeA >= tStart & timeA <= tEnd; + maskB = timeB >= tStart & timeB <= tEnd; + + sigA = sigA(maskA, :); + sigB = sigB(maskB, :); + + % Ensure equal length (trim to shorter) + nSamples = min(size(sigA, 1), size(sigB, 1)); + sigA = sigA(1:nSamples, :); + sigB = sigB(1:nSamples, :); + + if nSamples < 10 + error('exploreFNIRS:hyperscanning:computeDyad', ... + 'Insufficient overlapping samples (%d) for coupling analysis', nSamples); + end + + % Get coupling function + couplingFn = getCouplingFn(opts.Method); + methodLower = lower(opts.Method); + isBatchWcoh = strcmp(methodLower, 'wcoherence'); + + % Determine parfor usage + accelMode = lower(opts.Accelerate); + useParfor = false; + switch accelMode + case 'auto' + [canPf, poolOn] = pf2_base.accel.canParfor(); + useParfor = canPf && poolOn; + case 'parfor' + [canPf, ~] = pf2_base.accel.canParfor(); + useParfor = canPf; + case {'gpu', 'none'} + % no parfor + end + + % --- Batch CWT pre-computation for wcoherence --- + if isBatchWcoh + % Extract VoicesPerOctave and SmoothFactor from CouplingArgs if present + vpo = 10; + smoothFactor = 1; + for k = 1:2:length(opts.CouplingArgs) + if ischar(opts.CouplingArgs{k}) && strcmpi(opts.CouplingArgs{k}, 'VoicesPerOctave') + vpo = opts.CouplingArgs{k+1}; + elseif ischar(opts.CouplingArgs{k}) && strcmpi(opts.CouplingArgs{k}, 'SmoothFactor') + smoothFactor = opts.CouplingArgs{k+1}; + end + end + + sA = sigA(:, channelsA); + sB = sigB(:, channelsB); + cwtA = pf2_base.wavelet.cwt(sA, fs, 'VoicesPerOctave', vpo, 'Precision', 'single'); + cwtB = pf2_base.wavelet.cwt(sB, fs, 'VoicesPerOctave', vpo, 'Precision', 'single'); + baseCwt = struct('freqs', cwtA.freqs, 'scales', cwtA.scales, ... + 'coi', cwtA.coi, 'fs', cwtA.fs, 'omega0', cwtA.omega0); + + % Pre-compute smoothed auto-spectra for all channels + smoothedAutoA = precomputeSmoothedAuto(cwtA, fs, smoothFactor); + smoothedAutoB = precomputeSmoothedAuto(cwtB, fs, smoothFactor); + end + + % Compute coupling based on pairing mode + switch lower(opts.ChannelPairing) + case 'same' + nCh = length(channelsA); + values = nan(nCh, 1); + pvalues = nan(nCh, 1); + + sA = sigA(:, channelsA); + sB = sigB(:, channelsB); + + if isBatchWcoh + % Batch path: use pre-computed CWTs + smoothed auto-spectra + for c = 1:nCh + if all(isnan(sA(:, c))) || all(isnan(sB(:, c))) + continue; + end + cwtI = baseCwt; + cwtI.coeffs = cwtA.coeffs(:, :, c); + cwtJ = baseCwt; + cwtJ.coeffs = cwtB.coeffs(:, :, c); + res = pf2_base.wavelet.wcoherence(sA(:, c), sB(:, c), fs, ... + 'CwtX', cwtI, 'CwtY', cwtJ, ... + 'SmoothedAutoX', smoothedAutoA{c}, ... + 'SmoothedAutoY', smoothedAutoB{c}, ... + opts.CouplingArgs{:}); + values(c) = res.value; + pvalues(c) = res.pvalue; + end + elseif useParfor && nCh > 20 + parfor c = 1:nCh + xa = sA(:, c); + xb = sB(:, c); + if all(isnan(xa)) || all(isnan(xb)) + continue; + end + res = couplingFn(xa, xb, fs, opts.CouplingArgs{:}); + val = res.value; + pval = res.pvalue; + if res.windowed + val = mean(val, 'omitnan'); + pval = combinePvalues(pval); + end + values(c) = val; + pvalues(c) = pval; + end + else + for c = 1:nCh + xa = sA(:, c); + xb = sB(:, c); + if all(isnan(xa)) || all(isnan(xb)) + continue; + end + res = couplingFn(xa, xb, fs, opts.CouplingArgs{:}); + val = res.value; + pval = res.pvalue; + if res.windowed + val = mean(val, 'omitnan'); + pval = combinePvalues(pval); + end + values(c) = val; + pvalues(c) = pval; + end + end + + case 'all' + nA = length(channelsA); + nB = length(channelsB); + nPairs = nA * nB; + + sA = sigA(:, channelsA); + sB = sigB(:, channelsB); + + if isBatchWcoh + % Batch path: use pre-computed CWTs + smoothed auto-spectra + values = nan(nA, nB); + pvalues = nan(nA, nB); + for a = 1:nA + if all(isnan(sA(:, a))), continue; end + cwtI = baseCwt; + cwtI.coeffs = cwtA.coeffs(:, :, a); + for b = 1:nB + if all(isnan(sB(:, b))), continue; end + cwtJ = baseCwt; + cwtJ.coeffs = cwtB.coeffs(:, :, b); + res = pf2_base.wavelet.wcoherence(sA(:, a), sB(:, b), fs, ... + 'CwtX', cwtI, 'CwtY', cwtJ, ... + 'SmoothedAutoX', smoothedAutoA{a}, ... + 'SmoothedAutoY', smoothedAutoB{b}, ... + opts.CouplingArgs{:}); + values(a, b) = res.value; + pvalues(a, b) = res.pvalue; + end + end + elseif useParfor && nPairs > 20 + % Flatten to linear index for parfor + vals = nan(nPairs, 1); + pvals = nan(nPairs, 1); + + parfor k = 1:nPairs + a = ceil(k / nB); + b = k - (a - 1) * nB; + xa = sA(:, a); + xb = sB(:, b); + if all(isnan(xa)) || all(isnan(xb)) + continue; + end + res = couplingFn(xa, xb, fs, opts.CouplingArgs{:}); + val = res.value; + pval = res.pvalue; + if res.windowed + val = mean(val, 'omitnan'); + pval = combinePvalues(pval); + end + vals(k) = val; + pvals(k) = pval; + end + + values = reshape(vals, nB, nA)'; + pvalues = reshape(pvals, nB, nA)'; + else + values = nan(nA, nB); + pvalues = nan(nA, nB); + + for a = 1:nA + for b = 1:nB + xa = sA(:, a); + xb = sB(:, b); + if all(isnan(xa)) || all(isnan(xb)) + continue; + end + res = couplingFn(xa, xb, fs, opts.CouplingArgs{:}); + val = res.value; + pval = res.pvalue; + if res.windowed + val = mean(val, 'omitnan'); + pval = combinePvalues(pval); + end + values(a, b) = val; + pvalues(a, b) = pval; + end + end + end + + otherwise + error('exploreFNIRS:hyperscanning:computeDyad', ... + 'Unknown ChannelPairing "%s". Use: same, all', opts.ChannelPairing); + end + + result.values = values; + result.pvalues = pvalues; + result.channelsA = channelsA; + result.channelsB = channelsB; + result.method = opts.Method; + result.biomarker = bioM; + result.pairing = lower(opts.ChannelPairing); + result.nSamples = nSamples; + result.useROI = opts.UseROI; + + % Optional shared-physiology confound assessment for the dyad. Spurious + % inter-brain coherence in the LFO/VLFO band can arise from shared + % physiology (respiration, ~0.1 Hz Mayer waves); flag it if requested. + if opts.PhysioQC + try + result.physioQC = exploreFNIRS.hyperscanning.physioConfoundQC( ... + dataA, dataB, opts.PhysioQCArgs{:}); + catch ME + warning('exploreFNIRS:computeDyad:physioQCFailed', ... + 'PhysioQC skipped: %s', ME.message); + result.physioQC = struct('flag', false, 'available', false, ... + 'error', ME.message); + end + end + + % Build labels + if opts.UseROI + roiNamesA = {}; + roiNamesB = {}; + if isfield(dataA, 'ROI') && isfield(dataA.ROI, 'info') && istable(dataA.ROI.info) + roiNamesA = dataA.ROI.info.Properties.RowNames; + end + if isfield(dataB, 'ROI') && isfield(dataB.ROI, 'info') && istable(dataB.ROI.info) + roiNamesB = dataB.ROI.info.Properties.RowNames; + end + if ~isempty(roiNamesA) && max(channelsA) <= length(roiNamesA) + result.labelsA = roiNamesA(channelsA); + else + result.labelsA = arrayfun(@(c) sprintf('ROI%d', c), channelsA, 'UniformOutput', false); + end + if ~isempty(roiNamesB) && max(channelsB) <= length(roiNamesB) + result.labelsB = roiNamesB(channelsB); + else + result.labelsB = arrayfun(@(c) sprintf('ROI%d', c), channelsB, 'UniformOutput', false); + end + else + result.labelsA = arrayfun(@(c) sprintf('Ch%d', c), channelsA, 'UniformOutput', false); + result.labelsB = arrayfun(@(c) sprintf('Ch%d', c), channelsB, 'UniformOutput', false); + end +end + + +function fn = getCouplingFn(method) + switch lower(method) + case 'pearson' + fn = @exploreFNIRS.coupling.pearson; + case 'spearman' + fn = @exploreFNIRS.coupling.spearman; + case 'xcorr' + fn = @exploreFNIRS.coupling.xcorr; + case 'coherence' + fn = @exploreFNIRS.coupling.coherence; + case 'wcoherence' + fn = @exploreFNIRS.coupling.wcoherence; + case 'granger' + fn = @exploreFNIRS.coupling.granger; + case 'transferentropy' + fn = @exploreFNIRS.coupling.transferEntropy; + case 'hbica' + fn = @exploreFNIRS.coupling.hbica; + case 'partialcorr' + fn = @exploreFNIRS.coupling.partialCorr; + case 'mutualinfo' + fn = @exploreFNIRS.coupling.mutualInfo; + otherwise + error('exploreFNIRS:hyperscanning:computeDyad', ... + 'Unknown coupling method "%s". Use: pearson, spearman, xcorr, coherence, wcoherence, granger, transferentropy, hbica, partialcorr, mutualinfo', method); + end +end + + +function smoothedAuto = precomputeSmoothedAuto(cwtResult, fs, smoothFactor) +% Pre-compute smoothed auto-spectra S(|W|^2) for all channels + nCh = size(cwtResult.coeffs, 3); + scales = cwtResult.scales; + smoothedAuto = cell(nCh, 1); + + [nF, T] = size(cwtResult.coeffs(:, :, 1)); + dt = 1 / fs; + nfftSmooth = 2^nextpow2(T + max(ceil(3 * smoothFactor * scales / dt))); + + for ch = 1:nCh + W = abs(cwtResult.coeffs(:, :, ch)).^2; + Wf = fft(W, nfftSmooth, 2); + + S = zeros(nF, T, 'like', W); + for fi = 1:nF + sigma_t = smoothFactor * scales(fi) / dt; + halfWidth = ceil(3 * sigma_t); + if halfWidth < 1 + S(fi, :) = W(fi, 1:T); + continue; + end + halfWidth = min(halfWidth, floor(T/2)); + + kernel = zeros(1, nfftSmooth, 'like', real(W(1))); + kernel(1:halfWidth+1) = exp(-(0:halfWidth).^2 / (2 * sigma_t^2)); + kernel(end-halfWidth+1:end) = kernel(halfWidth+1:-1:2); + kernel = kernel / sum(kernel); + kernelF = fft(kernel, nfftSmooth); + + smoothed = ifft(Wf(fi, :) .* kernelF, nfftSmooth); + S(fi, :) = real(smoothed(1:T)); + end + + scaleSmooth = 0.6; + log2scales = log2(scales); + Sout = S; + for fi = 1:nF + mask = abs(log2scales - log2scales(fi)) <= scaleSmooth / 2; + if sum(mask) > 1 + Sout(fi, :) = mean(S(mask, :), 1); + end + end + smoothedAuto{ch} = Sout; + end +end + + +function p = combinePvalues(pvals) +% Combine p-values using Fisher's method (chi-squared test) + pvals = pvals(~isnan(pvals)); + if isempty(pvals) + p = NaN; + return; + end + % Clamp to eps to avoid log(0) = -Inf + pvals = max(pvals, eps); + chi2stat = -2 * sum(log(pvals)); + df = 2 * length(pvals); + p = 1 - chi2cdf(chi2stat, df); +end diff --git a/+exploreFNIRS/+hyperscanning/computeGroup.m b/+exploreFNIRS/+hyperscanning/computeGroup.m new file mode 100644 index 00000000..52f0f342 --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/computeGroup.m @@ -0,0 +1,410 @@ +function result = computeGroup(data, pairs, varargin) +% COMPUTEGROUP Aggregate inter-brain coupling across dyads or N-person groups +% +% Iterates over paired subjects from pairSubjects, computes pairwise dyad- +% level coupling for all within-group subject pairs, and aggregates into +% group-level statistics (Mean, SD, SEM, N) with one-sample t-tests against +% zero. +% +% For groups with more than 2 members (triads, etc.) each group of m members +% is expanded into nchoosek(m,2) pairwise sub-dyads. Because within-group +% sub-dyads share members they are NOT independent observations. To preserve +% statistical honesty, the function first averages sub-dyad values within each +% parent group (Fisher-z domain for correlation methods), then runs statistics +% across independent groups. N in the output therefore reflects the number of +% independent groups, not sub-dyads. +% +% For classic 2-member dyads the behavior is identical to the previous version: +% one sub-dyad per group, N = number of dyads. +% +% Uses alignMatrices to handle groups/dyads with different valid channels, +% ensuring channel identity is preserved during group aggregation. +% +% Syntax: +% result = exploreFNIRS.hyperscanning.computeGroup(data, pairs) +% result = exploreFNIRS.hyperscanning.computeGroup(data, pairs, ... +% 'Method', 'pearson', 'Biomarker', 'HbO') +% result = exploreFNIRS.hyperscanning.computeGroup(data, pairs, ... +% 'Align', 'intersection') +% +% Inputs: +% data - Cell array of processed fNIRS structs +% pairs - Struct array from pairSubjects (with .indices, .dyadID, etc.) +% .indices may have length 2 (dyad) or >= 3 (triad/N-group). +% +% Name-Value Parameters: +% Align - Channel alignment mode for group aggregation: +% 'union' (default) - all channels, NaN where missing +% 'intersection' - only channels in all dyads +% numeric 0-1 - channels in >= threshold fraction of dyads +% All parameters from computeDyad are supported (Method, Biomarker, +% ChannelPairing, Channels, TimeWindow, CouplingArgs). +% +% Outputs: +% result - Struct with fields: +% .Mean - Mean coupling across groups [same shape as dyad values] +% .SD - Standard deviation across group means +% .SEM - Standard error of the mean +% .N - Number of independent groups contributing each element +% .nValid - Per-cell count of non-NaN values (group-mean level) +% .tstat - One-sample t-statistic vs 0 (group-mean level). NaN where +% fewer than 3 groups contribute (df < 2 is not interpretable). +% .pvalue - P-value from one-sample t-test (group-mean level). NaN where +% fewer than 3 groups contribute (see .tstat). +% .dyads - Cell array of individual sub-dyad results +% .dyadIDs - Cell array of sub-dyad ID strings +% .groupIDs - Cell array of parent group ID strings (one per pairs entry) +% .groupMeans - [nGroups x 1] scalar per-group mean coupling +% .method - Coupling method used +% .biomarker - Biomarker used +% .pairing - Channel pairing mode +% .channels - Channels used (master channel set) +% +% Example: +% % Dyad (2-member) case — behavior unchanged +% pairs = exploreFNIRS.hyperscanning.pairSubjects(data); +% result = exploreFNIRS.hyperscanning.computeGroup(data, pairs, ... +% 'Method', 'pearson', 'Biomarker', 'HbO'); +% fprintf('Mean coupling: %.3f (p = %.4f)\n', ... +% mean(result.Mean, 'omitnan'), mean(result.pvalue, 'omitnan')); +% +% % Triad (3-member) case +% pairs = exploreFNIRS.hyperscanning.pairSubjects(triadData, 'GroupSize', 3); +% result = exploreFNIRS.hyperscanning.computeGroup(triadData, pairs, ... +% 'Method', 'pearson', 'Biomarker', 'HbO'); +% fprintf('N groups = %d, N sub-dyads = %d\n', ... +% length(result.groupIDs), length(result.dyadIDs)); +% +% References: +% Czeszumski, A., Ebers, S., Greshake Tzovaras, B., Gianotti, L. R. R., +% Kosonogov, V., et al. (2020). Hyperscanning: A Valid Method to Study +% Neural Inter-brain Underpinnings of Social Interaction. Frontiers in +% Human Neuroscience, 14, 39. DOI: 10.3389/fnhum.2020.00039 +% +% Silver, D. L. (1998). Fisher z-transformation. In Encyclopedia of +% Biostatistics (pp. 1544-1545). Wiley. +% +% See also: exploreFNIRS.hyperscanning.pairSubjects, +% exploreFNIRS.hyperscanning.computeDyad, +% exploreFNIRS.connectivity.alignMatrices + + % Extract Align parameter before forwarding rest to computeDyad + align = 'union'; + dyadArgs = {}; + k = 1; + while k <= length(varargin) + if ischar(varargin{k}) && strcmpi(varargin{k}, 'Align') + align = varargin{k+1}; + k = k + 2; + else + dyadArgs = [dyadArgs, varargin(k)]; %#ok + k = k + 1; + end + end + + nGroups = length(pairs); + if nGroups == 0 + error('exploreFNIRS:hyperscanning:computeGroup', 'No pairs provided'); + end + + % Determine method name early (needed for Fisher-z decision in aggregation) + methodName = 'pearson'; + for k2 = 1:2:length(dyadArgs) + if ischar(dyadArgs{k2}) && strcmpi(dyadArgs{k2}, 'Method') + methodName = lower(dyadArgs{k2+1}); + break; + end + end + useFisherZ = ismember(methodName, {'pearson', 'spearman', 'xcorr'}); + + % ----------------------------------------------------------------------- + % Expand each group into pairwise sub-dyads + % ----------------------------------------------------------------------- + % subDyads(s).idxA, .idxB : indices into data cell array + % subDyads(s).dyadID : label string, e.g. 'Triad01_AB' + % subDyads(s).groupIdx : index into pairs (parent group) + % groupIDs{g} : parent group ID string + + subDyads = struct('idxA', {}, 'idxB', {}, 'dyadID', {}, 'groupIdx', {}); + groupIDs = cell(nGroups, 1); + + for g = 1:nGroups + indices = pairs(g).indices(:)'; % row vector of data indices + m = length(indices); + groupIDs{g} = pairs(g).dyadID; + + if m == 2 + % Classic dyad: single sub-dyad, ID unchanged + s = length(subDyads) + 1; + subDyads(s).idxA = indices(1); + subDyads(s).idxB = indices(2); + subDyads(s).dyadID = pairs(g).dyadID; + subDyads(s).groupIdx = g; + else + % N-person group: expand to nchoosek(m,2) sub-dyads + % Assign role labels A=1st member, B=2nd, C=3rd, ... + roleLetters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + combos = nchoosek(1:m, 2); % [nchoosek(m,2) x 2] index pairs + for c = 1:size(combos, 1) + iA = combos(c, 1); + iB = combos(c, 2); + labelA = roleLetters(iA); + labelB = roleLetters(iB); + s = length(subDyads) + 1; + subDyads(s).idxA = indices(iA); + subDyads(s).idxB = indices(iB); + subDyads(s).dyadID = sprintf('%s_%s%s', pairs(g).dyadID, labelA, labelB); + subDyads(s).groupIdx = g; + end + end + end + + nSubDyads = length(subDyads); + + % Announce group/sub-dyad structure + hasTriads = any(arrayfun(@(g) length(pairs(g).indices) > 2, 1:nGroups)); + if hasTriads + fprintf('Computing %d sub-dyads from %d groups (triad/N-group mode).\n', ... + nSubDyads, nGroups); + fprintf(' Statistical inference will use N = %d independent groups.\n', nGroups); + else + fprintf('Computing %d dyads...\n', nSubDyads); + end + + % ----------------------------------------------------------------------- + % Compute each sub-dyad + % ----------------------------------------------------------------------- + dyadResults = cell(nSubDyads, 1); + dyadIDs = cell(nSubDyads, 1); + validDyads = true(nSubDyads, 1); + + for s = 1:nSubDyads + dyadIDs{s} = subDyads(s).dyadID; + end + + % Pre-extract indices for parfor compatibility (fixed-size matrix approach + % cannot be used for variable group sizes, so use simple arrays) + sdIdxA = zeros(nSubDyads, 1); + sdIdxB = zeros(nSubDyads, 1); + for s = 1:nSubDyads + sdIdxA(s) = subDyads(s).idxA; + sdIdxB(s) = subDyads(s).idxB; + end + + % Determine whether to use parfor + useParfor = false; + if nSubDyads > 2 + [canUse, poolRunning] = pf2_base.accel.canParfor(); + useParfor = canUse && poolRunning; + end + + if useParfor + fprintf(' Running %d sub-dyad computations (parallel)...\n', nSubDyads); + parfor s = 1:nSubDyads + try + dyadResults{s} = exploreFNIRS.hyperscanning.computeDyad( ... + data{sdIdxA(s)}, data{sdIdxB(s)}, dyadArgs{:}); + catch + validDyads(s) = false; + end + end + % Print summary after parallel completion + for s = 1:nSubDyads + if validDyads(s) + fprintf(' [%d/%d] %s: mean r = %.3f\n', s, nSubDyads, dyadIDs{s}, ... + mean(dyadResults{s}.values(:), 'omitnan')); + else + warning('pf2:hyperscanning:dyadFailed', ... + 'Sub-dyad [%d/%d] %s: FAILED', s, nSubDyads, dyadIDs{s}); + end + end + else + for s = 1:nSubDyads + try + dyadResults{s} = exploreFNIRS.hyperscanning.computeDyad( ... + data{sdIdxA(s)}, data{sdIdxB(s)}, dyadArgs{:}); + fprintf(' [%d/%d] %s: mean r = %.3f\n', s, nSubDyads, dyadIDs{s}, ... + mean(dyadResults{s}.values(:), 'omitnan')); + catch ME + warning('exploreFNIRS:hyperscanning:computeGroup', ... + 'Sub-dyad "%s" failed: %s', dyadIDs{s}, ME.message); + validDyads(s) = false; + end + end + end + + dyadResults = dyadResults(validDyads); + dyadIDs = dyadIDs(validDyads); + validSubDyads = subDyads(validDyads); + nValidSubDyads = sum(validDyads); + + if nValidSubDyads == 0 + error('exploreFNIRS:hyperscanning:computeGroup', 'All sub-dyads failed'); + end + + % ----------------------------------------------------------------------- + % Align sub-dyad values onto a common channel grid + % ----------------------------------------------------------------------- + [allSubDyadValues, masterCh, ~, ~] = ... + exploreFNIRS.connectivity.alignMatrices(dyadResults, align); + + % Shape: [M x 1 x nValidSubDyads] for 'same', [Ma x Mb x nValidSubDyads] for 'all' + valShape = size(allSubDyadValues); % e.g. [M 1 nSub] or [Ma Mb nSub] + % Use explicit dimension count rather than ndims() to avoid MATLAB's + % trailing-singleton collapsing (e.g. ndims(nan(M,M,1)) == 2, not 3). + nDim = length(valShape); % sub-dyad dimension is last + + % ----------------------------------------------------------------------- + % Per-group aggregation: average within-group sub-dyads (Fisher-z domain) + % then use per-group means for inference — preserving independence. + % ----------------------------------------------------------------------- + % Map valid sub-dyads back to their parent group + validGroupIdxs = [validSubDyads.groupIdx]; % [1 x nValidSubDyads] + + % Find which groups have at least one valid sub-dyad + uniqueValidGroups = unique(validGroupIdxs, 'stable'); + nValidGroups = length(uniqueValidGroups); + + % Stack shape: spatialShape x nValidGroups. + % The group dimension is always dimension length(spatialShape)+1. Use that + % explicitly to avoid ndims() trailing-singleton collapsing (e.g. + % ndims(nan(M,M,1)) == 2 in MATLAB, not 3). + spatialShape = valShape(1:end-1); % e.g. [M 1] or [Ma Mb] + nGroupDim = length(spatialShape) + 1; % explicit group dimension index + groupMeanStackZ = nan([spatialShape, nValidGroups]); % z-space (or raw) + + for gi = 1:nValidGroups + g = uniqueValidGroups(gi); + % Find sub-dyad indices (in the valid list) that belong to this group + memberMask = (validGroupIdxs == g); + memberCount = sum(memberMask); + + % Build index expressions: spatially colon-indexed, group dimension gi + % or logical mask. Using subsref-style index cell for n-D compatibility. + sdIdx = [repmat({':'}, 1, length(spatialShape)), {memberMask}]; + gIdx = [repmat({':'}, 1, length(spatialShape)), {gi}]; + + if memberCount == 1 + % Single sub-dyad (standard dyad): no within-group averaging needed + sdSingleIdx = [repmat({':'}, 1, length(spatialShape)), {find(memberMask,1)}]; + if useFisherZ + groupMeanStackZ(gIdx{:}) = ... + atanh(max(min(allSubDyadValues(sdSingleIdx{:}), 0.9999), -0.9999)); + else + groupMeanStackZ(gIdx{:}) = allSubDyadValues(sdSingleIdx{:}); + end + else + % Multiple sub-dyads: aggregate in Fisher-z domain for correlations + subVals = allSubDyadValues(sdIdx{:}); % [..., memberCount] + if useFisherZ + zSub = atanh(max(min(subVals, 0.9999), -0.9999)); + groupMeanStackZ(gIdx{:}) = mean(zSub, nDim, 'omitnan'); + else + groupMeanStackZ(gIdx{:}) = mean(subVals, nDim, 'omitnan'); + end + end + end + + % groupMeanStackZ is in Fisher-z space (for correlation methods) or raw + % values (for non-correlation methods). Compute r-space for output. + if useFisherZ + groupMeanStackR = tanh(groupMeanStackZ); % r-space for output + else + groupMeanStackR = groupMeanStackZ; + end + + % ----------------------------------------------------------------------- + % Group-level statistics (across independent group means) + % ----------------------------------------------------------------------- + nGroupsForInference = nValidGroups; + + nVals = sum(~isnan(groupMeanStackR), nGroupDim); + + if useFisherZ + % groupMeanStackZ holds within-group Fisher-z means; run group + % statistics in z-space (approximately Gaussian), then back-transform. + zMean = mean(groupMeanStackZ, nGroupDim, 'omitnan'); + zSD = std(groupMeanStackZ, 0, nGroupDim, 'omitnan'); + zSEM = zSD ./ sqrt(max(nVals, 1)); + meanVals = tanh(zMean); + % Back-transform SD/SEM to original scale (delta method approximation) + sdVals = zSD .* (1 - meanVals.^2); + semVals = zSEM .* (1 - meanVals.^2); + % T-test in z-space (where distribution is approximately normal) + tstat = zMean ./ max(zSEM, eps); + else + meanVals = mean(groupMeanStackR, nGroupDim, 'omitnan'); + sdVals = std(groupMeanStackR, 0, nGroupDim, 'omitnan'); + semVals = sdVals ./ sqrt(max(nVals, 1)); + tstat = meanVals ./ max(semVals, eps); + end + + df = max(nVals - 1, 1); + pvalue = 2 * (1 - pf2_base.compat.tcdf(abs(tstat), df)); + % A one-sample t needs df >= 2 (>= 3 groups) to be meaningful; with only 1-2 + % groups the df=1 p-value is degenerate (nearly useless dispersion). NaN both + % the statistic and its p-value so a t(1) is never reported as if it carried + % inferential weight. Mean/SD/SEM for small studies are still returned, + % governed by the `tooFew` display threshold below. + degenerate = nVals < 3; + tstat(degenerate) = NaN; + pvalue(degenerate) = NaN; + + % Suppress cells seen in fewer groups than the rest. Three independent + % groups is the target for stable group statistics, but the threshold is + % clamped to the number actually available so small studies (1-2 groups) + % still return a Mean; their pvalue is NaN'd below (nVals < 2) and the + % "exploratory only" note is printed for fewer than 3 groups. + minGroups = min(3, max(nVals(:))); + tooFew = nVals < minGroups; + meanVals(tooFew) = NaN; + sdVals(tooFew) = NaN; + semVals(tooFew) = NaN; + tstat(tooFew) = NaN; + pvalue(tooFew) = NaN; + + result.Mean = squeeze(meanVals); + result.SD = squeeze(sdVals); + result.SEM = squeeze(semVals); + result.N = squeeze(nVals); + result.nValid = squeeze(nVals); % group-level count (matches N; documented) + result.tstat = squeeze(tstat); + result.pvalue = squeeze(pvalue); + result.dyads = dyadResults; + result.dyadIDs = dyadIDs; + + % Group-level metadata + result.groupIDs = groupIDs(uniqueValidGroups); + % Per-group scalar mean coupling (mean over spatial elements, r-space) + result.groupMeans = zeros(nValidGroups, 1); + for gi = 1:nValidGroups + gIdx = [repmat({':'}, 1, length(spatialShape)), {gi}]; + slice = groupMeanStackR(gIdx{:}); + result.groupMeans(gi) = mean(slice(:), 'omitnan'); + end + + % Retain legacy dyadMeans field for backward compatibility (equals groupMeans) + result.dyadMeans = result.groupMeans; + + result.method = dyadResults{1}.method; + result.biomarker = dyadResults{1}.biomarker; + result.pairing = dyadResults{1}.pairing; + + % Use master channels from alignment + if iscell(masterCh) + result.channels = masterCh{1}; + result.channelsB = masterCh{2}; + else + result.channels = masterCh; + end + + % Report group-level summary + fprintf('Group result: %d valid groups (%d sub-dyads total), ', ... + nValidGroups, nValidSubDyads); + fprintf('mean coupling = %.3f (group-mean level), median = %.3f\n', ... + mean(result.groupMeans, 'omitnan'), median(result.groupMeans, 'omitnan')); + if nGroupsForInference < 3 + fprintf([' NOTE: Only %d independent groups for inference. ', ... + 'Statistics are exploratory only.\n'], nGroupsForInference); + end +end diff --git a/+exploreFNIRS/+hyperscanning/hbica.m b/+exploreFNIRS/+hyperscanning/hbica.m new file mode 100644 index 00000000..8eae688d --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/hbica.m @@ -0,0 +1,366 @@ +function result = hbica(dataA, dataB, varargin) +% HBICA Hyper-Brain Independent Component Analysis +% +% Decomposes concatenated multi-subject fNIRS data via TDSEP ICA, then uses +% a Goodness-of-Fit (GOF) index to classify each component as inter-brain +% (shared across subjects) or intra-brain (localized to one subject). +% +% Unlike pairwise coupling methods, HB-ICA is fully data-driven and +% component-based: it discovers inter-brain networks without requiring +% frequency band specification or channel pairing assumptions. +% +% Syntax: +% result = exploreFNIRS.hyperscanning.hbica(dataA, dataB) +% result = exploreFNIRS.hyperscanning.hbica(dataA, dataB, 'Biomarker', 'HbR') +% result = exploreFNIRS.hyperscanning.hbica(dataA, dataB, 'GOFThreshold', -0.5) +% result = exploreFNIRS.hyperscanning.hbica(dataA, dataB, 'NumComponents', 10) +% result = exploreFNIRS.hyperscanning.hbica(dataA, dataB, 'UseROI', true) +% +% Inputs: +% dataA - Processed fNIRS struct for subject A +% dataB - Processed fNIRS struct for subject B +% +% Name-Value Parameters: +% Biomarker - 'HbO' (default), 'HbR', 'HbTotal', 'HbDiff', 'CBSI' +% Channels - Channel/ROI indices (default: intersection of good channels, or all ROIs) +% TimeWindow - [start, end] seconds (default: full overlap) +% UseROI - Use ROI-level data instead of channels (default: false) +% Requires data.ROI. to exist. +% NumComponents - ICA components (default: auto from PCA) +% VarianceRetained - PCA threshold (default: 0.99) +% Lags - TDSEP lags in samples (default: auto from fs) +% GOFThreshold - Threshold for inter-brain classification (default: 0) +% GOF ranges from 0 (equal loading = inter-brain) to +% 1 (subject-specific = intra-brain). Components with +% GOF < threshold are classified as inter-brain. +% Detrend - Polynomial detrend order (default: 1, linear) +% Set to 0 for mean-only, -1 to skip. +% ZScore - Z-score channels/ROIs before concatenation (default: true) +% +% Outputs: +% result - Struct with fields: +% .sources - [T x K] group-level source time courses +% .mixingMatrix - [Ctotal x K] group mixing matrix (A) +% .unmixingMatrix - [K x Ctotal] group unmixing matrix (W) +% .sourcesA - [T x K] dual-regression sources for subject A +% .sourcesB - [T x K] dual-regression sources for subject B +% .mixingA - [Ca x K] dual-regression mixing for subject A +% .mixingB - [Cb x K] dual-regression mixing for subject B +% .GOF - [K x 1] Goodness-of-Fit index per component +% 0 = equal loading across subjects (inter-brain) +% 1 = loading concentrated on one subject (intra-brain) +% .GOF_A - [K x 1] per-subject GOF for subject A +% .GOF_B - [K x 1] per-subject GOF for subject B +% .isInterBrain - [K x 1] logical, true if GOF < GOFThreshold +% .interBrainIdx - Indices of inter-brain components +% .channelsA - Channel/ROI indices used for subject A +% .channelsB - Channel/ROI indices used for subject B +% .labelsA - Cell array of labels (ROI names when UseROI=true) +% .labelsB - Cell array of labels +% .biomarker - Biomarker used +% .method - 'hbica' +% .nComponents - Number of components extracted +% .fs - Sampling frequency +% .useROI - Whether ROI mode was used +% +% Algorithm: +% 1. Extract biomarker, time-align subjects +% 2. Detrend + optional z-score per channel +% 3. Concatenate channels: X = [sigA, sigB] +% 4. TDSEP decomposition -> sources, mixing, unmixing +% 5. Dual regression per subject (Luo et al. 2024, Eqs 4-5) +% 6. GOF scoring: computes ratio of within-subject vs cross-subject +% loading from z-scored mixing weights. Low GOF = shared loading +% across subjects (inter-brain), high GOF = subject-specific (intra). +% +% References: +% Luo, H., Cai, Y., Lin, X. & Duan, L. (2024). Hyper-brain independent +% component analysis (HB-ICA): an approach for detecting inter-brain +% networks from fNIRS-hyperscanning data. Biomedical Optics Express, +% 16(1). DOI: 10.1364/BOE.542554 +% +% Ziehe, A. & Muller, K.-R. (1998). TDSEP - an efficient algorithm for +% blind separation using time structure. Proc. ICANN'98, 675-680. +% +% See also: pf2_base.signal.tdsep, exploreFNIRS.hyperscanning.plotHBICA, +% exploreFNIRS.hyperscanning.computeDyad + + p = inputParser; + addRequired(p, 'dataA', @isstruct); + addRequired(p, 'dataB', @isstruct); + addParameter(p, 'Biomarker', 'HbO', @ischar); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'TimeWindow', [], @(v) isnumeric(v) && (isempty(v) || length(v) == 2)); + addParameter(p, 'UseROI', false, @islogical); + addParameter(p, 'NumComponents', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0); + addParameter(p, 'VarianceRetained', 0.99, @(v) isnumeric(v) && isscalar(v) && v > 0 && v <= 1); + addParameter(p, 'Lags', [], @(v) isnumeric(v) && (isempty(v) || isvector(v))); + addParameter(p, 'GOFThreshold', 0, @(v) isnumeric(v) && isscalar(v)); + addParameter(p, 'Detrend', 1, @(v) isnumeric(v) && isscalar(v)); + addParameter(p, 'ZScore', true, @islogical); + parse(p, dataA, dataB, varargin{:}); + opts = p.Results; + + bioM = opts.Biomarker; + + % Extract signals (ROI or channel mode) + if opts.UseROI + if ~isfield(dataA, 'ROI') || ~isfield(dataA.ROI, bioM) + error('exploreFNIRS:hyperscanning:hbica', ... + 'ROI data not found in subject A. Run defineROI + buildROI first.'); + end + if ~isfield(dataB, 'ROI') || ~isfield(dataB.ROI, bioM) + error('exploreFNIRS:hyperscanning:hbica', ... + 'ROI data not found in subject B. Run defineROI + buildROI first.'); + end + sigA = dataA.ROI.(bioM); + sigB = dataB.ROI.(bioM); + else + if ~isfield(dataA, bioM) || ~isfield(dataB, bioM) + error('exploreFNIRS:hyperscanning:hbica', ... + 'Biomarker "%s" not found in one or both subjects', bioM); + end + sigA = dataA.(bioM); + sigB = dataB.(bioM); + end + + % Determine channels/ROIs + if ~isempty(opts.Channels) + channelsA = opts.Channels; + channelsB = opts.Channels; + elseif opts.UseROI + channelsA = 1:size(sigA, 2); + channelsB = 1:size(sigB, 2); + else + % Intersection of good channels + if isfield(dataA, 'fchMask') + goodA = find(dataA.fchMask); + else + goodA = 1:size(sigA, 2); + end + if isfield(dataB, 'fchMask') + goodB = find(dataB.fchMask); + else + goodB = 1:size(sigB, 2); + end + common = intersect(goodA, goodB); + channelsA = common; + channelsB = common; + end + channelsA = channelsA(channelsA <= size(sigA, 2)); + channelsB = channelsB(channelsB <= size(sigB, 2)); + + % Sampling rate check + fsA = dataA.fs; + fsB = dataB.fs; + if abs(fsA - fsB) > 0.01 + error('exploreFNIRS:hyperscanning:hbica', ... + 'Sampling rates differ (%.2f vs %.2f Hz). Resample first.', fsA, fsB); + end + fs = fsA; + + % Time alignment + timeA = dataA.time(:); + timeB = dataB.time(:); + tStart = max(timeA(1), timeB(1)); + tEnd = min(timeA(end), timeB(end)); + + if ~isempty(opts.TimeWindow) + tStart = max(tStart, opts.TimeWindow(1)); + tEnd = min(tEnd, opts.TimeWindow(2)); + end + + maskA = timeA >= tStart & timeA <= tEnd; + maskB = timeB >= tStart & timeB <= tEnd; + + sigA = sigA(maskA, channelsA); + sigB = sigB(maskB, channelsB); + + % Ensure equal length + nSamples = min(size(sigA, 1), size(sigB, 1)); + sigA = sigA(1:nSamples, :); + sigB = sigB(1:nSamples, :); + + if nSamples < 20 + error('exploreFNIRS:hyperscanning:hbica', ... + 'Insufficient overlapping samples (%d) for ICA. Need >= 20.', nSamples); + end + + Ca = size(sigA, 2); + Cb = size(sigB, 2); + + % Detrend + if opts.Detrend >= 0 + for c = 1:Ca + sigA(:,c) = detrend(sigA(:,c), opts.Detrend); + end + for c = 1:Cb + sigB(:,c) = detrend(sigB(:,c), opts.Detrend); + end + end + + % Z-score per channel + if opts.ZScore + for c = 1:Ca + s = std(sigA(:,c)); + if s > eps + sigA(:,c) = (sigA(:,c) - mean(sigA(:,c))) / s; + end + end + for c = 1:Cb + s = std(sigB(:,c)); + if s > eps + sigB(:,c) = (sigB(:,c) - mean(sigB(:,c))) / s; + end + end + end + + % Concatenate channels + X = [sigA, sigB]; % [T x (Ca+Cb)] + + % TDSEP decomposition + tdsepArgs = {'VarianceRetained', opts.VarianceRetained}; + if opts.NumComponents > 0 + tdsepArgs = [tdsepArgs, 'NumComponents', opts.NumComponents]; + end + if ~isempty(opts.Lags) + tdsepArgs = [tdsepArgs, 'Lags', opts.Lags]; + end + + [W, sources, A] = pf2_base.signal.tdsep(X, tdsepArgs{:}); + + K = size(sources, 2); + + % Dual regression (Luo et al. 2024, Eqs 4-5) + % Subject-specific sources and mixing matrices + A_groupA = A(1:Ca, :); % [Ca x K] + A_groupB = A(Ca+1:end, :); % [Cb x K] + + % Dual regression: stage 1 — subject-specific sources + % sourcesA = sigA * A_A * inv(A_A' * A_A) + regA = A_groupA' * A_groupA; + regB = A_groupB' * A_groupB; + + % Regularize if needed + regA = regA + eye(K) * eps * trace(regA); + regB = regB + eye(K) * eps * trace(regB); + + sourcesA = sigA * A_groupA / regA; % [T x K] + sourcesB = sigB * A_groupB / regB; % [T x K] + + % Dual regression: stage 2 — subject-specific mixing + regSA = sourcesA' * sourcesA; + regSB = sourcesB' * sourcesB; + regSA = regSA + eye(K) * eps * trace(regSA); + regSB = regSB + eye(K) * eps * trace(regSB); + + mixingA = sigA' * sourcesA / regSA; % [Ca x K] + mixingB = sigB' * sourcesB / regSB; % [Cb x K] + + % GOF scoring per component + GOF = zeros(K, 1); + GOF_A = zeros(K, 1); + GOF_B = zeros(K, 1); + + for k = 1:K + % Absolute mixing weights for this component across all channels + wAll = abs([A_groupA(:,k); A_groupB(:,k)]); + Ctotal = Ca + Cb; + + if Ctotal < 2 + GOF(k) = 0; + continue; + end + + % Z-score the weights + mu_w = mean(wAll); + s_w = std(wAll); + if s_w < eps + GOF(k) = 0; + continue; + end + Zw = (wAll - mu_w) / s_w; + + % Per-subject GOF: ratio of within-subject vs total loading + % GOF > 0 means intra-brain (loads more on own subject) + % GOF < 0 means inter-brain (loads more on other subject) + sumAll = sum(abs(Zw)); + if sumAll < eps + GOF_A(k) = 0; + GOF_B(k) = 0; + GOF(k) = 0; + continue; + end + + % Subject A: fraction of total loading on A's channels vs B's + loadA_own = sum(abs(Zw(1:Ca))); + loadA_other = sum(abs(Zw(Ca+1:end))); + GOF_A(k) = (loadA_own - loadA_other) / sumAll; + + % Subject B: fraction of total loading on B's channels vs A's + loadB_own = sum(abs(Zw(Ca+1:end))); + loadB_other = sum(abs(Zw(1:Ca))); + GOF_B(k) = (loadB_own - loadB_other) / sumAll; + + % Combined GOF: average of absolute GOF values, with sign from + % whether the component loads more within or across subjects. + % Inter-brain components load roughly equally on both subjects, + % yielding small |GOF_A| and |GOF_B|. Intra-brain components + % load heavily on one subject, yielding large |GOF_A| or |GOF_B|. + avgAbsGOF = (abs(GOF_A(k)) + abs(GOF_B(k))) / 2; + % If both GOFs are near zero, component is inter-brain (shared) + % If either is large, component is intra-brain (subject-specific) + % Sign: negative = inter-brain, positive = intra-brain + GOF(k) = avgAbsGOF; + end + + % Classification: inter-brain components have low GOF (shared loading) + % GOF near 0 = equal loading across subjects = inter-brain + % GOF near 1 = loading concentrated on one subject = intra-brain + isInterBrain = GOF < opts.GOFThreshold; + interBrainIdx = find(isInterBrain); + + % Build result + result.sources = sources; + result.mixingMatrix = A; + result.unmixingMatrix = W; + result.sourcesA = sourcesA; + result.sourcesB = sourcesB; + result.mixingA = mixingA; + result.mixingB = mixingB; + result.GOF = GOF; + result.GOF_A = GOF_A; + result.GOF_B = GOF_B; + result.isInterBrain = isInterBrain; + result.interBrainIdx = interBrainIdx; + result.channelsA = channelsA; + result.channelsB = channelsB; + result.biomarker = bioM; + result.method = 'hbica'; + result.nComponents = K; + result.fs = fs; + result.useROI = opts.UseROI; + + % Build labels + if opts.UseROI + result.labelsA = buildROILabels(dataA, channelsA); + result.labelsB = buildROILabels(dataB, channelsB); + else + result.labelsA = arrayfun(@(c) sprintf('Ch%d', c), channelsA, 'UniformOutput', false); + result.labelsB = arrayfun(@(c) sprintf('Ch%d', c), channelsB, 'UniformOutput', false); + end +end + + +function labels = buildROILabels(data, indices) +% Extract ROI names from data.ROI.info table, or generate defaults + labels = {}; + if isfield(data, 'ROI') && isfield(data.ROI, 'info') && istable(data.ROI.info) + roiNames = data.ROI.info.Properties.RowNames; + if ~isempty(roiNames) && max(indices) <= length(roiNames) + labels = roiNames(indices); + return; + end + end + labels = arrayfun(@(c) sprintf('ROI%d', c), indices, 'UniformOutput', false); +end diff --git a/+exploreFNIRS/+hyperscanning/pairSubjects.m b/+exploreFNIRS/+hyperscanning/pairSubjects.m new file mode 100644 index 00000000..5a6ba07e --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/pairSubjects.m @@ -0,0 +1,174 @@ +function pairs = pairSubjects(data, varargin) +% PAIRSUBJECTS Match subjects into dyads or groups from .info metadata +% +% Reads .info.DyadID and .info.Role from each fNIRS struct to create +% matched pairs for hyperscanning analysis. Each dyad must have exactly +% the expected number of members. +% +% Syntax: +% pairs = exploreFNIRS.hyperscanning.pairSubjects(data) +% pairs = exploreFNIRS.hyperscanning.pairSubjects(data, 'ManualPairs', {{1,2},{3,4}}) +% pairs = exploreFNIRS.hyperscanning.pairSubjects(data, 'GroupSize', 3) +% +% Inputs: +% data - Cell array of processed fNIRS structs with .info.DyadID and .info.Role +% +% Name-Value Parameters: +% ManualPairs - Cell array of cell arrays with indices into data (overrides auto-pairing) +% e.g., {{1,2}, {3,4}} pairs data{1} with data{2}, etc. +% GroupSize - Expected members per group (default: 2 for dyads) +% DyadField - Info field for dyad/group ID (default: 'DyadID') +% RoleField - Info field for role label (default: 'Role') +% +% Outputs: +% pairs - Struct array with fields: +% .dyadID - Dyad/group identifier string +% .indices - [1 x GroupSize] indices into data cell array +% .roles - {1 x GroupSize} cell array of role labels +% .subjectIDs - {1 x GroupSize} cell array of subject IDs +% +% Example: +% % Automatic pairing by DyadID +% pairs = exploreFNIRS.hyperscanning.pairSubjects(data); +% fprintf('Found %d dyads\n', length(pairs)); +% +% % Manual pairing +% pairs = exploreFNIRS.hyperscanning.pairSubjects(data, ... +% 'ManualPairs', {{1,2}, {3,4}, {5,6}}); +% +% References: +% Czeszumski, A., Ebers, S., Greshake Tzovaras, B., Gianotti, L. R. R., +% Kosonogov, V., et al. (2020). Hyperscanning: A Valid Method to Study +% Neural Inter-brain Underpinnings of Social Interaction. Frontiers in +% Human Neuroscience, 14, 39. DOI: 10.3389/fnhum.2020.00039 +% +% See also: exploreFNIRS.hyperscanning.computeDyad, exploreFNIRS.hyperscanning.computeGroup + + p = inputParser; + addRequired(p, 'data', @iscell); + addParameter(p, 'ManualPairs', {}, @iscell); + addParameter(p, 'GroupSize', 2, @(v) isnumeric(v) && isscalar(v) && v >= 2); + addParameter(p, 'DyadField', 'DyadID', @ischar); + addParameter(p, 'RoleField', 'Role', @ischar); + parse(p, data, varargin{:}); + opts = p.Results; + + nData = length(data); + + if ~isempty(opts.ManualPairs) + % Manual pairing + pairs = buildManualPairs(data, opts.ManualPairs); + return; + end + + % Auto-pair by DyadID + dyadField = opts.DyadField; + roleField = opts.RoleField; + groupSize = opts.GroupSize; + + % Extract metadata + dyadIDs = cell(nData, 1); + roles = cell(nData, 1); + subjectIDs = cell(nData, 1); + + for i = 1:nData + if ~isfield(data{i}, 'info') + error('exploreFNIRS:hyperscanning:pairSubjects', ... + 'data{%d} has no .info field', i); + end + info = data{i}.info; + + if ~isfield(info, dyadField) + error('exploreFNIRS:hyperscanning:pairSubjects', ... + 'data{%d}.info has no .%s field. Set DyadField or use ManualPairs.', ... + i, dyadField); + end + dyadIDs{i} = char(string(info.(dyadField))); + + if isfield(info, roleField) + roles{i} = char(string(info.(roleField))); + else + roles{i} = sprintf('Member%d', i); + end + + if isfield(info, 'SubjectID') + subjectIDs{i} = char(string(info.SubjectID)); + else + subjectIDs{i} = sprintf('S%d', i); + end + end + + % Group by DyadID + uniqueDyads = unique(dyadIDs, 'stable'); + nDyads = length(uniqueDyads); + pairs = struct([]); + + for d = 1:nDyads + dID = uniqueDyads{d}; + members = find(strcmp(dyadIDs, dID)); + + if length(members) ~= groupSize + warning('exploreFNIRS:hyperscanning:pairSubjects', ... + ['Group "%s" has %d members (expected %d). Skipping.\n', ... + ' Fix: ensure each member has .info.%s = ''%s'', ', ... + 'or pass ''GroupSize'', %d to match actual group size.'], ... + dID, length(members), groupSize, dyadField, dID, length(members)); + continue; + end + + idx = length(pairs) + 1; + pairs(idx).dyadID = dID; + pairs(idx).indices = members(:)'; + pairs(idx).roles = roles(members)'; + pairs(idx).subjectIDs = subjectIDs(members)'; + end + + if isempty(pairs) + warning('exploreFNIRS:hyperscanning:pairSubjects', ... + 'No valid dyads found. Check .info.%s values.', dyadField); + pairs = struct('dyadID', {}, 'indices', {}, 'roles', {}, 'subjectIDs', {}); + end + + fprintf('Found %d dyads from %d subjects\n', length(pairs), nData); +end + + +function pairs = buildManualPairs(data, manualPairs) +% Build pairs struct from manual index specification + nPairs = length(manualPairs); + pairs = struct([]); + + for d = 1:nPairs + mp = manualPairs{d}; + if ~iscell(mp) + mp = num2cell(mp); + end + indices = [mp{:}]; + + pairs(d).dyadID = sprintf('Pair%02d', d); + pairs(d).indices = indices; + + roles = cell(1, length(indices)); + subjectIDs = cell(1, length(indices)); + for m = 1:length(indices) + idx = indices(m); + if isfield(data{idx}, 'info') + if isfield(data{idx}.info, 'Role') + roles{m} = char(string(data{idx}.info.Role)); + else + roles{m} = sprintf('Member%d', m); + end + if isfield(data{idx}.info, 'SubjectID') + subjectIDs{m} = char(string(data{idx}.info.SubjectID)); + else + subjectIDs{m} = sprintf('S%d', idx); + end + else + roles{m} = sprintf('Member%d', m); + subjectIDs{m} = sprintf('S%d', idx); + end + end + pairs(d).roles = roles; + pairs(d).subjectIDs = subjectIDs; + end +end diff --git a/+exploreFNIRS/+hyperscanning/permutationTest.m b/+exploreFNIRS/+hyperscanning/permutationTest.m new file mode 100644 index 00000000..aefce47d --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/permutationTest.m @@ -0,0 +1,212 @@ +function result = permutationTest(data, pairs, varargin) +% PERMUTATIONTEST Surrogate significance test for hyperscanning coupling +% +% Shuffles dyad pairings (pairing A1 with B2 instead of B1) to build a +% null distribution of coupling values under the hypothesis that +% inter-brain synchrony is spurious. Compares observed coupling against +% the null to derive permutation p-values. Uses FDR correction for +% multi-channel testing. +% +% Syntax: +% result = exploreFNIRS.hyperscanning.permutationTest(data, pairs) +% result = exploreFNIRS.hyperscanning.permutationTest(data, pairs, ... +% 'Permutations', 1000, 'PThreshold', 0.05, 'Method', 'pearson') +% +% Inputs: +% data - Cell array of processed fNIRS structs +% pairs - Struct array from pairSubjects (with .indices) +% +% Name-Value Parameters: +% Permutations - Number of permutations (default: 500) +% PThreshold - Significance threshold for FDR correction (default: 0.05) +% Align - Channel alignment mode for group aggregation (default: 'union') +% All computeDyad parameters are also supported (Method, Biomarker, +% ChannelPairing, Channels, TimeWindow, CouplingArgs). +% +% Outputs: +% result - Struct with fields: +% .observed - Observed group mean coupling (from real pairings) +% .nullDist - [nPerms x nElements] null distribution +% .nullMean - Mean of null distribution +% .nullSD - SD of null distribution +% .pvalue - Permutation p-values (proportion of null >= observed) +% .pvalueFDR - FDR-corrected p-values +% .significant - Logical mask of significant elements (after FDR) +% .nPerms - Number of permutations completed +% .zScore - Z-score of observed vs null +% +% Algorithm: +% For each permutation: +% 1. Randomly re-pair subjects (shuffle which B goes with which A) +% 2. Compute group mean coupling with shuffled pairs +% 3. Store in null distribution +% P-value = (# null >= observed + 1) / (nPerms + 1) +% +% References: +% Phipson, B. & Smyth, G. K. (2010). Permutation P-values should never +% be zero: calculating exact P-values when permutations are randomly +% drawn. Statistical Applications in Genetics and Molecular Biology, +% 9(1), Article 39. DOI: 10.2202/1544-6115.1585 +% +% Benjamini, Y. & Hochberg, Y. (1995). Controlling the false discovery +% rate: a practical and powerful approach to multiple testing. Journal of +% the Royal Statistical Society, Series B, 57(1), 289-300. +% +% See also: exploreFNIRS.hyperscanning.computeGroup, exploreFNIRS.fx.performFDR + + ip = inputParser; + addRequired(ip, 'data', @iscell); + addRequired(ip, 'pairs', @isstruct); + addParameter(ip, 'Permutations', 500, @(v) isnumeric(v) && isscalar(v) && v > 0); + addParameter(ip, 'PThreshold', 0.05, @isnumeric); + % Pass-through params for computeDyad + addParameter(ip, 'Method', 'pearson', @ischar); + addParameter(ip, 'Biomarker', 'HbO', @ischar); + addParameter(ip, 'ChannelPairing', 'same', @ischar); + addParameter(ip, 'Channels', [], @isnumeric); + addParameter(ip, 'TimeWindow', [], @(v) isnumeric(v) && (isempty(v) || length(v) == 2)); + addParameter(ip, 'CouplingArgs', {}, @iscell); + addParameter(ip, 'UseROI', false, @islogical); + addParameter(ip, 'Accelerate', 'auto', @(x) ischar(x) && ismember(lower(x), {'auto','gpu','parfor','none'})); + addParameter(ip, 'Align', 'union', @(x) (ischar(x) || isstring(x)) || (isnumeric(x) && isscalar(x))); + parse(ip, data, pairs, varargin{:}); + opts = ip.Results; + + nPerms = opts.Permutations; + nPairs = length(pairs); + + if nPairs < 2 + error('exploreFNIRS:hyperscanning:permutationTest', ... + 'Need at least 2 dyads for permutation testing (got %d)', nPairs); + end + + % Build args for computeDyad (exclude permutation-specific params) + dyadArgs = {'Method', opts.Method, 'Biomarker', opts.Biomarker, ... + 'ChannelPairing', opts.ChannelPairing}; + if ~isempty(opts.Channels) + dyadArgs = [dyadArgs, 'Channels', opts.Channels]; + end + if ~isempty(opts.TimeWindow) + dyadArgs = [dyadArgs, 'TimeWindow', opts.TimeWindow]; + end + if ~isempty(opts.CouplingArgs) + dyadArgs = [dyadArgs, 'CouplingArgs', {opts.CouplingArgs}]; + end + if opts.UseROI + dyadArgs = [dyadArgs, 'UseROI', true]; + end + if ~strcmpi(opts.Accelerate, 'auto') + dyadArgs = [dyadArgs, 'Accelerate', opts.Accelerate]; + end + + % Build align args for computeGroup + alignArgs = {'Align', opts.Align}; + + % Compute observed coupling + observedGroup = exploreFNIRS.hyperscanning.computeGroup(data, pairs, ... + alignArgs{:}, dyadArgs{:}); + observed = observedGroup.Mean(:); + nElements = length(observed); + + % Extract all subject indices by role (column 1 = A, column 2 = B) + roleA = zeros(nPairs, 1); + roleB = zeros(nPairs, 1); + for d = 1:nPairs + roleA(d) = pairs(d).indices(1); + roleB(d) = pairs(d).indices(2); + end + + % Permutation loop + nullDist = nan(nPerms, nElements); + + % Determine whether to use parfor + useParfor = false; + [canUse, poolRunning] = pf2_base.accel.canParfor(); + if canUse && poolRunning && nPerms > 10 + useParfor = true; + end + + if useParfor + fprintf('Permutation test (%d permutations, parallel)...\n', nPerms); + parfor perm = 1:nPerms + shuffledB = roleB(randperm(nPairs)); + shuffledPairs = pairs; + for d = 1:nPairs + shuffledPairs(d).indices = [roleA(d), shuffledB(d)]; + end + try + shuffResult = exploreFNIRS.hyperscanning.computeGroup( ... + data, shuffledPairs, alignArgs{:}, dyadArgs{:}); + nullDist(perm, :) = shuffResult.Mean(:); + catch + end + end + fprintf('done.\n'); + else + fprintf('Permutation test: '); + for perm = 1:nPerms + shuffledB = roleB(randperm(nPairs)); + shuffledPairs = pairs; + for d = 1:nPairs + shuffledPairs(d).indices = [roleA(d), shuffledB(d)]; + end + try + shuffResult = exploreFNIRS.hyperscanning.computeGroup( ... + data, shuffledPairs, alignArgs{:}, dyadArgs{:}); + nullDist(perm, :) = shuffResult.Mean(:); + catch + end + if mod(perm, max(1, round(nPerms/10))) == 0 + fprintf('%d%% ', round(perm/nPerms*100)); + end + end + fprintf('done.\n'); + end + + % Remove failed permutations + validPerms = ~all(isnan(nullDist), 2); + nullDist = nullDist(validPerms, :); + nValidPerms = size(nullDist, 1); + + % Compute permutation p-values + % p = (# null >= observed + 1) / (nPerms + 1) + pvalues = nan(nElements, 1); + for e = 1:nElements + if isnan(observed(e)) + continue; + end + nExceed = sum(abs(nullDist(:, e)) >= abs(observed(e))); + pvalues(e) = (nExceed + 1) / (nValidPerms + 1); + end + + % FDR correction + validP = ~isnan(pvalues); + pvalueFDR = nan(size(pvalues)); + significant = false(size(pvalues)); + if any(validP) + [qvals, ~, passed] = exploreFNIRS.fx.performFDR(pvalues(validP), opts.PThreshold); + pvalueFDR(validP) = qvals; + significant(validP) = passed; + end + + % Z-scores + nullMean = mean(nullDist, 1, 'omitnan')'; + nullSD = std(nullDist, 0, 1, 'omitnan')'; + zScore = (observed - nullMean) ./ max(nullSD, eps); + + % Reshape back to original shape + origShape = size(observedGroup.Mean); + result.observed = reshape(observed, origShape); + result.nullDist = nullDist; + result.nullMean = reshape(nullMean, origShape); + result.nullSD = reshape(nullSD, origShape); + result.pvalue = reshape(pvalues, origShape); + result.pvalueFDR = reshape(pvalueFDR, origShape); + result.significant = reshape(significant, origShape); + result.nPerms = nValidPerms; + result.zScore = reshape(zScore, origShape); + + nSig = sum(significant(:)); + fprintf('Permutation test: %d/%d elements significant (FDR q < %.2f)\n', ... + nSig, sum(~isnan(pvalues)), opts.PThreshold); +end diff --git a/+exploreFNIRS/+hyperscanning/physioConfoundQC.m b/+exploreFNIRS/+hyperscanning/physioConfoundQC.m new file mode 100644 index 00000000..dabb31bf --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/physioConfoundQC.m @@ -0,0 +1,112 @@ +function qc = physioConfoundQC(A, B, varargin) +% PHYSIOCONFOUNDQC Flag shared-physiology confound risk for a hyperscanning dyad +% +% Quantifies how strongly two subjects' physiological auxiliary signals (heart +% rate, respiration, PPG) co-vary in the low-frequency band. Shared physiology +% and ~0.1 Hz Mayer waves are a primary source of SPURIOUS inter-brain +% coherence in hyperscanning; a high aux-aux coherence is a warning that neural +% synchrony estimates in that band may be confounded. +% +% Syntax: +% qc = exploreFNIRS.hyperscanning.physioConfoundQC(A, B) +% qc = exploreFNIRS.hyperscanning.physioConfoundQC(A, B, 'Name', Value) +% +% Inputs: +% A, B - fNIRS data structs for the two members of the dyad, each with a +% physiological signal in .Aux. +% +% Name-Value Parameters: +% 'Aux' - Aux signal name shared by both subjects (default: auto-detect, +% preferring HR, then PPG, then EKG). +% 'Band' - LFO/VLFO band [loHz hiHz] to assess (default: [0.04 0.15], +% spanning the Mayer-wave range). +% 'Threshold' - Aux-aux coherence above which the dyad is flagged +% (default: 0.5). +% +% Outputs: +% qc - Struct with fields: +% .flag - true if mean aux coherence in Band exceeds Threshold +% .auxCoherence - mean aux-aux coherence in Band +% .signal - aux signal name used +% .band - band assessed +% .threshold - threshold used +% .available - false if a shared aux signal could not be resolved +% +% Notes: +% - When no shared aux signal is available, qc.flag is false and +% qc.available is false (no confound assessment possible). +% +% Example: +% qc = exploreFNIRS.hyperscanning.physioConfoundQC(subjA, subjB); +% if qc.flag, warning('Shared physiology may inflate LFO synchrony'); end +% +% See also: exploreFNIRS.coupling.partialCoherence, exploreFNIRS.coupling.coherence, +% exploreFNIRS.hyperscanning.computeDyad + +p = inputParser; +p.addRequired('A', @isstruct); +p.addRequired('B', @isstruct); +p.addParameter('Aux', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Band', [0.04 0.15], @(x) isnumeric(x) && numel(x) == 2); +p.addParameter('Threshold', 0.5, @(x) isnumeric(x) && isscalar(x)); +p.parse(A, B, varargin{:}); +band = p.Results.Band; +thr = p.Results.Threshold; + +auxName = char(string(p.Results.Aux)); +if isempty(auxName) + % Auto-detect only cardio-respiratory signals: these carry the shared + % LFO/Mayer-wave physiology relevant to the confound. EDA has negligible + % power in this band, so it is intentionally excluded from the fallback. + for cand = {'HR', 'PPG', 'EKG'} + nA = pf2_base.fnirs.findAuxByType(A, cand{1}, ''); + nB = pf2_base.fnirs.findAuxByType(B, cand{1}, ''); + if ~isempty(nA) && ~isempty(nB) + auxName = nA; + break; + end + end +end + +qc = struct('flag', false, 'auxCoherence', NaN, 'signal', auxName, ... + 'band', band, 'threshold', thr, 'available', false); + +if isempty(auxName) + return; +end + +try + aA = pf2.data.auxOnGrid(A, auxName); + aB = pf2.data.auxOnGrid(B, auxName); +catch + return; +end + +% Equalize length (assume a common sampling rate across the dyad) +n = min(size(aA, 1), size(aB, 1)); +if n < 8 + return; +end +sA = aA(1:n, 1); +sB = aB(1:n, 1); + +fs = A.fs; +if isempty(fs) || ~isfinite(fs) + fs = 1 / median(diff(A.time)); +end + +% The coherence assumes the two subjects share a synchronous time base. Warn +% on a clear sampling-rate mismatch (a sign the dyad is not time-aligned). +if isfield(B, 'fs') && ~isempty(B.fs) && isfinite(B.fs) && ... + abs(B.fs - fs) > 1e-6 * max(fs, B.fs) + warning('pf2:physioConfoundQC:fsMismatch', ... + ['Subjects A (%.3g Hz) and B (%.3g Hz) have different sampling rates; ', ... + 'aux coherence assumes a common synchronous grid.'], fs, B.fs); +end + +c = exploreFNIRS.coupling.coherence(sA, sB, fs, 'FreqRange', band); +qc.auxCoherence = c.value; +qc.available = true; +qc.flag = c.value > thr; + +end diff --git a/+exploreFNIRS/+hyperscanning/plotDualBrain.m b/+exploreFNIRS/+hyperscanning/plotDualBrain.m new file mode 100644 index 00000000..906f5c96 --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/plotDualBrain.m @@ -0,0 +1,351 @@ +function fig = plotDualBrain(result, dataA, dataB, varargin) +% PLOTDUALBRAIN Two brains side by side with cross-brain synchrony edges +% +% Visualizes inter-brain synchrony for a dyad by drawing both subjects' +% probes at their real anatomical (2D) layout, side by side, with edges +% connecting cross-brain channel pairs colored and sized by their coupling. +% This replaces the synthetic square-grid layout of plotInterBrainTopo with +% the actual optode geometry, so the spatial pattern of inter-brain coupling +% (e.g. homologous frontal channels) is readable. An optional linked +% wavelet-coherence time-frequency panel is drawn beneath the brains. +% +% Supports both dyad pairings from computeDyad: 'same' (matched channel +% pairs, [N x 1] values) and 'all' (the full [Na x Nb] cross-brain matrix). +% +% Syntax: +% exploreFNIRS.hyperscanning.plotDualBrain(result, dataA, dataB) +% exploreFNIRS.hyperscanning.plotDualBrain(result, dataA, dataB, ... +% 'Threshold', 0.3, 'TopN', 40) +% exploreFNIRS.hyperscanning.plotDualBrain(result, dataA, dataB, ... +% 'Wcoherence', wc) % add linked time-frequency panel +% fig = exploreFNIRS.hyperscanning.plotDualBrain(..., 'SavePath', 'dyad.png') +% +% Inputs: +% result - Dyad result from exploreFNIRS.hyperscanning.computeDyad +% (fields .values, .pvalues, .channelsA, .channelsB, .pairing). +% dataA - Processed fNIRS struct for subject A (provides probe layout). +% dataB - Processed fNIRS struct for subject B (provides probe layout). +% +% Name-Value Parameters: +% 'Threshold' - Minimum |coupling| to draw an edge (default: 0). +% 'TopN' - Keep only the strongest N edges (default: []). +% 'SignificanceMask' - Draw only edges with p < PThreshold (default: false). +% 'PThreshold' - Significance cutoff (default: 0.05). +% 'CLim' - [lo hi] edge color limits (default symmetric). +% 'EdgeColormap' - [M x 3] colormap or name (default blue-white-red). +% 'EdgeWidthRange' - [min max] line width from |coupling| (default [0.5 5]). +% 'NodeSize' - Node marker area (default: 40). +% 'BrainLabels' - {labelA, labelB} (default: {'Subject A','Subject B'}). +% 'Gap' - Horizontal gap between the two layouts as a fraction +% of subject A's width (default: 0.6). +% 'Wcoherence' - Wavelet-coherence result (struct with .wcoh, .freqs, +% .times, .coi) to draw as a linked panel (default: []). +% 'Colorbar' - Colorbar label (default: auto from method). +% 'Title' - Figure title (default: auto from method/biomarker). +% 'Visible' - Figure visibility 'on'|'off' (default: 'on'). +% 'SavePath' - Output image path for headless saving (default: ''). +% 'SaveWidth'/'SaveHeight'/'SaveDPI' - Saved image size/resolution. +% +% Outputs: +% fig - Handle to the created figure. +% +% Algorithm: +% 1. Read each subject's 2D optode layout; offset subject B to the right. +% 2. Build the cross-brain edge list from .values, applying Threshold, +% SignificanceMask and TopN. +% 3. Draw edges (color/width by coupling) and nodes; optionally add a +% wavelet-coherence time-frequency panel with its cone of influence. +% +% Example: +% data = pf2.import.sampleData.fNIR2000(); +% A = processFNIRS2(data); B = processFNIRS2(data); +% r = exploreFNIRS.hyperscanning.computeDyad(A, B, 'ChannelPairing', 'all'); +% exploreFNIRS.hyperscanning.plotDualBrain(r, A, B, 'TopN', 30, ... +% 'SavePath', 'dyad.png'); +% +% Notes: +% - This is a 2D anatomical dual-brain. Edges are drawn in a single axes so +% they can span both subjects; a full two-cortex 3D scene is a possible +% future extension. +% +% See also: exploreFNIRS.hyperscanning.computeDyad, +% exploreFNIRS.hyperscanning.plotInterBrainTopo, +% exploreFNIRS.coupling.wcoherence, pf2.probe.plot.connectome + +p = inputParser; +addRequired(p, 'result', @isstruct); +addRequired(p, 'dataA', @isstruct); +addRequired(p, 'dataB', @isstruct); +addParameter(p, 'Threshold', 0, @(x) isnumeric(x) && isscalar(x) && x >= 0); +addParameter(p, 'TopN', [], @(x) isempty(x) || (isnumeric(x) && isscalar(x) && x >= 1)); +addParameter(p, 'SignificanceMask', false, @islogical); +addParameter(p, 'PThreshold', 0.05, @(x) isnumeric(x) && isscalar(x) && x > 0 && x < 1); +addParameter(p, 'CLim', [], @(x) isempty(x) || (isnumeric(x) && numel(x) == 2)); +addParameter(p, 'EdgeColormap', [], @(x) isempty(x) || isnumeric(x) || ischar(x) || isstring(x)); +addParameter(p, 'EdgeWidthRange', [0.5 5], @(x) isnumeric(x) && numel(x) == 2); +addParameter(p, 'NodeSize', 40, @(x) isnumeric(x) && isscalar(x) && x > 0); +addParameter(p, 'BrainLabels', {'Subject A', 'Subject B'}, @(x) iscell(x) && numel(x) == 2); +addParameter(p, 'Gap', 0.6, @(x) isnumeric(x) && isscalar(x) && x >= 0); +addParameter(p, 'Wcoherence', [], @(x) isempty(x) || isstruct(x)); +addParameter(p, 'Colorbar', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'Title', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'Visible', 'on', @(x) any(strcmpi(char(x), {'on','off'}))); +addParameter(p, 'SavePath', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'SaveWidth', 900, @(x) isempty(x) || isnumeric(x)); +addParameter(p, 'SaveHeight', 650, @(x) isempty(x) || isnumeric(x)); +addParameter(p, 'SaveDPI', 150, @isnumeric); +parse(p, result, dataA, dataB, varargin{:}); + +% --- Per-subject node layouts, aligned to the dyad's matrix rows/cols --- +useROI = iField(result, 'useROI', false); +chA = iField(result, 'channelsA', []); +chB = iField(result, 'channelsB', []); +if isempty(chA), chA = (1:size(result.values, 1))'; end +if isempty(chB) + if strcmpi(char(string(iField(result, 'pairing', 'same'))), 'all') + chB = (1:size(result.values, 2))'; + else + chB = chA; + end +end +PA = iNodePositions(dataA, chA, useROI); % [Na x 2] +PB = iNodePositions(dataB, chB, useROI); % [Nb x 2] + +% Offset subject B to the right of subject A +spanA = max(PA(:,1)) - min(PA(:,1)); +if ~isfinite(spanA) || spanA == 0, spanA = 1; end +offset = (max(PA(:,1)) - min(PB(:,1))) + p.Results.Gap * spanA; +PBoff = PB + [offset, 0]; + +% --- Edge list (ai/bj are LOCAL indices into PA/PB) --- +[ai, bj, w, pv] = iEdgeList(result); +keep = isfinite(w) & abs(w) >= p.Results.Threshold; +if p.Results.SignificanceMask + if isempty(pv) + warning('exploreFNIRS:hyperscanning:plotDualBrain:noPValues', ... + 'SignificanceMask is true but result has no p-values; mask ignored.'); + else + keep = keep & (pv < p.Results.PThreshold); + end +end +ai = ai(keep); bj = bj(keep); w = w(keep); +if ~isempty(pv), pv = pv(keep); end %#ok +if ~isempty(p.Results.TopN) && numel(w) > p.Results.TopN + [~, order] = sort(abs(w), 'descend'); + sel = order(1:round(p.Results.TopN)); + ai = ai(sel); bj = bj(sel); w = w(sel); +end + +% --- Color scale + colormap --- +clim = p.Results.CLim; +if isempty(clim) + m = max(abs(w), [], 'omitnan'); + if isempty(m) || ~isfinite(m) || m == 0, m = 1; end + clim = [-m, m]; +end +clim = sort(clim); +ecmap = p.Results.EdgeColormap; +if isempty(ecmap) + ecmap = iDivergingMap(256); +elseif ischar(ecmap) || isstring(ecmap) + ecmap = feval(char(ecmap), 256); +end + +method = iField(result, 'method', ''); +biomarker = iField(result, 'biomarker', ''); +titleStr = char(p.Results.Title); +if isempty(titleStr) + bits = strtrim(strjoin({char(string(method)), char(string(biomarker))}, ' ')); + titleStr = strtrim(sprintf('Inter-brain synchrony %s', bits)); +end +cbarStr = char(p.Results.Colorbar); +if isempty(cbarStr) + if isempty(method), cbarStr = 'coupling'; + else, cbarStr = char(string(method)); end +end + +% --- Figure / layout --- +fig = figure('Visible', char(p.Results.Visible), 'Color', 'w'); +hasWcoh = ~isempty(p.Results.Wcoherence); +if hasWcoh + tl = tiledlayout(fig, 3, 1, 'TileSpacing', 'compact', 'Padding', 'compact'); + axBrain = nexttile(tl, 1, [2 1]); + axTF = nexttile(tl, 3); +else + axBrain = axes(fig); %#ok + axTF = []; +end + +% --- Draw the dual-brain panel --- +hold(axBrain, 'on'); +axis(axBrain, 'equal'); +axis(axBrain, 'off'); +title(axBrain, titleStr); + +% Faint full-probe context +scatter(axBrain, PA(:,1), PA(:,2), p.Results.NodeSize*0.6, [0.75 0.75 0.75], ... + 'filled', 'MarkerFaceAlpha', 0.4); +scatter(axBrain, PBoff(:,1), PBoff(:,2), p.Results.NodeSize*0.6, [0.75 0.75 0.75], ... + 'filled', 'MarkerFaceAlpha', 0.4); + +% Edges (weak first so strong edges sit on top) +[~, eorder] = sort(abs(w), 'ascend'); +wr = p.Results.EdgeWidthRange; +span = clim(2) - clim(1); if span == 0, span = 1; end +nc = size(ecmap, 1); +maxAbs = max(abs(w), [], 'omitnan'); if isempty(maxAbs) || maxAbs == 0, maxAbs = 1; end +for e = eorder(:)' + cn = (w(e) - clim(1)) / span; + ci = max(1, min(nc, round(cn * (nc - 1)) + 1)); + lw = wr(1) + (wr(2) - wr(1)) * (abs(w(e)) / maxAbs); + plot(axBrain, [PA(ai(e),1), PBoff(bj(e),1)], [PA(ai(e),2), PBoff(bj(e),2)], ... + '-', 'Color', ecmap(ci, :), 'LineWidth', lw); +end + +% Connected nodes emphasized +usedA = unique(ai); usedB = unique(bj); +scatter(axBrain, PA(usedA,1), PA(usedA,2), p.Results.NodeSize, [0.2 0.3 0.8], ... + 'filled', 'MarkerEdgeColor', 'k'); +scatter(axBrain, PBoff(usedB,1), PBoff(usedB,2), p.Results.NodeSize, [0.8 0.3 0.2], ... + 'filled', 'MarkerEdgeColor', 'k'); + +% Subject labels above each layout +text(axBrain, mean(PA(:,1)), max([PA(:,2); PBoff(:,2)]) + 0.12*spanA, ... + p.Results.BrainLabels{1}, 'HorizontalAlignment', 'center', ... + 'FontWeight', 'bold'); +text(axBrain, mean(PBoff(:,1)), max([PA(:,2); PBoff(:,2)]) + 0.12*spanA, ... + p.Results.BrainLabels{2}, 'HorizontalAlignment', 'center', ... + 'FontWeight', 'bold'); + +colormap(axBrain, ecmap); +set(axBrain, 'CLim', clim); +cb = colorbar(axBrain); +cb.Label.String = cbarStr; + +% --- Optional wavelet-coherence panel --- +if hasWcoh + iDrawWcoherence(axTF, p.Results.Wcoherence); +end + +% --- Save --- +if ~isempty(char(p.Results.SavePath)) + pf2_base.plot.saveFigure(fig, char(p.Results.SavePath), ... + p.Results.SaveWidth, p.Results.SaveHeight, p.Results.SaveDPI); +end + +if nargout == 0 + clear fig; +end + +end + +%%_Subfunctions_________________________________________________________ + +function P = iNodePositions(data, channels, useROI) +% Resolve a subject's 2D node positions aligned to the dyad's matrix order. +% Channel mode: the optode 2D layout indexed by channel number. ROI mode: +% the centroid of each ROI's member optodes (from data.ROI.info). +probeInfo = pf2_base.plot.loadProbeInfo(data, true); +allP = [probeInfo.OptPos.x_2d(:), probeInfo.OptPos.y_2d(:)]; +channels = channels(:); +if useROI + if ~isfield(data, 'ROI') || ~isfield(data.ROI, 'info') + error('exploreFNIRS:hyperscanning:plotDualBrain:noROI', ... + 'ROI dyad requires data.ROI.info to place ROI nodes.'); + end + opt = data.ROI.info.Optodes; + P = nan(numel(channels), 2); + for r = 1:numel(channels) + idx = channels(r); + if idx >= 1 && idx <= numel(opt) + members = opt{idx}; + members = members(members >= 1 & members <= size(allP, 1)); + if ~isempty(members) + P(r, :) = mean(allP(members, :), 1, 'omitnan'); + end + end + end +else + valid = channels >= 1 & channels <= size(allP, 1); + if ~all(valid) + error('exploreFNIRS:hyperscanning:plotDualBrain:badChannels', ... + 'Dyad channel indices exceed the probe optode count (%d).', ... + size(allP, 1)); + end + P = allP(channels, :); +end +end + +function [ai, bj, w, pv] = iEdgeList(result) +% Build cross-brain edges as LOCAL indices (1..Na, 1..Nb) into each subject's +% node table, plus weights and p-values. Branches on the recorded pairing +% (not matrix shape) so singleton 'all' results stay 2D. +vals = result.values; +pvals = iField(result, 'pvalues', []); +pairing = lower(char(string(iField(result, 'pairing', 'same')))); + +if strcmp(pairing, 'all') + Na = numel(iField(result, 'channelsA', (1:size(vals, 1))')); + Nb = numel(iField(result, 'channelsB', (1:size(vals, 2))')); + vals = reshape(vals, Na, Nb); + [I, J] = ndgrid(1:Na, 1:Nb); + ai = I(:); bj = J(:); w = vals(:); + if ~isempty(pvals), pv = reshape(pvals, Na, Nb); pv = pv(:); else, pv = []; end +else + % 'same' pairing: matched channel pairs, edge k connects A(k)-B(k) + w = vals(:); + n = numel(w); + ai = (1:n)'; bj = (1:n)'; + if ~isempty(pvals), pv = pvals(:); else, pv = []; end +end +ai = ai(:); bj = bj(:); w = w(:); +if ~isempty(pv), pv = pv(:); end +end + +function iDrawWcoherence(ax, wc) +% Draw a wavelet-coherence time-frequency heatmap with cone of influence. +if ~isfield(wc, 'wcoh') + title(ax, 'Wcoherence struct missing .wcoh'); + return; +end +t = iField(wc, 'times', 1:size(wc.wcoh, 2)); +f = iField(wc, 'freqs', 1:size(wc.wcoh, 1)); +imagesc(ax, t, f, wc.wcoh); +set(ax, 'YDir', 'normal'); +try, set(ax, 'YScale', 'log'); catch, end +colormap(ax, parula(256)); +set(ax, 'CLim', [0 1]); +cb = colorbar(ax); +cb.Label.String = 'coherence'; +xlabel(ax, 'Time (s)'); +ylabel(ax, 'Frequency (Hz)'); +title(ax, 'Wavelet coherence'); +if isfield(wc, 'coi') && ~isempty(wc.coi) + hold(ax, 'on'); + plot(ax, t, wc.coi(:)', 'w--', 'LineWidth', 1); + hold(ax, 'off'); +end +end + +function cmap = iDivergingMap(n) +% Blue-white-red diverging colormap with n rows. +half = floor(n / 2); +top = ones(half, 1); +ramp = linspace(0, 1, half)'; +lower = [ramp, ramp, top]; +upper = [top, flipud(ramp), flipud(ramp)]; +cmap = [lower; 1 1 1; upper]; +if size(cmap, 1) ~= n + xi = linspace(1, size(cmap, 1), n); + cmap = interp1(1:size(cmap, 1), cmap, xi); +end +cmap = max(0, min(1, cmap)); +end + +function v = iField(s, name, default) +if isfield(s, name) && ~isempty(s.(name)) + v = s.(name); +else + v = default; +end +end diff --git a/+exploreFNIRS/+hyperscanning/plotDyadMatrix.m b/+exploreFNIRS/+hyperscanning/plotDyadMatrix.m new file mode 100644 index 00000000..4f146139 --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/plotDyadMatrix.m @@ -0,0 +1,200 @@ +function fig = plotDyadMatrix(result, varargin) +% PLOTDYADMATRIX Dyad-level coupling heatmap +% +% Displays a heatmap with channels on the Y-axis and dyads on the X-axis. +% Each column shows one dyad's coupling values, providing an overview of +% inter-subject variability across dyads and channels. +% +% Syntax: +% fig = exploreFNIRS.hyperscanning.plotDyadMatrix(result) +% fig = exploreFNIRS.hyperscanning.plotDyadMatrix(result, 'SortDyads', 'mean') +% fig = exploreFNIRS.hyperscanning.plotDyadMatrix(result, 'CLim', [-0.5, 0.5]) +% +% Inputs: +% result - Struct from computeGroup with fields: +% .dyads - Cell array of dyad result structs, each with .values +% .channels - Channel indices +% .method, .biomarker +% +% Name-Value Parameters: +% SortDyads - Sort dyad columns: 'none' (default) or 'mean' (by mean coupling) +% SortChannels - Sort channel rows: 'index' (default) or 'mean' (by mean coupling) +% CLim - Color limits [cmin cmax] (default: [-1, 1]) +% Colormap - Colormap name or matrix (default: diverging blue-white-red) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 700) +% SaveHeight - Height in pixels (default: 500) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.hyperscanning.computeGroup, +% exploreFNIRS.hyperscanning.plotGroup + + p = inputParser; + addRequired(p, 'result', @isstruct); + addParameter(p, 'SortDyads', 'none', @ischar); + addParameter(p, 'SortChannels', 'index', @ischar); + addParameter(p, 'CLim', [-1, 1], @(v) isnumeric(v) && length(v) == 2); + addParameter(p, 'Colormap', '', @(v) ischar(v) || isnumeric(v)); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 700, @isnumeric); + addParameter(p, 'SaveHeight', 500, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + % Validate input + if ~isfield(result, 'dyads') || isempty(result.dyads) + error('exploreFNIRS:hyperscanning:plotDyadMatrix', ... + 'Result must have .dyads cell array from computeGroup.'); + end + + nDyads = length(result.dyads); + + % Determine number of channels from first dyad + firstDyad = result.dyads{1}; + nCh = length(firstDyad.values(:)); + + % Build [nCh x nDyads] matrix + mat = zeros(nCh, nDyads); + for d = 1:nDyads + vals = result.dyads{d}.values(:); + if length(vals) == nCh + mat(:, d) = vals; + else + % Pad or truncate to match + n = min(nCh, length(vals)); + mat(1:n, d) = vals(1:n); + end + end + + % Channel labels + if isfield(result, 'channels') + channels = result.channels(:)'; + chLabels = arrayfun(@(c) sprintf('Ch%d', c), channels, ... + 'UniformOutput', false); + else + chLabels = arrayfun(@(c) sprintf('Ch%d', c), 1:nCh, ... + 'UniformOutput', false); + end + + % Dyad labels + dyadLabels = cell(1, nDyads); + for d = 1:nDyads + dyadLabels{d} = sprintf('D%d', d); + end + + % Sort dyads by mean coupling if requested + dyadOrder = 1:nDyads; + if strcmpi(opts.SortDyads, 'mean') + dyadMeans = mean(mat, 1, 'omitnan'); + [~, dyadOrder] = sort(dyadMeans, 'descend'); + mat = mat(:, dyadOrder); + dyadLabels = dyadLabels(dyadOrder); + end + + % Sort channels by mean coupling if requested + chOrder = 1:nCh; + if strcmpi(opts.SortChannels, 'mean') + chMeans = mean(mat, 2, 'omitnan'); + [~, chOrder] = sort(chMeans, 'descend'); + mat = mat(chOrder, :); + chLabels = chLabels(chOrder); + end + + % Create figure + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'SavePath', opts.SavePath, 'Width', opts.SaveWidth, ... + 'Height', opts.SaveHeight); + ax = axes('Parent', fig); + + imagesc(ax, mat, opts.CLim); + + % Colormap + if isempty(opts.Colormap) + cmap = divergingColormap(256); + elseif ischar(opts.Colormap) + cmap = colormap(ax, opts.Colormap); + else + cmap = opts.Colormap; + end + colormap(ax, cmap); + + cb = colorbar(ax); + if isfield(result, 'method') + cb.Label.String = result.method; + else + cb.Label.String = 'Coupling'; + end + + % Axis labels + set(ax, 'XTick', 1:nDyads, 'XTickLabel', pf2_base.plot.escapeTeX(dyadLabels), 'XTickLabelRotation', 45); + set(ax, 'YTick', 1:nCh, 'YTickLabel', pf2_base.plot.escapeTeX(chLabels)); + + % Reduce label density for large matrices + if nDyads > 20 + tickStep = ceil(nDyads / 20); + ticks = 1:tickStep:nDyads; + set(ax, 'XTick', ticks, 'XTickLabel', pf2_base.plot.escapeTeX(dyadLabels(ticks))); + end + if nCh > 20 + tickStep = ceil(nCh / 20); + ticks = 1:tickStep:nCh; + set(ax, 'YTick', ticks, 'YTickLabel', pf2_base.plot.escapeTeX(chLabels(ticks))); + end + + xlabel(ax, 'Dyad'); + ylabel(ax, 'Channel'); + + % Title + if ~isempty(opts.Title) + title(ax, pf2_base.plot.escapeTeX(opts.Title)); + else + methodStr = ''; + bioStr = ''; + if isfield(result, 'method') + methodStr = result.method; + end + if isfield(result, 'biomarker') + bioStr = result.biomarker; + end + title(ax, pf2_base.plot.escapeTeX(sprintf('Dyad Coupling (%s, %s, N=%d)', methodStr, bioStr, nDyads))); + end + + % Apply style + sty = pf2_base.plot.PlotStyle.getDefault(); + sty.applyToAxes(ax); + + % Save + pf2_base.plot.handleSave(fig, opts); + +end + + +function cmap = divergingColormap(n) +% Blue-white-red diverging colormap + half = floor(n / 2); + + % Blue to white + r1 = linspace(0.2, 1, half)'; + g1 = linspace(0.3, 1, half)'; + b1 = linspace(0.8, 1, half)'; + + % White to red + r2 = linspace(1, 0.8, n - half)'; + g2 = linspace(1, 0.2, n - half)'; + b2 = linspace(1, 0.2, n - half)'; + + cmap = [r1 g1 b1; r2 g2 b2]; +end diff --git a/+exploreFNIRS/+hyperscanning/plotGroup.m b/+exploreFNIRS/+hyperscanning/plotGroup.m new file mode 100644 index 00000000..b747b076 --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/plotGroup.m @@ -0,0 +1,261 @@ +function fig = plotGroup(result, varargin) +% PLOTGROUP Group-level hyperscanning coupling bar chart +% +% Displays mean coupling per channel with SEM error bars for group-level +% hyperscanning results. Supports significance markers from permutation +% tests and comparison of multiple groups or blocks. +% +% Syntax: +% fig = exploreFNIRS.hyperscanning.plotGroup(result) +% fig = exploreFNIRS.hyperscanning.plotGroup(result, 'ShowSignificance', true) +% fig = exploreFNIRS.hyperscanning.plotGroup(blockResults) % from Blocks +% +% Inputs: +% result - Struct from computeGroup or Experiment.hyperscanning() with: +% .Mean, .SEM, .channels, .method, .biomarker +% OR struct array from block-wise hyperscanning with: +% .blockInfo, .coupling (each containing group result) +% +% Name-Value Parameters: +% ShowSignificance - Show significance stars from p-values (default: true) +% PThreshold - Significance threshold (default: 0.05) +% ChannelLabels - Custom channel labels (default: 'Ch1', 'Ch2', ...) +% BarWidth - Bar width (default: 0.7) +% Colors - Custom color matrix [nGroups x 3] (default: auto) +% ShowZeroLine - Show horizontal line at y=0 (default: true) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 800) +% SaveHeight - Height in pixels (default: 450) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.hyperscanning.computeGroup, +% exploreFNIRS.hyperscanning.permutationTest + + p = inputParser; + addRequired(p, 'result'); + addParameter(p, 'ShowSignificance', true, @islogical); + addParameter(p, 'PThreshold', 0.05, @isnumeric); + addParameter(p, 'ChannelLabels', {}, @iscell); + addParameter(p, 'BarWidth', 0.7, @isnumeric); + addParameter(p, 'Colors', [], @isnumeric); + addParameter(p, 'ShowZeroLine', true, @islogical); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 800, @isnumeric); + addParameter(p, 'SaveHeight', 450, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + % Detect input type: block results vs single group result + isBlockResult = isstruct(result) && isfield(result, 'coupling'); + + if isBlockResult + plotBlockGroupBars(result, opts); + fig = gcf; + else + plotSingleGroup(result, opts); + fig = gcf; + end + +end + + +function plotSingleGroup(result, opts) +% Plot a single group result as bar chart + + meanVals = result.Mean(:)'; + semVals = result.SEM(:)'; + nCh = length(meanVals); + + if ~isempty(opts.ChannelLabels) + chLabels = opts.ChannelLabels; + elseif isfield(result, 'channels') + chLabels = arrayfun(@(c) sprintf('Ch%d', c), result.channels, ... + 'UniformOutput', false); + else + chLabels = arrayfun(@(c) sprintf('Ch%d', c), 1:nCh, ... + 'UniformOutput', false); + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + ax = axes('Parent', fig); + + if isempty(opts.Colors) + barColor = [0.3, 0.5, 0.8]; + else + barColor = opts.Colors(1, :); + end + + b = bar(ax, 1:nCh, meanVals, opts.BarWidth, 'FaceColor', barColor, ... + 'EdgeColor', 'none'); + hold(ax, 'on'); + + % Error bars + errorbar(ax, 1:nCh, meanVals, semVals, '.', 'Color', sty.ForegroundColor, ... + 'LineWidth', sty.AxisLineWidth, 'CapSize', 4); + + % Zero line + if opts.ShowZeroLine + plot(ax, [0.5, nCh + 0.5], [0, 0], '-', ... + 'Color', sty.ZeroLineColor, 'LineWidth', 0.5); + end + + % Significance stars + if opts.ShowSignificance && isfield(result, 'pvalue') + pvals = result.pvalue(:)'; + yMax = max(abs(meanVals) + semVals); + for ch = 1:nCh + if pvals(ch) < opts.PThreshold + starY = meanVals(ch) + semVals(ch) + yMax * 0.05; + if pvals(ch) < 0.001 + starTxt = '***'; + elseif pvals(ch) < 0.01 + starTxt = '**'; + else + starTxt = '*'; + end + text(ax, ch, starY, starTxt, ... + 'HorizontalAlignment', 'center', 'FontSize', 12); + end + end + end + + % Permutation test significance + if opts.ShowSignificance && isfield(result, 'permutation') + perm = result.permutation; + if isfield(perm, 'significant') + sigMask = perm.significant(:)'; + yMax = max(abs(meanVals) + semVals); + for ch = 1:nCh + if sigMask(ch) + starY = meanVals(ch) + semVals(ch) + yMax * 0.05; + text(ax, ch, starY, '*', ... + 'HorizontalAlignment', 'center', ... + 'FontSize', 14, 'Color', [0.8, 0, 0]); + end + end + end + end + + hold(ax, 'off'); + + set(ax, 'XTick', 1:nCh, 'XTickLabel', pf2_base.plot.escapeTeX(chLabels), 'XTickLabelRotation', 45); + xlabel(ax, 'Channel'); + ylabel(ax, 'Coupling'); + + if ~isempty(opts.Title) + title(ax, opts.Title); + else + titleStr = sprintf('Group Hyperscanning (%s, %s, N=%d)', ... + result.method, result.biomarker, max(result.N(:))); + title(ax, titleStr); + end + + box(ax, 'on'); + sty.applyToAxes(ax); + + pf2_base.plot.handleSave(fig, opts); + +end + + +function plotBlockGroupBars(blockResults, opts) +% Plot block-wise results as grouped bars + + nBlocks = length(blockResults); + + % Get channel info from first block + firstResult = blockResults(1).coupling; + if isfield(firstResult, 'channels') + nCh = length(firstResult.channels); + chLabels = arrayfun(@(c) sprintf('Ch%d', c), firstResult.channels, ... + 'UniformOutput', false); + else + nCh = length(firstResult.Mean); + chLabels = arrayfun(@(c) sprintf('Ch%d', c), 1:nCh, ... + 'UniformOutput', false); + end + + if ~isempty(opts.ChannelLabels) + chLabels = opts.ChannelLabels; + end + + % Build data matrix [nCh x nBlocks] + meanMat = zeros(nCh, nBlocks); + semMat = zeros(nCh, nBlocks); + blockLabels = cell(1, nBlocks); + + for b = 1:nBlocks + r = blockResults(b).coupling; + meanMat(:, b) = r.Mean(:); + semMat(:, b) = r.SEM(:); + + if isfield(blockResults(b).blockInfo, 'Condition') + blockLabels{b} = blockResults(b).blockInfo.Condition; + else + blockLabels{b} = sprintf('Block %d', blockResults(b).blockNumber); + end + end + + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight, ... + 'SavePath', opts.SavePath); + sty = pf2_base.plot.PlotStyle.getDefault(); + ax = axes('Parent', fig); + + b = bar(ax, meanMat, 'grouped'); + + if ~isempty(opts.Colors) && size(opts.Colors, 1) >= nBlocks + for k = 1:nBlocks + b(k).FaceColor = opts.Colors(k, :); + end + end + + % Error bars + hold(ax, 'on'); + nGroups = size(meanMat, 1); + groupWidth = min(0.8, nBlocks / (nBlocks + 1.5)); + for k = 1:nBlocks + xOff = (2 * k - nBlocks - 1) / (2 * nBlocks) * groupWidth; + errorbar(ax, (1:nGroups) + xOff, meanMat(:, k), semMat(:, k), ... + '.', 'Color', sty.ForegroundColor, 'LineWidth', 1, 'CapSize', 3); + end + + if opts.ShowZeroLine + plot(ax, [0.5, nGroups + 0.5], [0, 0], '-', ... + 'Color', sty.ZeroLineColor, 'LineWidth', 0.5); + end + hold(ax, 'off'); + + set(ax, 'XTick', 1:nGroups, 'XTickLabel', pf2_base.plot.escapeTeX(chLabels), 'XTickLabelRotation', 45); + xlabel(ax, 'Channel'); + ylabel(ax, 'Coupling'); + legend(ax, pf2_base.plot.escapeTeX(blockLabels), 'Location', 'best'); + + if ~isempty(opts.Title) + title(ax, opts.Title); + else + title(ax, sprintf('Hyperscanning by Block (%s)', firstResult.method)); + end + + box(ax, 'on'); + sty.applyToAxes(ax); + + pf2_base.plot.handleSave(fig, opts); + +end diff --git a/+exploreFNIRS/+hyperscanning/plotGroupTemporal.m b/+exploreFNIRS/+hyperscanning/plotGroupTemporal.m new file mode 100644 index 00000000..e9e7877d --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/plotGroupTemporal.m @@ -0,0 +1,239 @@ +function fig = plotGroupTemporal(result, varargin) +% PLOTGROUPTEMPORAL Time-resolved group hyperscanning coupling +% +% Plots the mean coupling across dyads over time with shaded error bands. +% Requires windowed coupling results from computeGroup where dyads have +% .windowed=true and .windowTimes fields. Optionally shades time windows +% where coupling is statistically significant. +% +% Syntax: +% fig = exploreFNIRS.hyperscanning.plotGroupTemporal(result) +% fig = exploreFNIRS.hyperscanning.plotGroupTemporal(result, 'ErrorType', 'SD') +% fig = exploreFNIRS.hyperscanning.plotGroupTemporal(result, 'Channels', [1 3 5]) +% +% Inputs: +% result - Struct from computeGroup where dyads have windowed=true: +% .dyads{d}.values - [nWin x nCh] windowed coupling values +% .dyads{d}.windowTimes - [nWin x 1] time vector for windows +% .dyads{d}.windowed - true +% .channels, .method, .biomarker +% +% Name-Value Parameters: +% Channels - Which channels to average across (default: all) +% ErrorType - Error band type: 'SEM' (default), 'SD', 'none' +% ShowSignificance - Shade significant time windows (default: false) +% PThreshold - Significance threshold (default: 0.05) +% LineColor - Main line color [r g b] (default: [0.2, 0.4, 0.7]) +% FillColor - Error band color [r g b] (default: same as LineColor) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 800) +% SaveHeight - Height in pixels (default: 450) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.hyperscanning.computeGroup, +% exploreFNIRS.hyperscanning.plotGroup + + p = inputParser; + addRequired(p, 'result', @isstruct); + addParameter(p, 'Channels', [], @(v) isempty(v) || isnumeric(v)); + addParameter(p, 'ErrorType', 'SEM', @ischar); + addParameter(p, 'ShowSignificance', false, @islogical); + addParameter(p, 'PThreshold', 0.05, @isnumeric); + addParameter(p, 'LineColor', [0.2, 0.4, 0.7], @(v) isnumeric(v) && length(v) == 3); + addParameter(p, 'FillColor', [], @(v) isempty(v) || (isnumeric(v) && length(v) == 3)); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 800, @isnumeric); + addParameter(p, 'SaveHeight', 450, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + if isempty(opts.FillColor) + opts.FillColor = opts.LineColor; + end + + % Validate windowed data + if ~isfield(result, 'dyads') || isempty(result.dyads) + error('exploreFNIRS:hyperscanning:plotGroupTemporal', ... + 'Result must have .dyads cell array from computeGroup.'); + end + + firstDyad = result.dyads{1}; + if ~isfield(firstDyad, 'windowed') || ~firstDyad.windowed + error('exploreFNIRS:hyperscanning:plotGroupTemporal', ... + ['Dyad results must be windowed (windowed=true). ' ... + 'Note: computeGroup collapses windowed coupling to scalar means, ' ... + 'so its output cannot be used here. To get windowed group data, ' ... + 'call computeDyad directly with a windowed coupling method ' ... + '(e.g., ''CouplingArgs'', {''WindowSize'', 10}) for each dyad, ' ... + 'then pass the collected results to this function.']); + end + + if ~isfield(firstDyad, 'windowTimes') + error('exploreFNIRS:hyperscanning:plotGroupTemporal', ... + 'Dyad results must have .windowTimes field.'); + end + + nDyads = length(result.dyads); + timeVec = firstDyad.windowTimes(:); + nWin = length(timeVec); + + % Determine channel subset + if isempty(opts.Channels) + if isfield(result, 'channels') + chIdx = 1:length(result.channels); + else + nCh = size(firstDyad.values, 2); + chIdx = 1:nCh; + end + else + if isfield(result, 'channels') + [~, chIdx] = ismember(opts.Channels, result.channels); + chIdx(chIdx == 0) = []; + else + chIdx = opts.Channels; + end + end + + % For each dyad, average values across selected channels to get [nWin x 1] + dyadTimeSeries = zeros(nWin, nDyads); + for d = 1:nDyads + dyad = result.dyads{d}; + vals = dyad.values; % [nWin x nCh] + + % Handle case where values might be column vector (single channel) + if isvector(vals) + vals = vals(:); + end + + % Select channels and average + if size(vals, 2) >= max(chIdx) + selectedVals = vals(:, chIdx); + else + selectedVals = vals; + end + + dyadTimeSeries(:, d) = mean(selectedVals, 2, 'omitnan'); + end + + % Compute group mean and error across dyads + groupMean = mean(dyadTimeSeries, 2, 'omitnan'); + groupSD = std(dyadTimeSeries, 0, 2, 'omitnan'); + groupSEM = groupSD / sqrt(nDyads); + + switch upper(opts.ErrorType) + case 'SEM' + errorVals = groupSEM; + case 'SD' + errorVals = groupSD; + case 'NONE' + errorVals = zeros(nWin, 1); + otherwise + errorVals = groupSEM; + end + + % Get style + sty = pf2_base.plot.PlotStyle.getDefault(); + + % Create figure + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'SavePath', opts.SavePath, 'Width', opts.SaveWidth, ... + 'Height', opts.SaveHeight); + ax = axes('Parent', fig); + hold(ax, 'on'); + + % Shaded error band + if ~strcmpi(opts.ErrorType, 'none') + upperBound = groupMean + errorVals; + lowerBound = groupMean - errorVals; + + fillX = [timeVec; flipud(timeVec)]; + fillY = [upperBound; flipud(lowerBound)]; + + fill(ax, fillX, fillY, opts.FillColor, ... + 'FaceAlpha', sty.ErrorAlpha, 'EdgeColor', 'none'); + end + + % Main line + plot(ax, timeVec, groupMean, '-', 'Color', opts.LineColor, ... + 'LineWidth', sty.LineWidth); + + % Significance shading + if opts.ShowSignificance + % Perform one-sample t-test at each time window + for w = 1:nWin + vals = dyadTimeSeries(w, :); + if nDyads >= 3 + [~, pVal] = pf2_base.compat.ttest(vals); + else + pVal = 1; + end + + if pVal < opts.PThreshold + % Shade this window + wStart = timeVec(w); + if w < nWin + wEnd = timeVec(w + 1); + elseif nWin > 1 + wEnd = timeVec(w) + (timeVec(w) - timeVec(w - 1)); + else + wEnd = wStart + 1; + end + yRange = ylim(ax); + patch(ax, [wStart, wEnd, wEnd, wStart], ... + [yRange(1), yRange(1), yRange(2), yRange(2)], ... + [0.9, 0.8, 0.2], 'FaceAlpha', 0.15, 'EdgeColor', 'none'); + end + end + end + + % Zero line + plot(ax, [timeVec(1), timeVec(end)], [0, 0], '-', ... + 'Color', sty.ZeroLineColor, 'LineWidth', 0.5); + + hold(ax, 'off'); + + xlabel(ax, 'Time (s)'); + ylabel(ax, 'Coupling'); + + % Title + if ~isempty(opts.Title) + title(ax, opts.Title); + else + methodStr = ''; + bioStr = ''; + if isfield(result, 'method') + methodStr = result.method; + end + if isfield(result, 'biomarker') + bioStr = result.biomarker; + end + errLabel = ''; + if ~strcmpi(opts.ErrorType, 'none') + errLabel = sprintf(', %s', upper(opts.ErrorType)); + end + title(ax, pf2_base.plot.escapeTeX(sprintf('Temporal Coupling (%s, %s, N=%d%s)', ... + methodStr, bioStr, nDyads, errLabel))); + end + + box(ax, 'on'); + + % Apply style + sty.applyToAxes(ax); + + % Save + pf2_base.plot.handleSave(fig, opts); + +end diff --git a/+exploreFNIRS/+hyperscanning/plotHBICA.m b/+exploreFNIRS/+hyperscanning/plotHBICA.m new file mode 100644 index 00000000..36303c92 --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/plotHBICA.m @@ -0,0 +1,177 @@ +function fig = plotHBICA(result, varargin) +% PLOTHBICA Visualize HB-ICA decomposition results +% +% Creates a multi-panel figure showing: (1) GOF bar chart with inter-brain +% classification, (2) spatial mixing weight patterns for both subjects, +% and (3) source time courses for inter-brain components. +% +% Syntax: +% fig = exploreFNIRS.hyperscanning.plotHBICA(result) +% fig = exploreFNIRS.hyperscanning.plotHBICA(result, 'Components', 1:3) +% fig = exploreFNIRS.hyperscanning.plotHBICA(result, 'ShowIntraBrain', true) +% fig = exploreFNIRS.hyperscanning.plotHBICA(result, 'SavePath', 'hbica.png') +% +% Inputs: +% result - Struct from exploreFNIRS.hyperscanning.hbica with fields: +% .sources, .mixingMatrix, .GOF, .isInterBrain, .channelsA, +% .channelsB, .nComponents, .fs, .sourcesA, .sourcesB, +% .mixingA, .mixingB +% +% Name-Value Parameters: +% Components - Component indices to display (default: inter-brain only) +% ShowIntraBrain - Include intra-brain components (default: false) +% MaxComponents - Maximum components to show (default: 6) +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 1000) +% SaveHeight - Height in pixels (default: 700) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.hyperscanning.hbica, +% exploreFNIRS.hyperscanning.plotGroup + + p = inputParser; + addRequired(p, 'result', @isstruct); + addParameter(p, 'Components', [], @isnumeric); + addParameter(p, 'ShowIntraBrain', false, @islogical); + addParameter(p, 'MaxComponents', 6, @(v) isnumeric(v) && isscalar(v)); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 1000, @isnumeric); + addParameter(p, 'SaveHeight', 700, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + % Determine which components to show + if ~isempty(opts.Components) + compIdx = opts.Components; + elseif opts.ShowIntraBrain + compIdx = 1:result.nComponents; + else + compIdx = result.interBrainIdx(:)'; + if isempty(compIdx) + compIdx = 1:min(3, result.nComponents); + end + end + compIdx = compIdx(compIdx <= result.nComponents); + if length(compIdx) > opts.MaxComponents + compIdx = compIdx(1:opts.MaxComponents); + end + nShow = length(compIdx); + + K = result.nComponents; + Ca = length(result.channelsA); + Cb = length(result.channelsB); + fs = result.fs; + + % Create figure + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'Width', opts.SaveWidth, 'Height', opts.SaveHeight); + sty = pf2_base.plot.PlotStyle.getDefault(); + + if isempty(opts.Title) + nIB = sum(result.isInterBrain); + titleStr = sprintf('HB-ICA: %d inter-brain / %d components (%s)', ... + nIB, K, result.biomarker); + else + titleStr = opts.Title; + end + + % Layout: top row = GOF bar chart (full width) + % bottom rows = one row per component (mixing A | source | mixing B) + nRows = 1 + nShow; + if nShow == 0 + nRows = 1; + end + + % Panel 1: GOF bar chart (spans full width = 3 columns) + ax1 = subplot(nRows, 3, 1:3, 'Parent', fig); + barColors = zeros(K, 3); + for k = 1:K + if result.isInterBrain(k) + barColors(k,:) = [0.85, 0.33, 0.10]; % Orange for inter-brain + else + barColors(k,:) = [0.30, 0.60, 0.90]; % Blue for intra-brain + end + end + + bh = bar(ax1, 1:K, result.GOF, 'FaceColor', 'flat'); + bh.CData = barColors; + + hold(ax1, 'on'); + % GOF threshold line + yline(ax1, 0, '--', 'Color', [0.5 0.5 0.5], 'LineWidth', 1); + hold(ax1, 'off'); + + xlabel(ax1, 'Component', 'FontSize', sty.FontSize); + ylabel(ax1, 'GOF Index', 'FontSize', sty.FontSize); + title(ax1, titleStr, 'FontSize', sty.FontSize + 1); + set(ax1, 'FontSize', sty.FontSize); + xlim(ax1, [0.5, K + 0.5]); + + % Mark inter-brain components + if any(result.isInterBrain) + hold(ax1, 'on'); + ibIdx = find(result.isInterBrain); + for ii = 1:length(ibIdx) + text(ax1, ibIdx(ii), result.GOF(ibIdx(ii)) - 0.05, '*', ... + 'HorizontalAlignment', 'center', 'FontSize', 14, ... + 'Color', [0.85, 0.33, 0.10], 'FontWeight', 'bold'); + end + hold(ax1, 'off'); + end + + % Panels for each component: mixing weights + source time course + for iComp = 1:nShow + k = compIdx(iComp); + rowIdx = iComp + 1; + + % Mixing weights for subject A + ax_mA = subplot(nRows, 3, (rowIdx-1)*3 + 1, 'Parent', fig); + wA = result.mixingA(:, k); + bar(ax_mA, 1:Ca, wA, 'FaceColor', [0.30, 0.60, 0.90]); + xlabel(ax_mA, 'Ch (A)', 'FontSize', sty.FontSize - 1); + ylabel(ax_mA, 'Weight', 'FontSize', sty.FontSize - 1); + if result.isInterBrain(k) + compLabel = sprintf('IC%d (inter)', k); + else + compLabel = sprintf('IC%d (intra)', k); + end + title(ax_mA, ['A: ' compLabel], 'FontSize', sty.FontSize - 1); + set(ax_mA, 'FontSize', sty.FontSize - 1); + + % Source time course + ax_src = subplot(nRows, 3, (rowIdx-1)*3 + 2, 'Parent', fig); + T = size(result.sources, 1); + timeVec = (0:T-1)' / fs; + plot(ax_src, timeVec, result.sources(:, k), ... + 'Color', barColors(k,:), 'LineWidth', sty.LineWidth * 0.8); + xlabel(ax_src, 'Time (s)', 'FontSize', sty.FontSize - 1); + ylabel(ax_src, 'Source', 'FontSize', sty.FontSize - 1); + title(ax_src, sprintf('IC%d (GOF=%.2f)', k, result.GOF(k)), ... + 'FontSize', sty.FontSize - 1); + set(ax_src, 'FontSize', sty.FontSize - 1); + + % Mixing weights for subject B + ax_mB = subplot(nRows, 3, (rowIdx-1)*3 + 3, 'Parent', fig); + wB = result.mixingB(:, k); + bar(ax_mB, 1:Cb, wB, 'FaceColor', [0.85, 0.33, 0.10]); + xlabel(ax_mB, 'Ch (B)', 'FontSize', sty.FontSize - 1); + ylabel(ax_mB, 'Weight', 'FontSize', sty.FontSize - 1); + title(ax_mB, ['B: ' compLabel], 'FontSize', sty.FontSize - 1); + set(ax_mB, 'FontSize', sty.FontSize - 1); + end + + pf2_base.plot.handleSave(fig, opts); +end diff --git a/+exploreFNIRS/+hyperscanning/plotInterBrainTopo.m b/+exploreFNIRS/+hyperscanning/plotInterBrainTopo.m new file mode 100644 index 00000000..b7addf34 --- /dev/null +++ b/+exploreFNIRS/+hyperscanning/plotInterBrainTopo.m @@ -0,0 +1,219 @@ +function fig = plotInterBrainTopo(result, varargin) +% PLOTINTERBRAINTOPO Dual-brain topographic display with inter-brain coupling +% +% Shows two probe layouts (Subject A, Subject B) side-by-side with colored +% lines connecting coupled channel pairs. Line color encodes coupling +% strength and line width is proportional to absolute coupling value. +% +% Syntax: +% fig = exploreFNIRS.hyperscanning.plotInterBrainTopo(result) +% fig = exploreFNIRS.hyperscanning.plotInterBrainTopo(result, 'LineThreshold', 0.5) +% fig = exploreFNIRS.hyperscanning.plotInterBrainTopo(result, 'BrainLabels', {'Speaker','Listener'}) +% +% Inputs: +% result - Struct from computeGroup or computeDyad with fields: +% .Mean or .values - [nCh x 1] coupling values (same-channel pairing) +% .channels or .channelsA/.channelsB - channel indices +% .method, .biomarker +% +% Name-Value Parameters: +% LineThreshold - Minimum absolute coupling to draw a line (default: 0.3) +% BrainLabels - Cell array of two labels (default: {'Subject A','Subject B'}) +% CLim - Color limits [cmin cmax] for coupling lines (default: auto) +% Colormap - Colormap name or matrix for lines (default: 'hot') +% Title - Figure title (default: auto) +% Visible - 'on' (default) or 'off' +% SavePath - File path to save figure +% SaveWidth - Width in pixels (default: 800) +% SaveHeight - Height in pixels (default: 500) +% SaveDPI - Resolution (default: 150) +% +% Outputs: +% fig - Figure handle +% +% See also: exploreFNIRS.hyperscanning.computeGroup, +% exploreFNIRS.hyperscanning.computeDyad + + p = inputParser; + addRequired(p, 'result', @isstruct); + addParameter(p, 'LineThreshold', 0.3, @isnumeric); + addParameter(p, 'BrainLabels', {'Subject A', 'Subject B'}, @iscell); + addParameter(p, 'CLim', [], @(v) isempty(v) || (isnumeric(v) && length(v) == 2)); + addParameter(p, 'Colormap', 'hot', @(v) ischar(v) || isnumeric(v)); + addParameter(p, 'Title', '', @ischar); + addParameter(p, 'Visible', 'on', @ischar); + addParameter(p, 'SavePath', '', @ischar); + addParameter(p, 'SaveWidth', 800, @isnumeric); + addParameter(p, 'SaveHeight', 500, @isnumeric); + addParameter(p, 'SaveDPI', 150, @isnumeric); + addParameter(p, 'TightLayout', false, @islogical); + parse(p, result, varargin{:}); + opts = p.Results; + + if ~isempty(opts.SavePath) + opts.Visible = 'off'; + end + + % Extract coupling values + if isfield(result, 'Mean') + couplingVals = result.Mean(:); + elseif isfield(result, 'values') + couplingVals = result.values(:); + else + error('exploreFNIRS:hyperscanning:plotInterBrainTopo', ... + 'Result must have .Mean or .values field.'); + end + + nCh = length(couplingVals); + + % Extract channel indices + if isfield(result, 'channelsA') + channelsA = result.channelsA(:)'; + channelsB = result.channelsB(:)'; + elseif isfield(result, 'channels') + channelsA = result.channels(:)'; + channelsB = result.channels(:)'; + else + channelsA = 1:nCh; + channelsB = 1:nCh; + end + + % Generate grid positions for channel nodes + nCols = ceil(sqrt(nCh)); + nRows = ceil(nCh / nCols); + + % Compute node positions in grid + xA = zeros(nCh, 1); + yA = zeros(nCh, 1); + xB = zeros(nCh, 1); + yB = zeros(nCh, 1); + + xOffset = nCols + 2; % gap between left and right brain + + for ch = 1:nCh + row = ceil(ch / nCols); + col = ch - (row - 1) * nCols; + xA(ch) = col; + yA(ch) = nRows - row + 1; + xB(ch) = col + xOffset; + yB(ch) = nRows - row + 1; + end + + % Determine color limits + if isempty(opts.CLim) + absMax = max(abs(couplingVals)); + if absMax == 0 + absMax = 1; + end + cLim = [0, absMax]; + else + cLim = opts.CLim; + end + + % Build colormap + if ischar(opts.Colormap) + cmapFunc = str2func(opts.Colormap); + cmap = cmapFunc(256); + else + cmap = opts.Colormap; + end + nColors = size(cmap, 1); + + % Create figure + fig = pf2_base.plot.createFigure('Visible', opts.Visible, ... + 'SavePath', opts.SavePath, 'Width', opts.SaveWidth, ... + 'Height', opts.SaveHeight); + ax = axes('Parent', fig); + hold(ax, 'on'); + + % Draw coupling lines between brains + maxLineWidth = 4; + minLineWidth = 0.5; + + for ch = 1:nCh + val = couplingVals(ch); + if abs(val) < opts.LineThreshold + continue; + end + + % Map value to colormap index + normVal = (abs(val) - cLim(1)) / (cLim(2) - cLim(1)); + normVal = max(0, min(1, normVal)); + colorIdx = max(1, round(normVal * (nColors - 1)) + 1); + lineColor = cmap(colorIdx, :); + + % Line width proportional to absolute coupling + lineWidth = minLineWidth + (maxLineWidth - minLineWidth) * normVal; + + plot(ax, [xA(ch), xB(ch)], [yA(ch), yB(ch)], '-', ... + 'Color', [lineColor, 0.6], 'LineWidth', lineWidth); + end + + % Draw channel nodes - Subject A (left brain) + nodeSize = 60; + scatter(ax, xA, yA, nodeSize, [0.3, 0.5, 0.8], 'filled', ... + 'MarkerEdgeColor', 'k', 'LineWidth', 0.5); + for ch = 1:nCh + text(ax, xA(ch), yA(ch), sprintf('%d', channelsA(ch)), ... + 'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', ... + 'FontSize', 7, 'Color', 'w', 'FontWeight', 'bold'); + end + + % Draw channel nodes - Subject B (right brain) + scatter(ax, xB, yB, nodeSize, [0.8, 0.3, 0.3], 'filled', ... + 'MarkerEdgeColor', 'k', 'LineWidth', 0.5); + for ch = 1:nCh + text(ax, xB(ch), yB(ch), sprintf('%d', channelsB(ch)), ... + 'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', ... + 'FontSize', 7, 'Color', 'w', 'FontWeight', 'bold'); + end + + % Brain labels + labels = opts.BrainLabels; + text(ax, mean(xA), max(yA) + 0.8, labels{1}, ... + 'HorizontalAlignment', 'center', 'FontSize', 12, 'FontWeight', 'bold'); + text(ax, mean(xB), max(yB) + 0.8, labels{2}, ... + 'HorizontalAlignment', 'center', 'FontSize', 12, 'FontWeight', 'bold'); + + hold(ax, 'off'); + + % Colorbar + colormap(ax, cmap); + clim(ax, cLim); + cb = colorbar(ax); + if isfield(result, 'method') + cb.Label.String = result.method; + else + cb.Label.String = 'Coupling'; + end + + % Title + if ~isempty(opts.Title) + title(ax, opts.Title); + else + methodStr = 'coupling'; + bioStr = ''; + if isfield(result, 'method') + methodStr = result.method; + end + if isfield(result, 'biomarker') + bioStr = sprintf(', %s', result.biomarker); + end + title(ax, sprintf('Inter-Brain Coupling (%s%s)', methodStr, bioStr)); + end + + % Clean up axes + axis(ax, 'equal'); + set(ax, 'XTick', [], 'YTick', []); + xlim(ax, [min(xA) - 1, max(xB) + 1]); + ylim(ax, [min(yA) - 1, max(yA) + 1.5]); + box(ax, 'off'); + + % Apply style + sty = pf2_base.plot.PlotStyle.getDefault(); + sty.applyToAxes(ax); + + % Save + pf2_base.plot.handleSave(fig, opts); + +end diff --git a/+exploreFNIRS/+plot/barchart.m b/+exploreFNIRS/+plot/barchart.m index 0a425f28..a532a5c2 100644 --- a/+exploreFNIRS/+plot/barchart.m +++ b/+exploreFNIRS/+plot/barchart.m @@ -43,6 +43,7 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) % (No direct outputs - creates figures and populates ExFNIRS global) % % Global Variables Modified: +% ExFNIRS.curChartLMEResults - Full fitLME results struct % ExFNIRS.curChartModels - Cell array of fitted LME models % ExFNIRS.curChartModelsAIC - AIC values for model comparison % ExFNIRS.curChartModelsANOVA - ANOVA tables for each model @@ -50,7 +51,7 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) % ExFNIRS.curChartModelsCoefficents_tstat - t-statistics table % ExFNIRS.curChartModelsANOVACoefficents_pval - ANOVA p-values % ExFNIRS.curChartModelsANOVACoefficents_Fstat - F-statistics -% ExFNIRS.curMdlFits - Model fit p-values +% ExFNIRS.curMdlFits - Model fit test results % % Error Bar Options: % 'SEM' - Standard error of the mean @@ -79,6 +80,8 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) % See also: exploreFNIRS.plot.scatter, exploreFNIRS.plot.temporal, % exploreFNIRS.fx.performFDR, exploreFNIRS.fx.autoContrast +global setF % read-only here (device cfg name); bound once, not per loop iteration + curInfoGroup=exSettings.curInfoGroup; gbyVars_original=gbyVars; @@ -111,14 +114,14 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) if(strcmp(exSettings.ChannelMode,'Aux')) if(length(selectedBioM)>1) - error('Not supported yet!') + error('exploreFNIRS:plot:barchart:notSupported', 'Not supported yet!') end auxTable=get(handles.listbox_optode,'UserData'); selectedOpt=nan(length(selOpt)); for sI=1:length(selOpt) selectedOpt(sI)=auxTable{selectedBioM,selectedOptStr{sI}}; end - + else selectedOpt=selOpt; end @@ -279,35 +282,35 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) barChartData{1}=nan(numCurInfoG,1,5); barChartDataPoints{1}=cell(numCurInfoG,1); end - - - + + + chartGby=cell(size(subplotHandles)); - + %barChartTimes=times; - + gAStrs=cell(num2Plot,1); gAerrStrs=cell(num2Plot,1); - + for b=1:numBioM bioM=selectedBioM(b); if(iscell(bioM)) bioM=bioM{1}; end - - + + if(numUgroups>1) curChart=b; else curChart=1; end - + for g=1:numGroups - + curFNIRS=exGby(g).gbyFNIRS_blk; curGrand=exGby(g).gbyGrandBar; - - + + if(isfield(chartGby{curChart},'gby')) chartGby{curChart}.gby(end+1)=exGby(g); else @@ -319,8 +322,8 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) chartGby{curChart}.curBioM=selectedBioM(b); end end - - + + if(useCurInfoGroup) curGroupInfoIdx=uCurIdx(g); curGroupIdxOffset=(curGroupInfoIdx-1)*numChartTimes; @@ -330,43 +333,43 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) curGroupIdxOffset=0; curUgroupIdx=g; end - + if(isempty(curGrand)) if(plotGroupByBioM) barChartData{curChart}(:,b,1:3)=nan; - + else barChartData{curChart}(:,curUgroupIdx,1:3)=nan; end - + if(numUgroups>1||numBioM==1) gAStrs{curUgroupIdx,curChart}=sprintf('%s',gbyStrs{g}); elseif(numBioM>1&&~multiPlot) gAStrs{b,curChart}=sprintf('%s',selectedBioM{b}); end - + continue; end - + %if(exSettings.plot_bar_ga) [timeIdx,timeIdxRev]=ismember(round(curGrand.time),barChartTimes); timeIdxRev=timeIdxRev(timeIdxRev>0); - + switch(exSettings.ChannelMode) case 'fNIR' data2plot=curGrand.(bioM); case 'ROI' if(~pf2_base.isnestedfield(curGrand,'ROI.HbO.data')) - error('ROI data must be calculated using a build ROI step'); + error('exploreFNIRS:plot:barchart:roiNotBuilt', 'ROI data must be calculated using a build ROI step'); end - + data2plot=curGrand.ROI.(bioM); case 'Aux' data2plot=curGrand.Aux.(bioM); end - + plottingDataPoints=plotPoints; - + if(plotGroupByBioM) if(exSettings.plot_bar_ga) barChartData{curChart}(timeIdxRev+curGroupIdxOffset,b,1)=data2plot.(plotFeature)(timeIdx,ch); @@ -380,27 +383,27 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) else barChartData{curChart}(timeIdxRev+curGroupIdxOffset,curUgroupIdx,1)=nan(size(data2plot.(plotFeature)(timeIdx,ch))); end - + %barChartDataPoints{curChart}{timeIdxRev+curGroupIdxOffset,curUgroupIdx}=[]; end - + if(plotGroupByBioM&&(plotPoints||strcmp(errorFeature,'Violin'))) barChartDataPoints{curChart}(timeIdxRev+curGroupIdxOffset,b)={data2plot.data(timeIdx,ch,:)}; elseif(plotPoints||strcmp(errorFeature,'Violin')) barChartDataPoints{curChart}(timeIdxRev+curGroupIdxOffset,curUgroupIdx)={data2plot.data(timeIdx,ch,:)}; end - + if(numUgroups>1||numBioM==1) gAStrs{curUgroupIdx,curChart}=sprintf('%s',gbyStrs{g}); elseif(numBioM>1) gAStrs{b,curChart}=sprintf('%s',selectedBioM{b}); end %else - + %end - + if(exSettings.plot_bar_err&&~plotCount) - + errMultiply=exSettings.plot_bar_err_mult; [timeIdx,timeIdxRev]=ismember(round(curGrand.time),barChartTimes); timeIdxRev=timeIdxRev(timeIdxRev>0); @@ -408,77 +411,77 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) numErrFeatures=1; if(strcmp(errorFeature,'MaxMin')) numErrFeatures=2; %min and max) - + if(plotGroupByBioM) barChartData{curChart}(timeIdxRev+curGroupIdxOffset,b,2)=ga2plot.Min(timeIdx,ch); barChartData{curChart}(timeIdxRev+curGroupIdxOffset,b,3)=ga2plot.Max(timeIdx,ch); - + else barChartData{curChart}(timeIdxRev+curGroupIdxOffset,curUgroupIdx,2)=ga2plot.Min(timeIdx,ch); barChartData{curChart}(timeIdxRev+curGroupIdxOffset,curUgroupIdx,3)=ga2plot.Max(timeIdx,ch); end - - + + elseif(strcmp(errorFeature,'IQR')||strcmp(errorFeature,'IQR-NoOutliers')||strcmp(errorFeature,'Violin')) numErrFeatures=5; %min and max) and median - gaQuant=quantile(ga2plot.data(timeIdx,ch,:),3); + gaQuant=pf2_base.compat.quantile(ga2plot.data(timeIdx,ch,:),3); iqr=gaQuant(end)-gaQuant(1); - + gaPlotMin=min(ga2plot.Min(timeIdx,ch)); gaPlotMax=max(ga2plot.Max(timeIdx,ch)); - + if(contains(errorFeature,'IQR')) outlierMax=gaQuant(end)+errMultiply*iqr; outlierMin=gaQuant(1)-errMultiply*iqr; - - + + iqrPlotErrMin=max([gaPlotMin,outlierMin]); iqrPlotErrMax=min([gaPlotMax,outlierMax]); - + dataPlotMin_barchart=iqrPlotErrMin; dataPlotMax_barchart=iqrPlotErrMax; - + else numErrFeatures=4; dataPlotMin_barchart=gaPlotMin; dataPlotMax_barchart=gaPlotMax; end - - + + if(plotGroupByBioM) barChartData{curChart}(timeIdxRev+curGroupIdxOffset,b,2)=dataPlotMin_barchart; barChartData{curChart}(timeIdxRev+curGroupIdxOffset,b,3)=dataPlotMax_barchart; - + barChartData{curChart}(timeIdxRev+curGroupIdxOffset,b,4)=gaQuant(1); barChartData{curChart}(timeIdxRev+curGroupIdxOffset,b,5)=gaQuant(end); - + barChartData{curChart}(timeIdxRev+curGroupIdxOffset,b,6)=gaQuant(2); - + if(strcmp(errorFeature,'IQR')) dataPointsIdx=(ga2plot.data(timeIdx,ch,:)>iqrPlotErrMax|ga2plot.data(timeIdx,ch,:)iqrPlotErrMax|ga2plot.data(timeIdx,ch,:)1) curSubplotIdx=chIdx+(numOpt*(curChart-1)); - + numX=numBioM; numY=numOpt; subplotGby{curSubplotIdx}=chartGby{curChart}; - + else curSubplotIdx=chIdx+(numOpt*(curChart-1)); numX=1; @@ -539,11 +542,11 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) if(~showBarChart) continue; end - + subplotHandles{curSubplotIdx}=subplot(numX,numY,curSubplotIdx); subplotGby{curSubplotIdx}.barChartData=barChartData{curChart}; subplotGby{curSubplotIdx}.gAStrs=gAStrs(:,1); - + if(useCurInfoGroup&&numChartTimes>1) xBarLabels=cell(numChartTimes*numCurInfoG,1); timeStrs=num2str(round(barChartTimes)); @@ -560,18 +563,18 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) else xBarLabels=barChartTimeStrings; end - - + + subplotGby{curSubplotIdx}.xBarLabels=xBarLabels; - - + + if(exSettings.plot_bar_err&&~plotCount) - pf2_base.external.barweb(barChartData{curChart}(:,:,1),barChartData{curChart}(:,:,2:1+numErrFeatures),1,xBarLabels, 'ColorMap', cIndex,'Legend',gAStrs,'LegendType','hide','DataPoints',barChartDataPoints{curChart},'PlotViolin',strcmp(errorFeature,'Violin')); - - + pf2_base.external.barweb(barChartData{curChart}(:,:,1),barChartData{curChart}(:,:,2:1+numErrFeatures),0.8,xBarLabels, 'ColorMap', cIndex,'Legend',gAStrs,'LegendType','hide','DataPoints',barChartDataPoints{curChart},'PlotViolin',strcmp(errorFeature,'Violin')); + + if(plottingDataPoints||strcmp(errorFeature,'MaxMin')||strcmp(errorFeature,'IQR')||strcmp(errorFeature,'Violin')) [jSz,kSz]=size(barChartDataPoints{curChart}); - + for j=1:jSz for k=1:kSz if(~isempty(barChartDataPoints{curChart}{j,k})) @@ -580,38 +583,38 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) end end end - - + + elseif(strcmp(errorFeature,'IQR-NoOutliers')) minValFromBarChart=min(min(min(barChartData{curChart}(:,:,2)))); maxValFromBarChart=max(max(max(barChartData{curChart}(:,:,3)))); - - + + else maxValFromBarChart=max(max(max(barChartData{curChart}(:,:,1)+barChartData{curChart}(:,:,2)))); minValFromBarChart=min(min(min(barChartData{curChart}(:,:,1)-barChartData{curChart}(:,:,2)))); end - - - - + + + + ylimLower=minValFromBarChart; ylimUpper=maxValFromBarChart; yrange=ylimUpper-ylimLower; - - + + else maxValFromBarChart=max(max(barChartData{curChart}(:,:,1))); minValFromBarChart=min(min(barChartData{curChart}(:,:,1))); - - pf2_base.external.barweb(barChartData{curChart}(:,:,1),[],1,xBarLabels, 'ColorMap', cIndex,'Legend',gAStrs,'LegendType','hide', 'DataPoints',barChartDataPoints{curChart}); - + + pf2_base.external.barweb(barChartData{curChart}(:,:,1),[],0.8,xBarLabels, 'ColorMap', cIndex,'Legend',gAStrs,'LegendType','hide', 'DataPoints',barChartDataPoints{curChart}); + ylimLower=minValFromBarChart; ylimUpper=maxValFromBarChart; yrange=ylimUpper-ylimLower; - + end - + if(exSettings.ylim_fixed) %ylim([min(ylimLower-0.05*yrange,0),max(ylimUpper+0.05*yrange,0)]); if(yrange==0) @@ -626,7 +629,7 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) else ylim([ylimLower-0.1*yrange,ylimUpper+0.1*yrange]); end - + switch exSettings.ChannelMode case 'fNIR' chNamePart=sprintf('Opt. %s',selectedOptStr{chIdx}); @@ -638,14 +641,14 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) chNamePart=selectedOptStr{chIdx}; chNamePartLong=sprintf('Aux: %s %s',selectedOptStr{chIdx},bioM); end - + if(numBioM==1||numUgroups>1) if(numBioM==1) bioM=selectedBioM(curChart); if(iscell(bioM)) bioM=bioM{1}; end - + if(plotCount) ylabel_with_space(sprintf('%s [%s]',plotFeature,bioM)); elseif(strcmp(exSettings.ChannelMode,'Aux')) @@ -681,7 +684,7 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) ylabel_with_space(sprintf('%s %s %s +/- %dx(%s)',plotFeature,bioM,chNamePart,errMultiply,errorFeature)); end end - + end elseif(numBioM==b) if(plotCount) @@ -692,8 +695,8 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) ylabel_with_space(sprintf('%s \\Delta[%s] (\\muM) +/- (%s)',plotFeature,'X',errorFeature)); end end - - + + switch exSettings.ChannelMode case 'fNIR' title_with_space(sprintf('Optode %s',optStrs{ch})); @@ -702,7 +705,7 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) case 'Aux' title_with_space(chNamePartLong); end - + if(useCurInfoGroup&&numChartTimes==1) xlabel_with_space(sprintf('%s (t=%s)',curInfoGroup,barChartTimeStrings{1})); elseif(useCurInfoGroup) @@ -710,15 +713,17 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) else xlabel_with_space('Time (s)'); end - + if((numBioM>1||numUgroups>1)&&(exSettings.plot_legend_mode==3||(exSettings.plot_legend_mode==2&&curSubplotIdx==lastPlotNum))) for i=1:size(gAStrs,1) if(isnumeric(gAStrs{i,curChart})) gAStrs{i,curChart}=''; end end - legend(gAStrs(:,curChart),'Location', 'Best'); + lgd=legend(gAStrs(:,curChart),'Location', 'Best'); legend boxoff; + lgdSty=pf2_base.plot.PlotStyle.getDefault(); + set(lgd,'TextColor',lgdSty.LegendTextColor,'Color',lgdSty.LegendBgColor); end hold off; end @@ -729,35 +734,105 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) end +% --- LME Statistical Analysis --- +% Delegates to exploreFNIRS.stats.fitLME instead of fitting inline + +lmeResults = []; +curChartLME = cell(1, length(subplotHandles)); + if(exSettings.LME_enable) fprintf('Generating Models...\nAccessed at ExFNIRS.curChartModels\n') - + global ExFNIRS - - ExFNIRS.curChartModels=cell(0); - ExFNIRS.curChartModelsAIC=[]; - ExFNIRS.curChartModelsCoefficents=cell(0); - ExFNIRS.curChartModelsCoefficents_pval=table(); - ExFNIRS.curChartModelsCoefficents_tstat=table(); - ExFNIRS.curChartModelsCoefficents_df=table(); - ExFNIRS.curChartModelsANOVA=cell(0); - ExFNIRS.curChartModelsANOVACoefficents_pval=table(); - ExFNIRS.curChartModelsANOVACoefficents_Fstat=table(); - ExFNIRS.curChartModelsANOVACoefficents_df1=table(); - ExFNIRS.curChartModelsANOVACoefficents_df2=table(); - ExFNIRS.curMdlFits=table(); - - + + % Build fitLME arguments from GUI settings + lmeArgs = { ... + 'UseIntercept', logical(exSettings.LME_use_intercept), ... + 'AllInteractions', logical(exSettings.LME_all_interactions), ... + 'RandomEffects', exSettings.LME_randomFxStr, ... + 'Biomarkers', selectedBioM(:)', ... + 'Channels', selectedOpt, ... + 'Verbose', true, ... + 'TimeModel', exSettings.LME_timeModel, ... + 'PolynomialOrder', exSettings.LME_polyOrder, ... + 'SkipContrasts', showTopo, ... + 'ModelFitTest', true, ... + 'ExcludeShortSeparation', false}; + if(exSettings.LME_info_covariate) + lmeArgs = [lmeArgs, {'InfoCovariate', exSettings.curInfoStr}]; + end + if(exSettings.LME_use_customStr && ~isempty(exSettings.LME_customStr)) + lmeArgs = [lmeArgs, {'CustomFormula', exSettings.LME_customStr}]; + end + + switch exSettings.ChannelMode + case 'fNIR' + lmeArgs = [lmeArgs, {'DataType', 'fNIRS'}]; + case 'ROI' + lmeArgs = [lmeArgs, {'DataType', 'ROI'}]; + case 'Aux' + lmeArgs = [lmeArgs, {'DataType', 'Aux', 'AuxField', selectedBioM{1}}]; + end + + lmeResults = exploreFNIRS.stats.fitLME(exGby, gbyVars_original, lmeArgs{:}); + + % Store consolidated results + ExFNIRS.curChartLMEResults = lmeResults; + + % Legacy globals for backward compat + ExFNIRS.curChartModels = lmeResults.models; + ExFNIRS.curChartModelsAIC = lmeResults.AIC; + ExFNIRS.curChartModelsANOVA = lmeResults.anova; + ExFNIRS.curChartModelsANOVACoefficents_pval = lmeResults.anova_pval; + ExFNIRS.curChartModelsANOVACoefficents_Fstat = lmeResults.anova_Fstat; + ExFNIRS.curChartModelsANOVACoefficents_df1 = lmeResults.anova_df1; + ExFNIRS.curChartModelsANOVACoefficents_df2 = lmeResults.anova_df2; + ExFNIRS.curChartModelsCoefficents = lmeResults.coefficients; + ExFNIRS.curChartModelsCoefficents_pval = lmeResults.coef_pval; + ExFNIRS.curChartModelsCoefficents_tstat = lmeResults.coef_tstat; + ExFNIRS.curChartModelsCoefficents_df = lmeResults.coef_df; + + % Build legacy curMdlFits table: rows=biomarker, cols=channel, values=p + % Old format: ExFNIRS.curMdlFits{bioM, mdlChName} = p-value + legacyMdlFits = table(); + if ~isempty(lmeResults.modelFit) && height(lmeResults.modelFit) > 0 && ... + ismember('p', lmeResults.modelFit.Properties.VariableNames) + mfRows = lmeResults.modelFit.Properties.RowNames; + for mfI = 1:length(mfRows) + parts = strsplit(mfRows{mfI}, '_'); + if length(parts) >= 2 + mfBio = parts{end}; + mfCh = strjoin(parts(1:end-1), '_'); + else + mfBio = parts{1}; + mfCh = parts{1}; + end + legacyMdlFits{mfBio, mfCh} = lmeResults.modelFit{mfI, 'p'}; + end + end + ExFNIRS.curMdlFits = legacyMdlFits; + + % Build curChartLME cell array for topo section compat + for sH = 1:length(subplotHandles) + if(~isfield(subplotGby{sH}, 'gby')), continue; end + [bIdx, chI] = mapSubplotToResults(subplotGby{sH}, lmeResults, plotGroupByBioM); + if(~isempty(bIdx) && ~isempty(chI) && bIdx <= size(lmeResults.models,1) && chI <= size(lmeResults.models,2)) + curChartLME{sH} = lmeResults.models{bIdx, chI}; + end + end end LME_topo_mode='anova'; lmeString='None~'; +if(~isempty(lmeResults)) + lmeString = lmeResults.formula; +end for sH=1:length(subplotHandles) - + fprintf('\nInfo Table Values\n'); curData=subplotGby{sH}; if(isfield(curData,'gAStrs')) @@ -765,253 +840,78 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) for j=1:size(curData.xBarLabels,1) fprintf('%s:%s\tMean %.2f\tError: %.2f\n',curData.gAStrs{i},curData.xBarLabels{j},curData.barChartData(j,i,1),curData.barChartData(j,i,2)); end - + end end fprintf('\n'); - - - - if(exSettings.LME_enable&&isfield(subplotGby{sH},'gby')) - switch (exSettings.ChannelMode) - case 'fNIR' - mergedTables{sH}=exploreFNIRS.export.mergeGbyTablesLong(subplotGby{sH}.gby,subplotGby{sH}.curBioM,subplotGby{sH}.curCh,barChartTimes,false,false,optStrs(subplotGby{sH}.curCh)); - varNameStart='Opt'; - - case 'ROI' - mergedTables{sH}=exploreFNIRS.export.mergeGbyTablesLong(subplotGby{sH}.gby,subplotGby{sH}.curBioM,subplotGby{sH}.curCh,barChartTimes,false,true); - varNameStart='ROI'; - - case 'Aux' - mergedTables{sH}=exploreFNIRS.export.mergeGbyTablesLong(subplotGby{sH}.gby,subplotGby{sH}.curBioM,subplotGby{sH}.curCh,barChartTimes,true,false); - varNameStart='aux'; - end - x=gbyVars_original; - curLMEGbyString=''; - mdlPrtString=''; - - useAllInteractions=exSettings.LME_all_interactions; - - basicMdlStrings=cell(0); - - - if(exSettings.LME_info_covariate) - basicMdlStrings{length(basicMdlStrings)+1}=exSettings.curInfoStr; - end - % if(plotGroupByBioM&&numBioM>1) - % %basicMdlStrings{length(basicMdlStrings)+1}='BioM'; - % warning('GroupBy Biomarker Plots not supported yet\n Only using first biomarker'); - % end - - for z=1:length(basicMdlStrings) - if(z==1) - mdlPrtString=basicMdlStrings{z}; - else - mdlPrtString=sprintf('%s*%s',mdlPrtString,basicMdlStrings{z}); - end - end - - if(isempty(mdlPrtString)) - mdlPrtString='1'; - end - - - if(useAllInteractions) - curLMEGbyString=mdlPrtString; - for i=1:length(x) - curLMEGbyString=sprintf('%s*%s',curLMEGbyString,x{i}); - end - else - for i=1:length(x) - curLMEGbyString=sprintf('%s+%s*%s',curLMEGbyString,mdlPrtString,x{i}); - end - if(~isempty(curLMEGbyString)) - curLMEGbyString(1)=[]; - end - - - end - - if(numChartTimes>1) - curLMEGbyString=sprintf('%s+Time',curLMEGbyString); - end - - dummyCodeStr='reference'; - - if(strcmp(exSettings.ChannelMode,'Aux')) - varName=sprintf('%s_%s_%s',varNameStart,subplotGby{sH}.curBioM{1},selectedOptStr{(selectedOpt==subplotGby{sH}.curCh)}); - elseif(strcmp(exSettings.ChannelMode,'ROI')) - varName=sprintf('%s%i_%s_%s',varNameStart,subplotGby{sH}.curCh,selectedOptStr{(selectedOpt==subplotGby{sH}.curCh)},subplotGby{sH}.curBioM{1}); - else - varName=sprintf('%s%s_%s',varNameStart,selectedOptStr{(selectedOpt==subplotGby{sH}.curCh)},subplotGby{sH}.curBioM{1}); - end - - if(exSettings.LME_use_customStr&&~isempty(exSettings.LME_customStr)) - lmeString=sprintf('%s~%s',varName,exSettings.LME_customStr); - if(contains(lmeString,'-1+')||contains(lmeString,'~-1')) - dummyCodeStr='full'; - lmeString(lmeString=='*')=':'; - end - elseif(exSettings.LME_use_intercept) - lmeString=sprintf('%s~%s+(%s)',varName,curLMEGbyString,exSettings.LME_randomFxStr); - if(isempty(curLMEGbyString)) - lmeString=sprintf('%s~1+(%s)',varName,exSettings.LME_randomFxStr); - end - - else - lmeString=sprintf('%s~-1+%s+(%s)',varName,curLMEGbyString,exSettings.LME_randomFxStr); - dummyCodeStr='full'; - lmeString(lmeString=='*')=':'; - end - - try - if((~exSettings.LME_use_discreteTime||strcmp(LME_topo_mode,'anova'))&&numChartTimes>1) - mergedTables{sH}.Time=str2double(mergedTables{sH}.Time); - end - - - rng(2019); - curChartLME{sH}=fitlme(mergedTables{sH},lmeString,'FitMethod','REML','CheckHessian',true,'DummyVarCoding',dummyCodeStr); - % curChartLME_emm{sH}= pf2_base.external.emmeans(curChartLME{sH}, {'orig'}, 'effects'); - % h = emmip(curChartLME_emm{sH},'orig'); - nullMdlstring=sprintf('%s~1+(1|SubjectID)',varName); - curChartLME_ML=fitlme(mergedTables{sH},lmeString,'FitMethod','ML','CheckHessian',true,'DummyVarCoding',dummyCodeStr); - nullChartLME=fitlme(mergedTables{sH},nullMdlstring,'FitMethod','ML','CheckHessian',true,'DummyVarCoding',dummyCodeStr); - nullCompare{sH}=compare(curChartLME_ML,nullChartLME); - pVal=nullCompare{sH}.pValue(end); - if(pVal>0.05) - nullCompareStr{sH}='Model is marginally worse than naive model'; - elseif(~isnan(pVal)) - nullCompareStr{sH}='Model is significantly worse than naive model'; - else - nullCompare{sH}=compare(nullChartLME,curChartLME_ML); - pVal=nullCompare{sH}.pValue(end); - if(pVal>0.05) - nullCompareStr{sH}='Model is marginally better than naive model'; - else - nullCompareStr{sH}='Model is significantly better than naive model'; - end - end - + + + + if(exSettings.LME_enable && isfield(subplotGby{sH},'gby') && ~isempty(lmeResults)) + [bIdx, chI] = mapSubplotToResults(subplotGby{sH}, lmeResults, plotGroupByBioM); + + if(~isempty(bIdx) && ~isempty(chI) && bIdx <= size(lmeResults.models,1) && chI <= size(lmeResults.models,2) && ~isempty(lmeResults.models{bIdx, chI})) + mdl = lmeResults.models{bIdx, chI}; + switch (exSettings.ChannelMode) case 'fNIR' chName=sprintf('Opt%s',optStrs{subplotGby{sH}.curCh}); - mdlChName=chName; case 'ROI' chName=sprintf('ROI%i_%s',subplotGby{sH}.curCh,optStrs{subplotGby{sH}.curCh}); - mdlChName=sprintf('ROI%i',subplotGby{sH}.curCh); case 'Aux' chName=sprintf('%s',subplotGby{sH}.curBioM{1}); - mdlChName=chName; end - - + fprintf('Chart %i LME model: %s',sH,chName); if(~plotGroupByBioM) fprintf(' [%s]',subplotGby{sH}.curBioM{1}); end - if(useAllInteractions) + if(exSettings.LME_all_interactions) fprintf(' - All Interactions\n'); else fprintf(' - No Interactions\n'); end - ExFNIRS.curChartModels{sH}=curChartLME{sH}; - ExFNIRS.curChartModelsAIC(sH)=curChartLME{sH}.ModelCriterion.AIC; - [~,~,ExFNIRS.curChartModelsCoefficents{sH}]=randomEffects(curChartLME{sH},'DFMethod','satterthwaite'); - ExFNIRS.curChartModelsANOVA{sH}=anova(curChartLME{sH},'DFMethod','satterthwaite'); - - anovaNames=curChartLME{sH}.anova.Term; - - for a=1:length(anovaNames) - str=anovaNames{a}; - str(str=='('|str==')')=''; % replace shitty characters - str(str==':'|str=='_')=''; % replace shitty characters - str(str==' '|str=='-')=''; % replace shitty characters - anovaNames{a}=str; - end - - varNames=ExFNIRS.curChartModelsCoefficents{sH}.Name; - for v=1:length(varNames) - str=varNames{v}; - str(str=='('|str==')')=''; % replace shitty characters - str(str==':'|str=='_')=''; % replace shitty characters - str(str==' '|str=='-')=''; % replace shitty characters - varNames{v}=str; - end - - if(true)%~plotGroupByBioM) - curBioM=subplotGby{sH}.curBioM{1}; - curRowName=sprintf('%s_%s',chName,curBioM); - - - - - ExFNIRS.curChartModelsANOVACoefficents_pval{curRowName,anovaNames}= ExFNIRS.curChartModelsANOVA{sH}.pValue'; - ExFNIRS.curChartModelsANOVACoefficents_Fstat{curRowName,anovaNames}=ExFNIRS.curChartModelsANOVA{sH}.FStat'; - if(ismember('DF2',properties(ExFNIRS.curChartModelsANOVA{sH}))) - ExFNIRS.curChartModelsANOVACoefficents_df2{curRowName,anovaNames}=ExFNIRS.curChartModelsANOVA{sH}.DF2'; - ExFNIRS.curChartModelsANOVACoefficents_df1{curRowName,anovaNames}=ExFNIRS.curChartModelsANOVA{sH}.DF1'; - else - ExFNIRS.curChartModelsANOVACoefficents_df1{curRowName,anovaNames}=ExFNIRS.curChartModelsANOVA{sH}.DF'; - ExFNIRS.curChartModelsANOVACoefficents_df2{curRowName,anovaNames}=zeros(size(ExFNIRS.curChartModelsANOVA{sH}.DF')); - end - - - - - - ExFNIRS.curChartModelsCoefficents_pval{curRowName,varNames}=ExFNIRS.curChartModelsCoefficents{sH}.pValue'; - ExFNIRS.curChartModelsCoefficents_tstat{curRowName,varNames}=ExFNIRS.curChartModelsCoefficents{sH}.tStat'; - ExFNIRS.curChartModelsCoefficents_df{curRowName,varNames}=ExFNIRS.curChartModelsCoefficents{sH}.DF'; - ExFNIRS.curChartModels_ch(sH)=subplotGby{sH}.curCh; - else - curBioM=subplotGby{sH}.curBioM{1}; - curRowName=sprintf('%s_%s',chName,curBioM); - ExFNIRS.curChartModelsCoefficents_pval{curRowName,varNames}=ExFNIRS.curChartModelsCoefficents{sH}.pValue'; - ExFNIRS.curChartModelsCoefficents_tstat{curRowName,varNames}=ExFNIRS.curChartModelsCoefficents{sH}.tStat'; - ExFNIRS.curChartModelsCoefficents_df{curRowName,varNames}=ExFNIRS.curChartModelsCoefficents{sH}.DF'; - - - ExFNIRS.curChartModelsANOVACoefficents_pval{curRowName,anovaNames}=ExFNIRS.curChartModelsANOVA{sH}.pValue'; - ExFNIRS.curChartModelsANOVACoefficents_Fstat{curRowName,anovaNames}=ExFNIRS.curChartModelsANOVA{sH}.FStat'; - if(ismember('DF2',properties(ExFNIRS.curChartModelsANOVA{sH}))) - ExFNIRS.curChartModelsANOVACoefficents_df2{curRowName,anovaNames}=ExFNIRS.curChartModelsANOVA{sH}.DF2'; - ExFNIRS.curChartModelsANOVACoefficents_df1{curRowName,anovaNames}=ExFNIRS.curChartModelsANOVA{sH}.DF1'; + + disp(mdl); + displayLME(mdl); + + % Null comparison + if(~isempty(lmeResults.nullComparison{bIdx, chI})) + nc = lmeResults.nullComparison{bIdx, chI}; + pVal = nc.pValue(end); + if(pVal < 0.05 && ~isnan(pVal)) + fprintf(2,'\nModel is significantly better than naive model\n'); + elseif(~isnan(pVal)) + fprintf(2,'\nModel is not significantly better than naive model\n'); else - ExFNIRS.curChartModelsANOVACoefficents_df1{curRowName,anovaNames}=ExFNIRS.curChartModelsANOVA{sH}.DF'; - ExFNIRS.curChartModelsANOVACoefficents_df2{curRowName,anovaNames}=zeros(size(ExFNIRS.curChartModelsANOVA{sH}.DF')); + fprintf(2,'\nModel comparison inconclusive\n'); end - - ExFNIRS.curChartModels_ch(sH)=subplotGby{sH}.curCh; + disp(nc); end - disp(curChartLME{sH}); - displayLME(curChartLME{sH}); - fprintf(2,'\n%s\n',nullCompareStr{sH}); - disp(nullCompare{sH}); - - mdlTest=eye(length(curChartLME{sH}.Coefficients.Name)); - if(exSettings.LME_use_intercept) - mdlTest=mdlTest(2:end,:); - end - [curMdlFit{1:4}]=coefTest(curChartLME{sH},mdlTest,zeros(size(mdlTest,1),1),'DFMethod','satterthwaite'); - fprintf('\nModel Fit (H0: All F=0): p=%.5f\tF=%.2f\tdf1=%i\tdf2=%i\n\n',curMdlFit{1},curMdlFit{2},curMdlFit{3},curMdlFit{4}); - if(~showTopo) - curChartContrast=exploreFNIRS.fx.autoContrast(curChartLME{sH}); - if(~isempty(curChartContrast)) - disp(curChartContrast); + + % Model fit test + if(~isempty(lmeResults.modelFit) && height(lmeResults.modelFit) > 0) + rowNames = lmeResults.modelFit.Properties.RowNames; + curBioM = subplotGby{sH}.curBioM; + if(iscell(curBioM)), curBioM = curBioM{1}; end + matchRow = find(contains(rowNames, chName) & contains(rowNames, curBioM), 1); + if(~isempty(matchRow)) + mfVals = lmeResults.modelFit{matchRow, :}; + fprintf('\nModel Fit (H0: All F=0): p=%.5f\tF=%.2f\tdf1=%i\tdf2=%i\n\n', ... + mfVals(1), mfVals(2), mfVals(3), mfVals(4)); end end - ExFNIRS.curMdlFits{curBioM,mdlChName}=curMdlFit{1}; - %curChartLME{sH}.contrastTable=exploreFNIRS.fx.autoContrast(curChartLME{sH},0.5); - catch ME + + % Contrasts (not in topo mode) + if(~showTopo && ~isempty(lmeResults.contrasts{bIdx, chI})) + disp(lmeResults.contrasts{bIdx, chI}); + end + else fprintf(2,'Could not generate model for figure %i\n',sH); - fprintf(2,'\nLME: %s\n',lmeString); - fprintf(2,ME.message); - fprintf(2,'\n'); end end - - + + if(showBarChart&&exSettings.ylim_fixed) set(subplotHandles{sH},'YLim',[exSettings.ylim_fixed_min, exSettings.ylim_fixed_max]); end @@ -1024,22 +924,22 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) if(~exSettings.LME_enable) warning('LME must be enabled'); else - + topoH=figure(2000); clf(topoH); lmeString=strsplit(lmeString,'~'); lmeString=sprintf('[X]~%s',lmeString{2}); addDebugAnnotation(topoH,lmeString); - + chNames=ExFNIRS.curChartModelsCoefficents_tstat.Properties.RowNames; coefNames=ExFNIRS.curChartModelsCoefficents_tstat.Properties.VariableNames; numCoeff=size(ExFNIRS.curChartModelsCoefficents_tstat,2); - + numANOVA=size(ExFNIRS.curChartModelsANOVACoefficents_Fstat,2); anovaNames=ExFNIRS.curChartModelsANOVACoefficents_Fstat.Properties.VariableNames; - + chArr=1:length(chNames); - + for z=1:length(chNames) temp=strsplit(chNames{z},'_'); switch (exSettings.ChannelMode) @@ -1049,13 +949,13 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) %chArr(z)=sscanf(temp{1},'ROI%i'); case 'Aux' end - + bioMarr(z)=temp(end); end bioMLabel=cell(0,0); - - - + + + if(true&&~isempty(chNames))%~plotGroupByBioM) for b=1:numBioM bioM=selectedBioM(b); @@ -1063,7 +963,7 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) fprintf('\n Significant Models [%s]: ',bioM{1}); [curMdlQ,curMdlK]=exploreFNIRS.fx.performFDR(curMdlP,exSettings.topoSigThrehold{2}); [curMdlQ_rev,curMdlK_rev]=exploreFNIRS.fx.performFDR_twostep(curMdlP,exSettings.topoSigThrehold{2}); - + for i=1:size(curMdlP,2) varName=curMdlP.Properties.VariableNames{i}; if((curMdlP{1,i}* '); end - + if(strcmp(exSettings.topoSigThrehold{1},'q')) fprintf(', q=%.4f',curMdlQ(i)); end @@ -1086,19 +986,19 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) fprintf('* '); end end - + end - + fprintf('\n'); - + mdlPresent = ones(size(curChartLME)); nMdls=length(curChartLME); for sHidx=1:nMdls mdlPresent(sHidx)=~isempty(curChartLME{sHidx}); end - + curMdlIdx = 0; - + switch(LME_topo_mode) case 'coef' for c=1:numCoeff @@ -1113,37 +1013,37 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) fNIR_df{b,a}=nan(1,nMdls); fNIR_df2{b,a}=nan(1,nMdls); end - + end end - - - + + + for coefIdx=1:size(ExFNIRS.curChartModelsCoefficents_tstat,1) - + curMdlIdx=curMdlIdx+1; while(curMdlIdx=estimatedPval_min)) - + titleSTR=anovaNames{a}; - + if(any(curQ<0.05)) FDRfound=true; titleSTR=sprintf('%s*',anovaNames{a}); %FDR RESULTS FOUND end - - global setF - + switch(setF.device.Info.CfgName) case 'fNIR_Devices_fNIR1000' curF=nan(2,8); curP=nan(2,8); curDf1=nan(2,8); curDf2=nan(2,8); - + len=length(fNIR_f{b,a}); curF(1:len)=fNIR_f{b,a}; curP(1:len)=fNIR_p{b,a}; curDf1(1:len)=fNIR_df{b,a}; curDf2(1:len)=fNIR_df2{b,a}; - - - - + + + + switch(exSettings.ChannelMode) case 'fNIR' interpolateNIR(curF,'Mode','fstat','fontSize',12,'transparent',true,'lowerThreshold',estimatedPval_min,'TitleText',titleSTR,'ChannelLabels',true)%,7,11,2,1,false,'[Hb-Oxy] Natural High Vs. Low',12,'hot',true) @@ -1283,17 +1179,17 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) interpolateNIR(mapROIvaluesToCh(roiInfo,curF),'Mode','fstat','fontSize',12,'transparent',true,'lowerThreshold',estimatedPval_min,'TitleText',titleSTR,'ChannelLabels',true)%,7,11,2,1,false,'[Hb-Oxy] Natural High Vs. Low',12,'hot',true) end otherwise - + switch(exSettings.ChannelMode) case 'fNIR' - pf2.probe.plot.interpolateValues3D(curF,[],estimatedPval_min,[],titleSTR,'F-val','bufferDistance',1);%InterpolateValues(fNIR,data2plot,minVal,maxVal,bufferMult,titleString,clrBarTitle + pf2.probe.plot.interpolateValues3D(curF,setF.device.Info.CfgName,estimatedPval_min,[],titleSTR,'F-val','bufferDistance',1);%InterpolateValues(fNIR,data2plot,minVal,maxVal,bufferMult,titleString,clrBarTitle case 'ROI' roiInfo=ExFNIRS.currentROI; pf2.probe.plot.interpolateROIvalues(mapROIvaluesToCh(roiInfo,curF),[],'ROIinfo',roiInfo,'minVal',estimatedPval_min,'maxVal',[],'bufferMult',1,'titleString',titleSTR,'clrBarTitle','F-val');%,7,11,2,1,false,'[Hb-Oxy] Natural High Vs. Low',12,'hot',true) end end - - + + if(a==1) % first column curAxes=gca; axesPos=curAxes.OuterPosition; @@ -1310,17 +1206,17 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) end end end - + end end end - + if(doublePlotWithFDR&&FDRfound) topoHfdr=figure(2001); clf(topoHfdr); addDebugAnnotation(topoHfdr); - - + + for b=1:numBioM switch(LME_topo_mode) case 'coef' @@ -1329,7 +1225,7 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) curT=fNIR_t{b,c}; curP=fNIR_p{b,c}; curQ=exploreFNIRS.fx.performFDR(curP); - + switch(exSettings.ChannelMode) case 'fNIR' interpolateNIR(curT,'Mode','tstat','fontSize',12,'transparent',true,'pValueMask',curQ,'TitleText',coefNames{c},'ChannelLabels',true)%,7,11,2,1,false,'[Hb-Oxy] Natural High Vs. Low',12,'hot',true) @@ -1357,7 +1253,7 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) curT=fNIR_f{b,a}; curP=fNIR_p{b,a}; curQ=exploreFNIRS.fx.performFDR(curP); - + switch(exSettings.ChannelMode) case 'fNIR' interpolateNIR(curT,'Mode','tstat','fontSize',12,'transparent',true,'pValueMask',curQ,'TitleText',numANOVA{a},'ChannelLabels',true)%,7,11,2,1,false,'[Hb-Oxy] Natural High Vs. Low',12,'hot',true) @@ -1379,17 +1275,42 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) ylabel_with_space(selectedBioM(b)); end end - + end end suptitle_with_space('FDR Edition'); end - + end end end +function [bIdx, chI] = mapSubplotToResults(spGby, lmeResults, plotGroupByBioM) +% MAPSUBPLOTTORESULTS Map subplot gby struct to (biomarker, channel) indices in results + bIdx = []; + chI = []; + + curCh = spGby.curCh; + curBioM = spGby.curBioM; + if iscell(curBioM) + curBioM = curBioM{1}; + end + + % Find channel index + chI = find(lmeResults.channels == curCh, 1); + if isempty(chI), return; end + + % Find biomarker index + if plotGroupByBioM + % When grouped by biomarker, fitLME gets all biomarkers, bIdx=1 maps to first + bIdx = find(strcmp(curBioM, lmeResults.biomarkers), 1); + if isempty(bIdx), bIdx = 1; end + else + bIdx = find(strcmp(curBioM, lmeResults.biomarkers), 1); + if isempty(bIdx), bIdx = 1; end + end +end function possibleStr=num2strOrNot(possibleStr) @@ -1504,7 +1425,7 @@ function barchart(handles,exSettings,exGby,gbyVars, showBarChart,showTopo) if(nargin<2) labelstring=axHandle; - + if(~isempty(labelstring)) labelstring(labelstring=='_')=' '; end @@ -1534,6 +1455,7 @@ function addDebugAnnotation(figHandle,optionalstring) th.LineStyle='none'; th.HorizontalAlignment='left'; th.VerticalAlignment='bottom'; +th.Color=pf2_base.plot.PlotStyle.getDefault().ForegroundColor; curPos=th.Position; end @@ -1562,4 +1484,4 @@ function displayLME(lme_mdl) end %disp(['You clicked X:',num2str(pos(1)),', Y:',num2str(pos(2))]); -end \ No newline at end of file +end diff --git a/+exploreFNIRS/+plot/barchart_infogroup.m b/+exploreFNIRS/+plot/barchart_infogroup.m index 34411735..aafa3191 100644 --- a/+exploreFNIRS/+plot/barchart_infogroup.m +++ b/+exploreFNIRS/+plot/barchart_infogroup.m @@ -243,7 +243,7 @@ function barchart_infogroup(handles,exSettings,exGby,gbyVars) curDataH=pf2_base.hierarchicalAverage(curData,curTable(:,dataH),@nanmedian); curHAvg=nanmedian(curDataH); else - error('Unknown parameter'); + error('exploreFNIRS:plot:barchart_infogroup:unknownParameter', 'Unknown parameter'); %curHAvg=nanmedian(hierarchicalAverage(curData,curTable(:,dataH),@nanmedian)); end @@ -292,7 +292,7 @@ function barchart_infogroup(handles,exSettings,exGby,gbyVars) barChartData(cBarSec,curBarGroup,2)=curHerr*errMultiply; elseif(strcmp(errorFeature,'IQR')||strcmp(errorFeature,'IQR-NoOutliers')||strcmp(errorFeature,'Violin')) numErrFeatures=5; %min and max) and median - gaQuant=quantile(curDataH,3); + gaQuant=pf2_base.compat.quantile(curDataH,3); iqr=gaQuant(end)-gaQuant(1); gaPlotMin=min(curDataH); @@ -355,7 +355,7 @@ function barchart_infogroup(handles,exSettings,exGby,gbyVars) end if(exSettings.plot_bar_err&&~strcmp(plotFeature,'Count')) - pf2_base.external.barweb(barChartData(:,:,1),barChartData(:,:,2:1+numErrFeatures),'Width',1,'GroupNames',uCurInfoG, 'ColorMap',cIndex,'Legend',gAStrs,'LegendType','hide','DataPoints',barChartDataPoints,'PlotViolin',strcmp(errorFeature,'Violin')); + pf2_base.external.barweb(barChartData(:,:,1),barChartData(:,:,2:1+numErrFeatures),'Width',0.8,'GroupNames',uCurInfoG, 'ColorMap',cIndex,'Legend',gAStrs,'LegendType','hide','DataPoints',barChartDataPoints,'PlotViolin',strcmp(errorFeature,'Violin')); if(strcmp(errorFeature,'SEM')||strcmp(errorFeature,'SD'))&&~plotPoints ylimLower=min(min(barChartData(:,:,1)))-max(max(barChartData(:,:,2))); @@ -399,7 +399,7 @@ function barchart_infogroup(handles,exSettings,exGby,gbyVars) xlabel_with_space(xLabGby); end else - pf2_base.external.barweb(barChartData(:,:,1),[],'Width',1,'GroupNames',uCurInfoG, 'ColorMap',cIndex,'Legend',gAStrs,'LegendType','hide','DataPoints',barChartDataPoints); + pf2_base.external.barweb(barChartData(:,:,1),[],'Width',0.8,'GroupNames',uCurInfoG, 'ColorMap',cIndex,'Legend',gAStrs,'LegendType','hide','DataPoints',barChartDataPoints); if(~plotPoints||strcmp(plotFeature,'Count')) @@ -426,6 +426,14 @@ function barchart_infogroup(handles,exSettings,exGby,gbyVars) end end +% Apply current PlotStyle (theme-aware, respects ForceLightMode) so axes, +% legend, and colorbar colors match light/dark preference. +try + sty = pf2_base.plot.PlotStyle.getDefault(); + sty.applyToFigure(ExFNIRS.figHandles.main); +catch +end + fprintf('\nInfo Table Values\n'); global barChartTable; diff --git a/+exploreFNIRS/+plot/scatter.m b/+exploreFNIRS/+plot/scatter.m index 1fe51d83..7f244c18 100644 --- a/+exploreFNIRS/+plot/scatter.m +++ b/+exploreFNIRS/+plot/scatter.m @@ -96,7 +96,7 @@ if(strcmp(exSettings.ChannelMode,'Aux')) if(length(selectedBioM)>1) - error('Not supported yet!') + error('exploreFNIRS:plot:scatter:notSupported', 'Not supported yet!') end auxTable=get(handles.listbox_optode,'UserData'); selectedOpt=nan(length(selOpt)); @@ -622,12 +622,12 @@ [curHAvg,outH]=pf2_base.hierarchicalAverage(curData,curTable(:,dataH),@nanmedian); else - error('Unknown parameter'); + error('exploreFNIRS:plot:scatter:unknownParameter', 'Unknown parameter'); %curHAvg=nanmedian(hierarchicalAverage(curData,curTable(:,dataH),@nanmedian)); end if(numChartTimes==0) - error('No data in selected time range!'); + error('exploreFNIRS:plot:scatter:noData', 'No data in selected time range!'); end for t=1:numChartTimes @@ -657,7 +657,7 @@ dataHierarchy=curGrand.info.Hierarchy; case 'ROI' if(~pf2_base.isnestedfield(curGrand,'ROI.HbO.data')) - error('ROI data must be calculated using a build ROI step'); + error('exploreFNIRS:plot:scatter:roiNotBuilt', 'ROI data must be calculated using a build ROI step'); end data2plot=curGrand.ROI.(bioM); @@ -675,7 +675,7 @@ elseif(strcmp(plotFeature,'Median')) [curFeatureY]=pf2_base.hierarchicalAverage(curFeatureY,dataHierarchy,@nanmedian); else - error('Unknown parameter'); + error('exploreFNIRS:plot:scatter:unknownParameter', 'Unknown parameter'); %curHAvg=nanmedian(hierarchicalAverage(curData,curTable(:,dataH),@nanmedian)); end @@ -797,8 +797,8 @@ if(~isempty(xVals)&&~isempty(yVals)) - [rho,pval] = corr(xVals,yVals,'Type','Spearman'); - [r,p]=corr(xVals,yVals,'Type','Pearson'); + [rho,pval] = pf2_base.compat.corr(xVals,yVals,'Type','Spearman'); + [r,p]=pf2_base.compat.corr(xVals,yVals,'Type','Pearson'); curData=topoData{curFigIdx(1),curFigIdx(2)}.subH{curSy,curSx}; @@ -1090,7 +1090,7 @@ lineWidth=0.5; plotShaded=true; otherwise - error('Unspecified error style'); + error('exploreFNIRS:plot:scatter:unknownErrorStyle', 'Unspecified error style'); end errColor=sColor+(1-sColor)*0.55; @@ -1129,10 +1129,10 @@ set(gaH.Annotation.LegendInformation,'IconDisplayStyle','off'); - [rho,pval] = corr(xVals,yVals,'Type','Spearman'); + [rho,pval] = pf2_base.compat.corr(xVals,yVals,'Type','Spearman'); - [r,p]=corr(xVals,yVals,'Type','Pearson'); + [r,p]=pf2_base.compat.corr(xVals,yVals,'Type','Pearson'); if(~plotGroupByBioM) fitStr=gbyStrs{g}; @@ -1249,7 +1249,9 @@ lgStrs=[lgStrs;pointStrs(k)]; end - legend(sH{i,b}.subH{y,x},pointStrs(:),'Location', 'Best'); + lgd=legend(sH{i,b}.subH{y,x},pointStrs(:),'Location', 'Best'); + lgdSty=pf2_base.plot.PlotStyle.getDefault(); + set(lgd,'TextColor',lgdSty.LegendTextColor,'Color',lgdSty.LegendBgColor); end hold(sH{i,b}.subH{y,x},'off') @@ -1447,6 +1449,7 @@ function addDebugAnnotation(figHandle,optionalstring) th.LineStyle='none'; th.HorizontalAlignment='left'; th.VerticalAlignment='bottom'; +th.Color=pf2_base.plot.PlotStyle.getDefault().ForegroundColor; curPos=th.Position; end diff --git a/+exploreFNIRS/+plot/temporal.m b/+exploreFNIRS/+plot/temporal.m index 0c5c784f..b60e4ae5 100644 --- a/+exploreFNIRS/+plot/temporal.m +++ b/+exploreFNIRS/+plot/temporal.m @@ -6,8 +6,8 @@ % Supports multiple grouping variables, error shading, and marker overlays. % % Reference: -% Internal exploreFNIRS visualization. Uses shadedErrorBar for error -% visualization (Rob Campbell, 2009, MATLAB File Exchange). +% Internal exploreFNIRS visualization. Shaded error regions are drawn +% with native MATLAB patch/fill primitives. % % Syntax: % temporal(gbyData, gbyVars, exSettings, handles) @@ -39,7 +39,7 @@ % % Notes: % - Typically called from exploreFNIRS GUI, not directly -% - Uses shadedErrorBar for visualization +% - Draws shaded error bands with native MATLAB patch/fill % - Supports hierarchical averaging within subjects % % See also: exploreFNIRS.plot.barchart, exploreFNIRS.plot.scatter @@ -72,11 +72,10 @@ optStrs=cellstr(get(handles.listbox_optode,'String')); selOpt=get(handles.listbox_optode,'Value'); selectedOptStr=optStrs(selOpt); -%selectedOpt=str2num(selectedOpt); if(strcmp(exSettings.ChannelMode,'Aux')) if(length(selectedBioM)>1) - error('Not supported yet!') + error('exploreFNIRS:plot:temporal:notSupported', 'Not supported yet!') end auxTable=get(handles.listbox_optode,'UserData'); selectedOpt=nan(length(selOpt)); @@ -343,7 +342,7 @@ dataTime=curFNIRS{i}.time; case 'ROI' if(~pf2_base.isnestedfield(curGrand,'ROI.HbO.data')) - error('ROI data must be calculated using a build ROI step'); + error('exploreFNIRS:plot:temporal:roiNotBuilt', 'ROI data must be calculated using a build ROI step'); end if(~isempty(curFNIRS{i})&&isfield(curFNIRS{i},'ROI')) data2plot=curFNIRS{i}.ROI; @@ -694,7 +693,9 @@ end end - legend(sH{i,b}.subH{y,x},legendGFXstrs(:)','Location', 'Best'); + lgd=legend(sH{i,b}.subH{y,x},legendGFXstrs(:)','Location', 'Best'); + lgdSty=pf2_base.plot.PlotStyle.getDefault(); + set(lgd,'TextColor',lgdSty.LegendTextColor,'Color',lgdSty.LegendBgColor); end if(exSettings.plot_task_lines) @@ -878,6 +879,7 @@ function addDebugAnnotation(figHandle,optionalstring) th.LineStyle='none'; th.HorizontalAlignment='left'; th.VerticalAlignment='bottom'; +th.Color=pf2_base.plot.PlotStyle.getDefault().ForegroundColor; curPos=th.Position; end diff --git a/+exploreFNIRS/+report/Pipeline.m b/+exploreFNIRS/+report/Pipeline.m new file mode 100644 index 00000000..66987837 --- /dev/null +++ b/+exploreFNIRS/+report/Pipeline.m @@ -0,0 +1,294 @@ +classdef Pipeline < handle +% PIPELINE Orchestrator for reproducible fNIRS report generation +% +% Configures and runs a complete analysis pipeline on an Experiment +% object, collecting figures, tables, and statistics for report output. +% +% Syntax: +% pipe = exploreFNIRS.report.Pipeline(experiment) +% pipe.addStep('lme', 'Biomarkers', {'HbO'}, 'Channels', 1:16) +% pipe.addStep('temporal', 'Biomarkers', {'HbO','HbR'}) +% pipe.run() +% exploreFNIRS.report.generate(pipe, 'output/report') +% +% Inputs: +% experiment - Experiment object (grouped and aggregated) +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group','Condition'}); +% ex.aggregate(); +% +% pipe = exploreFNIRS.report.Pipeline(ex); +% pipe.addStep('lme', 'Biomarkers', {'HbO'}, 'Channels', 1:16); +% pipe.addStep('temporal', 'Biomarkers', {'HbO'}); +% pipe.addStep('bar', 'Biomarker', 'HbO'); +% pipe.addStep('demographics', 'Variables', {'Age','Sex'}); +% pipe.run(); +% +% exploreFNIRS.report.generate(pipe, 'output/report'); +% +% See also: exploreFNIRS.report.generate, exploreFNIRS.core.Experiment + + properties + % Experiment object + experiment + + % Analysis configuration + config + + % Pipeline steps (cell array of structs) + steps + + % Results after run + figures % Struct of figure handles + tables % Struct of MATLAB tables + stats % Struct of formatted stat strings + results % Struct of raw results (e.g., LME model objects) + + % State + isRun + end + + methods + + function obj = Pipeline(experiment, varargin) + % PIPELINE Create a new report pipeline + % + % pipe = Pipeline(experiment) + % pipe = Pipeline(experiment, 'Title', 'My Study') + + if ~isa(experiment, 'exploreFNIRS.core.Experiment') + error('exploreFNIRS:report:Pipeline', ... + 'Input must be an Experiment object'); + end + + ip = inputParser; + addParameter(ip, 'Title', '', @ischar); + addParameter(ip, 'Author', '', @ischar); + addParameter(ip, 'Date', datestr(now, 'yyyy-mm-dd'), @ischar); + parse(ip, varargin{:}); + + obj.experiment = experiment; + obj.config = ip.Results; + obj.steps = {}; + obj.figures = struct(); + obj.tables = struct(); + obj.stats = struct(); + obj.results = struct(); + obj.isRun = false; + end + + + function obj = addStep(obj, stepType, varargin) + % ADDSTEP Add an analysis step to the pipeline + % + % pipe.addStep('lme', 'Biomarkers', {'HbO'}, 'Channels', 1:16) + % pipe.addStep('temporal', 'Biomarkers', {'HbO','HbR'}) + % pipe.addStep('bar', 'Biomarker', 'HbO') + % pipe.addStep('demographics', 'Variables', {'Age','Sex'}) + % pipe.addStep('connectivity', 'Method', 'pearson') + % pipe.addStep('contrast', 'Channel', 1) + % + % Valid step types: + % 'lme' - LME analysis (plotLME) + % 'temporal' - Temporal plot + % 'bar' - Bar chart + % 'demographics' - Demographics Table 1 + % 'topo' - Topo brain map of F-stats (requires prior 'lme' step) + % 'connectivity' - Connectivity analysis + % 'contrast' - Contrast table (requires prior 'lme' step) + % 'anova' - ANOVA table (requires prior 'lme' step) + + validTypes = {'lme', 'temporal', 'bar', 'demographics', ... + 'connectivity', 'contrast', 'anova', 'topo'}; + if ~ismember(lower(stepType), validTypes) + error('exploreFNIRS:report:Pipeline:addStep', ... + 'Unknown step type: %s. Valid types: %s', ... + stepType, strjoin(validTypes, ', ')); + end + + step = struct(); + step.type = lower(stepType); + step.args = varargin; + step.name = generateStepName(step.type, length(obj.steps) + 1); + + obj.steps{end+1} = step; + obj.isRun = false; + + fprintf('Added step [%d] %s: %s\n', length(obj.steps), ... + step.type, step.name); + end + + + function obj = run(obj) + % RUN Execute all pipeline steps + % + % pipe.run() + + fprintf('Running pipeline (%d steps)...\n', length(obj.steps)); + t0 = tic; + + for i = 1:length(obj.steps) + step = obj.steps{i}; + fprintf('\n--- Step %d: %s ---\n', i, step.type); + + try + runStep(obj, step); + catch ME + warning('Step %d (%s) failed: %s', i, step.type, ME.message); + end + end + + elapsed = toc(t0); + obj.isRun = true; + fprintf('\nPipeline complete (%.1f seconds)\n', elapsed); + fprintf(' Figures: %d\n', length(fieldnames(obj.figures))); + fprintf(' Tables: %d\n', length(fieldnames(obj.tables))); + fprintf(' Stats: %d\n', length(fieldnames(obj.stats))); + end + + + function s = summary(obj) + % SUMMARY Return pipeline summary as struct + % + % s = pipe.summary() + + s.title = obj.config.Title; + s.author = obj.config.Author; + s.date = obj.config.Date; + s.nSteps = length(obj.steps); + s.stepTypes = cellfun(@(x) x.type, obj.steps, 'UniformOutput', false); + s.isRun = obj.isRun; + s.nFigures = length(fieldnames(obj.figures)); + s.nTables = length(fieldnames(obj.tables)); + s.nStats = length(fieldnames(obj.stats)); + + if nargout == 0 + fprintf('\nPipeline Summary\n'); + fprintf(' Title: %s\n', s.title); + fprintf(' Steps: %d\n', s.nSteps); + fprintf(' Run: %s\n', mat2str(s.isRun)); + if s.isRun + fprintf(' Figures: %d, Tables: %d, Stats: %d\n', ... + s.nFigures, s.nTables, s.nStats); + end + clear s; + end + end + + end + + methods (Access = private) + + function runStep(obj, step) + ex = obj.experiment; + + switch step.type + case 'lme' + args = [step.args, {'Visible', 'off'}]; + [fig, res] = ex.plotLME(args{:}); + applyReportStyle(fig); + obj.figures.(step.name) = fig; + obj.results.(step.name) = res; + + % Auto-generate formatted stats per term + if ~isempty(res.anova_pval) && height(res.anova_pval) > 0 + terms = res.anova_pval.Properties.VariableNames; + for t = 1:length(terms) + statKey = sprintf('%s_%s', step.name, terms{t}); + obj.stats.(statKey) = exploreFNIRS.report.formatStats( ... + res, 'Type', 'anova', 'Term', terms{t}); + end + end + + case 'temporal' + args = [step.args, {'Visible', 'off'}]; + fig = ex.plotTemporal(args{:}); + applyReportStyle(fig); + obj.figures.(step.name) = fig; + + case 'bar' + args = [step.args, {'Visible', 'off'}]; + fig = ex.plotBar(args{:}); + applyReportStyle(fig); + obj.figures.(step.name) = fig; + + case 'demographics' + T = exploreFNIRS.report.demographicsTable(ex, step.args{:}); + obj.tables.(step.name) = T; + + case 'connectivity' + res = ex.connectivity(step.args{:}); + obj.results.(step.name) = res; + T = exploreFNIRS.report.connectivitySummary(res); + obj.tables.(step.name) = T; + + case 'contrast' + % Find most recent LME results + lmeKey = findLMEResult(obj); + if isempty(lmeKey) + warning('No LME results found. Add an ''lme'' step before ''contrast''.'); + return; + end + res = obj.results.(lmeKey); + T = exploreFNIRS.report.contrastTable(res, step.args{:}); + obj.tables.(step.name) = T; + + case 'anova' + lmeKey = findLMEResult(obj); + if isempty(lmeKey) + warning('No LME results found. Add an ''lme'' step before ''anova''.'); + return; + end + res = obj.results.(lmeKey); + T = exploreFNIRS.report.anovaTable(res, step.args{:}); + obj.tables.(step.name) = T; + + case 'topo' + % Re-run LME with ShowTopo=true to get brain map + args = [step.args, {'Visible', 'off', 'ShowTopo', true, 'ShowBar', false}]; + [fig, ~] = ex.plotLME(args{:}); + applyReportStyle(fig); + obj.figures.(step.name) = fig; + end + end + + end +end + + +%% Local helpers + +function name = generateStepName(stepType, idx) + name = sprintf('%s_%d', stepType, idx); +end + + +function key = findLMEResult(obj) + flds = fieldnames(obj.results); + key = ''; + for i = length(flds):-1:1 + if startsWith(flds{i}, 'lme_') + key = flds{i}; + return; + end + end +end + + +function applyReportStyle(fig) +% Force white background and publication styling on a figure +% Ensures report figures look correct regardless of MATLAB dark mode. + if isempty(fig) || ~isvalid(fig) + return; + end + + set(fig, 'Color', 'w'); + + % Apply publication style + sty = pf2_base.plot.PlotStyle.getPublication(); + sty.applyToFigure(fig); + + exploreFNIRS.report.forceWhiteMode(fig); +end diff --git a/+exploreFNIRS/+report/anovaTable.m b/+exploreFNIRS/+report/anovaTable.m new file mode 100644 index 00000000..91c41de5 --- /dev/null +++ b/+exploreFNIRS/+report/anovaTable.m @@ -0,0 +1,196 @@ +function T = anovaTable(results, varargin) +% ANOVATABLE Formatted ANOVA table with df, F, p, and partial eta-squared +% +% Extracts LME ANOVA results per channel and formats for publication. +% +% Syntax: +% T = exploreFNIRS.report.anovaTable(results) +% T = exploreFNIRS.report.anovaTable(results, 'Channel', 3) +% T = exploreFNIRS.report.anovaTable(results, 'AllChannels', true) +% +% Inputs: +% results - Results struct from plotLME (with .anova field) +% +% Name-Value Parameters: +% Biomarker - Biomarker index (default: 1) +% Channel - Channel index for single-channel table (default: 1) +% AllChannels - Create multi-channel summary (default: false) +% When true, returns one row per channel x term combination +% SigThreshold - Significance threshold for stars (default: 0.05) +% +% Outputs: +% T - Table with columns: Term, df1, df2, F, p, etaSq, sig +% (AllChannels adds a Channel column) +% +% Example: +% [~, results] = ex.plotLME('Channels', 1:16); +% T = exploreFNIRS.report.anovaTable(results, 'AllChannels', true); +% disp(T); +% +% See also: exploreFNIRS.report.formatPValue, exploreFNIRS.core.plotLME + + ip = inputParser; + addRequired(ip, 'results', @isstruct); + addParameter(ip, 'Biomarker', 1, @isnumeric); + addParameter(ip, 'Channel', 1, @isnumeric); + addParameter(ip, 'AllChannels', false, @islogical); + addParameter(ip, 'SigThreshold', 0.05, @isnumeric); + parse(ip, results, varargin{:}); + opts = ip.Results; + + bIdx = opts.Biomarker; + + if opts.AllChannels + T = buildMultiChannelTable(results, bIdx, opts.SigThreshold); + else + T = buildSingleChannelTable(results, bIdx, opts.Channel, opts.SigThreshold); + end +end + + +function T = buildSingleChannelTable(results, bIdx, chIdx, sigThresh) + if ~isfield(results, 'anova') || isempty(results.anova{bIdx, chIdx}) + T = table(); + return; + end + + anv = results.anova{bIdx, chIdx}; + nTerms = size(anv.FStat, 1); + + Term = cell(nTerms, 1); + df1_col = nan(nTerms, 1); + df2_col = nan(nTerms, 1); + F_col = nan(nTerms, 1); + p_col = cell(nTerms, 1); + etaSq_col = nan(nTerms, 1); + sig_col = cell(nTerms, 1); + + for i = 1:nTerms + Term{i} = getTermName(anv, i); + + F_col(i) = anv.FStat(i); + p = anv.pValue(i); + p_col{i} = exploreFNIRS.report.formatPValue(p); + + % Degrees of freedom + [d1, d2] = getDF(anv, results, bIdx, chIdx, i); + df1_col(i) = d1; + df2_col(i) = d2; + + % Partial eta-squared + if ~isnan(d1) && ~isnan(d2) && F_col(i) > 0 + etaSq_col(i) = (F_col(i) * d1) / (F_col(i) * d1 + d2); + end + + sig_col{i} = getStars(p, sigThresh); + end + + T = table(Term, df1_col, df2_col, F_col, p_col, etaSq_col, sig_col, ... + 'VariableNames', {'Term', 'df1', 'df2', 'F', 'p', 'partialEtaSq', 'Sig'}); +end + + +function T = buildMultiChannelTable(results, bIdx, sigThresh) + nCh = size(results.anova, 2); + + rows = {}; + for chIdx = 1:nCh + if isempty(results.anova{bIdx, chIdx}) + continue; + end + + anv = results.anova{bIdx, chIdx}; + nTerms = size(anv.FStat, 1); + + for i = 1:nTerms + row = struct(); + if isfield(results, 'channels') && chIdx <= length(results.channels) + row.Channel = results.channels(chIdx); + else + row.Channel = chIdx; + end + row.Term = getTermName(anv, i); + + row.F = anv.FStat(i); + p = anv.pValue(i); + row.p = exploreFNIRS.report.formatPValue(p); + + [d1, d2] = getDF(anv, results, bIdx, chIdx, i); + row.df1 = d1; + row.df2 = d2; + + if ~isnan(d1) && ~isnan(d2) && row.F > 0 + row.partialEtaSq = (row.F * d1) / (row.F * d1 + d2); + else + row.partialEtaSq = NaN; + end + + row.Sig = getStars(p, sigThresh); + rows{end+1} = row; %#ok + end + end + + if isempty(rows) + T = table(); + return; + end + + T = struct2table([rows{:}]); +end + + +function [df1, df2] = getDF(anv, results, bIdx, chIdx, termIdx) + % Try direct access (works for both dataset and table) + try + df1 = anv.DF1(termIdx); + df2 = anv.DF2(termIdx); + return; + catch + end + try + df1 = anv.DF(termIdx); + df2 = anv.DF(termIdx); + return; + catch + end + % Fall back to summary tables + if isfield(results, 'anova_df1') && ~isempty(results.anova_df1) ... + && height(results.anova_df1) >= chIdx ... + && width(results.anova_df1) >= termIdx + df1 = results.anova_df1{chIdx, termIdx}; + df2 = results.anova_df2{chIdx, termIdx}; + else + df1 = NaN; + df2 = NaN; + end +end + + +function name = getTermName(anv, idx) + % Extract term name from ANOVA result (works for table or dataset) + try + t = anv.Term(idx); + if iscell(t) + name = t{1}; + else + name = char(t); + end + catch + name = sprintf('Term%d', idx); + end +end + + +function s = getStars(p, thresh) + if p < 0.001 + s = '***'; + elseif p < 0.01 + s = '**'; + elseif p < thresh + s = '*'; + elseif p < 0.1 + s = '+'; + else + s = ''; + end +end diff --git a/+exploreFNIRS/+report/connectivitySummary.m b/+exploreFNIRS/+report/connectivitySummary.m new file mode 100644 index 00000000..135bc473 --- /dev/null +++ b/+exploreFNIRS/+report/connectivitySummary.m @@ -0,0 +1,87 @@ +function T = connectivitySummary(connResult, varargin) +% CONNECTIVITYSUMMARY Summary statistics from connectivity analysis +% +% Extracts key metrics from Experiment.connectivity() output and formats +% as a publication-ready table. +% +% Syntax: +% T = exploreFNIRS.report.connectivitySummary(result) +% T = exploreFNIRS.report.connectivitySummary(result, 'Metric', 'global') +% +% Inputs: +% connResult - Struct array from Experiment.connectivity() (one per group) +% +% Name-Value Parameters: +% Metric - 'global' (default): mean of all edges +% 'diagonal': mean self-connections (if applicable) +% 'threshold': count of edges above threshold +% Threshold - Coupling threshold for 'threshold' metric (default: 0.3) +% Precision - Decimal places (default: 3) +% +% Outputs: +% T - Table with Group, N, Mean, SD, SEM, Method, Biomarker columns +% +% Example: +% result = ex.connectivity('Method', 'pearson'); +% T = exploreFNIRS.report.connectivitySummary(result); +% disp(T); +% +% See also: exploreFNIRS.connectivity.computeMatrix + + ip = inputParser; + addRequired(ip, 'connResult', @isstruct); + addParameter(ip, 'Metric', 'global', @ischar); + addParameter(ip, 'Threshold', 0.3, @isnumeric); + addParameter(ip, 'Precision', 3, @isnumeric); + parse(ip, connResult, varargin{:}); + opts = ip.Results; + + nGroups = length(connResult); + prec = opts.Precision; + + Group = cell(nGroups, 1); + N = nan(nGroups, 1); + MeanStr = cell(nGroups, 1); + SDStr = cell(nGroups, 1); + SEMStr = cell(nGroups, 1); + Method = cell(nGroups, 1); + Biomarker = cell(nGroups, 1); + + for g = 1:nGroups + cr = connResult(g); + Group{g} = cr.label; + N(g) = cr.N; + Method{g} = cr.method; + Biomarker{g} = cr.biomarker; + + switch lower(opts.Metric) + case 'global' + % Mean of upper triangle (exclude diagonal) + mask = triu(true(size(cr.Mean)), 1); + vals = cr.Mean(mask); + sdVals = cr.SD(mask); + semVals = cr.SEM(mask); + MeanStr{g} = sprintf('%.*f', prec, mean(vals, 'omitnan')); + SDStr{g} = sprintf('%.*f', prec, mean(sdVals, 'omitnan')); + SEMStr{g} = sprintf('%.*f', prec, mean(semVals, 'omitnan')); + + case 'threshold' + mask = triu(true(size(cr.Mean)), 1); + vals = cr.Mean(mask); + nAbove = sum(vals > opts.Threshold); + nTotal = sum(mask(:)); + MeanStr{g} = sprintf('%d/%d (%.0f%%)', nAbove, nTotal, ... + 100 * nAbove / max(nTotal, 1)); + SDStr{g} = '-'; + SEMStr{g} = '-'; + + otherwise + MeanStr{g} = '-'; + SDStr{g} = '-'; + SEMStr{g} = '-'; + end + end + + T = table(Group, N, MeanStr, SDStr, SEMStr, Method, Biomarker, ... + 'VariableNames', {'Group', 'N', 'Mean', 'SD', 'SEM', 'Method', 'Biomarker'}); +end diff --git a/+exploreFNIRS/+report/contrastTable.m b/+exploreFNIRS/+report/contrastTable.m new file mode 100644 index 00000000..58608bfa --- /dev/null +++ b/+exploreFNIRS/+report/contrastTable.m @@ -0,0 +1,115 @@ +function T = contrastTable(results, varargin) +% CONTRASTTABLE Formatted contrast table with significance stars and CI +% +% Extracts LME contrasts from plotLME results and formats for publication. +% +% Syntax: +% T = exploreFNIRS.report.contrastTable(results) +% T = exploreFNIRS.report.contrastTable(results, 'Channel', 3, 'CI', true) +% +% Inputs: +% results - Results struct from plotLME (with .contrasts field) +% +% Name-Value Parameters: +% Biomarker - Biomarker index (default: 1) +% Channel - Channel index (default: 1) +% CI - Include 95% CI column (default: false) +% Alpha - Confidence level (default: 0.05 -> 95% CI) +% UseCorrected - Use corrected p-values if available (default: true) +% +% Outputs: +% T - Formatted table with columns: Contrast, Delta, SD, F, df, p, sig +% If CI=true: adds CI column "[lower, upper]" +% +% Example: +% [~, results] = ex.plotLME('Channels', 1:5); +% T = exploreFNIRS.report.contrastTable(results, 'Channel', 1, 'CI', true); +% disp(T); +% +% See also: exploreFNIRS.report.formatPValue, exploreFNIRS.fx.autoContrast + + ip = inputParser; + addRequired(ip, 'results', @isstruct); + addParameter(ip, 'Biomarker', 1, @isnumeric); + addParameter(ip, 'Channel', 1, @isnumeric); + addParameter(ip, 'CI', false, @islogical); + addParameter(ip, 'Alpha', 0.05, @isnumeric); + addParameter(ip, 'UseCorrected', true, @islogical); + parse(ip, results, varargin{:}); + opts = ip.Results; + + bIdx = opts.Biomarker; + chIdx = opts.Channel; + + if ~isfield(results, 'contrasts') || isempty(results.contrasts{bIdx, chIdx}) + T = table(); + return; + end + + cTable = results.contrasts{bIdx, chIdx}; + if isempty(cTable) || height(cTable) == 0 + T = table(); + return; + end + + nRows = height(cTable); + + Contrast = cTable.Properties.RowNames; + Delta = arrayfun(@(x) sprintf('%.3f', x), cTable.deltaE, 'UniformOutput', false); + SD = arrayfun(@(x) sprintf('%.3f', x), cTable.SD, 'UniformOutput', false); + F_str = arrayfun(@(x) sprintf('%.2f', x), cTable.F, 'UniformOutput', false); + + % Format df + df = cell(nRows, 1); + for i = 1:nRows + if cTable.df2(i) == round(cTable.df2(i)) + df{i} = sprintf('%d, %d', cTable.df1(i), cTable.df2(i)); + else + df{i} = sprintf('%d, %.1f', cTable.df1(i), cTable.df2(i)); + end + end + + % P-values + if opts.UseCorrected && ismember('pVal_corr', cTable.Properties.VariableNames) + pVals = cTable.pVal_corr; + else + pVals = cTable.pVal; + end + + p_str = cell(nRows, 1); + sig = cell(nRows, 1); + for i = 1:nRows + p_str{i} = exploreFNIRS.report.formatPValue(pVals(i)); + sig{i} = strtrim(string(getStars(pVals(i)))); + end + + T = table(Contrast, Delta, SD, F_str, df, p_str, sig, ... + 'VariableNames', {'Contrast', 'Delta', 'SD', 'F', 'df', 'p', 'Sig'}); + + % Optional CI + if opts.CI + tCrit = tinv(1 - opts.Alpha/2, cTable.df2); + ciLow = cTable.deltaE - tCrit .* cTable.SD; + ciHigh = cTable.deltaE + tCrit .* cTable.SD; + CI = cell(nRows, 1); + for i = 1:nRows + CI{i} = sprintf('[%.3f, %.3f]', ciLow(i), ciHigh(i)); + end + T.CI = CI; + end +end + + +function s = getStars(p) + if p < 0.001 + s = '***'; + elseif p < 0.01 + s = '**'; + elseif p < 0.05 + s = '*'; + elseif p < 0.1 + s = '+'; + else + s = ''; + end +end diff --git a/+exploreFNIRS/+report/correlationTable.m b/+exploreFNIRS/+report/correlationTable.m new file mode 100644 index 00000000..11dfe4ab --- /dev/null +++ b/+exploreFNIRS/+report/correlationTable.m @@ -0,0 +1,90 @@ +function T = correlationTable(R, P, varargin) +% CORRELATIONTABLE Formatted correlation matrix with significance stars +% +% Syntax: +% T = exploreFNIRS.report.correlationTable(R, P) +% T = exploreFNIRS.report.correlationTable(R, P, 'Labels', labels) +% +% Inputs: +% R - [N x N] correlation coefficient matrix +% P - [N x N] p-value matrix +% +% Name-Value Parameters: +% Labels - Cell array of variable names (default: {'V1','V2',...}) +% Precision - Decimal places (default: 3) +% Triangle - 'lower' (default), 'upper', or 'full' +% +% Outputs: +% T - Table with formatted 'r*' strings (stars indicate significance) +% '*' p < .05, '**' p < .01, '***' p < .001 +% +% Example: +% [R, P] = corrcoef(randn(20, 4)); +% T = exploreFNIRS.report.correlationTable(R, P, ... +% 'Labels', {'Ch1','Ch2','Ch3','Ch4'}); +% disp(T); +% +% See also: exploreFNIRS.report.formatPValue, corrcoef + + ip = inputParser; + addRequired(ip, 'R', @(x) isnumeric(x) && size(x,1) == size(x,2)); + addRequired(ip, 'P', @(x) isnumeric(x) && size(x,1) == size(x,2)); + addParameter(ip, 'Labels', {}, @iscell); + addParameter(ip, 'Precision', 3, @isnumeric); + addParameter(ip, 'Triangle', 'lower', @ischar); + parse(ip, R, P, varargin{:}); + opts = ip.Results; + + n = size(R, 1); + + if isempty(opts.Labels) + labels = arrayfun(@(x) sprintf('V%d', x), 1:n, 'UniformOutput', false); + else + labels = opts.Labels; + end + + cells = cell(n, n); + + for i = 1:n + for j = 1:n + switch lower(opts.Triangle) + case 'lower' + show = (j < i); + case 'upper' + show = (j > i); + case 'full' + show = (i ~= j); + otherwise + show = (j < i); + end + + if i == j + cells{i, j} = '-'; + elseif show + rStr = sprintf('%.*f', opts.Precision, R(i,j)); + rStr = regexprep(rStr, '^0\.', '.'); + rStr = regexprep(rStr, '^-0\.', '-.'); + stars = getStars(P(i,j)); + cells{i, j} = [rStr, stars]; + else + cells{i, j} = ''; + end + end + end + + T = cell2table(cells, 'VariableNames', matlab.lang.makeValidName(labels), ... + 'RowNames', labels); +end + + +function s = getStars(p) + if p < 0.001 + s = '***'; + elseif p < 0.01 + s = '**'; + elseif p < 0.05 + s = '*'; + else + s = ''; + end +end diff --git a/+exploreFNIRS/+report/demographicsTable.m b/+exploreFNIRS/+report/demographicsTable.m new file mode 100644 index 00000000..b9621f54 --- /dev/null +++ b/+exploreFNIRS/+report/demographicsTable.m @@ -0,0 +1,593 @@ +function T = demographicsTable(experiment, varargin) +% DEMOGRAPHICSTABLE Publication-style Table 1 demographics summary +% +% Creates a formatted demographics summary at the subject level. +% Numeric variables are reported as M (SD), categorical variables as +% n (%) or just %. Supports optional group comparisons with t-tests +% (numeric) and chi-squared tests (categorical). +% +% When a GroupBy variable is between-subjects (constant within each +% subject), each subject appears in exactly one group and statistical +% comparisons are reported. When GroupBy is within-subjects (varies +% within a subject, e.g. Condition), subjects are counted in every +% group they appear in and the stats column is omitted. +% +% Syntax: +% T = exploreFNIRS.report.demographicsTable(ex) +% T = exploreFNIRS.report.demographicsTable(ex, 'Variables', {'Age','Sex'}) +% T = exploreFNIRS.report.demographicsTable(ex, 'GroupBy', 'Group') +% T = exploreFNIRS.report.demographicsTable(ex, 'GroupBy', 'Group', 'Paired', true) +% T = exploreFNIRS.report.demographicsTable(ex, 'Format', 'console') +% T = exploreFNIRS.report.demographicsTable(ex, 'Format', 'latex') +% +% Inputs: +% experiment - Experiment object or MATLAB table. When an Experiment +% is provided, the selected metadata table is used. +% +% Name-Value Parameters: +% Variables - Cell array of variable names to summarize +% (default: common demographics — Age, Sex, Gender, +% Group, Subgroup, Race, Ethnicity, Handedness, +% Education, SES — filtered to those present in data) +% GroupBy - Grouping variable name (default: '' = no grouping) +% SubjectVar - Column identifying unique subjects +% (default: 'SubjectID') +% Paired - Use paired t-test for numeric variables when +% exactly 2 groups (default: false) +% Precision - Decimal places for M (SD) (default: 1) +% CategoricalFormat - How to display categorical levels: +% 'counts' - 'n (%)' (default) +% 'percent' - '% only' +% Labels - Struct mapping variable names to display labels +% (default: struct()). Example: +% struct('Age', 'Age (years)', 'Sex', 'Biological Sex') +% Format - Output format: 'table' (default), 'console', +% or 'latex' +% +% Outputs: +% T - MATLAB table with one row per variable (or sub-level), one +% column per group plus Total and (optionally) Statistic. +% +% Example: +% ex = exploreFNIRS.core.Experiment(allData); +% T = ex.demographicsTable('GroupBy', 'Group'); +% disp(T); +% +% % Console output with percentage-only categorical display +% ex.demographicsTable('GroupBy', 'Group', ... +% 'Format', 'console', 'CategoricalFormat', 'percent'); +% +% See also: exploreFNIRS.core.Experiment + + ip = inputParser; + addRequired(ip, 'experiment'); + addParameter(ip, 'Variables', {}, @iscell); + addParameter(ip, 'GroupBy', '', @(x) ischar(x) || isstring(x)); + addParameter(ip, 'SubjectVar', 'SubjectID', @(x) ischar(x) || isstring(x)); + addParameter(ip, 'Paired', false, @islogical); + addParameter(ip, 'Precision', 1, @isnumeric); + addParameter(ip, 'CategoricalFormat', 'counts', ... + @(x) ismember(x, {'counts', 'percent'})); + addParameter(ip, 'Format', 'table', ... + @(x) ismember(x, {'table', 'console', 'latex'})); + addParameter(ip, 'Labels', struct(), @isstruct); + parse(ip, experiment, varargin{:}); + opts = ip.Results; + opts.GroupBy = char(opts.GroupBy); + opts.SubjectVar = char(opts.SubjectVar); + + % --- Step 1: Extract full table --- + if istable(experiment) + fullTable = experiment; + elseif isa(experiment, 'exploreFNIRS.core.Experiment') + fullTable = experiment.getSelectedTable(); + else + error('exploreFNIRS:report:demographicsTable', ... + 'Input must be an Experiment object or a table.'); + end + + % --- Step 2: Deduplicate to subject level --- + if ~ismember(opts.SubjectVar, fullTable.Properties.VariableNames) + error('exploreFNIRS:report:demographicsTable', ... + 'SubjectVar ''%s'' not found in table.', opts.SubjectVar); + end + subjectIDs = fullTable.(opts.SubjectVar); + [~, firstIdx] = unique(makeStringCol(subjectIDs), 'stable'); + subjTable = fullTable(firstIdx, :); + + % --- Step 3: Determine variables to summarize --- + if isempty(opts.Variables) + % Default: common demographic variables that exist in the table + defaultDemographics = {'Age', 'Sex', 'Gender', 'Group', 'Subgroup', ... + 'Race', 'Ethnicity', 'Handedness', 'Education', 'SES'}; + available = fullTable.Properties.VariableNames; + % Remove GroupBy from candidates (it's the column header, not a row) + if ~isempty(opts.GroupBy) + defaultDemographics = setdiff(defaultDemographics, {opts.GroupBy}, 'stable'); + end + vars = intersect(defaultDemographics, available, 'stable'); + if isempty(vars) + % Fallback: use all non-internal columns (original behavior) + exclude = {opts.SubjectVar, 'missingFNIRS', 'segmentIndex', ... + 'fileIndex', 'blockNumber', 'markerCode', ... + 'markerIndex', 'amplitude', 'filename', ... + 'probename', 'Session', 'Trial', 'Block', ... + 'BlockNumber'}; + if ~isempty(opts.GroupBy) + exclude = [exclude, {opts.GroupBy}]; + end + vars = setdiff(available, exclude, 'stable'); + end + else + vars = opts.Variables; + end + + % --- Step 4: Determine grouping --- + hasGrouping = ~isempty(opts.GroupBy); + isBetween = false; + groupLabels = {}; + groupSubjTables = {}; + + if hasGrouping + if ~ismember(opts.GroupBy, fullTable.Properties.VariableNames) + error('exploreFNIRS:report:demographicsTable', ... + 'GroupBy variable ''%s'' not found in table.', opts.GroupBy); + end + + % Check if grouping varies within any subject + isBetween = isGroupingBetween(fullTable, opts.GroupBy, opts.SubjectVar); + + groupCol = makeStringCol(fullTable.(opts.GroupBy)); + levels = unique(groupCol, 'stable'); + + if isBetween + % Between-subject: each subject in exactly one group + subjGroupCol = makeStringCol(subjTable.(opts.GroupBy)); + for g = 1:length(levels) + groupLabels{end+1} = char(levels(g)); %#ok + mask = subjGroupCol == levels(g); + groupSubjTables{end+1} = subjTable(mask, :); %#ok + end + else + % Within-subject: subject appears in every group they have data for + for g = 1:length(levels) + groupLabels{end+1} = char(levels(g)); %#ok + % Find subjects who have at least one row with this level + mask = groupCol == levels(g); + subjsInGroup = unique(makeStringCol(fullTable.(opts.SubjectVar)(mask))); + allSubjs = makeStringCol(subjTable.(opts.SubjectVar)); + groupSubjTables{end+1} = subjTable(ismember(allSubjs, subjsInGroup), :); %#ok + end + end + end + + % --- Step 5: Build column structure --- + nGroups = length(groupLabels); + showStats = hasGrouping && isBetween && nGroups >= 2; + + if hasGrouping + colNames = [groupLabels, {'Total'}]; + else + colNames = {'All'}; + end + if showStats + colNames = [colNames, {'Statistic'}]; + end + nCols = length(colNames); + + % --- Step 6: Build rows --- + rowLabels = {}; % unique names for table RowNames + displayLabels = {}; % original names for console/latex display + rowData = cell(0, nCols); + + % N row + nRow = cell(1, nCols); + if hasGrouping + for g = 1:nGroups + nRow{g} = sprintf('N = %d', height(groupSubjTables{g})); + end + nRow{nGroups+1} = sprintf('N = %d', height(subjTable)); + else + nRow{1} = sprintf('N = %d', height(subjTable)); + end + if showStats + nRow{end} = ''; + end + rowLabels{end+1} = 'N'; + displayLabels{end+1} = 'N'; + rowData(end+1, :) = nRow; + + for v = 1:length(vars) + varName = vars{v}; + if ~ismember(varName, fullTable.Properties.VariableNames) + continue; + end + + % Resolve display label from Labels override + dispName = resolveLabel(varName, opts.Labels); + col = subjTable.(varName); + + if isnumeric(col) + % --- Numeric variable: M (SD) --- + rowLabels{end+1} = varName; %#ok + displayLabels{end+1} = dispName; %#ok + row = cell(1, nCols); + + if hasGrouping + groupVals = cell(1, nGroups); + for g = 1:nGroups + vals = groupSubjTables{g}.(varName); + vals = vals(~isnan(vals)); + groupVals{g} = vals; + row{g} = formatMSD(vals, opts.Precision); + end + allVals = col(~isnan(col)); + row{nGroups+1} = formatMSD(allVals, opts.Precision); + + if showStats + row{end} = numericStat(groupVals, opts.Paired); + end + else + allVals = col(~isnan(col)); + row{1} = formatMSD(allVals, opts.Precision); + end + rowData(end+1, :) = row; %#ok + + elseif iscategorical(col) || isstring(col) || iscellstr(col) || iscell(col) + % --- Categorical variable --- + allCol = makeStringCol(col); + levels = unique(allCol(~ismissing(allCol)), 'stable'); + + % Header row for variable name + rowLabels{end+1} = varName; %#ok + displayLabels{end+1} = dispName; %#ok + headerRow = cell(1, nCols); + for c = 1:nCols + headerRow{c} = ''; + end + rowData(end+1, :) = headerRow; %#ok + + % Compute contingency data for stats + if showStats + contTable = zeros(nGroups, length(levels)); + end + + % One row per level + for lv = 1:length(levels) + levelLabel = sprintf(' %s', levels(lv)); + rowLabels{end+1} = sprintf('%s_%s', varName, levels(lv)); %#ok + displayLabels{end+1} = levelLabel; %#ok + row = cell(1, nCols); + + if hasGrouping + for g = 1:nGroups + gCol = makeStringCol(groupSubjTables{g}.(varName)); + n = sum(gCol == levels(lv)); + total = sum(~ismissing(gCol)); + row{g} = formatCategorical(n, total, opts.CategoricalFormat); + if showStats + contTable(g, lv) = n; + end + end + % Total column + n = sum(allCol == levels(lv)); + total = sum(~ismissing(allCol)); + row{nGroups+1} = formatCategorical(n, total, opts.CategoricalFormat); + else + n = sum(allCol == levels(lv)); + total = sum(~ismissing(allCol)); + row{1} = formatCategorical(n, total, opts.CategoricalFormat); + end + + % Stats on last level row only + if showStats && lv == length(levels) + row{end} = chiSquaredTest(contTable); + elseif showStats + row{end} = ''; + end + rowData(end+1, :) = row; %#ok + end + end + end + + % --- Step 7: Build output table --- + safeNames = matlab.lang.makeValidName(colNames); + % Replace empty row labels to avoid cell2table RowNames error + for ri = 1:length(rowLabels) + if isempty(rowLabels{ri}) || strtrim(rowLabels{ri}) == "" + rowLabels{ri} = sprintf('Var_%d', ri); + end + end + % Ensure unique row labels (categorical sub-levels may collide) + rowLabels = matlab.lang.makeUniqueStrings(rowLabels); + T = cell2table(rowData, 'VariableNames', safeNames, 'RowNames', rowLabels); + + % --- Step 8: Format output --- + switch opts.Format + case 'console' + printConsole(T, colNames, displayLabels); + case 'latex' + printLatex(T, colNames, displayLabels); + end +end + + +% ========================================================================= +% Local helper functions +% ========================================================================= + +function s = formatMSD(vals, prec) +% Format mean (SD) string + if isempty(vals) + s = '-'; + return; + end + s = sprintf('%.*f (%.*f)', prec, mean(vals), prec, std(vals)); +end + + +function s = formatCategorical(n, total, fmt) +% Format categorical count + if total == 0 + s = '-'; + return; + end + pct = 100 * n / total; + switch fmt + case 'counts' + s = sprintf('%d (%.0f%%)', n, pct); + case 'percent' + s = sprintf('%.0f%%', pct); + end +end + + +function tf = isGroupingBetween(tbl, groupVar, subjVar) +% Check whether grouping is between-subjects (constant within each subject) + subjCol = makeStringCol(tbl.(subjVar)); + groupCol = makeStringCol(tbl.(groupVar)); + uSubj = unique(subjCol, 'stable'); + tf = true; + for i = 1:length(uSubj) + mask = subjCol == uSubj(i); + if length(unique(groupCol(mask))) > 1 + tf = false; + return; + end + end +end + + +function s = numericStat(groupVals, paired) +% Compute t-test statistic string for numeric variable + if length(groupVals) ~= 2 + % For >2 groups, use one-way ANOVA + allVals = []; + groupIdx = []; + for g = 1:length(groupVals) + allVals = [allVals; groupVals{g}(:)]; %#ok + groupIdx = [groupIdx; repmat(g, length(groupVals{g}), 1)]; %#ok + end + if isempty(allVals) + s = ''; + return; + end + [~, tbl] = anova1(allVals, groupIdx, 'off'); + F = tbl{2, 5}; + df1 = tbl{2, 3}; + df2 = tbl{3, 3}; + p = tbl{2, 6}; + s = sprintf('F(%d,%d) = %.2f, p %s', df1, df2, F, formatP(p)); + return; + end + + x = groupVals{1}; + y = groupVals{2}; + if isempty(x) || isempty(y) + s = ''; + return; + end + + if paired + % Paired t-test + n = min(length(x), length(y)); + x = x(1:n); + y = y(1:n); + d = x - y; + tStat = mean(d) / (std(d) / sqrt(n)); + df = n - 1; + p = 2 * (1 - pf2_base.compat.tcdf(abs(tStat), df)); + s = sprintf('t(%d) = %.2f, p %s', df, tStat, formatP(p)); + else + % Unpaired (Welch's) t-test + n1 = length(x); + n2 = length(y); + m1 = mean(x); + m2 = mean(y); + s1 = std(x); + s2 = std(y); + se = sqrt(s1^2/n1 + s2^2/n2); + if se == 0 + s = 't(-) = NaN'; + return; + end + tStat = (m1 - m2) / se; + % Welch-Satterthwaite degrees of freedom + num = (s1^2/n1 + s2^2/n2)^2; + den = (s1^2/n1)^2/(n1-1) + (s2^2/n2)^2/(n2-1); + df = num / den; + p = 2 * (1 - pf2_base.compat.tcdf(abs(tStat), df)); + s = sprintf('t(%.1f) = %.2f, p %s', df, tStat, formatP(p)); + end +end + + +function s = chiSquaredTest(contTable) +% Chi-squared test of independence +% contTable: [nGroups x nLevels] matrix of observed counts + observed = contTable; + rowSums = sum(observed, 2); + colSums = sum(observed, 1); + total = sum(rowSums); + + if total == 0 + s = ''; + return; + end + + expected = rowSums * colSums / total; + + % Avoid division by zero in expected + valid = expected > 0; + if ~any(valid(:)) + s = ''; + return; + end + + chi2 = sum((observed(valid) - expected(valid)).^2 ./ expected(valid)); + df = (size(observed, 1) - 1) * (size(observed, 2) - 1); + + if df <= 0 + s = ''; + return; + end + + p = 1 - chi2cdf(chi2, df); + s = sprintf('%s(%d) = %.2f, p %s', char(0x03C7), df, chi2, formatP(p)); %#ok chi symbol +end + + +function s = formatP(p) +% Format p-value string + if p < 0.001 + s = '< .001'; + else + s = sprintf('= %.3f', p); + s = strrep(s, '= 0.', '= .'); + end +end + + +function col = makeStringCol(col) +% Convert any column type to string for safe comparison + if iscategorical(col) + col = string(col); + elseif iscell(col) + col = string(col); + elseif isnumeric(col) + col = string(arrayfun(@num2str, col, 'UniformOutput', false)); + end +end + + +function printConsole(T, colNames, displayLabels) +% Print demographics table to console with aligned columns + nCols = width(T); + nRows = height(T); + rowNames = displayLabels; + + % Convert all cells to strings + strs = cell(nRows, nCols); + for c = 1:nCols + for r = 1:nRows + strs{r, c} = char(string(T{r, c})); + end + end + + % Compute column widths + labelWidth = max(cellfun(@length, rowNames)); + labelWidth = max(labelWidth, 10); + colWidths = zeros(1, nCols); + for c = 1:nCols + colWidths(c) = max(length(colNames{c}), ... + max(cellfun(@length, strs(:, c)))); + end + + % Header + fprintf('\n %-*s', labelWidth, 'Variable'); + for c = 1:nCols + fprintf(' %-*s', colWidths(c), colNames{c}); + end + fprintf('\n'); + + % Divider + fprintf(' %s', repmat('-', 1, labelWidth)); + for c = 1:nCols + fprintf(' %s', repmat('-', 1, colWidths(c))); + end + fprintf('\n'); + + % Data rows + for r = 1:nRows + fprintf(' %-*s', labelWidth, rowNames{r}); + for c = 1:nCols + fprintf(' %-*s', colWidths(c), strs{r, c}); + end + fprintf('\n'); + end + fprintf('\n'); +end + + +function printLatex(T, colNames, displayLabels) +% Print demographics table as LaTeX booktabs tabular + nCols = width(T); + nRows = height(T); + rowNames = displayLabels; + + % Column alignment: l for label, l for each data column + alignStr = repmat('l', 1, nCols + 1); + + fprintf('\\begin{table}[htbp]\n'); + fprintf('\\centering\n'); + fprintf('\\caption{Participant Demographics}\n'); + fprintf('\\begin{tabular}{%s}\n', alignStr); + fprintf('\\toprule\n'); + + % Header + fprintf('Variable'); + for c = 1:nCols + fprintf(' & %s', latexEscape(colNames{c})); + end + fprintf(' \\\\\n'); + fprintf('\\midrule\n'); + + % Data rows + for r = 1:nRows + label = latexEscape(rowNames{r}); + % Indent sub-levels (they start with spaces) + if length(rowNames{r}) > 2 && rowNames{r}(1) == ' ' + label = ['\quad ', strtrim(label)]; + end + fprintf('%s', label); + for c = 1:nCols + val = char(string(T{r, c})); + fprintf(' & %s', latexEscape(val)); + end + fprintf(' \\\\\n'); + end + + fprintf('\\bottomrule\n'); + fprintf('\\end{tabular}\n'); + fprintf('\\end{table}\n'); +end + + +function s = latexEscape(s) +% Escape special LaTeX characters + s = strrep(s, '_', '\_'); + s = strrep(s, '%', '\%'); + s = strrep(s, '&', '\&'); + % Convert chi symbol to LaTeX + s = strrep(s, char(0x03C7), '$\chi^2$'); +end + + +function label = resolveLabel(varName, labelsStruct) +% Look up display label from Labels struct, defaulting to variable name + if isfield(labelsStruct, varName) + label = labelsStruct.(varName); + else + label = varName; + end +end diff --git a/+exploreFNIRS/+report/forceWhiteMode.m b/+exploreFNIRS/+report/forceWhiteMode.m new file mode 100644 index 00000000..8bb8f16c --- /dev/null +++ b/+exploreFNIRS/+report/forceWhiteMode.m @@ -0,0 +1,135 @@ +function forceWhiteMode(fig) +% FORCEWHITEMODE Override MATLAB dark mode colors for report output +% +% Forces all figure elements to use light-mode colors (white backgrounds, +% black text) regardless of the user's MATLAB theme. Call after creating +% or styling a figure that will be saved for reports. +% +% Syntax: +% exploreFNIRS.report.forceWhiteMode(fig) +% +% Inputs: +% fig - Figure handle +% +% See also: exploreFNIRS.report.Pipeline, exploreFNIRS.report.generate + + if isempty(fig) || ~isvalid(fig) + return; + end + + set(fig, 'Color', 'w'); + set(fig, 'InvertHardcopy', 'on'); + + % --- Axes --- + axList = findall(fig, 'Type', 'axes'); + for a = 1:length(axList) + ax = axList(a); + set(ax, 'Color', 'w'); + set(ax, 'XColor', 'k', 'YColor', 'k'); + if isprop(ax, 'ZColor') + set(ax, 'ZColor', 'k'); + end + if isprop(ax, 'GridColor') + set(ax, 'GridColor', [0.15 0.15 0.15]); + end + + % Title and labels + if ~isempty(ax.Title), set(ax.Title, 'Color', 'k'); end + if ~isempty(ax.XLabel), set(ax.XLabel, 'Color', 'k'); end + if ~isempty(ax.YLabel), set(ax.YLabel, 'Color', 'k'); end + if isprop(ax, 'ZLabel') && ~isempty(ax.ZLabel) + set(ax.ZLabel, 'Color', 'k'); + end + end + + % --- Legends --- + legList = findall(fig, 'Type', 'legend'); + for l = 1:length(legList) + leg = legList(l); + set(leg, 'Color', 'w'); + set(leg, 'TextColor', 'k'); + set(leg, 'EdgeColor', [0.5 0.5 0.5]); + set(leg, 'Box', 'on'); + end + + % --- All text objects (catches subplot titles, tick labels, etc.) --- + txtList = findall(fig, 'Type', 'text'); + for t = 1:length(txtList) + set(txtList(t), 'Color', 'k'); + end + + % --- Sgtitle: lives in a hidden SubplotText or on a separate axes --- + % findall with '-depth' catches all children including hidden + allChildren = findall(fig); + for c = 1:length(allChildren) + obj = allChildren(c); + cls = class(obj); + % SubplotText is the sgtitle container in R2018b+ + if contains(cls, 'SubplotText') || contains(cls, 'subplottext') + try + set(obj, 'Color', 'k'); + catch + end + % Also fix children of the SubplotText + try + kids = allobj_text(obj); + for k = 1:length(kids) + set(kids(k), 'Color', 'k'); + end + catch + end + end + % Annotation textboxes + if contains(cls, 'Annotation') || contains(cls, 'textbox') + try + if isprop(obj, 'Color') + set(obj, 'Color', 'k'); + end + if isprop(obj, 'BackgroundColor') + set(obj, 'BackgroundColor', 'none'); + end + catch + end + end + end + + % --- Brute force: any object with a Color property set to light gray --- + % This catches sgtitle and other dark-mode themed elements + for c = 1:length(allChildren) + obj = allChildren(c); + try + if isprop(obj, 'Color') && ~ischar(get(obj, 'Color')) + clr = get(obj, 'Color'); + if isnumeric(clr) && numel(clr) == 3 + % If it's a light/medium gray (dark mode text color), + % force to black + brightness = mean(clr); + if brightness > 0.5 && brightness < 0.99 + set(obj, 'Color', 'k'); + end + end + end + catch + end + % Fix any white-on-dark boxes + try + if isprop(obj, 'BackgroundColor') && ~ischar(get(obj, 'BackgroundColor')) + bg = get(obj, 'BackgroundColor'); + if isnumeric(bg) && numel(bg) == 3 && mean(bg) < 0.3 + set(obj, 'BackgroundColor', 'w'); + end + end + catch + end + end +end + + +function txts = allobj_text(parent) +% Find all text objects inside a parent (recursive) + try + txts = findall(parent, 'Type', 'text'); + catch + txts = []; + end +end diff --git a/+exploreFNIRS/+report/formatPValue.m b/+exploreFNIRS/+report/formatPValue.m new file mode 100644 index 00000000..782e1405 --- /dev/null +++ b/+exploreFNIRS/+report/formatPValue.m @@ -0,0 +1,57 @@ +function str = formatPValue(p, varargin) +% FORMATPVALUE APA-style p-value formatting +% +% Formats p-values according to APA 7th edition guidelines: +% - No leading zero (e.g., .045 not 0.045) +% - Three decimal places for p >= .001 +% - "< .001" for p < .001 +% +% Syntax: +% str = exploreFNIRS.report.formatPValue(p) +% str = exploreFNIRS.report.formatPValue(p, 'Precision', 3) +% str = exploreFNIRS.report.formatPValue(p, 'Prefix', true) +% +% Inputs: +% p - Scalar p-value (0 to 1) +% +% Name-Value Parameters: +% Precision - Number of decimal places (default: 3) +% Prefix - Include "p = " or "p < " prefix (default: false) +% +% Outputs: +% str - Formatted string (e.g., '.045', '< .001', 'p = .045') +% +% Example: +% formatPValue(0.045) % '.045' +% formatPValue(0.0003) % '< .001' +% formatPValue(0.045, 'Prefix', true) % 'p = .045' +% +% See also: exploreFNIRS.report.formatStats + + ip = inputParser; + addRequired(ip, 'p', @(x) isnumeric(x) && isscalar(x)); + addParameter(ip, 'Precision', 3, @(x) isnumeric(x) && isscalar(x)); + addParameter(ip, 'Prefix', false, @islogical); + parse(ip, p, varargin{:}); + prec = ip.Results.Precision; + usePrefix = ip.Results.Prefix; + + threshold = 10^(-prec); + + if p < threshold + valStr = sprintf('< .%s1', repmat('0', 1, prec - 1)); + if usePrefix + str = ['p ', valStr]; + else + str = valStr; + end + else + raw = sprintf('%.*f', prec, p); + valStr = regexprep(raw, '^0', ''); + if usePrefix + str = ['p = ', valStr]; + else + str = valStr; + end + end +end diff --git a/+exploreFNIRS/+report/formatStats.m b/+exploreFNIRS/+report/formatStats.m new file mode 100644 index 00000000..b962f726 --- /dev/null +++ b/+exploreFNIRS/+report/formatStats.m @@ -0,0 +1,219 @@ +function str = formatStats(results, varargin) +% FORMATSTATS APA-style statistical result string from LME or GLM output +% +% Generates formatted strings like: +% "F(1, 23.4) = 5.67, p = .018" +% "t(45) = 2.31, p = .025, d = 0.68" +% +% Syntax: +% str = exploreFNIRS.report.formatStats(results) +% str = exploreFNIRS.report.formatStats(results, 'Type', 'anova') +% str = exploreFNIRS.report.formatStats(results, 'Channel', 1, 'Term', 'Group') +% +% Inputs: +% results - Results struct from plotLME or fitGLM: +% LME: .anova (cell of ANOVA tables), .contrasts (cell of tables) +% GLM: .beta, .tstat, .pval, .se, .dof +% +% Name-Value Parameters: +% Type - 'anova' (default), 'contrast', 'ttest', 'correlation' +% Biomarker - Biomarker index (default: 1) for multi-biomarker results +% Channel - Channel index (default: 1) for multi-channel results +% Term - ANOVA term name or contrast row name (default: '' = first) +% EffectSize - Include effect size (default: true for anova/contrast) +% +% Outputs: +% str - Formatted APA string +% +% Example: +% [~, results] = ex.plotLME('Biomarkers', {'HbO'}, 'Channels', 1:5); +% str = exploreFNIRS.report.formatStats(results, 'Channel', 3, 'Term', 'Group'); +% % "F(1, 18.2) = 7.43, p = .014, partial eta-sq = .292" +% +% See also: exploreFNIRS.report.formatPValue, exploreFNIRS.core.plotLME + + ip = inputParser; + addRequired(ip, 'results', @isstruct); + addParameter(ip, 'Type', 'anova', @ischar); + addParameter(ip, 'Biomarker', 1, @isnumeric); + addParameter(ip, 'Channel', 1, @isnumeric); + addParameter(ip, 'Term', '', @ischar); + addParameter(ip, 'EffectSize', true, @islogical); + parse(ip, results, varargin{:}); + opts = ip.Results; + + bIdx = opts.Biomarker; + chIdx = opts.Channel; + + switch lower(opts.Type) + case 'anova' + str = formatAnova(results, bIdx, chIdx, opts.Term, opts.EffectSize); + + case 'contrast' + str = formatContrast(results, bIdx, chIdx, opts.Term, opts.EffectSize); + + case 'ttest' + str = formatTtest(results, opts.Term, opts.EffectSize); + + case 'correlation' + str = formatCorrelation(results); + + otherwise + error('exploreFNIRS:report:formatStats', ... + 'Unknown Type: %s. Use anova, contrast, ttest, or correlation.', opts.Type); + end +end + + +%% Local helpers + +function str = formatAnova(results, bIdx, chIdx, termName, showEffect) + if ~isfield(results, 'anova') || isempty(results.anova{bIdx, chIdx}) + str = 'No ANOVA results available'; + return; + end + + anv = results.anova{bIdx, chIdx}; + + % Find requested term (works for both table and dataset objects) + terms = anv.Term; + if iscell(terms) + termList = terms; + else + termList = cellstr(terms); + end + + if isempty(termName) + termIdx = 1; + termName = termList{1}; + else + termIdx = find(strcmp(termList, termName), 1); + if isempty(termIdx) + str = sprintf('Term "%s" not found', termName); + return; + end + end + + F = anv.FStat(termIdx); + p = anv.pValue(termIdx); + + % Get degrees of freedom (works for both table and dataset) + try + df1 = anv.DF1(termIdx); + df2 = anv.DF2(termIdx); + catch + try + df1 = anv.DF(termIdx); + df2 = anv.DF(termIdx); + catch + if isfield(results, 'anova_df1') && ~isempty(results.anova_df1) ... + && height(results.anova_df1) >= chIdx + df1 = results.anova_df1{chIdx, termIdx}; + df2 = results.anova_df2{chIdx, termIdx}; + else + df1 = NaN; + df2 = NaN; + end + end + end + + pStr = exploreFNIRS.report.formatPValue(p, 'Prefix', true); + + if isnan(df1) || isnan(df2) + str = sprintf('F = %.2f, %s', F, pStr); + elseif df2 == round(df2) + str = sprintf('F(%d, %d) = %.2f, %s', df1, df2, F, pStr); + else + str = sprintf('F(%d, %.1f) = %.2f, %s', df1, df2, F, pStr); + end + + if showEffect && ~isnan(df1) && ~isnan(df2) + etaSq = (F * df1) / (F * df1 + df2); + str = sprintf('%s, partial eta-sq = %.3f', str, etaSq); + end +end + + +function str = formatContrast(results, bIdx, chIdx, contrastName, showEffect) + if ~isfield(results, 'contrasts') || isempty(results.contrasts{bIdx, chIdx}) + str = 'No contrast results available'; + return; + end + + cTable = results.contrasts{bIdx, chIdx}; + if isempty(cTable) || height(cTable) == 0 + str = 'No contrasts computed'; + return; + end + + % Find requested contrast + if isempty(contrastName) + rowIdx = 1; + else + rowIdx = find(strcmp(cTable.Properties.RowNames, contrastName), 1); + if isempty(rowIdx) + str = sprintf('Contrast "%s" not found', contrastName); + return; + end + end + + deltaE = cTable.deltaE(rowIdx); + F = cTable.F(rowIdx); + df1 = cTable.df1(rowIdx); + df2 = cTable.df2(rowIdx); + p = cTable.pVal(rowIdx); + + pStr = exploreFNIRS.report.formatPValue(p, 'Prefix', true); + + if df2 == round(df2) + str = sprintf('delta = %.3f, F(%d, %d) = %.2f, %s', ... + deltaE, df1, df2, F, pStr); + else + str = sprintf('delta = %.3f, F(%d, %.1f) = %.2f, %s', ... + deltaE, df1, df2, F, pStr); + end + + if showEffect && df2 > 0 + d = deltaE / cTable.SD(rowIdx); + str = sprintf('%s, d = %.2f', str, d); + end +end + + +function str = formatTtest(results, varName, showEffect) + if isfield(results, 'tstat') + t = results.tstat; + p = results.pval; + df = results.dof; + elseif ~isempty(varName) && isfield(results, varName) + r = results.(varName); + t = r.tstat; + p = r.pval; + df = r.dof; + else + str = 'No t-test results available'; + return; + end + + pStr = exploreFNIRS.report.formatPValue(p, 'Prefix', true); + str = sprintf('t(%d) = %.2f, %s', df, t, pStr); + + if showEffect && isfield(results, 'd') + str = sprintf('%s, d = %.2f', str, results.d); + end +end + + +function str = formatCorrelation(results) + if isfield(results, 'r') + r = results.r; + p = results.p; + n = results.n; + else + str = 'No correlation results available'; + return; + end + + pStr = exploreFNIRS.report.formatPValue(p, 'Prefix', true); + str = sprintf('r(%d) = %.3f, %s', n - 2, r, pStr); +end diff --git a/+exploreFNIRS/+report/generate.m b/+exploreFNIRS/+report/generate.m new file mode 100644 index 00000000..7570a762 --- /dev/null +++ b/+exploreFNIRS/+report/generate.m @@ -0,0 +1,273 @@ +function outputPath = generate(pipeline, basePath, varargin) +% GENERATE Create HTML report from Pipeline results +% +% Generates a self-contained HTML report with embedded CSS, figures as +% linked images, and formatted tables. +% +% Syntax: +% outputPath = exploreFNIRS.report.generate(pipe, 'output/report') +% outputPath = exploreFNIRS.report.generate(pipe, 'output/report', ... +% 'SaveFigures', true, 'DPI', 300) +% +% Inputs: +% pipeline - Pipeline object (after run()) +% basePath - Base path for output (creates basePath.html and basePath_figures/) +% +% Name-Value Parameters: +% SaveFigures - Save figures as image files (default: true) +% FigureFormat - 'png' (default), 'svg', 'pdf' +% DPI - Figure resolution (default: 300) +% FigureWidth - Figure width in pixels (default: 800) +% FigureHeight - Figure height in pixels (default: 500) +% IncludeLatex - Include LaTeX table code in HTML (default: false) +% +% Outputs: +% outputPath - Path to generated HTML file +% +% Example: +% pipe = exploreFNIRS.report.Pipeline(ex); +% pipe.addStep('lme', 'Biomarkers', {'HbO'}); +% pipe.addStep('temporal', 'Biomarkers', {'HbO'}); +% pipe.run(); +% path = exploreFNIRS.report.generate(pipe, 'results/my_report'); +% web(path); +% +% See also: exploreFNIRS.report.Pipeline, exploreFNIRS.report.toLatex + + ip = inputParser; + addRequired(ip, 'pipeline'); + addRequired(ip, 'basePath', @ischar); + addParameter(ip, 'SaveFigures', true, @islogical); + addParameter(ip, 'FigureFormat', 'png', @ischar); + addParameter(ip, 'DPI', 300, @isnumeric); + addParameter(ip, 'FigureWidth', 800, @isnumeric); + addParameter(ip, 'FigureHeight', 500, @isnumeric); + addParameter(ip, 'IncludeLatex', false, @islogical); + parse(ip, pipeline, basePath, varargin{:}); + opts = ip.Results; + + if ~pipeline.isRun + error('exploreFNIRS:report:generate', ... + 'Pipeline has not been run yet. Call pipe.run() first.'); + end + + % Create output directories + htmlPath = [basePath, '.html']; + figDir = [basePath, '_figures']; + + [outDir, ~, ~] = fileparts(basePath); + if ~isempty(outDir) && ~isfolder(outDir) + mkdir(outDir); + end + + % Save figures + figPaths = struct(); + if opts.SaveFigures + if ~isfolder(figDir) + mkdir(figDir); + end + + figNames = fieldnames(pipeline.figures); + for i = 1:length(figNames) + name = figNames{i}; + fig = pipeline.figures.(name); + if ~isempty(fig) && isvalid(fig) + % Force white background and light-mode styling + set(fig, 'Color', 'w'); + sty = pf2_base.plot.PlotStyle.getPublication(); + sty.applyToFigure(fig); + exploreFNIRS.report.forceWhiteMode(fig); + figPath = fullfile(figDir, sprintf('%s.%s', name, opts.FigureFormat)); + pf2_base.plot.saveFigure(fig, figPath, ... + opts.FigureWidth, opts.FigureHeight, opts.DPI); + % Store relative path for HTML + [~, figDirName] = fileparts(figDir); + figPaths.(name) = fullfile(figDirName, ... + sprintf('%s.%s', name, opts.FigureFormat)); + end + end + end + + % Build HTML + html = buildHTML(pipeline, figPaths, opts); + + % Write file + fid = fopen(htmlPath, 'w', 'n', 'UTF-8'); + if fid == -1 + error('exploreFNIRS:report:generate', 'Cannot write to %s', htmlPath); + end + fprintf(fid, '%s', html); + fclose(fid); + + outputPath = htmlPath; + fprintf('Report saved: %s\n', outputPath); +end + + +%% Local helpers + +function html = buildHTML(pipeline, figPaths, opts) + cfg = pipeline.config; + title = cfg.Title; + if isempty(title) + title = 'fNIRS Analysis Report'; + end + + parts = {}; + parts{end+1} = ''; + parts{end+1} = ''; + parts{end+1} = ''; + parts{end+1} = ''; + parts{end+1} = sprintf('%s', title); + parts{end+1} = getCSS(); + parts{end+1} = ''; + parts{end+1} = ''; + + % Header + parts{end+1} = '
'; + parts{end+1} = sprintf('

%s

', title); + if ~isempty(cfg.Author) + parts{end+1} = sprintf('

Author: %s

', cfg.Author); + end + parts{end+1} = sprintf('

Date: %s

', cfg.Date); + parts{end+1} = sprintf('

Generated by exploreFNIRS.report at %s

', ... + datestr(now, 'yyyy-mm-dd HH:MM:SS')); + parts{end+1} = '
'; + + % Statistics section + statNames = fieldnames(pipeline.stats); + if ~isempty(statNames) + parts{end+1} = '

Statistical Results

'; + parts{end+1} = '
'; + for i = 1:length(statNames) + parts{end+1} = sprintf('

%s: %s

', ... + strrep(statNames{i}, '_', ' '), pipeline.stats.(statNames{i})); + end + parts{end+1} = '
'; + end + + % Tables section + tableNames = fieldnames(pipeline.tables); + if ~isempty(tableNames) + parts{end+1} = '

Tables

'; + for i = 1:length(tableNames) + name = tableNames{i}; + T = pipeline.tables.(name); + parts{end+1} = sprintf('

%s

', strrep(name, '_', ' ')); + parts{end+1} = tableToHTML(T); + + if opts.IncludeLatex + latex = exploreFNIRS.report.toLatex(T, 'Environment', 'none'); + parts{end+1} = '
LaTeX code'; + parts{end+1} = sprintf('
%s
', ... + strrep(strrep(latex, '<', '<'), '>', '>')); + parts{end+1} = '
'; + end + end + end + + % Figures section + figNames = fieldnames(figPaths); + if ~isempty(figNames) + parts{end+1} = '

Figures

'; + for i = 1:length(figNames) + name = figNames{i}; + parts{end+1} = sprintf('
'); + parts{end+1} = sprintf('

%s

', strrep(name, '_', ' ')); + parts{end+1} = sprintf('%s', ... + figPaths.(name), name); + parts{end+1} = '
'; + end + end + + % Pipeline info + parts{end+1} = '

Pipeline Configuration

'; + parts{end+1} = '
'; + parts{end+1} = ''; + parts{end+1} = ''; + for i = 1:length(pipeline.steps) + step = pipeline.steps{i}; + parts{end+1} = sprintf('', i, step.type); + end + parts{end+1} = '
StepType
%d%s
'; + parts{end+1} = '
'; + + parts{end+1} = ''; + parts{end+1} = ''; + + html = strjoin(parts, newline); +end + + +function css = getCSS() + css = ['']; +end + + +function html = tableToHTML(T) + varNames = T.Properties.VariableNames; + useRowNames = ~isempty(T.Properties.RowNames); + + html = ''; + + % Header + html = [html, '']; + if useRowNames + html = [html, '']; + end + for c = 1:width(T) + html = [html, sprintf('', varNames{c})]; %#ok + end + html = [html, '']; + + % Rows + for r = 1:height(T) + html = [html, '']; %#ok + if useRowNames + html = [html, sprintf('', ... + T.Properties.RowNames{r})]; %#ok + end + for c = 1:width(T) + val = T.(varNames{c})(r); + if isnumeric(val) + if isnan(val) + cellStr = '-'; + else + cellStr = sprintf('%.3f', val); + end + elseif isstring(val) || ischar(val) + cellStr = char(val); + elseif iscell(val) + cellStr = char(string(val{1})); + else + cellStr = char(string(val)); + end + html = [html, sprintf('', cellStr)]; %#ok + end + html = [html, '']; %#ok + end + + html = [html, '
%s
%s%s
']; +end diff --git a/+exploreFNIRS/+report/saveFigureSet.m b/+exploreFNIRS/+report/saveFigureSet.m new file mode 100644 index 00000000..5bc8ddf1 --- /dev/null +++ b/+exploreFNIRS/+report/saveFigureSet.m @@ -0,0 +1,84 @@ +function paths = saveFigureSet(figures, basePath, varargin) +% SAVEFIGURESET Batch-save struct of figure handles with consistent naming +% +% Saves all figures in a struct using field names as file suffixes. +% Delegates to pf2_base.plot.saveFigure for each figure. +% +% Syntax: +% paths = exploreFNIRS.report.saveFigureSet(figures, basePath) +% paths = exploreFNIRS.report.saveFigureSet(figures, basePath, 'DPI', 300) +% +% Inputs: +% figures - Struct with figure handles (field names become suffixes) +% e.g., struct('temporal', fig1, 'bar', fig2, 'topo', fig3) +% basePath - Base file path (e.g., 'output/study1') +% Files saved as: 'output/study1_temporal.png', etc. +% +% Name-Value Parameters: +% Format - File extension (default: 'png') +% Width - Width in pixels (default: 800) +% Height - Height in pixels (default: 500) +% DPI - Resolution (default: 300) +% Style - 'default', 'publication', or 'presentation' (default: 'publication') +% +% Outputs: +% paths - Struct with same field names, containing saved file paths +% +% Example: +% figs.temporal = ex.plotTemporal('Visible', 'off'); +% figs.bar = ex.plotBar('Visible', 'off'); +% paths = exploreFNIRS.report.saveFigureSet(figs, 'results/group1', ... +% 'DPI', 300, 'Style', 'publication'); +% +% See also: pf2_base.plot.saveFigure, pf2_base.plot.PlotStyle + + ip = inputParser; + addRequired(ip, 'figures', @isstruct); + addRequired(ip, 'basePath', @ischar); + addParameter(ip, 'Format', 'png', @ischar); + addParameter(ip, 'Width', 800, @isnumeric); + addParameter(ip, 'Height', 500, @isnumeric); + addParameter(ip, 'DPI', 300, @isnumeric); + addParameter(ip, 'Style', 'publication', @ischar); + parse(ip, figures, basePath, varargin{:}); + opts = ip.Results; + + % Ensure output directory exists + [outDir, ~, ~] = fileparts(basePath); + if ~isempty(outDir) && ~isfolder(outDir) + mkdir(outDir); + end + + % Apply style to all figures + switch lower(opts.Style) + case 'publication' + sty = pf2_base.plot.PlotStyle.getPublication(); + case 'presentation' + sty = pf2_base.plot.PlotStyle.getPresentation(); + otherwise + sty = pf2_base.plot.PlotStyle.getDefault(); + end + + flds = fieldnames(figures); + paths = struct(); + + for i = 1:length(flds) + name = flds{i}; + fig = figures.(name); + + if ~isvalid(fig) + warning('Figure "%s" is invalid, skipping.', name); + continue; + end + + % Force white background and light-mode styling + set(fig, 'Color', 'w'); + sty.applyToFigure(fig); + exploreFNIRS.report.forceWhiteMode(fig); + + savePath = sprintf('%s_%s.%s', basePath, name, opts.Format); + pf2_base.plot.saveFigure(fig, savePath, opts.Width, opts.Height, opts.DPI); + paths.(name) = savePath; + fprintf('Saved: %s\n', savePath); + end +end diff --git a/+exploreFNIRS/+report/toLatex.m b/+exploreFNIRS/+report/toLatex.m new file mode 100644 index 00000000..e6f6bb56 --- /dev/null +++ b/+exploreFNIRS/+report/toLatex.m @@ -0,0 +1,171 @@ +function str = toLatex(T, varargin) +% TOLATEX Convert MATLAB table to LaTeX tabular string +% +% Generates publication-ready LaTeX code using booktabs formatting. +% +% Syntax: +% str = exploreFNIRS.report.toLatex(T) +% str = exploreFNIRS.report.toLatex(T, 'Style', 'booktabs') +% str = exploreFNIRS.report.toLatex(T, 'Caption', 'ANOVA Results') +% +% Inputs: +% T - MATLAB table +% +% Name-Value Parameters: +% Style - 'booktabs' (default) or 'plain' (\hline) +% Caption - Table caption (default: '' = no caption) +% Label - LaTeX label (default: '' = no label) +% Alignment - Column alignment string (default: auto 'l' for text, 'r' for numeric) +% RowNames - Include row names (default: true if they exist) +% Environment - 'table' (default) wraps in \begin{table}, 'none' = bare tabular +% Precision - Decimal places for numeric values (default: 3) +% Escape - Escape special LaTeX characters (default: true) +% +% Outputs: +% str - LaTeX string +% +% Example: +% T = table({'A';'B'}, [1.23; 4.56], [0.01; 0.5], ... +% 'VariableNames', {'Group','Mean','pValue'}); +% latex = exploreFNIRS.report.toLatex(T, 'Caption', 'Results'); +% fprintf('%s\n', latex); +% +% See also: exploreFNIRS.report.anovaTable, exploreFNIRS.report.contrastTable + + ip = inputParser; + addRequired(ip, 'T', @istable); + addParameter(ip, 'Style', 'booktabs', @ischar); + addParameter(ip, 'Caption', '', @ischar); + addParameter(ip, 'Label', '', @ischar); + addParameter(ip, 'Alignment', '', @ischar); + addParameter(ip, 'RowNames', true, @islogical); + addParameter(ip, 'Environment', 'table', @ischar); + addParameter(ip, 'Precision', 3, @isnumeric); + addParameter(ip, 'Escape', true, @islogical); + parse(ip, T, varargin{:}); + opts = ip.Results; + + useRowNames = opts.RowNames && ~isempty(T.Properties.RowNames); + varNames = T.Properties.VariableNames; + nCols = width(T); + nRows = height(T); + useBooktabs = strcmpi(opts.Style, 'booktabs'); + + % Build alignment string + if isempty(opts.Alignment) + align = ''; + if useRowNames + align = 'l'; + end + for c = 1:nCols + col = T.(varNames{c}); + if isnumeric(col) + align = [align, 'r']; %#ok + else + align = [align, 'l']; %#ok + end + end + else + align = opts.Alignment; + end + + lines = {}; + + % Environment wrapper + if strcmpi(opts.Environment, 'table') + lines{end+1} = '\begin{table}[htbp]'; + lines{end+1} = '\centering'; + if ~isempty(opts.Caption) + lines{end+1} = sprintf('\\caption{%s}', escapeLatex(opts.Caption, opts.Escape)); + end + if ~isempty(opts.Label) + lines{end+1} = sprintf('\\label{%s}', opts.Label); + end + end + + % Tabular begin + lines{end+1} = sprintf('\\begin{tabular}{%s}', align); + + if useBooktabs + lines{end+1} = '\toprule'; + else + lines{end+1} = '\hline'; + end + + % Header row + header = {}; + if useRowNames + header{end+1} = ''; + end + for c = 1:nCols + header{end+1} = escapeLatex(varNames{c}, opts.Escape); %#ok + end + lines{end+1} = [strjoin(header, ' & '), ' \\']; + + if useBooktabs + lines{end+1} = '\midrule'; + else + lines{end+1} = '\hline'; + end + + % Data rows + for r = 1:nRows + row = {}; + if useRowNames + row{end+1} = escapeLatex(T.Properties.RowNames{r}, opts.Escape); %#ok + end + + for c = 1:nCols + val = T.(varNames{c})(r); + if isnumeric(val) + if isnan(val) + row{end+1} = '-'; %#ok + else + row{end+1} = sprintf('%.*f', opts.Precision, val); %#ok + end + elseif isstring(val) || ischar(val) + row{end+1} = escapeLatex(char(val), opts.Escape); %#ok + elseif iscell(val) + row{end+1} = escapeLatex(char(string(val{1})), opts.Escape); %#ok + elseif iscategorical(val) + row{end+1} = escapeLatex(char(val), opts.Escape); %#ok + else + row{end+1} = escapeLatex(char(string(val)), opts.Escape); %#ok + end + end + + lines{end+1} = [strjoin(row, ' & '), ' \\']; %#ok + end + + % Bottom rule + if useBooktabs + lines{end+1} = '\bottomrule'; + else + lines{end+1} = '\hline'; + end + + lines{end+1} = '\end{tabular}'; + + if strcmpi(opts.Environment, 'table') + lines{end+1} = '\end{table}'; + end + + str = strjoin(lines, newline); +end + + +function s = escapeLatex(s, doEscape) + if ~doEscape + return; + end + s = strrep(s, '\', '\textbackslash{}'); + s = strrep(s, '&', '\&'); + s = strrep(s, '%', '\%'); + s = strrep(s, '$', '\$'); + s = strrep(s, '#', '\#'); + s = strrep(s, '_', '\_'); + s = strrep(s, '{', '\{'); + s = strrep(s, '}', '\}'); + s = strrep(s, '~', '\textasciitilde{}'); + s = strrep(s, '^', '\textasciicircum{}'); +end diff --git a/+exploreFNIRS/+stats/autoModelLME.m b/+exploreFNIRS/+stats/autoModelLME.m new file mode 100644 index 00000000..2a7f3a73 --- /dev/null +++ b/+exploreFNIRS/+stats/autoModelLME.m @@ -0,0 +1,1167 @@ +function results = autoModelLME(groups, groupByVars, varargin) +% AUTOMODELLME Automatic per-channel LME model selection via forward stepwise IC +% +% Discovers which factors (Group, Condition, Session, Gender, etc.) matter +% for each channel independently using forward stepwise selection with +% information criteria (AIC/BIC). ML fitting is used for model comparison; +% the final selected model is refit with REML for unbiased variance. +% +% Syntax: +% results = exploreFNIRS.stats.autoModelLME(groups, groupByVars) +% results = exploreFNIRS.stats.autoModelLME(groups, groupByVars, 'Criterion', 'BIC') +% results = exploreFNIRS.stats.autoModelLME(groups, groupByVars, 'Biomarkers', {'HbO'}) +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% groupByVars - Cell array of grouping variable names used in groupby() +% +% Name-Value Parameters: +% Candidates - Cell array of predictor names to try (default: auto-discover) +% Criterion - 'AIC' or 'BIC' (default: 'AIC') +% DeltaThreshold - Min IC improvement to retain a term (default: 2) +% MaxTerms - Max fixed-effect terms (default: floor(N/2)) +% TryInteractions - Try pairwise interactions after main effects (default: true) +% Biomarkers - Cell array of biomarker names (default: {'HbO','HbR','HbTotal','CBSI'}) +% Channels - Vector of channel indices (default: all) +% DataType - 'fNIRS' or 'ROI' (default: 'fNIRS') +% TimeModel - Time handling: 'polynomial'|'discrete'|'continuous'|'none' (default: 'polynomial') +% PolynomialOrder - Polynomial degree (default: 2) +% AlwaysIncludeTime - Time terms in base model, not subject to selection (default: true) +% RandomEffects - Random effects formula (default: '1|SubjectID') +% StatWindow - Time bin filter [start, end] (default: []) +% Verbose - Print progress to console (default: true) +% ExcludeShortSeparation - Skip short separation channels (default: true) +% ContrastThreshold - p-value threshold for auto-contrasts (default: 0.1) +% Covariates - Info variable(s) (e.g. {'RT','Score'}) forced as fixed- +% effect covariates in every channel's model, instead of +% being subject to forward selection. Alias for ForcedTerms; +% parallels AuxCovariates (which is for aux_* signals). +% Continuous covariates are entered UNCENTERED; mean-center +% (e.g. zscore) before passing so the group intercepts stay +% interpretable. +% ResponseVar - Info variable name to use as response (default: '') +% When set, the response is the named info variable (e.g. +% 'reactionTime') and each channel's biomarker column becomes +% a candidate predictor. Discovers whether brain activation +% predicts the behavioral outcome. +% +% Outputs: +% results - Struct with fields: +% .bestModels - {nBio x nCh} LinearMixedModel objects (REML) +% .bestFormulas - {nBio x nCh} formula strings +% .bestAIC - [nBio x nCh] AIC of best model +% .bestBIC - [nBio x nCh] BIC of best model +% .selectedTerms - {nBio x nCh} cell of selected term name lists +% .selectionPath - {nBio x nCh} struct arrays with selection history +% .comparisonTable - Table: Biomarker, Channel, Step, Term, AIC, BIC, DeltaIC, Selected +% .models - Alias for bestModels (fitLME compatibility) +% .anova - {nBio x nCh} ANOVA tables +% .anova_pval - Table of ANOVA p-values [channels x terms] +% .anova_Fstat - Table of ANOVA F-statistics [channels x terms] +% .contrasts - {nBio x nCh} contrast tables +% .AIC - Alias for bestAIC +% .nullComparison - {nBio x nCh} null model comparison +% .formula - 'auto' (per-channel formulas differ) +% .responseVar - ResponseVar name (empty in normal mode) +% .biomarkers, .channels, .groupByVars, .mergedTable +% .candidates - Cell of candidate predictor names tested +% .criterion - 'AIC' or 'BIC' +% .timeModel - TimeModel string used +% .termLabels - Struct mapping polynomial terms to readable names +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group'}); +% ex.aggregate(); +% results = ex.statsAutoLME('Biomarkers', {'HbO'}, 'Channels', 1:3); +% disp(results.bestFormulas); +% disp(results.comparisonTable); +% +% % Compatible with existing summary pipeline +% T = exploreFNIRS.stats.summarize(results, 'Type', 'anova'); +% +% References: +% Burnham, K. P. & Anderson, D. R. (2002). Model Selection and +% Multimodel Inference: A Practical Information-Theoretic Approach (2nd +% ed.). Springer. DOI: 10.1007/b97636 +% +% Pinheiro, J. C. & Bates, D. M. (2000). Mixed-Effects Models in S and +% S-PLUS. Springer. DOI: 10.1007/b98882 +% +% See also: exploreFNIRS.stats.fitLME, exploreFNIRS.stats.summarize, +% exploreFNIRS.fx.autoContrast, fitlme + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addRequired(p, 'groupByVars', @iscell); + addParameter(p, 'Candidates', {}, @iscell); + addParameter(p, 'Criterion', 'AIC', @(x) ismember(upper(x), {'AIC','BIC'})); + addParameter(p, 'DeltaThreshold', 2, @(x) isnumeric(x) && isscalar(x) && x > 0); + addParameter(p, 'MaxTerms', [], @(x) isnumeric(x) && isscalar(x) && x > 0); + addParameter(p, 'TryInteractions', true, @islogical); + addParameter(p, 'Biomarkers', {'HbO','HbR','HbTotal','CBSI'}, @iscell); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'DataType', 'fNIRS', @ischar); + addParameter(p, 'TimeModel', 'polynomial', @ischar); + addParameter(p, 'PolynomialOrder', 2, @(x) isnumeric(x) && isscalar(x) && x >= 1 && x <= 5); + addParameter(p, 'AlwaysIncludeTime', true, @islogical); + addParameter(p, 'RandomEffects', '1|SubjectID', @ischar); + addParameter(p, 'StatWindow', [], @isnumeric); + addParameter(p, 'Verbose', true, @islogical); + addParameter(p, 'ExcludeShortSeparation', true, @islogical); + addParameter(p, 'ContrastThreshold', 0.1, @isnumeric); + addParameter(p, 'ResponseVar', '', @ischar); + addParameter(p, 'ForcedTerms', {}, @iscell); + % Aux columns (aux_*) are excluded from predictors by default. Opt in by + % naming the auxiliary signals to promote to candidate covariates (e.g. + % {'heartRate','gsr'}); use {'all'} to admit every aux_ column. + addParameter(p, 'AuxCovariates', {}, @iscell); + % 'Covariates' is a friendly alias for 'ForcedTerms': named INFO variables + % (e.g. 'RT', 'Score') are forced as fixed-effect covariates in every + % channel's model instead of being subject to forward selection. Parallels + % 'AuxCovariates' (which is for aux_* signals). + addParameter(p, 'Covariates', {}, @iscell); + parse(p, groups, groupByVars, varargin{:}); + opts = p.Results; + % Fold the 'Covariates' alias into ForcedTerms (info vars forced as + % fixed-effect covariates, listed before any explicit ForcedTerms). + if ~isempty(opts.Covariates) + opts.ForcedTerms = unique([opts.Covariates(:)', opts.ForcedTerms(:)'], 'stable'); + end + opts.Criterion = upper(opts.Criterion); + + isROI = strcmpi(opts.DataType, 'ROI'); + + nGroups = length(groups); + nBioM = length(opts.Biomarkers); + + % Validate groups have bar-flat data + for g = 1:nGroups + if isempty(groups(g).gbyGrandBarFlat) + error('exploreFNIRS:stats:autoModelLME', ... + 'Group %d has no bar-flat grand average. Call aggregate() first.', g); + end + end + + % Get time bins from bar-flat data + barTimes = groups(1).gbyGrandBarFlat.time; + + % Filter time bins by StatWindow + if ~isempty(opts.StatWindow) + sw = opts.StatWindow; + tMask = barTimes >= sw(1) & barTimes <= sw(2); + barTimes = barTimes(tMask); + if isempty(barTimes) + error('exploreFNIRS:stats:autoModelLME:emptyWindow', ... + 'StatWindow [%.1f, %.1f] contains no time bins.', sw(1), sw(2)); + end + end + + hasMultipleTimeBins = length(barTimes) > 1; + + % Clamp PolynomialOrder to available time bins + if strcmpi(opts.TimeModel, 'polynomial') && hasMultipleTimeBins + maxOrder = length(barTimes) - 1; + if opts.PolynomialOrder > maxOrder + if opts.Verbose + fprintf('Clamping PolynomialOrder from %d to %d (%d time bins).\n', ... + opts.PolynomialOrder, maxOrder, length(barTimes)); + end + opts.PolynomialOrder = maxOrder; + end + end + + if isROI + % --- ROI mode --- + ga = groups(1).gbyGrandBarFlat; + if ~pf2_base.isnestedfield(ga, 'ROI.HbO.data') + error('exploreFNIRS:stats:autoModelLME', ... + 'No ROI data. Define ROIs before aggregating.'); + end + nTotalROIs = size(ga.ROI.(opts.Biomarkers{1}).data, 2); + if isfield(ga.ROI, 'info') && ~isempty(ga.ROI.info) + roiLabels = ga.ROI.info.Properties.RowNames; + else + roiLabels = arrayfun(@(i) sprintf('ROI%d', i), 1:nTotalROIs, ... + 'UniformOutput', false); + end + if isempty(opts.Channels) + roiChannels = 1:nTotalROIs; + else + roiChannels = opts.Channels(opts.Channels <= nTotalROIs); + end + nCh = length(roiChannels); + channels = roiChannels; + chLabels = roiLabels(roiChannels); + else + % --- Standard fNIRS mode --- + if isempty(opts.Channels) + nCh = size(groups(1).gbyGrandBarFlat.(opts.Biomarkers{1}).data, 2); + channels = 1:nCh; + else + channels = opts.Channels; + nCh = length(channels); + end + + % Exclude short separation channels + if opts.ExcludeShortSeparation + ssIdx = getShortSeparationIdx(groups); + if ~isempty(ssIdx) + channels = channels(~ismember(channels, ssIdx)); + nCh = length(channels); + if opts.Verbose + fprintf('Excluding %d short separation channels\n', length(ssIdx)); + end + end + end + + chLabels = arrayfun(@(x) num2str(x), channels, 'UniformOutput', false); + end + + % Initialize results + results = struct(); + results.bestModels = cell(nBioM, nCh); + results.bestFormulas = cell(nBioM, nCh); + results.bestAIC = nan(nBioM, nCh); + results.bestBIC = nan(nBioM, nCh); + results.selectedTerms = cell(nBioM, nCh); + results.selectionPath = cell(nBioM, nCh); + + % fitLME-compatible fields + results.models = cell(nBioM, nCh); + results.anova = cell(nBioM, nCh); + results.anova_pval = table(); + results.anova_Fstat = table(); + results.anova_df1 = table(); + results.anova_df2 = table(); + results.contrasts = cell(nBioM, nCh); + results.coefficients = cell(nBioM, nCh); + results.nullComparison = cell(nBioM, nCh); + results.AIC = nan(nBioM, nCh); + results.coef_pval = table(); + results.coef_tstat = table(); + results.coef_df = table(); + results.modelFit = table(); + results.formula = 'auto'; + results.mergedTable = []; + results.biomarkers = opts.Biomarkers; + results.channels = channels; + results.groupByVars = groupByVars; + results.statWindow = opts.StatWindow; + results.candidates = {}; + results.criterion = opts.Criterion; + results.responseVar = opts.ResponseVar; + results.timeModel = opts.TimeModel; + results.termLabels = struct(); + + % Comparison table accumulator + compRows = {}; + + for bIdx = 1:nBioM + bioM = opts.Biomarkers{bIdx}; + + for chI = 1:nCh + ch = channels(chI); + + % Build merged long-format table (biomarker as the value column). + % Note: mergeGbyTablesLong with exportAux=true *replaces* the + % biomarker column with aux columns, so aux covariates are joined + % on separately below rather than requested here. + if isROI + mergedTable = exploreFNIRS.export.mergeGbyTablesLong( ... + groups, {bioM}, ch, barTimes, false, true, chLabels(chI)); + varName = sprintf('ROI%d_%s_%s', ch, chLabels{chI}, bioM); + else + mergedTable = exploreFNIRS.export.mergeGbyTablesLong( ... + groups, {bioM}, ch, barTimes, false, false, chLabels(chI)); + varName = sprintf('Opt%s_%s', chLabels{chI}, bioM); + end + + % Aux covariate opt-in: build an aux-bearing table over the same + % rows and graft the whitelisted aux_ columns onto mergedTable. + if ~isempty(opts.AuxCovariates) && ~isempty(mergedTable) + mergedTable = appendAuxColumns(mergedTable, groups, bioM, ch, ... + barTimes, isROI, chLabels(chI), opts.AuxCovariates); + end + + if isempty(mergedTable) || height(mergedTable) == 0 + if opts.Verbose + warning('No data for %s channel %d, skipping', bioM, ch); + end + continue; + end + + % Transform Time column based on TimeModel + mergedTable = prepareTimeColumn(mergedTable, opts, hasMultipleTimeBins); + + if ~ismember(varName, mergedTable.Properties.VariableNames) + if opts.Verbose + warning('Variable %s not found in merged table, skipping', varName); + end + continue; + end + + % Determine response and biomarker predictor column + if ~isempty(opts.ResponseVar) + % ResponseVar mode: behavioral response, biomarker as predictor + if ~ismember(opts.ResponseVar, mergedTable.Properties.VariableNames) + if opts.Verbose + warning('ResponseVar %s not found in table, skipping', opts.ResponseVar); + end + continue; + end + responseCol = opts.ResponseVar; + biomarkerCol = varName; + else + responseCol = varName; + biomarkerCol = ''; + end + + if ~isfield(results, 'mergedTable') || isempty(results.mergedTable) + results.mergedTable = mergedTable; + end + + % Count unique subjects for parameter budget + if ismember('SubjectID', mergedTable.Properties.VariableNames) + nSubjects = length(unique(mergedTable.SubjectID)); + else + nSubjects = height(mergedTable); + end + + if isempty(opts.MaxTerms) + maxTerms = floor(nSubjects / 2); + else + maxTerms = opts.MaxTerms; + end + + % Discover or validate candidates + candidates = discoverCandidates(mergedTable, responseCol, nSubjects, ... + opts, hasMultipleTimeBins, biomarkerCol); + + if isempty(results.candidates) + results.candidates = candidates; + end + + % Build base time terms + baseTimeTerms = {}; + if opts.AlwaysIncludeTime && hasMultipleTimeBins && ... + ~strcmpi(opts.TimeModel, 'none') + if strcmpi(opts.TimeModel, 'polynomial') + for k = 1:opts.PolynomialOrder + baseTimeTerms{end+1} = sprintf('ot%d', k); %#ok + end + elseif strcmpi(opts.TimeModel, 'discrete') + baseTimeTerms = {'Time'}; + elseif strcmpi(opts.TimeModel, 'continuous') + baseTimeTerms = {'Time'}; + end + end + + % Prepend forced terms (always included in base model) + forcedTerms = {}; + for fi = 1:numel(opts.ForcedTerms) + ft = opts.ForcedTerms{fi}; + if ismember(ft, mergedTable.Properties.VariableNames) + forcedTerms{end+1} = ft; %#ok + elseif opts.Verbose + warning('ForcedTerm "%s" not found in merged table, skipping', ft); + end + end + baseTimeTerms = [forcedTerms, baseTimeTerms]; + + randomFx = opts.RandomEffects; + + % ---- Forward stepwise selection ---- + currentTerms = {}; + baseFormula = buildFormulaFromTerms(responseCol, [baseTimeTerms, currentTerms], randomFx); + + % Fit base model with ML + [~, baseIC] = fitModelML(mergedTable, baseFormula, opts.Criterion); + currentIC = baseIC; + + selPath = struct('step', {}, 'termAdded', {}, 'IC', {}, ... + 'deltaIC', {}, 'formula', {}, 'converged', {}); + + % Record base model in path + selPath(end+1) = struct('step', 0, 'termAdded', 'base', ... + 'IC', baseIC, 'deltaIC', 0, ... + 'formula', baseFormula, 'converged', isfinite(baseIC)); + + if opts.Verbose + if ~isempty(opts.ResponseVar) + fprintf('Auto model [%s ~ %s Ch %d]: base %s=%.1f\n', ... + opts.ResponseVar, bioM, ch, opts.Criterion, baseIC); + else + fprintf('Auto model [%s Ch %d]: base %s=%.1f\n', ... + bioM, ch, opts.Criterion, baseIC); + end + end + + % Step 1: Forward selection of main effects + remaining = candidates; + stepNum = 0; + + while ~isempty(remaining) && length(currentTerms) < maxTerms + bestDelta = -Inf; + bestIdx = 0; + bestIC_trial = Inf; + + for ci = 1:length(remaining) + trialTerms = [currentTerms, remaining(ci)]; + allTerms = [baseTimeTerms, trialTerms]; + + % Check parameter budget + nDf = countDf(allTerms, mergedTable); + if nDf >= nSubjects - 2 + continue; + end + + trialFormula = buildFormulaFromTerms(responseCol, allTerms, randomFx); + [~, trialIC] = fitModelML(mergedTable, trialFormula, opts.Criterion); + + delta = currentIC - trialIC; + + if opts.Verbose + convergedStr = ''; + if ~isfinite(trialIC) + convergedStr = ' [failed]'; + end + marker = ''; + if delta >= opts.DeltaThreshold + marker = ' *'; + end + fprintf(' +%-14s %s=%.1f (delta=%.1f)%s%s\n', ... + remaining{ci}, opts.Criterion, trialIC, delta, ... + marker, convergedStr); + end + + if delta > bestDelta + bestDelta = delta; + bestIdx = ci; + bestIC_trial = trialIC; + end + end + + if bestDelta >= opts.DeltaThreshold + stepNum = stepNum + 1; + selectedTerm = remaining{bestIdx}; + currentTerms{end+1} = selectedTerm; %#ok + currentIC = bestIC_trial; + remaining(bestIdx) = []; + + curFormula = buildFormulaFromTerms(responseCol, ... + [baseTimeTerms, currentTerms], randomFx); + + selPath(end+1) = struct('step', stepNum, ... + 'termAdded', selectedTerm, ... + 'IC', bestIC_trial, 'deltaIC', bestDelta, ... + 'formula', curFormula, 'converged', true); %#ok + + if opts.Verbose + fprintf(' -> selected %s (%s=%.1f)\n', ... + selectedTerm, opts.Criterion, bestIC_trial); + end + + % Add to comparison table + compRows{end+1} = {bioM, ch, stepNum, selectedTerm, ... + bestIC_trial, nan, bestDelta, true}; %#ok + else + break; + end + end + + % Step 2: Forward selection of interactions + if opts.TryInteractions && length(currentTerms) >= 2 + interactionCandidates = {}; + + % Pairwise interactions among selected main effects + for a = 1:length(currentTerms) + for b = (a+1):length(currentTerms) + interactionCandidates{end+1} = sprintf('%s:%s', ... + currentTerms{a}, currentTerms{b}); %#ok + end + end + + % Interactions with time polynomial terms + if strcmpi(opts.TimeModel, 'polynomial') && hasMultipleTimeBins + for a = 1:length(currentTerms) + for k = 1:opts.PolynomialOrder + interactionCandidates{end+1} = sprintf('%s:ot%d', ... + currentTerms{a}, k); %#ok + end + end + end + + remainingInt = interactionCandidates; + + while ~isempty(remainingInt) && ... + (length(currentTerms) + length(baseTimeTerms)) < maxTerms + bestDelta = -Inf; + bestIdx = 0; + bestIC_trial = Inf; + + for ci = 1:length(remainingInt) + trialTerms = [currentTerms, remainingInt(ci)]; + allTerms = [baseTimeTerms, trialTerms]; + + nDf = countDf(allTerms, mergedTable); + if nDf >= nSubjects - 2 + continue; + end + + trialFormula = buildFormulaFromTerms(responseCol, allTerms, randomFx); + [~, trialIC] = fitModelML(mergedTable, trialFormula, opts.Criterion); + + delta = currentIC - trialIC; + + if opts.Verbose + convergedStr = ''; + if ~isfinite(trialIC) + convergedStr = ' [failed]'; + end + marker = ''; + if delta >= opts.DeltaThreshold + marker = ' *'; + end + fprintf(' +%-14s %s=%.1f (delta=%.1f)%s%s\n', ... + remainingInt{ci}, opts.Criterion, trialIC, delta, ... + marker, convergedStr); + end + + if delta > bestDelta + bestDelta = delta; + bestIdx = ci; + bestIC_trial = trialIC; + end + end + + if bestDelta >= opts.DeltaThreshold + stepNum = stepNum + 1; + selectedTerm = remainingInt{bestIdx}; + currentTerms{end+1} = selectedTerm; %#ok + currentIC = bestIC_trial; + remainingInt(bestIdx) = []; + + curFormula = buildFormulaFromTerms(responseCol, ... + [baseTimeTerms, currentTerms], randomFx); + + selPath(end+1) = struct('step', stepNum, ... + 'termAdded', selectedTerm, ... + 'IC', bestIC_trial, 'deltaIC', bestDelta, ... + 'formula', curFormula, 'converged', true); %#ok + + if opts.Verbose + fprintf(' -> selected %s (%s=%.1f)\n', ... + selectedTerm, opts.Criterion, bestIC_trial); + end + + compRows{end+1} = {bioM, ch, stepNum, selectedTerm, ... + bestIC_trial, nan, bestDelta, true}; %#ok + else + break; + end + end + end + + % ---- Refit final model with REML ---- + allFinalTerms = [baseTimeTerms, currentTerms]; + finalFormula = buildFormulaFromTerms(responseCol, allFinalTerms, randomFx); + + results.bestFormulas{bIdx, chI} = finalFormula; + results.selectedTerms{bIdx, chI} = currentTerms; + results.selectionPath{bIdx, chI} = selPath; + + if opts.Verbose + fprintf(' Best: %s\n', finalFormula); + end + + % Fit final model with REML + try + rng(2019); + mdl = fitlme(mergedTable, finalFormula, ... + 'FitMethod', 'REML', 'CheckHessian', true, ... + 'DummyVarCoding', 'reference'); + + results.bestModels{bIdx, chI} = mdl; + results.models{bIdx, chI} = mdl; + results.bestAIC(bIdx, chI) = mdl.ModelCriterion.AIC; + results.bestBIC(bIdx, chI) = mdl.ModelCriterion.BIC; + results.AIC(bIdx, chI) = mdl.ModelCriterion.AIC; + catch ME + if opts.Verbose + warning('pf2:stats:autoModelLME:refitFailed', ... + 'REML refit failed for %s Ch %d: %s', bioM, ch, ME.message); + end + continue; + end + + % Extract ANOVA, contrasts, null comparison (same as fitLME) + if isROI + chRowName = sprintf('ROI%d_%s_%s', ch, chLabels{chI}, bioM); + else + chRowName = sprintf('Opt%s_%s', chLabels{chI}, bioM); + end + + results = extractModelStats(results, mergedTable, mdl, ... + responseCol, chRowName, bIdx, chI, randomFx, finalFormula, opts); + end + end + + % Build comparison table + if ~isempty(compRows) + compData = vertcat(compRows{:}); + results.comparisonTable = table( ... + compData(:,1), compData(:,2), compData(:,3), compData(:,4), ... + compData(:,5), compData(:,6), compData(:,7), compData(:,8), ... + 'VariableNames', {'Biomarker','Channel','Step','Term', ... + 'AIC','BIC','DeltaIC','Selected'}); + % Convert numeric columns + results.comparisonTable.Channel = cell2mat(results.comparisonTable.Channel); + results.comparisonTable.Step = cell2mat(results.comparisonTable.Step); + results.comparisonTable.AIC = cell2mat(results.comparisonTable.AIC); + results.comparisonTable.BIC = cell2mat(results.comparisonTable.BIC); + results.comparisonTable.DeltaIC = cell2mat(results.comparisonTable.DeltaIC); + results.comparisonTable.Selected = cell2mat(results.comparisonTable.Selected); + else + results.comparisonTable = table(); + end + + % Build term labels for polynomial terms + if strcmpi(opts.TimeModel, 'polynomial') + results.termLabels = buildTermLabels(opts.PolynomialOrder); + end + + % Print ANOVA summary + if opts.Verbose && ~isempty(results.anova_pval) && height(results.anova_pval) > 0 + fprintf('\n--- ANOVA p-values ---\n'); + disp(results.anova_pval); + end +end + + +%% Local helper functions + +function candidates = discoverCandidates(T, responseVar, nSubjects, opts, hasMultiTime, biomarkerCol) +% DISCOVERCANDIDATES Auto-detect valid predictor columns from merged table +% +% Excludes response, SubjectID, time columns, other biomarker/ROI/aux columns, +% and missingFNIRS. Validates categorical (2+ levels, levels <= N/2) and +% numeric (non-zero variance) columns. +% +% When biomarkerCol is non-empty (ResponseVar mode), the specified biomarker +% column is included as a candidate while other Opt/ROI columns are excluded. + + if nargin < 6, biomarkerCol = ''; end + + if ~isempty(opts.Candidates) + % User-provided: validate they exist in table + candidates = opts.Candidates; + valid = ismember(candidates, T.Properties.VariableNames); + if any(~valid) + warning('pf2:stats:autoModelLME:badCandidate', ... + 'Candidate(s) not found in table: %s', ... + strjoin(candidates(~valid), ', ')); + candidates = candidates(valid); + end + return; + end + + isResponseVarMode = ~isempty(biomarkerCol); + + allVars = T.Properties.VariableNames; + + % Patterns to exclude + excludeExact = {responseVar, 'SubjectID', 'missingFNIRS', ... + 'Time', 'TimeStart', 'TimeEnd'}; + + % Exclude polynomial time columns + for k = 1:10 + excludeExact{end+1} = sprintf('ot%d', k); %#ok + end + + % Exclude by prefix patterns + if isResponseVarMode + % In ResponseVar mode, don't blanket-exclude Opt/ROI — handle per-var + excludePrefixes = {'aux_'}; + else + excludePrefixes = {'Opt', 'ROI', 'aux_'}; + end + + % Also exclude other biomarker columns + bioNames = {'HbO','HbR','HbTotal','HbDiff','CBSI'}; + + % Aux covariate opt-in: aux_ columns matching this whitelist are promoted + % to candidate predictors instead of being excluded. + auxWhitelist = {}; + if isfield(opts, 'AuxCovariates') + auxWhitelist = opts.AuxCovariates; + end + + candidates = {}; + for i = 1:length(allVars) + vn = allVars{i}; + + % Exact match exclusion + if ismember(vn, excludeExact) + continue; + end + + % Aux opt-in: skip the aux_ prefix exclusion for whitelisted signals + if startsWith(vn, 'aux_') && isAuxWhitelisted(vn, auxWhitelist) + col = T.(vn); + if isnumeric(col) + vals = col(~isnan(col)); + if ~isempty(vals) && var(vals) > 0 + candidates{end+1} = vn; %#ok + end + end + continue; + end + + % In ResponseVar mode: include biomarkerCol, skip other Opt/ROI columns + if isResponseVarMode && (startsWith(vn, 'Opt') || startsWith(vn, 'ROI')) + if ~strcmp(vn, biomarkerCol) + continue; + end + % biomarkerCol passes through to validation below + end + + % Prefix exclusion + skip = false; + for px = 1:length(excludePrefixes) + if startsWith(vn, excludePrefixes{px}) + skip = true; + break; + end + end + if skip, continue; end + + % Exclude columns containing biomarker names (skip in ResponseVar mode + % for the biomarkerCol itself, which contains a biomarker suffix) + if ~isResponseVarMode || ~strcmp(vn, biomarkerCol) + skipBio = false; + for bn = 1:length(bioNames) + if contains(vn, bioNames{bn}) + skipBio = true; + break; + end + end + if skipBio, continue; end + end + + col = T.(vn); + + % Categorical or string/cell: need 2+ levels, levels <= N/2 + if iscategorical(col) || iscell(col) || isstring(col) + if iscell(col) || isstring(col) + uLevels = unique(string(col)); + uLevels = uLevels(~ismissing(uLevels)); + else + uLevels = categories(col); + end + nLevels = length(uLevels); + if nLevels >= 2 && nLevels <= nSubjects / 2 + candidates{end+1} = vn; %#ok + end + elseif isnumeric(col) + % Numeric: need non-zero variance + vals = col(~isnan(col)); + if ~isempty(vals) && var(vals) > 0 + candidates{end+1} = vn; %#ok + end + end + end +end + + +function T = appendAuxColumns(T, groups, bioM, ch, barTimes, isROI, chLabel, whitelist) +% APPENDAUXCOLUMNS Graft whitelisted aux_ columns onto a biomarker long-table +% +% Builds an aux-bearing long table over the same grouping/channel/time rows +% (mergeGbyTablesLong with exportAux=true) and copies the aux_ columns matching +% the AuxCovariates whitelist into T. Rows are matched on the shared identifier +% columns (grouping/channel/time) via a key, NOT by row position, so each +% covariate value lands on the correct outcome row even if the two tables are +% ordered differently. Skips quietly if it cannot align (so model selection +% proceeds on the biomarker table). + try + auxT = exploreFNIRS.export.mergeGbyTablesLong( ... + groups, {bioM}, ch, barTimes, true, isROI, chLabel); + catch + return; + end + if isempty(auxT) + return; + end + + % Which aux_ columns to copy: whitelisted, not an aux time column, and not + % already present in T. + avn = auxT.Properties.VariableNames; + isAux = startsWith(avn, 'aux_') & ~endsWith(lower(avn), '_time'); + auxCols = avn(isAux); + keep = false(1, numel(auxCols)); + for i = 1:numel(auxCols) + keep(i) = isAuxWhitelisted(auxCols{i}, whitelist) ... + && ~ismember(auxCols{i}, T.Properties.VariableNames); + end + auxCols = auxCols(keep); + if isempty(auxCols) + return; + end + + % Identifier columns = columns shared by both tables that are neither aux_ + % columns nor the biomarker value columns (which end in _ and carry + % NaNs that defeat equality matching). Build a per-row string key from them. + shared = intersect(T.Properties.VariableNames, avn, 'stable'); + isId = ~startsWith(shared, 'aux_') & ~endsWith(shared, ['_' bioM]); + keyCols = shared(isId); + + keyT = buildRowKey(T, keyCols); + keyA = buildRowKey(auxT, keyCols); + + % Use the key only when it is complete and unambiguous: every aux row has a + % unique key and every T row maps to one. Otherwise fall back to a + % positional copy when the heights already match. + if ~isempty(keyCols) && numel(unique(keyA)) == numel(keyA) + [tf, loc] = ismember(keyT, keyA); + if all(tf) + for i = 1:numel(auxCols) + col = auxT.(auxCols{i}); + T.(auxCols{i}) = col(loc, :); + end + return; + end + end + + if height(auxT) == height(T) + for i = 1:numel(auxCols) + T.(auxCols{i}) = auxT.(auxCols{i}); + end + end +end + + +function key = buildRowKey(T, keyCols) +% BUILDROWKEY Per-row string key built from the given identifier columns + n = height(T); + if isempty(keyCols) + key = strings(n, 1); + return; + end + parts = strings(n, numel(keyCols)); + for c = 1:numel(keyCols) + col = T.(keyCols{c}); + if isnumeric(col) || islogical(col) + parts(:, c) = string(num2str(col(:), '%.10g')); + else + parts(:, c) = string(col(:)); + end + end + key = join(parts, char(31), 2); % unit-separator-joined composite key +end + + +function tf = isAuxWhitelisted(vn, whitelist) +% ISAUXWHITELISTED True if an aux_ column is opted in as a candidate covariate +% Matches when the whitelist contains 'all'/'*', the exact column name, or a +% token contained in the column name (e.g. 'heartRate' matches +% 'aux_heartRate_HR'). + tf = false; + if isempty(whitelist) + return; + end + if any(strcmpi(whitelist, 'all')) || any(strcmp(whitelist, '*')) + tf = true; + return; + end + for k = 1:numel(whitelist) + tok = whitelist{k}; + if isempty(tok) + continue; + end + if strcmpi(vn, tok) || contains(lower(vn), lower(tok)) + tf = true; + return; + end + end +end + + +function formula = buildFormulaFromTerms(response, terms, randomEffects) +% BUILDFORMULAFROMTERMS Assemble formula string from term list and random effects + + if isempty(terms) + fixedStr = '1'; + else + fixedStr = strjoin(terms, '+'); + end + + formula = sprintf('%s~%s+(%s)', response, fixedStr, randomEffects); +end + + +function nDf = countDf(terms, T) +% COUNTDF Estimate total fixed-effect parameters from term list +% +% For categorical variables, df = nLevels - 1 (reference coding). +% For numeric, df = 1. For interactions A:B, df = product of component dfs. + + nDf = 1; % intercept + + for i = 1:length(terms) + termStr = terms{i}; + parts = strsplit(termStr, ':'); + + termDf = 1; + for j = 1:length(parts) + pName = parts{j}; + if ismember(pName, T.Properties.VariableNames) + col = T.(pName); + if iscategorical(col) || iscell(col) || isstring(col) + if iscell(col) || isstring(col) + nLevels = length(unique(string(col(~ismissing(col))))); + else + nLevels = length(categories(col)); + end + termDf = termDf * max(nLevels - 1, 1); + else + termDf = termDf * 1; + end + else + % Unknown column (e.g. ot1 numeric) — 1 df + termDf = termDf * 1; + end + end + + nDf = nDf + termDf; + end +end + + +function [mdl, ic] = fitModelML(T, formula, criterion) +% FITMODELML Fit LME with ML and return model + IC value (Inf on failure) + + mdl = []; + ic = Inf; + + try + rng(2019); + mdl = fitlme(T, formula, 'FitMethod', 'ML', ... + 'CheckHessian', true, 'DummyVarCoding', 'reference'); + + if strcmp(criterion, 'AIC') + ic = mdl.ModelCriterion.AIC; + else + ic = mdl.ModelCriterion.BIC; + end + catch + % Convergence failure — return Inf + end +end + + +function results = extractModelStats(results, mergedTable, mdl, ... + varName, chRowName, bIdx, chI, randomEffects, finalFormula, opts) +% EXTRACTMODELSTATS Extract ANOVA, contrasts, coefficients, null comparison + + try + anv = anova(mdl, 'DFMethod', 'satterthwaite'); + results.anova{bIdx, chI} = anv; + + anovaNames = sanitizeNames(anv.Term); + + results.anova_pval{chRowName, anovaNames} = anv.pValue(:)'; + results.anova_Fstat{chRowName, anovaNames} = anv.FStat(:)'; + + try + results.anova_df1{chRowName, anovaNames} = anv.DF1(:)'; + results.anova_df2{chRowName, anovaNames} = anv.DF2(:)'; + catch + results.anova_df1{chRowName, anovaNames} = anv.DF(:)'; + results.anova_df2{chRowName, anovaNames} = anv.DF(:)'; + end + + % Auto contrasts + try + cTable = exploreFNIRS.fx.autoContrast(mdl, opts.ContrastThreshold); + results.contrasts{bIdx, chI} = cTable; + catch + results.contrasts{bIdx, chI} = table(); + end + + % Random effects coefficients + try + [~, ~, reCoefs] = randomEffects(mdl, 'DFMethod', 'satterthwaite'); + results.coefficients{bIdx, chI} = reCoefs; + + coefNames = reCoefs.Name; + cleanCoefNames = sanitizeNames(coefNames); + results.coef_pval{chRowName, cleanCoefNames} = reCoefs.pValue(:)'; + results.coef_tstat{chRowName, cleanCoefNames} = reCoefs.tStat(:)'; + results.coef_df{chRowName, cleanCoefNames} = reCoefs.DF(:)'; + catch + results.coefficients{bIdx, chI} = []; + end + + % Model fit test + try + mdlTest = eye(length(mdl.Coefficients.Name)); + mdlTest = mdlTest(2:end,:); + if ~isempty(mdlTest) + [mfP, mfF, mfDF1, mfDF2] = coefTest(mdl, mdlTest, ... + zeros(size(mdlTest,1),1), 'DFMethod', 'satterthwaite'); + results.modelFit{chRowName, {'p','F','df1','df2'}} = ... + [mfP, mfF, mfDF1, mfDF2]; + end + catch + end + + % Null model comparison (ML for LRT) + try + nullStr = sprintf('%s~1+(%s)', varName, randomEffects); + mdlML = fitlme(mergedTable, finalFormula, ... + 'FitMethod', 'ML', 'CheckHessian', true, ... + 'DummyVarCoding', 'reference'); + nullMdl = fitlme(mergedTable, nullStr, ... + 'FitMethod', 'ML', 'CheckHessian', true, ... + 'DummyVarCoding', 'reference'); + results.nullComparison{bIdx, chI} = compare(nullMdl, mdlML); + catch + results.nullComparison{bIdx, chI} = []; + end + + catch ME + if opts.Verbose + warning('pf2:stats:autoModelLME', ... + 'Stats extraction failed for %s: %s', chRowName, ME.message); + end + end +end + + +function cleanNames = sanitizeNames(names) +% SANITIZENAMES Clean ANOVA term names for use as table variable names + cleanNames = cell(size(names)); + for i = 1:length(names) + str = names{i}; + str(str == '(' | str == ')') = ''; + str(str == ':' | str == '_') = ''; + str(str == ' ' | str == '-') = ''; + cleanNames{i} = str; + end +end + + +function T = prepareTimeColumn(T, opts, hasMultipleTimeBins) +% PREPARETIMECOLUMN Transform Time column based on TimeModel setting + + if ~ismember('Time', T.Properties.VariableNames) + return; + end + + % Ensure numeric + if iscell(T.Time) || isstring(T.Time) + T.Time = str2double(T.Time); + end + + if ~hasMultipleTimeBins + return; + end + + switch lower(opts.TimeModel) + case 'polynomial' + timeVals = T.Time; + uTime = unique(timeVals); + nBins = length(uTime); + polyOrder = min(opts.PolynomialOrder, nBins - 1); + + tMin = min(uTime); + tMax = max(uTime); + if tMax == tMin + tNorm = zeros(size(uTime)); + else + tNorm = 2 * (uTime - tMin) / (tMax - tMin) - 1; + end + + rawPoly = zeros(nBins, polyOrder); + for k = 1:polyOrder + rawPoly(:, k) = tNorm .^ k; + end + [Q, ~] = qr(rawPoly, 0); + + [~, binIdx] = ismember(timeVals, uTime); + for k = 1:polyOrder + colName = sprintf('ot%d', k); + T.(colName) = Q(binIdx, k); + end + + T.Time = []; + + case 'discrete' + T.Time = categorical(T.Time); + + case 'continuous' + T.Time = T.Time - mean(T.Time); + + case 'none' + T.Time = []; + end +end + + +function labels = buildTermLabels(polyOrder) +% BUILDTERMLABELS Map polynomial term names to readable labels + ordinalNames = {'Linear', 'Quadratic', 'Cubic', 'Quartic', 'Quintic'}; + labels = struct(); + for k = 1:polyOrder + termName = sprintf('ot%d', k); + if k <= length(ordinalNames) + labels.(termName) = sprintf('Time (%s)', ordinalNames{k}); + else + labels.(termName) = sprintf('Time (Order %d)', k); + end + end +end + + +function ssIdx = getShortSeparationIdx(groups) +% GETSHORTSEPARATIONIDX Get indices of short separation channels from probe info + + ssIdx = []; + + if isempty(groups) || isempty(groups(1).gbyFNIRS) + return; + end + + fNIR = groups(1).gbyFNIRS{1}; + + probeInfo = []; + if isfield(fNIR, 'probeinfo') && isfield(fNIR.probeinfo, 'Probe') ... + && iscell(fNIR.probeinfo.Probe) && ~isempty(fNIR.probeinfo.Probe) + probeInfo = fNIR.probeinfo.Probe{1}; + elseif isfield(fNIR, 'info') && isfield(fNIR.info, 'probename') ... + && ~isempty(fNIR.info.probename) && ~contains(fNIR.info.probename, 'Unknown') + try + device = pf2_base.loadDeviceCfg(fNIR.info.probename); + if isstruct(device) && isfield(device, 'Probe') ... + && iscell(device.Probe) && ~isempty(device.Probe) + probeInfo = device.Probe{1}; + end + catch + return; + end + end + + if isempty(probeInfo) + return; + end + + if isfield(probeInfo, 'TableOpt') && istable(probeInfo.TableOpt) ... + && ismember('IsShortSeparation', probeInfo.TableOpt.Properties.VariableNames) + ssIdx = find(probeInfo.TableOpt.IsShortSeparation(:)'); + elseif isfield(probeInfo, 'NumShortSeparation') && probeInfo.NumShortSeparation > 0 ... + && isfield(probeInfo, 'TableOpt') && istable(probeInfo.TableOpt) ... + && ismember('SD', probeInfo.TableOpt.Properties.VariableNames) + ssIdx = find(probeInfo.TableOpt.SD(:)' < 2); + end +end diff --git a/+exploreFNIRS/+stats/behavioralTable.m b/+exploreFNIRS/+stats/behavioralTable.m new file mode 100644 index 00000000..7583dbfa --- /dev/null +++ b/+exploreFNIRS/+stats/behavioralTable.m @@ -0,0 +1,1220 @@ +function T = behavioralTable(experiment, variables, varargin) +% BEHAVIORALTABLE Descriptive stats, comparisons, or correlations for behavioral data +% +% Generates publication-ready tables for behavioral and experimental +% variables. Supports three analysis types: descriptive statistics grouped +% by condition, paired/unpaired comparisons with effect sizes, and +% correlation tables (one-to-many or matrix). Output as MATLAB table, +% formatted console text, or LaTeX with booktabs. +% +% Syntax: +% T = exploreFNIRS.stats.behavioralTable(ex, {'RT','Accuracy'}) +% T = exploreFNIRS.stats.behavioralTable(ex, vars, 'GroupBy', 'Condition') +% T = exploreFNIRS.stats.behavioralTable(ex, vars, 'Type', 'comparisons', ... +% 'GroupBy', 'Condition', 'Paired', true) +% T = exploreFNIRS.stats.behavioralTable(ex, vars, 'Type', 'correlations', ... +% 'YVar', 'Outcome', 'CorrMethod', 'spearman') +% T = exploreFNIRS.stats.behavioralTable(T_table, vars, 'Format', 'latex') +% +% Inputs: +% experiment - Experiment object or MATLAB table with behavioral data +% variables - Cell array of variable names to analyze +% +% Name-Value Parameters: +% Type - 'descriptive' (default), 'comparisons', or 'correlations' +% GroupBy - Categorical column for condition grouping (default: '') +% SubjectVar - Column for within-subject averaging (default: 'SubjectID') +% Paired - Use paired tests for comparisons (default: true) +% Comparisons - Specific pairs: {{'A','B'}, ...}. Empty = all pairwise. +% ComparisonLabels - Labels for comparisons: {'Label1', ...} (default: {}) +% YVar - Outcome variable for one-to-many correlations (default: '') +% CorrMethod - 'spearman' (default) or 'pearson' +% Triangle - 'lower' (default), 'upper', or 'full' (matrix correlations) +% Precision - Decimal places (default: 3) +% IncludeRange - Include [min, max] in descriptive tables (default: true) +% Labels - Variable name -> display label mapping (default: struct()) +% Accepts struct, containers.Map, or Nx2 cell array. +% Use Map or cell for names with special characters: +% containers.Map({'RT (Target)'},{'Target RT'}) +% {'RT (Target)','Target RT'; 'Non-Response','Non-Resp'} +% Format - 'table' (default), 'console', or 'latex' +% Caption - LaTeX table caption (default: '' = auto-generated) +% +% Outputs: +% T - MATLAB table with results. Columns depend on Type: +% 'descriptive': Variable, Group, n, M, SD, Min, Max +% 'comparisons': Variable, Comparison, Label, n, MeanDiff, SD_diff, +% t, df, p, d_z, CI_lower, CI_upper, Sig +% 'correlations': Variable (or matrix), rho/r, p, N, Sig +% +% Examples: +% % Descriptive stats by condition +% T = exploreFNIRS.stats.behavioralTable(ex, {'CravingRating','RT'}, ... +% 'GroupBy', 'Condition', 'Format', 'latex'); +% +% % Paired comparisons with custom labels +% T = exploreFNIRS.stats.behavioralTable(ex, {'CravingRating'}, ... +% 'Type', 'comparisons', 'GroupBy', 'Condition', ... +% 'Comparisons', {{'Watch','Neutral'}, {'Watch','Down'}}, ... +% 'ComparisonLabels', {'Reactivity', 'Regulation'}); +% +% % Correlation matrix +% T = exploreFNIRS.stats.behavioralTable(ex, {'RT','Age','Score'}, ... +% 'Type', 'correlations', 'CorrMethod', 'spearman', 'Format', 'latex'); +% +% See also: exploreFNIRS.stats.summarize, exploreFNIRS.report.correlationTable + + p = inputParser; + addRequired(p, 'experiment'); + addRequired(p, 'variables', @(x) iscell(x) || ischar(x) || isstring(x)); + addParameter(p, 'Type', 'descriptive', @ischar); + addParameter(p, 'GroupBy', '', @(x) ischar(x) || isstring(x)); + addParameter(p, 'SubjectVar', 'SubjectID', @(x) ischar(x) || isstring(x)); + addParameter(p, 'Paired', true, @islogical); + addParameter(p, 'Comparisons', {}, @iscell); + addParameter(p, 'ComparisonLabels', {}, @iscell); + addParameter(p, 'YVar', '', @(x) ischar(x) || isstring(x)); + addParameter(p, 'CorrMethod', 'spearman', @ischar); + addParameter(p, 'Triangle', 'lower', @ischar); + addParameter(p, 'Precision', 3, @isnumeric); + addParameter(p, 'IncludeRange', true, @islogical); + addParameter(p, 'Labels', struct(), @(x) isstruct(x) || isa(x, 'containers.Map') || iscell(x)); + addParameter(p, 'Format', 'table', @ischar); + addParameter(p, 'Caption', '', @(x) ischar(x) || isstring(x)); + parse(p, experiment, variables, varargin{:}); + opts = p.Results; + + % Normalize variables to cell array + if ischar(opts.variables) || isstring(opts.variables) + opts.variables = cellstr(opts.variables); + end + opts.GroupBy = char(opts.GroupBy); + opts.SubjectVar = char(opts.SubjectVar); + opts.YVar = char(opts.YVar); + opts.Caption = char(opts.Caption); + + % Extract table from Experiment or use directly + tbl = extractTable(experiment, opts); + + % Within-subject averaging when GroupBy is set + tbl = subjectLevelTable(tbl, opts); + + % Dispatch by type + switch lower(opts.Type) + case 'descriptive' + T = buildDescriptive(tbl, opts); + case 'comparisons' + T = buildComparisons(tbl, opts); + case 'correlations' + T = buildCorrelations(tbl, opts); + otherwise + error('exploreFNIRS:stats:behavioralTable', ... + 'Unknown Type: ''%s''. Use ''descriptive'', ''comparisons'', or ''correlations''.', ... + opts.Type); + end + + % Output formatting + if strcmpi(opts.Format, 'console') && ~isempty(T) + printConsole(T, opts); + end + if strcmpi(opts.Format, 'latex') && ~isempty(T) + printLatex(T, opts); + end +end + + +%% Table extraction + +function tbl = extractTable(experiment, opts) +% Extract a MATLAB table from an Experiment object or pass through a table + + if istable(experiment) + tbl = experiment; + return; + end + + % Experiment object — extract merged table + if isa(experiment, 'exploreFNIRS.core.Experiment') + if ~isempty(experiment.groups) && isfield(experiment.groups, 'gbyGrandBarFlat') + tbl = experiment.groups.gbyGrandBarFlat; + elseif isprop(experiment, 'data') && ~isempty(experiment.data) + tbl = pf2.data.infoToTable(experiment.data); + else + error('exploreFNIRS:stats:behavioralTable', ... + 'Experiment has no data. Call select() first.'); + end + else + error('exploreFNIRS:stats:behavioralTable', ... + 'First argument must be an Experiment object or a MATLAB table.'); + end + + % Validate required columns exist + allVars = opts.variables; + if ~isempty(opts.GroupBy) + allVars = [allVars, {opts.GroupBy}]; + end + if ~isempty(opts.YVar) + allVars = [allVars, {opts.YVar}]; + end + missing = setdiff(allVars, tbl.Properties.VariableNames); + if ~isempty(missing) + error('exploreFNIRS:stats:behavioralTable', ... + 'Variables not found in table: %s', strjoin(missing, ', ')); + end +end + + +function tbl = subjectLevelTable(tbl, opts) +% Average within subject (and optionally within group) to get one row per +% subject per condition. Only averages numeric variables. + + if isempty(opts.SubjectVar) || ... + ~ismember(opts.SubjectVar, tbl.Properties.VariableNames) + return; + end + + % Determine grouping columns + groupCols = {opts.SubjectVar}; + if ~isempty(opts.GroupBy) && ismember(opts.GroupBy, tbl.Properties.VariableNames) + groupCols{end+1} = opts.GroupBy; + end + + % Identify numeric columns to average + numVars = opts.variables; + if ~isempty(opts.YVar) && ~ismember(opts.YVar, numVars) + numVars = [numVars, {opts.YVar}]; + end + numVars = numVars(ismember(numVars, tbl.Properties.VariableNames)); + + % Check if averaging is needed (more rows than unique combinations) + if isempty(numVars) + return; + end + + groupVals = tbl(:, groupCols); + [~, ia] = unique(groupVals, 'rows'); + if length(ia) == height(tbl) + return; % Already one row per subject-condition + end + + tbl = groupsummary(tbl, groupCols, 'mean', numVars); + + % Rename mean_ columns back to original names + for i = 1:length(numVars) + meanName = ['mean_', numVars{i}]; + if ismember(meanName, tbl.Properties.VariableNames) + tbl.Properties.VariableNames{strcmp(tbl.Properties.VariableNames, meanName)} = numVars{i}; + end + end + + % Drop GroupCount column added by groupsummary + if ismember('GroupCount', tbl.Properties.VariableNames) + tbl.GroupCount = []; + end +end + + +%% Type: descriptive + +function T = buildDescriptive(tbl, opts) +% Build descriptive statistics table: M, SD, min, max per group per variable + + vars = opts.variables; + hasGroup = ~isempty(opts.GroupBy) && ismember(opts.GroupBy, tbl.Properties.VariableNames); + + if hasGroup + groups = categories(categorical(tbl.(opts.GroupBy))); + else + groups = {''}; + end + + rows = {}; + for v = 1:length(vars) + for g = 1:length(groups) + if hasGroup + mask = strcmp(string(tbl.(opts.GroupBy)), groups{g}); + vals = tbl.(vars{v})(mask); + else + vals = tbl.(vars{v}); + end + vals = vals(~isnan(vals)); + + row = struct(); + row.Variable = getLabel(vars{v}, opts.Labels); + row.Group = groups{g}; + row.n = length(vals); + row.M = mean(vals); + row.SD = std(vals); + if opts.IncludeRange + row.Min = min(vals); + row.Max = max(vals); + end + rows{end+1} = row; %#ok + end + end + + if isempty(rows) + T = table(); + return; + end + + S = [rows{:}]; + if isscalar(S) + T = struct2table(S, 'AsArray', true); + else + T = struct2table(S); + end + T.Variable = string(T.Variable); + T.Group = string(T.Group); + + if ~hasGroup + T.Group = []; + end +end + + +%% Type: comparisons + +function T = buildComparisons(tbl, opts) +% Build paired or unpaired comparison table with t-tests and effect sizes + + vars = opts.variables; + + if isempty(opts.GroupBy) + error('exploreFNIRS:stats:behavioralTable', ... + 'GroupBy is required for Type=''comparisons''.'); + end + + groups = categories(categorical(tbl.(opts.GroupBy))); + + % Generate comparison pairs + if isempty(opts.Comparisons) + pairs = {}; + for i = 1:length(groups) + for j = (i+1):length(groups) + pairs{end+1} = {groups{i}, groups{j}}; %#ok + end + end + else + pairs = opts.Comparisons; + end + + % Labels for comparisons + compLabels = opts.ComparisonLabels; + if length(compLabels) < length(pairs) + for k = (length(compLabels)+1):length(pairs) + compLabels{k} = ''; + end + end + + rows = {}; + for v = 1:length(vars) + for c = 1:length(pairs) + pairGroups = pairs{c}; + g1 = pairGroups{1}; + g2 = pairGroups{2}; + compName = [g1, '--', g2]; + + varLabel = getLabel(vars{v}, opts.Labels); + if opts.Paired + row = pairedTest(tbl, vars{v}, opts, g1, g2, compName, compLabels{c}, varLabel); + else + row = unpairedTest(tbl, vars{v}, opts, g1, g2, compName, compLabels{c}, varLabel); + end + rows{end+1} = row; %#ok + end + end + + if isempty(rows) + T = table(); + return; + end + + S = [rows{:}]; + if isscalar(S) + T = struct2table(S, 'AsArray', true); + else + T = struct2table(S); + end + T.Variable = string(T.Variable); + T.Comparison = string(T.Comparison); + T.Label = string(T.Label); + T.Sig = string(T.Sig); +end + + +function row = pairedTest(tbl, varName, opts, g1, g2, compName, compLabel, varLabel) +% Paired t-test: match subjects across conditions via SubjectVar + + subVar = opts.SubjectVar; + + mask1 = strcmp(string(tbl.(opts.GroupBy)), g1); + mask2 = strcmp(string(tbl.(opts.GroupBy)), g2); + + t1 = tbl(mask1, :); + t2 = tbl(mask2, :); + + % Match subjects present in both conditions + subs1 = string(t1.(subVar)); + subs2 = string(t2.(subVar)); + common = intersect(subs1, subs2); + + if isempty(common) + row = emptyCompRow(varLabel, compName, compLabel); + return; + end + + [~, idx1] = ismember(common, subs1); + [~, idx2] = ismember(common, subs2); + + vals1 = t1.(varName)(idx1); + vals2 = t2.(varName)(idx2); + + % Remove NaN pairs + valid = ~isnan(vals1) & ~isnan(vals2); + vals1 = vals1(valid); + vals2 = vals2(valid); + n = length(vals1); + + if n < 2 + row = emptyCompRow(varLabel, compName, compLabel); + row.n = n; + return; + end + + d = vals1 - vals2; + meanDiff = mean(d); + sdDiff = std(d); + tStat = meanDiff / (sdDiff / sqrt(n)); + df = n - 1; + pVal = 2 * (1 - pf2_base.compat.tcdf(abs(tStat), df)); + dz = meanDiff / sdDiff; % paired Cohen's d_z + + % 95% CI on the mean difference + tCrit = tinv(0.975, df); + sem = sdDiff / sqrt(n); + ciLower = meanDiff - tCrit * sem; + ciUpper = meanDiff + tCrit * sem; + + row = struct(); + row.Variable = varLabel; + row.Comparison = compName; + row.Label = compLabel; + row.n = n; + row.MeanDiff = meanDiff; + row.SD_diff = sdDiff; + row.t = tStat; + row.df = df; + row.p = pVal; + row.d_z = dz; + row.CI_lower = ciLower; + row.CI_upper = ciUpper; + row.Sig = sigStars(pVal); +end + + +function row = unpairedTest(tbl, varName, opts, g1, g2, compName, compLabel, varLabel) +% Independent samples t-test (Welch's) + + mask1 = strcmp(string(tbl.(opts.GroupBy)), g1); + mask2 = strcmp(string(tbl.(opts.GroupBy)), g2); + + vals1 = tbl.(varName)(mask1); + vals2 = tbl.(varName)(mask2); + + vals1 = vals1(~isnan(vals1)); + vals2 = vals2(~isnan(vals2)); + + n1 = length(vals1); + n2 = length(vals2); + n = n1 + n2; + + if n1 < 2 || n2 < 2 + row = emptyCompRow(varLabel, compName, compLabel); + row.n = n; + return; + end + + m1 = mean(vals1); + m2 = mean(vals2); + s1 = std(vals1); + s2 = std(vals2); + + meanDiff = m1 - m2; + se = sqrt(s1^2/n1 + s2^2/n2); + tStat = meanDiff / se; + + % Welch-Satterthwaite df + num = (s1^2/n1 + s2^2/n2)^2; + den = (s1^2/n1)^2/(n1-1) + (s2^2/n2)^2/(n2-1); + df = num / den; + + pVal = 2 * (1 - pf2_base.compat.tcdf(abs(tStat), df)); + + % Cohen's d (pooled SD) + sp = sqrt(((n1-1)*s1^2 + (n2-1)*s2^2) / (n1+n2-2)); + dz = meanDiff / sp; + + tCrit = tinv(0.975, df); + ciLower = meanDiff - tCrit * se; + ciUpper = meanDiff + tCrit * se; + + row = struct(); + row.Variable = varLabel; + row.Comparison = compName; + row.Label = compLabel; + row.n = n; + row.MeanDiff = meanDiff; + row.SD_diff = se; % report SE for unpaired + row.t = tStat; + row.df = df; + row.p = pVal; + row.d_z = dz; + row.CI_lower = ciLower; + row.CI_upper = ciUpper; + row.Sig = sigStars(pVal); +end + + +function row = emptyCompRow(varLabel, compName, compLabel) + row = struct(); + row.Variable = varLabel; + row.Comparison = compName; + row.Label = compLabel; + row.n = 0; + row.MeanDiff = NaN; + row.SD_diff = NaN; + row.t = NaN; + row.df = NaN; + row.p = NaN; + row.d_z = NaN; + row.CI_lower = NaN; + row.CI_upper = NaN; + row.Sig = ''; +end + + +%% Type: correlations + +function T = buildCorrelations(tbl, opts) +% Build correlation table — one-to-many or many-to-many matrix + + if ~isempty(opts.YVar) + T = buildCorrelationsOneToMany(tbl, opts); + else + T = buildCorrelationsMatrix(tbl, opts); + end +end + + +function T = buildCorrelationsOneToMany(tbl, opts) +% One-to-many: correlate each variable in list with a single YVar + + vars = opts.variables; + yVar = opts.YVar; + prec = opts.Precision; + + rows = {}; + for v = 1:length(vars) + x = tbl.(vars{v}); + y = tbl.(yVar); + + valid = ~isnan(x) & ~isnan(y); + x = x(valid); + y = y(valid); + n = length(x); + + if n < 3 + row = struct(); + row.Variable = getLabel(vars{v}, opts.Labels); + row.N = n; + if strcmpi(opts.CorrMethod, 'spearman') + row.rho = NaN; row.p = NaN; + else + row.r = NaN; row.p = NaN; + end + row.Sig = ''; + rows{end+1} = row; %#ok + continue; + end + + [rho, pVal] = pf2_base.compat.corr(x, y, 'Type', opts.CorrMethod, 'Rows', 'complete'); + + row = struct(); + row.Variable = getLabel(vars{v}, opts.Labels); + row.N = n; + if strcmpi(opts.CorrMethod, 'spearman') + row.rho = round(rho, prec); + else + row.r = round(rho, prec); + end + row.p = pVal; + row.Sig = sigStars(pVal); + rows{end+1} = row; %#ok + end + + if isempty(rows) + T = table(); + return; + end + + S = [rows{:}]; + if isscalar(S) + T = struct2table(S, 'AsArray', true); + else + T = struct2table(S); + end + T.Variable = string(T.Variable); + T.Sig = string(T.Sig); +end + + +function T = buildCorrelationsMatrix(tbl, opts) +% Many-to-many: correlation matrix between all variables in list + + vars = opts.variables; + nVars = length(vars); + prec = opts.Precision; + + % Build data matrix + X = zeros(height(tbl), nVars); + for v = 1:nVars + X(:, v) = tbl.(vars{v}); + end + + [R, P] = pf2_base.compat.corr(X, 'Type', opts.CorrMethod, 'Rows', 'pairwise'); + + % Count pairwise N + N = zeros(nVars); + for i = 1:nVars + for j = 1:nVars + valid = ~isnan(X(:,i)) & ~isnan(X(:,j)); + N(i,j) = sum(valid); + end + end + + % Build formatted cell matrix + labels = cell(1, nVars); + for v = 1:nVars + labels{v} = getLabel(vars{v}, opts.Labels); + end + + cells = cell(nVars, nVars); + for i = 1:nVars + for j = 1:nVars + switch lower(opts.Triangle) + case 'lower' + show = (j < i); + case 'upper' + show = (j > i); + case 'full' + show = (i ~= j); + otherwise + show = (j < i); + end + + if i == j + cells{i,j} = '--'; + elseif show + rStr = sprintf('%.*f', prec, R(i,j)); + rStr = regexprep(rStr, '^0\.', '.'); + rStr = regexprep(rStr, '^-0\.', '-.'); + cells{i,j} = [rStr, sigStars(P(i,j))]; + else + cells{i,j} = ''; + end + end + end + + % Number the row labels + rowLabels = cell(nVars, 1); + for v = 1:nVars + rowLabels{v} = sprintf('%d. %s', v, labels{v}); + end + + colLabels = arrayfun(@(x) sprintf('%d', x), 1:nVars, 'UniformOutput', false); + T = cell2table(cells, 'VariableNames', matlab.lang.makeValidName(colLabels), ... + 'RowNames', rowLabels); + + % Store R, P, N as UserData for latex formatting + T.Properties.UserData = struct('R', R, 'P', P, 'N', N, ... + 'labels', {labels}, 'CorrMethod', opts.CorrMethod, ... + 'Triangle', opts.Triangle, 'Precision', prec); +end + + +%% Console output + +function printConsole(T, opts) +% Print formatted table to console + + switch lower(opts.Type) + case 'descriptive' + printConsoleDescriptive(T, opts); + case 'comparisons' + printConsoleComparisons(T, opts); + case 'correlations' + printConsoleCorrelations(T, opts); + end +end + + +function printConsoleDescriptive(T, opts) + prec = opts.Precision; + hasGroup = ismember('Group', T.Properties.VariableNames); + hasRange = opts.IncludeRange && ismember('Min', T.Properties.VariableNames); + + fprintf('\n Descriptive Statistics'); + if hasGroup + fprintf(' by %s', opts.GroupBy); + end + fprintf('\n'); + fprintf(' %s\n', repmat('-', 1, 60)); + + if hasGroup + groups = unique(T.Group, 'stable'); + vars = unique(T.Variable, 'stable'); + + % Header + fprintf(' %-25s', ''); + for g = 1:length(groups) + gMask = T.Group == groups(g); + n = T.n(find(gMask, 1)); + fprintf(' %-25s', sprintf('%s (n = %d)', groups(g), n)); + end + fprintf('\n'); + fprintf(' %s\n', repmat('-', 1, 25 + 27*length(groups))); + + for v = 1:length(vars) + fprintf(' %-25s', vars(v)); + for g = 1:length(groups) + mask = T.Variable == vars(v) & T.Group == groups(g); + row = T(mask, :); + if hasRange + fprintf(' %.*f (%.*f) [%.*f, %.*f] ', ... + prec, row.M, prec, row.SD, prec, row.Min, prec, row.Max); + else + fprintf(' %.*f (%.*f) ', prec, row.M, prec, row.SD); + end + end + fprintf('\n'); + end + else + for r = 1:height(T) + if hasRange + fprintf(' %-25s %.*f (%.*f) [%.*f, %.*f] n = %d\n', ... + T.Variable(r), prec, T.M(r), prec, T.SD(r), ... + prec, T.Min(r), prec, T.Max(r), T.n(r)); + else + fprintf(' %-25s %.*f (%.*f) n = %d\n', ... + T.Variable(r), prec, T.M(r), prec, T.SD(r), T.n(r)); + end + end + end + + fprintf('\n'); + if hasRange + fprintf(' Note. Values are M (SD) [Min, Max].\n\n'); + else + fprintf(' Note. Values are M (SD).\n\n'); + end +end + + +function printConsoleComparisons(T, opts) + prec = opts.Precision; + + fprintf('\n Paired Comparisons\n'); + fprintf(' %s\n', repmat('-', 1, 90)); + fprintf(' %-20s %-20s %5s %8s %8s %5s %8s\n', ... + 'Variable', 'Comparison', 'n', 'Delta M', 't(df)', 'p', 'd_z'); + fprintf(' %s\n', repmat('-', 1, 90)); + + for r = 1:height(T) + tdf = sprintf('%.*f(%d)', max(prec-1,2), T.t(r), round(T.df(r))); + pStr = exploreFNIRS.report.formatPValue(T.p(r), 'Precision', prec); + fprintf(' %-20s %-20s %5d %8.*f %8s %5s %8.*f\n', ... + T.Variable(r), T.Comparison(r), T.n(r), ... + prec, T.MeanDiff(r), tdf, pStr, prec, T.d_z(r)); + end + fprintf('\n'); + if opts.Paired + fprintf(' Note. d_z = paired Cohen''s d. CI = confidence interval on the mean difference.\n'); + else + fprintf(' Note. Cohen''s d (pooled SD). CI = confidence interval on the mean difference.\n'); + end + fprintf(' *p < .05, **p < .01, ***p < .001.\n\n'); +end + + +function printConsoleCorrelations(T, opts) + prec = opts.Precision; + + if ~isempty(T.Properties.RowNames) + % Matrix format + fprintf('\n Correlation Matrix (%s)\n', opts.CorrMethod); + fprintf(' %s\n', repmat('-', 1, 60)); + + colNames = T.Properties.VariableNames; + rowNames = T.Properties.RowNames; + + % Header + fprintf(' %-25s', ''); + for c = 1:width(T) + fprintf(' %8s', colNames{c}); + end + fprintf('\n'); + fprintf(' %s\n', repmat('-', 1, 25 + 9*width(T))); + + for r = 1:height(T) + fprintf(' %-25s', rowNames{r}); + for c = 1:width(T) + fprintf(' %8s', T{r,c}{1}); + end + fprintf('\n'); + end + if ~isempty(T.Properties.UserData) + Nmin = min(T.Properties.UserData.N(:)); + Nmax = max(T.Properties.UserData.N(:)); + if Nmin == Nmax + fprintf('\n Note. N = %d.', Nmin); + else + fprintf('\n Note. N = %d-%d.', Nmin, Nmax); + end + end + else + % One-to-many format + corrSym = 'rho'; + if strcmpi(opts.CorrMethod, 'pearson') + corrSym = 'r'; + end + fprintf('\n Correlations with %s (%s)\n', opts.YVar, opts.CorrMethod); + fprintf(' %s\n', repmat('-', 1, 55)); + fprintf(' %-25s %8s %8s %5s\n', 'Measure', corrSym, 'p', 'N'); + fprintf(' %s\n', repmat('-', 1, 55)); + + for r = 1:height(T) + if ismember('rho', T.Properties.VariableNames) + rVal = T.rho(r); + else + rVal = T.r(r); + end + pStr = exploreFNIRS.report.formatPValue(T.p(r), 'Precision', prec); + fprintf(' %-25s %8.*f %8s %5d\n', ... + T.Variable(r), prec, rVal, pStr, T.N(r)); + end + end + fprintf('\n %s correlations.', capitalize(opts.CorrMethod)); + fprintf('\n *p < .05, **p < .01, ***p < .001.\n\n'); +end + + +%% LaTeX output + +function printLatex(T, opts) +% Print LaTeX booktabs table + + switch lower(opts.Type) + case 'descriptive' + printLatexDescriptive(T, opts); + case 'comparisons' + printLatexComparisons(T, opts); + case 'correlations' + printLatexCorrelations(T, opts); + end +end + + +function printLatexDescriptive(T, opts) + prec = opts.Precision; + hasGroup = ismember('Group', T.Properties.VariableNames); + hasRange = opts.IncludeRange && ismember('Min', T.Properties.VariableNames); + + caption = opts.Caption; + if isempty(caption) + caption = 'Descriptive Statistics'; + if hasGroup + caption = sprintf('Descriptive Statistics by %s', opts.GroupBy); + end + end + + if hasGroup + groups = unique(T.Group, 'stable'); + vars = unique(T.Variable, 'stable'); + nGroups = length(groups); + + % Build alignment: l for variable name + l per group + align = ['l', repmat('l', 1, nGroups)]; + + fprintf('\\begin{table}[htbp]\n'); + fprintf('\\centering\n'); + fprintf('\\caption{%s}\n', caption); + fprintf('\\begin{tabular}{%s}\n', align); + fprintf('\\toprule\n'); + + % Header with n per group + fprintf(' '); + for g = 1:nGroups + gMask = T.Group == groups(g); + n = T.n(find(gMask, 1)); + fprintf(' & %s ($n$ = %d)', groups(g), n); + end + fprintf(' \\\\\n'); + fprintf('\\midrule\n'); + + for v = 1:length(vars) + fprintf('%s', vars(v)); + for g = 1:nGroups + mask = T.Variable == vars(v) & T.Group == groups(g); + row = T(mask, :); + if hasRange + fprintf(' & %.*f (%.*f) [%.*f, %.*f]', ... + prec, row.M, prec, row.SD, prec, row.Min, prec, row.Max); + else + fprintf(' & %.*f (%.*f)', prec, row.M, prec, row.SD); + end + end + fprintf(' \\\\\n'); + end + + fprintf('\\bottomrule\n'); + fprintf('\\end{tabular}\n'); + if hasRange + fprintf('\\par\\smallskip\\footnotesize\\textit{Note.} Values are $M$ ($SD$) [Min, Max].\n'); + else + fprintf('\\par\\smallskip\\footnotesize\\textit{Note.} Values are $M$ ($SD$).\n'); + end + fprintf('\\end{table}\n'); + else + % No groups — simple table + if hasRange + align = 'lrrrrrr'; + fprintf('\\begin{table}[htbp]\n'); + fprintf('\\centering\n'); + fprintf('\\caption{%s}\n', caption); + fprintf('\\begin{tabular}{%s}\n', align); + fprintf('\\toprule\n'); + fprintf('Variable & $n$ & $M$ & $SD$ & Min & Max \\\\\n'); + fprintf('\\midrule\n'); + for r = 1:height(T) + fprintf('%s & %d & %.*f & %.*f & %.*f & %.*f \\\\\n', ... + T.Variable(r), T.n(r), prec, T.M(r), prec, T.SD(r), ... + prec, T.Min(r), prec, T.Max(r)); + end + else + align = 'lrrr'; + fprintf('\\begin{table}[htbp]\n'); + fprintf('\\centering\n'); + fprintf('\\caption{%s}\n', caption); + fprintf('\\begin{tabular}{%s}\n', align); + fprintf('\\toprule\n'); + fprintf('Variable & $n$ & $M$ & $SD$ \\\\\n'); + fprintf('\\midrule\n'); + for r = 1:height(T) + fprintf('%s & %d & %.*f & %.*f \\\\\n', ... + T.Variable(r), T.n(r), prec, T.M(r), prec, T.SD(r)); + end + end + fprintf('\\bottomrule\n'); + fprintf('\\end{tabular}\n'); + fprintf('\\end{table}\n'); + end +end + + +function printLatexComparisons(T, opts) + prec = opts.Precision; + + caption = opts.Caption; + if isempty(caption) + if opts.Paired + caption = 'Paired Comparisons'; + else + caption = 'Independent Samples Comparisons'; + end + end + + fprintf('\\begin{table}[htbp]\n'); + fprintf('\\centering\n'); + fprintf('\\caption{%s}\n', caption); + fprintf('\\begin{tabular}{llrrrrrl}\n'); + fprintf('\\toprule\n'); + fprintf('Variable & Comparison & $n$ & $\\Delta M$ & $t$($df$) & $p$ & $d_z$ & 95\\%% CI \\\\\n'); + fprintf('\\midrule\n'); + + for r = 1:height(T) + tdf = sprintf('%.2f(%d)', T.t(r), round(T.df(r))); + pStr = formatPValueLatex(T.p(r), prec); + ciStr = formatCILatex(T.CI_lower(r), T.CI_upper(r), prec); + + fprintf('%s & %s & %d & %.*f & %s & %s & %.*f & %s \\\\\n', ... + T.Variable(r), strrep(char(T.Comparison(r)), '--', '--'), ... + T.n(r), prec, T.MeanDiff(r), tdf, pStr, prec, T.d_z(r), ciStr); + end + + fprintf('\\bottomrule\n'); + fprintf('\\end{tabular}\n'); + if opts.Paired + fprintf('\\par\\smallskip\\footnotesize\\textit{Note.} $d_z$ = paired Cohen''s $d$. CI = confidence interval on the mean difference.\n'); + else + fprintf('\\par\\smallskip\\footnotesize\\textit{Note.} $d$ = Cohen''s $d$ (pooled $SD$). CI = confidence interval on the mean difference.\n'); + end + fprintf('$^{*}p < .05$, $^{**}p < .01$, $^{***}p < .001$.\n'); + fprintf('\\end{table}\n'); +end + + +function printLatexCorrelations(T, opts) + prec = opts.Precision; + + if ~isempty(T.Properties.RowNames) + % Matrix format + printLatexCorrMatrix(T, opts, prec); + else + % One-to-many format + printLatexCorrOneToMany(T, opts, prec); + end +end + + +function printLatexCorrOneToMany(T, opts, prec) + caption = opts.Caption; + if isempty(caption) + yLabel = opts.YVar; + if isfield(opts.Labels, opts.YVar) + yLabel = opts.Labels.(opts.YVar); + end + caption = sprintf('Correlations with %s', yLabel); + end + + isSpearman = strcmpi(opts.CorrMethod, 'spearman'); + corrSym = '$\rho$'; + if ~isSpearman + corrSym = '$r$'; + end + + fprintf('\\begin{table}[htbp]\n'); + fprintf('\\centering\n'); + fprintf('\\caption{%s}\n', caption); + fprintf('\\begin{tabular}{lrrl}\n'); + fprintf('\\toprule\n'); + fprintf('Measure & %s & $p$ & $N$ \\\\\n', corrSym); + fprintf('\\midrule\n'); + + for r = 1:height(T) + if isSpearman + rVal = T.rho(r); + else + rVal = T.r(r); + end + rStr = formatCorrValLatex(rVal, prec); + pStr = formatPValueLatex(T.p(r), prec); + + fprintf('%s & %s & %s & %d \\\\\n', ... + T.Variable(r), rStr, pStr, T.N(r)); + end + + fprintf('\\bottomrule\n'); + fprintf('\\end{tabular}\n'); + fprintf('\\par\\smallskip\\footnotesize\\textit{Note.} %s rank correlations.\n', ... + capitalize(opts.CorrMethod)); + fprintf('$^{*}p < .05$, $^{**}p < .01$, $^{***}p < .001$.\n'); + fprintf('\\end{table}\n'); +end + + +function printLatexCorrMatrix(T, opts, prec) + ud = T.Properties.UserData; + nVars = length(ud.labels); + R = ud.R; + P = ud.P; + + caption = opts.Caption; + if isempty(caption) + caption = 'Correlation Matrix'; + end + + % Build alignment: l + c per column + align = ['l', repmat('c', 1, nVars)]; + + fprintf('\\begin{table}[htbp]\n'); + fprintf('\\centering\n'); + fprintf('\\caption{%s}\n', caption); + fprintf('\\begin{tabular}{%s}\n', align); + fprintf('\\toprule\n'); + + % Header + fprintf(' '); + for v = 1:nVars + fprintf(' & %d', v); + end + fprintf(' \\\\\n'); + fprintf('\\midrule\n'); + + % Rows + for i = 1:nVars + fprintf('%d. %s', i, ud.labels{i}); + for j = 1:nVars + if i == j + fprintf(' & --'); + else + switch lower(ud.Triangle) + case 'lower' + show = (j < i); + case 'upper' + show = (j > i); + case 'full' + show = true; + otherwise + show = (j < i); + end + if show + rStr = formatCorrValLatex(R(i,j), prec); + stars = sigStarsLatex(P(i,j)); + fprintf(' & %s%s', rStr, stars); + else + fprintf(' & '); + end + end + end + fprintf(' \\\\\n'); + end + + fprintf('\\bottomrule\n'); + fprintf('\\end{tabular}\n'); + + Nmin = min(ud.N(~eye(nVars))); + Nmax = max(ud.N(~eye(nVars))); + if Nmin == Nmax + nStr = sprintf('$N = %d$', Nmin); + else + nStr = sprintf('$N = %d$--%d', Nmin, Nmax); + end + fprintf('\\par\\smallskip\\footnotesize\\textit{Note.} %s. %s correlations. %s triangle shown.\n', ... + nStr, capitalize(ud.CorrMethod), capitalize(ud.Triangle)); + fprintf('$^{*}p < .05$, $^{**}p < .01$, $^{***}p < .001$.\n'); + fprintf('\\end{table}\n'); +end + + +%% Shared helpers + +function s = sigStars(p) + if isnan(p) + s = ''; + elseif p < 0.001 + s = '***'; + elseif p < 0.01 + s = '**'; + elseif p < 0.05 + s = '*'; + else + s = ''; + end +end + + +function s = sigStarsLatex(p) +% Significance stars wrapped in math mode for LaTeX + if isnan(p) + s = ''; + elseif p < 0.001 + s = '$^{***}$'; + elseif p < 0.01 + s = '$^{**}$'; + elseif p < 0.05 + s = '$^{*}$'; + else + s = ''; + end +end + + +function s = formatPValueLatex(p, prec) +% APA-style p-value for LaTeX (no leading zero) + if isnan(p) + s = ''; + elseif p < 0.001 + s = '< .001'; + else + raw = sprintf('%.*f', prec, p); + s = regexprep(raw, '^0', ''); + end +end + + +function s = formatCorrValLatex(r, prec) +% Format correlation coefficient for LaTeX, with $-$ for negative + if isnan(r) + s = ''; + return; + end + rStr = sprintf('%.*f', prec, abs(r)); + rStr = regexprep(rStr, '^0\.', '.'); + if r < 0 + s = ['$-$', rStr]; + else + s = rStr; + end +end + + +function s = formatCILatex(lo, hi, prec) +% Format 95% CI for LaTeX + if isnan(lo) || isnan(hi) + s = ''; + return; + end + loStr = sprintf('%.*f', prec, lo); + hiStr = sprintf('%.*f', prec, hi); + % Use $-$ for negative values + if lo < 0 + loStr = sprintf('$-$%.*f', prec, abs(lo)); + end + if hi < 0 + hiStr = sprintf('$-$%.*f', prec, abs(hi)); + end + s = sprintf('[%s, %s]', loStr, hiStr); +end + + +function label = getLabel(varName, labels) +% Get display label for a variable, or use the variable name itself +% Supports struct, containers.Map, or Nx2 cell array of {key, label} pairs + if isa(labels, 'containers.Map') + if labels.isKey(varName) + label = labels(varName); + else + label = varName; + end + elseif iscell(labels) && size(labels, 2) >= 2 + idx = find(strcmp(labels(:,1), varName), 1); + if ~isempty(idx) + label = labels{idx, 2}; + else + label = varName; + end + elseif isstruct(labels) + safeKey = matlab.lang.makeValidName(varName); + if isfield(labels, varName) + label = labels.(varName); + elseif isfield(labels, safeKey) + label = labels.(safeKey); + else + label = varName; + end + else + label = varName; + end +end + + +function s = capitalize(str) +% Capitalize first letter + str = char(str); + if isempty(str) + s = str; + return; + end + s = [upper(str(1)), str(2:end)]; +end diff --git a/+exploreFNIRS/+stats/buildContrasts.m b/+exploreFNIRS/+stats/buildContrasts.m new file mode 100644 index 00000000..0f85ed2c --- /dev/null +++ b/+exploreFNIRS/+stats/buildContrasts.m @@ -0,0 +1,354 @@ +function spec = buildContrasts(mdl, type) +% BUILDCONTRASTS Generate standard contrast matrices from a fitted LME model +% +% Builds contrast specification structs for common comparison types, +% suitable for use with exploreFNIRS.stats.runContrasts('Contrasts', spec). +% +% Syntax: +% spec = exploreFNIRS.stats.buildContrasts(mdl, type) +% +% Inputs: +% mdl - Fitted LinearMixedModel object +% type - Contrast type: +% 'pairwise' - All pairwise level comparisons (replicates autoContrast) +% 'polynomial' - Linear + quadratic trends (for ordered factors) +% 'linear' - Linear trend only +% 'quadratic' - Quadratic trend only +% 'helmert' - Compare each level to mean of subsequent levels +% 'deviation' - Compare each level to grand mean +% +% Outputs: +% spec - Struct with fields: +% .matrix - [nContrasts x nCoefficients] contrast matrix +% .labels - Cell array of contrast names +% +% Example: +% mdl = fitlme(T, 'HbO ~ Condition + (1|SubjectID)'); +% +% % Polynomial (linear + quadratic) for 3-level factor +% spec = exploreFNIRS.stats.buildContrasts(mdl, 'polynomial'); +% cr = exploreFNIRS.stats.runContrasts(results, 'Contrasts', spec); +% +% % All pairwise comparisons +% spec = exploreFNIRS.stats.buildContrasts(mdl, 'pairwise'); +% +% See also: exploreFNIRS.stats.runContrasts, coefTest + + if nargin < 2 + type = 'pairwise'; + end + + coefNames = mdl.CoefficientNames'; + nCoefs = length(coefNames); + hasIntercept = any(strcmp(coefNames, '(Intercept)')); + + % Parse coefficient structure to find factor levels + [factors, levelMap] = parseCoefficients(coefNames, hasIntercept); + + switch lower(type) + case 'pairwise' + spec = buildPairwise(coefNames, nCoefs, factors, levelMap, hasIntercept); + + case 'polynomial' + spec = buildPolynomial(coefNames, nCoefs, factors, levelMap, hasIntercept, 'both'); + + case 'linear' + spec = buildPolynomial(coefNames, nCoefs, factors, levelMap, hasIntercept, 'linear'); + + case 'quadratic' + spec = buildPolynomial(coefNames, nCoefs, factors, levelMap, hasIntercept, 'quadratic'); + + case 'helmert' + spec = buildHelmert(coefNames, nCoefs, factors, levelMap, hasIntercept); + + case 'deviation' + spec = buildDeviation(coefNames, nCoefs, factors, levelMap, hasIntercept); + + otherwise + error('exploreFNIRS:stats:buildContrasts:unknownType', ... + 'Unknown contrast type: ''%s''. Use ''pairwise'', ''polynomial'', ''linear'', ''quadratic'', ''helmert'', or ''deviation''.', type); + end +end + + +function [factors, levelMap] = parseCoefficients(coefNames, hasIntercept) +% PARSECOEFFICIENTS Extract factor names and their levels from coefficient names +% +% Returns: +% factors - cell array of factor names (excluding Intercept) +% levelMap - containers.Map from factor name to cell array of level suffixes + + factors = {}; + levelMap = containers.Map(); + + startIdx = 1 + hasIntercept; % skip intercept + + for i = startIdx:length(coefNames) + name = coefNames{i}; + if contains(name, ':'), continue; end % skip interactions + + % Pattern: FactorName_LevelValue + parts = regexp(name, '^(.+?)_(.+)$', 'tokens', 'once'); + if isempty(parts), continue; end + + factorName = parts{1}; + levelVal = parts{2}; + + if ~levelMap.isKey(factorName) + factors{end+1} = factorName; %#ok + levelMap(factorName) = {levelVal}; + else + existing = levelMap(factorName); + existing{end+1} = levelVal; + levelMap(factorName) = existing; + end + end +end + + +function spec = buildPairwise(coefNames, nCoefs, factors, levelMap, hasIntercept) +% BUILDPAIRWISE All pairwise comparisons between factor levels + + rows = []; + labels = {}; + + for fi = 1:length(factors) + f = factors{fi}; + levels = levelMap(f); + nLevels = length(levels); + + % Find coefficient indices for each level + coefIdx = zeros(1, nLevels); + for li = 1:nLevels + fullName = sprintf('%s_%s', f, levels{li}); + coefIdx(li) = find(strcmp(coefNames, fullName)); + end + + % All pairwise: level i vs level j + for i = 1:nLevels + for j = (i+1):nLevels + cRow = zeros(1, nCoefs); + cRow(coefIdx(i)) = 1; + cRow(coefIdx(j)) = -1; + rows = [rows; cRow]; %#ok + labels{end+1} = sprintf('%s_%s vs %s_%s', f, levels{i}, f, levels{j}); %#ok + end + end + + % If intercept model, also compare each level vs reference + if hasIntercept + for li = 1:nLevels + cRow = zeros(1, nCoefs); + cRow(coefIdx(li)) = 1; + rows = [rows; cRow]; %#ok + labels{end+1} = sprintf('%s_%s vs Reference', f, levels{li}); %#ok + end + end + end + + if isempty(rows) + rows = zeros(0, nCoefs); + end + spec.matrix = rows; + spec.labels = labels(:); +end + + +function spec = buildPolynomial(coefNames, nCoefs, factors, levelMap, hasIntercept, mode) +% BUILDPOLYNOMIAL Linear and/or quadratic trend contrasts + + rows = []; + labels = {}; + + for fi = 1:length(factors) + f = factors{fi}; + levels = levelMap(f); + nLevels = length(levels); + + % Find coefficient indices + coefIdx = zeros(1, nLevels); + for li = 1:nLevels + fullName = sprintf('%s_%s', f, levels{li}); + coefIdx(li) = find(strcmp(coefNames, fullName)); + end + + % For intercept models, the reference level is implicit + % Total levels = nLevels + 1 (reference) + totalLevels = nLevels + hasIntercept; + + if totalLevels < 2, continue; end + + % Build polynomial contrast coefficients for all levels + % Using centered integer codes + x = (1:totalLevels)' - mean(1:totalLevels); + + if strcmp(mode, 'linear') || strcmp(mode, 'both') + linCoefs = x / norm(x); + + cRow = zeros(1, nCoefs); + if hasIntercept + % Reference level is first, intercept absorbs it + cRow(1) = linCoefs(1); % intercept gets reference level weight + for li = 1:nLevels + cRow(coefIdx(li)) = linCoefs(li + 1); + end + else + for li = 1:nLevels + cRow(coefIdx(li)) = linCoefs(li); + end + end + rows = [rows; cRow]; %#ok + labels{end+1} = sprintf('%s Linear', f); %#ok + end + + if (strcmp(mode, 'quadratic') || strcmp(mode, 'both')) && totalLevels >= 3 + quadCoefs = x.^2 - mean(x.^2); + quadCoefs = quadCoefs / norm(quadCoefs); + + cRow = zeros(1, nCoefs); + if hasIntercept + cRow(1) = quadCoefs(1); + for li = 1:nLevels + cRow(coefIdx(li)) = quadCoefs(li + 1); + end + else + for li = 1:nLevels + cRow(coefIdx(li)) = quadCoefs(li); + end + end + rows = [rows; cRow]; %#ok + labels{end+1} = sprintf('%s Quadratic', f); %#ok + end + end + + if isempty(rows) + rows = zeros(0, nCoefs); + end + spec.matrix = rows; + spec.labels = labels(:); +end + + +function spec = buildHelmert(coefNames, nCoefs, factors, levelMap, hasIntercept) +% BUILDHELMERT Each level vs mean of subsequent levels + + rows = []; + labels = {}; + + for fi = 1:length(factors) + f = factors{fi}; + levels = levelMap(f); + nLevels = length(levels); + totalLevels = nLevels + hasIntercept; + + if totalLevels < 2, continue; end + + % Find coefficient indices + coefIdx = zeros(1, nLevels); + for li = 1:nLevels + fullName = sprintf('%s_%s', f, levels{li}); + coefIdx(li) = find(strcmp(coefNames, fullName)); + end + + % Helmert: level k vs mean of levels k+1..K + % For K total levels, gives K-1 contrasts + allLabels = {}; + if hasIntercept + allLabels = [{'Reference'}, levels(:)']; + else + allLabels = levels(:)'; + end + + for k = 1:(totalLevels - 1) + nRemaining = totalLevels - k; + cRow = zeros(1, nCoefs); + + if hasIntercept + if k == 1 + % Reference level vs mean of all coded levels + % In reference coding, reference = intercept + 0 effects + % Other levels = intercept + effect_i + % So (ref) - mean(others) = -mean(effects) + % Contrast on coefficients: intercept=0, effects=-1/nRemaining + for li = 1:nLevels + cRow(coefIdx(li)) = -1 / nRemaining; + end + else + % Coded level vs mean of subsequent coded levels + curLevelIdx = k - 1; + cRow(coefIdx(curLevelIdx)) = 1; + for li = curLevelIdx+1:nLevels + cRow(coefIdx(li)) = -1 / nRemaining; + end + end + else + cRow(coefIdx(k)) = 1; + for li = k+1:nLevels + cRow(coefIdx(li)) = -1 / nRemaining; + end + end + + rows = [rows; cRow]; %#ok + labels{end+1} = sprintf('%s %s vs Later', f, allLabels{k}); %#ok + end + end + + if isempty(rows) + rows = zeros(0, nCoefs); + end + spec.matrix = rows; + spec.labels = labels(:); +end + + +function spec = buildDeviation(coefNames, nCoefs, factors, levelMap, hasIntercept) +% BUILDDEVIATION Each level vs grand mean + + rows = []; + labels = {}; + + for fi = 1:length(factors) + f = factors{fi}; + levels = levelMap(f); + nLevels = length(levels); + totalLevels = nLevels + hasIntercept; + + if totalLevels < 2, continue; end + + % Find coefficient indices + coefIdx = zeros(1, nLevels); + for li = 1:nLevels + fullName = sprintf('%s_%s', f, levels{li}); + coefIdx(li) = find(strcmp(coefNames, fullName)); + end + + % Deviation: each level minus grand mean + % Grand mean of intercept model = intercept + mean(all effects) + % So deviation for coded level i = effect_i - mean(all effects) + + for li = 1:nLevels + cRow = zeros(1, nCoefs); + cRow(coefIdx(li)) = 1; + + % Subtract mean of all effects + for lj = 1:nLevels + cRow(coefIdx(lj)) = cRow(coefIdx(lj)) - 1/totalLevels; + end + + if hasIntercept + % Reference level contributes 0 effect, mean includes it + % Already handled by totalLevels denominator + cRow(1) = -1/totalLevels; % subtract reference share + end + + rows = [rows; cRow]; %#ok + labels{end+1} = sprintf('%s_%s vs Mean', f, levels{li}); %#ok + end + end + + if isempty(rows) + rows = zeros(0, nCoefs); + end + spec.matrix = rows; + spec.labels = labels(:); +end diff --git a/+exploreFNIRS/+stats/clusterPermutation.m b/+exploreFNIRS/+stats/clusterPermutation.m new file mode 100644 index 00000000..10dd1b1b --- /dev/null +++ b/+exploreFNIRS/+stats/clusterPermutation.m @@ -0,0 +1,466 @@ +function results = clusterPermutation(lmeResults, data, varargin) +% CLUSTERPERMUTATION Cluster-based permutation testing for fNIRS channel statistics +% +% Performs nonparametric cluster-based permutation testing to correct for +% multiple comparisons while preserving spatial structure. Identifies +% spatially contiguous clusters of channels showing significant effects, +% then tests those clusters against a null distribution built by permuting +% condition labels across subjects. +% +% This method controls the family-wise error rate (FWER) at the cluster +% level and is more sensitive than channel-wise FDR when effects are +% spatially extended. +% +% Reference: +% Maris, E. & Oostenveld, R. (2007). Nonparametric statistical testing +% of EEG- and MEG-data. Journal of Neuroscience Methods, 164(1), 177-190. +% DOI: 10.1016/j.jneumeth.2007.03.024 +% +% Syntax: +% results = exploreFNIRS.stats.clusterPermutation(lmeResults, data) +% results = exploreFNIRS.stats.clusterPermutation(lmeResults, data, Name, Value) +% +% Inputs: +% lmeResults - Struct from exploreFNIRS.stats.fitLME with fields: +% .anova_Fstat, .anova_pval, .models, .channels, .biomarkers, +% .groupByVars, .formula +% data - Cell array of processed fNIRS structs (for device info +% and label permutation) +% +% Name-Value Parameters: +% Permutations - Number of permutations (default: 1000) +% ClusterAlpha - Threshold for initial cluster formation (default: 0.05) +% Alpha - Cluster-level significance threshold (default: 0.05) +% MaxDistance - Adjacency distance in mm (default: 30) +% ClusterStat - 'sumstat' (default), 'maxstat', or 'extent' +% Tail - 'both' (default), 'positive', or 'negative' +% Biomarker - Which biomarker to test (default: first in lmeResults) +% Term - ANOVA term to test (default: first non-intercept) +% Verbose - Print progress (default: true) +% +% Outputs: +% results - Struct with fields: +% .clusters - Struct array of significant clusters, each with: +% .channels, .stat, .pvalue, .significant, .polarity +% .allClusters - All observed clusters (before significance filter) +% .adjacency - Adjacency matrix used +% .nullDist - [1 x nPerm] max cluster stat null distribution +% .observedStats - [1 x nCh] observed test statistics per channel +% .params - Struct of parameters used +% .biomarker - Biomarker tested +% .term - ANOVA term tested +% +% Example: +% % After fitting LME models +% ex = exploreFNIRS.core.Experiment(allData); +% ex.groupby({'Condition'}); +% ex.aggregate(); +% lme = ex.statsFitLME('Biomarkers', {'HbO'}); +% +% % Run cluster permutation (reduced permutations for speed) +% cp = exploreFNIRS.stats.clusterPermutation(lme, allData, ... +% 'Permutations', 500, 'Verbose', true); +% +% % Examine significant clusters +% for k = 1:length(cp.clusters) +% fprintf('Cluster %d: channels [%s], p=%.4f\n', k, ... +% num2str(cp.clusters(k).channels), cp.clusters(k).pvalue); +% end +% +% See also: exploreFNIRS.stats.fitLME, exploreFNIRS.stats.findClusters, +% pf2.probe.computeAdjacency, exploreFNIRS.stats.runContrasts + +%% Parse inputs +p = inputParser; +addRequired(p, 'lmeResults', @isstruct); +addRequired(p, 'data', @iscell); +addParameter(p, 'Permutations', 1000, @(x) isnumeric(x) && isscalar(x) && x > 0); +addParameter(p, 'ClusterAlpha', 0.05, @(x) isnumeric(x) && x > 0 && x < 1); +addParameter(p, 'Alpha', 0.05, @(x) isnumeric(x) && x > 0 && x < 1); +addParameter(p, 'MaxDistance', 30, @(x) isnumeric(x) && x > 0); +addParameter(p, 'ClusterStat', 'sumstat', @(x) ismember(x, {'sumstat','maxstat','extent'})); +addParameter(p, 'Tail', 'both', @(x) ismember(x, {'both','positive','negative'})); +addParameter(p, 'Biomarker', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'Term', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'Verbose', true, @islogical); +parse(p, lmeResults, data, varargin{:}); + +opts = p.Results; +nPerm = opts.Permutations; + +%% Resolve biomarker +if isempty(opts.Biomarker) + biomarker = lmeResults.biomarkers{1}; +else + biomarker = opts.Biomarker; +end + +bIdx = find(strcmp(lmeResults.biomarkers, biomarker), 1); +if isempty(bIdx) + error('exploreFNIRS:stats:clusterPermutation', ... + 'Biomarker ''%s'' not found in LME results.', biomarker); +end + +%% Resolve ANOVA term +termNames = lmeResults.anova_Fstat.Properties.VariableNames; +if isempty(opts.Term) + % Use first non-intercept term + nonIntercept = termNames(~strcmpi(termNames, 'Intercept')); + if isempty(nonIntercept) + error('exploreFNIRS:stats:clusterPermutation', ... + 'No non-intercept ANOVA terms found.'); + end + termName = nonIntercept{1}; +else + termName = opts.Term; + if ~ismember(termName, termNames) + error('exploreFNIRS:stats:clusterPermutation', ... + 'Term ''%s'' not found. Available: %s', termName, strjoin(termNames, ', ')); + end +end + +%% Extract observed test statistics +channels = lmeResults.channels; +nCh = length(channels); + +% Get F-statistics for the chosen term across channels +% Row names in anova_Fstat are like 'Opt1_HbO', 'Opt2_HbO', etc. +observedF = nan(1, nCh); +observedP = nan(1, nCh); + +for chI = 1:nCh + ch = channels(chI); + rowName = sprintf('Opt%d_%s', ch, biomarker); + + if ismember(rowName, lmeResults.anova_Fstat.Properties.RowNames) + observedF(chI) = lmeResults.anova_Fstat{rowName, termName}; + observedP(chI) = lmeResults.anova_pval{rowName, termName}; + end +end + +%% Convert F to signed statistics using coefficient direction +% F-statistics are always positive. To get direction (for two-tailed +% clustering), we use the sign of the corresponding fixed-effect coefficient. +observedStat = sqrt(observedF); % sqrt(F) approximates |t| for single-df effects +for chI = 1:nCh + mdl = lmeResults.models{bIdx, chI}; + if isempty(mdl), continue; end + + try + coeffs = mdl.Coefficients; + % Find the coefficient matching this term + termRows = strcmp(coeffs.Name, termName); + if ~any(termRows) + % Match categorical dummy-coded levels like 'Condition_2' + termRows = startsWith(string(coeffs.Name), termName + "_"); + end + if any(termRows) + coefVal = coeffs.Estimate(find(termRows, 1)); + if coefVal < 0 + observedStat(chI) = -observedStat(chI); + end + end + catch + % Keep positive if we can't determine sign + end +end + +%% Build adjacency matrix +if opts.Verbose + fprintf('Building channel adjacency (MaxDistance = %d mm)...\n', opts.MaxDistance); +end + +adj = pf2.probe.computeAdjacency(data{1}, 'MaxDistance', opts.MaxDistance); + +% Subset adjacency to channels used in analysis +adjFull = adj; +adj = adj(channels, channels); + +%% Compute cluster-forming threshold +% Convert ClusterAlpha to F-stat threshold using the observed distribution +% Use the ANOVA p-values: channels with p < ClusterAlpha form candidate clusters +fThreshold = getStatThreshold(observedF, observedP, opts.ClusterAlpha); +if opts.Verbose + fprintf('Cluster-forming threshold: F > %.2f (alpha=%.3f)\n', ... + fThreshold^2, opts.ClusterAlpha); +end + +%% Find observed clusters +observedClusters = exploreFNIRS.stats.findClusters( ... + observedStat, adj, fThreshold, opts.ClusterStat, opts.Tail); + +if opts.Verbose + fprintf('Found %d observed cluster(s)\n', length(observedClusters)); + for k = 1:length(observedClusters) + fprintf(' Cluster %d: %d channels, stat=%.2f (%s)\n', k, ... + length(observedClusters(k).channels), ... + observedClusters(k).stat, observedClusters(k).polarity); + end +end + +%% Build null distribution by permuting condition labels +if opts.Verbose + fprintf('Running %d permutations...\n', nPerm); +end + +nullDist = zeros(1, nPerm); + +% Extract merged table and determine permutation strategy +mergedTable = lmeResults.mergedTable; +groupByVars = lmeResults.groupByVars; +formula = lmeResults.formula; + +% Identify the permutation variable (first groupby var that isn't Time) +permVar = ''; +for vi = 1:length(groupByVars) + if ~strcmpi(groupByVars{vi}, 'Time') + permVar = groupByVars{vi}; + break; + end +end + +if isempty(permVar) + error('exploreFNIRS:stats:clusterPermutation', ... + 'No suitable grouping variable found for permutation.'); +end + +% Get unique subjects and their condition assignments +if ismember('SubjectID', mergedTable.Properties.VariableNames) + subjects = unique(mergedTable.SubjectID); +else + % Fall back: permute all rows + subjects = {}; +end + +% Extract the dependent variable from the formula for replacement +formulaDV = strtrim(extractBefore(formula, '~')); + +for iPerm = 1:nPerm + % Shuffle condition labels + rng(2019 + iPerm); + permTable = shuffleLabels(mergedTable, permVar, subjects); + + % Re-fit models for all channels and extract statistics + permStat = nan(1, nCh); + for chI = 1:nCh + ch = channels(chI); + varName = sprintf('Opt%d_%s', ch, biomarker); + + if ~ismember(varName, permTable.Properties.VariableNames) + continue; + end + + try + % Rewrite formula with this channel's dependent variable + chFormula = strrep(formula, formulaDV, varName); + + mdl = fitlme(permTable, chFormula, ... + 'FitMethod', 'REML', 'CheckHessian', false, ... + 'DummyVarCoding', 'reference'); + + anv = anova(mdl, 'DFMethod', 'satterthwaite'); + termIdx = find(strcmp(anv.Term, termName), 1); + if isempty(termIdx) + % Try sanitized name match + for ti = 1:height(anv) + cleanTerm = anv.Term{ti}; + cleanTerm(cleanTerm == '(' | cleanTerm == ')') = ''; + cleanTerm(cleanTerm == ':' | cleanTerm == '_') = ''; + cleanTerm(cleanTerm == ' ' | cleanTerm == '-') = ''; + if strcmp(cleanTerm, termName) + termIdx = ti; + break; + end + end + end + + if ~isempty(termIdx) + fVal = anv.FStat(termIdx); + tVal = sqrt(fVal); + + % Get sign from coefficient + coeffs = mdl.Coefficients; + termRows = strcmp(coeffs.Name, termName); + if ~any(termRows) + % Match categorical dummy-coded levels like 'Condition_2' + termRows = startsWith(string(coeffs.Name), termName + "_"); + end + if any(termRows) + coefVal = coeffs.Estimate(find(termRows, 1)); + if coefVal < 0 + tVal = -tVal; + end + end + permStat(chI) = tVal; + end + catch + % Skip failed models + end + end + + % Find clusters in permuted data + permClusters = exploreFNIRS.stats.findClusters( ... + permStat, adj, fThreshold, opts.ClusterStat, opts.Tail); + + % Record max cluster statistic + if ~isempty(permClusters) + allStats = [permClusters.stat]; + nullDist(iPerm) = max(abs(allStats)); + end + + if opts.Verbose && mod(iPerm, max(1, floor(nPerm/10))) == 0 + fprintf(' Permutation %d/%d\n', iPerm, nPerm); + end +end + +%% Compute cluster p-values +sigClusters = struct('channels', {}, 'stat', {}, 'pvalue', {}, ... + 'significant', {}, 'polarity', {}); + +for k = 1:length(observedClusters) + cl = observedClusters(k); + pval = mean(nullDist >= abs(cl.stat)); + cl.pvalue = pval; + cl.significant = pval < opts.Alpha; + + % Map cluster channel indices back to original channel numbers + cl.channels = channels(cl.channels); + + if cl.significant + sigClusters(end+1) = cl; %#ok + end +end + +% Also map allClusters back +allClusters = observedClusters; +for k = 1:length(allClusters) + pval = mean(nullDist >= abs(allClusters(k).stat)); + allClusters(k).pvalue = pval; + allClusters(k).significant = pval < opts.Alpha; + allClusters(k).channels = channels(allClusters(k).channels); +end + +%% Assemble output +results = struct(); +results.clusters = sigClusters; +results.allClusters = allClusters; +results.adjacency = adjFull; +results.nullDist = nullDist; +results.observedStats = observedStat; +results.params = struct( ... + 'Permutations', nPerm, ... + 'ClusterAlpha', opts.ClusterAlpha, ... + 'Alpha', opts.Alpha, ... + 'MaxDistance', opts.MaxDistance, ... + 'ClusterStat', opts.ClusterStat, ... + 'Tail', opts.Tail); +results.biomarker = biomarker; +results.term = termName; + +if opts.Verbose + fprintf('\nCluster permutation complete.\n'); + fprintf('Significant clusters: %d (alpha=%.3f)\n', length(sigClusters), opts.Alpha); + for k = 1:length(sigClusters) + fprintf(' Cluster %d: channels [%s], stat=%.2f, p=%.4f\n', k, ... + num2str(sigClusters(k).channels), sigClusters(k).stat, sigClusters(k).pvalue); + end +end + +end + + +%% Local helper functions + +function threshold = getStatThreshold(fStats, pVals, alpha) +% Convert alpha threshold to a signed-statistic threshold. +% Uses the observed F/p relationship: find the F value closest to alpha. + +validIdx = ~isnan(fStats) & ~isnan(pVals); +if ~any(validIdx) + threshold = 2; % Default fallback + return; +end + +fValid = fStats(validIdx); +pValid = pVals(validIdx); + +% Find the F-value that corresponds to p=alpha by interpolation +% Sort by p-value +[pSorted, sortIdx] = sort(pValid); +fSorted = fValid(sortIdx); + +% Find where p crosses alpha +crossIdx = find(pSorted <= alpha, 1, 'last'); +if isempty(crossIdx) + % No channels below alpha; use the minimum F as threshold + threshold = sqrt(min(fValid)); +elseif crossIdx == length(pSorted) + threshold = sqrt(min(fSorted)); +else + % Interpolate between the two bracketing F values + threshold = sqrt(fSorted(crossIdx)); +end + +end + + +function permTable = shuffleLabels(tbl, permVar, subjects) +% Shuffle condition labels across subjects (between-subject permutation) +% or within subjects (within-subject sign-flip). + +permTable = tbl; + +if isempty(subjects) + % No subject structure: shuffle all labels + idx = randperm(height(tbl)); + permTable.(permVar) = tbl.(permVar)(idx); + return; +end + +% Determine if design is within-subject or between-subject +% Within-subject: each subject has multiple levels of permVar +isWithin = false; +nSubjects = length(subjects); + +if nSubjects > 1 && iscategorical(tbl.(permVar)) + levels = categories(tbl.(permVar)); + for si = 1:min(nSubjects, 5) % Check first 5 subjects + subRows = tbl.SubjectID == subjects(si); + subLevels = unique(tbl.(permVar)(subRows)); + if length(subLevels) > 1 + isWithin = true; + break; + end + end +end + +if isWithin + % Within-subject: permute condition labels within each subject + for si = 1:nSubjects + subRows = find(tbl.SubjectID == subjects(si)); + if isempty(subRows), continue; end + + % Random permutation of the condition labels for this subject + subLabels = tbl.(permVar)(subRows); + permTable.(permVar)(subRows) = subLabels(randperm(length(subLabels))); + end +else + % Between-subject: shuffle subject-to-condition assignment + subjectConditions = cell(nSubjects, 1); + for si = 1:nSubjects + subRows = tbl.SubjectID == subjects(si); + vals = unique(tbl.(permVar)(subRows)); + subjectConditions{si} = vals(1); + end + + % Permute the assignment + permIdx = randperm(nSubjects); + permConditions = subjectConditions(permIdx); + + for si = 1:nSubjects + subRows = find(tbl.SubjectID == subjects(si)); + permTable.(permVar)(subRows) = repmat(permConditions{si}, length(subRows), 1); + end +end + +end diff --git a/+exploreFNIRS/+stats/effectSize.m b/+exploreFNIRS/+stats/effectSize.m new file mode 100644 index 00000000..6f5effcd --- /dev/null +++ b/+exploreFNIRS/+stats/effectSize.m @@ -0,0 +1,359 @@ +function results = effectSize(groups, groupByVars, varargin) +% EFFECTSIZE Effect size with bootstrap confidence intervals for fNIRS data +% +% Computes effect sizes (Hedges' g, Cohen's d, or Glass's delta) between +% two conditions with bootstrap confidence intervals. Designed for small-N +% fNIRS studies where parametric CIs may be unreliable. +% +% Syntax: +% results = exploreFNIRS.stats.effectSize(groups, groupByVars) +% results = exploreFNIRS.stats.effectSize(groups, groupByVars, 'CI', 0.95) +% results = exploreFNIRS.stats.effectSize(groups, groupByVars, ... +% 'Method', 'hedges_g', 'NumBoot', 5000) +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% groupByVars - Cell array of grouping variable names +% +% Name-Value Parameters: +% Method - 'hedges_g' (default), 'cohens_d', or 'glass_delta' +% CI - Confidence level (default: 0.95) +% NumBoot - Number of bootstrap resamples (default: 5000) +% Seed - Random seed for reproducibility (default: 2024) +% Biomarkers - Cell array of biomarker names (default: {'HbO','HbR','HbTotal','CBSI'}) +% Channels - Channel indices (default: all) +% DataType - 'fNIRS' (default) or 'ROI'. When 'ROI', computes effect +% sizes per ROI instead of per channel. +% StatWindow - [start, end] seconds to filter time bins (default: []) +% Verbose - Print progress (default: true) +% ExcludeShortSeparation - Skip short-sep channels (default: true) +% +% Outputs: +% results - Struct with fields: +% .observed - [nBio x nCh] effect size values +% .ci_lower - [nBio x nCh] lower CI bound +% .ci_upper - [nBio x nCh] upper CI bound +% .p - [nBio x nCh] parametric p-values (two-sample t-test) +% .bootstrap_dist - {nBio x nCh} cell of bootstrap distributions +% .method - Effect size method used +% .ci_level - Confidence level used +% .nBoot - Number of bootstrap resamples +% .biomarkers - Biomarker names +% .channels - Channel indices +% .conditions - {2 x 1} cell of condition labels +% .nPerGroup - [1 x 2] sample sizes +% .dataType - 'fNIRS' or 'ROI' +% .labels - Channel numbers or ROI names (for display) +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.select('Condition', {'Easy','Hard'}); +% ex.groupby('Condition'); +% ex.aggregate(); +% es = ex.statsEffectSize('Biomarkers', {'HbO'}, 'NumBoot', 2000); +% fprintf('Hedges'' g = %.2f [%.2f, %.2f]\n', ... +% es.observed(1,1), es.ci_lower(1,1), es.ci_upper(1,1)); +% +% References: +% Hedges, L. V. & Olkin, I. (1985). Statistical Methods for +% Meta-Analysis. Academic Press. +% +% Efron, B. & Tibshirani, R. J. (1993). An Introduction to the +% Bootstrap. Chapman and Hall/CRC. DOI: 10.1201/9780429246593 +% +% See also: exploreFNIRS.stats.fitLME, exploreFNIRS.stats.permTest, +% exploreFNIRS.fx.autoContrast + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addRequired(p, 'groupByVars', @iscell); + addParameter(p, 'Method', 'hedges_g', @ischar); + addParameter(p, 'CI', 0.95, @(x) isnumeric(x) && x > 0 && x < 1); + addParameter(p, 'NumBoot', 5000, @(x) isnumeric(x) && x > 0); + addParameter(p, 'Seed', 2024, @isnumeric); + addParameter(p, 'Biomarkers', {'HbO','HbR','HbTotal','CBSI'}, @iscell); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'DataType', 'fNIRS', @ischar); + addParameter(p, 'StatWindow', [], @isnumeric); + addParameter(p, 'Verbose', true, @islogical); + addParameter(p, 'ExcludeShortSeparation', true, @islogical); + parse(p, groups, groupByVars, varargin{:}); + opts = p.Results; + + % Validate method + validMethods = {'hedges_g', 'cohens_d', 'glass_delta'}; + if ~ismember(lower(opts.Method), validMethods) + error('exploreFNIRS:stats:effectSize:invalidMethod', ... + 'Unknown method: ''%s''. Use ''hedges_g'', ''cohens_d'', or ''glass_delta''.', ... + opts.Method); + end + + % Must have exactly 2 groups + nGroups = length(groups); + if nGroups ~= 2 + error('exploreFNIRS:stats:effectSize:needTwoGroups', ... + 'Effect size requires exactly 2 groups (got %d). Use select() to choose 2 conditions.', ... + nGroups); + end + + isROIMode = strcmpi(opts.DataType, 'ROI'); + ga = groups(1).gbyGrandBarFlat; + + % Filter biomarkers to those available + validBio = {}; + for i = 1:length(opts.Biomarkers) + if isROIMode + if pf2_base.isnestedfield(ga, ['ROI.' opts.Biomarkers{i}]) + validBio{end+1} = opts.Biomarkers{i}; %#ok + end + else + if isfield(ga, opts.Biomarkers{i}) && ~isempty(ga.(opts.Biomarkers{i})) + validBio{end+1} = opts.Biomarkers{i}; %#ok + end + end + end + if isempty(validBio) + error('exploreFNIRS:stats:effectSize:noBiomarkers', ... + 'None of the requested biomarkers found in data.'); + end + opts.Biomarkers = validBio; + nBioM = length(opts.Biomarkers); + + % Get time bins and apply StatWindow mask + barTimes = ga.time; + if ~isempty(opts.StatWindow) + sw = opts.StatWindow; + if ~isnumeric(sw) || numel(sw) ~= 2 + error('exploreFNIRS:stats:effectSize:invalidStatWindow', ... + 'StatWindow must be a 2-element numeric vector [start, end].'); + end + tMask = barTimes >= sw(1) & barTimes <= sw(2); + else + tMask = true(size(barTimes)); + end + + % Determine channels/ROIs + firstBio = opts.Biomarkers{1}; + if isROIMode + if ~isfield(ga, 'ROI') + error('exploreFNIRS:stats:effectSize:noROI', ... + 'No ROI data in grand average. Define ROIs before aggregating.'); + end + if isempty(opts.Channels) + nCh = size(ga.ROI.(firstBio).data, 2); + channels = 1:nCh; + else + channels = opts.Channels; + nCh = length(channels); + end + else + if isempty(opts.Channels) + nCh = size(ga.(firstBio).data, 2); + channels = 1:nCh; + else + channels = opts.Channels; + nCh = length(channels); + end + + % Exclude short separation channels (channel mode only) + if opts.ExcludeShortSeparation + ssIdx = getShortSeparationIdx(groups); + if ~isempty(ssIdx) + channels = channels(~ismember(channels, ssIdx)); + nCh = length(channels); + if opts.Verbose + fprintf('Excluding %d short separation channels\n', length(ssIdx)); + end + end + end + end + + % Build display labels + if isROIMode && isfield(ga.ROI, 'info') + roiInfo = ga.ROI.info; + if istable(roiInfo) + allNames = roiInfo.Properties.RowNames; + elseif isstruct(roiInfo) && isfield(roiInfo, 'Names') + allNames = roiInfo.Names; + else + allNames = arrayfun(@(i) sprintf('ROI%d', i), channels, 'UniformOutput', false); + end + labels = allNames(channels); + else + labels = arrayfun(@(c) sprintf('Ch%d', c), channels, 'UniformOutput', false); + end + + % Initialize results + results = struct(); + results.observed = nan(nBioM, nCh); + results.ci_lower = nan(nBioM, nCh); + results.ci_upper = nan(nBioM, nCh); + results.p = nan(nBioM, nCh); + results.bootstrap_dist = cell(nBioM, nCh); + results.method = opts.Method; + results.ci_level = opts.CI; + results.nBoot = opts.NumBoot; + results.biomarkers = opts.Biomarkers; + results.channels = channels; + results.conditions = {groups(1).label, groups(2).label}; + results.dataType = opts.DataType; + results.labels = labels; + + % Get per-group sample sizes from 3rd dim of data + if isROIMode + nA = size(groups(1).gbyGrandBarFlat.ROI.(firstBio).data, 3); + nB = size(groups(2).gbyGrandBarFlat.ROI.(firstBio).data, 3); + else + nA = size(groups(1).gbyGrandBarFlat.(firstBio).data, 3); + nB = size(groups(2).gbyGrandBarFlat.(firstBio).data, 3); + end + results.nPerGroup = [nA, nB]; + + rng(opts.Seed); + alpha = 1 - opts.CI; + + for bIdx = 1:nBioM + bioM = opts.Biomarkers{bIdx}; + + for chI = 1:nCh + ch = channels(chI); + + % Extract per-subject means from gbyGrandBarFlat + % Data is [time x channels/ROIs x subjects] + if isROIMode + dataA = groups(1).gbyGrandBarFlat.ROI.(bioM).data(tMask, ch, :); + dataB = groups(2).gbyGrandBarFlat.ROI.(bioM).data(tMask, ch, :); + else + dataA = groups(1).gbyGrandBarFlat.(bioM).data(tMask, ch, :); + dataB = groups(2).gbyGrandBarFlat.(bioM).data(tMask, ch, :); + end + + % Average across time bins -> [1 x 1 x nSub] -> [nSub x 1] + meansA = squeeze(mean(dataA, 1, 'omitnan')); + meansB = squeeze(mean(dataB, 1, 'omitnan')); + + % Handle case where squeeze removes dimensions + meansA = meansA(:); + meansB = meansB(:); + + % Remove NaN subjects + meansA = meansA(~isnan(meansA)); + meansB = meansB(~isnan(meansB)); + + if isempty(meansA) || isempty(meansB) + continue; + end + + % Compute observed effect size + results.observed(bIdx, chI) = computeES(meansA, meansB, opts.Method); + + % Parametric p-value (two-sample t-test) + [~, pval] = pf2_base.compat.ttest2(meansA, meansB); + results.p(bIdx, chI) = pval; + + % Bootstrap CI + bootDist = nan(opts.NumBoot, 1); + nAval = length(meansA); + nBval = length(meansB); + + for b = 1:opts.NumBoot + idxA = randi(nAval, nAval, 1); + idxB = randi(nBval, nBval, 1); + bootDist(b) = computeES(meansA(idxA), meansB(idxB), opts.Method); + end + + results.bootstrap_dist{bIdx, chI} = bootDist; + results.ci_lower(bIdx, chI) = pf2_base.compat.quantile(bootDist, alpha / 2); + results.ci_upper(bIdx, chI) = pf2_base.compat.quantile(bootDist, 1 - alpha / 2); + end + + if opts.Verbose + sigCount = sum(~isnan(results.observed(bIdx, :))); + unitLabel = 'channels'; + if isROIMode, unitLabel = 'ROIs'; end + fprintf('Effect size [%s]: computed for %d/%d %s\n', ... + bioM, sigCount, nCh, unitLabel); + end + end +end + + +function es = computeES(meansA, meansB, method) +% COMPUTEES Compute effect size between two groups + + diff = mean(meansA) - mean(meansB); + nA = length(meansA); + nB = length(meansB); + + switch lower(method) + case 'cohens_d' + sp = sqrt(((nA - 1) * var(meansA) + (nB - 1) * var(meansB)) / (nA + nB - 2)); + if sp == 0 + es = 0; + else + es = diff / sp; + end + + case 'hedges_g' + sp = sqrt(((nA - 1) * var(meansA) + (nB - 1) * var(meansB)) / (nA + nB - 2)); + if sp == 0 + es = 0; + else + d = diff / sp; + df = nA + nB - 2; + J = 1 - 3 / (4 * df - 1); + es = d * J; + end + + case 'glass_delta' + sdB = std(meansB); + if sdB == 0 + es = 0; + else + es = diff / sdB; + end + end +end + + +function ssIdx = getShortSeparationIdx(groups) +% GETSHORTSEPARATIONIDX Get indices of short separation channels from probe info + + ssIdx = []; + + if isempty(groups) || isempty(groups(1).gbyFNIRS) + return; + end + + fNIR = groups(1).gbyFNIRS{1}; + + probeInfo = []; + if isfield(fNIR, 'probeinfo') && isfield(fNIR.probeinfo, 'Probe') ... + && iscell(fNIR.probeinfo.Probe) && ~isempty(fNIR.probeinfo.Probe) + probeInfo = fNIR.probeinfo.Probe{1}; + elseif isfield(fNIR, 'info') && isfield(fNIR.info, 'probename') ... + && ~isempty(fNIR.info.probename) && ~contains(fNIR.info.probename, 'Unknown') + try + device = pf2_base.loadDeviceCfg(fNIR.info.probename); + if isstruct(device) && isfield(device, 'Probe') ... + && iscell(device.Probe) && ~isempty(device.Probe) + probeInfo = device.Probe{1}; + end + catch + return; + end + end + + if isempty(probeInfo) + return; + end + + if isfield(probeInfo, 'TableOpt') && istable(probeInfo.TableOpt) ... + && ismember('IsShortSeparation', probeInfo.TableOpt.Properties.VariableNames) + ssIdx = find(probeInfo.TableOpt.IsShortSeparation(:)'); + elseif isfield(probeInfo, 'NumShortSeparation') && probeInfo.NumShortSeparation > 0 ... + && isfield(probeInfo, 'TableOpt') && istable(probeInfo.TableOpt) ... + && ismember('SD', probeInfo.TableOpt.Properties.VariableNames) + ssIdx = find(probeInfo.TableOpt.SD(:)' < 2); + end +end diff --git a/+exploreFNIRS/+stats/findClusters.m b/+exploreFNIRS/+stats/findClusters.m new file mode 100644 index 00000000..32ce0781 --- /dev/null +++ b/+exploreFNIRS/+stats/findClusters.m @@ -0,0 +1,117 @@ +function clusters = findClusters(statMap, adjacency, threshold, clusterStatType, tail) +% FINDCLUSTERS Find spatially contiguous clusters in a thresholded stat map +% +% Identifies connected components in a statistical map after thresholding, +% using a spatial adjacency matrix to define connectivity. Each cluster is +% characterized by its member channels and a summary statistic. +% +% Syntax: +% clusters = exploreFNIRS.stats.findClusters(statMap, adjacency, threshold) +% clusters = exploreFNIRS.stats.findClusters(..., clusterStatType, tail) +% +% Inputs: +% statMap - [1 x nCh] vector of test statistics (t or F values) +% adjacency - [nCh x nCh] sparse logical adjacency matrix +% threshold - Scalar threshold for cluster formation +% clusterStatType - 'sumstat' (default), 'maxstat', or 'extent' +% tail - 'both' (default), 'positive', or 'negative' +% +% Outputs: +% clusters - Struct array with fields: +% .channels - Indices of channels in the cluster +% .stat - Cluster-level statistic +% .polarity - 'positive' or 'negative' +% +% See also: exploreFNIRS.stats.clusterPermutation, pf2.probe.computeAdjacency + +if nargin < 4 || isempty(clusterStatType) + clusterStatType = 'sumstat'; +end +if nargin < 5 || isempty(tail) + tail = 'both'; +end + +clusters = struct('channels', {}, 'stat', {}, 'polarity', {}); + +nCh = length(statMap); + +% Process positive tail +if ismember(tail, {'both', 'positive'}) + posMask = statMap > threshold; + posClusters = findConnectedComponents(posMask, adjacency, statMap, clusterStatType, 'positive'); + clusters = [clusters, posClusters]; +end + +% Process negative tail +if ismember(tail, {'both', 'negative'}) + negMask = statMap < -threshold; + negClusters = findConnectedComponents(negMask, adjacency, statMap, clusterStatType, 'negative'); + clusters = [clusters, negClusters]; +end + +end + + +function clusters = findConnectedComponents(mask, adjacency, statMap, clusterStatType, polarity) +% BFS-based connected component labeling on the masked adjacency graph + +clusters = struct('channels', {}, 'stat', {}, 'polarity', {}); + +candidates = find(mask); +if isempty(candidates) + return; +end + +visited = false(size(mask)); +adj = adjacency; + +for startIdx = 1:length(candidates) + node = candidates(startIdx); + if visited(node) + continue; + end + + % BFS from this node + component = []; + queue = node; + visited(node) = true; + + while ~isempty(queue) + current = queue(1); + queue(1) = []; + component(end+1) = current; %#ok + + % Find adjacent nodes that are also in the mask + neighbors = find(adj(current, :)); + for ni = 1:length(neighbors) + nb = neighbors(ni); + if mask(nb) && ~visited(nb) + visited(nb) = true; + queue(end+1) = nb; %#ok + end + end + end + + % Compute cluster statistic + clusterStats = statMap(component); + switch lower(clusterStatType) + case 'sumstat' + cStat = sum(clusterStats); + case 'maxstat' + if strcmp(polarity, 'positive') + cStat = max(clusterStats); + else + cStat = min(clusterStats); + end + case 'extent' + cStat = length(component); + otherwise + cStat = sum(clusterStats); + end + + clusters(end+1).channels = sort(component); %#ok + clusters(end).stat = cStat; + clusters(end).polarity = polarity; +end + +end diff --git a/+exploreFNIRS/+stats/fitInfoLME.m b/+exploreFNIRS/+stats/fitInfoLME.m new file mode 100644 index 00000000..3c9f4fc3 --- /dev/null +++ b/+exploreFNIRS/+stats/fitInfoLME.m @@ -0,0 +1,279 @@ +function results = fitInfoLME(dataTable, infoVar, groupByVars, varargin) +% FITINFOLME Fit a linear mixed-effects model for an info/behavioral variable +% +% Fits a single LME model using an info variable as the response and groupby +% variables as fixed effects, with random intercepts for subjects. Returns +% fitted model, ANOVA table, auto-generated contrasts, and model comparison. +% +% Unlike fitLME (which iterates over channels), this fits one model since +% the response is a scalar info variable per observation. +% +% Syntax: +% results = exploreFNIRS.stats.fitInfoLME(dataTable, 'reactionTime', {'Condition'}) +% results = exploreFNIRS.stats.fitInfoLME(dataTable, 'accuracy', {'Group','Condition'}, ... +% 'AllInteractions', true) +% +% Inputs: +% dataTable - Table from Experiment.getSelectedTable() (one row per segment) +% infoVar - Response variable name (must be numeric column in dataTable) +% groupByVars - Cell array of fixed-effect variable names +% +% Name-Value Parameters: +% RandomEffects - Random effects formula (default: '1|SubjectID') +% UseIntercept - Include intercept (default: true) +% AllInteractions - Use full interaction model (default: false) +% InfoCovariate - Additional numeric covariate (default: '') +% CustomFormula - Override auto-built formula (default: '') +% ContrastThreshold - p-value threshold for auto-contrasts (default: 0.1) +% Verbose - Print progress to console (default: true) +% +% Outputs: +% results - Struct with fields (compatible with runContrasts/summarize): +% .model - LinearMixedModel object +% .models - {1x1} cell (for pipeline compatibility) +% .anova - {1x1} cell of ANOVA table +% .anova_pval - Table of ANOVA p-values +% .anova_Fstat - Table of ANOVA F-statistics +% .anova_df1 - Table of numerator df +% .anova_df2 - Table of denominator df +% .contrasts - {1x1} cell of contrast table +% .coefficients - {1x1} cell of random effects +% .AIC - Scalar AIC value +% .formula - Formula string used +% .mergedTable - The dataTable used for fitting +% .nullComparison - {1x1} cell of null model comparison +% .biomarkers - {infoVar} +% .channels - [] +% .groupByVars - groupByVars +% .responseVar - infoVar +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Condition'}); +% results = ex.statsInfoLME('reactionTime'); +% T = ex.statsSummarize(results, 'Type', 'anova'); +% +% See also: exploreFNIRS.stats.fitLME, exploreFNIRS.stats.runContrasts, +% exploreFNIRS.stats.summarize, exploreFNIRS.core.Experiment + + p = inputParser; + addRequired(p, 'dataTable', @istable); + addRequired(p, 'infoVar', @ischar); + addRequired(p, 'groupByVars', @iscell); + addParameter(p, 'RandomEffects', '1|SubjectID', @ischar); + addParameter(p, 'UseIntercept', true, @islogical); + addParameter(p, 'AllInteractions', false, @islogical); + addParameter(p, 'InfoCovariate', '', @ischar); + addParameter(p, 'CustomFormula', '', @ischar); + addParameter(p, 'ContrastThreshold', 0.1, @isnumeric); + addParameter(p, 'Verbose', true, @islogical); + parse(p, dataTable, infoVar, groupByVars, varargin{:}); + opts = p.Results; + + % Validate infoVar exists and is numeric + if ~ismember(infoVar, dataTable.Properties.VariableNames) + error('exploreFNIRS:stats:fitInfoLME', ... + 'Variable "%s" not found in dataTable. Available: %s', ... + infoVar, strjoin(dataTable.Properties.VariableNames, ', ')); + end + + testCol = dataTable.(infoVar); + if ~isnumeric(testCol) + error('exploreFNIRS:stats:fitInfoLME', ... + 'Variable "%s" must be numeric (got %s)', infoVar, class(testCol)); + end + + % Remove rows with NaN response + validRows = ~isnan(dataTable.(infoVar)); + fitTable = dataTable(validRows, :); + + if height(fitTable) < 3 + error('exploreFNIRS:stats:fitInfoLME', ... + 'Too few valid observations (%d) for LME fitting', height(fitTable)); + end + + if opts.Verbose + warning('pf2:stats:fitInfoNaN', ... + 'fitInfoLME: %d valid observations (removed %d NaN)', ... + height(fitTable), sum(~validRows)); + end + + % Initialize results (compatible with fitLME output format) + results = struct(); + results.model = []; + results.models = cell(1, 1); + results.anova = cell(1, 1); + results.contrasts = cell(1, 1); + results.AIC = NaN; + results.formula = ''; + results.mergedTable = fitTable; + results.anova_pval = table(); + results.anova_Fstat = table(); + results.anova_df1 = table(); + results.anova_df2 = table(); + results.coefficients = cell(1, 1); + results.nullComparison = cell(1, 1); + results.biomarkers = {infoVar}; + results.channels = []; + results.groupByVars = groupByVars; + results.responseVar = infoVar; + + % Build LME formula + if ~isempty(opts.CustomFormula) + lmeString = opts.CustomFormula; + dummyCodeStr = 'reference'; + if contains(lmeString, '-1+') || contains(lmeString, '~-1') + dummyCodeStr = 'full'; + lmeString = strrep(lmeString, '*', ':'); + end + else + [lmeString, dummyCodeStr] = buildFormula(infoVar, groupByVars, opts); + end + + results.formula = lmeString; + + % Suppress MATLAB's own fitlme rank/Hessian spam when not verbose. + % Scoped to the specific LME identifiers (not a blanket off-all), so + % unrelated warnings still surface; restored when cleanupObj clears. + if ~opts.Verbose + cleanupObj = exploreFNIRS.stats.suppressLMEWarnings(); %#ok + end + + % Fit LME + try + rng(2019); + mdl = fitlme(fitTable, lmeString, ... + 'FitMethod', 'REML', 'CheckHessian', true, ... + 'DummyVarCoding', dummyCodeStr); + + results.model = mdl; + results.models{1, 1} = mdl; + results.AIC = mdl.ModelCriterion.AIC; + + % ANOVA with Satterthwaite degrees of freedom + anv = anova(mdl, 'DFMethod', 'satterthwaite'); + results.anova{1, 1} = anv; + + % Store ANOVA results in summary tables + rowName = infoVar; + anovaNames = sanitizeNames(anv.Term); + + results.anova_pval{rowName, anovaNames} = anv.pValue(:)'; + results.anova_Fstat{rowName, anovaNames} = anv.FStat(:)'; + + try + results.anova_df1{rowName, anovaNames} = anv.DF1(:)'; + results.anova_df2{rowName, anovaNames} = anv.DF2(:)'; + catch + results.anova_df1{rowName, anovaNames} = anv.DF(:)'; + results.anova_df2{rowName, anovaNames} = anv.DF(:)'; + end + + % Auto contrasts + try + cTable = exploreFNIRS.fx.autoContrast(mdl, opts.ContrastThreshold); + results.contrasts{1, 1} = cTable; + catch + results.contrasts{1, 1} = table(); + end + + % Random effects coefficients + try + [~, ~, results.coefficients{1, 1}] = ... + randomEffects(mdl, 'DFMethod', 'satterthwaite'); + catch + results.coefficients{1, 1} = []; + end + + % Null model comparison (ML required for LRT) + try + nullStr = sprintf('%s~1+(%s)', infoVar, opts.RandomEffects); + mdlML = fitlme(fitTable, lmeString, ... + 'FitMethod', 'ML', 'CheckHessian', true, ... + 'DummyVarCoding', dummyCodeStr); + nullMdl = fitlme(fitTable, nullStr, ... + 'FitMethod', 'ML', 'CheckHessian', true, ... + 'DummyVarCoding', dummyCodeStr); + results.nullComparison{1, 1} = compare(nullMdl, mdlML); + catch + results.nullComparison{1, 1} = []; + end + + if opts.Verbose + fprintf('LME [%s]: AIC=%.1f, formula=%s\n', infoVar, results.AIC, lmeString); + fprintf('\n--- ANOVA p-values ---\n'); + disp(results.anova_pval); + end + + catch ME + if opts.Verbose + warning('pf2:stats:lmeFailed', ... + 'LME failed for %s: %s', infoVar, ME.message); + end + end +end + + +%% Local helpers + +function [lmeString, dummyCodeStr] = buildFormula(varName, groupByVars, opts) +% Build LME formula string from groupby variables and options + + dummyCodeStr = 'reference'; + + % Build fixed effects part + basicParts = {}; + if ~isempty(opts.InfoCovariate) + basicParts{end+1} = opts.InfoCovariate; + end + + mdlPrtString = strjoin(basicParts, '*'); + if isempty(mdlPrtString) + mdlPrtString = '1'; + end + + % Add groupby variables + if opts.AllInteractions + curLMEGbyString = mdlPrtString; + for i = 1:length(groupByVars) + curLMEGbyString = sprintf('%s*%s', curLMEGbyString, groupByVars{i}); + end + else + parts = {}; + for i = 1:length(groupByVars) + if strcmp(mdlPrtString, '1') + parts{end+1} = groupByVars{i}; %#ok + else + parts{end+1} = sprintf('%s*%s', mdlPrtString, groupByVars{i}); %#ok + end + end + curLMEGbyString = strjoin(parts, '+'); + end + + % Build full formula + if opts.UseIntercept + if isempty(curLMEGbyString) || strcmp(curLMEGbyString, '1') + lmeString = sprintf('%s~1+(%s)', varName, opts.RandomEffects); + else + lmeString = sprintf('%s~%s+(%s)', varName, curLMEGbyString, ... + opts.RandomEffects); + end + else + dummyCodeStr = 'full'; + lmeString = sprintf('%s~-1+%s+(%s)', varName, ... + strrep(curLMEGbyString, '*', ':'), opts.RandomEffects); + end +end + + +function cleanNames = sanitizeNames(names) +% Clean ANOVA term names for use as table variable names + cleanNames = cell(size(names)); + for i = 1:length(names) + str = names{i}; + str(str == '(' | str == ')') = ''; + str(str == ':' | str == '_') = ''; + str(str == ' ' | str == '-') = ''; + cleanNames{i} = str; + end +end diff --git a/+exploreFNIRS/+stats/fitLME.m b/+exploreFNIRS/+stats/fitLME.m new file mode 100644 index 00000000..2bd5ef3c --- /dev/null +++ b/+exploreFNIRS/+stats/fitLME.m @@ -0,0 +1,996 @@ +function results = fitLME(groups, groupByVars, varargin) +% FITLME Fit linear mixed-effects models per channel for grouped fNIRS data +% +% Fits LME models per channel using groupby variables as fixed effects, +% with random intercepts for subjects. Returns fitted models, ANOVA tables, +% auto-generated contrasts, and model comparison statistics. +% +% This is the pure statistical engine. For combined analysis and +% visualization, use exploreFNIRS.core.plotLME instead. +% +% Syntax: +% results = exploreFNIRS.stats.fitLME(groups, groupByVars) +% results = exploreFNIRS.stats.fitLME(groups, groupByVars, 'Biomarkers', {'HbO'}) +% results = exploreFNIRS.stats.fitLME(groups, groupByVars, 'Channels', 1:5) +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% groupByVars - Cell array of grouping variable names used in groupby() +% +% Name-Value Parameters: +% Biomarkers - Cell array of biomarker names (default: {'HbO','HbR','HbTotal','CBSI'}) +% Channels - Vector of channel indices (default: all) +% RandomEffects - Random effects formula (default: '1|SubjectID') +% UseIntercept - Include intercept (default: true) +% AllInteractions - Use full interaction model (default: false) +% InfoCovariate - Info variable name as covariate (default: '') +% CustomFormula - Override auto-built formula (default: '') +% ContrastThreshold - p-value threshold for auto-contrasts (default: 0.1) +% Verbose - Print progress to console (default: true) +% ExcludeShortSeparation - Skip short separation channels (default: true) +% DataType - 'fNIRS' (default), 'Aux', or 'ROI' +% AuxField - Aux field name (required when DataType='Aux') +% TimeModel - How to model Time when multiple bins exist (default: 'polynomial'): +% 'polynomial' - Orthogonal polynomial time (growth curve analysis) +% 'discrete' - Categorical dummy codes (one per bin) +% 'continuous' - Centered numeric time (linear trend) +% 'none' - Drop Time from model entirely +% PolynomialOrder - Degree for polynomial TimeModel (default: 2, range: 1-5) +% DiscreteTime - [Deprecated] Use TimeModel instead. true maps to +% 'discrete', false maps to 'continuous'. +% ModelFitTest - Run joint coefficient test H0:all betas=0 (default: true) +% SkipContrasts - Skip auto-contrast generation (default: false) +% +% Outputs: +% results - Struct with fields: +% .models - Cell array of LinearMixedModel objects [nBio x nCh] +% .anova - Cell array of ANOVA tables [nBio x nCh] +% .anova_pval - Table of ANOVA p-values [channels x terms] +% .anova_Fstat - Table of ANOVA F-statistics [channels x terms] +% .anova_df1 - Table of numerator df [channels x terms] +% .anova_df2 - Table of denominator df [channels x terms] +% .contrasts - Cell array of contrast tables [nBio x nCh] +% .coefficients - Cell array of random effects [nBio x nCh] +% .AIC - Matrix of AIC values [nBio x nCh] +% .formula - The formula string used +% .mergedTable - Long-format merged data table (first channel) +% .nullComparison - Cell array of null model comparisons [nBio x nCh] +% .biomarkers - Cell array of biomarker names used +% .channels - Channel indices used +% .groupByVars - Grouping variables used +% .coef_pval - Table of coefficient p-values [channels x coefficients] +% .coef_tstat - Table of coefficient t-statistics [channels x coefficients] +% .coef_df - Table of coefficient degrees of freedom [channels x coefficients] +% .modelFit - Table of joint coefficient test results [channels x {p,F,df1,df2}] +% .timeModel - TimeModel string used ('polynomial', 'discrete', etc.) +% .termLabels - Struct mapping polynomial terms to readable names +% (e.g. termLabels.ot1 = 'Time (Linear)') +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.groupby({'Group', 'Condition'}); +% ex.aggregate(); +% +% % Fit LME models (statistics only, no visualization) +% results = exploreFNIRS.stats.fitLME(ex.getGroups(), {'Group','Condition'}); +% disp(results.anova_pval); +% +% % Summarize results +% T = exploreFNIRS.stats.summarize(results, 'Type', 'anova'); +% +% References: +% Pinheiro, J. C. & Bates, D. M. (2000). Mixed-Effects Models in S and +% S-PLUS. Springer. DOI: 10.1007/b98882 +% +% Satterthwaite, F. E. (1946). An approximate distribution of estimates +% of variance components. Biometrics Bulletin, 2(6), 110-114. +% +% Mirman, D. (2017). Growth Curve Analysis and Visualization Using R. +% Chapman and Hall/CRC. DOI: 10.1201/9781315373218 +% +% See also: exploreFNIRS.stats.runContrasts, exploreFNIRS.stats.summarize, +% exploreFNIRS.core.plotLME, exploreFNIRS.fx.autoContrast, fitlme + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addRequired(p, 'groupByVars', @iscell); + addParameter(p, 'Biomarkers', {'HbO','HbR','HbTotal','CBSI'}, @iscell); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'RandomEffects', '1|SubjectID', @ischar); + addParameter(p, 'UseIntercept', true, @islogical); + addParameter(p, 'AllInteractions', false, @islogical); + addParameter(p, 'InfoCovariate', '', @ischar); + addParameter(p, 'CustomFormula', '', @ischar); + addParameter(p, 'ContrastThreshold', 0.1, @isnumeric); + addParameter(p, 'Verbose', true, @islogical); + addParameter(p, 'DataType', 'fNIRS', @ischar); + addParameter(p, 'AuxField', '', @ischar); + addParameter(p, 'ExcludeShortSeparation', true, @islogical); + addParameter(p, 'SkipTimeFactor', false, @islogical); + addParameter(p, 'TimeModel', '', @ischar); + addParameter(p, 'PolynomialOrder', 2, @(x) isnumeric(x) && isscalar(x) && x >= 1 && x <= 5); + addParameter(p, 'DiscreteTime', [], @islogical); + addParameter(p, 'ModelFitTest', true, @islogical); + addParameter(p, 'SkipContrasts', false, @islogical); + addParameter(p, 'StatWindow', [], @isnumeric); + parse(p, groups, groupByVars, varargin{:}); + opts = p.Results; + + % Resolve TimeModel from deprecated DiscreteTime if needed + if ~isempty(opts.DiscreteTime) + if isempty(opts.TimeModel) + if opts.DiscreteTime + opts.TimeModel = 'discrete'; + else + opts.TimeModel = 'continuous'; + end + warning('pf2:stats:deprecatedParam', ... + 'DiscreteTime is deprecated. Use TimeModel=''%s'' instead.', ... + opts.TimeModel); + end + end + if isempty(opts.TimeModel) + opts.TimeModel = 'polynomial'; + end + + isAux = strcmpi(opts.DataType, 'Aux'); + isROI = strcmpi(opts.DataType, 'ROI'); + if isAux && isempty(opts.AuxField) + error('exploreFNIRS:stats:fitLME', ... + 'AuxField is required when DataType is ''Aux'''); + end + + nGroups = length(groups); + nBioM = length(opts.Biomarkers); + + % Validate groups have bar-flat data + for g = 1:nGroups + if isempty(groups(g).gbyGrandBarFlat) + error('exploreFNIRS:stats:fitLME', ... + 'Group %d has no bar-flat grand average. Call aggregate() first.', g); + end + end + + % Get time bins from bar-flat data + barTimes = groups(1).gbyGrandBarFlat.time; + + % Filter time bins by StatWindow + if ~isempty(opts.StatWindow) + sw = opts.StatWindow; + if ~isnumeric(sw) || numel(sw) ~= 2 + error('exploreFNIRS:stats:fitLME:invalidStatWindow', ... + 'StatWindow must be a 2-element numeric vector [start, end].'); + end + tMask = barTimes >= sw(1) & barTimes <= sw(2); + barTimes = barTimes(tMask); + if isempty(barTimes) + error('exploreFNIRS:stats:fitLME:emptyWindow', ... + 'StatWindow [%.1f, %.1f] contains no time bins. Adjust barBinSize or StatWindow.', sw(1), sw(2)); + end + end + + if ~isempty(opts.StatWindow) && length(barTimes) == 1 && ... + length(groups(1).gbyGrandBarFlat.time) == 1 + warning('pf2:stats:singleBin', ... + 'StatWindow has no effect with a single time bin (barBinSize=0). Set barBinSize > 0 for time-resolved analysis.'); + end + + % Auto-include Time as factor when multiple time bins exist + % (skip for GLM betas where time bins are meaningless) + hasMultipleTimeBins = length(barTimes) > 1; + skipTimeInclusion = strcmpi(opts.TimeModel, 'none'); + if hasMultipleTimeBins && ~ismember('Time', groupByVars) && ~opts.SkipTimeFactor && ~skipTimeInclusion + groupByVars = [groupByVars, {'Time'}]; + if opts.Verbose + tmLabel = opts.TimeModel; + if strcmpi(tmLabel, 'polynomial') + tmLabel = sprintf('polynomial (order %d)', opts.PolynomialOrder); + end + fprintf('Multiple time bins (%d). Auto-including Time as %s.\n', ... + length(barTimes), tmLabel); + end + end + + % Clamp PolynomialOrder to available time bins + if strcmpi(opts.TimeModel, 'polynomial') && hasMultipleTimeBins + maxOrder = length(barTimes) - 1; + if opts.PolynomialOrder > maxOrder + if opts.Verbose + fprintf('Clamping PolynomialOrder from %d to %d (%d time bins).\n', ... + opts.PolynomialOrder, maxOrder, length(barTimes)); + end + opts.PolynomialOrder = maxOrder; + end + end + + if isAux + % --- Aux mode: iterate over aux channels --- + auxField = opts.AuxField; + + % Determine aux channel count from grand average + ga = groups(1).gbyGrandBarFlat; + [auxDataField, auxVarNames, nAuxCh] = resolveAuxField(ga, auxField); + + if nAuxCh == 0 + error('exploreFNIRS:stats:fitLME', ... + 'Aux field "%s" not found in grand average data.', auxField); + end + + % Apply Channels filter to aux channels + if isempty(opts.Channels) + auxChannels = 1:nAuxCh; + else + auxChannels = opts.Channels; + end + nCh = length(auxChannels); + + % Build merged table with aux data (use minimal biomarker) + mergedTable = exploreFNIRS.export.mergeGbyTablesLong( ... + groups, opts.Biomarkers(1), 1, barTimes, true, false, {'1'}); + + if isempty(mergedTable) || height(mergedTable) == 0 + error('exploreFNIRS:stats:fitLME', ... + 'Empty merged table for aux data'); + end + + % Transform Time column based on TimeModel + mergedTable = prepareTimeColumn(mergedTable, opts, hasMultipleTimeBins); + + % Build aux column names (matching mergeGbyTablesLong convention) + % Use the resolved field name (may have _data suffix) for column matching + auxColNames = cell(1, nAuxCh); + for ch = 1:nAuxCh + if nAuxCh == 1 + auxColNames{ch} = sprintf('aux_%s', auxDataField); + elseif ~isempty(auxVarNames) + auxColNames{ch} = sprintf('aux_%s_%s', auxDataField, auxVarNames{ch}); + else + auxColNames{ch} = sprintf('aux_%s_%d', auxDataField, ch); + end + end + + % If resolved column names not found, try user-friendly names (non-flattened) + if ~isempty(mergedTable) && ~ismember(auxColNames{1}, mergedTable.Properties.VariableNames) + for ch = 1:nAuxCh + if nAuxCh == 1 + auxColNames{ch} = sprintf('aux_%s', auxField); + elseif ~isempty(auxVarNames) + auxColNames{ch} = sprintf('aux_%s_%s', auxField, auxVarNames{ch}); + else + auxColNames{ch} = sprintf('aux_%s_%d', auxField, ch); + end + end + end + + % Initialize results (1 x nAuxCh) + results = initResults(1, nCh, {auxField}, auxChannels, groupByVars); + results.mergedTable = mergedTable; + + for chI = 1:nCh + ch = auxChannels(chI); + varName = auxColNames{ch}; + + if ~ismember(varName, mergedTable.Properties.VariableNames) + if opts.Verbose + warning('Aux variable %s not found in merged table, skipping', varName); + end + continue; + end + + chRowName = varName; + results = fitOneModel(results, mergedTable, varName, chRowName, ... + 1, chI, groupByVars, opts); + + if opts.Verbose && ~isnan(results.AIC(1, chI)) + fprintf('LME [%s ch %d]: AIC=%.1f\n', auxField, ch, ... + results.AIC(1, chI)); + end + end + + elseif isROI + % --- ROI mode: iterate over biomarkers x ROIs --- + ga = groups(1).gbyGrandBarFlat; + + if ~pf2_base.isnestedfield(ga, 'ROI.HbO.data') + error('exploreFNIRS:stats:fitLME', ... + 'No ROI data in grand average. Define ROIs before aggregating.'); + end + + % Get ROI count and names + nTotalROIs = size(ga.ROI.(opts.Biomarkers{1}).data, 2); + if isfield(ga.ROI, 'info') && ~isempty(ga.ROI.info) + roiLabels = ga.ROI.info.Properties.RowNames; + else + roiLabels = arrayfun(@(i) sprintf('ROI%d', i), 1:nTotalROIs, ... + 'UniformOutput', false); + end + + % Apply Channels filter to ROI indices + if isempty(opts.Channels) + roiChannels = 1:nTotalROIs; + else + roiChannels = opts.Channels(opts.Channels <= nTotalROIs); + end + nROI = length(roiChannels); + + % Initialize results (nBioM x nROI) + roiChLabels = roiLabels(roiChannels); + results = initResults(nBioM, nROI, opts.Biomarkers, roiChannels, groupByVars); + + for bIdx = 1:nBioM + bioM = opts.Biomarkers{bIdx}; + + for rI = 1:nROI + roiIdx = roiChannels(rI); + roiLabel = roiLabels{roiIdx}; + + % Build merged long-format table with ROI data + mergedTable = exploreFNIRS.export.mergeGbyTablesLong( ... + groups, {bioM}, roiIdx, barTimes, false, true, roiChLabels(rI)); + + if isempty(mergedTable) || height(mergedTable) == 0 + if opts.Verbose + warning('No data for %s ROI %d (%s), skipping', ... + bioM, roiIdx, roiLabel); + end + continue; + end + + % Transform Time column based on TimeModel + mergedTable = prepareTimeColumn(mergedTable, opts, hasMultipleTimeBins); + + % Build response variable name (matches mergeGbyTablesLong ROI convention) + varName = sprintf('ROI%d_%s_%s', roiIdx, roiLabel, bioM); + + if ~ismember(varName, mergedTable.Properties.VariableNames) + if opts.Verbose + warning('Variable %s not found in merged table, skipping', varName); + end + continue; + end + + chRowName = sprintf('ROI%d_%s_%s', roiIdx, roiLabel, bioM); + + if bIdx == 1 && rI == 1 + results.mergedTable = mergedTable; + end + + results = fitOneModel(results, mergedTable, varName, chRowName, ... + bIdx, rI, groupByVars, opts); + + if opts.Verbose && ~isnan(results.AIC(bIdx, rI)) + fprintf('LME [%s ROI %d %s]: AIC=%.1f\n', bioM, roiIdx, ... + roiLabel, results.AIC(bIdx, rI)); + end + end + end + + else + % --- Standard fNIRS mode --- + + % Determine channels + if isempty(opts.Channels) + nCh = size(groups(1).gbyGrandBarFlat.(opts.Biomarkers{1}).data, 2); + channels = 1:nCh; + else + channels = opts.Channels; + nCh = length(channels); + end + + % Exclude short separation channels if requested + if opts.ExcludeShortSeparation + ssIdx = getShortSeparationIdx(groups); + if ~isempty(ssIdx) + channels = channels(~ismember(channels, ssIdx)); + nCh = length(channels); + if opts.Verbose + fprintf('Excluding %d short separation channels\n', length(ssIdx)); + end + end + end + + % Build channel labels + chLabels = arrayfun(@(x) num2str(x), channels, 'UniformOutput', false); + + % Initialize results + results = initResults(nBioM, nCh, opts.Biomarkers, channels, groupByVars); + + % Determine whether to use parfor for channel loop + nTotal = nBioM * nCh; + useParfor = false; + if nTotal > 4 + [canUse, poolRunning] = pf2_base.accel.canParfor(); + useParfor = canUse && poolRunning; + end + + if useParfor && nBioM == 1 + % Single-biomarker parallel path: parfor over channels + bioM = opts.Biomarkers{1}; + parResults = cell(nCh, 1); + firstTable = []; + + parfor chI = 1:nCh + ch = channels(chI); + mTable = exploreFNIRS.export.mergeGbyTablesLong( ... + groups, {bioM}, ch, barTimes, false, false, chLabels(chI)); + if isempty(mTable) || height(mTable) == 0 + continue; + end + mTable = prepareTimeColumn(mTable, opts, hasMultipleTimeBins); + varName = sprintf('Opt%s_%s', chLabels{chI}, bioM); + if ~ismember(varName, mTable.Properties.VariableNames) + continue; + end + chRowName = sprintf('Opt%s_%s', chLabels{chI}, bioM); + tmpRes = initResults(1, 1, {bioM}, ch, groupByVars); + tmpRes = fitOneModel(tmpRes, mTable, varName, chRowName, ... + 1, 1, groupByVars, opts); + parResults{chI} = struct('tmpRes', tmpRes, 'mTable', mTable, ... + 'chRowName', chRowName); + end + + % Merge parallel results back + for chI = 1:nCh + if isempty(parResults{chI}), continue; end + pr = parResults{chI}; + results.models{1, chI} = pr.tmpRes.models{1, 1}; + results.anova{1, chI} = pr.tmpRes.anova{1, 1}; + results.contrasts{1, chI} = pr.tmpRes.contrasts{1, 1}; + results.coefficients{1, chI} = pr.tmpRes.coefficients{1, 1}; + results.nullComparison{1, chI} = pr.tmpRes.nullComparison{1, 1}; + results.AIC(1, chI) = pr.tmpRes.AIC(1, 1); + if ~isempty(pr.tmpRes.anova_pval) && height(pr.tmpRes.anova_pval) > 0 + aCols = pr.tmpRes.anova_pval.Properties.VariableNames; + results.anova_pval{pr.chRowName, aCols} = pr.tmpRes.anova_pval{1, :}; + results.anova_Fstat{pr.chRowName, aCols} = pr.tmpRes.anova_Fstat{1, :}; + if height(pr.tmpRes.anova_df1) > 0 + results.anova_df1{pr.chRowName, aCols} = pr.tmpRes.anova_df1{1, :}; + results.anova_df2{pr.chRowName, aCols} = pr.tmpRes.anova_df2{1, :}; + end + end + if ~isempty(pr.tmpRes.coef_pval) && height(pr.tmpRes.coef_pval) > 0 + cCols = pr.tmpRes.coef_pval.Properties.VariableNames; + results.coef_pval{pr.chRowName, cCols} = pr.tmpRes.coef_pval{1, :}; + results.coef_tstat{pr.chRowName, cCols} = pr.tmpRes.coef_tstat{1, :}; + results.coef_df{pr.chRowName, cCols} = pr.tmpRes.coef_df{1, :}; + end + if ~isempty(pr.tmpRes.modelFit) && height(pr.tmpRes.modelFit) > 0 + results.modelFit{pr.chRowName, pr.tmpRes.modelFit.Properties.VariableNames} = ... + pr.tmpRes.modelFit{1, :}; + end + if isempty(firstTable) + firstTable = pr.mTable; + end + if opts.Verbose && ~isnan(results.AIC(1, chI)) + fprintf('LME [%s Ch %d]: AIC=%.1f\n', bioM, channels(chI), ... + results.AIC(1, chI)); + end + end + if ~isempty(firstTable) + results.mergedTable = firstTable; + end + else + % Serial path (or multi-biomarker) + for bIdx = 1:nBioM + bioM = opts.Biomarkers{bIdx}; + + for chI = 1:nCh + ch = channels(chI); + + % Build merged long-format table + mergedTable = exploreFNIRS.export.mergeGbyTablesLong( ... + groups, {bioM}, ch, barTimes, false, false, chLabels(chI)); + + if isempty(mergedTable) || height(mergedTable) == 0 + if opts.Verbose + warning('No data for %s channel %d, skipping', bioM, ch); + end + continue; + end + + % Transform Time column based on TimeModel + mergedTable = prepareTimeColumn(mergedTable, opts, hasMultipleTimeBins); + + % Build variable name (response) + varName = sprintf('Opt%s_%s', chLabels{chI}, bioM); + + if ~ismember(varName, mergedTable.Properties.VariableNames) + if opts.Verbose + warning('Variable %s not found in merged table, skipping', varName); + end + continue; + end + + chRowName = sprintf('Opt%s_%s', chLabels{chI}, bioM); + + if bIdx == 1 && chI == 1 + results.mergedTable = mergedTable; + end + + results = fitOneModel(results, mergedTable, varName, chRowName, ... + bIdx, chI, groupByVars, opts); + + if opts.Verbose && ~isnan(results.AIC(bIdx, chI)) + fprintf('LME [%s Ch %d]: AIC=%.1f\n', bioM, ch, ... + results.AIC(bIdx, chI)); + end + end + end + end + end + + results.statWindow = opts.StatWindow; + results.timeModel = opts.TimeModel; + + % Build readable term labels for polynomial terms + if strcmpi(opts.TimeModel, 'polynomial') + results.termLabels = buildTermLabels(opts.PolynomialOrder); + end + + % Print ANOVA summary + if opts.Verbose && ~isempty(results.anova_pval) && height(results.anova_pval) > 0 + fprintf('\n--- ANOVA p-values ---\n'); + disp(results.anova_pval); + end +end + + +%% Local helpers + +function [lmeString, dummyCodeStr] = buildFormula(varName, groupByVars, opts) +% Build LME formula string from groupby variables and options + + dummyCodeStr = 'reference'; + isPolyTime = strcmpi(opts.TimeModel, 'polynomial') && any(strcmpi(groupByVars, 'Time')); + + % Separate polynomial time terms from regular groupby vars + if isPolyTime + nonTimeVars = groupByVars(~strcmpi(groupByVars, 'Time')); + else + nonTimeVars = groupByVars; + end + + % Build fixed effects part + basicParts = {}; + if ~isempty(opts.InfoCovariate) + basicParts{end+1} = opts.InfoCovariate; + end + + mdlPrtString = strjoin(basicParts, '*'); + if isempty(mdlPrtString) + mdlPrtString = '1'; + end + + % Add groupby variables (excluding Time for polynomial mode) + if opts.AllInteractions + curLMEGbyString = mdlPrtString; + for i = 1:length(nonTimeVars) + curLMEGbyString = sprintf('%s*%s', curLMEGbyString, nonTimeVars{i}); + end + else + parts = {}; + for i = 1:length(nonTimeVars) + if strcmp(mdlPrtString, '1') + parts{end+1} = nonTimeVars{i}; %#ok + else + parts{end+1} = sprintf('%s*%s', mdlPrtString, nonTimeVars{i}); %#ok + end + end + curLMEGbyString = strjoin(parts, '+'); + end + + % Append polynomial time terms and interactions + if isPolyTime + polyOrder = opts.PolynomialOrder; + otTerms = arrayfun(@(k) sprintf('ot%d', k), 1:polyOrder, ... + 'UniformOutput', false); + + % Main effects: ot1 + ot2 + ot3 + polyMain = strjoin(otTerms, '+'); + + % Interactions: each non-Time groupby var x each ot term + polyInteract = {}; + for i = 1:length(nonTimeVars) + for k = 1:polyOrder + polyInteract{end+1} = sprintf('%s:ot%d', nonTimeVars{i}, k); %#ok + end + end + polyInteractStr = strjoin(polyInteract, '+'); + + if isempty(curLMEGbyString) || strcmp(curLMEGbyString, '1') + curLMEGbyString = polyMain; + else + curLMEGbyString = sprintf('%s+%s', curLMEGbyString, polyMain); + end + if ~isempty(polyInteractStr) + curLMEGbyString = sprintf('%s+%s', curLMEGbyString, polyInteractStr); + end + end + + % Random effects: upgrade to random slope for polynomial time + randomFx = opts.RandomEffects; + if isPolyTime && strcmp(randomFx, '1|SubjectID') + randomFx = '1+ot1|SubjectID'; + end + + % Build full formula + if opts.UseIntercept + if isempty(curLMEGbyString) || strcmp(curLMEGbyString, '1') + lmeString = sprintf('%s~1+(%s)', varName, randomFx); + else + lmeString = sprintf('%s~%s+(%s)', varName, curLMEGbyString, ... + randomFx); + end + else + dummyCodeStr = 'full'; + lmeString = sprintf('%s~-1+%s+(%s)', varName, ... + strrep(curLMEGbyString, '*', ':'), randomFx); + end +end + + +function cleanNames = sanitizeNames(names) +% Clean ANOVA term names for use as table variable names + cleanNames = cell(size(names)); + for i = 1:length(names) + str = names{i}; + str(str == '(' | str == ')') = ''; + str(str == ':' | str == '_') = ''; + str(str == ' ' | str == '-') = ''; + cleanNames{i} = str; + end +end + + +function results = initResults(nBioM, nCh, biomarkers, channels, groupByVars) +% Initialize an empty results struct with correct dimensions + results = struct(); + results.models = cell(nBioM, nCh); + results.anova = cell(nBioM, nCh); + results.contrasts = cell(nBioM, nCh); + results.AIC = nan(nBioM, nCh); + results.formula = ''; + results.mergedTable = []; + results.anova_pval = table(); + results.anova_Fstat = table(); + results.anova_df1 = table(); + results.anova_df2 = table(); + results.coefficients = cell(nBioM, nCh); + results.nullComparison = cell(nBioM, nCh); + results.coef_pval = table(); + results.coef_tstat = table(); + results.coef_df = table(); + results.modelFit = table(); + results.biomarkers = biomarkers; + results.channels = channels; + results.groupByVars = groupByVars; + results.statWindow = []; + results.termLabels = struct(); +end + + +function results = fitOneModel(results, mergedTable, varName, chRowName, ... + bIdx, chI, groupByVars, opts) +% Fit a single LME model and store results +% +% Extracted from the channel loop to share between fNIRS and Aux modes. + + % Build LME formula + if ~isempty(opts.CustomFormula) + lmeString = opts.CustomFormula; + dummyCodeStr = 'reference'; + if contains(lmeString, '-1+') || contains(lmeString, '~-1') + dummyCodeStr = 'full'; + lmeString = strrep(lmeString, '*', ':'); + end + else + [lmeString, dummyCodeStr] = buildFormula(varName, groupByVars, opts); + end + + results.formula = lmeString; + + % Suppress MATLAB's own fitlme rank/Hessian spam when not verbose. + % Scoped to the specific LME identifiers (not a blanket off-all), so + % unrelated warnings still surface; restored when cleanupObj clears. + if ~opts.Verbose + cleanupObj = exploreFNIRS.stats.suppressLMEWarnings(); %#ok + end + + try + rng(2019); + mdl = fitlme(mergedTable, lmeString, ... + 'FitMethod', 'REML', 'CheckHessian', true, ... + 'DummyVarCoding', dummyCodeStr); + + results.models{bIdx, chI} = mdl; + results.AIC(bIdx, chI) = mdl.ModelCriterion.AIC; + catch ME_fit + % Convergence fallback: if polynomial random slope failed, retry + % with intercept-only random effects + if contains(lmeString, '1+ot1|SubjectID') + fallbackFormula = strrep(lmeString, '1+ot1|SubjectID', '1|SubjectID'); + try + if opts.Verbose + warning('pf2:stats:polyFallback', ... + 'Random slope model failed for %s. Falling back to (1|SubjectID).', ... + varName); + end + rng(2019); + mdl = fitlme(mergedTable, fallbackFormula, ... + 'FitMethod', 'REML', 'CheckHessian', true, ... + 'DummyVarCoding', dummyCodeStr); + lmeString = fallbackFormula; + results.formula = lmeString; + results.models{bIdx, chI} = mdl; + results.AIC(bIdx, chI) = mdl.ModelCriterion.AIC; + catch ME_fallback + if opts.Verbose + warning('pf2:stats:lmeFailed', ... + 'LME failed for %s (fallback also failed): %s', ... + varName, ME_fallback.message); + end + return; + end + else + if opts.Verbose + warning('pf2:stats:lmeFailed', ... + 'LME failed for %s: %s', varName, ME_fit.message); + end + return; + end + end + + try + % ANOVA with Satterthwaite degrees of freedom + anv = anova(mdl, 'DFMethod', 'satterthwaite'); + results.anova{bIdx, chI} = anv; + + % Store ANOVA results in summary tables + anovaNames = sanitizeNames(anv.Term); + + results.anova_pval{chRowName, anovaNames} = anv.pValue(:)'; + results.anova_Fstat{chRowName, anovaNames} = anv.FStat(:)'; + + try + results.anova_df1{chRowName, anovaNames} = anv.DF1(:)'; + results.anova_df2{chRowName, anovaNames} = anv.DF2(:)'; + catch + results.anova_df1{chRowName, anovaNames} = anv.DF(:)'; + results.anova_df2{chRowName, anovaNames} = anv.DF(:)'; + end + + % Auto contrasts + if ~opts.SkipContrasts + try + cTable = exploreFNIRS.fx.autoContrast(mdl, opts.ContrastThreshold); + results.contrasts{bIdx, chI} = cTable; + catch + results.contrasts{bIdx, chI} = table(); + end + end + + % Random effects coefficients + try + [~, ~, reCoefs] = randomEffects(mdl, 'DFMethod', 'satterthwaite'); + results.coefficients{bIdx, chI} = reCoefs; + + % Store coefficient summary tables + coefNames = reCoefs.Name; + cleanCoefNames = sanitizeNames(coefNames); + results.coef_pval{chRowName, cleanCoefNames} = reCoefs.pValue(:)'; + results.coef_tstat{chRowName, cleanCoefNames} = reCoefs.tStat(:)'; + results.coef_df{chRowName, cleanCoefNames} = reCoefs.DF(:)'; + catch + results.coefficients{bIdx, chI} = []; + end + + % Model fit test (joint H0: all fixed-effect betas = 0) + if opts.ModelFitTest + try + mdlTest = eye(length(mdl.Coefficients.Name)); + if opts.UseIntercept + mdlTest = mdlTest(2:end,:); + end + [mfP, mfF, mfDF1, mfDF2] = coefTest(mdl, mdlTest, ... + zeros(size(mdlTest,1),1), 'DFMethod', 'satterthwaite'); + results.modelFit{chRowName, {'p','F','df1','df2'}} = ... + [mfP, mfF, mfDF1, mfDF2]; + catch + end + end + + % Null model comparison (ML required for LRT) + try + nullStr = sprintf('%s~1+(%s)', varName, opts.RandomEffects); + mdlML = fitlme(mergedTable, lmeString, ... + 'FitMethod', 'ML', 'CheckHessian', true, ... + 'DummyVarCoding', dummyCodeStr); + nullMdl = fitlme(mergedTable, nullStr, ... + 'FitMethod', 'ML', 'CheckHessian', true, ... + 'DummyVarCoding', dummyCodeStr); + results.nullComparison{bIdx, chI} = compare(nullMdl, mdlML); + catch + results.nullComparison{bIdx, chI} = []; + end + + catch ME + if opts.Verbose + warning('pf2:stats:lmeFailed', ... + 'LME failed for %s: %s', varName, ME.message); + end + end +end + + +function labels = buildTermLabels(polyOrder) +% BUILDTERMLABELS Map polynomial term names to readable labels +% +% Returns a struct where field names are the sanitized ANOVA term names +% (e.g. 'ot1') and values are readable strings (e.g. 'Time (Linear)'). + + ordinalNames = {'Linear', 'Quadratic', 'Cubic', 'Quartic', 'Quintic'}; + labels = struct(); + + for k = 1:polyOrder + termName = sprintf('ot%d', k); + if k <= length(ordinalNames) + labels.(termName) = sprintf('Time (%s)', ordinalNames{k}); + else + labels.(termName) = sprintf('Time (Order %d)', k); + end + end +end + + +function T = prepareTimeColumn(T, opts, hasMultipleTimeBins) +% PREPARETIMECOLUMN Transform Time column based on TimeModel setting +% +% Handles conversion from raw time values to the representation needed +% for the selected TimeModel: polynomial, discrete, continuous, or none. + + if ~ismember('Time', T.Properties.VariableNames) + return; + end + + % Ensure numeric + if iscell(T.Time) || isstring(T.Time) + T.Time = str2double(T.Time); + end + + if ~hasMultipleTimeBins + return; + end + + switch lower(opts.TimeModel) + case 'polynomial' + % Orthogonal polynomial coding via QR decomposition + % Center time to [-1, 1] then compute ot1..otK + timeVals = T.Time; + uTime = unique(timeVals); + nBins = length(uTime); + polyOrder = min(opts.PolynomialOrder, nBins - 1); + + % Map to [-1, 1] + tMin = min(uTime); + tMax = max(uTime); + if tMax == tMin + tNorm = zeros(size(uTime)); + else + tNorm = 2 * (uTime - tMin) / (tMax - tMin) - 1; + end + + % Build raw polynomial matrix and orthogonalize via QR + rawPoly = zeros(nBins, polyOrder); + for k = 1:polyOrder + rawPoly(:, k) = tNorm .^ k; + end + [Q, ~] = qr(rawPoly, 0); + + % Map each observation's time bin to its orthogonal polynomial values + [~, binIdx] = ismember(timeVals, uTime); + for k = 1:polyOrder + colName = sprintf('ot%d', k); + T.(colName) = Q(binIdx, k); + end + + % Remove Time column (polynomial terms replace it) + T.Time = []; + + case 'discrete' + % Categorical dummy codes (original DiscreteTime=true behavior) + T.Time = categorical(T.Time); + + case 'continuous' + % Center numeric time around mean + T.Time = T.Time - mean(T.Time); + + case 'none' + % Drop Time entirely + T.Time = []; + end +end + + +function [auxDataField, auxVarNames, nAuxCh] = resolveAuxField(ga, auxField) +% Resolve aux field name in grand average, handling _data suffix convention + + auxDataField = ''; + auxVarNames = {}; + nAuxCh = 0; + + if ~isfield(ga, 'Aux') || ~isstruct(ga.Aux) + return; + end + + % Try direct field name first, then _data suffix + if isfield(ga.Aux, auxField) + actualField = auxField; + elseif isfield(ga.Aux, [auxField '_data']) + actualField = [auxField '_data']; + else + return; + end + + auxData = ga.Aux.(actualField); + if ~isstruct(auxData) || ~isfield(auxData, 'data') + return; + end + + auxDataField = actualField; + nAuxCh = size(auxData.data, 2); + + if isfield(auxData, 'varNames') + auxVarNames = auxData.varNames; + end +end + + +function ssIdx = getShortSeparationIdx(groups) +% GETSHORTSEPARATIONIDX Get indices of short separation channels from probe info + + ssIdx = []; + + % Try first subject in first group + if isempty(groups) || isempty(groups(1).gbyFNIRS) + return; + end + + fNIR = groups(1).gbyFNIRS{1}; + + % First check if probeinfo is directly on the struct + probeInfo = []; + if isfield(fNIR, 'probeinfo') && isfield(fNIR.probeinfo, 'Probe') ... + && iscell(fNIR.probeinfo.Probe) && ~isempty(fNIR.probeinfo.Probe) + probeInfo = fNIR.probeinfo.Probe{1}; + elseif isfield(fNIR, 'info') && isfield(fNIR.info, 'probename') ... + && ~isempty(fNIR.info.probename) && ~contains(fNIR.info.probename, 'Unknown') + % Load probe info from device config file + try + device = pf2_base.loadDeviceCfg(fNIR.info.probename); + if isstruct(device) && isfield(device, 'Probe') ... + && iscell(device.Probe) && ~isempty(device.Probe) + probeInfo = device.Probe{1}; + end + catch ME + warning('exploreFNIRS:stats:fitLME', ... + 'Could not load probe config for "%s": %s', ... + fNIR.info.probename, ME.message); + return; + end + end + + if isempty(probeInfo) + return; + end + + % TableOpt is a MATLAB table, not a struct — use ismember for variable check + if isfield(probeInfo, 'TableOpt') && istable(probeInfo.TableOpt) ... + && ismember('IsShortSeparation', probeInfo.TableOpt.Properties.VariableNames) + ssIdx = find(probeInfo.TableOpt.IsShortSeparation(:)'); + elseif isfield(probeInfo, 'NumShortSeparation') && probeInfo.NumShortSeparation > 0 ... + && isfield(probeInfo, 'TableOpt') && istable(probeInfo.TableOpt) ... + && ismember('SD', probeInfo.TableOpt.Properties.VariableNames) + % Fallback: use SD distance < 2 cm + ssIdx = find(probeInfo.TableOpt.SD(:)' < 2); + end +end diff --git a/+exploreFNIRS/+stats/permTest.m b/+exploreFNIRS/+stats/permTest.m new file mode 100644 index 00000000..95208bcb --- /dev/null +++ b/+exploreFNIRS/+stats/permTest.m @@ -0,0 +1,434 @@ +function results = permTest(groups, groupByVars, varargin) +% PERMTEST Non-parametric permutation test for paired fNIRS comparisons +% +% Performs sign-flip permutation testing for 2-condition within-subject +% comparisons. Critical for small-N (N=5-7) studies where LME assumptions +% may not hold. Supports exact enumeration or Monte Carlo approximation. +% +% Syntax: +% results = exploreFNIRS.stats.permTest(groups, groupByVars) +% results = exploreFNIRS.stats.permTest(groups, groupByVars, 'NumPerm', 5000) +% results = exploreFNIRS.stats.permTest(groups, groupByVars, ... +% 'Statistic', 'tstat', 'Tail', 'right') +% +% Inputs: +% groups - Struct array from Experiment.groups (after aggregate()) +% groupByVars - Cell array of grouping variable names +% +% Name-Value Parameters: +% Biomarkers - Cell array of biomarker names (default: {'HbO','HbR','HbTotal','CBSI'}) +% Channels - Channel indices (default: all) +% DataType - 'fNIRS' (default) or 'ROI'. When 'ROI', tests per ROI +% instead of per channel. +% StatWindow - [start, end] seconds to filter time bins (default: []) +% NumPerm - Number of permutations (default: 5000), or 'exact' +% Paired - Paired (sign-flip) test (default: true) +% Statistic - 'mean_diff' (default) or 'tstat' +% Tail - 'both' (default), 'right', or 'left' +% Seed - Random seed for reproducibility (default: 2024) +% Verbose - Print progress (default: true) +% ExcludeShortSeparation - Skip short-sep channels (default: true) +% FDRThreshold - FDR threshold across channels (default: 0.05) +% FDRMethod - 'bh' (default) or 'twostep' +% +% Outputs: +% results - Struct with fields: +% .observed - [nBio x nCh] observed test statistic +% .nullDist - {nBio x nCh} cell of null distributions +% .pvalue - [nBio x nCh] uncorrected p-values +% .pvalueFDR - [nBio x nCh] FDR-corrected p-values +% .significant - [nBio x nCh] logical significance after FDR +% .effectSize - [nBio x nCh] Hedges' g effect size +% .nPerms - Number of permutations used +% .isExact - Whether exact enumeration was used +% .statistic - Test statistic used +% .tail - Tail direction +% .biomarkers - Biomarker names +% .channels - Channel indices +% .conditions - {2 x 1} cell of condition labels +% .nSubjects - Number of paired subjects +% .dataType - 'fNIRS' or 'ROI' +% .labels - Channel numbers or ROI names (for display) +% +% Example: +% ex = exploreFNIRS.core.Experiment(data); +% ex.select('Condition', {'Easy','Hard'}); +% ex.groupby('Condition'); +% ex.aggregate(); +% perm = ex.statsPermTest('Biomarkers', {'HbO'}, 'NumPerm', 1000); +% fprintf('Channel 1 p = %.4f (FDR q = %.4f)\n', ... +% perm.pvalue(1,1), perm.pvalueFDR(1,1)); +% +% References: +% Phipson, B. & Smyth, G. K. (2010). Permutation P-values should never +% be zero: calculating exact P-values when permutations are randomly +% drawn. Statistical Applications in Genetics and Molecular Biology, +% 9(1), Article 39. DOI: 10.2202/1544-6115.1585 +% +% Nichols, T. E. & Holmes, A. P. (2002). Nonparametric permutation tests +% for functional neuroimaging: a primer with examples. Human Brain +% Mapping, 15(1), 1-25. DOI: 10.1002/hbm.1058 +% +% See also: exploreFNIRS.stats.fitLME, exploreFNIRS.stats.effectSize, +% exploreFNIRS.fx.performFDR + + p = inputParser; + addRequired(p, 'groups', @isstruct); + addRequired(p, 'groupByVars', @iscell); + addParameter(p, 'Biomarkers', {'HbO','HbR','HbTotal','CBSI'}, @iscell); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'DataType', 'fNIRS', @ischar); + addParameter(p, 'StatWindow', [], @isnumeric); + addParameter(p, 'NumPerm', 5000, @(x) (isnumeric(x) && x > 0) || (ischar(x) && strcmpi(x, 'exact'))); + addParameter(p, 'Paired', true, @islogical); + addParameter(p, 'Statistic', 'mean_diff', @ischar); + addParameter(p, 'Tail', 'both', @ischar); + addParameter(p, 'Seed', 2024, @isnumeric); + addParameter(p, 'Verbose', true, @islogical); + addParameter(p, 'ExcludeShortSeparation', true, @islogical); + addParameter(p, 'FDRThreshold', 0.05, @isnumeric); + addParameter(p, 'FDRMethod', 'bh', @ischar); + parse(p, groups, groupByVars, varargin{:}); + opts = p.Results; + + % Must have exactly 2 groups + nGroups = length(groups); + if nGroups ~= 2 + error('exploreFNIRS:stats:permTest:needTwoGroups', ... + 'Permutation test requires exactly 2 groups (got %d). Use select() to choose 2 conditions.', ... + nGroups); + end + + if ~opts.Paired + error('exploreFNIRS:stats:permTest:unpairedNotSupported', ... + 'Unpaired permutation test is not yet supported. Use ''Paired'', true.'); + end + + isROIMode = strcmpi(opts.DataType, 'ROI'); + ga = groups(1).gbyGrandBarFlat; + + % Filter biomarkers to those available + validBio = {}; + for i = 1:length(opts.Biomarkers) + if isROIMode + if pf2_base.isnestedfield(ga, ['ROI.' opts.Biomarkers{i}]) + validBio{end+1} = opts.Biomarkers{i}; %#ok + end + else + if isfield(ga, opts.Biomarkers{i}) && ~isempty(ga.(opts.Biomarkers{i})) + validBio{end+1} = opts.Biomarkers{i}; %#ok + end + end + end + if isempty(validBio) + error('exploreFNIRS:stats:permTest:noBiomarkers', ... + 'None of the requested biomarkers found in data.'); + end + opts.Biomarkers = validBio; + nBioM = length(opts.Biomarkers); + + % Get time bins and apply StatWindow mask + barTimes = ga.time; + if ~isempty(opts.StatWindow) + sw = opts.StatWindow; + if ~isnumeric(sw) || numel(sw) ~= 2 + error('exploreFNIRS:stats:permTest:invalidStatWindow', ... + 'StatWindow must be a 2-element numeric vector [start, end].'); + end + tMask = barTimes >= sw(1) & barTimes <= sw(2); + else + tMask = true(size(barTimes)); + end + + % Determine channels/ROIs + firstBio = opts.Biomarkers{1}; + if isROIMode + if ~isfield(ga, 'ROI') + error('exploreFNIRS:stats:permTest:noROI', ... + 'No ROI data in grand average. Define ROIs before aggregating.'); + end + if isempty(opts.Channels) + nCh = size(ga.ROI.(firstBio).data, 2); + channels = 1:nCh; + else + channels = opts.Channels; + nCh = length(channels); + end + else + if isempty(opts.Channels) + nCh = size(ga.(firstBio).data, 2); + channels = 1:nCh; + else + channels = opts.Channels; + nCh = length(channels); + end + + % Exclude short separation channels (channel mode only) + if opts.ExcludeShortSeparation + ssIdx = getShortSeparationIdx(groups); + if ~isempty(ssIdx) + channels = channels(~ismember(channels, ssIdx)); + nCh = length(channels); + if opts.Verbose + fprintf('Excluding %d short separation channels\n', length(ssIdx)); + end + end + end + end + + % Build display labels + if isROIMode && isfield(ga.ROI, 'info') + roiInfo = ga.ROI.info; + if istable(roiInfo) + allNames = roiInfo.Properties.RowNames; + elseif isstruct(roiInfo) && isfield(roiInfo, 'Names') + allNames = roiInfo.Names; + else + allNames = arrayfun(@(i) sprintf('ROI%d', i), channels, 'UniformOutput', false); + end + labels = allNames(channels); + else + labels = arrayfun(@(c) sprintf('Ch%d', c), channels, 'UniformOutput', false); + end + + % Number of subjects per group (paired: must be equal) + if isROIMode + nSubA = size(groups(1).gbyGrandBarFlat.ROI.(firstBio).data, 3); + nSubB = size(groups(2).gbyGrandBarFlat.ROI.(firstBio).data, 3); + else + nSubA = size(groups(1).gbyGrandBarFlat.(firstBio).data, 3); + nSubB = size(groups(2).gbyGrandBarFlat.(firstBio).data, 3); + end + nSub = min(nSubA, nSubB); + + if nSub < 2 + error('exploreFNIRS:stats:permTest:tooFewSubjects', ... + 'Need at least 2 subjects per group for permutation test (got %d, %d).', ... + nSubA, nSubB); + end + + if nSubA ~= nSubB + warning('exploreFNIRS:stats:permTest:unequalGroups', ... + 'Unequal group sizes (%d vs %d). Truncating to %d paired subjects.', ... + nSubA, nSubB, nSub); + end + + % Determine permutation mode + requestedExact = ischar(opts.NumPerm) && strcmpi(opts.NumPerm, 'exact'); + if requestedExact + useExact = true; + nPerms = 2^nSub; + elseif 2^nSub <= opts.NumPerm + useExact = true; + nPerms = 2^nSub; + else + useExact = false; + if isnumeric(opts.NumPerm) + nPerms = opts.NumPerm; + else + nPerms = 5000; + end + end + + % Initialize results + results = struct(); + results.observed = nan(nBioM, nCh); + results.nullDist = cell(nBioM, nCh); + results.pvalue = nan(nBioM, nCh); + results.pvalueFDR = nan(nBioM, nCh); + results.significant = false(nBioM, nCh); + results.effectSize = nan(nBioM, nCh); + results.nPerms = nPerms; + results.isExact = useExact; + results.statistic = opts.Statistic; + results.tail = opts.Tail; + results.biomarkers = opts.Biomarkers; + results.channels = channels; + results.conditions = {groups(1).label, groups(2).label}; + results.nSubjects = nSub; + results.dataType = opts.DataType; + results.labels = labels; + + rng(opts.Seed); + + for bIdx = 1:nBioM + bioM = opts.Biomarkers{bIdx}; + + for chI = 1:nCh + ch = channels(chI); + + % Extract per-subject means from gbyGrandBarFlat + % Data is [time x channels/ROIs x subjects] + if isROIMode + dataA = groups(1).gbyGrandBarFlat.ROI.(bioM).data(tMask, ch, :); + dataB = groups(2).gbyGrandBarFlat.ROI.(bioM).data(tMask, ch, :); + else + dataA = groups(1).gbyGrandBarFlat.(bioM).data(tMask, ch, :); + dataB = groups(2).gbyGrandBarFlat.(bioM).data(tMask, ch, :); + end + + % Average across time -> [1 x 1 x nSub] -> [nSub x 1] + meansA = squeeze(mean(dataA, 1, 'omitnan')); + meansB = squeeze(mean(dataB, 1, 'omitnan')); + meansA = meansA(:); + meansB = meansB(:); + + % Truncate to common length (paired) + n = min(length(meansA), length(meansB)); + meansA = meansA(1:n); + meansB = meansB(1:n); + + % Paired differences + diffs = meansA - meansB; + + % Remove NaN pairs + valid = ~isnan(diffs); + diffs = diffs(valid); + n = length(diffs); + + if n < 2, continue; end + + % Observed test statistic + obsT = computeStat(diffs, opts.Statistic); + results.observed(bIdx, chI) = obsT; + + % Hedges' g effect size for paired differences + meanDiff = mean(diffs); + sdDiff = std(diffs); + if sdDiff > 0 + d = meanDiff / sdDiff; + J = 1 - 3 / (4 * (n - 1) - 1); + results.effectSize(bIdx, chI) = d * J; + else + results.effectSize(bIdx, chI) = 0; + end + + % Generate null distribution via sign flips + nullDist = nan(nPerms, 1); + if useExact + actualPerms = 2^n; + for pIdx = 1:actualPerms + signs = 2 * de2bi(pIdx - 1, n) - 1; + nullDist(pIdx) = computeStat(diffs .* signs(:), opts.Statistic); + end + nullDist = nullDist(1:actualPerms); + else + for pIdx = 1:nPerms + signs = 2 * (rand(n, 1) > 0.5) - 1; + nullDist(pIdx) = computeStat(diffs .* signs, opts.Statistic); + end + end + + results.nullDist{bIdx, chI} = nullDist; + + % P-value (Phipson & Smyth 2010) + switch lower(opts.Tail) + case 'both' + count = sum(abs(nullDist) >= abs(obsT)); + case 'right' + count = sum(nullDist >= obsT); + case 'left' + count = sum(nullDist <= obsT); + end + results.pvalue(bIdx, chI) = (count + 1) / (length(nullDist) + 1); + end + + % FDR correction across channels + pVals = results.pvalue(bIdx, :); + if ~all(isnan(pVals)) + switch lower(opts.FDRMethod) + case 'bh' + [qVals, ~, sig] = exploreFNIRS.fx.performFDR( ... + pVals, opts.FDRThreshold); + case 'twostep' + [qVals, ~, sig] = exploreFNIRS.fx.performFDR_twostep( ... + pVals, opts.FDRThreshold); + otherwise + [qVals, ~, sig] = exploreFNIRS.fx.performFDR( ... + pVals, opts.FDRThreshold); + end + results.pvalueFDR(bIdx, :) = qVals; + results.significant(bIdx, :) = sig; + end + + if opts.Verbose + nSig = sum(results.significant(bIdx, :)); + nValid = sum(~isnan(results.pvalue(bIdx, :))); + unitLabel = 'channels'; + if isROIMode, unitLabel = 'ROIs'; end + fprintf('PermTest [%s]: %d/%d %s significant (FDR < %.2f)\n', ... + bioM, nSig, nValid, unitLabel, opts.FDRThreshold); + end + end +end + + +function stat = computeStat(diffs, statType) +% COMPUTESTAT Compute test statistic from paired differences + + switch lower(statType) + case 'mean_diff' + stat = mean(diffs); + case 'tstat' + n = length(diffs); + stat = mean(diffs) / (std(diffs) / sqrt(n)); + if isnan(stat), stat = 0; end + otherwise + error('exploreFNIRS:stats:permTest:invalidStatistic', ... + 'Unknown statistic type: ''%s''. Use ''mean_diff'' or ''tstat''.', statType); + end +end + + +function bits = de2bi(n, nBits) +% DE2BI Convert decimal to binary vector (no Communications Toolbox needed) + + bits = zeros(1, nBits); + for i = 1:nBits + bits(i) = mod(n, 2); + n = floor(n / 2); + end +end + + +function ssIdx = getShortSeparationIdx(groups) +% GETSHORTSEPARATIONIDX Get indices of short separation channels from probe info + + ssIdx = []; + + if isempty(groups) || isempty(groups(1).gbyFNIRS) + return; + end + + fNIR = groups(1).gbyFNIRS{1}; + + probeInfo = []; + if isfield(fNIR, 'probeinfo') && isfield(fNIR.probeinfo, 'Probe') ... + && iscell(fNIR.probeinfo.Probe) && ~isempty(fNIR.probeinfo.Probe) + probeInfo = fNIR.probeinfo.Probe{1}; + elseif isfield(fNIR, 'info') && isfield(fNIR.info, 'probename') ... + && ~isempty(fNIR.info.probename) && ~contains(fNIR.info.probename, 'Unknown') + try + device = pf2_base.loadDeviceCfg(fNIR.info.probename); + if isstruct(device) && isfield(device, 'Probe') ... + && iscell(device.Probe) && ~isempty(device.Probe) + probeInfo = device.Probe{1}; + end + catch + return; + end + end + + if isempty(probeInfo) + return; + end + + if isfield(probeInfo, 'TableOpt') && istable(probeInfo.TableOpt) ... + && ismember('IsShortSeparation', probeInfo.TableOpt.Properties.VariableNames) + ssIdx = find(probeInfo.TableOpt.IsShortSeparation(:)'); + elseif isfield(probeInfo, 'NumShortSeparation') && probeInfo.NumShortSeparation > 0 ... + && isfield(probeInfo, 'TableOpt') && istable(probeInfo.TableOpt) ... + && ismember('SD', probeInfo.TableOpt.Properties.VariableNames) + ssIdx = find(probeInfo.TableOpt.SD(:)' < 2); + end +end diff --git a/+exploreFNIRS/+stats/runContrasts.m b/+exploreFNIRS/+stats/runContrasts.m new file mode 100644 index 00000000..443a7697 --- /dev/null +++ b/+exploreFNIRS/+stats/runContrasts.m @@ -0,0 +1,320 @@ +function contrastResults = runContrasts(lmeResults, varargin) +% RUNCONTRASTS Post-hoc contrasts across channels with FDR correction +% +% Takes output from exploreFNIRS.stats.fitLME and generates post-hoc +% contrast tables per channel, then applies FDR correction across channels +% for each unique contrast name. +% +% Supports both automatic pairwise contrasts (default) and user-specified +% custom contrast matrices for planned comparisons. +% +% Syntax: +% contrastResults = exploreFNIRS.stats.runContrasts(lmeResults) +% contrastResults = exploreFNIRS.stats.runContrasts(lmeResults, 'FDRThreshold', 0.05) +% contrastResults = exploreFNIRS.stats.runContrasts(lmeResults, 'FDRMethod', 'twostep') +% contrastResults = exploreFNIRS.stats.runContrasts(lmeResults, 'Contrasts', spec) +% +% Inputs: +% lmeResults - Struct from exploreFNIRS.stats.fitLME containing .models +% +% Name-Value Parameters: +% PThreshold - ANOVA p-value threshold for eligible contrasts (default: 0.1) +% Only used when Contrasts='auto'. +% FDRThreshold - FDR significance threshold (default: 0.05) +% FDRMethod - 'bh' (Benjamini-Hochberg, default) or 'twostep' (adaptive) +% Contrasts - 'auto' (default) for automatic pairwise contrasts, or a +% struct with fields: +% .matrix - [nContrasts x nCoefficients] contrast matrix +% .labels - Cell array of contrast names +% Each row of .matrix is a contrast vector tested via coefTest. +% +% Outputs: +% contrastResults - Struct with fields: +% .contrasts - Cell array of contrast tables [nBio x nCh] +% .contrastNames - Cell array of unique contrast names across channels +% .pvalueMatrix - [nContrasts x nBio x nCh] uncorrected p-values +% .qvalueMatrix - [nContrasts x nBio x nCh] FDR-corrected q-values +% .significantMatrix - [nContrasts x nBio x nCh] logical significance +% .effectSizeMatrix - [nContrasts x nBio x nCh] delta estimates +% .fdrThreshold - Threshold used +% .fdrMethod - Method used +% .biomarkers - Biomarker names +% .channels - Channel indices +% +% Example: +% results = exploreFNIRS.stats.fitLME(groups, {'Group','Condition'}); +% +% % Auto contrasts (default) +% cr = exploreFNIRS.stats.runContrasts(results); +% +% % Custom planned comparisons +% spec.matrix = [-1 0 1; -1 2 -1]; +% spec.labels = {'Linear', 'Quadratic'}; +% cr = exploreFNIRS.stats.runContrasts(results, 'Contrasts', spec); +% +% % Generate standard contrast types +% spec = exploreFNIRS.stats.buildContrasts(results.models{1,1}, 'polynomial'); +% cr = exploreFNIRS.stats.runContrasts(results, 'Contrasts', spec); +% +% References: +% Benjamini, Y. & Hochberg, Y. (1995). Controlling the false discovery +% rate: a practical and powerful approach to multiple testing. Journal of +% the Royal Statistical Society, Series B, 57(1), 289-300. +% +% Benjamini, Y., Krieger, A. M. & Yekutieli, D. (2006). Adaptive linear +% step-up procedures that control the false discovery rate. Biometrika, +% 93(3), 491-507. DOI: 10.1093/biomet/93.3.491 +% +% Searle, S. R., Speed, F. M. & Milliken, G. A. (1980). Population +% marginal means in the linear model: An alternative to least squares +% means. The American Statistician, 34(4), 216-221. +% +% See also: exploreFNIRS.stats.fitLME, exploreFNIRS.stats.buildContrasts, +% exploreFNIRS.fx.autoContrast, exploreFNIRS.fx.performFDR + + p = inputParser; + addRequired(p, 'lmeResults', @isstruct); + addParameter(p, 'PThreshold', 0.1, @isnumeric); + addParameter(p, 'FDRThreshold', 0.05, @isnumeric); + addParameter(p, 'FDRMethod', 'bh', @ischar); + addParameter(p, 'Contrasts', 'auto', @(x) ischar(x) || isstruct(x)); + parse(p, lmeResults, varargin{:}); + opts = p.Results; + + % Validate FDR method early + validMethods = {'bh', 'twostep'}; + if ~ismember(lower(opts.FDRMethod), validMethods) + error('exploreFNIRS:stats:runContrasts', ... + 'Unknown FDR method: ''%s''. Use ''bh'' or ''twostep''.', opts.FDRMethod); + end + + % Determine contrast mode + useCustom = isstruct(opts.Contrasts); + + if useCustom + contrastResults = runCustomContrasts(lmeResults, opts); + else + contrastResults = runAutoContrasts(lmeResults, opts); + end +end + + +function contrastResults = runAutoContrasts(lmeResults, opts) +% RUNAUTOCONTRASTS Original autoContrast-based pipeline + + [nBioM, nCh] = size(lmeResults.models); + + contrastResults = struct(); + contrastResults.contrasts = cell(nBioM, nCh); + contrastResults.biomarkers = lmeResults.biomarkers; + contrastResults.channels = lmeResults.channels; + + % First pass: run autoContrast per model and collect all unique names + allContrastNames = {}; + + for bIdx = 1:nBioM + for chI = 1:nCh + mdl = lmeResults.models{bIdx, chI}; + if isempty(mdl), continue; end + + try + cTable = exploreFNIRS.fx.autoContrast(mdl, opts.PThreshold); + contrastResults.contrasts{bIdx, chI} = cTable; + + if ~isempty(cTable) && height(cTable) > 0 + names = cTable.Properties.RowNames; + allContrastNames = union(allContrastNames, names, 'stable'); + end + catch + contrastResults.contrasts{bIdx, chI} = table(); + end + end + end + + contrastResults.contrastNames = allContrastNames; + nContrasts = length(allContrastNames); + + if nContrasts == 0 + contrastResults.pvalueMatrix = []; + contrastResults.qvalueMatrix = []; + contrastResults.significantMatrix = []; + contrastResults.effectSizeMatrix = []; + contrastResults.fdrThreshold = opts.FDRThreshold; + contrastResults.fdrMethod = opts.FDRMethod; + return; + end + + % Build p-value and effect size matrices + pMatrix = nan(nContrasts, nBioM, nCh); + eMatrix = nan(nContrasts, nBioM, nCh); + + for bIdx = 1:nBioM + for chI = 1:nCh + cTable = contrastResults.contrasts{bIdx, chI}; + if isempty(cTable) || height(cTable) == 0, continue; end + + for c = 1:nContrasts + if ismember(allContrastNames{c}, cTable.Properties.RowNames) + pMatrix(c, bIdx, chI) = cTable{allContrastNames{c}, 'pVal'}; + eMatrix(c, bIdx, chI) = cTable{allContrastNames{c}, 'deltaE'}; + end + end + end + end + + contrastResults.pvalueMatrix = pMatrix; + contrastResults.effectSizeMatrix = eMatrix; + + % FDR correction + [contrastResults.qvalueMatrix, contrastResults.significantMatrix] = ... + applyFDR(pMatrix, nContrasts, nBioM, opts); + contrastResults.fdrThreshold = opts.FDRThreshold; + contrastResults.fdrMethod = opts.FDRMethod; +end + + +function contrastResults = runCustomContrasts(lmeResults, opts) +% RUNCUSTOMCONTRASTS User-specified contrast matrix pipeline + + spec = opts.Contrasts; + + % Validate contrast spec + if ~isfield(spec, 'matrix') || ~isfield(spec, 'labels') + error('exploreFNIRS:stats:runContrasts:invalidSpec', ... + 'Contrasts struct must have .matrix and .labels fields.'); + end + if size(spec.matrix, 1) ~= length(spec.labels) + error('exploreFNIRS:stats:runContrasts:sizeMismatch', ... + 'Number of rows in .matrix (%d) must match length of .labels (%d).', ... + size(spec.matrix, 1), length(spec.labels)); + end + + [nBioM, nCh] = size(lmeResults.models); + nContrasts = size(spec.matrix, 1); + + contrastResults = struct(); + contrastResults.contrasts = cell(nBioM, nCh); + contrastResults.biomarkers = lmeResults.biomarkers; + contrastResults.channels = lmeResults.channels; + contrastResults.contrastNames = spec.labels(:)'; + + pMatrix = nan(nContrasts, nBioM, nCh); + eMatrix = nan(nContrasts, nBioM, nCh); + + for bIdx = 1:nBioM + for chI = 1:nCh + mdl = lmeResults.models{bIdx, chI}; + if isempty(mdl), continue; end + + cTable = customContrast(mdl, spec); + contrastResults.contrasts{bIdx, chI} = cTable; + + if ~isempty(cTable) && height(cTable) > 0 + pMatrix(:, bIdx, chI) = cTable.pVal; + eMatrix(:, bIdx, chI) = cTable.deltaE; + end + end + end + + contrastResults.pvalueMatrix = pMatrix; + contrastResults.effectSizeMatrix = eMatrix; + + % FDR correction + [contrastResults.qvalueMatrix, contrastResults.significantMatrix] = ... + applyFDR(pMatrix, nContrasts, nBioM, opts); + contrastResults.fdrThreshold = opts.FDRThreshold; + contrastResults.fdrMethod = opts.FDRMethod; +end + + +function cTable = customContrast(mdl, spec) +% CUSTOMCONTRAST Run user-specified contrasts against an LME model +% +% Validates matrix dimensions, runs coefTest per row, computes effect sizes. + + nCoefs = length(mdl.CoefficientNames); + nContrasts = size(spec.matrix, 1); + + if size(spec.matrix, 2) ~= nCoefs + error('exploreFNIRS:stats:runContrasts:coefMismatch', ... + 'Contrast matrix has %d columns but model has %d coefficients (%s).', ... + size(spec.matrix, 2), nCoefs, strjoin(mdl.CoefficientNames, ', ')); + end + + [~, ~, mdlCoef] = fixedEffects(mdl, 'DFMethod', 'satterthwaite'); + + deltaE = nan(nContrasts, 1); + SD = nan(nContrasts, 1); + F = nan(nContrasts, 1); + df1 = nan(nContrasts, 1); + df2 = nan(nContrasts, 1); + pVal = nan(nContrasts, 1); + sig = strings(nContrasts, 1); + + for c = 1:nContrasts + cRow = spec.matrix(c, :); + + [pVal(c), F(c), df1(c), df2(c)] = coefTest(mdl, cRow, 0, ... + 'DFMethod', 'satterthwaite'); + + % Effect size: contrast vector dot product with coefficients + deltaE(c) = cRow * mdlCoef.Estimate; + + % SE of the contrast estimate: sqrt(L * CovBeta * L') + covBeta = mdl.CoefficientCovariance; + SD(c) = sqrt(cRow * covBeta * cRow'); + + % Significance stars + if pVal(c) < 0.001 + sig(c) = " *** "; + elseif pVal(c) < 0.01 + sig(c) = " ** "; + elseif pVal(c) < 0.05 + sig(c) = " * "; + elseif pVal(c) < 0.1 + sig(c) = " + "; + else + sig(c) = " "; + end + end + + % Bonferroni correction within custom contrast set + pVal_corr = min(pVal * nContrasts, 1); + + cTable = table(deltaE, SD, F, df1, df2, pVal, pVal_corr, sig, ... + repmat({nan(1, nCoefs)}, nContrasts, 1), ... + 'VariableNames', {'deltaE','SD','F','df1','df2','pVal','pVal_corr','sig','coefContrasts'}, ... + 'RowNames', spec.labels(:)); + + % Store actual contrast rows + for c = 1:nContrasts + cTable.coefContrasts{c} = spec.matrix(c, :); + end +end + + +function [qMatrix, sigMatrix] = applyFDR(pMatrix, nContrasts, nBioM, opts) +% APPLYFDR FDR correction across channels for each contrast x biomarker + + qMatrix = nan(size(pMatrix)); + sigMatrix = false(size(pMatrix)); + + for c = 1:nContrasts + for bIdx = 1:nBioM + pVals = squeeze(pMatrix(c, bIdx, :))'; + if all(isnan(pVals)), continue; end + + switch lower(opts.FDRMethod) + case 'bh' + [qVals, ~, sig] = exploreFNIRS.fx.performFDR( ... + pVals, opts.FDRThreshold); + case 'twostep' + [qVals, ~, sig] = exploreFNIRS.fx.performFDR_twostep( ... + pVals, opts.FDRThreshold); + end + + qMatrix(c, bIdx, :) = qVals; + sigMatrix(c, bIdx, :) = sig; + end + end +end diff --git a/+exploreFNIRS/+stats/summarize.m b/+exploreFNIRS/+stats/summarize.m new file mode 100644 index 00000000..dffa54f2 --- /dev/null +++ b/+exploreFNIRS/+stats/summarize.m @@ -0,0 +1,1043 @@ +function T = summarize(lmeResults, varargin) +% SUMMARIZE Publication-ready summary tables from LME results +% +% Formats ANOVA results, contrast tests, fixed-effect coefficients, or +% model fit statistics from exploreFNIRS.stats.fitLME into a single clean +% table. Optionally generates APA-style formatted strings. +% +% Syntax: +% T = exploreFNIRS.stats.summarize(results) +% T = exploreFNIRS.stats.summarize(results, 'Type', 'anova') +% T = exploreFNIRS.stats.summarize(results, 'Type', 'contrasts', 'Format', 'apa') +% T = exploreFNIRS.stats.summarize(results, 'Type', 'fit') +% T = exploreFNIRS.stats.summarize(corrStats, 'Type', 'correlations') +% +% Inputs: +% lmeResults - Struct from exploreFNIRS.stats.fitLME +% +% Name-Value Parameters: +% Type - Summary type (default: 'anova'): +% 'anova' - ANOVA F-tests per channel and term +% 'contrasts' - Post-hoc contrast tests +% 'coefficients' - Fixed-effect coefficient estimates +% 'fit' - Model fit statistics (AIC, BIC, LRT) +% 'correlations' - Scatter/topo correlation results +% 'effectsize' - Effect sizes with bootstrap CIs +% Format - Output format (default: 'table'): +% 'table' - Standard MATLAB table +% 'console' - Prints clean formatted text to console +% 'latex' - Prints LaTeX tabular environment to console +% 'apa' - Adds APA-style formatted string column +% SigThreshold - Significance threshold for stars (default: 0.05) +% OnlySignificant - Filter to rows with non-empty Sig (default: false) +% IncludeFDR - Apply FDR correction to ANOVA p-values (default: false) +% Biomarkers - Cell array of biomarker names (for 'correlations') +% Channels - Numeric array of channel numbers (for 'correlations') +% Groups - Cell array of group labels (for 'correlations') +% CorrType - 'Pearson' (default) or 'Spearman' (for 'correlations') +% InfoVar - Name of the info variable (for 'correlations') +% +% Outputs: +% T - Table with formatted results. Columns depend on Type: +% +% 'anova': Optode, Biomarker, Term, FStat, df1, df2, pValue, Sig +% 'contrasts': Biomarker, Optode, Contrast, DeltaE, SD, F, df1, df2, +% pValue, pCorrected, Sig +% 'coefficients': Biomarker, Optode, Name, Estimate, SE, tStat, DF, +% pValue, Sig +% 'fit': Biomarker, Optode, AIC, BIC, LogLik, NullChi2, +% NullPval, Formula +% 'correlations': Optode, N, r, p_pearson, rho, p_spearman, Sig +% (Group and Biomarker columns included when multiple) +% 'effectsize': Optode, Biomarker, g, CI_lower, CI_upper, pValue, Sig +% (pValue included when effectSize results contain .p) +% +% When Format='apa', an additional 'APA' column is appended with +% formatted strings like "F(1, 23.4) = 5.67, p = .012". +% +% Example: +% results = exploreFNIRS.stats.fitLME(groups, {'Group','Condition'}, ... +% 'Channels', 1:5); +% +% % ANOVA summary +% T = exploreFNIRS.stats.summarize(results); +% disp(T); +% +% % APA-formatted contrasts +% T = exploreFNIRS.stats.summarize(results, 'Type', 'contrasts', ... +% 'Format', 'apa'); +% disp(T.APA); +% +% % Model fit comparison +% T = exploreFNIRS.stats.summarize(results, 'Type', 'fit'); +% writetable(T, 'model_fit.csv'); +% +% See also: exploreFNIRS.stats.fitLME, exploreFNIRS.stats.runContrasts + + p = inputParser; + addRequired(p, 'lmeResults', @isstruct); + addParameter(p, 'Type', 'anova', @ischar); + addParameter(p, 'Format', 'table', @ischar); + addParameter(p, 'SigThreshold', 0.05, @isnumeric); + addParameter(p, 'OnlySignificant', false, @islogical); + addParameter(p, 'IncludeFDR', false, @islogical); + % Metadata for correlation stats (optional, used with Type='correlations') + addParameter(p, 'Biomarkers', {}, @iscell); + addParameter(p, 'Channels', [], @isnumeric); + addParameter(p, 'Groups', {}, @iscell); + addParameter(p, 'CorrType', 'Pearson', @ischar); + addParameter(p, 'InfoVar', '', @ischar); + parse(p, lmeResults, varargin{:}); + opts = p.Results; + + % Extract term labels from results if available (polynomial time) + termLabels = struct(); + if isfield(lmeResults, 'termLabels') && isstruct(lmeResults.termLabels) + termLabels = lmeResults.termLabels; + end + + switch lower(opts.Type) + case 'anova' + T = summarizeAnova(lmeResults, opts, termLabels); + case 'contrasts' + T = summarizeContrasts(lmeResults, opts); + case 'coefficients' + T = summarizeCoefficients(lmeResults, opts, termLabels); + case 'fit' + T = summarizeFit(lmeResults, opts); + case 'correlations' + T = summarizeCorrelations(lmeResults, opts); + case 'effectsize' + T = summarizeEffectSize(lmeResults, opts); + otherwise + error('exploreFNIRS:stats:summarize', ... + 'Unknown Type: ''%s''. Use ''anova'', ''contrasts'', ''coefficients'', ''fit'', ''correlations'', or ''effectsize''.', ... + opts.Type); + end + + % Clean up Optode/Biomarker columns for readability + if ~isempty(T) + if ismember('Optode', T.Properties.VariableNames) + vals = T.Optode; + if (isstring(vals) || iscell(vals)) + uVals = unique(string(vals)); + % Check if values look like fNIRS optode identifiers (Opt1, Opt1_HbO, etc.) + isGenericOpt = all(~ismissing(uVals) & ... + ~cellfun('isempty', regexp(cellstr(uVals), '^Opt\d+'))); + if isGenericOpt + if numel(uVals) <= 1 + % Single optode — uninformative, drop + T.Optode = []; + else + % Multiple optodes — keep as Optode, deduplicate + T = deduplicateColumn(T, 'Optode'); + end + else + % Non-optode values (variable names) — rename and deduplicate + T = renamevars(T, 'Optode', 'Variable'); + T = deduplicateColumn(T, 'Variable'); + end + elseif isnumeric(vals) && numel(unique(vals)) <= 1 + T.Optode = []; + end + end + if ismember('Biomarker', T.Properties.VariableNames) + vals = T.Biomarker; + if (isstring(vals) || iscell(vals)) + uVals = unique(string(vals)); + uVals = uVals(~ismissing(uVals) & uVals ~= ""); + if numel(uVals) <= 1 + % Single-valued or empty — always uninformative, drop + T.Biomarker = []; + else + T = deduplicateColumn(T, 'Biomarker'); + end + elseif isnumeric(vals) && numel(unique(vals)) <= 1 + T.Biomarker = []; + end + end + end + + % Filter to significant rows only + if opts.OnlySignificant && ~isempty(T) && ismember('Sig', T.Properties.VariableNames) + keep = T.Sig ~= "" & ~ismissing(T.Sig); + T = T(keep, :); + end + + % Console format: print formatted text and return the table + if strcmpi(opts.Format, 'console') && ~isempty(T) + printFormattedTable(T); + end + + % LaTeX format: print tabular environment + if strcmpi(opts.Format, 'latex') && ~isempty(T) + printLatexTable(T, opts.Type); + end +end + + +%% Summary generators + +function T = summarizeAnova(results, opts, termLabels) +% Build a tidy long-format ANOVA summary table + + if isempty(results.anova_pval) || height(results.anova_pval) == 0 + T = table(); + return; + end + + termNames = results.anova_pval.Properties.VariableNames; + rowNames = results.anova_pval.Properties.RowNames; + nRows = length(rowNames); + nTerms = length(termNames); + + % Pre-allocate + nTotal = nRows * nTerms; + Optode = cell(nTotal, 1); + Biomarker = cell(nTotal, 1); + Term = cell(nTotal, 1); + FStat = nan(nTotal, 1); + df1 = nan(nTotal, 1); + df2 = nan(nTotal, 1); + pValue = nan(nTotal, 1); + Sig = cell(nTotal, 1); + + idx = 0; + for r = 1:nRows + for t = 1:nTerms + idx = idx + 1; + Optode{idx} = rowNames{r}; + Term{idx} = applyTermLabel(termNames{t}, termLabels); + FStat(idx) = results.anova_Fstat{r, t}; + pValue(idx) = results.anova_pval{r, t}; + + if ~isempty(results.anova_df1) && height(results.anova_df1) >= r + df1(idx) = results.anova_df1{r, t}; + df2(idx) = results.anova_df2{r, t}; + end + + % Parse biomarker from row name (format: Opt_) + parts = strsplit(rowNames{r}, '_'); + if length(parts) >= 2 + Biomarker{idx} = parts{end}; + else + Biomarker{idx} = ''; + end + + Sig{idx} = sigStars(pValue(idx), opts.SigThreshold); + end + end + + T = table(string(Optode), string(Biomarker), string(Term), ... + FStat, df1, df2, pValue, string(Sig), ... + 'VariableNames', {'Optode','Biomarker','Term','FStat','df1','df2','pValue','Sig'}); + + % FDR correction across all ANOVA p-values + if opts.IncludeFDR + [qvals, ~, passed] = exploreFNIRS.fx.performFDR(pValue, opts.SigThreshold); + T.qValue = qvals; + T.FDR_Sig = passed; + end + + % APA format column + if strcmpi(opts.Format, 'apa') + apaStr = strings(height(T), 1); + for i = 1:height(T) + if isnan(df2(i)) + apaStr(i) = sprintf('F = %.2f, p %s', ... + FStat(i), formatP(pValue(i))); + else + apaStr(i) = sprintf('F(%d, %.1f) = %.2f, p %s', ... + round(df1(i)), df2(i), FStat(i), formatP(pValue(i))); + end + end + T.APA = apaStr; + end +end + + +function T = summarizeContrasts(results, opts) +% Build a tidy contrast summary table + + [nBioM, nCh] = size(results.contrasts); + + allRows = {}; + + for bIdx = 1:nBioM + for chI = 1:nCh + cTable = results.contrasts{bIdx, chI}; + if isempty(cTable) || height(cTable) == 0, continue; end + + bioM = results.biomarkers{bIdx}; + ch = results.channels(chI); + + for r = 1:height(cTable) + row = struct(); + row.Biomarker = bioM; + row.Optode = ch; + row.Contrast = cTable.Properties.RowNames{r}; + row.DeltaE = cTable.deltaE(r); + row.SD = cTable.SD(r); + row.F = cTable.F(r); + row.df1 = cTable.df1(r); + row.df2 = cTable.df2(r); + row.pValue = cTable.pVal(r); + row.pCorrected = cTable.pVal_corr(r); + row.Sig = strtrim(char(cTable.sig(r))); + allRows{end+1} = row; %#ok + end + end + end + + if isempty(allRows) + T = table(); + return; + end + + T = struct2table([allRows{:}]); + T = cellColumnsToString(T); + + if strcmpi(opts.Format, 'apa') + apaStr = strings(height(T), 1); + for i = 1:height(T) + apaStr(i) = sprintf('%s: delta = %.3f, F(%d, %.1f) = %.2f, p %s', ... + T.Contrast(i), T.DeltaE(i), ... + round(T.df1(i)), T.df2(i), T.F(i), ... + formatP(T.pValue(i))); + end + T.APA = apaStr; + end +end + + +function T = summarizeCoefficients(results, opts, termLabels) +% Build a tidy fixed-effect coefficient table + + [nBioM, nCh] = size(results.models); + + allRows = {}; + + for bIdx = 1:nBioM + for chI = 1:nCh + mdl = results.models{bIdx, chI}; + if isempty(mdl), continue; end + + bioM = results.biomarkers{bIdx}; + ch = results.channels(chI); + coefs = mdl.Coefficients; + + for r = 1:height(coefs) + row = struct(); + row.Biomarker = bioM; + row.Optode = ch; + row.Name = applyTermLabel(coefs.Name{r}, termLabels); + row.Estimate = coefs.Estimate(r); + row.SE = coefs.SE(r); + row.tStat = coefs.tStat(r); + row.DF = coefs.DF(r); + row.pValue = coefs.pValue(r); + row.Sig = sigStars(coefs.pValue(r), opts.SigThreshold); + allRows{end+1} = row; %#ok + end + end + end + + if isempty(allRows) + T = table(); + return; + end + + T = struct2table([allRows{:}]); + T = cellColumnsToString(T); +end + + +function T = summarizeFit(results, opts) %#ok +% Build a model fit statistics table + + [nBioM, nCh] = size(results.models); + + Biomarker = {}; + Optode = []; + AIC = []; + BIC = []; + LogLik = []; + NullPval = []; + NullChi2 = []; + Formula = {}; + + for bIdx = 1:nBioM + for chI = 1:nCh + mdl = results.models{bIdx, chI}; + if isempty(mdl), continue; end + + Biomarker{end+1, 1} = results.biomarkers{bIdx}; %#ok + Optode(end+1, 1) = results.channels(chI); %#ok + AIC(end+1, 1) = mdl.ModelCriterion.AIC; %#ok + BIC(end+1, 1) = mdl.ModelCriterion.BIC; %#ok + LogLik(end+1, 1) = mdl.LogLikelihood; %#ok + Formula{end+1, 1} = char(mdl.Formula); %#ok + + nc = results.nullComparison{bIdx, chI}; + if ~isempty(nc) + NullPval(end+1, 1) = nc.pValue(end); %#ok + NullChi2(end+1, 1) = nc.LRStat(end); %#ok + else + NullPval(end+1, 1) = NaN; %#ok + NullChi2(end+1, 1) = NaN; %#ok + end + end + end + + if isempty(Biomarker) + T = table(); + return; + end + + T = table(string(Biomarker), Optode, AIC, BIC, LogLik, NullChi2, NullPval, string(Formula), ... + 'VariableNames', {'Biomarker','Optode','AIC','BIC','LogLik','NullChi2','NullPval','Formula'}); +end + + +function T = summarizeEffectSize(results, opts) +% Build a tidy effect size summary table from effectSize results + + if ~isfield(results, 'observed') || ~isfield(results, 'ci_lower') + error('exploreFNIRS:stats:summarize:invalidEffectSize', ... + 'Input does not look like effectSize results. Expected .observed, .ci_lower, .ci_upper fields.'); + end + + [nBioM, nCh] = size(results.observed); + bioNames = results.biomarkers; + channels = results.channels; + + % Method display name + switch lower(results.method) + case 'hedges_g', methodStr = 'g'; methodFull = 'Hedges'' g'; + case 'cohens_d', methodStr = 'd'; methodFull = 'Cohen''s d'; + case 'glass_delta', methodStr = 'delta'; methodFull = 'Glass''s delta'; + otherwise, methodStr = 'ES'; methodFull = results.method; + end + + allRows = {}; + for bIdx = 1:nBioM + for chI = 1:nCh + g = results.observed(bIdx, chI); + if isnan(g), continue; end + + row = struct(); + row.Optode = channels(chI); + if nBioM > 1 + row.Biomarker = bioNames{bIdx}; + end + row.g = g; + row.CI_lower = results.ci_lower(bIdx, chI); + row.CI_upper = results.ci_upper(bIdx, chI); + + % Raw p-value from parametric t-test (if available) + if isfield(results, 'p') + row.pValue = results.p(bIdx, chI); + end + + % Significance: CI excludes zero + lo = results.ci_lower(bIdx, chI); + hi = results.ci_upper(bIdx, chI); + if lo > 0 || hi < 0 + row.Sig = '*'; + else + row.Sig = ''; + end + + allRows{end+1} = row; %#ok + end + end + + if isempty(allRows) + T = table(); + return; + end + + T = struct2table([allRows{:}]); + T = cellColumnsToString(T); + + % Rename 'g' column to match the method + if ismember('g', T.Properties.VariableNames) && ~strcmp(methodStr, 'g') + T.Properties.VariableNames{strcmp(T.Properties.VariableNames, 'g')} = methodStr; + end + + % FDR correction on raw p-values + if opts.IncludeFDR && ismember('pValue', T.Properties.VariableNames) + [qvals, ~, passed] = exploreFNIRS.fx.performFDR(T.pValue, opts.SigThreshold); + T.pCorrected = qvals; + T.FDR_Sig = passed; + end + + % Store method info for latex/console output + T.Properties.UserData = struct('methodStr', methodStr, 'methodFull', methodFull, ... + 'ciLevel', results.ci_level, 'nBoot', results.nBoot, ... + 'conditions', {results.conditions}, 'nPerGroup', results.nPerGroup); + + if strcmpi(opts.Format, 'apa') + esCol = methodStr; + if ~ismember(esCol, T.Properties.VariableNames) + esCol = 'g'; + end + hasP = ismember('pValue', T.Properties.VariableNames); + apaStr = strings(height(T), 1); + for i = 1:height(T) + base = sprintf('%s = %.3f, %d%% CI [%.3f, %.3f]', ... + methodStr, T.(esCol)(i), ... + round(results.ci_level * 100), ... + T.CI_lower(i), T.CI_upper(i)); + if hasP + apaStr(i) = sprintf('%s, p %s', base, formatP(T.pValue(i))); + else + apaStr(i) = base; + end + end + T.APA = apaStr; + end +end + + +function T = summarizeCorrelations(stats, opts) +% Build a tidy correlation summary table from plotScatter stats output +% +% Handles two formats: +% 1. Struct array stats(nGroups, nBioM, nCh) — per-channel scatter +% 2. Struct array stats(nGroups, nBioM) with vector fields — topo mode + + isTopo = isfield(stats, 'r') && isvector(stats(1).r) && length(stats(1).r) > 1; + + if isTopo + T = summarizeCorrelationsTopo(stats, opts); + else + T = summarizeCorrelationsPerChannel(stats, opts); + end +end + + +function T = summarizeCorrelationsPerChannel(stats, opts) +% Per-channel scatter stats: stats(nGroups, nBioM, nCh) + + sz = size(stats); + nGroups = sz(1); + nBioM = max(sz(2), 1); + nCh = max(1, prod(sz(3:end))); + + bioNames = opts.Biomarkers; + chNums = opts.Channels; + groupNames = opts.Groups; + + allRows = {}; + for g = 1:nGroups + for bIdx = 1:nBioM + for chI = 1:nCh + s = stats(g, bIdx, chI); + if isnan(s.r) && s.N == 0, continue; end + + row = struct(); + if ~isempty(groupNames) && g <= length(groupNames) + row.Group = groupNames{g}; + elseif nGroups > 1 + row.Group = sprintf('Group %d', g); + end + if ~isempty(bioNames) && bIdx <= length(bioNames) + row.Biomarker = bioNames{bIdx}; + elseif nBioM > 1 + row.Biomarker = sprintf('Bio %d', bIdx); + end + if ~isempty(chNums) && chI <= length(chNums) + row.Optode = chNums(chI); + else + row.Optode = chI; + end + row.N = s.N; + row.r = s.r; + row.p_pearson = s.p; + row.rho = s.rho; + row.p_spearman = s.pval; + row.Sig = sigStars(selectP(s, opts.CorrType), opts.SigThreshold); + allRows{end+1} = row; %#ok + end + end + end + + if isempty(allRows) + T = table(); + return; + end + + T = struct2table([allRows{:}]); + T = cellColumnsToString(T); + + % Remove single-valued columns + if nGroups == 1 && ismember('Group', T.Properties.VariableNames) + T.Group = []; + end + if nBioM == 1 && ismember('Biomarker', T.Properties.VariableNames) + T.Biomarker = []; + end + + if strcmpi(opts.Format, 'apa') + T.APA = buildCorrAPA(T, opts.CorrType); + end +end + + +function T = summarizeCorrelationsTopo(stats, opts) +% Topo correlation stats: stats(nGroups, nBioM) with vector fields + + sz = size(stats); + nGroups = sz(1); + nBioM = max(sz(2), 1); + + bioNames = opts.Biomarkers; + chNums = opts.Channels; + groupNames = opts.Groups; + + allRows = {}; + for g = 1:nGroups + for bIdx = 1:nBioM + s = stats(g, bIdx); + nCh = length(s.r); + + for chI = 1:nCh + if isnan(s.r(chI)), continue; end + + row = struct(); + if ~isempty(groupNames) && g <= length(groupNames) + row.Group = groupNames{g}; + elseif nGroups > 1 + row.Group = sprintf('Group %d', g); + end + if ~isempty(bioNames) && bIdx <= length(bioNames) + row.Biomarker = bioNames{bIdx}; + elseif nBioM > 1 + row.Biomarker = sprintf('Bio %d', bIdx); + end + if ~isempty(chNums) && chI <= length(chNums) + row.Optode = chNums(chI); + else + row.Optode = chI; + end + row.N = s.N(chI); + row.r = s.r(chI); + row.p_pearson = s.p(chI); + row.rho = s.rho(chI); + row.p_spearman = s.pval(chI); + if ~isempty(s.q) && chI <= length(s.q) + row.q = s.q(chI); + end + row.Sig = sigStars(selectPScalar(s, chI, opts.CorrType), ... + opts.SigThreshold); + allRows{end+1} = row; %#ok + end + end + end + + if isempty(allRows) + T = table(); + return; + end + + T = struct2table([allRows{:}]); + T = cellColumnsToString(T); + + % Remove single-valued columns + if nGroups == 1 && ismember('Group', T.Properties.VariableNames) + T.Group = []; + end + if nBioM == 1 && ismember('Biomarker', T.Properties.VariableNames) + T.Biomarker = []; + end + + if strcmpi(opts.Format, 'apa') + T.APA = buildCorrAPA(T, opts.CorrType); + end +end + + +function pVal = selectP(s, corrType) +% Select p-value based on correlation type + if strcmpi(corrType, 'Spearman') + pVal = s.pval; + else + pVal = s.p; + end +end + + +function pVal = selectPScalar(s, idx, corrType) +% Select p-value from vector fields + if strcmpi(corrType, 'Spearman') + pVal = s.pval(idx); + else + pVal = s.p(idx); + end +end + + +function apaStr = buildCorrAPA(T, corrType) +% Build APA-formatted correlation strings + apaStr = strings(height(T), 1); + for i = 1:height(T) + N = T.N(i); + df = N - 2; + if strcmpi(corrType, 'Spearman') + rVal = T.rho(i); + pVal = T.p_spearman(i); + sym = 'r_s'; + else + rVal = T.r(i); + pVal = T.p_pearson(i); + sym = 'r'; + end + apaStr(i) = sprintf('%s(%d) = %.3f, p %s', sym, df, rVal, formatP(pVal)); + end +end + + +%% Formatting helpers + +function s = sigStars(p, thresh) + if p < 0.001 + s = '***'; + elseif p < 0.01 + s = '**'; + elseif p < thresh + s = '*'; + elseif p < 0.1 + s = '+'; + else + s = ''; + end +end + + +function s = formatP(p) + if p < 0.001 + s = '< .001'; + else + s = sprintf('= %.3f', p); + end +end + + +function T = cellColumnsToString(T) +% Convert cell columns to string arrays for clean display + for v = 1:width(T) + if iscell(T{:, v}) + T.(T.Properties.VariableNames{v}) = string(T{:, v}); + end + end +end + + +function printFormattedTable(T) +% Print a table with clean formatting (no quotes, no braces) + names = T.Properties.VariableNames; + nCols = width(T); + nRows = height(T); + + % Format each column as strings + colStrs = cell(1, nCols); + for c = 1:nCols + col = T{:, c}; + if isstring(col) || iscell(col) + colStrs{c} = string(col); + elseif isnumeric(col) + strs = strings(nRows, 1); + for r = 1:nRows + if isnan(col(r)) + strs(r) = ""; + elseif col(r) == round(col(r)) && abs(col(r)) < 1e6 + strs(r) = sprintf('%d', col(r)); + elseif abs(col(r)) < 0.001 && col(r) ~= 0 + strs(r) = sprintf('%.1e', col(r)); + else + strs(r) = sprintf('%.4f', col(r)); + end + end + colStrs{c} = strs; + elseif islogical(col) + colStrs{c} = string(col); + else + colStrs{c} = string(col); + end + end + + % Compute column widths + colWidths = zeros(1, nCols); + for c = 1:nCols + colWidths(c) = max(strlength(names{c}), max(strlength(colStrs{c}))); + end + + % Print header + headerParts = strings(1, nCols); + divParts = strings(1, nCols); + for c = 1:nCols + w = colWidths(c); + headerParts(c) = pad(names{c}, w); + divParts(c) = repmat('-', 1, w); + end + fprintf(' %s\n', join(headerParts, ' ')); + fprintf(' %s\n', join(divParts, ' ')); + + % Print rows + for r = 1:nRows + parts = strings(1, nCols); + for c = 1:nCols + w = colWidths(c); + s = colStrs{c}(r); + % Right-align numbers, left-align text + col = T{r, c}; + if isnumeric(col) || islogical(col) + parts(c) = pad(s, w, 'left'); + else + parts(c) = pad(s, w); + end + end + fprintf(' %s\n', join(parts, ' ')); + end + fprintf('\n'); +end + + +function printLatexTable(T, tableType) +% Print a LaTeX tabular environment for the table + names = T.Properties.VariableNames; + nCols = width(T); + nRows = height(T); + + % Column alignment: l for text, r for numbers + alignStr = ''; + for c = 1:nCols + col = T{:, c}; + if isnumeric(col) || islogical(col) + alignStr = [alignStr, 'r']; %#ok + else + alignStr = [alignStr, 'l']; %#ok + end + end + + % Map nice header names per table type + headerNames = latexHeaders(names, tableType); + + fprintf('\\begin{table}[htbp]\n'); + fprintf('\\centering\n'); + fprintf('\\caption{%s}\n', latexCaption(tableType)); + fprintf('\\begin{tabular}{%s}\n', alignStr); + fprintf('\\toprule\n'); + + % Header row + fprintf('%s', headerNames{1}); + for c = 2:nCols + fprintf(' & %s', headerNames{c}); + end + fprintf(' \\\\\n'); + fprintf('\\midrule\n'); + + % Data rows + for r = 1:nRows + parts = strings(1, nCols); + for c = 1:nCols + col = T{r, c}; + if isnumeric(col) + if isnan(col) + parts(c) = ""; + elseif col == round(col) && abs(col) < 1e6 + parts(c) = sprintf('%d', col); + elseif abs(col) < 0.001 && col ~= 0 + parts(c) = sprintf('$<$ .001'); + else + parts(c) = sprintf('%.3f', col); + end + elseif islogical(col) + if col + parts(c) = "Yes"; + else + parts(c) = ""; + end + elseif isstring(col) || iscell(col) + s = string(col); + % Escape underscores and add italic for significance stars + s = strrep(s, '_', '\_'); + if s == "*" || s == "**" || s == "***" || s == "+" + parts(c) = sprintf('$%s$', s); + else + parts(c) = s; + end + else + parts(c) = string(col); + end + end + fprintf('%s', parts(1)); + for c = 2:nCols + fprintf(' & %s', parts(c)); + end + fprintf(' \\\\\n'); + end + + fprintf('\\bottomrule\n'); + fprintf('\\end{tabular}\n'); + + % Notes + notes = latexNotes(tableType); + if ~isempty(notes) + fprintf('\\par\\smallskip\\footnotesize\\textit{Note.} %s\n', notes); + end + + fprintf('\\end{table}\n'); +end + + +function h = latexHeaders(names, tableType) +% Map variable names to publication-style LaTeX headers + map = containers.Map('KeyType', 'char', 'ValueType', 'char'); + map('Optode') = 'Optode'; + map('Variable') = 'Variable'; + map('Biomarker') = 'Biomarker'; + map('Term') = 'Term'; + map('FStat') = '$F$'; + map('df1') = '$df_1$'; + map('df2') = '$df_2$'; + map('pValue') = '$p$'; + map('Sig') = ''; + map('qValue') = '$q$'; + map('FDR_Sig') = 'FDR'; + map('Contrast') = 'Contrast'; + map('DeltaE') = '$\Delta$'; + map('SD') = 'SE'; + map('F') = '$F$'; + map('pCorrected') = '$p_\mathrm{corr}$'; + map('Name') = 'Coefficient'; + map('Estimate') = '$\beta$'; + map('SE') = 'SE'; + map('tStat') = '$t$'; + map('DF') = '$df$'; + map('AIC') = 'AIC'; + map('BIC') = 'BIC'; + map('LogLik') = 'Log-Lik'; + map('NullChi2') = '$\chi^2$'; + map('NullPval') = '$p_\mathrm{null}$'; + map('Formula') = 'Formula'; + map('APA') = 'APA'; + map('r') = '$r$'; + map('rho') = '$\rho$'; + map('p_pearson') = '$p_\mathrm{Pearson}$'; + map('p_spearman') = '$p_\mathrm{Spearman}$'; + map('N') = '$N$'; + map('q') = '$q$'; + map('Group') = 'Group'; + map('g') = '$g$'; + map('d') = '$d$'; + map('delta') = '$\delta$'; + map('ES') = 'ES'; + map('CI_lower') = 'CI Lower'; + map('CI_upper') = 'CI Upper'; + + h = cell(1, length(names)); + for i = 1:length(names) + if map.isKey(names{i}) + h{i} = map(names{i}); + else + h{i} = strrep(names{i}, '_', '\_'); + end + end + + % Suppress unused arg warning + if isempty(tableType), return; end +end + + +function c = latexCaption(tableType) +% Default caption per table type + switch lower(tableType) + case 'anova' + c = 'ANOVA Results for Fixed Effects'; + case 'contrasts' + c = 'Post-Hoc Contrast Tests'; + case 'coefficients' + c = 'Fixed-Effect Coefficient Estimates'; + case 'fit' + c = 'Model Fit Statistics'; + case 'correlations' + c = 'Correlation Results'; + case 'effectsize' + c = 'Effect Sizes with Bootstrap Confidence Intervals'; + otherwise + c = 'Summary'; + end +end + + +function n = latexNotes(tableType) +% Footnote text per table type + switch lower(tableType) + case 'anova' + n = '$^{*}p < .05$, $^{**}p < .01$, $^{***}p < .001$, $^{+}p < .10$.'; + case 'contrasts' + n = '$p_\mathrm{corr}$ = FDR-corrected $p$-value. $^{*}p < .05$, $^{**}p < .01$, $^{***}p < .001$.'; + case 'coefficients' + n = '$^{*}p < .05$, $^{**}p < .01$, $^{***}p < .001$.'; + case 'correlations' + n = '$r$ = Pearson, $\rho$ = Spearman. $^{*}p < .05$, $^{**}p < .01$, $^{***}p < .001$.'; + case 'effectsize' + n = '$^{*}$CI excludes zero. $p$ = two-sample $t$-test.'; + otherwise + n = ''; + end +end + + +function label = applyTermLabel(termName, termLabels) +% APPLYTERMLABEL Map sanitized term name to readable label +% +% Handles direct matches (e.g. 'ot1' -> 'Time (Linear)') and interaction +% terms (e.g. 'Condition:ot1' -> 'Condition x Time (Linear)'). + + if isempty(fieldnames(termLabels)) + label = termName; + return; + end + + % Direct match + if isfield(termLabels, termName) + label = termLabels.(termName); + return; + end + + % Check for interaction terms containing polynomial components + % e.g. 'Conditionot1' (sanitized from 'Condition:ot1') + % or 'Condition:ot1' (raw ANOVA term) + label = termName; + fnames = fieldnames(termLabels); + for i = 1:length(fnames) + otName = fnames{i}; + % Match both 'Var:otN' and sanitized 'VarotN' patterns + if contains(termName, otName) + prefix = strrep(termName, otName, ''); + prefix = strrep(prefix, ':', ''); + if ~isempty(prefix) + label = sprintf('%s x %s', prefix, termLabels.(otName)); + else + label = termLabels.(otName); + end + return; + end + end +end + + +function T = deduplicateColumn(T, colName) +% DEDUPLICATECOLUMN Show value only on first row of each consecutive group +% Replaces repeated consecutive values with "" for cleaner display. + vals = string(T.(colName)); + for i = 2:numel(vals) + if vals(i) == vals(i-1) + vals(i) = ""; + end + end + T.(colName) = vals; +end diff --git a/+exploreFNIRS/+stats/suppressLMEWarnings.m b/+exploreFNIRS/+stats/suppressLMEWarnings.m new file mode 100644 index 00000000..cd8676f1 --- /dev/null +++ b/+exploreFNIRS/+stats/suppressLMEWarnings.m @@ -0,0 +1,45 @@ +function cleanupObj = suppressLMEWarnings() +% SUPPRESSLMEWARNINGS Scope-suppress fitlme rank/Hessian warning spam +% +% Turns off the specific MATLAB LinearMixedModel warning identifiers that +% fitlme repeats per channel/term when a design is rank-deficient or has +% more covariance parameters than the data support (the typical symptom of +% a between-subjects confound). Only these identifiers are muted; unrelated +% warnings are left untouched, unlike a blanket warning('off','all'). The +% previous warning state is restored automatically when the returned +% onCleanup object goes out of scope, so suppression is strictly scoped to +% the fit. +% +% This is the single source of truth for the suppressed identifier set, +% shared by exploreFNIRS.stats.fitLME / fitInfoLME and the +% Experiment LME methods, so the list cannot drift between them. +% +% Syntax: +% cleanupObj = exploreFNIRS.stats.suppressLMEWarnings() +% +% Outputs: +% cleanupObj - onCleanup handle that restores the prior warning state when +% it goes out of scope. Keep it alive for the duration of the +% fit (e.g. assign to a local variable). +% +% Notes: +% The clean, consolidated explanation of an inestimable design comes from +% the caller (e.g. Experiment.warnBetweenSubjectConfound); this helper +% only mutes the raw, repeated fitlme spam. + + ids = { + 'stats:classreg:regr:lmeutils:StandardLinearMixedModel:Message_NotSPDHessian_REML' + 'stats:classreg:regr:lmeutils:StandardLinearMixedModel:Message_NotSPDHessian_ML' + 'stats:classreg:regr:lmeutils:StandardLinearMixedModel:Message_NotSPDCovarianceUnconstrainedScale' + 'stats:classreg:regr:lmeutils:StandardLinearMixedModel:Message_NotSPDCovarianceNaturalScale' + 'stats:classreg:regr:lmeutils:StandardLinearLikeMixedModel:Message_NaNInfInHessian' + 'stats:classreg:regr:lmeutils:StandardLinearLikeMixedModel:Message_TooManyCovarianceParameters' + 'stats:classreg:regr:lmeutils:StandardLinearLikeMixedModel:MustBeFullRank_X' + 'stats:classreg:regr:lmeutils:StandardLinearLikeMixedModel:InValidX_Rank' + }; + prev = repmat(warning('query', ids{1}), numel(ids), 1); + for i = 1:numel(ids) + prev(i) = warning('off', ids{i}); + end + cleanupObj = onCleanup(@() warning(prev)); +end diff --git a/+exploreFNIRS/browseEx.m b/+exploreFNIRS/browseEx.m index bb1a57d7..2cfe05ab 100644 --- a/+exploreFNIRS/browseEx.m +++ b/+exploreFNIRS/browseEx.m @@ -28,20 +28,20 @@ % % Example: % % Launch browser with file dialog -% exploreFNIRS.BrowseEx(); +% exploreFNIRS.browseEx(); % % % Launch browser starting in specific directory -% exploreFNIRS.BrowseEx('/path/to/fnirs/data'); +% exploreFNIRS.browseEx('/path/to/fnirs/data'); % % % Load data and capture in variable -% myData = exploreFNIRS.BrowseEx(); +% myData = exploreFNIRS.browseEx(); % % Notes: % - Supports loading multiple file formats (NIR, SNIRF, Hitachi, NIRx) % - Selected files are automatically imported using appropriate readers % - For programmatic loading without GUI, use LoadEx instead % -% See also: exploreFNIRS.LoadEx, exploreFNIRS.SaveEx, exploreFNIRS, +% See also: exploreFNIRS.loadEx, exploreFNIRS.saveEx, exploreFNIRS, % exploreFNIRS_browse if(nargout>0) diff --git a/+exploreFNIRS/loadEx.m b/+exploreFNIRS/loadEx.m index 1ab9e0e9..339795bb 100644 --- a/+exploreFNIRS/loadEx.m +++ b/+exploreFNIRS/loadEx.m @@ -42,13 +42,13 @@ % % Example: % % Load session with file dialog -% exploreFNIRS.LoadEx(); +% exploreFNIRS.loadEx(); % % % Load specific saved experiment -% exploreFNIRS.LoadEx('/path/to/myexperiment_exf.mat'); +% exploreFNIRS.loadEx('/path/to/myexperiment_exf.mat'); % % % Load and capture the filename for logging -% loadedFile = exploreFNIRS.LoadEx(); +% loadedFile = exploreFNIRS.loadEx(); % fprintf('Loaded session from: %s\n', loadedFile); % % Notes: @@ -57,7 +57,7 @@ % - Compatible with files saved by SaveEx using MATLAB v7.3 format % - After loading, the GUI automatically refreshes to display loaded data % -% See also: exploreFNIRS.SaveEx, exploreFNIRS, exploreFNIRS.BrowseEx +% See also: exploreFNIRS.saveEx, exploreFNIRS, exploreFNIRS.browseEx pathname=''; if(nargin<1) @@ -76,7 +76,7 @@ if(~isfield(tempLoadEx,'ExFNIRS')||isempty(tempLoadEx)) - error('No data found'); + error('exploreFNIRS:loadEx:noData', 'No data found'); end diff --git a/+exploreFNIRS/plotExTimeline.m b/+exploreFNIRS/plotExTimeline.m index 57dc5739..8db87157 100644 --- a/+exploreFNIRS/plotExTimeline.m +++ b/+exploreFNIRS/plotExTimeline.m @@ -53,43 +53,56 @@ temporalHeight=0.8; barchartHeight=0.6; +% Dark mode color adaptation +isDark = pf2_base.plot.PlotStyle.isDarkMode(); +if isDark + blockColor = [0.85 0.85 0.85]; + plotColor = [0.4 0.6 1.0]; + blColor = [1.0 0.4 0.4]; + dimColor = [0.45 0.45 0.45]; + blockDash = {'--', 'Color', blockColor, 'HandleVisibility', 'off'}; + plotDash = {'--', 'Color', plotColor, 'HandleVisibility', 'off'}; + blDash = {'--', 'Color', blColor, 'HandleVisibility', 'off'}; +else + blockColor = 'k'; + plotColor = 'b'; + blColor = 'r'; + dimColor = [40,40,40]/255; + blockDash = {'--k', 'HandleVisibility', 'off'}; + plotDash = {'--b', 'HandleVisibility', 'off'}; + blDash = {'--r', 'HandleVisibility', 'off'}; +end + yticks([sort([plotHeight,blHeight,blockHeight,temporalHeight,barchartHeight])]); -plotHorizViewBar([blockStart,blockEnd],blockHeight,lineBarHeight,lineWeight,{'k'}); +plotHorizViewBar([blockStart,blockEnd],blockHeight,lineBarHeight,lineWeight,{'Color',blockColor}); hold on; -plotHorizViewBar([plotStart,plotEnd],plotHeight,lineBarHeight,lineWeight,{'b'}); +plotHorizViewBar([plotStart,plotEnd],plotHeight,lineBarHeight,lineWeight,{'Color',plotColor}); if(blEnabled) - plotHorizViewBar([blStart,blEnd],blHeight,lineBarHeight,lineWeight,{'r'}); - %text(mean([blStart,blEnd]),blHeight+0.05,'\downarrow Baseline Period'); + plotHorizViewBar([blStart,blEnd],blHeight,lineBarHeight,lineWeight,{'Color',blColor}); end -%text(mean([plotStart,plotEnd]),plotHeight+0.05,'\downarrow Plot View'); - -%text(mean([blockStart,blockEnd]),blockHeight+0.05,'\downarrow Task Block Period'); - ylim([0,1]); -pf2_base.external.vline([ExFNIRS.settings.block_start,ExFNIRS.settings.block_end],{'--k','HandleVisibility','off'}); +pf2_base.external.vline([ExFNIRS.settings.block_start,ExFNIRS.settings.block_end],blockDash); - -pf2_base.external.vline([ExFNIRS.settings.plot_start,ExFNIRS.settings.plot_end],{'--b','HandleVisibility','off'}); +pf2_base.external.vline([ExFNIRS.settings.plot_start,ExFNIRS.settings.plot_end],plotDash); if(blEnabled) - pf2_base.external.vline([ExFNIRS.settings.baseline_start,ExFNIRS.settings.baseline_end],{'--r','HandleVisibility','off'}); - -end + pf2_base.external.vline([ExFNIRS.settings.baseline_start,ExFNIRS.settings.baseline_end],blDash); +end % Plot temporal signal -plotPeriodicSample(minStart-grandavg_resample_size,maxEnd+grandavg_resample_size,blockStart,temporalHeight,sigAmp,grandavg_resample_size,sigWeight/4,{'Color',[40,40,40]/255}); +plotPeriodicSample(minStart-grandavg_resample_size,maxEnd+grandavg_resample_size,blockStart,temporalHeight,sigAmp,grandavg_resample_size,sigWeight/4,{'Color',dimColor}); plotPeriodicSample(plotStart,plotEnd,blockStart,temporalHeight,sigAmp,grandavg_resample_size,sigWeight/2,{'Color',[50,200,78]/255}); % Plot barchart/block signal -plotPeriodicSample(minStart-blk_resample_size,maxEnd+blk_resample_size,blockStart,barchartHeight,sigAmp,blk_resample_size,sigWeight/4,{'lineStyle','--','Color',[40,40,40]/255}); +plotPeriodicSample(minStart-blk_resample_size,maxEnd+blk_resample_size,blockStart,barchartHeight,sigAmp,blk_resample_size,sigWeight/4,{'lineStyle','--','Color',dimColor}); plotPeriodicSample(plotStart,plotEnd,blockStart,barchartHeight,sigAmp,blk_resample_size,sigWeight,{'Color',[150,30,178]/255}); @@ -104,7 +117,11 @@ [ytickVals,srtIdx]=sort([plotHeight,blHeight,blockHeight,temporalHeight,barchartHeight]); yticks(ytickVals); -ytLabels={'Plot View','Baseline Period','Task Block Period','Temporal Resample','Barchart Resample'}; +ytLabels={sprintf('Plot View [%.1f–%.1fs]', plotStart, plotEnd), ... + sprintf('Baseline [%.1f–%.1fs]', blStart, blEnd), ... + sprintf('Task Block [%.1f–%.1fs]', blockStart, blockEnd), ... + sprintf('Temporal (%.2fs, %.1fHz)', grandavg_resample_size, 1/grandavg_resample_size), ... + sprintf('Barchart (%.2fs)', blk_resample_size)}; yticklabels(ytLabels(srtIdx)); end @@ -137,7 +154,12 @@ function plotPeriodicSample(startTime,endTime,centerTime,sigHeight,sigAmp,rsLen, xPoints=round(startTimePlt+(idx-1)*rsLen,5); - offset0=rem(find(xPoints==centerTime)+1,2); + centerIdx=find(xPoints==centerTime,1); + if isempty(centerIdx) + offset0=0; + else + offset0=rem(centerIdx+1,2); + end if(isnan(sigLen)) return; diff --git a/+exploreFNIRS/processMethods.m b/+exploreFNIRS/processMethods.m index 488e3146..1b423331 100644 --- a/+exploreFNIRS/processMethods.m +++ b/+exploreFNIRS/processMethods.m @@ -1,4 +1,25 @@ function processMethods(rawMethodStr,oxyMethodStr) +% PROCESSMETHODS Process all loaded fNIRS data with specified method pair +% +% Runs a raw+oxy method combination on all segments in the exploreFNIRS +% dataset. Results are cached so re-selecting a previously processed +% method pair returns instantly. On first load, ROI fields are +% standardized across devices. +% +% Syntax: +% exploreFNIRS.processMethods(rawMethodStr, oxyMethodStr) +% +% Inputs: +% rawMethodStr - Name of the raw processing method, or empty [] to skip +% raw processing and apply oxy-only +% oxyMethodStr - Name of the oxy processing method +% +% Example: +% exploreFNIRS.processMethods('x5_TDDR', 'takizawa_easy'); +% exploreFNIRS.processMethods([], 'None'); % oxy-only reprocessing +% +% See also: processFNIRS2, pf2.methods.raw.list, pf2.methods.oxy.list, +% exploreFNIRS.dataset.standardizeROIs global ExFNIRS %global ProgressHandles @@ -59,23 +80,36 @@ function processMethods(rawMethodStr,oxyMethodStr) %fprintf('ExploreFNIRS\nProcessing Method %s x %s %i of %i\n',rawMethodStr_label,oxyMethodStr_label,1,numData); %hF=ProgressHandles.h.hF; - for i=1:numData - fprintf('ExploreFNIRS - Processing Method %s x %s %i of %i\n',rawMethodStr_label,oxyMethodStr_label,i,numData); - - if(~isempty(data{i})&&length(data{i}.time)>1) - if(processOxyOnly) - if(isfield(data{i},'HbO')) - data{i}=pf2.process.processOxy(data{i}); - else - warning('Data file for item %i has no Oxy Data, attempting to process with ''None''\n',data{i}); - data{i}=pf2(data{i}); - end - else - data{i}=pf2(data{i}); - end - data{i}=pf2.data.applyChannelMask(data{i}); - data{i}=pf2.data.resample(data{i},ExFNIRS.settings.grandavg_resample_size,'centerOnT0',true,'timeOutMode','end','averageAux',false,'flattenAux',true); - end + % Filter out empty/invalid segments + validIdx = find(cellfun(@(d) ~isempty(d) && length(d.time) > 1, data)); + + if processOxyOnly + % Oxy-only: must loop (processOxy doesn't support cell arrays) + for k = 1:numel(validIdx) + i = validIdx(k); + fprintf('ExploreFNIRS - Processing Method %s x %s %i of %i\n', rawMethodStr_label, oxyMethodStr_label, i, numData); + if isfield(data{i}, 'HbO') + data{i} = pf2.process.processOxy(data{i}); + else + warning('Data file for item %i has no Oxy Data, attempting full processing', i); + data{i} = pf2(data{i}); + end + end + else + % Full processing: use batch mode (processFNIRS2 handles parfor internally) + fprintf('ExploreFNIRS - Processing Method %s x %s (%d segments)\n', rawMethodStr_label, oxyMethodStr_label, numel(validIdx)); + validData = data(validIdx); + validData = processFNIRS2(validData); + data(validIdx) = validData; + end + + % Apply channel mask + resample + rsSize = ExFNIRS.settings.grandavg_resample_size; + for k = 1:numel(validIdx) + i = validIdx(k); + data{i} = pf2.data.applyChannelMask(data{i}); + data{i} = pf2.data.resample(data{i}, rsSize, 'centerOnT0', true, ... + 'timeOutMode', 'end', 'averageAux', false, 'flattenAux', true); end @@ -89,6 +123,41 @@ function processMethods(rawMethodStr,oxyMethodStr) ExFNIRS.curProcessedData= ExFNIRS.processedData{curRawMatchIdx&curOxyMatchIdx,3}; end +% Update optode list from processed data (channels created by bvoxy) +uOpt = []; +for ii = 1:length(ExFNIRS.curProcessedData) + if ~isempty(ExFNIRS.curProcessedData{ii}) && isfield(ExFNIRS.curProcessedData{ii}, 'channels') + uOpt = [uOpt; ExFNIRS.curProcessedData{ii}.channels(:)]; %#ok + end +end +if ~isempty(uOpt) + uOpt = sort(unique(uOpt)); + ExFNIRS.currentOpt = uOpt; + % Rebuild labels with short-sep markers + labels = arrayfun(@num2str, uOpt, 'UniformOutput', false); + try + dev = []; + for ii2 = 1:length(ExFNIRS.curProcessedData) + d = ExFNIRS.curProcessedData{ii2}; + if ~isempty(d) && isfield(d,'device') && isa(d.device,'pf2.Device') + dev = d.device; break; + end + end + if ~isempty(dev) && dev.nShortSep > 0 + ssMask = dev.isShortSep(); + chList = dev.channelList(); + for kk = 1:numel(uOpt) + idx = find(chList == uOpt(kk), 1); + if ~isempty(idx) && idx <= numel(ssMask) && ssMask(idx) + labels{kk} = sprintf('%d (ss)', uOpt(kk)); + end + end + end + catch + end + ExFNIRS.currentOptLabels = labels; +end + if(processOxyOnly) ExFNIRS.curMethodName=sprintf('Skipped : %s',oxyMethodStr); else diff --git a/+exploreFNIRS/saveEx.m b/+exploreFNIRS/saveEx.m index 27c0918b..1926e21f 100644 --- a/+exploreFNIRS/saveEx.m +++ b/+exploreFNIRS/saveEx.m @@ -39,18 +39,18 @@ % % Example: % % Save session with file dialog -% exploreFNIRS.SaveEx(); +% exploreFNIRS.saveEx(); % % % Save to specific file -% exploreFNIRS.SaveEx('/path/to/myexperiment_exf.mat'); +% exploreFNIRS.saveEx('/path/to/myexperiment_exf.mat'); % % % Save and capture filename for logging -% savedFile = exploreFNIRS.SaveEx(); +% savedFile = exploreFNIRS.saveEx(); % fprintf('Session saved to: %s\n', savedFile); % % % Typical workflow: process data, then save % % ... perform analysis in exploreFNIRS GUI ... -% exploreFNIRS.SaveEx('study_analysis_exf.mat'); +% exploreFNIRS.saveEx('study_analysis_exf.mat'); % % Notes: % - Uses MATLAB v7.3 format (HDF5) to support large datasets (>2GB) @@ -58,7 +58,7 @@ % - Progress messages printed to console during save operation % - File can be loaded by LoadEx or standard MATLAB load() function % -% See also: exploreFNIRS.LoadEx, exploreFNIRS, exploreFNIRS.BrowseEx +% See also: exploreFNIRS.loadEx, exploreFNIRS, exploreFNIRS.browseEx pathname=''; if(nargin<1) @@ -73,7 +73,7 @@ global ExFNIRS if(~isfield(ExFNIRS,'data')) - error('No data present in ExFNIRS'); + error('exploreFNIRS:saveEx:noData', 'No data present in ExFNIRS'); end if(~isempty(pathname)) diff --git a/+exploreFNIRS/versInfo.m b/+exploreFNIRS/versInfo.m index 07af7448..692e60ed 100644 --- a/+exploreFNIRS/versInfo.m +++ b/+exploreFNIRS/versInfo.m @@ -12,7 +12,7 @@ % None % % Outputs: -% versInfoString - Version string (e.g., 'Explore fNIRS v0.3a') +% versInfoString - Version string (e.g., 'Explore fNIRS v1.0.0') % Only returned if output argument is requested. % % Example: @@ -25,7 +25,7 @@ % % See also: pf2version, exploreFNIRS -vers='0.3a'; +vers='1.0.0'; versInfo=sprintf('Explore fNIRS v%s\n',vers); if(nargout==0) diff --git a/+pf2/+GUI/+functions/add.m b/+pf2/+GUI/+functions/add.m new file mode 100644 index 00000000..5728c2f2 --- /dev/null +++ b/+pf2/+GUI/+functions/add.m @@ -0,0 +1,33 @@ +function varargout=add(varargin) +% ADD Open the GUI to add a new processing function definition +% +% Wrapper that launches the processFNIRS2 add/edit-function GUI to register a +% new processing function (its arguments, defaults, and metadata) so it can +% be used in raw/oxy method pipelines. Outputs are forwarded from +% processFNIRS2_configureMethods_functionAddEdit. +% +% Syntax: +% pf2.GUI.functions.add() +% out = pf2.GUI.functions.add() +% +% Inputs: +% varargin - Reserved; the underlying add/edit GUI is invoked with no +% arguments (new-function mode). +% +% Outputs: +% varargout - Whatever the add/edit-function GUI returns when an output is +% requested (e.g. the app handle); empty otherwise. +% +% Example: +% % Open the GUI to define a new processing function +% pf2.GUI.functions.add(); +% +% See also: pf2.GUI.functions.edit, pf2.GUI.functions, +% processFNIRS2_configureMethods_functionAddEdit + +if(nargout>0) + varargout{1:nargout}=processFNIRS2_configureMethods_functionAddEdit(); +else + processFNIRS2_configureMethods_functionAddEdit(); + varargout=[]; +end \ No newline at end of file diff --git a/+pf2/+GUI/+functions/edit.m b/+pf2/+GUI/+functions/edit.m new file mode 100644 index 00000000..6b3a8ca4 --- /dev/null +++ b/+pf2/+GUI/+functions/edit.m @@ -0,0 +1,36 @@ +function varargout=edit(varargin) +% EDIT Open the GUI to edit an existing processing function definition +% +% Wrapper that launches the processFNIRS2 add/edit-function GUI to modify the +% definition of an existing processing function (its arguments, defaults, and +% metadata). Requires the name of the function to edit. Arguments and outputs +% are forwarded to processFNIRS2_configureMethods_functionAddEdit. +% +% Syntax: +% pf2.GUI.functions.edit(funcName) +% out = pf2.GUI.functions.edit(funcName, ...) +% +% Inputs: +% funcName - Name of the processing function to edit (e.g. 'pf2_lpf') +% varargin - Additional arguments accepted by the add/edit-function GUI. +% +% Outputs: +% varargout - Whatever the add/edit-function GUI returns when an output is +% requested (e.g. the app handle). +% +% Example: +% % Edit the definition of the low-pass filter function +% pf2.GUI.functions.edit('pf2_lpf'); +% +% See also: pf2.GUI.functions.add, pf2.GUI.functions, +% processFNIRS2_configureMethods_functionAddEdit + +if(nargin<1) + error('pf2:GUI:functions:edit:noFunctionName', 'Please provide function name to edit'); +end + +if(nargout>0) + varargout{1:nargout}=processFNIRS2_configureMethods_functionAddEdit(varargin{:}); +else + processFNIRS2_configureMethods_functionAddEdit(varargin{:}); +end \ No newline at end of file diff --git a/+pf2/+GUI/configureOxyMethods.m b/+pf2/+GUI/configureOxyMethods.m new file mode 100644 index 00000000..85c25951 --- /dev/null +++ b/+pf2/+GUI/configureOxyMethods.m @@ -0,0 +1,32 @@ +function varargout=configureOxyMethods(varargin) +% CONFIGUREOXYMETHODS Open the GUI to configure oxy (Stage 3) methods +% +% Wrapper that launches the processFNIRS2 method-configuration GUI scoped to +% the oxy (hemoglobin, Stage 3) processing stage. All arguments and outputs +% are forwarded to processFNIRS2_configureMethods with the 'oxy' stage +% pre-selected. +% +% Syntax: +% pf2.GUI.configureOxyMethods() +% out = pf2.GUI.configureOxyMethods(...) +% +% Inputs: +% varargin - Any arguments accepted by processFNIRS2_configureMethods +% (after the implicit 'oxy' stage argument). +% +% Outputs: +% varargout - Whatever processFNIRS2_configureMethods returns when an +% output is requested (e.g. the app handle). +% +% Example: +% % Open the oxy method configuration GUI +% pf2.GUI.configureOxyMethods(); +% +% See also: pf2.GUI.configureRawMethods, pf2.methods.oxy.configureMethods, +% processFNIRS2_configureMethods + +if(nargout>0) + varargout{1:nargout}=processFNIRS2_configureMethods('oxy',varargin{:}); +else + processFNIRS2_configureMethods('oxy',varargin{:}); +end \ No newline at end of file diff --git a/+pf2/+GUI/configureRawMethods.m b/+pf2/+GUI/configureRawMethods.m new file mode 100644 index 00000000..bbbcacbb --- /dev/null +++ b/+pf2/+GUI/configureRawMethods.m @@ -0,0 +1,32 @@ +function varargout=configureRawMethods(varargin) +% CONFIGURERAWMETHODS Open the GUI to configure raw (Stage 1) methods +% +% Wrapper that launches the processFNIRS2 method-configuration GUI scoped to +% the raw (light-to-optical-density, Stage 1) processing stage. All arguments +% and outputs are forwarded to processFNIRS2_configureMethods with the 'raw' +% stage pre-selected. +% +% Syntax: +% pf2.GUI.configureRawMethods() +% out = pf2.GUI.configureRawMethods(...) +% +% Inputs: +% varargin - Any arguments accepted by processFNIRS2_configureMethods +% (after the implicit 'raw' stage argument). +% +% Outputs: +% varargout - Whatever processFNIRS2_configureMethods returns when an +% output is requested (e.g. the app handle). +% +% Example: +% % Open the raw method configuration GUI +% pf2.GUI.configureRawMethods(); +% +% See also: pf2.GUI.configureOxyMethods, pf2.methods.raw.configureMethods, +% processFNIRS2_configureMethods + +if(nargout>0) + varargout{1:nargout}=processFNIRS2_configureMethods('raw',varargin{:}); +else + processFNIRS2_configureMethods('raw',varargin{:}); +end \ No newline at end of file diff --git a/+pf2/+GUI/functions.m b/+pf2/+GUI/functions.m new file mode 100644 index 00000000..eeb3e91b --- /dev/null +++ b/+pf2/+GUI/functions.m @@ -0,0 +1,23 @@ +function functions() +% FUNCTIONS Display the processing functions available to the method GUI +% +% Placeholder entry point for listing the processing functions that can be +% added to raw/oxy methods through the configuration GUI. Currently prints a +% notice to the console; reserved for the interactive function browser. +% +% Syntax: +% pf2.GUI.functions() +% +% Inputs: +% None +% +% Outputs: +% None. A notice is printed to the command window. +% +% Example: +% % Show the available-functions notice +% pf2.GUI.functions(); +% +% See also: pf2.GUI.functions.add, pf2.GUI.functions.edit, +% pf2.GUI.configureRawMethods, pf2.GUI.configureOxyMethods +disp('This will display currently available functions'); \ No newline at end of file diff --git a/+pf2/+data/+aux/accelFeatures.m b/+pf2/+data/+aux/accelFeatures.m new file mode 100644 index 00000000..1c88fb9e --- /dev/null +++ b/+pf2/+data/+aux/accelFeatures.m @@ -0,0 +1,62 @@ +function [feat, info] = accelFeatures(x, fs, opts) +% ACCELFEATURES Derive motion features from a multi-axis accelerometer signal +% +% Computes summary motion features from accelerometer/IMU axes: the vector +% magnitude (norm) and its temporal derivative (jerk). These features drive +% accelerometer-informed motion detection and correction, and serve as motion +% nuisance regressors. +% +% Syntax: +% [feat, info] = pf2.data.aux.accelFeatures(x, fs) +% [feat, info] = pf2.data.aux.accelFeatures(x, fs, 'Name', Value) +% +% Inputs: +% x - Accelerometer data [T x C] (typically C = 3 axes). +% fs - Sampling rate in Hz [scalar]. +% +% Name-Value Parameters: +% 'RemoveGravity' - Subtract the median norm (gravity baseline) so the norm +% reflects dynamic acceleration around 0 (default: true). +% +% Outputs: +% feat - Struct with fields: +% .norm - [T x 1] vector magnitude sqrt(sum(x.^2, 2)) +% (gravity-removed if requested). +% .jerk - [T x 1] magnitude of the time-derivative of the norm +% (abs(diff)*fs), same length as norm. +% info - Struct with: gravity (subtracted baseline), nAxes. +% +% Notes: +% - With gravity removed, a still subject sits near norm = 0; motion shows as +% positive deflections. Jerk emphasizes abrupt transients (head movement). +% +% Example: +% [feat, info] = pf2.data.aux.accelFeatures(proc.Aux.accelerometer.data, 50); +% motionMask = feat.norm > 3 * mad(feat.norm, 1); +% +% See also: pf2_base.auxSignalType, pf2.data.auxOnGrid + +arguments + x {mustBeNumeric} + fs {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} + opts.RemoveGravity (1,1) logical = true +end +removeGravity = opts.RemoveGravity; + +if isrow(x) + x = x(:); +end + +nrm = sqrt(sum(x.^2, 2)); +gravity = 0; +if removeGravity + gravity = median(nrm, 'omitnan'); + nrm = nrm - gravity; +end + +jerk = [0; abs(diff(nrm)) * fs]; + +feat = struct('norm', nrm, 'jerk', jerk); +info = struct('gravity', gravity, 'nAxes', size(x, 2)); + +end diff --git a/+pf2/+data/+aux/addFeature.m b/+pf2/+data/+aux/addFeature.m new file mode 100644 index 00000000..01d641d3 --- /dev/null +++ b/+pf2/+data/+aux/addFeature.m @@ -0,0 +1,78 @@ +function data = addFeature(data, name, values, opts) +% ADDFEATURE Store a derived signal as a typed auxiliary feature +% +% Writes a derived feature (e.g. an HR series from heartRateFrom, RVT from +% respFeatures, an EEG band-power envelope) into data.Aux as a canonical, +% typed signal so it propagates through the pipeline and survives SNIRF export +% / re-import like any other auxiliary signal. +% +% Syntax: +% data = pf2.data.aux.addFeature(data, name, values) +% data = pf2.data.aux.addFeature(data, name, values, 'Name', Value) +% +% Inputs: +% data - fNIRS data struct (must have .time; .Aux is created if absent). +% name - Field name for the new aux signal [char|string]. +% values - Feature samples [T x C]. Length should match the Time grid. +% +% Name-Value Parameters: +% 'Time' - Time vector for the feature (default: data.time). +% 'Unit' - Unit string (default: the inferred type's canonical unit). +% 'VarNames' - Channel labels (default: synthesized). +% +% Outputs: +% data - Input struct with data.Aux.(name) added as a canonical signal +% struct {data,time,unit,varNames,type,kind}. +% +% Notes: +% - The signal is normalized via pf2_base.normalizeAux, so the type/kind are +% inferred from the field name (e.g. 'heartRate' -> HR). +% - If data.Aux is in the flattened pipeline representation, a warning is +% issued (mixing nested and flattened signals); re-flatten downstream if +% needed. +% +% Example: +% [hr, ~] = pf2.data.aux.heartRateFrom(proc.Aux.ppg.data, proc.Aux.ppg.fs); +% proc = pf2.data.aux.addFeature(proc, 'heartRate', hr); % typed HR series +% +% 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 +name = matlab.lang.makeValidName(char(string(name))); + +t = opts.Time; +if isempty(t) + if ~isfield(data, 'time') || isempty(data.time) + error('pf2:addFeature:noTime', ... + 'No Time given and data.time is empty.'); + end + t = data.time; +end + +sig = struct('data', values, 'time', t(:)); +if ~isempty(char(string(opts.Unit))) + sig.unit = char(string(opts.Unit)); +end +if ~isempty(opts.VarNames) + sig.varNames = cellstr(opts.VarNames); +end + +if ~isfield(data, 'Aux') || isempty(data.Aux) || ~isstruct(data.Aux) + data.Aux = struct(); +elseif isfield(data.Aux, 'flattened') && islogical(data.Aux.flattened) && data.Aux.flattened + warning('pf2:addFeature:flattenedAux', ... + ['data.Aux is in the flattened pipeline representation; adding a ', ... + 'nested feature "%s" mixes representations.'], name); +end + +data.Aux.(name) = pf2_base.normalizeAux(sig, 'Single', true, 'Name', name); + +end diff --git a/+pf2/+data/+aux/edaDecompose.m b/+pf2/+data/+aux/edaDecompose.m new file mode 100644 index 00000000..85d71cce --- /dev/null +++ b/+pf2/+data/+aux/edaDecompose.m @@ -0,0 +1,84 @@ +function [tonic, phasic, info] = edaDecompose(x, fs, opts) +% EDADECOMPOSE Split electrodermal activity into tonic and phasic components +% +% Decomposes a galvanic skin response / electrodermal activity (GSR/EDA) +% signal into its slowly varying tonic level (skin conductance level, SCL) +% and the faster phasic response (skin conductance responses, SCRs). The +% tonic component reflects general arousal; the phasic component reflects +% event-related sympathetic activity. +% +% Syntax: +% [tonic, phasic, info] = pf2.data.aux.edaDecompose(x, fs) +% [tonic, phasic, info] = pf2.data.aux.edaDecompose(x, fs, 'Name', Value) +% +% Inputs: +% x - EDA/GSR signal [T x 1] (microsiemens). +% fs - Sampling rate in Hz [scalar]. +% +% Name-Value Parameters: +% 'TonicCutoff' - Low-pass cutoff (Hz) separating tonic from phasic +% (default: 0.05). Frequencies below this form the tonic +% component; the remainder is phasic. +% +% Outputs: +% tonic - Tonic level (SCL) [T x 1], the low-pass component. +% phasic - Phasic activity (SCR) [T x 1] = x - tonic. +% info - Struct with: tonicCutoff, tonicMean, phasicStd. +% +% Algorithm: +% Zero-phase moving-average low-pass at TonicCutoff yields the tonic level; +% the phasic component is the residual. This is a lightweight alternative to +% model-based deconvolution (e.g. cvxEDA) suitable for covariate extraction. +% +% Notes: +% - Self-contained (no Signal Processing Toolbox dependency). +% - Reference for the tonic/phasic framing: Boucsein, W. (2012). +% Electrodermal Activity, 2nd ed. Springer. DOI: 10.1007/978-1-4614-1126-0 +% +% Example: +% [scl, scr] = pf2.data.aux.edaDecompose(proc.Aux.gsr.data, proc.Aux.gsr.fs); +% +% See also: pf2_base.auxSignalType, pf2.data.aux.heartRateFrom + +arguments + x {mustBeNumeric} + fs {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} + opts.TonicCutoff {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = 0.05 +end +cutoff = opts.TonicCutoff; + +x = x(:); + +% The tonic low-pass window is ~ fs/cutoff samples (e.g. 20 s at 0.05 Hz). On a +% short recording the window dominates and the tonic collapses to a near-flat +% line, dumping nearly everything into the phasic component. +win = round(fs / cutoff); +if win > numel(x) / 3 + warning('pf2:edaDecompose:shortRecording', ... + ['Tonic window (~%.0f s) exceeds a third of the recording (%.0f s); ', ... + 'the tonic/phasic split will be unreliable.'], win/fs, numel(x)/fs); +end + +tonic = movavgLowpass(x, fs, cutoff); +phasic = x - tonic; + +info = struct('tonicCutoff', cutoff, 'tonicMean', mean(tonic), ... + 'phasicStd', std(phasic)); + +end + +%%_Subfunctions_________________________________________________________ + +function y = movavgLowpass(x, fs, cutoff) +% MOVAVGLOWPASS Zero-phase Hann moving-average low-pass at ~cutoff Hz +win = max(3, round(fs / cutoff)); +if mod(win, 2) == 0 + win = win + 1; +end +k = 0.5 * (1 - cos(2 * pi * (0:win-1)' / (win - 1))); +k = k / sum(k); +half = (win - 1) / 2; +xp = [repmat(x(1), half, 1); x; repmat(x(end), half, 1)]; +yc = conv(xp, k, 'same'); +y = yc(half + 1 : half + numel(x)); +end diff --git a/+pf2/+data/+aux/eegBandPower.m b/+pf2/+data/+aux/eegBandPower.m new file mode 100644 index 00000000..edf0925b --- /dev/null +++ b/+pf2/+data/+aux/eegBandPower.m @@ -0,0 +1,111 @@ +function [bandPower, info] = eegBandPower(x, fs, opts) +% EEGBANDPOWER Extract canonical EEG band-power feature series +% +% Converts a raw EEG waveform (one or more channels) into per-band power +% envelopes over the canonical clinical bands (delta, theta, alpha, beta, +% gamma). EEG is treated as its own signal family in processFNIRS2: high +% sampling rate, multichannel, and analyzed by frequency band rather than by +% peak detection or tonic/phasic decomposition. The resulting band-power +% series are time-varying features suitable as covariates or for fNIRS-EEG +% fusion after alignment with pf2.data.auxOnGrid. +% +% Syntax: +% [bandPower, info] = pf2.data.aux.eegBandPower(x, fs) +% [bandPower, info] = pf2.data.aux.eegBandPower(x, fs, 'Name', Value) +% +% Inputs: +% x - EEG data [T x C] (C channels, microvolts). +% fs - Sampling rate in Hz [scalar]. +% +% Name-Value Parameters: +% 'Bands' - Struct mapping band name -> [loHz hiHz] +% (default: canonical bands from pf2_base.auxSignalType('eeg')). +% 'SmoothWin' - Power-envelope smoothing window in seconds (default: 1). +% +% Outputs: +% bandPower - Struct with one field per band, each [T x C], holding the +% smoothed band-limited power envelope. +% info - Struct with: bands (used), smoothWin, channels (C). +% +% Algorithm: +% For each band: zero-phase FFT band-pass, square to instantaneous power, +% then smooth with a zero-phase moving average of length SmoothWin seconds. +% +% Notes: +% - Self-contained (no Signal Processing Toolbox dependency). +% - Canonical bands: delta 1-4, theta 4-8, alpha 8-13, beta 13-30, +% gamma 30-45 Hz. +% - The per-band filter is an FFT brick-wall mask; it has edge ringing and +% sidelobe leakage, so the band-power envelope is a lightweight covariate +% feature, not a substitute for a dedicated EEG spectral pipeline. Edge +% samples (~one smoothing window) are unreliable. +% +% Example: +% bp = pf2.data.aux.eegBandPower(eeg, 256); +% alphaCz = bp.alpha(:, chCz); +% +% See also: pf2_base.auxSignalType, pf2.data.auxOnGrid + +arguments + x {mustBeNumeric} + fs {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} + opts.Bands = [] + opts.SmoothWin {mustBeNumeric, mustBeScalarOrEmpty, mustBePositive} = 1 +end +bands = opts.Bands; +smoothWin = opts.SmoothWin; + +if isempty(bands) + info0 = pf2_base.auxSignalType('eeg'); + bands = info0.bands; +end + +if isrow(x) + x = x(:); +end +[T, C] = size(x); + +winSamp = max(3, round(smoothWin * fs)); +if mod(winSamp, 2) == 0 + winSamp = winSamp + 1; +end + +bandNames = fieldnames(bands); +bandPower = struct(); +for b = 1:numel(bandNames) + nm = bandNames{b}; + rng = bands.(nm); + P = zeros(T, C); + for c = 1:C + xf = bandpassFFT(x(:, c), fs, rng(1), rng(2)); + P(:, c) = movavgSmooth(xf.^2, winSamp); + end + bandPower.(nm) = P; +end + +info = struct('bands', bands, 'smoothWin', smoothWin, 'channels', C); + +end + +%%_Subfunctions_________________________________________________________ + +function y = bandpassFFT(x, fs, lo, hi) +% BANDPASSFFT Zero-phase brick-wall band-pass via FFT masking +N = numel(x); +xm = x - mean(x); +X = fft(xm); +f = (0:N-1)' * (fs / N); +fpos = min(f, fs - f); +mask = (fpos >= lo) & (fpos <= hi); +y = real(ifft(X .* mask)); +end + +function y = movavgSmooth(x, win) +% MOVAVGSMOOTH Zero-phase Hann moving-average smoother +k = 0.5 * (1 - cos(2 * pi * (0:win-1)' / (win - 1))); +k = k / sum(k); +half = (win - 1) / 2; +xp = [repmat(x(1), half, 1); x; repmat(x(end), half, 1)]; +yc = conv(xp, k, 'same'); +y = yc(half + 1 : half + numel(x)); +end diff --git a/+pf2/+data/+aux/heartRateFrom.m b/+pf2/+data/+aux/heartRateFrom.m new file mode 100644 index 00000000..421458b3 --- /dev/null +++ b/+pf2/+data/+aux/heartRateFrom.m @@ -0,0 +1,137 @@ +function [hr, info] = heartRateFrom(x, fs, opts) +% HEARTRATEFROM Derive a heart-rate (bpm) series from a PPG or EKG waveform +% +% Detects cardiac beats in a pulsatile waveform (photoplethysmography or +% electrocardiogram) and returns an instantaneous heart-rate series in beats +% per minute, interpolated onto the input sample times. This turns a raw +% cardiac WAVEFORM aux signal into an HR FEATURE series usable as a covariate +% or for quality control. +% +% Syntax: +% [hr, info] = pf2.data.aux.heartRateFrom(x, fs) +% [hr, info] = pf2.data.aux.heartRateFrom(x, fs, 'Name', Value) +% +% Inputs: +% x - Cardiac waveform [T x 1] (PPG or single-lead EKG). +% fs - Sampling rate in Hz [scalar]. +% +% Name-Value Parameters: +% 'Band' - Cardiac band-pass [loHz hiHz] for beat enhancement +% (default: [0.5 5]). +% 'MinBPM' - Lowest plausible rate; sets the search ceiling (default: 30). +% 'MaxBPM' - Highest plausible rate; sets the beat refractory period +% (default: 220). +% +% Outputs: +% hr - Instantaneous heart rate [T x 1] in bpm on the input time base +% (NaN-free; ends held at the nearest estimate). +% info - Struct with: peakIdx, peakTimes (s), meanBPM, nBeats, band. +% +% Algorithm: +% 1. Zero-phase band-pass to the cardiac band (FFT brick-wall). +% 2. Adaptive-threshold local-maxima peak picking with a refractory period +% derived from MaxBPM. +% 3. Instantaneous bpm = 60 / inter-beat-interval, placed at beat midpoints +% and linearly interpolated onto the input grid. +% +% Notes: +% - Self-contained (no Signal Processing Toolbox dependency). +% - For noisy data, prefer PPG over EKG unless R-peaks are clean. +% - The band-pass is an FFT brick-wall filter; it can introduce mild Gibbs +% ringing near sharp beats and at the record edges, so the first/last +% beat estimates may be less reliable. +% +% Example: +% ppg = proc.Aux.ppg.data; fs = proc.Aux.ppg.fs; +% [hr, info] = pf2.data.aux.heartRateFrom(ppg, fs); +% +% 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 +band = opts.Band; +maxBPM = opts.MaxBPM; + +x = x(:); +T = numel(x); +t = (0:T-1)' / fs; + +% --- 1. Band-pass to the cardiac band ------------------------------------ +xf = bandpassFFT(x, fs, band(1), band(2)); + +% --- 2. Peak detection ---------------------------------------------------- +minDist = max(1, round(fs * 60 / maxBPM)); % refractory in samples +thr = median(xf) + 0.5 * std(xf); % adaptive amplitude threshold +peakIdx = pickPeaks(xf, minDist, thr); + +info = struct('peakIdx', peakIdx(:), 'peakTimes', t(peakIdx), ... + 'meanBPM', NaN, 'nBeats', numel(peakIdx), 'band', band); + +if numel(peakIdx) < 2 + % Not enough beats: fall back to a flat NaN-free series at the global rate + hr = repmat(60 * numel(peakIdx) / max(t(end), eps), T, 1); + return; +end + +% --- 3. Instantaneous bpm via inter-beat intervals ----------------------- +pkTimes = t(peakIdx); +ibi = diff(pkTimes); +instBPM = 60 ./ ibi; +midTimes = pkTimes(1:end-1) + ibi / 2; + +if numel(midTimes) == 1 + hr = repmat(instBPM, T, 1); +else + hr = interp1(midTimes, instBPM, t, 'linear'); + hr = fillEnds(hr); % hold first/last valid estimate at the edges +end + +info.meanBPM = mean(instBPM); + +end + +%%_Subfunctions_________________________________________________________ + +function y = bandpassFFT(x, fs, lo, hi) +% BANDPASSFFT Zero-phase brick-wall band-pass via FFT masking +N = numel(x); +xm = x - mean(x); +X = fft(xm); +f = (0:N-1)' * (fs / N); +fpos = min(f, fs - f); % fold to one-sided frequency +mask = (fpos >= lo) & (fpos <= hi); +y = real(ifft(X .* mask)); +end + +function pk = pickPeaks(x, minDist, thr) +% PICKPEAKS Local maxima above threshold with a refractory distance +n = numel(x); +pk = []; +last = -inf; +for i = 2:n-1 + if x(i) > x(i-1) && x(i) >= x(i+1) && x(i) > thr + if i - last >= minDist + pk(end+1) = i; %#ok + last = i; + elseif ~isempty(pk) && x(i) > x(pk(end)) + pk(end) = i; % keep the taller peak within the refractory window + last = i; + end + end +end +end + +function y = fillEnds(y) +% FILLENDS Replace leading/trailing NaNs with the nearest valid value +valid = find(~isnan(y)); +if isempty(valid) + return; +end +y(1:valid(1)-1) = y(valid(1)); +y(valid(end)+1:end) = y(valid(end)); +end diff --git a/+pf2/+data/+aux/hrvFeatures.m b/+pf2/+data/+aux/hrvFeatures.m new file mode 100644 index 00000000..38581a07 --- /dev/null +++ b/+pf2/+data/+aux/hrvFeatures.m @@ -0,0 +1,161 @@ +function [hrv, info] = hrvFeatures(x, fs, opts) +% HRVFEATURES Heart-rate variability metrics from a waveform or beat series +% +% Computes standard time- and frequency-domain HRV metrics, which serve as +% autonomic/arousal covariates. Input may be a PPG/EKG waveform (beats are +% detected internally), a vector of beat times, or a vector of inter-beat (NN) +% intervals. The returned metrics are scalars (one summary per record/epoch). +% +% Reference: +% Task Force of the European Society of Cardiology and the North American +% Society of Pacing and Electrophysiology (1996). Heart rate variability: +% standards of measurement, physiological interpretation, and clinical use. +% Circulation, 93(5), 1043-1065. DOI: 10.1161/01.CIR.93.5.1043 +% +% Syntax: +% [hrv, info] = pf2.data.aux.hrvFeatures(x, fs) +% [hrv, info] = pf2.data.aux.hrvFeatures(ibiSeries, [], 'Input', 'ibi') +% [hrv, info] = pf2.data.aux.hrvFeatures(x, fs, 'Name', Value) +% +% Inputs: +% x - One of: a PPG/EKG waveform [T x 1] (default); beat times in seconds +% ('Input','beats'); or NN/RR intervals ('Input','ibi'). +% fs - Sampling rate in Hz (required for 'waveform'; ignored otherwise, pass +% [] ). +% +% Name-Value Parameters: +% 'Input' - 'waveform' (default) | 'beats' | 'ibi'. +% 'IBIUnit' - Unit of an 'ibi' input: 'ms' (default) or 's'. +% 'Band' - Cardiac band for waveform beat detection (default: [0.5 5]). +% 'LFBand' - Low-frequency band, Hz (default: [0.04 0.15]). +% 'HFBand' - High-frequency band, Hz (default: [0.15 0.40]). +% 'ResampleFs' - Tachogram resampling rate for spectral HRV (default: 4 Hz). +% +% Outputs: +% hrv - Struct of scalar metrics: +% .meanHR - mean heart rate (bpm) +% .meanNN - mean NN interval (ms) +% .SDNN - standard deviation of NN intervals (ms) +% .RMSSD - root mean square of successive differences (ms) +% .pNN50 - % of successive NN differences > 50 ms +% .LF - low-frequency power (ms^2), NaN if too few beats +% .HF - high-frequency power (ms^2), NaN if too few beats +% .LFHF - LF/HF ratio, NaN if HF is 0 or undefined +% info - Struct with: nBeats, beatTimes (s), source ('waveform'|'beats'|'ibi'). +% +% Notes: +% - Self-contained (no Signal Processing Toolbox dependency). +% - Frequency-domain metrics require a usable number of beats (>= ~20) and a +% recording long enough to resolve the LF band; otherwise LF/HF are NaN. +% - HRV from a smoothed HR *series* is not equivalent to beat-to-beat NN +% intervals; pass a waveform or NN intervals for valid SDNN/RMSSD. +% +% Example: +% hrv = pf2.data.aux.hrvFeatures(proc.Aux.ppg.data, proc.Aux.ppg.fs); +% +% 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 +inputType = lower(char(opts.Input)); +ibiUnit = lower(char(opts.IBIUnit)); +lfBand = opts.LFBand; +hfBand = opts.HFBand; +reFs = opts.ResampleFs; + +x = x(:); + +% --- Resolve NN intervals (ms) and beat times (s) ------------------------ +switch inputType + case 'waveform' + if isempty(fs) + error('pf2:hrvFeatures:noFs', 'fs is required for waveform input.'); + end + [~, hrInfo] = pf2.data.aux.heartRateFrom(x, fs, 'Band', opts.Band); + beatTimes = hrInfo.peakTimes(:); + nnMs = diff(beatTimes) * 1000; + case 'beats' + beatTimes = x; + nnMs = diff(beatTimes) * 1000; + case 'ibi' + if strcmp(ibiUnit, 's') + nnMs = x * 1000; + else + nnMs = x; + end + beatTimes = cumsum([0; nnMs / 1000]); + otherwise + error('pf2:hrvFeatures:badInput', ... + 'Input must be one of ''waveform'', ''beats'', or ''ibi'' (got ''%s'').', ... + inputType); +end + +info = struct('nBeats', numel(beatTimes), 'beatTimes', beatTimes, 'source', inputType); + +hrv = struct('meanHR', NaN, 'meanNN', NaN, 'SDNN', NaN, 'RMSSD', NaN, ... + 'pNN50', NaN, 'LF', NaN, 'HF', NaN, 'LFHF', NaN); + +if numel(nnMs) < 2 + return; +end + +% --- Time-domain metrics -------------------------------------------------- +hrv.meanNN = mean(nnMs); +hrv.meanHR = 60000 / max(hrv.meanNN, eps); % guard against zero/degenerate NN +hrv.SDNN = std(nnMs); +dNN = diff(nnMs); +hrv.RMSSD = sqrt(mean(dNN.^2)); +hrv.pNN50 = 100 * mean(abs(dNN) > 50); + +% --- Frequency-domain metrics (resampled tachogram PSD) ------------------ +% Require a tachogram long enough to resolve the LF lower edge: the lowest LF +% frequency (lfBand(1)) needs >= 1/lfBand(1) seconds, i.e. reFs/lfBand(1) +% samples. A shorter record cannot estimate LF, so LF/HF stay NaN. +minTachoSamp = ceil(reFs / lfBand(1)); +if numel(nnMs) >= 20 + % Tachogram: NN value at the time of each beat (use beat end times) + tNN = beatTimes(2:end); + tNN = tNN - tNN(1); + tGrid = (0:1/reFs:tNN(end))'; + if numel(tGrid) >= minTachoSamp + nnGrid = interp1(tNN, nnMs, tGrid, 'linear'); + nnGrid(isnan(nnGrid)) = 0; + nnGrid = detrend(nnGrid); % linear detrend (removes mean + slow drift) + N = numel(nnGrid); + X = fft(nnGrid); + psd = (abs(X).^2) / (N * reFs); % one-sided scaling below + f = (0:N-1)' * (reFs / N); + half = f <= reFs/2; + f = f(half); psd = psd(half); + psd(2:end) = 2 * psd(2:end); + hrv.LF = bandPower(f, psd, lfBand); + hrv.HF = bandPower(f, psd, hfBand); + if hrv.HF > 0 + hrv.LFHF = hrv.LF / hrv.HF; + end + end +end + +end + +%%_Subfunctions_________________________________________________________ + +function p = bandPower(f, psd, band) +% BANDPOWER Integrate the PSD over a frequency band (trapezoidal) +% Returns NaN (not 0) when the band is too sparsely sampled to integrate, so +% "unresolvable" is distinguishable from "no power". +mask = f >= band(1) & f <= band(2); +if nnz(mask) < 2 + p = NaN; + return; +end +p = trapz(f(mask), psd(mask)); +end diff --git a/+pf2/+data/+aux/hrvSeries.m b/+pf2/+data/+aux/hrvSeries.m new file mode 100644 index 00000000..7f078e88 --- /dev/null +++ b/+pf2/+data/+aux/hrvSeries.m @@ -0,0 +1,291 @@ +function [series, t] = hrvSeries(signal, fs, opts) +% HRVSERIES Time-resolved HRV metrics via a sliding window over a waveform +% +% Computes standard HRV metrics in successive overlapping windows of a PPG +% or EKG waveform, returning a continuous time series instead of a single +% scalar summary. Each window's metrics are placed at the window's mid-point +% time, producing a sparse but time-resolved representation that can be stored +% as a typed auxiliary signal and aligned to the fNIRS grid with +% pf2.data.auxOnGrid. This is the natural next step after pf2.data.aux.hrvFeatures +% for users who need HRV as a psychophysiological modulator or GLM regressor +% (e.g. PPI analyses, cardiac-arousal covariates, epoch-by-epoch quality checks). +% +% References: +% Task Force of the European Society of Cardiology and the North American +% Society of Pacing and Electrophysiology (1996). Heart rate variability: +% standards of measurement, physiological interpretation, and clinical use. +% Circulation, 93(5), 1043-1065. DOI: 10.1161/01.CIR.93.5.1043 +% +% Shaffer, F., & Ginsberg, J. P. (2017). An Overview of Heart Rate +% Variability Metrics and Norms. Frontiers in Public Health, 5. +% DOI: 10.3389/fpubh.2017.00258 +% +% Syntax: +% [series, t] = pf2.data.aux.hrvSeries(signal, fs) +% [series, t] = pf2.data.aux.hrvSeries(signal, fs, 'Name', Value) +% +% Inputs: +% signal - Raw PPG or EKG waveform [N x 1] at sampling rate fs. +% Must be a numeric column (or row) vector. NaN samples are +% treated as missing and are not interpolated across during beat +% detection. +% fs - Sampling rate in Hz [positive scalar]. +% +% Name-Value Parameters: +% 'Window' - Analysis window length in seconds (default: 60). +% Shorter windows reduce frequency-domain reliability; the +% Task Force guidelines recommend >= 5 min for full LF/HF +% estimates and >= 2 min for reliable time-domain metrics. +% Windows with fewer than 'MinBeats' detected beats produce +% NaN for all metrics. +% 'Step' - Advance between successive windows in seconds (default: 5). +% Mutually exclusive with 'Overlap'. With Step = Window the +% windows are non-overlapping. +% 'Overlap' - Fractional overlap in [0, 1); sets Step = Window*(1-Overlap). +% Mutually exclusive with 'Step'. Mirrors the convention in +% pf2.data.slidingWindows. +% 'Metric' - Metric(s) to include in the output, specified as a string +% or cellstr drawn from: 'meanHR', 'meanNN', 'SDNN', 'RMSSD', +% 'pNN50', 'LF', 'HF', 'LFHF' (default: all eight). +% Use a cell array to select a subset, e.g. {'RMSSD','LF','HF'}. +% 'MinBeats' - Minimum number of detected beats required in a window for +% that window's metrics to be non-NaN. Windows below this +% threshold return NaN for every metric, consistent with the +% gating in pf2.data.aux.hrvFeatures. Default scales with the +% window length (~0.33 beats/s, i.e. 20 beats for the default +% 60 s window, floored at 6) so short windows are not silently +% NaN-gated by a fixed 60 s-tuned threshold. Pass an explicit +% value to override the scaling. +% 'Band' - Cardiac band-pass [loHz hiHz] forwarded to beat detection +% (default: [0.5 5]). +% 'LFBand' - Low-frequency HRV band in Hz (default: [0.04 0.15]). +% 'HFBand' - High-frequency HRV band in Hz (default: [0.15 0.40]). +% 'ResampleFs'- Tachogram resampling rate for spectral HRV (default: 4 Hz). +% +% Outputs: +% series - Struct with one field per requested metric, each a [W x 1] +% column vector of windowed estimates (NaN where a window had +% insufficient beats). Units and field names match those returned +% by pf2.data.aux.hrvFeatures: +% .meanHR - mean heart rate (bpm) +% .meanNN - mean NN interval (ms) +% .SDNN - SD of NN intervals (ms) +% .RMSSD - root mean square of successive NN differences (ms) +% .pNN50 - % of successive differences > 50 ms +% .LF - low-frequency power (ms^2) +% .HF - high-frequency power (ms^2) +% .LFHF - LF/HF ratio +% Additionally contains: +% .time - [W x 1] window centre times in seconds +% .units - Struct of unit strings, one per metric field +% .metrics - Cellstr of metric field names actually present +% t - [W x 1] window centre times in seconds (identical to series.time). +% +% Algorithm: +% 1. Parse and validate inputs; resolve Step from Overlap if needed. +% 2. Build window start/end pairs covering the full signal at the chosen step. +% 3. For each window, extract the corresponding waveform slice and call +% pf2.data.aux.hrvFeatures, which handles beat detection and all HRV math. +% 4. Gate on MinBeats: windows with fewer detected beats receive NaN for +% every requested metric. +% 5. Assemble per-metric vectors and annotate with units and time. +% +% Example: +% % Basic usage: 60 s window, 5 s step, all metrics +% data = pf2.import.sampleData(); +% proc = processFNIRS2(data); +% ppg = proc.Aux.ppg.data; +% ppgFs = proc.Aux.ppg.fs; +% [series, t] = pf2.data.aux.hrvSeries(ppg, ppgFs); +% plot(t, series.RMSSD); +% xlabel('Time (s)'); ylabel('RMSSD (ms)'); +% +% % Fast step, RMSSD only (common PPI modulator) +% series = pf2.data.aux.hrvSeries(ppg, ppgFs, 'Window', 60, 'Step', 2, ... +% 'Metric', {'RMSSD'}); +% +% % Store as typed Aux signal and align onto the fNIRS grid +% proc = pf2.data.aux.addFeature(proc, 'hrvRMSSD', series.RMSSD, ... +% 'Time', series.time, 'Unit', 'ms'); +% rmssdOnGrid = pf2.data.auxOnGrid(proc, 'hrvRMSSD'); +% +% Notes: +% - Overlapping windows are not statistically independent; high overlap +% inflates the effective sample size for downstream parametric tests. +% - Frequency-domain metrics (LF, HF, LFHF) require the window to be long +% enough to resolve the LF lower edge (~1/0.04 Hz = 25 s minimum, but the +% default 60 s is strongly recommended). They return NaN for shorter windows +% even when MinBeats is met. +% - This function calls pf2.data.aux.hrvFeatures on each window slice; it +% does not duplicate the RR or metric math. Beat detection is therefore +% fully consistent with the scalar-HRV path. +% - The time axis is in seconds from the start of the signal (t = 0 at the +% first sample). If the fNIRS proc struct has an absolute time base, add +% proc.time(1) to series.time before calling addFeature. +% - For very short recordings (< Window), hrvSeries returns an empty series +% (W = 0) with all numeric fields as 0x1 doubles and a warning, rather +% than erroring, so batch pipelines remain robust. +% +% See also: pf2.data.aux.hrvFeatures, pf2.data.aux.heartRateFrom, +% 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 + +winLen = opts.Window; +if isempty(opts.MinBeats) + % Scale the beat-count gate to the window length (~0.33 beats/s, i.e. 20 + % beats for the default 60 s window, floored at 6) so short windows are not + % silently NaN-gated by a fixed 60 s-tuned threshold. + minBeats = max(6, round(winLen / 3)); +else + minBeats = opts.MinBeats; +end + +% --- Resolve Step from Step / Overlap (mutually exclusive) ---------------- +if ~isempty(opts.Step) && ~isempty(opts.Overlap) + error('pf2:hrvSeries:stepAndOverlap', ... + 'Specify only one of ''Step'' or ''Overlap'', not both.'); +end +if ~isempty(opts.Overlap) + step = winLen * (1 - opts.Overlap); +elseif ~isempty(opts.Step) + step = opts.Step; +else + step = 5; % default 5 s step +end + +% --- Resolve metric list -------------------------------------------------- +allMetrics = {'meanHR', 'meanNN', 'SDNN', 'RMSSD', 'pNN50', 'LF', 'HF', 'LFHF'}; +metricUnits = struct('meanHR','bpm','meanNN','ms','SDNN','ms','RMSSD','ms', ... + 'pNN50','%','LF','ms^2','HF','ms^2','LFHF','ratio'); + +metricArg = opts.Metric; +if ischar(metricArg) || isstring(metricArg) + if strcmpi(char(string(metricArg)), 'all') + metrics = allMetrics; + else + metrics = {char(string(metricArg))}; + end +else + metrics = cellstr(metricArg); +end +% Validate every requested metric name +for mi = 1:numel(metrics) + if ~ismember(metrics{mi}, allMetrics) + error('pf2:hrvSeries:badMetric', ... + '"%s" is not a recognized HRV metric. Valid names: %s.', ... + metrics{mi}, strjoin(allMetrics, ', ')); + end +end + +% --- Prepare signal ------------------------------------------------------- +signal = signal(:); +N = numel(signal); +tSig = (0:N-1)' / fs; % time axis in seconds, t=0 at first sample +recLen = tSig(end); + +% --- Handle recordings shorter than one full window ----------------------- +if recLen < winLen + warning('pf2:hrvSeries:recordTooShort', ... + ['Signal duration (%.1f s) is shorter than the requested window ' ... + '(%.1f s). Returning an empty series.'], recLen, winLen); + series = emptySeriesStruct(metrics, metricUnits); + t = zeros(0, 1); + return; +end + +% --- Build window start times --------------------------------------------- +% Small floating-point tolerance so the final full window is always included. +% Relative to the window/step magnitude (not a tiny fixed fraction of step) so +% that very small steps still admit the final window despite round-off. +tol = max(winLen, step) * 1e-6; +starts = (0 : step : recLen - winLen + tol)'; +nWin = numel(starts); + +% --- Allocate output arrays ----------------------------------------------- +data_out = nan(nWin, numel(metrics)); + +% --- Sliding window loop -------------------------------------------------- +for wi = 1:nWin + tStart = starts(wi); + tEnd = tStart + winLen; + + % Sample mask for this window + mask = tSig >= tStart & tSig < tEnd; + slice = signal(mask); + + % Skip windows that are entirely NaN or too short to detect beats + if all(isnan(slice)) || isempty(slice) + continue; + end + + % Delegate all beat detection and HRV math to hrvFeatures + try + [hrv, info] = pf2.data.aux.hrvFeatures(slice, fs, ... + 'Band', opts.Band, ... + 'LFBand', opts.LFBand, ... + 'HFBand', opts.HFBand, ... + 'ResampleFs', opts.ResampleFs); + catch + % Any error in a single window (e.g. degenerate slice) -> NaN row + continue; + end + + % Gate on MinBeats + if info.nBeats < minBeats + continue; + end + + % Copy requested metrics into the row + for mi = 1:numel(metrics) + data_out(wi, mi) = hrv.(metrics{mi}); + end +end + +% --- Assemble output struct ----------------------------------------------- +t = starts + winLen / 2; % window centre times +series = struct(); +series.time = t; +for mi = 1:numel(metrics) + series.(metrics{mi}) = data_out(:, mi); +end +units = struct(); +for mi = 1:numel(metrics) + units.(metrics{mi}) = metricUnits.(metrics{mi}); +end +series.units = units; +series.metrics = metrics; + +end + +%%_Subfunctions_________________________________________________________ + +function s = emptySeriesStruct(metrics, metricUnits) +% EMPTYSERIESSTRUCT Build an empty (0-window) series struct with correct fields +% Returns a struct with all metric fields as 0x1 doubles plus metadata, +% used when the recording is shorter than one window. +s = struct(); +s.time = zeros(0, 1); +for mi = 1:numel(metrics) + s.(metrics{mi}) = zeros(0, 1); +end +units = struct(); +for mi = 1:numel(metrics) + units.(metrics{mi}) = metricUnits.(metrics{mi}); +end +s.units = units; +s.metrics = metrics; +end diff --git a/+pf2/+data/+aux/respFeatures.m b/+pf2/+data/+aux/respFeatures.m new file mode 100644 index 00000000..3a0a6f90 --- /dev/null +++ b/+pf2/+data/+aux/respFeatures.m @@ -0,0 +1,166 @@ +function [feat, info] = respFeatures(x, fs, opts) +% RESPFEATURES Derive respiration rate and RVT from a respiration waveform +% +% Detects breaths in a respiration signal (belt / RIP / derived) and returns +% the instantaneous respiration rate (breaths per minute) and the respiration +% volume per time (RVT), both interpolated onto the input sample times. RVT is +% a standard low-frequency physiological nuisance regressor; respiration is the +% preferred conditioning signal for LFO/Mayer-band confound control. +% +% Reference: +% Birn, R. M., Diamond, J. B., Smith, M. A., & Bandettini, P. A. (2006). +% Separating respiratory-variation-related fluctuations from +% neuronal-activity-related fluctuations in fMRI. NeuroImage, 31(4), +% 1536-1548. DOI: 10.1016/j.neuroimage.2006.02.048 +% +% Syntax: +% [feat, info] = pf2.data.aux.respFeatures(x, fs) +% [feat, info] = pf2.data.aux.respFeatures(x, fs, 'Name', Value) +% +% Inputs: +% x - Respiration waveform [T x 1]. +% fs - Sampling rate in Hz [scalar]. +% +% Name-Value Parameters: +% 'Band' - Respiration band-pass [loHz hiHz] (default: [0.1 0.5]). +% 'MinRate' - Lowest plausible rate (breaths/min) (default: 5). +% 'MaxRate' - Highest plausible rate; sets the breath refractory period +% (default: 60). +% +% Outputs: +% feat - Struct with fields: +% .rate - [T x 1] instantaneous respiration rate (breaths/min), +% NaN-free (edges held at the nearest estimate). +% .rvt - [T x 1] respiration volume per time: peak-to-trough +% amplitude divided by breath period, per breath, interpolated. +% info - Struct with: peakIdx, troughIdx, peakTimes (s), meanRate, nBreaths, +% band. +% +% Algorithm: +% 1. Zero-phase band-pass to the respiration band (FFT brick-wall). +% 2. Peak (inhalation) and trough (exhalation) detection with a refractory +% period from MaxRate. +% 3. rate = 60 / breath-interval; RVT = (peak-trough)/period; both placed at +% breath times and linearly interpolated onto the input grid. +% +% Notes: +% - Self-contained (no Signal Processing Toolbox dependency). +% - The FFT brick-wall band-pass can ring near transients/edges; treat the +% first/last breath as less reliable. +% +% Example: +% [feat, info] = pf2.data.aux.respFeatures(proc.Aux.resp.data, proc.Aux.resp.fs); +% +% 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 +band = opts.Band; +maxRate = opts.MaxRate; + +x = x(:); +T = numel(x); +t = (0:T-1)' / fs; + +xf = bandpassFFT(x, fs, band(1), band(2)); + +minDist = max(1, round(fs * 60 / maxRate)); % refractory in samples +pThr = median(xf) + 0.3 * std(xf); +peakIdx = pickPeaks(xf, minDist, pThr); +troughIdx = pickPeaks(-xf, minDist, -median(xf) + 0.3 * std(xf)); + +info = struct('peakIdx', peakIdx(:), 'troughIdx', troughIdx(:), ... + 'peakTimes', t(peakIdx), 'meanRate', NaN, 'nBreaths', numel(peakIdx), ... + 'band', band); + +feat = struct('rate', nan(T, 1), 'rvt', nan(T, 1)); + +if numel(peakIdx) < 2 + feat.rate = repmat(60 * numel(peakIdx) / max(t(end), eps), T, 1); + feat.rvt = zeros(T, 1); + return; +end + +pkTimes = t(peakIdx); +ibi = diff(pkTimes); +instRate = 60 ./ ibi; +midTimes = pkTimes(1:end-1) + ibi / 2; + +% --- Respiration rate onto the grid -------------------------------------- +if numel(midTimes) == 1 + feat.rate = repmat(instRate, T, 1); +else + feat.rate = fillEnds(interp1(midTimes, instRate, t, 'linear')); +end +info.meanRate = mean(instRate); + +% --- RVT (Birn-style): within-cycle excursion / breath period ------------ +% For breath cycle k (peak_k -> peak_{k+1}), amplitude is the peak-to-deepest- +% exhalation-trough excursion within that same cycle, divided by the cycle +% period, so amplitude and period are drawn from the same breath. +rvtVals = zeros(numel(peakIdx) - 1, 1); +for k = 1:numel(peakIdx) - 1 + pkA = peakIdx(k); + pkB = peakIdx(k + 1); + inCycle = troughIdx(troughIdx > pkA & troughIdx < pkB); + if isempty(inCycle) + trVal = min(xf(pkA:pkB)); + else + trVal = min(xf(inCycle)); + end + period = pkTimes(k + 1) - pkTimes(k); + rvtVals(k) = abs(xf(pkA) - trVal) / max(period, eps); +end +if numel(midTimes) == 1 + feat.rvt = repmat(rvtVals(1), T, 1); +else + feat.rvt = fillEnds(interp1(midTimes, rvtVals, t, 'linear')); +end + +end + +%%_Subfunctions_________________________________________________________ + +function y = bandpassFFT(x, fs, lo, hi) +% BANDPASSFFT Zero-phase brick-wall band-pass via FFT masking +N = numel(x); +xm = x - mean(x); +X = fft(xm); +f = (0:N-1)' * (fs / N); +fpos = min(f, fs - f); +mask = (fpos >= lo) & (fpos <= hi); +y = real(ifft(X .* mask)); +end + +function pk = pickPeaks(x, minDist, thr) +% PICKPEAKS Local maxima above threshold with a refractory distance +n = numel(x); +pk = []; +last = -inf; +for i = 2:n-1 + if x(i) > x(i-1) && x(i) >= x(i+1) && x(i) > thr + if i - last >= minDist + pk(end+1) = i; %#ok + last = i; + elseif ~isempty(pk) && x(i) > x(pk(end)) + pk(end) = i; + last = i; + end + end +end +end + +function y = fillEnds(y) +% FILLENDS Replace leading/trailing NaNs with the nearest valid value +valid = find(~isnan(y)); +if isempty(valid) + return; +end +y(1:valid(1)-1) = y(valid(1)); +y(valid(end)+1:end) = y(valid(end)); +end diff --git a/+pf2/+data/+plot/auxData.m b/+pf2/+data/+plot/auxData.m index 7b6f74cc..07fc5968 100644 --- a/+pf2/+data/+plot/auxData.m +++ b/+pf2/+data/+plot/auxData.m @@ -1,4 +1,4 @@ -function [ figHandle ] = auxData(fNIR,rois2plot,showMarkers,bioMlist,baseline,ylimit,lineProps,rejectedLineProps) +function [ figHandle ] = auxData(fNIR, varargin) % AUXDATA Plot auxiliary temporal data alongside ROI hemoglobin signals % % Creates time series plots combining auxiliary data (accelerometer, @@ -8,105 +8,115 @@ % % Syntax: % pf2.data.plot.auxData(fNIR) -% pf2.data.plot.auxData(fNIR, rois2plot) -% pf2.data.plot.auxData(fNIR, rois2plot, showMarkers, bioMlist) -% pf2.data.plot.auxData(fNIR, rois2plot, showMarkers, bioMlist, baseline, ylimit) -% figHandle = pf2.data.plot.auxData(..., lineProps, rejectedLineProps) +% pf2.data.plot.auxData(fNIR, rois) % Specific ROIs +% pf2.data.plot.auxData(fNIR, ..., Name, Value) % With options % % Inputs: -% fNIR - fNIRS data structure with ROI and Aux fields [struct] -% Must contain 'ROI' field with 'info' table and -% biomarker data, plus auxiliary data to display. -% rois2plot - ROIs to display [numeric | cell | char | 'all'] -% (default: all ROIs) Can be numeric indices, logical -% array, ROI names as cell array, or 'all'. -% showMarkers - Display event markers [logical | numeric | 'all'] -% (default: true) If numeric, specifies marker codes. -% bioMlist - Biomarkers to plot [cell array of strings | 'all'] -% (default: {'HbO', 'HbR'}) -% Options: 'HbO', 'HbR', 'HbDiff', 'HbTotal', 'CBSI' -% baseline - Baseline correction specification [numeric | struct | logical] -% (default: false, no baseline) -% - Positive number: baseline duration from start (seconds) -% - Negative number: baseline from end of recording -% - [start, end]: explicit baseline window -% - fNIRS struct: use as baseline reference -% - true: use default 10s baseline -% ylimit - Y-axis limits [1x2 numeric | scalar] -% (default: auto from data range) -% Scalar value creates symmetric limits [-val, val]. -% lineProps - Line properties for good data [cell array] -% (default: {'LineWidth', 1}) -% rejectedLineProps - Line properties for rejected channels [cell array] -% (default: {'--', 'LineWidth', 1}) +% fNIR - fNIRS data structure with ROI and Aux fields [struct] +% Must contain 'ROI' field with 'info' table and +% biomarker data, plus auxiliary data to display. +% rois - (optional) ROIs to plot: numeric, logical, cell, or 'all' % -% Outputs: -% figHandle - Handle to the created figure [figure handle] -% Only returned when output argument is requested. +% Options (Name-Value): +% 'markers' - true (default), false, or numeric array of codes +% 'biomarkers' - {'HbO','HbR'} (default), or 'all', or specific list +% 'baseline' - false (default), or seconds, or [start,end] +% 'ylim' - [] (auto), or [min max] +% 'interactive' - true (default), false to skip prompts (for batch/headless) +% 'savePath' - '' (default), filename to save figure (.png, .pdf, .fig) +% 'saveWidth' - [] (default), figure width in pixels +% 'saveHeight' - [] (default), figure height in pixels +% 'saveDPI' - 150 (default), resolution for raster formats % % Example: -% % Load data with auxiliary channels -% data = pf2.import.importNIR('datafile.nir'); -% processed = processFNIRS2(data); -% -% % Build ROIs and plot with auxiliary data -% processed = pf2.probe.roi.Build(processed, {{1:9, 'PFC'}}); -% pf2.data.plot.auxData(processed); -% -% % Plot specific ROI with baseline and markers -% pf2.data.plot.auxData(processed, 'PFC', true, {'HbO','HbR'}, 10, [-2 2]); -% -% Notes: -% - Requires ROI field to be populated (see pf2.probe.roi.Build) -% - Auxiliary data displayed depends on what is available in fNIR.Aux -% - Rejected channels indicated with 'X' or '~' markers -% - Data cursor mode enabled for interactive value inspection +% pf2.data.plot.auxData(data) % Simple +% pf2.data.plot.auxData(data, 'FrontalL') % ROI by name +% pf2.data.plot.auxData(data, 1:2) % ROIs 1-2 +% pf2.data.plot.auxData(data, 'baseline', 10) % With 10s baseline +% pf2.data.plot.auxData(data, 1, 'ylim', [-2 2]) % ROI 1, fixed y-axis % % See also: pf2.data.plot.roi, pf2.data.plot.oxy, pf2.probe.roi.Build -global PF2 -if(~isfield(PF2,'RejectLevel')) - pf2_base.pf2_initialize(); -end -if(isfield(fNIR,'fchMask')) - rejectLevel=PF2.RejectLevel; -end - -if(~isfield(fNIR,'ROI')||~isfield(fNIR.ROI,'info')) - error('No ROI information present'); -end - - - -if(nargin<8||isempty(rejectedLineProps)) - rejectedLineProps={'--','LineWidth',1}; -end - -if(nargin<7||isempty(lineProps)) - lineProps={'LineWidth',1}; -end - - -if(nargin<6) - ylimit=[]; % will use max device info to plot -end - -if(nargin<5) - baseline=false; +% Validate fNIR input +if ~isstruct(fNIR) + error('pf2:InvalidInput', 'First argument must be a fNIRS data structure'); +end + +% Parameter names for detection +paramNames = {'markers', 'showmarkers', 'biomarkers', 'biomlist', 'baseline', ... + 'ylim', 'ylimit', 'lineprops', 'rejectedlineprops', 'interactive', ... + 'savepath', 'savewidth', 'saveheight', 'savedpi', 'rejectlevel'}; + +% Extract positional 'rois' argument if present +rois2plot = []; +nvStart = 1; + +if ~isempty(varargin) + firstArg = varargin{1}; + if isnumeric(firstArg) || islogical(firstArg) || ... + (ischar(firstArg) && strcmpi(firstArg, 'all')) + rois2plot = firstArg; + nvStart = 2; + elseif iscell(firstArg) + % Cell array of ROI names + rois2plot = firstArg; + nvStart = 2; + elseif ischar(firstArg) || isstring(firstArg) + if ~ismember(lower(char(firstArg)), paramNames) + % Not a param name, treat as ROI name + rois2plot = firstArg; + nvStart = 2; + end + end end +% Parse name-value pairs +p = inputParser; +p.CaseSensitive = false; +addParameter(p, 'markers', true, @(x) islogical(x) || isnumeric(x)); +addParameter(p, 'showMarkers', [], @(x) islogical(x) || isnumeric(x) || isempty(x)); % Legacy +addParameter(p, 'biomarkers', {'HbO', 'HbR'}, @(x) iscell(x) || ischar(x)); +addParameter(p, 'bioMlist', {}, @(x) iscell(x) || ischar(x)); % Legacy +addParameter(p, 'baseline', false, @(x) isnumeric(x) || islogical(x) || isstruct(x)); +addParameter(p, 'ylim', [], @isnumeric); +addParameter(p, 'ylimit', [], @isnumeric); % Legacy +addParameter(p, 'lineProps', {'LineWidth', 1}, @iscell); +addParameter(p, 'rejectedLineProps', {'--', 'LineWidth', 1}, @iscell); +addParameter(p, 'interactive', true, @islogical); +addParameter(p, 'savePath', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'saveWidth', [], @(x) isempty(x) || isnumeric(x)); +addParameter(p, 'saveHeight', [], @(x) isempty(x) || isnumeric(x)); +addParameter(p, 'saveDPI', 150, @isnumeric); +addParameter(p, 'rejectLevel', 0, @isnumeric); + +parse(p, varargin{nvStart:end}); + +% Assign parsed values (with legacy fallbacks) +showMarkers = p.Results.markers; +if ~isempty(p.Results.showMarkers), showMarkers = p.Results.showMarkers; end +bioMlist = p.Results.biomarkers; +if ~isempty(p.Results.bioMlist), bioMlist = p.Results.bioMlist; end +baseline = p.Results.baseline; +ylimit = p.Results.ylim; +if ~isempty(p.Results.ylimit), ylimit = p.Results.ylimit; end +lineProps = p.Results.lineProps; +rejectedLineProps = p.Results.rejectedLineProps; +interactive = p.Results.interactive; +savePath = p.Results.savePath; +saveWidth = p.Results.saveWidth; +saveHeight = p.Results.saveHeight; +saveDPI = p.Results.saveDPI; + +rejectLevel = p.Results.rejectLevel; -if(nargin<4||isempty(bioMlist)) - bioMlist={'HbO','HbR'}; +if(~isfield(fNIR,'ROI')||~isfield(fNIR.ROI,'info')) + error('pf2:data:plot:auxData:noROI', 'No ROI information present'); end -if(nargin<3) - showMarkers=true; %will plot all markers -end if(~iscell(bioMlist)) if(any(~ischar(bioMlist))) - error('Must specify biomarkers'); + error('pf2:data:plot:auxData:badBiomarkers', 'Must specify biomarkers'); end if(strcmpi(bioMlist,'all')) bioMlist={'HbO','HbR','HbDiff','HbTotal','CBSI'}; @@ -119,7 +129,7 @@ ROInames=fNIR.ROI.info.Properties.RowNames; -if(nargin<2||isempty(rois2plot)||(ischar(rois2plot)&&strcmpi(rois2plot,'all'))) +if(isempty(rois2plot)||(ischar(rois2plot)&&strcmpi(rois2plot,'all'))) rois2plot=[]; end @@ -131,46 +141,14 @@ end if(any(logical(rois2plot))&&~any(isnumeric(rois2plot))&&~any(ischar(rois2plot))) - rois2plot=find(rois2plot); + rois2plot=find(rois2plot); end - - - -if(pf2_base.isnestedfield(fNIR,'info.probename')&&isfield(fNIR.info,'probename')&&~contains(fNIR.info.probename,'Unknown')) - %try to load the probename cfg file - cfgFilePath=sprintf('%s.cfg',fNIR.info.probename); -else - cfgFilePath=''; -end - - -if(isempty(cfgFilePath)||~contains(cfgFilePath,'.cfg')) - - warning('Missing or invalid configuration file path\n') - - disp('No device specified. Please load device configuration'); - probeInfo=pf2_base.loadDeviceCfg([],true); - if(~isempty(probeInfo)) - error('No valid devices selected'); - end - -elseif(~isempty(cfgFilePath)) % If we're not looking at the GUI, doesn't matter - probeInfo=pf2_base.loadDeviceCfg(cfgFilePath,false); -end - -if(pf2_base.isnestedfield(probeInfo,'Probe')) - deviceInfo=probeInfo.Info; - if(~isfield(deviceInfo,'numberProbes')||deviceInfo.numberProbes==1) - probeNum=1; - end - probeInfo=probeInfo.Probe{probeNum}; -else - error('Unable to identify probe'); -end +% Load probe info using helper +probeInfo = pf2_base.plot.loadProbeInfo(fNIR, false); if(isempty(rois2plot)) @@ -179,19 +157,19 @@ for i=1:length(bioMlist) if(~isfield(fNIR,bioMlist{i})) - error('Biomarker %s does not exist',bioMlist{i}); + error('pf2:data:plot:auxData:missingBiomarker', 'Biomarker %s does not exist',bioMlist{i}); end - + if(isempty(fNIR.(bioMlist{i}))) - error('Biomarker %s is empty, please build ROI first',bioMlist{i}); + error('pf2:data:plot:auxData:emptyBiomarker', 'Biomarker %s is empty, please build ROI first',bioMlist{i}); end end - - + + if(any(rois2plot>size(fNIR.ROI.info,1))) - error('Some indexes are higher than number of ROIs'); + error('pf2:data:plot:auxData:roiOutOfRange', 'Some indexes are higher than number of ROIs'); elseif(any(rois2plot<0)) - error('ROI index can not be negative'); + error('pf2:data:plot:auxData:negativeROI', 'ROI index can not be negative'); end @@ -203,117 +181,45 @@ tmax=nanmax(t); tmean=nanmean(t)-tmin; else - error('Must have valid time field'); + error('pf2:data:plot:auxData:noTime', 'Must have valid time field'); end idx2plot=ismember(probeInfo.ChannelList,rois2plot); +sty = pf2_base.plot.PlotStyle.getDefault(); + if(~isempty(rois2plot)) if(nargout>0) - figHandle=figure(); + figHandle=figure('Color', sty.FigureColor); else - figure(); + figure('Color', sty.FigureColor); end else warning('Nothing to Plot'); - return; + return; end -if(~isfield(fNIR,'markers')||isempty(fNIR.markers)) - showMarkers=false; -end - -if(ischar(showMarkers)&&strcmpi(showMarkers,'all')) - showMarkers=true; -end +% Process markers using helper +tooManyMarkers = 100; +tooManyLabels = 10; +[showMarkers, showMarkersIdx, curMarkers, numMarkers] = ... + pf2_base.plot.processMarkers(fNIR, showMarkers, tooManyMarkers); -if(islogical(showMarkers)) - if(~showMarkers) - showMarkers=[]; +% Handle too many markers prompt +plotTonsOfMarkers = false; +if ~isempty(showMarkers) && any(numMarkers > tooManyMarkers) + if interactive + user_entry = input('Enable TonsOfMarkers Mode? (Can be VERY slow) y/n: ', 's'); + plotTonsOfMarkers = ismember(lower(user_entry), {'1', 'y', 'yes'}); else - [showMarkers,~,showMarkersIdx]=unique(fNIR.markers(:,2)); - end -elseif(isnumeric(showMarkers)) - [uMarkers,~,showMarkersIdxTemp]=unique(fNIR.markers(:,2)); - showMarkersIdx=nan(size(showMarkersIdxTemp)); - showMarkersUidx=find(ismember(uMarkers,showMarkers)); - for i=1:length(showMarkersUidx) - showMarkersIdx(showMarkersIdxTemp==(showMarkersUidx(i)))=i; - end - showMarkers=uMarkers(showMarkersUidx); -end - -if(isfield(fNIR,'markers')&&~isempty(showMarkers)) - curMarkers=fNIR.markers; - if(~isnumeric(curMarkers)&&isfield(curMarkers,'data')) - curMarkers=curMarkers.data; + warning('pf2:TooManyMarkers', 'Too many markers to display (>%d). Use ''interactive'', true to enable.', tooManyMarkers); end end -tooManyMarkers=100; -tooManyLabels=10; -if(~isempty(showMarkers)) - plotTonsOfMarkers=[]; - numMarkers=zeros(1,length(showMarkers)); - for i=1:length(showMarkers) - numMarkers(i)=sum(showMarkersIdx==i); - if(numMarkers(i)>tooManyMarkers&&isempty(plotTonsOfMarkers)) - fprintf(2,'Warning: Over %i markers for marker %i\n',tooManyMarkers,i); - user_entry = input(sprintf('Enable TonsOfMarkers Mode?\n(Can be VERY slow)\ny/n: '), 's'); - user_entry=lower(user_entry); - switch user_entry - case '1' - plotTonsOfMarkers=true; - case '0' - plotTonsOfMarkers=false; - case 'y' - plotTonsOfMarkers=true; - case 'n' - plotTonsOfMarkers=false; - case 'yes' - plotTonsOfMarkers=true; - case 'no' - plotTonsOfMarkers=false; - end - end - end - if(isempty(plotTonsOfMarkers)) - plotTonsOfMarkers=false; - end -end - - -if(islogical(baseline)&&baseline&&any(~isnumeric(baseline))) - baseline=10; - fNIR=pf2.data.split(fNIR,'blLength',baseline,'relative',true); - baseline=[nan,baseline]; -elseif(~any(~isnumeric(baseline))&&length(baseline)==1&&baseline>0&&baseline<(tmax-tmin)) - fNIR=pf2.data.split(fNIR,'blLength',baseline,'relative',true); - baseline=[nan,baseline]; -elseif(~any(~isnumeric(baseline))&&length(baseline)==1&&baseline<0&&baseline>(tmin-tmax)) %from end - if(baseline(1)<0) - baseline(1)=tmax-tmin+baseline(1); - baseline(2)=tmax-tmin; - end - fNIR=pf2.data.split(fNIR,'blStartTime',baseline(1),'blEndTime',baseline(2),'relative',true); - baseline=baseline+tmin; -elseif(any(isnumeric(baseline))&&length(baseline)==2) %from end - if(baseline(1)<0) - baseline(1)=tmax+baseline(1)-tmin; - end - if(baseline(2)<0) - baseline(2)=tmax+baseline(2)-tmin; - end - fNIR=pf2.data.split(fNIR,'blStartTime',baseline(1),'blEndTime',baseline(2),'relative',true); - baseline=baseline+tmin; -elseif(isstruct(baseline)&&isfield(baseline,'time')&&isfield(baseline,bioMlist{1})) - fNIR=pf2.data.split(fNIR,tmin,tmax,'blfNIR',baseline); - baseline=[]; -else - baseline=[]; -end +% Apply baseline correction using helper +[fNIR, baseline] = pf2_base.plot.processBaseline(fNIR, baseline, bioMlist); t=fNIR.time; @@ -349,38 +255,29 @@ h=cell(0); for(roiIdx=1:length(rois2plot)) roiNum=rois2plot(roiIdx); -% if(plotArranged) -% optPos=probeInfo.OptLayout2D{roiNum}; -% optPos([3,4])=optPos([3,4]).*[0.65,0.9]; -% optPos([1,2])=optPos([1,2])+0.03; -% h{roiIdx}= axes('Position',optPos,'Box','on'); -% -% else subplot(length(rois2plot),1,roiIdx); -% end - + gh=gcf(); dcm_obj=datacursormode(gh); set(dcm_obj,'DisplayStyle','datatip',... 'SnapToDataVertex','off','Enable','on'); set(dcm_obj,'UpdateFcn', @myupdatefcn); - - - - + + + if(oxyMaxValue>0&&oxyMinValue<0) - zeroH=plot([tmin,tmax],[0,0],'--k','HandleVisibility','off'); + zeroH=plot([tmin,tmax],[0,0],'--','Color',sty.ZeroLineColor,'HandleVisibility','off'); hold on; end - + for b=1:length(bioMlist) bioM=bioMlist{b}; - + bio2plot=fNIR.ROI.(bioM)(:,roiIdx); if(isfield(fNIR.ROI,'fchMask')&&fNIR.ROI.fchMask(roiNum)<=rejectLevel) lh=plot(t,bio2plot,rejectedLineProps{:},'color',colorTable.(bioM),lineProps{:}); - switch(fNIR.fchMask(roiNum)) + switch(fNIR.ROI.fchMask(roiNum)) case 0.5 th=text(tmin+tmean*0.6,mean(ylimit),'~','FontSize',20,'color',[ 0.9100,0.4100,0.1700]); case 0 @@ -394,12 +291,12 @@ end set(lh,'Tag',sprintf('Opt%i:%s',roiNum,bioM)); end - + if(~isempty(baseline)||isempty(showMarkers)) - maxH=plot([tmean],ylimit(2),'color',[1,1,1],'HandleVisibility','off'); - minH=plot([tmean],ylimit(1),'color',[1,1,1],'HandleVisibility','off'); + maxH=plot([tmean],ylimit(2),'color',sty.FigureColor,'HandleVisibility','off'); + minH=plot([tmean],ylimit(1),'color',sty.FigureColor,'HandleVisibility','off'); end - + if(~isempty(baseline)) if(~isnan(baseline(1))&&baseline(1)>0) bh=pf2_base.external.vline(tmin+baseline(1),'--r','Baseline Start',0.95); @@ -410,57 +307,69 @@ set(bh,'Tag','Baseline End'); end end - - + + if(~isempty(showMarkers)) for i=1:length(showMarkers) mrkName=sprintf('Mrk%i',showMarkers(i)); if(numMarkers(i)1) + + xlabel(sprintf('ROI%i: %s',roiNum,pf2_base.plot.escapeTeX(ROInames{roiNum}))); + + + if(length(bioMlist)>1) ylblstring=sprintf('\\Delta[X]'); else - ylblstring=sprintf('\\Delta[%s]',bioM{1}); + ylblstring=sprintf('\\Delta[%s]',bioMlist{1}); end - + if(isfield(fNIR,'units')) ylblstring=sprintf('%s %s',ylblstring,fNIR.units); end - + ylabel(ylblstring); - - + + if(roiIdx==length(rois2plot)) legend(bioMlist); end end +% Add figure title from processingInfo if available +pf2_base.plot.addProcessingInfoTitle(fNIR, gcf()); + +% Apply theme styling +sty.applyToFigure(gcf()); + +% Save figure if requested +if ~isempty(savePath) + fig = gcf(); + pf2_base.plot.saveFigure(fig, savePath, saveWidth, saveHeight, saveDPI); +end + end - + function txt = myupdatefcn(pointDataTip, event_obj) hAxes=get(pointDataTip,'Parent'); @@ -468,16 +377,16 @@ selectedObjectTag=event_obj.Target.Tag; if(~isempty(selectedObjectTag)&&contains(selectedObjectTag,'Baseline')) txt={sprintf('%s\nt=%.2f',selectedObjectTag,pos(1))}; - + elseif(~isempty(selectedObjectTag)&&contains(selectedObjectTag,'Mrk')) txt={sprintf('%s\nt=%.2f',selectedObjectTag,pos(1))}; elseif(~isempty(selectedObjectTag)) txt={sprintf('%s\nt=%.2f, y=%.2f',selectedObjectTag,pos(1),pos(2))}; - + else - txt={''}; + txt={''}; end - + for i=1:length(txt) txtprt=txt{i}; txtprt(txtprt=='_')=' '; diff --git a/+pf2/+data/+plot/oxy.m b/+pf2/+data/+plot/oxy.m index f4dfb468..ec97cf2c 100644 --- a/+pf2/+data/+plot/oxy.m +++ b/+pf2/+data/+plot/oxy.m @@ -1,4 +1,4 @@ -function [figHandle] = oxy(varargin) +function [figHandle] = oxy(fNIR, varargin) % OXY Plot hemoglobin concentration time series % % Creates time series plots of processed fNIRS hemoglobin data (HbO, HbR, @@ -7,95 +7,125 @@ % visual distinction of rejected channels. % % Syntax: -% pf2.data.plot.oxy(fNIR) % Plot all channels -% pf2.data.plot.oxy(fNIR, channel) % Plot specific channel -% pf2.data.plot.oxy(fNIR, 'all') % Explicit all channels -% pf2.data.plot.oxy(..., 'Name', Value) % With options -% figHandle = pf2.data.plot.oxy(...) % Return figure handle +% pf2.data.plot.oxy(fNIR) % All channels +% pf2.data.plot.oxy(fNIR, channels) % Specific channels +% pf2.data.plot.oxy(fNIR, ..., Name, Value) % With options % % Inputs: -% fNIR - Processed fNIRS structure with HbO, HbR fields -% channels - Channel(s) to plot (optional): -% - Numeric: Specific channel number(s) -% - 'all' or []: All channels in probe arrangement -% 'markers' - Marker display options: -% - true: Show all markers (default) -% - false: Hide markers -% - Numeric array: Show only specified marker codes -% 'bioMlist' - Biomarkers to plot (default: {'HbO', 'HbR'}) -% Options: 'HbO', 'HbR', 'HbTotal', 'HbDiff', 'CBSI' -% 'baseline' - Baseline subtraction: -% - Numeric: Seconds from start for baseline period -% - Negative: Index from end of recording -% - fNIR struct: Use separate data as baseline -% 'ylimit' - Y-axis limits [min max] for all subplots -% 'plotArranged' - Force probe arrangement layout (default: false) -% 'lineProps' - Line properties for all plots (cell array) -% Default: {'LineWidth', 1} -% 'rejectedLineProps' - Line properties for rejected channels -% Default: {'--', 'LineWidth', 1} -% 'showMarkers' - Display event markers (default: true) +% fNIR - Processed fNIRS structure with HbO, HbR fields +% channels - (optional) Channels to plot: numeric, logical, or 'all' % -% Outputs: -% figHandle - Handle to the created figure -% -% Notes: -% - Requires processed data (must contain HbO field) -% - Probe arrangement uses device configuration subplot layout -% - Rejected channels (fchMask < RejectLevel) shown with dashed lines -% - Standard biomarker colors: HbO=red, HbR=blue, HbTotal=green +% Options (Name-Value): +% 'markers' - true (default), false, or numeric array of codes +% 'biomarkers' - {'HbO','HbR'} (default), or 'all', or specific list +% 'baseline' - false (default), or seconds, or [start,end] +% 'ylim' - [] (auto), or [min max] +% 'interactive' - true (default), false to skip prompts (for batch/headless). +% When left unset, auto-detects headless sessions +% (pf2_base.isHeadless) and skips prompts automatically. +% 'savePath' - '' (default), filename to save figure (.png, .pdf, .fig) +% 'saveWidth' - [] (default), figure width in pixels +% 'saveHeight' - [] (default), figure height in pixels +% 'saveDPI' - 150 (default), resolution for raster formats % % Example: -% % Basic plot of all channels -% pf2.data.plot.oxy(processedData); -% -% % Plot specific channel with custom biomarkers -% pf2.data.plot.oxy(data, 5, 'bioMlist', {'HbO', 'HbR', 'HbTotal'}); +% pf2.data.plot.oxy(data) % Simple +% pf2.data.plot.oxy(data, 5) % Channel 5 +% pf2.data.plot.oxy(data, 1:5) % Channels 1-5 +% pf2.data.plot.oxy(data, 'baseline', 10) % With 10s baseline +% pf2.data.plot.oxy(data, 5, 'ylim', [-2 2]) % Channel 5, fixed y-axis % -% % Plot with baseline subtraction -% pf2.data.plot.oxy(data, 'baseline', 10); % 10s baseline -% -% See also: pf2.data.plot.raw, pf2.data.plot.roi, pf2.probe.plot.imageValues - -validFnirs = @(x) (iscell(x) || isstruct(x)); -validChannels = @(x) (isnumeric(x) || ischar(x)); -validbioMlist = @(x) (iscell(x) || ischar(x)); - -p=inputParser; -addRequired(p, 'fNIR', validFnirs); -addOptional(p, 'channels', [], validChannels); -addOptional(p, 'markers', [], @isnumeric); -addOptional(p, 'bioMlist', {'HbO', 'HbR'}, validbioMlist); -addOptional(p, 'baseline', false, @isnumeric); -addOptional(p, 'ylimit', [], @isnumeric); -addOptional(p, 'plotArranged', false, @islogical); -addOptional(p, 'lineProps', {'LineWidth', 1}, @iscell); -addOptional(p, 'rejectedLineProps', {'--', 'LineWidth', 1}, @iscell); -addOptional(p, 'showMarkers', true, @islogical); - -parse(p, varargin{:}); -fNIR = p.Results.fNIR; -channels = p.Results.channels; -showMarkers = p.Results.showMarkers; -bioMlist = p.Results.bioMlist; +% See also: pf2.data.plot.raw, pf2.data.plot.roi, pf2.probe.plot + +% Validate fNIR input +if ~isstruct(fNIR) + error('pf2:InvalidInput', 'First argument must be a fNIRS data structure'); +end + +% Parameter names for detection +paramNames = {'markers', 'biomarkers', 'biomlist', 'baseline', 'ylim', ... + 'ylimit', 'arranged', 'plotarranged', 'lineprops', ... + 'rejectedlineprops', 'showmarkers', 'interactive', ... + 'savepath', 'savewidth', 'saveheight', 'savedpi', 'rejectlevel'}; + +% Extract positional 'channels' argument if present +channels = []; +nvStart = 1; % Where name-value pairs start in varargin + +if ~isempty(varargin) + firstArg = varargin{1}; + % If first arg is numeric/logical/or 'all', it's channels + if isnumeric(firstArg) || islogical(firstArg) || ... + (ischar(firstArg) && strcmpi(firstArg, 'all')) + channels = firstArg; + nvStart = 2; + elseif ischar(firstArg) || isstring(firstArg) + % Check if it's a parameter name + if ~ismember(lower(char(firstArg)), paramNames) + % Not a param name, treat as channels specifier + channels = firstArg; + nvStart = 2; + end + end +end + +% Parse name-value pairs +p = inputParser; +p.CaseSensitive = false; +addParameter(p, 'markers', true, @(x) islogical(x) || isnumeric(x)); +addParameter(p, 'biomarkers', {'HbO', 'HbR'}, @(x) iscell(x) || ischar(x)); +addParameter(p, 'bioMlist', {}, @(x) iscell(x) || ischar(x)); % Legacy +addParameter(p, 'baseline', false, @(x) isnumeric(x) || islogical(x) || isstruct(x)); +addParameter(p, 'ylim', [], @isnumeric); +addParameter(p, 'ylimit', [], @isnumeric); % Legacy +addParameter(p, 'arranged', [], @(x) islogical(x) || isempty(x)); +addParameter(p, 'plotArranged', [], @(x) islogical(x) || isempty(x)); % Legacy +addParameter(p, 'lineProps', {'LineWidth', 1}, @iscell); +addParameter(p, 'rejectedLineProps', {'--', 'LineWidth', 1}, @iscell); +addParameter(p, 'showMarkers', [], @(x) islogical(x) || isnumeric(x) || isempty(x)); +addParameter(p, 'interactive', true, @islogical); +addParameter(p, 'savePath', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'saveWidth', [], @(x) isempty(x) || isnumeric(x)); +addParameter(p, 'saveHeight', [], @(x) isempty(x) || isnumeric(x)); +addParameter(p, 'saveDPI', 150, @isnumeric); +addParameter(p, 'rejectLevel', 0, @isnumeric); + +parse(p, varargin{nvStart:end}); + +% Assign parsed values (with legacy fallbacks) +showMarkers = p.Results.markers; +if ~isempty(p.Results.showMarkers), showMarkers = p.Results.showMarkers; end +bioMlist = p.Results.biomarkers; +if ~isempty(p.Results.bioMlist), bioMlist = p.Results.bioMlist; end baseline = p.Results.baseline; -ylimit = p.Results.ylimit; -plotArranged = p.Results.plotArranged; +ylimit = p.Results.ylim; +if ~isempty(p.Results.ylimit), ylimit = p.Results.ylimit; end +plotArranged = p.Results.arranged; +if ~isempty(p.Results.plotArranged), plotArranged = p.Results.plotArranged; end lineProps = p.Results.lineProps; rejectedLineProps = p.Results.rejectedLineProps; - - -global PF2 -if(~isfield(PF2,'RejectLevel')) - pf2_base.pf2_initialize(); +interactive = p.Results.interactive; +% Auto-detect non-interactive sessions when the caller didn't set 'interactive' +% explicitly, so the default never reaches a blocking input() under -batch / +% -nodisplay (a user can still force the prompt with 'interactive', true). +if ismember('interactive', p.UsingDefaults) && pf2_base.isHeadless() + interactive = false; end -if(isfield(fNIR,'fchMask')) - rejectLevel=PF2.RejectLevel; +savePath = p.Results.savePath; +saveWidth = p.Results.saveWidth; +saveHeight = p.Results.saveHeight; +saveDPI = p.Results.saveDPI; + +% Default plotArranged +if isempty(plotArranged) + plotArranged = false; end +rejectLevel = p.Results.rejectLevel; + if(~iscell(bioMlist)) if(any(~ischar(bioMlist))) - error('Must specify biomarkers'); + error('pf2:data:plot:oxy:badBiomarkers', 'Must specify biomarkers'); end if(strcmpi(bioMlist,'all')) bioMlist={'HbO','HbR','HbDiff','HbTotal','CBSI'}; @@ -111,48 +141,13 @@ if(length(channels)>1&&any(logical(channels))&&any(~isnumeric(channels))) if(any(~channels)) - plotArranged=true; + plotArranged=true; end - channels=find(channels); -end - - -if(isfield(fNIR,'probeinfo')) - probeInfo=fNIR.probeinfo; -else - - if(pf2_base.isnestedfield(fNIR,'info.probename')&&isfield(fNIR.info,'probename')&&~contains(fNIR.info.probename,'Unknown')) - %try to load the probename cfg file - cfgFilePath=sprintf('%s.cfg',fNIR.info.probename); - else - cfgFilePath=''; - end - - - if(isempty(cfgFilePath)||~contains(cfgFilePath,'.cfg')) - - warning('Missing or invalid configuration file path\n') - - disp('No device specified. Please load device configuration'); - probeInfo=pf2_base.loadDeviceCfg([],true); - if(~isempty(probeInfo)) - error('No valid devices selected'); - end - - elseif(~isempty(cfgFilePath)) % If we're not looking at the GUI, doesn't matter - probeInfo=pf2_base.loadDeviceCfg(cfgFilePath,plotArranged); - end + channels=find(channels); end -if(pf2_base.isnestedfield(probeInfo,'Probe')) - deviceInfo=probeInfo.Info; - if(~isfield(deviceInfo,'numberProbes')||deviceInfo.numberProbes==1) - probeNum=1; - end - probeInfo=probeInfo.Probe{probeNum}; -else - error('Unable to identify probe'); -end +% Load probe info using helper +probeInfo = pf2_base.plot.loadProbeInfo(fNIR, plotArranged); if(isempty(channels)) @@ -168,9 +163,9 @@ if(any(channels>probeInfo.NumOptodes)) - error('Some channels are higher than probe optode count'); + error('pf2:data:plot:oxy:channelOutOfRange', 'Some channels are higher than probe optode count'); elseif(any(channels<0)) - error('Channels can not be negative'); + error('pf2:data:plot:oxy:negativeChannel', 'Channels can not be negative'); end @@ -180,90 +175,40 @@ tmax=nanmax(t); tmean=nanmean(t)-tmin; else - error('Must have valid time field'); + error('pf2:data:plot:oxy:noTime', 'Must have valid time field'); end idx2plot=ismember(probeInfo.TableOpt.OptodeNum,channels); +sty = pf2_base.plot.PlotStyle.getDefault(); + if(~isempty(channels)) if(nargout>0) - figHandle=figure(); + figHandle=figure('Color', sty.FigureColor); clf(figHandle); else figHandle=gcf; clf(figHandle); - %figure(); + set(figHandle, 'Color', sty.FigureColor); end else warning('Nothing to Plot'); return; end -if(~isfield(fNIR,'markers')||isempty(fNIR.markers)) - showMarkers=false; -end - -if(ischar(showMarkers)&&strcmpi(showMarkers,'all')) - showMarkers=true; -end - -if(islogical(showMarkers)) - if(~showMarkers) - showMarkers=[]; - else - [showMarkers,~,showMarkersIdx]=unique(fNIR.markers(:,2)); - end -elseif(isnumeric(showMarkers)) - [uMarkers,~,showMarkersIdxTemp]=unique(fNIR.markers(:,2)); - showMarkersIdx=nan(size(showMarkersIdxTemp)); - showMarkersUidx=find(ismember(uMarkers,showMarkers)); - for i=1:length(showMarkersUidx) - showMarkersIdx(showMarkersIdxTemp==(showMarkersUidx(i)))=i; - end - showMarkers=uMarkers(showMarkersUidx); -end - -if(isfield(fNIR,'markers')&&~isempty(showMarkers)) - curMarkers=fNIR.markers; - if(~isnumeric(curMarkers)&&isfield(curMarkers,'data')) - curMarkers=curMarkers.data; - end -end +% Process markers using helper +numch2plot = length(channels); +tooManyMarkers = 1500 / numch2plot; +[showMarkers, showMarkersIdx, curMarkers, numMarkers] = ... + pf2_base.plot.processMarkers(fNIR, showMarkers, tooManyMarkers); -if(islogical(baseline)&&baseline&&any(~isnumeric(baseline))) - baseline=10; - fNIR=pf2.data.split(fNIR,'blLength',baseline,'relative',true); - baseline=[nan,baseline]; -elseif(~any(~isnumeric(baseline))&&length(baseline)==1&&baseline>0&&baseline<(tmax-tmin)) - fNIR=pf2.data.split(fNIR,'blLength',baseline,'relative',true); - baseline=[nan,baseline]; -elseif(~any(~isnumeric(baseline))&&length(baseline)==1&&baseline<0&&baseline>(tmin-tmax)) %from end - if(baseline(1)<0) - baseline(1)=tmax-tmin+baseline(1); - baseline(2)=tmax-tmin; - end - fNIR=pf2.data.split(fNIR,'blStartTime',baseline(1),'blEndTime',baseline(2),'relative',true); - baseline=baseline+tmin; -elseif(any(isnumeric(baseline))&&length(baseline)==2) %from end - if(baseline(1)<0) - baseline(1)=tmax+baseline(1)-tmin; - end - if(baseline(2)<0) - baseline(2)=tmax+baseline(2)-tmin; - end - fNIR=pf2.data.split(fNIR,'blStartTime',baseline(1),'blEndTime',baseline(2),'relative',true); - baseline=baseline+tmin; -elseif(isstruct(baseline)&&isfield(baseline,'time')&&isfield(baseline,bioMlist{1})) - fNIR=pf2.data.split(fNIR,tmin,tmax,'blfNIR',baseline); - baseline=[]; -else - baseline=[]; -end +% Apply baseline correction using helper +[fNIR, baseline] = pf2_base.plot.processBaseline(fNIR, baseline, bioMlist); @@ -296,61 +241,39 @@ h=cell(0); -numch2plot=length(channels); -tooManyLabels=200/numch2plot; - -tooManyMarkers=1500/numch2plot; - -if(~isempty(showMarkers)) - plotTonsOfMarkers=[]; - numMarkers=zeros(1,length(showMarkers)); - for i=1:length(showMarkers) - numMarkers(i)=sum(showMarkersIdx==i); - if(numMarkers(i)>tooManyMarkers&&isempty(plotTonsOfMarkers)) - fprintf(2,'Warning: ~ %.0f markers for marker %i\n',tooManyMarkers,i); - user_entry = input(sprintf('Enable TonsOfMarkers Mode?\n(Can be VERY slow)\ny/n: '), 's'); - user_entry=lower(user_entry); - switch user_entry - case '1' - plotTonsOfMarkers=true; - case '0' - plotTonsOfMarkers=false; - case 'y' - plotTonsOfMarkers=true; - case 'n' - plotTonsOfMarkers=false; - case 'yes' - plotTonsOfMarkers=true; - case 'no' - plotTonsOfMarkers=false; - end - end - end - if(isempty(plotTonsOfMarkers)) - plotTonsOfMarkers=false; +tooManyLabels = 200 / numch2plot; + +% Handle too many markers prompt (numMarkers already computed by helper) +plotTonsOfMarkers = false; +if ~isempty(showMarkers) && any(numMarkers > tooManyMarkers) + if interactive + user_entry = input('Enable TonsOfMarkers Mode? (Can be VERY slow) y/n: ', 's'); + plotTonsOfMarkers = ismember(lower(user_entry), {'1', 'y', 'yes'}); + else + warning('pf2:TooManyMarkers', 'Too many markers to display (>%d). Use ''interactive'', true to enable.', round(tooManyMarkers)); end end -printOnce=false; % flag for multiple printing -flagOnce=false; +printOnce = false; +flagOnce = false; -if(isfield(probeInfo,'OptPos')) - optLayout=probeInfo.OptPos.subplot_layout_ss; +if isfield(probeInfo, 'OptPos') + optLayout = probeInfo.OptPos.subplot_layout_ss; else - plotArranged=false; + plotArranged = false; end -for(optIdx=1:length(channels)) - optNum=channels(optIdx); - if(plotArranged) - optPos=optLayout{optNum}; - optPos([2])=1-optPos([2])-optPos([4]); %flips y vertical axis - optPos([3,4])=optPos([3,4]).*[0.65,0.7]; - optPos([1,2])=optPos([1,2])+[0.03,0.075]; - h{optIdx}= axes('Position',optPos,'Box','on'); - +for optIdx = 1:length(channels) + optNum = channels(optIdx); + if plotArranged + % Use helper for position calculation + optPos = pf2_base.plot.getOptodePosition(optLayout, optNum, [0.65, 0.7], [0.03, 0.075]); + if isempty(optPos) + continue; + end + h{optIdx} = axes('Position', optPos, 'Box', 'on'); else - subplot(length(channels),1,optIdx); + subplot(length(channels), 1, optIdx); end gh=gcf(); @@ -364,7 +287,7 @@ num2plot=sum(idx2plot); if(oxyMaxValue>0&&oxyMinValue<0) - zeroH=plot([tmin,tmax],[0,0],'--k','HandleVisibility','off'); + zeroH=plot([tmin,tmax],[0,0],'--','Color',sty.ZeroLineColor,'HandleVisibility','off'); hold on; end @@ -390,8 +313,8 @@ end if(~isempty(baseline)||isempty(showMarkers)) - maxH=plot([tmean],ylimit(2),'color',[1,1,1],'HandleVisibility','off'); - minH=plot([tmean],ylimit(1),'color',[1,1,1],'HandleVisibility','off'); + maxH=plot([tmean],ylimit(2),'color',sty.FigureColor,'HandleVisibility','off'); + minH=plot([tmean],ylimit(1),'color',sty.FigureColor,'HandleVisibility','off'); end if(~isempty(baseline)) @@ -411,11 +334,11 @@ for i=1:length(showMarkers) mrkName=sprintf('Mrk%i',showMarkers(i)); if(numMarkers(i)1) + if(length(bioMlist)>1) ylblstring=sprintf('\\Delta[X]'); else - ylblstring=sprintf('\\Delta[%s]',bioM{1}); + ylblstring=sprintf('\\Delta[%s]',bioMlist{1}); end if(isfield(fNIR,'units')) @@ -459,10 +382,22 @@ end end +% Add figure title from processingInfo if available +pf2_base.plot.addProcessingInfoTitle(fNIR, gcf()); + +% Apply theme styling +sty.applyToFigure(gcf()); + +% Save figure if requested +if ~isempty(savePath) + fig = gcf(); + pf2_base.plot.saveFigure(fig, savePath, saveWidth, saveHeight, saveDPI); end +end + + - function txt = myupdatefcn(pointDataTip, event_obj) hAxes=get(pointDataTip,'Parent'); diff --git a/+pf2/+data/+plot/raw.m b/+pf2/+data/+plot/raw.m index f27578cc..90cef2b6 100644 --- a/+pf2/+data/+plot/raw.m +++ b/+pf2/+data/+plot/raw.m @@ -1,121 +1,115 @@ -function [ figHandle ] = raw(varargin) +function [ figHandle ] = raw(fNIR, varargin) % RAW Plot raw light intensity data from fNIRS acquisition % % Creates time series plots of raw fNIRS intensity data, optionally % arranged according to probe geometry. Supports wavelength selection, -% marker overlay, and visual indication of rejected channels. Useful for -% quality assessment of raw data before processing. +% marker overlay, and visual indication of rejected channels. % % Syntax: -% pf2.data.plot.raw(fNIR) -% pf2.data.plot.raw(fNIR, channels) -% pf2.data.plot.raw(fNIR, channels, showMarkers, wavelengths) -% pf2.data.plot.raw(fNIR, channels, showMarkers, wavelengths, ylimit, plotArranged) -% figHandle = pf2.data.plot.raw(..., lineProps, rejectedLineProps) +% pf2.data.plot.raw(fNIR) % All channels +% pf2.data.plot.raw(fNIR, channels) % Specific channels +% pf2.data.plot.raw(fNIR, ..., Name, Value) % With options % % Inputs: -% fNIR - fNIRS data structure [struct] -% Must contain 'raw' [T x C] and 'time' [T x 1] fields. -% channels - Channels to plot [numeric array | logical | 'all'] -% (default: all channels, enables arranged plot) -% Can be channel numbers or logical index. -% showMarkers - Display event markers on plots [logical | numeric | 'all'] -% (default: true) If numeric, specifies marker codes to show. -% wavelengths - Wavelengths to include [numeric array | 'all'] -% (default: all available wavelengths from probe config) -% Common values: 730, 850 nm. -% ylimit - Y-axis limits for all subplots [1x2 numeric] -% (default: [RawMin, max(data)] from device config) -% plotArranged - Use probe geometry layout for subplots [logical] -% (default: true when all channels plotted) -% lineProps - Line properties for good channels [cell array] -% (default: {'LineWidth', 1}) -% rejectedLineProps - Line properties for rejected channels [cell array] -% (default: {'--', 'LineWidth', 1}) +% fNIR - fNIRS data structure with 'raw' and 'time' fields +% channels - (optional) Channels to plot: numeric, logical, or 'all' % -% Outputs: -% figHandle - Handle to the created figure [figure handle] -% Only returned when output argument is requested. +% Options (Name-Value): +% 'markers' - true (default), false, or numeric array of codes +% 'wavelengths' - [] (all), or specific wavelength values [730, 850] +% 'ylim' - [] (auto), or [min max] +% 'arranged' - [] (auto), true, or false +% 'interactive' - true (default), false to skip prompts (for batch/headless) +% 'savePath' - '' (default), filename to save figure (.png, .pdf, .fig) +% 'saveWidth' - [] (default), figure width in pixels +% 'saveHeight' - [] (default), figure height in pixels +% 'saveDPI' - 150 (default), resolution for raster formats % % Example: -% % Basic raw data plot -% data = pf2.import.sampleData.fNIR2000(); -% pf2.data.plot.raw(data); -% -% % Plot specific channels and wavelengths -% pf2.data.plot.raw(data, 1:5, true, 730); -% -% % Custom line styling with markers disabled -% pf2.data.plot.raw(data, 'all', false, 'all', [], true, ... -% {'LineWidth', 2, 'Color', 'b'}); -% -% Notes: -% - Requires valid device configuration for probe geometry -% - Rejected channels (fchMask=0) shown with 'X', marginal (0.5) with '~' -% - Data cursor mode enabled for interactive inspection -% - Large numbers of markers may prompt for confirmation (slow rendering) +% pf2.data.plot.raw(data) % Simple +% pf2.data.plot.raw(data, 5) % Channel 5 +% pf2.data.plot.raw(data, 1:5) % Channels 1-5 +% pf2.data.plot.raw(data, 'wavelengths', 730) % Single wavelength +% pf2.data.plot.raw(data, 5, 'markers', false) % No markers % % See also: pf2.data.plot.oxy, pf2.data.plot, pf2.settings.selectDevice -validFnirs = @(x) (iscell(x) || isstruct(x)); -validChannels = @(x) (isnumeric(x) || ischar(x)); -validWavelength = @(x) (isnumeric(x) || ischar(x)); - -p=inputParser; -addRequired(p, 'fNIR', validFnirs); -addOptional(p, 'channels', [], validChannels); -addOptional(p, 'showMarkers', true, @islogical); -addOptional(p, 'wavelengths', [], validWavelength); -addOptional(p, 'ylimit', [], @isnumeric); -addOptional(p, 'plotArranged', false, @islogical); -addOptional(p, 'lineProps', {'LineWidth', 1}, @iscell); -addOptional(p, 'rejectedLineProps', {'--', 'LineWidth', 1}, @iscell); - -parse(p, varargin{:}); -fNIR = p.Results.fNIR; -channels = p.Results.channels; -showMarkers = p.Results.showMarkers; -wavelengths = p.Results.wavelengths; -ylimit = p.Results.ylimit; -plotArranged = p.Results.plotArranged; -lineProps = p.Results.lineProps; -rejectedLineProps = p.Results.rejectedLineProps; - - -global PF2 -if(~isfield(PF2,'RejectLevel')) - pf2_base.pf2_initialize(); -end -if(isfield(fNIR,'fchMask')) - rejectLevel=PF2.RejectLevel; -end - -if(nargin<8||isempty(rejectedLineProps)) - rejectedLineProps={'--','LineWidth',1}; -end - -if(nargin<7||isempty(lineProps)) - lineProps={'LineWidth',1}; -end - - - -if(nargin<6) - plotArranged=false; % plot when channels is all or empty -end - -if(nargin<5) - ylimit=[]; % will use max device info to plot +% Validate fNIR input +if ~isstruct(fNIR) + error('pf2:InvalidInput', 'First argument must be a fNIRS data structure'); end - -if(nargin<3) - showMarkers=true; %will plot all markers +% Parameter names for detection +paramNames = {'markers', 'showmarkers', 'wavelengths', 'ylim', 'ylimit', ... + 'arranged', 'plotarranged', 'lineprops', 'rejectedlineprops', ... + 'interactive', 'savepath', 'savewidth', 'saveheight', 'savedpi', ... + 'rejectlevel'}; + +% Extract positional 'channels' argument if present +channels = []; +nvStart = 1; + +if ~isempty(varargin) + firstArg = varargin{1}; + if isnumeric(firstArg) || islogical(firstArg) || ... + (ischar(firstArg) && strcmpi(firstArg, 'all')) + channels = firstArg; + nvStart = 2; + elseif ischar(firstArg) || isstring(firstArg) + if ~ismember(lower(char(firstArg)), paramNames) + channels = firstArg; + nvStart = 2; + end + end end -if(nargin<2||isempty(channels)||(ischar(channels)&&strcmpi(channels,'all'))) - plotArranged=true; %Enabled when all channels are plot - channels=[]; +% Parse name-value pairs +p = inputParser; +p.CaseSensitive = false; +addParameter(p, 'markers', true, @(x) islogical(x) || isnumeric(x)); +addParameter(p, 'showMarkers', [], @(x) islogical(x) || isnumeric(x) || isempty(x)); % Legacy +addParameter(p, 'wavelengths', [], @(x) isnumeric(x) || ischar(x)); +addParameter(p, 'ylim', [], @isnumeric); +addParameter(p, 'ylimit', [], @isnumeric); % Legacy +addParameter(p, 'arranged', [], @(x) islogical(x) || isempty(x)); +addParameter(p, 'plotArranged', [], @(x) islogical(x) || isempty(x)); % Legacy +addParameter(p, 'lineProps', {'LineWidth', 1}, @iscell); +addParameter(p, 'rejectedLineProps', {'--', 'LineWidth', 1}, @iscell); +addParameter(p, 'interactive', true, @islogical); +addParameter(p, 'savePath', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'saveWidth', [], @(x) isempty(x) || isnumeric(x)); +addParameter(p, 'saveHeight', [], @(x) isempty(x) || isnumeric(x)); +addParameter(p, 'saveDPI', 150, @isnumeric); +addParameter(p, 'rejectLevel', 0, @isnumeric); + +parse(p, varargin{nvStart:end}); + +% Assign parsed values (with legacy fallbacks) +showMarkers = p.Results.markers; +if ~isempty(p.Results.showMarkers), showMarkers = p.Results.showMarkers; end +wavelengths = p.Results.wavelengths; +ylimit = p.Results.ylim; +if ~isempty(p.Results.ylimit), ylimit = p.Results.ylimit; end +plotArranged = p.Results.arranged; +if ~isempty(p.Results.plotArranged), plotArranged = p.Results.plotArranged; end +lineProps = p.Results.lineProps; +rejectedLineProps = p.Results.rejectedLineProps; +interactive = p.Results.interactive; +savePath = p.Results.savePath; +saveWidth = p.Results.saveWidth; +saveHeight = p.Results.saveHeight; +saveDPI = p.Results.saveDPI; + +rejectLevel = p.Results.rejectLevel; + +% Handle default plotArranged (true when all channels) +if isempty(channels) || (ischar(channels) && strcmpi(channels, 'all')) + if isempty(plotArranged) + plotArranged = true; % Enabled when all channels are plotted + end + channels = []; +elseif isempty(plotArranged) + plotArranged = false; end if(any(logical(channels))&&any(~isnumeric(channels))) @@ -127,42 +121,8 @@ -if(isfield(fNIR,'probeinfo')) - probeInfo=fNIR.probeinfo; -else - -if(pf2_base.isnestedfield(fNIR,'info.probename')&&isfield(fNIR.info,'probename')&&~contains(fNIR.info.probename,'Unknown')) - %try to load the probename cfg file - cfgFilePath=sprintf('%s.cfg',fNIR.info.probename); -else - cfgFilePath=''; -end - - -if(isempty(cfgFilePath)||~contains(cfgFilePath,'.cfg')) - - warning('Missing or invalid configuration file path\n') - - disp('No device specified. Please load device configuration'); - probeInfo=pf2_base.loadDeviceCfg([],true); - if(~isempty(probeInfo)) - error('No valid devices selected'); - end - -elseif(~isempty(cfgFilePath)) % If we're not looking at the GUI, doesn't matter - probeInfo=pf2_base.loadDeviceCfg(cfgFilePath,plotArranged); -end - -end -if(pf2_base.isnestedfield(probeInfo,'Probe')) - deviceInfo=probeInfo.Info; - if(~isfield(deviceInfo,'numberProbes')||deviceInfo.numberProbes==1) - probeNum=1; - end - probeInfo=probeInfo.Probe{probeNum}; -else - error('Unable to identify probe'); -end +% Load probe info using helper +[probeInfo, deviceInfo] = pf2_base.plot.loadProbeInfo(fNIR, plotArranged); @@ -191,14 +151,14 @@ fprintf(2,'%i ',wavelengths(i)); end fprintf('\n'); - error('No Wavelengths to plot'); + error('pf2:data:plot:raw:noWavelengths', 'No Wavelengths to plot'); end if(any(channels>probeInfo.NumOptodes)) - error('Some channels are higher than probe optode count'); + error('pf2:data:plot:raw:channelOutOfRange', 'Some channels are higher than probe optode count'); elseif(any(channels<0)) - error('Channels can not be negative'); + error('pf2:data:plot:raw:negativeChannel', 'Channels can not be negative'); end @@ -208,7 +168,7 @@ tmax=nanmax(t); tmean=nanmean(t)-tmin; else - error('Must have valid time field'); + error('pf2:data:plot:raw:noTime', 'Must have valid time field'); end idx2plot=ismember(probeInfo.TableCh.OptodeNumber,channels); @@ -234,9 +194,11 @@ RawMin=0; end +sty = pf2_base.plot.PlotStyle.getDefault(); + if(~isempty(channels)) if(nargout>0) - figHandle=figure(); + figHandle=figure('Color', sty.FigureColor); else %figure(); end @@ -245,36 +207,11 @@ return; end -if(~isfield(fNIR,'markers')||isempty(fNIR.markers)) - showMarkers=false; -end - -if(ischar(showMarkers)&&strcmpi(showMarkers,'all')) - showMarkers=true; -end - -if(islogical(showMarkers)) - if(~showMarkers) - showMarkers=[]; - else - [showMarkers,~,showMarkersIdx]=unique(fNIR.markers(:,2)); - end -elseif(isnumeric(showMarkers)) - [uMarkers,~,showMarkersIdxTemp]=unique(fNIR.markers(:,2)); - showMarkersIdx=nan(size(showMarkersIdxTemp)); - showMarkersUidx=find(ismember(uMarkers,showMarkers)); - for i=1:length(showMarkersUidx) - showMarkersIdx(showMarkersIdxTemp==(showMarkersUidx(i)))=i; - end - showMarkers=uMarkers(showMarkersUidx); -end - -if(isfield(fNIR,'markers')&&~isempty(showMarkers)) - curMarkers=fNIR.markers; - if(~isnumeric(curMarkers)&&isfield(curMarkers,'data')) - curMarkers=curMarkers.data; - end -end +% Process markers using helper +numch2plot = length(channels); +tooManyMarkers = 1500 / numch2plot; +[showMarkers, showMarkersIdx, curMarkers, numMarkers] = ... + pf2_base.plot.processMarkers(fNIR, showMarkers, tooManyMarkers); @@ -285,68 +222,40 @@ end -numch2plot=length(channels); -tooManyLabels=200/numch2plot; - -tooManyMarkers=1500/numch2plot; - -if(~isempty(showMarkers)) - plotTonsOfMarkers=[]; - numMarkers=zeros(1,length(showMarkers)); - for i=1:length(showMarkers) - numMarkers(i)=sum(showMarkersIdx==i); - if(numMarkers(i)>tooManyMarkers&&isempty(plotTonsOfMarkers)) - fprintf(2,'Warning: ~ %.0f markers for marker %i\n',tooManyMarkers,i); - user_entry = input(sprintf('Enable TonsOfMarkers Mode?\n(Can be VERY slow)\ny/n: '), 's'); - user_entry=lower(user_entry); - switch user_entry - case '1' - plotTonsOfMarkers=true; - case '0' - plotTonsOfMarkers=false; - case 'y' - plotTonsOfMarkers=true; - case 'n' - plotTonsOfMarkers=false; - case 'yes' - plotTonsOfMarkers=true; - case 'no' - plotTonsOfMarkers=false; - end - end - end - if(isempty(plotTonsOfMarkers)) - plotTonsOfMarkers=false; +tooManyLabels = 200 / numch2plot; + +% Handle too many markers prompt (numMarkers already computed by helper) +plotTonsOfMarkers = false; +if ~isempty(showMarkers) && any(numMarkers > tooManyMarkers) + if interactive + user_entry = input('Enable TonsOfMarkers Mode? (Can be VERY slow) y/n: ', 's'); + plotTonsOfMarkers = ismember(lower(user_entry), {'1', 'y', 'yes'}); + else + warning('pf2:TooManyMarkers', 'Too many markers to display (>%d). Use ''interactive'', true to enable.', round(tooManyMarkers)); end end -printOnce=false; % flag for multiple printing -flagOnce=false; +printOnce = false; +flagOnce = false; -if(isfield(probeInfo,'OptPos')) - optLayout=probeInfo.OptPos.subplot_layout_ss; -elseif(isfield(probeInfo,'OptPos')) - optLayout=probeInfo.OptPos.OptPos.subplot_layout; +if isfield(probeInfo, 'OptPos') + optLayout = probeInfo.OptPos.subplot_layout_ss; else - plotArranged=false; + plotArranged = false; end -h=cell(0); -for(optIdx=1:length(channels)) - optNum=channels(optIdx); - if(plotArranged) - if optNum > numel(optLayout) - continue - else - optPos=optLayout{optNum}; - optPos([2])=1-optPos([2])-optPos([4]); %flips y vertical axis - optPos([3,4])=optPos([3,4]).*[0.65,0.9]; - optPos([1,2])=optPos([1,2])+0.03; - h{optIdx}= axes('Position',optPos,'Box','on'); +h = cell(0); +for optIdx = 1:length(channels) + optNum = channels(optIdx); + if plotArranged + % Use helper for position calculation + optPos = pf2_base.plot.getOptodePosition(optLayout, optNum, [0.65, 0.7], [0.03, 0.075]); + if isempty(optPos) + continue; end - + h{optIdx} = axes('Position', optPos, 'Box', 'on'); else - h{optIdx}=subplot(length(channels),1,optIdx); + h{optIdx} = subplot(length(channels), 1, optIdx); end gh=gcf(); @@ -364,11 +273,11 @@ rawToPlot=fNIR.raw(:,idx2plot); - minH=plot([tmin,tmax],[RawMin,RawMin],'k','HandleVisibility','off'); + minH=plot([tmin,tmax],[RawMin,RawMin],'-','Color',sty.ForegroundColor,'HandleVisibility','off'); set(minH,'Tag',sprintf('Min Device Intensity')); hold on; if(~isempty(RawMax)) - maxH=plot([tmin,tmax],[RawMax,RawMax],'--k','HandleVisibility','off'); + maxH=plot([tmin,tmax],[RawMax,RawMax],'--','Color',sty.ForegroundColor,'HandleVisibility','off'); set(maxH,'Tag',sprintf('Max Device Intensity')); end @@ -397,17 +306,17 @@ ylim(ylimit); if(~isempty(showMarkers)) - maxH=plot([tmean],ylimit(2),'color',[1,1,1],'HandleVisibility','off'); - minH=plot([tmean],ylimit(1),'color',[1,1,1],'HandleVisibility','off'); + maxH=plot([tmean],ylimit(2),'color',sty.FigureColor,'HandleVisibility','off'); + minH=plot([tmean],ylimit(1),'color',sty.FigureColor,'HandleVisibility','off'); for i=1:length(showMarkers) - + mrkName=sprintf('Mrk%i',showMarkers(i)); if(numMarkers(i)size(fNIR.ROI.info,1))) - error('Some indexes are higher than number of ROIs'); + error('pf2:data:plot:roi:roiOutOfRange', 'Some indexes are higher than number of ROIs'); elseif(any(rois2plot<0)) - error('ROI index can not be negative'); + error('pf2:data:plot:roi:negativeROI', 'ROI index can not be negative'); end @@ -205,117 +185,45 @@ tmax=nanmax(t); tmean=nanmean(t)-tmin; else - error('Must have valid time field'); + error('pf2:data:plot:roi:noTime', 'Must have valid time field'); end idx2plot=ismember(probeInfo.ChannelList,rois2plot); +sty = pf2_base.plot.PlotStyle.getDefault(); + if(~isempty(rois2plot)) if(nargout>0) - figHandle=figure(); + figHandle=figure('Color', sty.FigureColor); else - figure(); + figure('Color', sty.FigureColor); end else warning('Nothing to Plot'); return; end -if(~isfield(fNIR,'markers')||isempty(fNIR.markers)) - showMarkers=false; -end +% Process markers using helper +tooManyMarkers = 100; +tooManyLabels = 10; +[showMarkers, showMarkersIdx, curMarkers, numMarkers] = ... + pf2_base.plot.processMarkers(fNIR, showMarkers, tooManyMarkers); -if(ischar(showMarkers)&&strcmpi(showMarkers,'all')) - showMarkers=true; -end - -if(islogical(showMarkers)) - if(~showMarkers) - showMarkers=[]; +% Handle too many markers prompt +plotTonsOfMarkers = false; +if ~isempty(showMarkers) && any(numMarkers > tooManyMarkers) + if interactive + user_entry = input('Enable TonsOfMarkers Mode? (Can be VERY slow) y/n: ', 's'); + plotTonsOfMarkers = ismember(lower(user_entry), {'1', 'y', 'yes'}); else - [showMarkers,~,showMarkersIdx]=unique(fNIR.markers(:,2)); - end -elseif(isnumeric(showMarkers)) - [uMarkers,~,showMarkersIdxTemp]=unique(fNIR.markers(:,2)); - showMarkersIdx=nan(size(showMarkersIdxTemp)); - showMarkersUidx=find(ismember(uMarkers,showMarkers)); - for i=1:length(showMarkersUidx) - showMarkersIdx(showMarkersIdxTemp==(showMarkersUidx(i)))=i; + warning('pf2:TooManyMarkers', 'Too many markers to display (>%d). Use ''interactive'', true to enable.', tooManyMarkers); end - showMarkers=uMarkers(showMarkersUidx); end -if(isfield(fNIR,'markers')&&~isempty(showMarkers)) - curMarkers=fNIR.markers; - if(~isnumeric(curMarkers)&&isfield(curMarkers,'data')) - curMarkers=curMarkers.data; - end -end - -tooManyMarkers=100; -tooManyLabels=10; -if(~isempty(showMarkers)) - plotTonsOfMarkers=[]; - numMarkers=zeros(1,length(showMarkers)); - for i=1:length(showMarkers) - numMarkers(i)=sum(showMarkersIdx==i); - if(numMarkers(i)>tooManyMarkers&&isempty(plotTonsOfMarkers)) - fprintf(2,'Warning: Over %i markers for marker %i\n',tooManyMarkers,i); - user_entry = input(sprintf('Enable TonsOfMarkers Mode?\n(Can be VERY slow)\ny/n: '), 's'); - user_entry=lower(user_entry); - switch user_entry - case '1' - plotTonsOfMarkers=true; - case '0' - plotTonsOfMarkers=false; - case 'y' - plotTonsOfMarkers=true; - case 'n' - plotTonsOfMarkers=false; - case 'yes' - plotTonsOfMarkers=true; - case 'no' - plotTonsOfMarkers=false; - end - end - end - if(isempty(plotTonsOfMarkers)) - plotTonsOfMarkers=false; - end -end - - -if(islogical(baseline)&&baseline&&any(~isnumeric(baseline))) - baseline=10; - fNIR=pf2.data.split(fNIR,'blLength',baseline,'relative',true); - baseline=[nan,baseline]; -elseif(~any(~isnumeric(baseline))&&length(baseline)==1&&baseline>0&&baseline<(tmax-tmin)) - fNIR=pf2.data.split(fNIR,'blLength',baseline,'relative',true); - baseline=[nan,baseline]; -elseif(~any(~isnumeric(baseline))&&length(baseline)==1&&baseline<0&&baseline>(tmin-tmax)) %from end - if(baseline(1)<0) - baseline(1)=tmax-tmin+baseline(1); - baseline(2)=tmax-tmin; - end - fNIR=pf2.data.split(fNIR,'blStartTime',baseline(1),'blEndTime',baseline(2),'relative',true); - baseline=baseline+tmin; -elseif(any(isnumeric(baseline))&&length(baseline)==2) %from end - if(baseline(1)<0) - baseline(1)=tmax+baseline(1)-tmin; - end - if(baseline(2)<0) - baseline(2)=tmax+baseline(2)-tmin; - end - fNIR=pf2.data.split(fNIR,'blStartTime',baseline(1),'blEndTime',baseline(2),'relative',true); - baseline=baseline+tmin; -elseif(isstruct(baseline)&&isfield(baseline,'time')&&isfield(baseline,bioMlist{1})) - fNIR=pf2.data.split(fNIR,tmin,tmax,'blfNIR',baseline); - baseline=[]; -else - baseline=[]; -end +% Apply baseline correction using helper +[fNIR, baseline] = pf2_base.plot.processBaseline(fNIR, baseline, bioMlist); t=fNIR.time; @@ -372,7 +280,7 @@ if(oxyMaxValue>0&&oxyMinValue<0) - zeroH=plot([tmin,tmax],[0,0],'--k','HandleVisibility','off'); + zeroH=plot([tmin,tmax],[0,0],'--','Color',sty.ZeroLineColor,'HandleVisibility','off'); hold on; end @@ -382,7 +290,7 @@ bio2plot=fNIR.ROI.(bioM)(:,roiIdx); if(isfield(fNIR.ROI,'fchMask')&&fNIR.ROI.fchMask(roiNum)<=rejectLevel) lh=plot(t,bio2plot,rejectedLineProps{:},'color',colorTable.(bioM),lineProps{:}); - switch(fNIR.fchMask(roiNum)) + switch(fNIR.ROI.fchMask(roiNum)) case 0.5 th=text(tmin+tmean*0.6,mean(ylimit),'~','FontSize',20,'color',[ 0.9100,0.4100,0.1700]); case 0 @@ -398,8 +306,8 @@ end if(~isempty(baseline)||isempty(showMarkers)) - maxH=plot([tmean],ylimit(2),'color',[1,1,1],'HandleVisibility','off'); - minH=plot([tmean],ylimit(1),'color',[1,1,1],'HandleVisibility','off'); + maxH=plot([tmean],ylimit(2),'color',sty.FigureColor,'HandleVisibility','off'); + minH=plot([tmean],ylimit(1),'color',sty.FigureColor,'HandleVisibility','off'); end if(~isempty(baseline)) @@ -418,9 +326,9 @@ for i=1:length(showMarkers) mrkName=sprintf('Mrk%i',showMarkers(i)); if(numMarkers(i)1) + if(length(bioMlist)>1) ylblstring=sprintf('\\Delta[X]'); else - ylblstring=sprintf('\\Delta[%s]',bioM{1}); + ylblstring=sprintf('\\Delta[%s]',bioMlist{1}); end if(isfield(fNIR,'units')) @@ -459,6 +367,18 @@ end end +% Add figure title from processingInfo if available +pf2_base.plot.addProcessingInfoTitle(fNIR, gcf()); + +% Apply theme styling +sty.applyToFigure(gcf()); + +% Save figure if requested +if ~isempty(savePath) + fig = gcf(); + pf2_base.plot.saveFigure(fig, savePath, saveWidth, saveHeight, saveDPI); +end + end diff --git a/+pf2/+data/applyChannelMask.m b/+pf2/+data/applyChannelMask.m index f7ae908b..04c284e1 100644 --- a/+pf2/+data/applyChannelMask.m +++ b/+pf2/+data/applyChannelMask.m @@ -1,24 +1,28 @@ -function fnir=applyChannelMask(fnir) +function fnir=applyChannelMask(fnir, rejectLevel) % APPLYCHANNELMASK Set bad channels to NaN based on channel quality mask % % Applies the channel rejection mask (fchMask) to all biomarker fields in % an fNIRS struct by setting data from rejected channels to NaN. Channels -% with fchMask values at or below the global RejectLevel threshold are -% considered rejected. This allows downstream analysis to ignore poor -% quality channels while preserving data structure dimensions. +% with fchMask values at or below the rejectLevel threshold are considered +% rejected. This allows downstream analysis to ignore poor quality channels +% while preserving data structure dimensions. % % Syntax: % fnir = pf2.data.applyChannelMask(fnir) +% fnir = pf2.data.applyChannelMask(fnir, rejectLevel) % % Inputs: -% fnir - fNIRS data structure [struct] -% Must contain 'fchMask' field [1 x C] where values indicate -% channel quality (1=good, 0.5=marginal, 0=bad). Biomarker -% fields (HbO, HbR, HbTotal, HbDiff, CBSI) will be modified. +% fnir - fNIRS data structure [struct] +% Must contain 'fchMask' field [1 x C] where values indicate +% channel quality (1=good, 0.5=marginal, 0=bad). Biomarker +% fields (HbO, HbR, HbTotal, HbDiff, CBSI) will be modified. +% rejectLevel - (optional) Rejection threshold (default: 0) +% Channels with fchMask <= rejectLevel are set to NaN. +% 0 = reject only fully bad channels, 0.5 = also reject marginal. % % Outputs: % fnir - Modified fNIRS struct with rejected channels set to NaN [struct] -% Biomarker data columns for channels where fchMask <= RejectLevel +% Biomarker data columns for channels where fchMask <= rejectLevel % are replaced with NaN values. % % Example: @@ -34,21 +38,21 @@ % fprintf('Channel 3 is now NaN: %d\n', all(isnan(masked.HbO(:,3)))); % % Notes: -% - Uses global PF2.RejectLevel to determine rejection threshold % - Only affects biomarker fields (HbO, HbR, etc.), not raw data % - Channel dimensions are preserved; rejected data becomes NaN -% - Call pf2_base.pf2_initialize() if PF2 global is not set % % See also: pf2.data.editChannelMaskGUI, processFNIRS2, pf2.settings.setRejectLevel -global PF2 +if nargin < 2 || isempty(rejectLevel) + rejectLevel = 0; +end validFields=pf2_base.pf2_getFNIRSbiomFields(); if(isfield(fnir,'fchMask')) for i=1:length(validFields) if(isfield(fnir,validFields{i})) - fnir.(validFields{i})(:,~(fnir.fchMask>PF2.RejectLevel))=NaN; + fnir.(validFields{i})(:,~(fnir.fchMask>rejectLevel))=NaN; end end end diff --git a/+pf2/+data/auxOnGrid.m b/+pf2/+data/auxOnGrid.m new file mode 100644 index 00000000..f08c1b8c --- /dev/null +++ b/+pf2/+data/auxOnGrid.m @@ -0,0 +1,241 @@ +function [vals, info] = auxOnGrid(data, name, opts) +% AUXONGRID Resample a named auxiliary signal onto a target time base +% +% Returns an auxiliary signal aligned to an arbitrary time grid (the fNIRS +% time vector by default) so it can be used as a regressor, covariate, or +% overlay. Handles anti-aliasing when downsampling, clock offset between +% devices, NaN gaps (not interpolated across), and out-of-range samples +% (set to NaN rather than extrapolated). This is the single alignment +% primitive that the Aux modeling and correction functions build on. +% +% Syntax: +% [vals, info] = pf2.data.auxOnGrid(data, name) +% vals = pf2.data.auxOnGrid(data, name, 'Name', Value) +% +% Inputs: +% data - fNIRS data struct with a .Aux container and a .time vector. +% name - Auxiliary signal name [char|string], a field of data.Aux +% (e.g. 'heartRate', 'accelerometer'). +% +% Name-Value Parameters: +% 'Time' - Target time grid [N x 1] in seconds (default: data.time). +% 'Channels' - Channel subset: indices [1 x K] or names (cellstr/string) +% matched against the signal's varNames (default: all). +% 'Method' - interp1 method for alignment (default: 'linear'). +% 'Offset' - Clock offset in seconds added to the Aux time base before +% alignment, to correct device skew (default: 0). +% 'AntiAlias' - Low-pass filter the source before downsampling (default: +% true). Ignored when upsampling. +% 'MaxGap' - Source-time gaps (or NaN runs) wider than this many seconds +% are not interpolated across; target points inside such a gap +% are returned as NaN (default: Inf, i.e. interpolate freely). +% +% Outputs: +% vals - [N x K] signal sampled on the target grid (NaN where unavailable). +% info - Struct with fields: signal, channels, srcFs, tgtFs, offset, +% antiAliased (logical), nInterp (in-range target samples), nNaN. +% +% Notes: +% - The signal is read through pf2_base.normalizeAux, so any reasonable Aux +% shape (struct/table/numeric, missing varNames, etc.) is accepted. +% - Anti-aliasing uses a zero-phase Hann-windowed moving average sized to +% the target sample period; it requires no Signal Processing Toolbox. +% - With the default grid equal to the source time base, the output equals +% the input (identity), aside from NaN handling. +% +% Example: +% hr = pf2.data.auxOnGrid(proc, 'heartRate'); % onto proc.time +% acc = pf2.data.auxOnGrid(proc, 'accelerometer', 'Channels', {'X','Y'}); +% eda = pf2.data.auxOnGrid(proc, 'gsr', 'Offset', 0.25, 'MaxGap', 2); +% +% 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 + +name = char(string(name)); +method = char(string(opts.Method)); +offset = opts.Offset; +antiAlias = opts.AntiAlias; +maxGap = opts.MaxGap; + +% --- Locate and normalize the requested signal --------------------------- +if ~isfield(data, 'Aux') || isempty(data.Aux) || ~isstruct(data.Aux) + error('pf2:auxOnGrid:noAux', 'Data has no .Aux container.'); +end +try + [sig, sigName] = pf2_base.resolveAux(data.Aux, name); +catch ME + if strcmp(ME.identifier, 'pf2:resolveAux:notFound') + error('pf2:auxOnGrid:notFound', '%s', ME.message); + else + rethrow(ME); + end +end + +srcTime = sig.time(:) + offset; +srcData = sig.data; +if isrow(srcData), srcData = srcData(:); end + +% --- Channel subset ------------------------------------------------------- +nCh = size(srcData, 2); +chans = opts.Channels; +if isempty(chans) + chanIdx = 1:nCh; +elseif isnumeric(chans) + chanIdx = chans(:)'; +else + chans = cellstr(chans); + chanIdx = zeros(1, numel(chans)); + for c = 1:numel(chans) + m = find(strcmpi(sig.varNames, chans{c}), 1); + if isempty(m) + error('pf2:auxOnGrid:badChannel', ... + 'Channel "%s" not found in signal "%s".', chans{c}, sigName); + end + chanIdx(c) = m; + end +end +srcData = srcData(:, chanIdx); + +% --- Target grid ---------------------------------------------------------- +tgt = opts.Time; +if isempty(tgt) + if ~isfield(data, 'time') || isempty(data.time) + error('pf2:auxOnGrid:noTime', ... + 'No target grid given and data.time is empty.'); + end + tgt = data.time; +end +tgt = tgt(:); + +srcFs = estimateFs(srcTime); +tgtFs = estimateFs(tgt); + +% --- Anti-alias before downsampling -------------------------------------- +didAA = false; +if antiAlias && isfinite(srcFs) && isfinite(tgtFs) && tgtFs < srcFs + srcData = antiAliasLowpass(srcData, srcFs, tgtFs / 2); + didAA = true; +end + +% --- Interpolate channel-by-channel onto the grid ------------------------ +vals = nan(numel(tgt), size(srcData, 2)); +for c = 1:size(srcData, 2) + x = srcData(:, c); + valid = ~isnan(x) & ~isnan(srcTime); + if nnz(valid) < 2 + continue; % leave NaN + end + tv = srcTime(valid); + xv = x(valid); + % De-duplicate / sort time for interp1 + [tv, order] = sort(tv); + xv = xv(order); + [tv, ia] = unique(tv, 'stable'); + xv = xv(ia); + vals(:, c) = interp1(tv, xv, tgt, method, NaN); + % Blank target points that fall inside a source gap wider than MaxGap + if isfinite(maxGap) + vals(:, c) = blankWideGaps(vals(:, c), tgt, tv, maxGap); + end +end + +info = struct(); +info.signal = sigName; +info.channels = sig.varNames(chanIdx); +info.srcFs = srcFs; +info.tgtFs = tgtFs; +info.offset = offset; +info.antiAliased = didAA; +info.nNaN = nnz(isnan(vals)); +info.nInterp = numel(vals) - info.nNaN; + +end + +%%_Subfunctions_________________________________________________________ + +function fs = estimateFs(t) +% ESTIMATEFS Robust sampling-rate estimate from a time vector +dt = median(diff(t(:)), 'omitnan'); +if isempty(dt) || ~isfinite(dt) || dt <= 0 + fs = NaN; +else + fs = 1 / dt; +end +end + +function y = antiAliasLowpass(x, fs, cutoff) +% ANTIALIASLOWPASS Zero-phase Hann moving-average low-pass (NaN-aware) +% Cutoff is approximate; window length ~ fs/cutoff samples. No toolbox use. + +win = max(3, round(fs / max(cutoff, eps))); +if mod(win, 2) == 0 + win = win + 1; % odd length keeps it centered +end +k = hann(win); +k = k / sum(k); + +y = x; +for c = 1:size(x, 2) + xc = x(:, c); + nanMask = isnan(xc); + if all(nanMask) + continue; + end + % Fill NaNs by nearest for filtering, then restore + xf = fillNearest(xc, nanMask); + yc = zeroPhaseConv(xf, k); + yc(nanMask) = NaN; + y(:, c) = yc; +end + +end + +function w = hann(n) +% HANN Hann window of length n (avoids Signal Processing Toolbox dependency) +if n == 1 + w = 1; + return; +end +w = 0.5 * (1 - cos(2 * pi * (0:n-1)' / (n - 1))); +end + +function y = zeroPhaseConv(x, k) +% ZEROPHASECONV Symmetric (forward+reverse) moving-average via centered conv +half = (numel(k) - 1) / 2; +xp = [repmat(x(1), half, 1); x; repmat(x(end), half, 1)]; % edge-pad +yc = conv(xp, k, 'same'); +y = yc(half + 1 : half + numel(x)); +end + +function xf = fillNearest(x, nanMask) +% FILLNEAREST Replace NaNs with nearest valid sample (for filtering only) +idx = find(~nanMask); +if isempty(idx) + xf = x; + return; +end +allI = (1:numel(x))'; +nn = interp1(idx, idx, allI, 'nearest', 'extrap'); +xf = x(nn); +end + +function y = blankWideGaps(y, tgt, srcValidTime, maxGap) +% BLANKWIDEGAPS NaN-out target points inside source gaps wider than maxGap +gaps = diff(srcValidTime); +wide = find(gaps > maxGap); +for g = wide(:)' + lo = srcValidTime(g); + hi = srcValidTime(g + 1); + y(tgt > lo & tgt < hi) = NaN; +end +end diff --git a/+pf2/+data/betasToSegments.m b/+pf2/+data/betasToSegments.m new file mode 100644 index 00000000..ac3c258a --- /dev/null +++ b/+pf2/+data/betasToSegments.m @@ -0,0 +1,220 @@ +function segments = betasToSegments(glmResults, data, opts) +% BETASTOSEGMENTS Package GLM betas into Experiment-compatible pseudo-segments +% +% Converts first-level GLM beta weights into fNIRS-like structs that can be +% fed directly into exploreFNIRS.core.Experiment for group-level analysis. +% Each stimulus regressor becomes a separate pseudo-segment with the beta +% row as its "time series" (duplicated to 2 timepoints for compatibility +% with grandAvgFNIRS). +% +% Syntax: +% segments = pf2.data.betasToSegments(glmResults, data) +% segments = pf2.data.betasToSegments(glmResults, data, 'Name', Value) +% +% Inputs: +% glmResults - Struct from pf2_base.fnirs.fitGLM with fields: +% .beta [P x C], .regressorNames {1 x P} +% data - Original processed fNIRS struct (source of .info, .fchMask, +% .units, and probe geometry) +% +% Name-Value Parameters: +% 'Biomarker' - Which biomarker field to populate (default: 'HbO') +% 'Conditions' - Cell array of regressor names to include +% (default: auto-detect stimulus regressors) +% 'ConditionMap' - Cell {regName, 'Label'; ...} to rename conditions +% (default: {}) +% 'Units' - Units string for beta segments (default: '\beta') +% 'BiomarkerResults' - Struct with fields named by biomarker (e.g. .HbO, +% .HbR), each containing a fitGLM result. When +% provided, glmResults is ignored and each biomarker +% is populated from its own model. (default: []) +% +% Outputs: +% segments - Cell array {1 x nConditions} of fNIRS-like structs with: +% .HbO/.HbR/.HbTotal/.HbDiff/.CBSI - [1 x C] beta values +% .time - 0 (scalar, single timepoint) +% .fs - 1 +% .fchMask - copied from data +% .units - '\beta' +% .info - copied from data with .Condition set +% .markers - empty [0 x 3] +% +% Algorithm: +% 1. Auto-detect stimulus regressors (exclude drift, constant, short-ch, +% derivative, dispersion regressors) +% 2. For each stimulus regressor, extract beta row [1 x C] +% 3. Create single-timepoint pseudo-segment (time = 0) +% 4. Fill non-fitted biomarkers with NaN +% 5. Copy metadata from source data +% +% Example: +% results = pf2_base.fnirs.fitGLM(data.HbO, X, names); +% segments = pf2.data.betasToSegments(results, data); +% ex = exploreFNIRS.core.Experiment(segments); +% ex.settings.useBaseline = false; +% ex.settings.resampleRate = 0; +% +% See also: pf2_base.fnirs.fitGLM, pf2.data.blocksToEvents, +% 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 + +biomarker = char(opts.Biomarker); +conditions = opts.Conditions; +conditionMap = opts.ConditionMap; +units = char(opts.Units); +bioResults = opts.BiomarkerResults; + +% All biomarker fields +allBiomarkers = {'HbO', 'HbR', 'HbTotal', 'HbDiff', 'CBSI'}; + +% --- Determine which results to use --- +if ~isempty(bioResults) + % Multi-biomarker mode + fittedBios = intersect(fieldnames(bioResults), allBiomarkers, 'stable'); + if isempty(fittedBios) + error('pf2:betasToSegments:noBiomarkers', ... + 'BiomarkerResults must have fields named HbO, HbR, etc.'); + end + % Use first fitted biomarker for regressor names + primaryResult = bioResults.(fittedBios{1}); +else + % Single-biomarker mode + fittedBios = {biomarker}; + primaryResult = glmResults; + bioResults = struct(); + bioResults.(biomarker) = glmResults; +end + +regressorNames = primaryResult.regressorNames; +nCh = size(primaryResult.beta, 2); + +% --- Determine stimulus conditions --- +if isempty(conditions) + conditions = detectStimulusRegressors(regressorNames); +end + +if isempty(conditions) + error('pf2:betasToSegments:noConditions', ... + 'No stimulus regressors found. Available: %s', ... + strjoin(regressorNames, ', ')); +end + +% --- Build condition map --- +mapNames = containers.Map(); +if ~isempty(conditionMap) && size(conditionMap, 2) >= 2 + for k = 1:size(conditionMap, 1) + mapNames(char(conditionMap{k, 1})) = char(conditionMap{k, 2}); + end +end + +% --- Build pseudo-segments --- +nCond = length(conditions); +segments = cell(1, nCond); + +for c = 1:nCond + condName = char(conditions{c}); + + % Find regressor index + regIdx = find(strcmp(regressorNames, condName), 1); + if isempty(regIdx) + error('pf2:betasToSegments:regressorNotFound', ... + 'Regressor "%s" not found. Available: %s', ... + condName, strjoin(regressorNames, ', ')); + end + + % Determine display label + if mapNames.isKey(condName) + label = mapNames(condName); + else + label = condName; + end + + % Build pseudo-segment + seg = struct(); + + % Fill each biomarker (single timepoint — betas are scalar per channel) + for b = 1:length(allBiomarkers) + bio = allBiomarkers{b}; + if isfield(bioResults, bio) + seg.(bio) = bioResults.(bio).beta(regIdx, :); % [1 x C] + else + seg.(bio) = NaN(1, nCh); + end + end + + % Single timepoint — betas have no temporal dimension + seg.time = 0; + seg.fs = 1; + + % Channel mask + if isfield(data, 'fchMask') + seg.fchMask = data.fchMask; + else + seg.fchMask = ones(1, nCh); + end + + % Units + seg.units = units; + + % Markers (empty canonical table) + seg.markers = pf2_base.normalizeMarkers([]); + + % Info - copy from source data, set Condition + if isfield(data, 'info') + seg.info = data.info; + else + seg.info = struct(); + end + seg.info.Condition = label; + + % Copy probe geometry if present + if isfield(data, 'probe') + seg.probe = data.probe; + end + + % Copy ROI if present + if isfield(data, 'ROI') + seg.ROI = data.ROI; + end + + segments{c} = seg; +end + +end + + +function stimRegs = detectStimulusRegressors(regressorNames) +% DETECTSTIMULUSREGRESSORS Identify stimulus regressors by excluding nuisance +% +% Nuisance patterns: constant, drift_*, dct_*, short_ch*, nuis*, aux_*, +% *_deriv, *_disp + +nuisancePatterns = { + '^constant$' + '^drift_' + '^dct_' + '^short_ch' + '^nuis\d' + '^aux_' + '_deriv$' + '_disp$' +}; + +isNuisance = false(size(regressorNames)); +for k = 1:length(nuisancePatterns) + isNuisance = isNuisance | ~cellfun(@isempty, regexp(regressorNames, nuisancePatterns{k})); +end + +stimRegs = regressorNames(~isNuisance); + +end diff --git a/+pf2/+data/blockAverage.m b/+pf2/+data/blockAverage.m new file mode 100644 index 00000000..a20e59fe --- /dev/null +++ b/+pf2/+data/blockAverage.m @@ -0,0 +1,144 @@ +function ga = blockAverage(segments, opts) +% BLOCKAVERAGE Trial/grand average of epoched fNIRS segments onto a common grid +% +% Averages a cell array of epoched fNIRS structs (e.g. from +% pf2.data.extractBlocks) into a single grand-average waveform with mean, +% SEM, SD, N, min, and max per timepoint and channel. This is the one-call, +% single-subject equivalent of the group Experiment averaging path. +% +% Segments cut around event markers commonly share a sampling rate but start +% at different sub-sample phases, so their time vectors never line up. +% Calling grandAvgFNIRS on them directly then yields an almost entirely NaN +% average. blockAverage first resamples every segment onto one shared time +% grid (anchored at t=0, the SetT0 block onset) so the average is valid. +% +% Syntax: +% ga = pf2.data.blockAverage(segments) +% ga = pf2.data.blockAverage(segments, 'Name', Value) +% +% Inputs: +% segments - Cell array {1 x N} of oxy-processed fNIRS structs, each with +% .time and biomarker fields (.HbO, .HbR, ...). Typically the +% output of pf2.data.extractBlocks (use its default SetT0 so the +% segments are aligned to block onset at t=0). Empty cells are +% ignored. +% +% Name-Value Parameters: +% 'ResampleInterval' - Sample interval in seconds for the shared output +% grid (default: [] = median of the segments' native +% sample intervals). +% 'AverageAux' - Also average auxiliary signals present on the +% segments (default: false). +% 'HierarchyVars' - Grouping matrix for hierarchical (nested) averaging, +% one row per (non-empty) segment (default: [] = flat +% average over all segments). See grandAvgFNIRS. +% +% Outputs: +% ga - Grand-average struct. For each biomarker B in +% {HbO, HbR, HbTotal, HbDiff, CBSI}: +% ga.(B).Mean [T x C] mean across segments +% ga.(B).SEM [T x C] standard error of the mean +% ga.(B).SD [T x C] standard deviation +% ga.(B).N [T x C] count of contributing segments +% ga.(B).Median, ga.(B).Max, ga.(B).Min +% ga.(B).data [T x C x N] per-segment aligned data +% Plus ga.time [T x 1], ga.units, and ga.info with hierarchy details. +% Returns [] if there are no averageable segments. +% +% Algorithm: +% 1. Drop empty segments and pick a common sample interval (the median of +% per-segment native intervals unless overridden). +% 2. Build a shared time grid anchored at t=0 spanning all segments. +% 3. Resample every segment onto that exact grid (pf2.data.resample with +% 'specifiedTimepoints'); timepoints outside a segment's range become +% NaN and simply lower N there. +% 4. grandAvgFNIRS averages the now grid-aligned segments. +% +% Example: +% data = pf2.import.sampleData(); % recording with markers +% proc = processFNIRS2(data); +% blocks = pf2.data.defineBlocks(proc, 50, 15, 'Embed', false); +% segments = pf2.data.extractBlocks(proc, blocks, ... +% 'PreTime', 5, 'PostTime', 15, 'SetT0', true); +% ga = pf2.data.blockAverage(segments); +% plot(ga.time, ga.HbO.Mean(:, 1)); % averaged HbO, channel 1 +% +% See also: pf2.data.extractBlocks, pf2.data.defineBlocks, +% exploreFNIRS.core.Experiment, grandAvgFNIRS + +arguments + segments + opts.ResampleInterval {mustBeNumeric} = [] + opts.AverageAux = false + opts.HierarchyVars {mustBeNumeric} = [] +end + +pf2_base.ensureStatsFallbacks(); % ensure stats-toolbox fallbacks (nan*) are on the path before use + +if ~iscell(segments) + error('pf2:blockAverage:badInput', ... + ['SEGMENTS must be a cell array of fNIRS structs ', ... + '(e.g. the output of pf2.data.extractBlocks).']); +end + +% Keep only non-empty segments (track indices for HierarchyVars alignment). +keep = find(~cellfun(@isempty, segments)); +if isempty(keep) + error('pf2:blockAverage:noSegments', 'No non-empty segments to average.'); +end +segs = segments(keep); + +% Resolve a common sample interval from the segments' native intervals. +ri = opts.ResampleInterval; +if isempty(ri) + dts = nan(1, numel(segs)); + for i = 1:numel(segs) + if isfield(segs{i}, 'time') && numel(segs{i}.time) > 1 + dts(i) = median(diff(segs{i}.time)); + end + end + dts = dts(isfinite(dts) & dts > 0); + if isempty(dts) + % No multi-sample segments (e.g. single-point GLM betas); defer to + % grandAvgFNIRS without pre-gridding. + hv = subsetHierarchy(opts.HierarchyVars, keep); + ga = grandAvgFNIRS(segs, true, [], false, hv, false, logical(opts.AverageAux)); + return; + end + ri = median(dts); +end + +% Build a shared grid anchored at t=0 (block onset under SetT0) spanning all +% segments, then resample every segment onto exactly that grid so their time +% vectors are identical and the average aligns sample-for-sample. +tmin = min(cellfun(@(s) min(s.time), segs)); +tmax = max(cellfun(@(s) max(s.time), segs)); +grid = unique([fliplr(0:-ri:(tmin - ri)), 0:ri:(tmax + ri)]); +grid = grid(grid >= tmin - 1e-9 & grid <= tmax + 1e-9); + +for i = 1:numel(segs) + if logical(opts.AverageAux) + segs{i} = pf2.data.resample(segs{i}, 'specifiedTimepoints', grid, ... + 'averageAux', true, 'trimAux', true); + else + segs{i} = pf2.data.resample(segs{i}, 'specifiedTimepoints', grid); + end +end + +% Segments now share an identical grid; timeAlign=false and resampleSize=[] +% keep that grid and let grandAvgFNIRS match timepoints exactly. +hv = subsetHierarchy(opts.HierarchyVars, keep); +ga = grandAvgFNIRS(segs, false, [], false, hv, false, logical(opts.AverageAux)); + +end + +function hv = subsetHierarchy(hierarchyVars, keep) +% Restrict a per-segment hierarchy matrix to the kept (non-empty) segments. +if isempty(hierarchyVars) + hv = []; +elseif size(hierarchyVars, 1) >= max(keep) + hv = hierarchyVars(keep, :); +else + hv = hierarchyVars; % size mismatch: pass through, let grandAvgFNIRS handle +end +end diff --git a/+pf2/+data/blocksToEvents.m b/+pf2/+data/blocksToEvents.m new file mode 100644 index 00000000..301546f4 --- /dev/null +++ b/+pf2/+data/blocksToEvents.m @@ -0,0 +1,113 @@ +function events = blocksToEvents(blocks, opts) +% BLOCKSTOEVENTS Convert block definitions to GLM event structs +% +% Groups blocks by condition and collects onset times, durations, and +% amplitudes into the events struct format expected by buildDesignMatrix. +% This bridges defineBlocks (epoch-oriented) with the GLM pipeline. +% +% Syntax: +% events = pf2.data.blocksToEvents(blocks) +% events = pf2.data.blocksToEvents(blocks, 'GroupBy', 'Condition') +% +% Inputs: +% blocks - Struct array from pf2.data.defineBlocks with fields: +% .startTime, .duration, .markerCode, .amplitude, .info +% +% Name-Value Parameters: +% 'GroupBy' - Field in blocks.info to group by (default: 'Condition'). +% If the field is missing from all blocks, falls back to +% grouping by .markerCode. +% +% Outputs: +% events - Struct array [1 x nConditions] with fields: +% .name - Condition label [char] +% .onsets - [1 x N] onset times in seconds +% .duration - [1 x N] durations (scalar if all equal) +% .amplitude - [1 x N] amplitudes (scalar if all equal) +% .markerCode - Marker code(s) for this condition +% +% Algorithm: +% 1. Group blocks by the specified info field (or markerCode as fallback) +% 2. For each group: collect .startTime → .onsets, .duration → .duration, +% .amplitude → .amplitude +% 3. Collapse duration/amplitude to scalar when all values are identical +% +% Example: +% blocks = pf2.data.defineBlocks(data, [49, 50], 30, ... +% 'ConditionMap', {49, 'Easy'; 50, 'Hard'}); +% events = pf2.data.blocksToEvents(blocks); +% [X, names] = pf2_base.fnirs.buildDesignMatrix(data.time, data.fs, events); +% +% See also: pf2.data.defineBlocks, pf2_base.fnirs.buildDesignMatrix, +% pf2_base.fnirs.fitGLM + +arguments + blocks {mustBeA(blocks, 'struct')} + opts.GroupBy = 'Condition' +end + +groupField = char(opts.GroupBy); + +if isempty(blocks) + events = struct('name', {}, 'onsets', {}, 'duration', {}, ... + 'amplitude', {}, 'markerCode', {}); + return; +end + +% --- Determine grouping keys --- +useInfoField = false; +if isfield(blocks(1), 'info') && isfield(blocks(1).info, groupField) + % Check that at least one block has a non-empty value + for k = 1:length(blocks) + if isfield(blocks(k).info, groupField) && ~isempty(blocks(k).info.(groupField)) + useInfoField = true; + break; + end + end +end + +if useInfoField + % Group by info field + keys = cell(length(blocks), 1); + for k = 1:length(blocks) + val = blocks(k).info.(groupField); + if isnumeric(val) + keys{k} = num2str(val); + else + keys{k} = char(val); + end + end +else + % Fallback: group by markerCode + keys = cell(length(blocks), 1); + for k = 1:length(blocks) + keys{k} = num2str(blocks(k).markerCode); + end +end + +% --- Build events per group --- +[uniqueKeys, ~, groupIdx] = unique(keys, 'stable'); +nGroups = length(uniqueKeys); +events = repmat(struct('name', '', 'onsets', [], 'duration', [], ... + 'amplitude', [], 'markerCode', []), 1, nGroups); + +for g = 1:nGroups + mask = (groupIdx == g); + groupBlocks = blocks(mask); + + events(g).name = uniqueKeys{g}; + events(g).onsets = [groupBlocks.startTime]; + events(g).duration = [groupBlocks.duration]; + events(g).amplitude = [groupBlocks.amplitude]; + events(g).markerCode = unique([groupBlocks.markerCode]); + + % Collapse to scalar if all identical + if all(events(g).duration == events(g).duration(1)) + events(g).duration = events(g).duration(1); + end + if all(events(g).amplitude == events(g).amplitude(1)) + events(g).amplitude = events(g).amplitude(1); + end +end + +end diff --git a/+pf2/+data/concatenate.m b/+pf2/+data/concatenate.m index 773107e2..95119db9 100644 --- a/+pf2/+data/concatenate.m +++ b/+pf2/+data/concatenate.m @@ -47,6 +47,8 @@ % % See also: pf2.data.concatenateHorizontal, pf2.data.resample, pf2.data.split +pf2_base.ensureStatsFallbacks(); % ensure stats-toolbox fallbacks (nan*) are on the path before use + centerOnT0=true; if(nargin>1) @@ -79,7 +81,7 @@ for i=1:length(fNIR_objs) %use Slowest fNIR file as reference if(~isfield(fNIR_objs{i},'HbO')) - error('fNIR segment %i has not been processed for Oxy data yet'); + error('pf2:concatenate:notProcessed', 'fNIR segment %i has not been processed for Oxy data yet'); end numCh=numCh+length(fNIR_objs{i}.channels); if(minFsIdx~=i||centerOnT0) diff --git a/+pf2/+data/concatenateHorizontal.m b/+pf2/+data/concatenateHorizontal.m index 4fd89fd6..580aeace 100644 --- a/+pf2/+data/concatenateHorizontal.m +++ b/+pf2/+data/concatenateHorizontal.m @@ -25,6 +25,7 @@ % .time - Merged and sorted time vector [T_total x 1] % .markers - Merged and sorted event markers % .fchMask - Combined mask (channel valid only if valid in all) +% .Aux - Merged auxiliary data (if present) % .t0 - Reference time from earliest segment % % Algorithm: @@ -40,6 +41,8 @@ % - Does NOT concatenate processed fields (HbO, HbR, etc.) % - Use before processing, not after % - Channel mask uses AND logic (valid only if valid in ALL segments) +% - Aux signals are concatenated and sorted by their own time vectors +% - Aux fields present in any segment are included; missing segments skipped % % Example: % % Merge two recording segments from same session @@ -54,6 +57,8 @@ %centerOnT0=true; +pf2_base.ensureStatsFallbacks(); % ensure stats-toolbox fallbacks (nan*) are on the path before use + if(nargin>1) if(isstruct(varargin{1})) fNIR_objs={fNIR_objs,varargin{1}}; @@ -100,10 +105,35 @@ outFNIR.datetime=[outFNIR.datetime;appendFNIR.datetime]; end outFNIR.raw=[outFNIR.raw;appendFNIR.raw]; - outFNIR.markers=[outFNIR.markers;appendFNIR.markers]; + outFNIR.markers=pf2_base.mergeMarkers(outFNIR.markers,appendFNIR.markers); outFNIR.fchMask=outFNIR.fchMask.*appendFNIR.fchMask; + + % Merge Aux signals + if isfield(appendFNIR, 'Aux') && isstruct(appendFNIR.Aux) + if ~isfield(outFNIR, 'Aux') || ~isstruct(outFNIR.Aux) + outFNIR.Aux = struct(); + end + auxNames = fieldnames(appendFNIR.Aux); + for a = 1:length(auxNames) + name = auxNames{a}; + appAux = appendFNIR.Aux.(name); + if ~isstruct(appAux); continue; end + if ~isfield(outFNIR.Aux, name) + % First time seeing this Aux field — copy as-is + outFNIR.Aux.(name) = appAux; + else + % Concatenate .data and .time vertically + if isfield(appAux, 'data') && isfield(outFNIR.Aux.(name), 'data') + outFNIR.Aux.(name).data = [outFNIR.Aux.(name).data; appAux.data]; + end + if isfield(appAux, 'time') && isfield(outFNIR.Aux.(name), 'time') + outFNIR.Aux.(name).time = [outFNIR.Aux.(name).time; appAux.time]; + end + end + end + end end % order data @@ -114,9 +144,17 @@ end outFNIR.raw=outFNIR.raw(b,:); - - - +% Sort Aux signals by their own time vectors +if isfield(outFNIR, 'Aux') && isstruct(outFNIR.Aux) + auxNames = fieldnames(outFNIR.Aux); + for a = 1:length(auxNames) + s = outFNIR.Aux.(auxNames{a}); + if isstruct(s) && isfield(s, 'time') && isfield(s, 'data') + [outFNIR.Aux.(auxNames{a}).time, si] = sort(s.time); + outFNIR.Aux.(auxNames{a}).data = s.data(si, :); + end + end +end diff --git a/+pf2/+data/crop.m b/+pf2/+data/crop.m new file mode 100644 index 00000000..25bfcb65 --- /dev/null +++ b/+pf2/+data/crop.m @@ -0,0 +1,33 @@ +function outfNIR = crop(fNIR, startTime, endTime) +% CROP Extract time segment from fNIRS data (no baseline correction) +% +% Simple wrapper around pf2.data.split for extracting a time window +% without baseline correction. For baseline correction, use split directly. +% +% Syntax: +% cropped = pf2.data.crop(fNIR, startTime, endTime) +% cropped = pf2.data.crop(fNIR, startTime) % to end +% +% Inputs: +% fNIR - fNIRS data structure +% startTime - Start time in seconds (absolute) +% endTime - End time in seconds (optional, defaults to end of data) +% +% Outputs: +% outfNIR - Cropped fNIRS structure with all fields truncated +% +% Example: +% % Extract t=10 to t=60 +% segment = pf2.data.crop(data, 10, 60); +% +% % Extract from t=100 to end +% segment = pf2.data.crop(data, 100); +% +% See also: pf2.data.split, pf2.data.resample + +if nargin < 3 + endTime = nan; % split defaults to max(time) +end + +outfNIR = pf2.data.split(fNIR, startTime, endTime); +end diff --git a/+pf2/+data/dedupeMarkers.m b/+pf2/+data/dedupeMarkers.m new file mode 100644 index 00000000..a36b97f3 --- /dev/null +++ b/+pf2/+data/dedupeMarkers.m @@ -0,0 +1,178 @@ +function out = dedupeMarkers(data, opts) +% DEDUPEMARKERS Collapse near-duplicate markers firing within a tolerance +% +% Removes near-duplicate event markers, where "near-duplicate" means a row +% that shares the same marker Code as, and falls within a small time tolerance +% of, an earlier KEPT row (the cluster anchor). Such duplicates typically come +% from bouncing trigger lines, repeated serial sends, or a stimulus that +% re-fires the same code in quick succession. +% +% The dedup is anchor-based, not run-collapsing: per Code, the earliest row +% becomes the anchor and any later same-code row within Tolerance of THAT +% anchor is dropped. When a row falls outside the window it is kept and +% becomes the new anchor. So, at Tolerance = 0.05 s, onsets at 0, 0.04, 0.08 +% keep BOTH 0 and 0.08 (0.08 is > 0.05 past the 0 anchor) and drop only 0.04 - +% it does NOT collapse the whole run to a single row. +% +% Reference: +% Internal pf2 implementation. +% +% Syntax: +% data = pf2.data.dedupeMarkers(data) +% data = pf2.data.dedupeMarkers(data, 'Tolerance', tol) +% markers = pf2.data.dedupeMarkers(markerTable, ...) +% ... = pf2.data.dedupeMarkers(..., 'Name', Value) +% +% Inputs: +% data - fNIRS data struct with a .markers table, or a marker table/matrix +% directly. A struct returns a struct (with .markers deduped); a +% table/matrix returns a deduped canonical table. +% +% Name-Value Parameters: +% 'Tolerance' - Time window in seconds within which two same-code markers +% are treated as duplicates (default: 0.05). Markers of the +% same Code whose Time falls within this gap of the kept +% (earliest) row of the cluster are removed. Larger values +% collapse more aggressively. +% 'Verbose' - Print the number of markers removed (default: true). +% +% Outputs: +% out - Same form as input (struct or table) with near-duplicate marker +% rows removed. Surviving rows are returned sorted by ascending Time +% (chronological), regardless of input order; extra columns are kept. +% +% Algorithm: +% 1. Normalize markers to the canonical table (Time, Code, Duration, +% Amplitude + extras) so matrix/table inputs both work. +% 2. Sort rows by Time (stable) and walk them per Code, anchoring each +% cluster on its earliest row. +% 3. Drop any same-code row whose Time is within Tolerance of the cluster +% anchor (the window is measured from the anchor, so a run is NOT fully +% collapsed); a row outside the window is kept and becomes the new anchor. +% 4. Return the surviving rows sorted by ascending Time. +% +% Example: +% % Collapse trigger bounce within 50 ms on the sample data +% data = pf2.import.sampleData(); +% data = pf2.data.dedupeMarkers(data); +% +% % Operate on a marker table directly, wider tolerance, quietly +% m = pf2_base.normalizeMarkers([10 49; 10.02 49; 30 49]); +% m = pf2.data.dedupeMarkers(m, 'Tolerance', 0.1, 'Verbose', false); +% +% Notes: +% - Duplicate detection is per Code: same-time markers with DIFFERENT +% codes are never collapsed. +% - 'Tolerance' = 0 disables deduplication (nothing is removed). +% - The window is measured from each cluster's anchor (earliest kept row), +% so a long run of closely-spaced repeats is thinned, not collapsed to +% a single row. +% - Survivors are returned sorted by ascending Time even if the input was +% unsorted; extra/user marker columns ride along with their rows. +% +% 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 + +% --- Cell array input: apply to each element --- +if iscell(data) + fwd = namedargs2cell(opts); + out = data; + for ci = 1:numel(data) + out{ci} = pf2.data.dedupeMarkers(data{ci}, fwd{:}); + end + return; +end + +tol = opts.Tolerance; +verbose = opts.Verbose; + +% --- Resolve the marker table from the input form --- +isStructInput = isstruct(data) && isfield(data, 'markers'); +if isStructInput + mt = pf2_base.normalizeMarkers(data.markers); +elseif istable(data) || isnumeric(data) + mt = pf2_base.normalizeMarkers(data); +else + error('pf2:dedupeMarkers:badInput', ... + ['First argument must be an fNIRS struct with .markers, a marker ', ... + 'table/matrix, or a cell array.']); +end + +nBefore = height(mt); + +% --- Nothing to do for empty or single-row marker sets --- +if nBefore <= 1 + if verbose + fprintf('pf2.data.dedupeMarkers: removed 0 of %d markers.\n', nBefore); + end + out = packResult(data, mt, isStructInput); + return; +end + +% --- Sort chronologically (stable) and find duplicate clusters per code --- +times = mt.Time; +codes = mt.Code; +[~, sortOrd] = sortrows([times, codes], 1); % stable sort by Time + +keepSorted = true(nBefore, 1); +% Walk the time-sorted rows; per Code, hold the time of the current cluster +% ANCHOR (its earliest kept row). Drop a row within Tolerance of that anchor; +% otherwise keep it and make it the new anchor. The window is measured from +% the anchor, not the previous row, so it does not chain indefinitely. +anchorTimeByCode = containers.Map('KeyType', 'double', 'ValueType', 'double'); +for r = 1:nBefore + idx = sortOrd(r); + c = codes(idx); + t = times(idx); + if isKey(anchorTimeByCode, c) + anchorT = anchorTimeByCode(c); + if (t - anchorT) <= tol + keepSorted(idx) = false; % within tolerance of cluster anchor: drop + continue; % anchor unchanged (window stays on anchor) + end + end + anchorTimeByCode(c) = t; % start (or advance) this code's anchor +end + +% Surviving rows, returned in ascending Time order (chronological). sortrows +% is stable, so same-Time rows keep their relative order and extra/user +% columns ride along with their row. +mtOut = sortrows(mt(keepSorted, :), 'Time'); +nRemoved = nBefore - height(mtOut); + +if verbose + fprintf('pf2.data.dedupeMarkers: removed %d of %d markers (Tolerance = %g s).\n', ... + nRemoved, nBefore, tol); +end + +out = packResult(data, mtOut, isStructInput); + +end + +%%_Subfunctions_________________________________________________________ + +function out = packResult(data, mt, isStructInput) +% PACKRESULT Return the deduped markers in the same form as the input +% +% Inputs: +% data - Original input (struct or table/matrix) +% mt - Deduped canonical marker table +% isStructInput - True if the original input was an fNIRS struct +% +% Outputs: +% out - Struct (with .markers replaced) or the marker table directly + +if isStructInput + data.markers = mt; + out = data; +else + out = mt; +end + +end diff --git a/+pf2/+data/defineBlocks.m b/+pf2/+data/defineBlocks.m new file mode 100644 index 00000000..796d0ddc --- /dev/null +++ b/+pf2/+data/defineBlocks.m @@ -0,0 +1,676 @@ +function blocks = defineBlocks(data, markerCodes, duration, opts) +% DEFINEBLOCKS Create block definition struct array from event markers +% +% Parses fNIRS markers into a block definition array describing time +% windows for epoching, GLM design matrices, or connectivity analysis. +% Supports simple positional syntax for common cases and name-value +% parameters for advanced configuration. +% +% Syntax: +% blocks = pf2.data.defineBlocks(data, markerCodes) +% blocks = pf2.data.defineBlocks(data, markerCodes, duration) +% blocks = pf2.data.defineBlocks(data, markerCodes, 'EndMarker', endCode) +% blocks = pf2.data.defineBlocks(data, 'MarkerCode', code, 'Duration', dur) +% blocks = pf2.data.defineBlocks(data, 'MarkerCode', code, 'EndMarker', endCode) +% blocks = pf2.data.defineBlocks(data, 'StartMarker', s, 'EndMarker', e) +% data = pf2.data.defineBlocks(data, ..., 'Embed', true) +% allData = pf2.data.defineBlocks(allData, ..., 'Embed', true) % cell array +% blocks = pf2.data.defineBlocks(data, ..., 'Name', Value) +% +% Inputs: +% data - fNIRS data structure with .markers field, or a cell +% array of fNIRS structs (requires 'Embed', true) +% Markers are a canonical table with variables .Time, +% .Code, .Duration, .Amplitude (+ optional extra columns) +% markerCodes - (Optional positional) Marker code(s) to find [numeric] +% Scalar or vector; all codes are treated as separate block types. +% duration - (Optional positional) Fixed block duration in seconds [scalar] +% If omitted and markers have nonzero .Duration values, +% those durations are used automatically. +% +% Name-Value Parameters: +% 'MarkerCode' - Marker code(s) to define blocks [scalar or vector] +% 'Duration' - Fixed duration in seconds for each block (default: 0) +% If 0 and markers have nonzero .Duration values, those +% are used. Otherwise blocks have zero duration. +% 'UseDuration' - Force use of duration from markers .Duration (default: false) +% 'StartMarker' - Start marker code(s) for paired extraction [scalar or column vector] +% 'EndMarker' - End marker code(s) for paired extraction [scalar or column vector] +% Can be used with StartMarker or MarkerCode. +% Must match StartMarker/MarkerCode length or be scalar. +% 'ConditionMap' - Cell array mapping marker codes to labels (default: {}) +% Two-column: {code1, 'Label1'; code2, 'Label2'} +% Multi-column: {code1, 'Easy', 'Stroop'; code2, 'Hard', 'Stroop'} +% Extra columns map to extra fields via ConditionField. +% When omitted and data.info.eventTypes exists (from BIDS +% events.tsv), the mapping is auto-populated for requested codes. +% 'ConditionField' - Field name(s) for ConditionMap labels (default: 'Condition') +% Char for single field, cell array for multiple: +% {'Condition', 'Task'} maps columns 2, 3 of ConditionMap. +% 'InfoTable' - Table with one row per block (default: []) +% Column names become .info fields for each block. +% 'InfoFields' - Struct of constant fields applied to all blocks (default: struct()) +% 'MinDuration' - Reject blocks shorter than this in seconds (default: 0) +% 'MaxDuration' - Reject blocks longer than this in seconds (default: Inf) +% 'SortByTime' - Sort blocks chronologically (default: true) +% 'PrePad' - Seconds to include before block start (default: 0) +% Shifts startTime earlier by this amount. +% 'PostPad' - Seconds to include after block end (default: 0) +% Shifts endTime later by this amount. +% 'MarkerWindow' - Which block window attributes marker rows to a block for +% auto-promotion of per-trial extra columns (default 'core'): +% 'core' - the original PRE-PAD block window, so PrePad/ +% PostPad cannot pull a neighbouring block's onset +% marker into this block (the safe default). +% 'padded' - the widened (post-pad) window, to deliberately +% attribute markers that fall in the pad region. +% 'Embed' - Store blocks on data.blocks and return the data struct +% instead of the blocks array (default: true). +% Set false to return just the blocks struct array. +% +% Outputs: +% blocks - Struct array [1 x N] (or data struct if Embed=true) with fields: +% .startTime - Block start time in seconds (absolute) +% .endTime - Block end time in seconds +% .duration - endTime - startTime in seconds +% .markerCode - Marker code that triggered this block +% .markerIndex - Row index into data.markers +% .amplitude - Marker amplitude from .Amplitude (default 1) +% .info - Struct with block-level metadata: +% .BlockNumber - Sequential 1, 2, 3... +% .(ConditionField) - From ConditionMap (default 'Condition') +% .(extraColumn) - Any marker EXTRA column (e.g. RT) +% that resolves to a single value for the block is +% auto-promoted here for Experiment groupby/plots, +% but only for fields not already set by an +% explicit source (ConditionMap/InfoTable/ +% InfoFields), which take precedence. +% ... any user fields from InfoTable/InfoFields +% +% Algorithm: +% 1. Normalize markers to the canonical table (.Time, .Code, .Duration, +% .Amplitude) and read out the numeric values needed below +% 2. Select mode: MarkerCode (fixed duration), MarkerCode+EndMarker +% (pair start codes with terminating marker), or StartMarker+EndMarker +% 3. For MarkerCode: duration from fixed > marker .Duration > zero +% For MarkerCode+EndMarker or StartMarker+EndMarker: pair each start +% with the next available end marker to determine duration +% 4. Build struct array with startTime, endTime, duration, markerCode, markerIndex +% 5. Apply PrePad/PostPad to extend block boundaries +% 6. Auto-populate ConditionMap from data.info.eventTypes if not provided +% 7. Apply ConditionMap, InfoTable, InfoFields to .info (explicit sources) +% 8. Auto-promote single-valued marker EXTRA columns into any block .info +% field those explicit sources left unset (explicit sources take +% precedence over promotion) +% 9. Filter by MinDuration/MaxDuration, sort by time +% +% A near-duplicate onset check warns (pf2:defineBlocks:nearDuplicateOnsets) +% when two consecutive same-code markers fall within 0.05 s. The check is +% limited to the codes being extracted (markerCode/startMarker) so a bounce +% on an un-extracted code does not raise a false alarm; clean flagged codes +% with pf2.data.dedupeMarkers. +% +% Example: +% % Simple: marker codes + fixed duration +% blocks = pf2.data.defineBlocks(data, [49, 50], 30); +% +% % Auto-detect duration from marker .Duration +% blocks = pf2.data.defineBlocks(data, 49); +% +% % Marker code + end marker (duration from terminating marker) +% blocks = pf2.data.defineBlocks(data, [49, 50], 'EndMarker', 51); +% +% % Per-code end markers: 49->59, 48->58 +% blocks = pf2.data.defineBlocks(data, [49, 48], 'EndMarker', [59, 58]); +% +% % Start/end pairs with condition mapping +% blocks = pf2.data.defineBlocks(data, ... +% 'StartMarker', [50; 51], 'EndMarker', [52; 53], ... +% 'ConditionMap', {50, 'Natural'; 51, 'Synthetic'}); +% +% % With pre/post padding and metadata +% blocks = pf2.data.defineBlocks(data, 49, 30, ... +% 'PrePad', 5, 'PostPad', 2, ... +% 'ConditionMap', {49, 'Stroop'}); +% +% % Embed blocks on the data struct (returns data, not blocks) +% data = pf2.data.defineBlocks(data, [49,50], 30, 'Embed', true); +% segments = pf2.data.extractBlocks(data); % uses data.blocks +% +% % Cell array: embed blocks on every subject in one call +% allData = pf2.data.defineBlocks(allData, [49,50], 30, ... +% 'ConditionMap', {49,'Easy';50,'Hard'}, 'Embed', true); +% segments = pf2.data.extractBlocks(allData); +% +% % BIDS auto-labeling: when data has .info.eventTypes from events.tsv, +% % ConditionMap is auto-populated (no manual mapping needed) +% data = pf2.import.importSNIRF('sub-01_nirs.snirf'); +% blocks = pf2.data.defineBlocks(data, [1, 2, 3]); % auto-labeled +% +% 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 + +% --- Cell array input: apply to each element (requires Embed) --- +if iscell(data) + fwd = namedargs2cell(opts); + blocks = data; + for ci = 1:numel(data) + blocks{ci} = pf2.data.defineBlocks(data{ci}, markerCodes, duration, fwd{:}); + end + return; +end + +% Merge positional args with name-value (positional takes precedence) +if ~isempty(markerCodes) + markerCode = markerCodes(:); % Always treat as column (OR logic) +else + markerCode = opts.MarkerCode; +end + +if ~isempty(duration) + fixedDuration = duration; +else + fixedDuration = opts.Duration; +end + +useDuration = opts.UseDuration; +startMarker = opts.StartMarker; +endMarker = opts.EndMarker; +conditionMap = opts.ConditionMap; +condMapProvided = iscell(conditionMap); % [] default = not provided; a cell = provided +conditionField = opts.ConditionField; +if ischar(conditionField) || isstring(conditionField) + conditionField = {char(conditionField)}; +end +infoTable = opts.InfoTable; +infoFields = opts.InfoFields; +minDur = opts.MinDuration; +maxDur = opts.MaxDuration; +sortByTime = opts.SortByTime; +prePad = opts.PrePad; +postPad = opts.PostPad; +markerWindow = lower(char(opts.MarkerWindow)); +embedBlocks = opts.Embed; + +% Validate input data +if ~isstruct(data) || ~isfield(data, 'markers') + error('pf2:defineBlocks:badInput', 'First argument must be an fNIRS struct with .markers field.'); +end + +% Validate mode: MarkerCode, MarkerCode+EndMarker, or StartMarker+EndMarker +hasMarkerCode = ~isempty(markerCode); +hasStartMarker = ~isempty(startMarker); +hasEndMarker = ~isempty(endMarker); + +if ~hasMarkerCode && ~hasStartMarker + error('pf2:defineBlocks:noMode', ... + 'Must specify marker codes or ''StartMarker''.'); +end + +if hasMarkerCode && hasStartMarker + error('pf2:defineBlocks:ambiguousMode', ... + 'Cannot specify both marker codes and ''StartMarker''.'); +end + +if hasStartMarker && ~hasEndMarker + error('pf2:defineBlocks:missingEndMarker', ... + '''StartMarker'' requires ''EndMarker''.'); +end + +% Convert markers to a numeric array [time, value, duration, amplitude] +% for the positional column math in the block builders below. The stored +% data.markers field remains a canonical table. +mrk = pf2_base.markersToArray(data.markers); + +% Near-duplicate onset check: two consecutive same-code markers within a small +% time tolerance usually indicate trigger bounce / repeated sends and will +% spawn overlapping near-identical blocks. Warn (do not auto-remove) and point +% the user at pf2.data.dedupeMarkers. The check is restricted to the codes +% actually being extracted by this call (markerCode / startMarker) so a bounce +% on an UN-extracted code (e.g. a device heartbeat) never raises a false alarm +% about overlapping blocks that will not exist. When no explicit code set is +% available, all codes are inspected (current behavior). +nearDupTol = 0.05; +if hasMarkerCode + extractCodes = markerCode(:); +elseif hasStartMarker + extractCodes = startMarker(:); +else + extractCodes = []; +end +if ~isempty(mrk) && size(mrk, 1) > 1 + [srt, ~] = sortrows(mrk(:, [1 2]), 1); % by Time + if ~isempty(extractCodes) + inSet = ismember(srt(:, 2), extractCodes); + else + inSet = true(size(srt, 1), 1); % no code set: inspect everything + end + % Only flag a pair when BOTH rows are extracted codes of the same code. + sameCode = (srt(2:end, 2) == srt(1:end-1, 2)) & inSet(2:end) & inSet(1:end-1); + closeInTime = (srt(2:end, 1) - srt(1:end-1, 1)) <= nearDupTol; + nNearDup = sum(sameCode & closeInTime); + if nNearDup > 0 + warning('pf2:defineBlocks:nearDuplicateOnsets', ... + ['Found %d near-duplicate marker onset(s) (same code within %g s). ', ... + 'These will create overlapping blocks. Consider cleaning markers ', ... + 'first with pf2.data.dedupeMarkers.'], nNearDup, nearDupTol); + end +end + +if isempty(mrk) || size(mrk, 1) == 0 + blocks = struct('startTime', {}, 'endTime', {}, 'duration', {}, ... + 'markerCode', {}, 'markerIndex', {}, 'amplitude', {}, 'info', {}); + if embedBlocks + data.blocks = blocks; + blocks = data; + end + return; +end + +% Build blocks based on mode +if hasMarkerCode && hasEndMarker + % MarkerCode + EndMarker: use start/end pairing with MarkerCode as start + blocks = buildFromStartEnd(mrk, markerCode, endMarker); +elseif hasMarkerCode + blocks = buildFromMarkerCode(mrk, markerCode, fixedDuration, useDuration); +else + blocks = buildFromStartEnd(mrk, startMarker, endMarker); +end + +if isempty(blocks) + if embedBlocks + data.blocks = blocks; + blocks = data; + end + return; +end + +% Record the CORE (pre-pad) block bounds. Per-trial marker promotion below +% uses these by default (MarkerWindow='core') so that PrePad/PostPad widening +% does not pull a neighbouring block's onset marker into this block's window +% (which would make a per-trial extra column multi-valued and silently drop or +% misattribute it). MarkerWindow='padded' opts into the widened window. +coreStart = [blocks.startTime]; +coreEnd = [blocks.endTime]; + +% Apply PrePad / PostPad +if prePad > 0 || postPad > 0 + for k = 1:length(blocks) + blocks(k).startTime = blocks(k).startTime - prePad; + blocks(k).endTime = blocks(k).endTime + postPad; + blocks(k).duration = blocks(k).endTime - blocks(k).startTime; + end +end + +% Sort by time (carry the core-bounds arrays along with the reorder) +if sortByTime + [~, sortIdx] = sort([blocks.startTime]); + blocks = blocks(sortIdx); + coreStart = coreStart(sortIdx); + coreEnd = coreEnd(sortIdx); +end + +% Assign BlockNumber +for k = 1:length(blocks) + blocks(k).info.BlockNumber = k; +end + +% Auto-populate ConditionMap from the dataset's marker dictionary when not +% explicitly provided (markerDict -> eventTypes -> COBI MarkerDict). +if ~condMapProvided && isstruct(data) + dict = pf2.data.getMarkerDict(data); + dict = dict(~ismissing(dict.Label), :); + if ~isempty(dict) + % Determine which codes are being extracted + if hasMarkerCode + allCodes = markerCode(:); + elseif hasStartMarker + allCodes = startMarker(:); + else + allCodes = []; + end + if ~isempty(allCodes) + keep = ismember(dict.Code, allCodes); + if any(keep) + conditionMap = [num2cell(dict.Code(keep)), cellstr(dict.Label(keep))]; + end + end + end +end + +% Apply ConditionMap +if ~isempty(conditionMap) && size(conditionMap, 2) >= 2 + mapCodes = cell2mat(conditionMap(:, 1)); + nFields = min(length(conditionField), size(conditionMap, 2) - 1); + for k = 1:length(blocks) + idx = find(mapCodes == blocks(k).markerCode, 1); + if ~isempty(idx) + for f = 1:nFields + blocks(k).info.(conditionField{f}) = conditionMap{idx, f + 1}; + end + end + end +end + +% Apply InfoTable (row k -> block k) +if ~isempty(infoTable) + nRows = height(infoTable); + nBlocks = length(blocks); + nApply = min(nRows, nBlocks); + colNames = infoTable.Properties.VariableNames; + for k = 1:nApply + for c = 1:length(colNames) + val = infoTable.(colNames{c})(k); + if iscell(val) + blocks(k).info.(colNames{c}) = val{1}; + else + blocks(k).info.(colNames{c}) = val; + end + end + end +end + +% Apply InfoFields (constant across all blocks) +if ~isempty(fieldnames(infoFields)) + fNames = fieldnames(infoFields); + for k = 1:length(blocks) + for f = 1:length(fNames) + blocks(k).info.(fNames{f}) = infoFields.(fNames{f}); + end + end +end + +% Auto-promote scalar marker extras into block.info. For each block, inspect +% its marker rows (start marker, or all rows spanning the block window) and +% any EXTRA column (beyond Time/Code/Duration/Amplitude) that resolves to a +% single value for that block is copied into block.info.. This makes +% per-trial factors (e.g. an onset marker's RT, condition label) visible to +% the Experiment class for groupby/plotInfoBar. +% +% Precedence: explicit info sources (ConditionMap, InfoTable, InfoFields) win +% over promotion. This step runs AFTER them and only fills a field that those +% higher-priority sources left unset (empty), so a promoted value never +% overwrites an explicitly provided one. Reserved canonical names are excluded. +mtFull = pf2_base.normalizeMarkers(data.markers); +canonNames = {'Time', 'Code', 'Duration', 'Amplitude'}; +extraNames = setdiff(mtFull.Properties.VariableNames, canonNames, 'stable'); +if ~isempty(extraNames) && height(mtFull) > 0 + % Window used to attribute marker rows to a block. Default 'core' uses the + % pre-pad bounds so PrePad/PostPad cannot drag a neighbour's onset marker in; + % 'padded' uses the widened bounds to deliberately include markers that fall + % in the pad region. + if strcmp(markerWindow, 'padded') + blockTimes = [blocks.startTime]; + blockEnds = [blocks.endTime]; + else + blockTimes = coreStart; + blockEnds = coreEnd; + end + for k = 1:length(blocks) + % Rows belonging to this block: the triggering marker row plus any + % rows whose Time falls within [start, end). The end is EXCLUSIVE so + % a neighbouring block's onset marker (adjacent blocks share an + % endpoint) is not pulled in, which would make a per-trial column + % multi-valued and silently drop it. + rowMask = false(height(mtFull), 1); + mi = blocks(k).markerIndex; + if ~isempty(mi) && isscalar(mi) && mi >= 1 && mi <= height(mtFull) + rowMask(mi) = true; + end + rowMask = rowMask | (mtFull.Time >= blockTimes(k) & mtFull.Time < blockEnds(k)); + if ~any(rowMask) + continue; + end + for e = 1:numel(extraNames) + name = extraNames{e}; + % Only promote a field left unset by the explicit (higher-priority) + % info sources above. An existing non-empty .info field wins. + if isfield(blocks(k).info, name) && ~isempty(blocks(k).info.(name)) + continue; + end + vals = mtFull.(name)(rowMask, :); + scalarVal = singleScalarValue(vals); + if isempty(scalarVal) + continue; % column does not resolve to one value for this block + end + blocks(k).info.(name) = scalarVal{1}; + end + end +end + +% Filter by duration +if minDur > 0 || isfinite(maxDur) + keep = arrayfun(@(b) b.duration >= minDur && b.duration <= maxDur, blocks); + blocks = blocks(keep); + % Re-number after filtering + for k = 1:length(blocks) + blocks(k).info.BlockNumber = k; + end +end + +% Embed: store blocks on data struct and return data instead +if embedBlocks + data.blocks = blocks; + blocks = data; +end + +end + +%%_Subfunctions_________________________________________________________ + +function val = singleScalarValue(vals) +% SINGLESCALARVALUE Resolve a block's marker-column rows to one scalar value +% +% Returns a 1x1 cell containing the single value of the column for the block, +% or empty ({}) when the column has multiple distinct values (and so cannot be +% promoted to a scalar block.info field). +% +% Inputs: +% vals - Column slice for the block's marker rows (any type) +% +% Outputs: +% val - 1x1 cell with the unique value, or {} if not single-valued + +val = {}; +if isempty(vals) + return; +end + +% Cell columns: compare by isequal; treat [] / '' entries as missing and drop +% them so a partially-empty column still resolves to its single real value. +if iscell(vals) + vals = vals(~cellfun(@isempty, vals)); + if isempty(vals) + return; + end + if all(cellfun(@(x) isequal(x, vals{1}), vals)) + val = vals(1); + end + return; +end + +% Non-cell columns: drop missing entries (NaN / / ) +% first, then require exactly one unique remaining value. A missing-only +% column (e.g. an all-NaN extra) does NOT promote - it returns {} so NaN +% never leaks into block.info. +try + if size(vals, 2) == 1 + vals = vals(~ismissing(vals)); + end +catch + % Types without a missing concept (e.g. logical) keep all rows. +end +if isempty(vals) + return; +end +try + u = unique(vals); + if numel(u) == 1 + % u is already scalar here; wrap it directly. (categorical/string and + % numeric all take the same 1x1-cell path.) + val = {u}; + end +catch + val = {}; +end + +end + +function blocks = buildFromMarkerCode(mrk, markerCode, fixedDuration, useDuration) +% BUILDFROMMARKERCODE Find markers matching code and build blocks +% +% Inputs: +% mrk - Normalized marker array [M x 4] +% markerCode - Marker code(s) to match [column vector] +% fixedDuration - Fixed block duration in seconds (0 = auto from markers) +% useDuration - Force use of column 3 duration +% +% Outputs: +% blocks - Struct array of block definitions + +mrkValues = mrk(:, 2); +mrkTimes = mrk(:, 1); +mrkDurations = mrk(:, 3); +mrkAmplitudes = mrk(:, 4); + +% Find matching markers +matchIdx = find(ismember(mrkValues, markerCode(:))); + +if isempty(matchIdx) + blocks = struct('startTime', {}, 'endTime', {}, 'duration', {}, ... + 'markerCode', {}, 'markerIndex', {}, 'amplitude', {}, 'info', {}); + return; +end + +nBlocks = length(matchIdx); +blocks = repmat(struct('startTime', 0, 'endTime', 0, 'duration', 0, ... + 'markerCode', 0, 'markerIndex', 0, 'amplitude', 1, 'info', struct()), 1, nBlocks); + +for k = 1:nBlocks + idx = matchIdx(k); + st = mrkTimes(idx); + code = mrkValues(idx); + + if useDuration + % Explicitly requested: always use column 3 + dur = mrkDurations(idx); + elseif fixedDuration > 0 + % Fixed duration provided: use it + dur = fixedDuration; + else + % Auto: if marker has nonzero duration in column 3, use it + dur = mrkDurations(idx); + end + + blocks(k).startTime = st; + blocks(k).endTime = st + dur; + blocks(k).duration = dur; + blocks(k).markerCode = code; + blocks(k).markerIndex = idx; + blocks(k).amplitude = mrkAmplitudes(idx); + blocks(k).info = struct(); +end + +end + +function blocks = buildFromStartEnd(mrk, startMarker, endMarker) +% BUILDFROMSTARTEND Pair start/end markers to build blocks +% +% Inputs: +% mrk - Normalized marker array [M x 4] +% startMarker - Start marker code(s) [column vector] +% endMarker - End marker code(s) [column vector] +% +% Outputs: +% blocks - Struct array of block definitions + +mrkValues = mrk(:, 2); +mrkTimes = mrk(:, 1); +mrkAmplitudes = mrk(:, 4); + +% Ensure column vectors +startMarker = startMarker(:); +endMarker = endMarker(:); + +% If scalar endMarker, expand to match startMarker +if isscalar(endMarker) && ~isscalar(startMarker) + endMarker = repmat(endMarker, size(startMarker)); +end +if isscalar(startMarker) && ~isscalar(endMarker) + startMarker = repmat(startMarker, size(endMarker)); +end + +if length(startMarker) ~= length(endMarker) + error('pf2:defineBlocks:markerMismatch', ... + 'StartMarker and EndMarker must have the same number of elements (or one must be scalar).'); +end + +blockList = []; +for pidx = 1:length(startMarker) + sCode = startMarker(pidx); + eCode = endMarker(pidx); + + sIdx = find(mrkValues == sCode); + eIdx = find(mrkValues == eCode); + + % Pair each start with the next available end + for s = 1:length(sIdx) + si = sIdx(s); + st = mrkTimes(si); + % Find first end marker after this start + validEnds = eIdx(mrkTimes(eIdx) > st); + if isempty(validEnds) + continue; + end + ei = validEnds(1); + et = mrkTimes(ei); + + entry.startTime = st; + entry.endTime = et; + entry.duration = et - st; + entry.markerCode = sCode; + entry.markerIndex = si; + entry.amplitude = mrkAmplitudes(si); + entry.info = struct(); + + if isempty(blockList) + blockList = entry; + else + blockList(end+1) = entry; %#ok + end + + % Remove this end marker from available pool so it's not reused + eIdx(eIdx == ei) = []; + end +end + +if isempty(blockList) + blocks = struct('startTime', {}, 'endTime', {}, 'duration', {}, ... + 'markerCode', {}, 'markerIndex', {}, 'amplitude', {}, 'info', {}); +else + blocks = blockList; +end + +end diff --git a/+pf2/+data/editChannelMaskGUI.m b/+pf2/+data/editChannelMaskGUI.m index e465defc..78e27c57 100644 --- a/+pf2/+data/editChannelMaskGUI.m +++ b/+pf2/+data/editChannelMaskGUI.m @@ -1,4 +1,37 @@ function fNIR=editChannelMaskGUI(fNIR) +% EDITCHANNELMASKGUI Launch the interactive channel-quality mask editor +% +% Opens the probe channel-check GUI so a user can visually inspect each +% channel and toggle its quality flag in the fNIRS channel mask (fchMask). +% This is a thin wrapper around the underlying probeCheckGUI: it accepts an +% fNIRS struct, a saved channel-mask file path, or no argument at all, and +% returns the data with the edited mask applied. +% +% Syntax: +% fNIR = pf2.data.editChannelMaskGUI(fNIR) +% fNIR = pf2.data.editChannelMaskGUI(maskFilePath) +% fNIR = pf2.data.editChannelMaskGUI() +% +% Inputs: +% fNIR - One of the following [struct | char | string]: +% - fNIRS data structure to inspect and edit interactively. +% - Path to a saved channel-mask file (loads it into the GUI). +% - Omitted: launches the GUI with an empty probe-check session. +% +% Outputs: +% fNIR - fNIRS data structure with the updated .fchMask reflecting any +% channels the user accepted or rejected in the GUI [struct]. +% +% Example: +% % Edit the channel mask for sample data interactively +% data = pf2.import.sampleData.fNIR2000(); +% data = pf2.data.editChannelMaskGUI(data); +% +% Notes: +% - This is an interactive GUI function and requires a display; it is not +% suitable for headless/-batch sessions. +% +% See also: pf2.data.applyChannelMask, pf2.qc.ChannelCheck %This is a wrapper for ProbeCheckGUI if(nargin<1) diff --git a/+pf2/+data/extractBlocks.m b/+pf2/+data/extractBlocks.m new file mode 100644 index 00000000..185c6459 --- /dev/null +++ b/+pf2/+data/extractBlocks.m @@ -0,0 +1,311 @@ +function segments = extractBlocks(data, blocks, opts) +% EXTRACTBLOCKS Extract each block as a separate fNIRS struct +% +% Takes an fNIRS data structure and a block definition array (from +% defineBlocks) and extracts each block as a separate fNIRS struct. +% Returns a cell array ready for exploreFNIRS.core.Experiment. +% +% Blocks can be provided explicitly as the second argument or read +% from data.blocks (set by defineBlocks with 'Embed', true). When +% data is a cell array, each element is extracted and results are +% concatenated. +% +% Syntax: +% segments = pf2.data.extractBlocks(data, blocks) +% segments = pf2.data.extractBlocks(data, blocks, 'Name', Value) +% segments = pf2.data.extractBlocks(data) % uses data.blocks +% segments = pf2.data.extractBlocks(data, 'Name', Value) % uses data.blocks +% segments = pf2.data.extractBlocks(cellData) % cell array input +% +% Inputs: +% data - fNIRS data structure with time-series fields (.HbO, .time, etc.) +% or a cell array of fNIRS structs (each with .blocks) +% blocks - (Optional) Block definition struct array from pf2.data.defineBlocks +% Each element must have .startTime, .endTime, and .info fields. +% If omitted, data.blocks is used. +% +% Name-Value Parameters: +% 'PreTime' - Seconds to include before block start. Overrides Buffer +% for the pre side when given (default: from Buffer). +% 'PostTime' - Seconds to include after block end. Overrides Buffer for +% the post side when given (default: from Buffer). +% 'Buffer' - Symmetric padding in seconds applied to BOTH sides when +% the corresponding PreTime/PostTime is not given +% (default: 2). With no window argument at all, the +% default Buffer = 2 s is used and a note is emitted +% (pf2:extractBlocks:defaultBuffer) whenever this default +% path is taken (i.e. on every call with no window given). +% 'BaselineWindow' - [start, end] relative to block start for baseline +% subtraction, e.g. [-5, 0] (default: []) +% 'SetT0' - Shift time so block start = 0 (default: true) +% 'OverwriteInfo' - Allow block.info fields to overwrite parent data.info +% fields of the same name (default: true). When false, +% parent fields are preserved and block.info only adds +% new fields. +% 'SkipInvalid' - Skip blocks outside data time range (default: true) +% 'RejectByAux' - Reject epochs overlapping accelerometer-flagged motion. +% Pass the accelerometer Aux signal name, or true to +% auto-detect an ACCEL-type signal (default: '' = off). +% 'AuxMotionFraction' - Max tolerated fraction of a block's samples that may +% fall in flagged motion before the epoch is dropped +% (default: 0.2). Only used with RejectByAux. +% 'AuxMotionThresh' - Absolute motion-metric threshold passed to +% accelMotionDetect (default: [] = adaptive MAD threshold). +% +% Outputs: +% segments - Cell array {1 x N} of fNIRS structs, one per valid block +% Each struct has merged .info from parent data and block. +% +% Algorithm: +% 1. For each block, compute extraction window with PreTime/PostTime +% 2. Optionally compute baseline window relative to block start +% 3. Call pf2.data.split to extract the time window +% 4. Optionally shift time with pf2.data.setT0 so block onset = 0 +% 5. Merge parent data.info and block.info into segment.info +% +% Example: +% blocks = pf2.data.defineBlocks(data, 'StartMarker', 50, 'EndMarker', 51); +% segments = pf2.data.extractBlocks(data, blocks, ... +% 'PreTime', 5, 'PostTime', 2, ... +% 'BaselineWindow', [-5, 0], 'SetT0', true); +% +% % Symmetric padding on both sides with Buffer +% segments = pf2.data.extractBlocks(data, blocks, 'Buffer', 10); +% +% % Prevent block info from overwriting parent subject info +% segments = pf2.data.extractBlocks(data, 'OverwriteInfo', false); +% +% % Using embedded blocks +% data = pf2.data.defineBlocks(data, [49,50], 30, 'Embed', true); +% segments = pf2.data.extractBlocks(data, 'PreTime', 5); +% +% % Cell array of subjects with embedded blocks +% segments = pf2.data.extractBlocks(allData); +% +% % Feed directly to Experiment +% ex = exploreFNIRS.core.Experiment(segments); +% +% 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 + +% --- Cell array input: iterate and concatenate --- +if iscell(data) + fwd = namedargs2cell(opts); + segments = {}; + for i = 1:numel(data) + segs = pf2.data.extractBlocks(data{i}, blocks, fwd{:}); + segments = [segments, segs]; %#ok + end + return; +end + +% --- Resolve blocks: explicit argument or data.blocks --- +if ~isempty(blocks) && isstruct(blocks) + % Guard: a data struct carrying embedded blocks (from defineBlocks with + % 'Embed', true, which is the default) is easy to pass here by mistake. + % It has a .blocks field but no .startTime, so detect it and use its + % blocks, or give an actionable error instead of failing later with a + % cryptic "Unrecognized field 'startTime'". + if ~isfield(blocks, 'startTime') + if isfield(blocks, 'blocks') + blocks = blocks.blocks; + else + error('pf2:extractBlocks:badBlocks', ... + ['Second argument is a struct but not a block array (no ''startTime'' field). ', ... + 'Either pass a block array from pf2.data.defineBlocks(..., ''Embed'', false), ', ... + 'or pass the embedded data struct as the FIRST argument: ', ... + 'pf2.data.extractBlocks(data).']); + end + end +else + if isfield(data, 'blocks') && ~isempty(data.blocks) + blocks = data.blocks; + else + error('pf2:extractBlocks:noBlocks', ... + 'No blocks provided and data.blocks is empty. Call defineBlocks first.'); + end +end + +% Resolve the extraction window. Precedence: an explicit PreTime/PostTime +% overrides for its side; otherwise the Buffer value (default 2 s) is applied +% to both sides. When the user passes no window argument at all, the default +% Buffer is used and an info-level note is emitted below. +% Empty defaults double as the "not given" sentinels the one-time note below +% keys on (formerly inputParser p.UsingDefaults). +gavePre = ~isempty(opts.PreTime); +gavePost = ~isempty(opts.PostTime); +gaveBuffer = ~isempty(opts.Buffer); +if gaveBuffer + buffer = opts.Buffer; +else + buffer = 2; +end + +if gavePre + preTime = opts.PreTime; +else + preTime = buffer; +end +if gavePost + postTime = opts.PostTime; +else + postTime = buffer; +end + +% This note is emitted when the default Buffer is used because NO window control +% was given at all (the pure default path), so a 15 s block does not silently +% inherit a surprise window. It is suppressed when a BaselineWindow is supplied +% (a deliberate epoch spec) and fires at most ONCE per session so batch loops +% are not flooded. +gaveBaseline = ~isempty(opts.BaselineWindow); +persistent defaultBufferNoted +if ~gavePre && ~gavePost && ~gaveBuffer && ~gaveBaseline && isempty(defaultBufferNoted) + defaultBufferNoted = true; + warning('pf2:extractBlocks:defaultBuffer', ... + ['No window given; using default Buffer = 2 s (both sides); ', ... + 'pass ''Buffer'' or ''PreTime''/''PostTime'' to set your own. ', ... + '(This note is shown once per session.)']); +end + +blWindow = opts.BaselineWindow; +doSetT0 = opts.SetT0; +overwriteInfo = opts.OverwriteInfo; +skipInvalid = opts.SkipInvalid; + +% --- Resolve aux motion mask for trial rejection (computed once) ---------- +rejectByAux = opts.RejectByAux; +auxMotionFraction = opts.AuxMotionFraction; +doRejectAux = false; +auxMotionMask = []; +if (islogical(rejectByAux) && rejectByAux) || ... + (~islogical(rejectByAux) && ~isempty(char(string(rejectByAux)))) + detectArgs = {}; + if ~islogical(rejectByAux) + detectArgs = {'Signal', char(string(rejectByAux))}; + end + if ~isempty(opts.AuxMotionThresh) + detectArgs = [detectArgs, {'Threshold', opts.AuxMotionThresh}]; %#ok + end + try + auxMotionMask = pf2_base.fnirs.accelMotionDetect(data, detectArgs{:}); + doRejectAux = true; + catch ME + warning('pf2:extractBlocks:auxRejectFailed', ... + 'Aux motion rejection skipped: %s', ME.message); + end +end + +if isempty(blocks) + segments = {}; + return; +end + +% Get data time range for validation +dataTimeMin = min(data.time); +dataTimeMax = max(data.time); + +segments = {}; +for k = 1:length(blocks) + blk = blocks(k); + + % Compute extraction window + extractStart = blk.startTime - preTime; + extractEnd = blk.endTime + postTime; + + % Validate time range + if skipInvalid + if extractStart > dataTimeMax || extractEnd < dataTimeMin + continue; + end + end + + % Aux-conditioned rejection: drop this epoch if too many of its samples + % fall in accelerometer-flagged motion windows (the block interval itself, + % not the padded extraction window). + if doRejectAux + inBlock = data.time >= blk.startTime & data.time <= blk.endTime; + if any(inBlock) && mean(auxMotionMask(inBlock)) > auxMotionFraction + continue; + end + end + + % Extract segment using split (without baseline - apply separately) + segment = pf2.data.split(data, extractStart, extractEnd); + + % Apply baseline subtraction if requested + if ~isempty(blWindow) + blAbsStart = blk.startTime + blWindow(1); + blAbsEnd = blk.startTime + blWindow(2); + + if blAbsEnd > blAbsStart + % Extract baseline period from original data + blSeg = pf2.data.split(data, blAbsStart, blAbsEnd); + + % Subtract baseline mean from hemoglobin fields + hbFields = {'HbO', 'HbR', 'HbDiff', 'HbTotal', 'CBSI'}; + for f = 1:length(hbFields) + fn = hbFields{f}; + if isfield(segment, fn) && isfield(blSeg, fn) + segment.(fn) = segment.(fn) - mean(blSeg.(fn), 1, 'omitnan'); + end + end + end + end + + % Skip empty/invalid segments + if isfield(segment, 'empty') && segment.empty + if skipInvalid + continue; + end + end + + % Shift time so block start = 0 + if doSetT0 + segment = pf2.data.setT0(segment, blk.startTime); + end + + % Remove parent blocks from segment (segments are individual blocks) + if isfield(segment, 'blocks') + segment = rmfield(segment, 'blocks'); + end + + % Merge info: start with parent data.info, overlay block.info + mergedInfo = struct(); + if isfield(data, 'info') + parentFields = fieldnames(data.info); + for f = 1:length(parentFields) + mergedInfo.(parentFields{f}) = data.info.(parentFields{f}); + end + end + + % Overlay block-level info + if isstruct(blk.info) + blockFields = fieldnames(blk.info); + for f = 1:length(blockFields) + if overwriteInfo || ~isfield(mergedInfo, blockFields{f}) + mergedInfo.(blockFields{f}) = blk.info.(blockFields{f}); + end + end + end + + segment.info = mergedInfo; + + segments{end+1} = segment; %#ok +end + +end diff --git a/+pf2/+data/getMarkerDict.m b/+pf2/+data/getMarkerDict.m new file mode 100644 index 00000000..17857315 --- /dev/null +++ b/+pf2/+data/getMarkerDict.m @@ -0,0 +1,105 @@ +function dict = getMarkerDict(data) +% GETMARKERDICT Resolve the canonical marker dictionary for a dataset +% +% Returns the dataset's code->label dictionary as a canonical table (Code, +% Label, + attributes). If no explicit dictionary has been set, one is +% derived from the best available source, in order: +% 1. data.info.markerDict (explicitly set canonical dictionary) +% 2. data.info.eventTypes (BIDS events.tsv mapping) +% 3. data.info.log_info.MarkerDict (COBI .nir Marker Dictionary) +% 4. the unique codes in data.markers (labels left blank) +% +% Syntax: +% dict = pf2.data.getMarkerDict(data) +% +% Inputs: +% data - fNIRS data struct, a marker table, or a cell array of structs +% (a cell array returns the union of all element dictionaries). +% +% Outputs: +% dict - Canonical dictionary table keyed by Code (see normalizeMarkerDict). +% +% Algorithm: +% 1. Cell array -> recurse over elements and union the dictionaries with +% pf2_base.mergeMarkerDict (earlier elements win on Code conflicts). +% 2. Marker table -> derive a label-less dictionary from its unique codes. +% 3. Struct -> return the first available source in priority order: +% info.markerDict, then info.eventTypes, then info.log_info.MarkerDict, +% each normalized via pf2_base.normalizeMarkerDict; if none exist, derive +% a label-less dictionary from the codes in data.markers. +% +% Notes: +% - The function always returns a valid (possibly 0-row) canonical table; it +% never errors on a missing dictionary. Derived dictionaries carry Code +% values with Label, which labelMarkers/defineBlocks skip. +% - Only the FIRST populated source is used (no merge across sources on a +% single struct); set an explicit dictionary with setMarkerDict to override. +% +% Example: +% dict = pf2.data.getMarkerDict(data); +% label = dict.Label(dict.Code == 49); +% +% See also: pf2.data.setMarkerDict, pf2.data.labelMarkers, +% pf2_base.normalizeMarkerDict + +% Cell array: union dictionaries across all elements +if iscell(data) + dict = pf2_base.normalizeMarkerDict([]); + for ci = 1:numel(data) + dict = pf2_base.mergeMarkerDict(dict, pf2.data.getMarkerDict(data{ci})); + end + return; +end + +% Marker table passed directly: derive from its codes +if istable(data) + dict = dictFromCodes(data); + return; +end + +if ~isstruct(data) + error('pf2:getMarkerDict:badInput', ... + 'Input must be an fNIRS struct, a marker table, or a cell array.'); +end + +% 1. Explicit dictionary +if isfield(data, 'info') && isfield(data.info, 'markerDict') && ... + ~isempty(data.info.markerDict) + dict = pf2_base.normalizeMarkerDict(data.info.markerDict); + return; +end + +% 2. BIDS eventTypes +if isfield(data, 'info') && isfield(data.info, 'eventTypes') && ... + ~isempty(data.info.eventTypes) + dict = pf2_base.normalizeMarkerDict(data.info.eventTypes); + return; +end + +% 3. COBI .nir Marker Dictionary +if isfield(data, 'info') && isfield(data.info, 'log_info') && ... + isstruct(data.info.log_info) && isfield(data.info.log_info, 'MarkerDict') && ... + ~isempty(data.info.log_info.MarkerDict) + dict = pf2_base.normalizeMarkerDict(data.info.log_info.MarkerDict); + return; +end + +% 4. Derive from the codes present in the markers +if isfield(data, 'markers') + dict = dictFromCodes(data.markers); +else + dict = pf2_base.normalizeMarkerDict([]); +end + +end + +%%_Subfunctions_________________________________________________________ + +function dict = dictFromCodes(markers) +% DICTFROMCODES Build a label-less dictionary from the unique marker codes +mt = pf2_base.normalizeMarkers(markers); +codes = unique(mt.Code); +labels = strings(numel(codes), 1); +labels(:) = missing; +dict = table(codes, labels, 'VariableNames', {'Code', 'Label'}); +end diff --git a/+pf2/+data/getMarkers.m b/+pf2/+data/getMarkers.m index 2d270193..e2f515ab 100644 --- a/+pf2/+data/getMarkers.m +++ b/+pf2/+data/getMarkers.m @@ -1,4 +1,4 @@ -function [markerTimes, tableMrkTimes, matchedPatterns] = getMarkers(varargin) +function [markerTimes, tableMrkTimes, matchedPatterns] = getMarkers(fNIR, markersStart, markersEnd, opts) % GETMARKERS Extract event marker times matching specified codes or patterns % % Searches fNIRS marker data to find events matching specified marker codes @@ -12,8 +12,9 @@ % [markerTimes, tableMrkTimes, matchedPatterns] = pf2.data.getMarkers(...) % % Inputs: -% fNIR - fNIRS data structure with .markers field [M x 3] -% Or marker table/matrix directly +% fNIR - fNIRS data structure with .markers field (canonical +% marker table: .Time .Code .Duration .Amplitude + extras) +% Or a marker table/matrix directly % markerCode - Single marker code(s) to find: % - Scalar: Find all markers with this code % - [50,51] row vector: Find sequence 50 followed by 51 @@ -59,40 +60,28 @@ -p=inputParser; +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 validfNIR_Input = @(x) (isstruct(x) && (isfield(x,'raw')||isfield(x,'time')||isfield(x,'info'))); -validfNIR_or_marker_Input = @(x) (istable(x)&&size(x,1)>1)||(isnumeric(x)&&length(x)>1) ||validfNIR_Input(x); -validScalarNum = @(x) isnumeric(x) && ismatrix(x)||islogical(x); -validScalarNumOrCell = @(x) (isnumeric(x) && ismatrix(x) || iscell(x)); -isStringOrChar = @(x) isstring(x)||ischar(x); - -addRequired(p,'fNIR',validfNIR_or_marker_Input); -addOptional(p,'markersStart',[],validScalarNum); -addOptional(p,'markersEnd',[],validScalarNum); -addParameter(p,'markerPattern',[],validScalarNumOrCell); -addParameter(p,'markerColumn',2,validScalarNum); -addParameter(p,'markerVariableName',[],isStringOrChar); -addParameter(p,'timeColumn',1,validScalarNum); -addParameter(p,'returnIndicies',false,validScalarNum); -addParameter(p,'exactMatch',false,validScalarNum); -addParameter(p,'sortTimes',false,validScalarNum); - - - -parse(p,varargin{:}); - -fNIR=p.Results.fNIR; -markersStart=p.Results.markersStart; -markersEnd=p.Results.markersEnd; - -markerPatternIn=p.Results.markerPattern; -markerColumn=p.Results.markerColumn; -markerVariableName=p.Results.markerVariableName; -timeColumn=p.Results.timeColumn; -returnIndicies=p.Results.returnIndicies; -exactMatch=p.Results.exactMatch; -sortTimes=p.Results.sortTimes; + +markerPatternIn=opts.markerPattern; +markerColumn=opts.markerColumn; +markerVariableName=opts.markerVariableName; +timeColumn=opts.timeColumn; +returnIndices=opts.returnIndices; +exactMatch=opts.exactMatch; +sortTimes=opts.sortTimes; if(iscell(markersStart)) markerPatternIn=markersStart; @@ -101,7 +90,7 @@ if(timeColumn<=0) - returnIndicies=true; + returnIndices=true; end isFNIRstruct=validfNIR_Input(fNIR); @@ -127,6 +116,49 @@ return; end +% --- Fast path: pure OR query (column vector of codes) ------------------- +% A column vector such as [50;51] means "markers with code 50 OR 51". +% The general regex-based matcher below builds a separate single-code +% pattern per row and can drop matches for multi-code OR queries, so handle +% this common case directly with set membership. Returns onset times for any +% of the requested codes, sorted chronologically. Scalar codes (1x1) and +% sequence row vectors ([50,51]) fall through to the general matcher. +isPureOR = ~iscell(markersStart) && isnumeric(markersStart) ... + && size(markersStart,1) > 1 && size(markersStart,2) == 1 ... + && isempty(markersEnd) && isempty(markerPatternIn) && ~exactMatch; +if isPureOR + mVals = fNIR.markers(:, markerColumn); + if istable(mVals); mVals = mVals{:,1}; end + codes = unique(markersStart(:)); + sel = ismember(mVals, codes); + + if ~any(sel) + warning('pf2:getMarkers:noMatch', ... + 'None of the requested marker codes (%s) were found in the data.', ... + mat2str(codes(:)')); + markerTimes = []; + tableMrkTimes = {}; + matchedPatterns = cell(0); + return; + end + + if timeColumn <= 0 || returnIndices + tVals = find(sel); + else + tVals = fNIR.markers(sel, timeColumn); + if istable(tVals); tVals = tVals{:,1}; end + end + selCodes = mVals(sel); + + [tVals, ord] = sort(tVals); + selCodes = selCodes(ord); + + markerTimes = tVals; % Nx1 onset times (start-only semantics) + tableMrkTimes = table(tVals, selCodes, 'VariableNames', {'Time','Code'}); + matchedPatterns = {codes(:)'}; + return; +end + uMatchingMarkers=[]; for i=1:size(markersStart,1) @@ -280,7 +312,7 @@ matchedPatterns{j,2}=markersEndStr(j,:); end else - error('Marker mismatch\nPlease supply 1 start marker for each end marker or only one start/end marker'); + error('pf2:getMarkers:markerMismatch', 'Marker mismatch\nPlease supply 1 start marker for each end marker or only one start/end marker'); end end @@ -351,7 +383,7 @@ return; end -if(returnIndicies) +if(returnIndices) markerTimes=markerTimes(:,[4,5,3,1,2,6]); end @@ -387,7 +419,7 @@ regMrkIdx(reg_upper_idx)=uVals(reg_upper_idx)+64; regMrkIdx(reg_lower_idx)=uVals(reg_lower_idx)+96; if(max(uVals>63)) - error('Too many unique markers'); + error('pf2:getMarkers:tooManyMarkers', 'Too many unique markers'); end end diff --git a/+pf2/+data/grandAverage.m b/+pf2/+data/grandAverage.m new file mode 100644 index 00000000..fd7b5fcd --- /dev/null +++ b/+pf2/+data/grandAverage.m @@ -0,0 +1,25 @@ +function ga = grandAverage(segments, varargin) +% GRANDAVERAGE Alias for pf2.data.blockAverage +% +% Convenience alias so the trial/grand-averaging entry point is discoverable +% under both names. See pf2.data.blockAverage for the full documentation, +% options, and output format. +% +% Syntax: +% ga = pf2.data.grandAverage(segments) +% ga = pf2.data.grandAverage(segments, 'Name', Value) +% +% Inputs: +% segments - Cell array of oxy-processed fNIRS structs (see blockAverage). +% +% Outputs: +% ga - Grand-average struct (see pf2.data.blockAverage). +% +% Example: +% ga = pf2.data.grandAverage(segments); +% +% See also: pf2.data.blockAverage, pf2.data.extractBlocks + +ga = pf2.data.blockAverage(segments, varargin{:}); + +end diff --git a/+pf2/+data/importBlockInfo.m b/+pf2/+data/importBlockInfo.m new file mode 100644 index 00000000..6574af21 --- /dev/null +++ b/+pf2/+data/importBlockInfo.m @@ -0,0 +1,382 @@ +function blocks = importBlockInfo(blocks, source, opts) +% IMPORTBLOCKINFO Import block-level metadata into block structs +% +% Attaches per-block metadata to a block struct array (from defineBlocks). +% The metadata source can be a CSV/Excel file path, an in-memory MATLAB +% table, or a numeric vector (one value per block). File and table sources +% support positional matching (row order) and key-based matching; filtering +% by MarkerCode or Condition restricts which blocks receive metadata, and +% non-matching blocks pass through unchanged. +% +% Most users already have their per-block factor (e.g. a behavioral score or +% condition label) in memory, so passing a table or numeric vector avoids a +% round-trip through disk. A table behaves identically to a just-read CSV. +% +% Syntax: +% blocks = pf2.data.importBlockInfo(blocks, filepath) +% blocks = pf2.data.importBlockInfo(blocks, tbl) +% blocks = pf2.data.importBlockInfo(blocks, vec, 'Field', 'score') +% blocks = pf2.data.importBlockInfo(blocks, source, 'MarkerCode', 49) +% blocks = pf2.data.importBlockInfo(blocks, source, 'Keys', keyCol) +% blocks = pf2.data.importBlockInfo(blocks, source, ..., 'Name', Value) +% +% Inputs: +% blocks - Struct array from pf2.data.defineBlocks [1 x N struct] +% Each element has .markerCode, .info (with .BlockNumber, etc.) +% source - Metadata source, one of: +% * Path to a CSV (.csv) or Excel (.xlsx, .xls) file +% [char|string]. Read via readtable, then treated as a table. +% * MATLAB table. Columns are merged into block .info using the +% same key/positional semantics as a just-read CSV. +% * Numeric vector/column [N x 1] or [1 x N]. One value per +% block, assigned to the field named by 'Field' (default +% 'value'). Its length must match the number of (filtered) +% blocks. +% +% Name-Value Parameters: +% 'Field' - Target .info field name for a numeric vector source +% (default: 'value'). Ignored for file/table sources. +% 'Keys' - Column name(s) for key-based matching [char|string|cellstr] +% When specified, uses exact-match semantics (like importInfo). +% When omitted, uses positional matching (row order). +% Applies to file/table sources only. +% 'MarkerCode' - Filter: only apply to blocks with this marker code [numeric] +% 'Condition' - Filter: only apply to blocks with this condition label [char|string] +% 'Sheet' - Sheet name or index for Excel files (default: 1) +% 'Overwrite' - Overwrite existing .info fields (default: true) +% 'ReadOptions' - Cell array of extra arguments passed to readtable (default: {}) +% +% Outputs: +% blocks - Same struct array with .info fields updated on matched blocks. +% Non-matching blocks (filtered out) are returned unchanged. +% +% Algorithm: +% 1. Classify the source (file path -> readtable -> table; table; or +% numeric vector). Numeric vectors assign one value per filtered block. +% 2. Apply MarkerCode/Condition filter to identify target blocks +% 3a. Positional mode: verify row count == filtered block count, then +% assign row k to k-th filtered block +% 3b. Key mode: for each filtered block, find matching row (error on 0 or >1) +% 4. Copy columns into block .info (respecting Overwrite setting) +% 5. Warn if any source rows were not matched +% +% Example: +% % Positional: row 1 -> block 1, row 2 -> block 2 +% blocks = pf2.data.importBlockInfo(blocks, 'trial_data.csv'); +% +% % Only apply to Task blocks (marker 49), skip Rest (marker 50) +% blocks = pf2.data.importBlockInfo(blocks, 'trial_data.csv', ... +% 'MarkerCode', 49); +% +% % Filter by condition label +% blocks = pf2.data.importBlockInfo(blocks, 'trial_data.csv', ... +% 'Condition', 'Task'); +% +% % Key-based matching +% blocks = pf2.data.importBlockInfo(blocks, 'trial_data.csv', ... +% 'Keys', 'BlockNumber'); +% +% % In-memory table (behaves like a just-read CSV) +% T = table([85; 90]', 'VariableNames', {'Score'}); +% blocks = pf2.data.importBlockInfo(blocks, T); +% +% % Numeric per-block vector -> named .info field +% data = pf2.import.sampleData.fNIR2000(); +% proc = processFNIRS2(data); +% blocks = pf2.data.defineBlocks(proc, [1 2], 20, 'Embed', false); +% scores = (1:numel(blocks))'; +% blocks = pf2.data.importBlockInfo(blocks, scores, 'Field', 'score'); +% blocks(1).info.score % -> 1 +% +% 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 + +keys = cellstr(opts.Keys); +if numel(keys) == 1 && isempty(keys{1}) + keys = {}; +end +fieldName = char(opts.Field); +markerFilter = opts.MarkerCode; +condFilter = string(opts.Condition); +sheet = opts.Sheet; +overwrite = opts.Overwrite; +readOpts = opts.ReadOptions; + +useKeyMode = ~isempty(keys); + +% --- Resolve the source into a table (file/table) or numeric vector --- +isNumericSource = false; +numericVals = []; + +if ischar(source) || (isstring(source) && isscalar(source)) + % File path -> read into a table + filepath = char(source); + if ~isfile(filepath) + error('pf2:data:importBlockInfo:fileNotFound', ... + 'File not found: %s', filepath); + end + readArgs = readOpts; + [~, ~, ext] = fileparts(filepath); + if any(strcmpi(ext, {'.xlsx', '.xls'})) + readArgs = [readArgs, {'Sheet', sheet}]; + end + tbl = readtable(filepath, readArgs{:}); +elseif istable(source) + % In-memory table -> identical handling to a just-read CSV + tbl = source; +elseif isnumeric(source) && isvector(source) + % Per-block numeric vector + isNumericSource = true; + numericVals = source(:); +else + error('pf2:data:importBlockInfo:badSource', ... + ['Unsupported metadata source. Provide one of: a CSV/XLSX file ', ... + 'path (char/string), a MATLAB table, or a per-block numeric ', ... + 'vector (with the ''Field'' option naming the target .info field).']); +end + +if ~isNumericSource + colNames = tbl.Properties.VariableNames; +end + +if ~isNumericSource + % Validate key columns exist in source + if useKeyMode + for k = 1:numel(keys) + if ~any(strcmp(colNames, keys{k})) + error('pf2:data:importBlockInfo:keyNotInFile', ... + 'Key column ''%s'' not found in source. Available columns: %s', ... + keys{k}, strjoin(colNames, ', ')); + end + end + end + + % Determine columns to copy (all if positional, non-key if key mode) + if useKeyMode + copyCols = colNames(~ismember(colNames, keys)); + else + copyCols = colNames; + end +end + +% Apply filter to find target block indices +nBlocks = numel(blocks); +filterMask = true(1, nBlocks); + +if ~isempty(markerFilter) + for b = 1:nBlocks + filterMask(b) = filterMask(b) && ismember(blocks(b).markerCode, markerFilter); + end +end + +if strlength(condFilter) > 0 + for b = 1:nBlocks + if isfield(blocks(b).info, 'Condition') + filterMask(b) = filterMask(b) && string(blocks(b).info.Condition) == condFilter; + else + filterMask(b) = false; + end + end +end + +targetIdx = find(filterMask); +nTargets = numel(targetIdx); + +if isNumericSource + % --- Per-block numeric vector --- + if isempty(fieldName) + fieldName = 'value'; + noteOnce('pf2:data:importBlockInfo:defaultField', ... + ['importBlockInfo: ''Field'' not specified for a numeric ', ... + 'source; storing values in block .info.value.']); + end + + if numel(numericVals) ~= nTargets + error('pf2:data:importBlockInfo:lengthMismatch', ... + ['Numeric vector length (%d) does not match the number of ', ... + '%sblocks (%d).'], numel(numericVals), ... + filterDescription(markerFilter, condFilter), nTargets); + end + + for t = 1:nTargets + bidx = targetIdx(t); + if ~overwrite && isfield(blocks(bidx).info, fieldName) + continue; + end + blocks(bidx).info.(fieldName) = numericVals(t); + end + + return; +end + +if useKeyMode + % --- Key-based matching --- + rowUsed = false(height(tbl), 1); + + for t = 1:nTargets + bidx = targetIdx(t); + blk = blocks(bidx); + + matchMask = true(height(tbl), 1); + keyVals = cell(1, numel(keys)); + + for k = 1:numel(keys) + keyName = keys{k}; + + % Look for key in block .info first, then top-level block fields + if isfield(blk.info, keyName) + infoVal = blk.info.(keyName); + elseif isfield(blk, keyName) + infoVal = blk.(keyName); + else + error('pf2:data:importBlockInfo:keyNotInBlock', ... + 'Key field ''%s'' not found in block %d .info or block fields.', ... + keyName, bidx); + end + + tblCol = tbl.(keyName); + keyVals{k} = infoVal; + + if isnumeric(infoVal) + if isnumeric(tblCol) + matchMask = matchMask & (tblCol == infoVal); + else + matchMask = matchMask & (double(string(tblCol)) == infoVal); + end + else + matchMask = matchMask & (string(tblCol) == string(infoVal)); + end + end + + matchRows = find(matchMask); + + if isempty(matchRows) + keyStr = strjoin(cellfun(@(k,v) sprintf('%s=%s', k, string(v)), ... + keys, keyVals, 'UniformOutput', false), ', '); + error('pf2:data:importBlockInfo:noMatch', ... + 'No rows match block %d (%s).', bidx, keyStr); + end + + if numel(matchRows) > 1 + keyStr = strjoin(cellfun(@(k,v) sprintf('%s=%s', k, string(v)), ... + keys, keyVals, 'UniformOutput', false), ', '); + error('pf2:data:importBlockInfo:ambiguousMatch', ... + 'Multiple rows (%d) match block %d (%s).', numel(matchRows), bidx, keyStr); + end + + rowUsed(matchRows) = true; + blocks(bidx) = applyRow(blocks(bidx), tbl, matchRows, copyCols, overwrite); + end + + % Warn about unused rows + if any(~rowUsed) + warning('pf2:data:importBlockInfo:unusedRows', ... + '%d source row(s) not matched to any block.', sum(~rowUsed)); + end + +else + % --- Positional matching --- + nRows = height(tbl); + + if nRows ~= nTargets + error('pf2:data:importBlockInfo:rowCountMismatch', ... + 'Row count (%d) does not match filtered block count (%d).', nRows, nTargets); + end + + for t = 1:nTargets + bidx = targetIdx(t); + blocks(bidx) = applyRow(blocks(bidx), tbl, t, copyCols, overwrite); + end +end + +end + +%%_Subfunctions_________________________________________________________ + +function noteOnce(id, msg) +% NOTEONCE Emit an informational note at most once per MATLAB session +% +% Used to notify the user of a defaulted option without spamming repeated +% calls (e.g. inside a batch loop). +% +% Inputs: +% id - Unique identifier string for this note +% msg - Message text to display +% +% Outputs: +% (none) + +persistent seen +if isempty(seen) + seen = {}; +end +if ~any(strcmp(seen, id)) + seen{end+1} = id; %#ok + fprintf('%s\n', msg); +end + +end + +function desc = filterDescription(markerFilter, condFilter) +% FILTERDESCRIPTION Build a human-readable prefix describing active filters +% +% Inputs: +% markerFilter - MarkerCode filter value(s) or [] [numeric] +% condFilter - Condition filter label or "" [string] +% +% Outputs: +% desc - Prefix string such as 'filtered (MarkerCode=49) ' or '' when no +% filter is active. Trailing space included for sentence assembly. + +parts = {}; +if ~isempty(markerFilter) + parts{end+1} = sprintf('MarkerCode=%s', mat2str(markerFilter)); %#ok +end +if strlength(condFilter) > 0 + parts{end+1} = sprintf('Condition=%s', condFilter); %#ok +end +if isempty(parts) + desc = ''; +else + desc = sprintf('filtered (%s) ', strjoin(parts, ', ')); +end + +end + +function blk = applyRow(blk, tbl, rowIdx, copyCols, overwrite) +% APPLYROW Copy table row columns into block .info +% +% Inputs: +% blk - Single block struct +% tbl - Table read from file +% rowIdx - Row index to copy from +% copyCols - Cell array of column names to copy +% overwrite - Whether to overwrite existing fields + +for c = 1:numel(copyCols) + colName = copyCols{c}; + if ~overwrite && isfield(blk.info, colName) + continue; + end + val = tbl.(colName)(rowIdx); + if iscell(val) + blk.info.(colName) = val{1}; + elseif iscategorical(val) + blk.info.(colName) = char(val); + else + blk.info.(colName) = val; + end +end + +end diff --git a/+pf2/+data/importInfo.m b/+pf2/+data/importInfo.m new file mode 100644 index 00000000..bcf8c40d --- /dev/null +++ b/+pf2/+data/importInfo.m @@ -0,0 +1,250 @@ +function data = importInfo(data, filepath, varargin) +% IMPORTINFO Import subject-level metadata from CSV/Excel into fNIRS structs +% +% Reads a CSV or Excel file and matches rows to fNIRS data structures by +% key columns. Each struct must match exactly one row. All non-key columns +% are copied into the struct's .info field. +% +% Syntax: +% data = pf2.data.importInfo(data, filepath, keyColumn) +% data = pf2.data.importInfo(data, filepath, 'Keys', keyColumns) +% data = pf2.data.importInfo(data, filepath, ..., 'Name', Value) +% +% Inputs: +% data - fNIRS data structure or cell array of structures. +% Each struct must have an .info field containing key values. +% filepath - Path to CSV (.csv) or Excel (.xlsx, .xls) file [char|string] +% +% Name-Value Parameters: +% 'Keys' - Column name(s) to match on [char|string|cellstr] +% Also accepted as first positional argument after filepath. +% 'Sheet' - Sheet name or index for Excel files (default: 1) +% 'Overwrite' - Overwrite existing .info fields (default: true) +% 'ReadOptions' - Cell array of extra arguments passed to readtable (default: {}) +% +% Outputs: +% data - Same structure(s) with .info fields updated from matched rows. +% Returns same type as input (struct -> struct, cell -> cell). +% +% Algorithm: +% 1. Read file into table via readtable +% 2. Validate key columns exist in file and in each struct's .info +% 3. For each struct, find row(s) where all keys match +% 4. Error if 0 or >1 rows match any struct +% 5. Copy non-key columns into .info (respecting Overwrite setting) +% 6. Warn if any CSV rows were not matched to any struct +% +% Example: +% % Single key matching +% allData = pf2.data.importInfo(allData, 'demographics.csv', 'SubjectID'); +% +% % Multi-key matching +% allData = pf2.data.importInfo(allData, 'metadata.xlsx', ... +% 'Keys', {'SubjectID', 'Session'}); +% +% % Preserve existing .info fields +% d = pf2.data.importInfo(d, 'extra.csv', 'SubjectID', 'Overwrite', false); +% +% See also: pf2.data.importBlockInfo, pf2.data.defineBlocks + +% --- Parse positional key argument vs name-value --- +positionalKeys = {}; +remainingArgs = varargin; + +if ~isempty(varargin) && (ischar(varargin{1}) || isstring(varargin{1})) + candidate = varargin{1}; + % Check if it's a name-value parameter name (not a key column name) + nvNames = {'Keys', 'Sheet', 'Overwrite', 'ReadOptions'}; + if ~any(strcmpi(string(candidate), nvNames)) + positionalKeys = cellstr(candidate); + remainingArgs = varargin(2:end); + end +elseif ~isempty(varargin) && iscellstr(varargin{1}) + positionalKeys = varargin{1}; + remainingArgs = varargin(2:end); +end + +p = inputParser; +p.addParameter('Keys', {}, @(x) ischar(x) || isstring(x) || iscellstr(x)); +p.addParameter('Sheet', 1, @(x) isnumeric(x) || ischar(x) || isstring(x)); +p.addParameter('Overwrite', true, @islogical); +p.addParameter('ReadOptions', {}, @iscell); +p.parse(remainingArgs{:}); + +if ~isempty(positionalKeys) + keys = positionalKeys; +else + keys = cellstr(p.Results.Keys); +end + +sheet = p.Results.Sheet; +overwrite = p.Results.Overwrite; +readOpts = p.Results.ReadOptions; + +% Validate keys provided +if isempty(keys) || (numel(keys) == 1 && isempty(keys{1})) + error('pf2:data:importInfo:noKeys', ... + 'Must specify at least one key column.'); +end + +% Validate file exists +if ~isfile(filepath) + error('pf2:data:importInfo:fileNotFound', ... + 'File not found: %s', filepath); +end + +% Read file +readArgs = readOpts; +[~, ~, ext] = fileparts(filepath); +if any(strcmpi(ext, {'.xlsx', '.xls'})) + readArgs = [readArgs, {'Sheet', sheet}]; +end +tbl = readtable(filepath, readArgs{:}); + +% Validate key columns exist in file +colNames = tbl.Properties.VariableNames; +for k = 1:numel(keys) + if ~any(strcmp(colNames, keys{k})) + error('pf2:data:importInfo:keyNotInFile', ... + 'Key column ''%s'' not found in file. Available columns: %s', ... + keys{k}, strjoin(colNames, ', ')); + end +end + +% Determine non-key columns +nonKeyIdx = ~ismember(colNames, keys); +nonKeyCols = colNames(nonKeyIdx); + +% Normalize input to cell array for uniform processing +inputWasCell = iscell(data); +if ~inputWasCell + dataCell = {data}; +else + dataCell = data; +end + +nStructs = numel(dataCell); +rowUsed = false(height(tbl), 1); + +for s = 1:nStructs + d = dataCell{s}; + + if ~isfield(d, 'info') + error('pf2:data:importInfo:keyNotInInfo', ... + 'Struct %d has no .info field.', s); + end + + % Validate key fields exist in .info and build match mask + matchMask = true(height(tbl), 1); + keyVals = cell(1, numel(keys)); + + for k = 1:numel(keys) + keyName = keys{k}; + if ~isfield(d.info, keyName) + error('pf2:data:importInfo:keyNotInInfo', ... + 'Key field ''%s'' not found in struct %d .info.', keyName, s); + end + + infoVal = d.info.(keyName); + tblCol = tbl.(keyName); + keyVals{k} = infoVal; + + % Type-aware comparison + if isnumeric(infoVal) + if isnumeric(tblCol) + matchMask = matchMask & (tblCol == infoVal); + else + matchMask = matchMask & (double(string(tblCol)) == infoVal); + end + else + % Compare as strings for char/string/categorical interop + matchMask = matchMask & (string(tblCol) == string(infoVal)); + end + end + + matchIdx = find(matchMask); + + if isempty(matchIdx) + keyStr = strjoin(cellfun(@(k,v) sprintf('%s=%s', k, string(v)), ... + keys, keyVals, 'UniformOutput', false), ', '); + hint = ''; + nShared = countStructsWithKeyVals(dataCell, keys, keyVals); + if nShared > 1 + hint = sprintf([' Note: %d of %d imported structs share these ' ... + 'key value(s) and cannot be matched to distinct rows -- a ' ... + 'directory import may have assigned the same key to multiple ' ... + 'files (see the ''Filename'' option of pf2.import.importDirectory).'], ... + nShared, nStructs); + end + error('pf2:data:importInfo:noMatch', ... + 'No rows match struct %d (%s).%s', s, keyStr, hint); + end + + if numel(matchIdx) > 1 + keyStr = strjoin(cellfun(@(k,v) sprintf('%s=%s', k, string(v)), ... + keys, keyVals, 'UniformOutput', false), ', '); + error('pf2:data:importInfo:ambiguousMatch', ... + 'Multiple rows (%d) match struct %d (%s).', numel(matchIdx), s, keyStr); + end + + rowUsed(matchIdx) = true; + + % Copy non-key columns into .info + for c = 1:numel(nonKeyCols) + colName = nonKeyCols{c}; + if ~overwrite && isfield(d.info, colName) + continue; + end + val = tbl.(colName)(matchIdx); + if iscell(val) + d.info.(colName) = val{1}; + elseif iscategorical(val) + d.info.(colName) = char(val); + else + d.info.(colName) = val; + end + end + + dataCell{s} = d; +end + +% Warn about unused rows +if any(~rowUsed) + nUnused = sum(~rowUsed); + warning('pf2:data:importInfo:unusedRows', ... + '%d row(s) in file not matched to any struct.', nUnused); +end + +% Return same type as input +if inputWasCell + data = dataCell; +else + data = dataCell{1}; +end + +end + + +function n = countStructsWithKeyVals(dataCell, keys, keyVals) +% COUNTSTRUCTSWITHKEYVALS Count structs whose key fields all equal keyVals. +% Used to diagnose no-match failures caused by duplicate identifiers +% (e.g. several files imported with the same SubjectID). + n = 0; + for i = 1:numel(dataCell) + d = dataCell{i}; + if ~isfield(d, 'info') + continue; + end + allEq = true; + for k = 1:numel(keys) + if ~isfield(d.info, keys{k}) || ... + ~isequal(string(d.info.(keys{k})), string(keyVals{k})) + allEq = false; + break; + end + end + if allEq + n = n + 1; + end + end +end diff --git a/+pf2/+data/infoFromTable.m b/+pf2/+data/infoFromTable.m new file mode 100644 index 00000000..eaee7cfe --- /dev/null +++ b/+pf2/+data/infoFromTable.m @@ -0,0 +1,223 @@ +function data = infoFromTable(data, T, value, opts) +% INFOFROMTABLE Write table columns back into .info fields of fNIRS structs +% +% Maps table rows positionally to fNIRS data structs: row 1 updates +% data{1}.info, row 2 updates data{2}.info, etc. By default, table columns +% are merged into existing .info fields, preserving any fields not present +% in the table. Missing values (NaN, "", NaT) are skipped rather than +% written, so existing .info values are preserved for those entries. +% +% A single field name and value can be passed instead of a table to set +% one field across all structs. A scalar value is broadcast; a vector is +% mapped positionally. +% +% Syntax: +% data = pf2.data.infoFromTable(data, T) +% data = pf2.data.infoFromTable(data, T, 'Overwrite', false) +% data = pf2.data.infoFromTable(data, T, 'Clear', true) +% data = pf2.data.infoFromTable(data, fieldName, value) +% data = pf2.data.infoFromTable(data, fieldName, scalarValue) +% +% Inputs: +% data - fNIRS data structure or cell array of structures, each with +% an .info field (or one will be created). +% T - MATLAB table with one row per struct. Column names become +% .info field names. height(T) must equal numel(data). +% fieldName - (Alternative) Single field name [char|string]. When used, +% the third argument is the value to assign. +% value - Value(s) to assign. Scalar is broadcast to all structs; +% vector must have numel(data) elements. +% +% Name-Value Parameters: +% 'Overwrite' - Whether to overwrite existing .info fields that appear +% in the table (default: true). When false, existing fields +% are preserved even if the table has a value for them. +% 'Clear' - If true, replaces .info entirely with table row contents, +% removing fields not in the table (default: false). +% +% Outputs: +% data - Same structure(s) with .info fields updated from table values. +% Returns same type as input (struct -> struct, cell -> cell). +% +% Example: +% T = pf2.data.infoToTable(allData); +% T.Group = ["A"; "B"; "A"]; +% allData = pf2.data.infoFromTable(allData, T); +% +% % Add new field without overwriting existing ones +% allData = pf2.data.infoFromTable(allData, T, 'Overwrite', false); +% +% % Set single field: scalar broadcast +% allData = pf2.data.infoFromTable(allData, 'Group', 'Control'); +% +% % Set single field: per-element vector +% allData = pf2.data.infoFromTable(allData, 'Group', ["A"; "B"; "C"]); +% +% 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 + +% A sentinel default marks "value was not supplied" (single-field mode +% requires an explicit value argument, matching the original ~isempty(varargin) +% guard). A real supplied value of [] must still trigger single-field mode. +valueSupplied = ~(isstring(value) && isscalar(value) && ... + value == "__pf2_infoFromTable_novalue__"); + +% --- Detect single-field mode: infoFromTable(data, fieldName, value) --- +if (ischar(T) || (isstring(T) && isscalar(T))) && valueSupplied + fieldName = char(T); + + % Normalize to cell array + inputWasCell = iscell(data); + if ~inputWasCell + dataCell = {data}; + else + dataCell = data; + end + N = numel(dataCell); + + % Expand scalar to vector + isScalarVal = isscalar(value) || (ischar(value) && size(value, 1) <= 1); + if isScalarVal + % Convert to char for .info convention if string + if isstring(value) + value = char(value); + end + for i = 1:N + d = dataCell{i}; + if ~isfield(d, 'info') || ~isstruct(d.info) + d.info = struct(); + end + d.info.(fieldName) = value; + dataCell{i} = d; + end + else + % Vector: must match length + if numel(value) ~= N + error('pf2:data:infoFromTable:sizeMismatch', ... + 'Value has %d elements but data has %d elements.', numel(value), N); + end + for i = 1:N + d = dataCell{i}; + if ~isfield(d, 'info') || ~isstruct(d.info) + d.info = struct(); + end + v = value(i); + if iscell(v), v = v{1}; end + if isstring(v), v = char(v); end + d.info.(fieldName) = v; + dataCell{i} = d; + end + end + + if inputWasCell + data = dataCell; + else + data = dataCell{1}; + end + return; +end + +% --- Table mode: parse inputs --- +overwrite = opts.Overwrite; +clearMode = opts.Clear; + +% --- Normalize to cell array --- +inputWasCell = iscell(data); +if ~inputWasCell + dataCell = {data}; +else + dataCell = data; +end +N = numel(dataCell); + +% --- Validate dimensions --- +if height(T) ~= N + error('pf2:data:infoFromTable:sizeMismatch', ... + 'Table has %d rows but data has %d elements.', height(T), N); +end + +colNames = T.Properties.VariableNames; + +% --- Write table rows into .info --- +for i = 1:N + d = dataCell{i}; + + if clearMode + info = struct(); + elseif isfield(d, 'info') && isstruct(d.info) + info = d.info; + else + info = struct(); + end + + for c = 1:numel(colNames) + fn = colNames{c}; + + % Skip if Overwrite is false and field already exists + if ~overwrite && ~clearMode && isfield(info, fn) + continue; + end + + % Extract value from table + val = T.(fn)(i); + + % Unwrap cell + if iscell(val) + val = val{1}; + end + + % Skip missing values + if isMissingValue(val) + continue; + end + + % Convert string to char for consistency with .info convention + if isstring(val) + val = char(val); + end + + info.(fn) = val; + end + + d.info = info; + dataCell{i} = d; +end + +% --- Return same type as input --- +if inputWasCell + data = dataCell; +else + data = dataCell{1}; +end + +end + +% ========================================================================= +% Local functions +% ========================================================================= + +function tf = isMissingValue(val) +% Check if a value is a type-appropriate "missing" that should be skipped + if isstring(val) && (ismissing(val) || val == "") + tf = true; + elseif ischar(val) && isempty(val) + tf = true; + elseif isnumeric(val) && isscalar(val) && isnan(val) + tf = true; + elseif isdatetime(val) && isnat(val) + tf = true; + elseif isduration(val) && isnan(val) + tf = true; + elseif iscategorical(val) && isundefined(val) + tf = true; + else + tf = false; + end +end diff --git a/+pf2/+data/infoToTable.m b/+pf2/+data/infoToTable.m new file mode 100644 index 00000000..a41b5d0a --- /dev/null +++ b/+pf2/+data/infoToTable.m @@ -0,0 +1,302 @@ +function T = infoToTable(data, varargin) +% INFOTOTABLE Extract .info metadata from fNIRS structs into a MATLAB table +% +% Collects the .info field from each element of a cell array (or single +% struct) into a MATLAB table with one row per struct and one column per +% info field. Only scalar-compatible values are extracted (numeric scalar, +% char, string, logical, categorical, datetime). Non-scalar fields (nested +% structs, arrays, cells) are silently skipped. Missing fields are filled +% with type-appropriate defaults (NaN, "", NaT). +% +% A single field name can be passed as the second argument to extract just +% that field as a column vector instead of a table. An optional SavePath +% writes the table to an Excel file. +% +% Syntax: +% T = pf2.data.infoToTable(data) +% T = pf2.data.infoToTable(data, 'Fields', {'SubjectID', 'Age', 'Group'}) +% vals = pf2.data.infoToTable(data, fieldName) +% T = pf2.data.infoToTable(data, 'SavePath', 'info.xlsx') +% +% Inputs: +% data - fNIRS data structure with .info field, or cell array of +% such structures. Each struct's .info is a flat struct. +% fieldName - (Optional positional) Single field name [char|string]. +% When provided, returns a column vector of that field's +% values instead of a table. +% +% Name-Value Parameters: +% 'Fields' - Cell array of field names to include (default: all) +% If specified, only these columns appear in the output table. +% Fields not found in any struct are still included as columns +% filled with their type-appropriate missing value. +% 'SavePath' - File path to write the table as Excel (.xlsx) [char|string] +% (default: ''). Ignored in single-field vector mode. +% +% Outputs: +% T - MATLAB table [N x F], or column vector [N x 1] when a single +% field name is given as a positional argument. +% +% Example: +% allData = {processed1, processed2, processed3}; +% T = pf2.data.infoToTable(allData); +% disp(T); +% +% % Select specific fields +% T = pf2.data.infoToTable(allData, 'Fields', {'SubjectID', 'Group'}); +% +% % Extract single field as vector +% groups = pf2.data.infoToTable(allData, 'Group'); +% +% % Export to Excel +% T = pf2.data.infoToTable(allData, 'SavePath', 'metadata.xlsx'); +% +% See also: pf2.data.infoFromTable, pf2.data.importInfo + +% --- Detect single-field positional argument --- +singleField = ''; +remainingArgs = varargin; +nvNames = {'Fields', 'SavePath'}; +if ~isempty(varargin) && (ischar(varargin{1}) || (isstring(varargin{1}) && isscalar(varargin{1}))) + candidate = char(varargin{1}); + % Check if it's a name-value parameter name + if ~any(strcmpi(candidate, nvNames)) + singleField = candidate; + remainingArgs = varargin(2:end); + end +end + +% --- Parse remaining name-value arguments --- +p = inputParser; +p.addParameter('Fields', {}, @(x) ischar(x) || isstring(x) || iscellstr(x)); +p.addParameter('SavePath', '', @(x) ischar(x) || isstring(x)); +p.parse(remainingArgs{:}); +requestedFields = cellstr(p.Results.Fields); +hasFieldFilter = ~isempty(requestedFields) && ~(numel(requestedFields) == 1 && isempty(requestedFields{1})); +savePath = char(p.Results.SavePath); + +% Single-field mode overrides Fields parameter +returnVector = ~isempty(singleField); +if returnVector + requestedFields = {singleField}; + hasFieldFilter = true; +end + +% --- Normalize to cell array --- +if ~iscell(data) + dataCell = {data}; +else + dataCell = data; +end +N = numel(dataCell); + +if N == 0 + T = table(); + return; +end + +% --- Pass 1: Discover all scalar-compatible fields and their types --- +% Map: fieldName -> MATLAB class name (first non-empty encounter wins) +allFields = {}; +fieldTypes = struct(); + +for i = 1:N + d = dataCell{i}; + if ~isfield(d, 'info') || ~isstruct(d.info) + continue; + end + fnames = fieldnames(d.info); + for j = 1:numel(fnames) + fn = fnames{j}; + val = d.info.(fn); + if ~isScalarCompatible(val) + continue; + end + if ~ismember(fn, allFields) + allFields{end+1} = fn; %#ok + fieldTypes.(fn) = classOfValue(val); + end + end +end + +% --- Apply field filter --- +if hasFieldFilter + colNames = requestedFields; +else + colNames = allFields; +end + +if isempty(colNames) + T = table(); + T = T(ones(N, 0), :); % N rows, 0 columns -> won't work; just return empty + % Actually return a table with N rows and no columns + T = cell2table(cell(N, 0)); + return; +end + +% --- Pass 2: Build column arrays --- +cols = cell(1, numel(colNames)); +for c = 1:numel(colNames) + fn = colNames{c}; + + % Determine column type + if isfield(fieldTypes, fn) + colType = fieldTypes.(fn); + else + colType = 'string'; % unknown fields default to string + end + + % Initialize column with missing values + col = initMissingColumn(N, colType); + + % Fill values + for i = 1:N + d = dataCell{i}; + if ~isfield(d, 'info') || ~isstruct(d.info) || ~isfield(d.info, fn) + continue; + end + val = d.info.(fn); + if ~isScalarCompatible(val) + continue; + end + + % Coerce to column type + col(i) = coerceValue(val, colType); + end + + cols{c} = col; +end + +% --- Assemble table or return vector --- +if returnVector + T = cols{1}; +else + T = table(cols{:}, 'VariableNames', colNames); +end + +% --- Export to file if requested --- +if ~isempty(savePath) && ~returnVector + writetable(T, savePath); + fprintf('Saved info table to: %s\n', savePath); +end + +end + +% ========================================================================= +% Local functions +% ========================================================================= + +function tf = isScalarCompatible(val) +% Returns true if val can be stored as a single table cell + if isempty(val) + % empty char '' or empty numeric [] -- allow empty char + if ischar(val) && isequal(size(val), [1 0]) + tf = true; % empty char '' + else + tf = false; + end + return; + end + if isstruct(val) || iscell(val) || istable(val) + tf = false; + return; + end + if (isnumeric(val) || islogical(val)) && ~isscalar(val) + tf = false; + return; + end + if ischar(val) && size(val, 1) > 1 + tf = false; % multi-row char array + return; + end + if (iscategorical(val) || isdatetime(val) || isduration(val)) && ~isscalar(val) + tf = false; + return; + end + tf = true; +end + +function cls = classOfValue(val) +% Determine storage class for a scalar-compatible value + if ischar(val) || isstring(val) || iscategorical(val) + cls = 'string'; + elseif islogical(val) + cls = 'logical'; + elseif isdatetime(val) + cls = 'datetime'; + elseif isduration(val) + cls = 'duration'; + elseif isnumeric(val) + cls = 'double'; + else + cls = 'string'; % fallback + end +end + +function col = initMissingColumn(N, colType) +% Create an N-element column filled with the type-appropriate missing value + switch colType + case 'double' + col = nan(N, 1); + case 'string' + col = repmat("", N, 1); + case 'logical' + % Store as double to allow NaN for missing + col = nan(N, 1); + case 'datetime' + col = NaT(N, 1); + case 'duration' + col = duration(nan(N, 1), 0, 0); + otherwise + col = repmat("", N, 1); + end +end + +function out = coerceValue(val, colType) +% Coerce a scalar value into the target column type + switch colType + case 'double' + if isnumeric(val) + out = double(val); + elseif islogical(val) + out = double(val); + else + out = NaN; + end + case 'string' + if ischar(val) || isstring(val) + out = string(strtrim(val)); + elseif iscategorical(val) + out = string(val); + elseif isnumeric(val) + out = string(num2str(val)); + elseif islogical(val) + out = string(num2str(val)); + else + out = string(val); + end + case 'logical' + % Stored as double column to allow NaN + if islogical(val) + out = double(val); + elseif isnumeric(val) + out = double(val); + else + out = NaN; + end + case 'datetime' + if isdatetime(val) + out = val; + else + out = NaT; + end + case 'duration' + if isduration(val) + out = val; + else + out = duration(NaN, 0, 0); + end + otherwise + out = string(val); + end +end diff --git a/+pf2/+data/labelMarkers.m b/+pf2/+data/labelMarkers.m new file mode 100644 index 00000000..2d0d1222 --- /dev/null +++ b/+pf2/+data/labelMarkers.m @@ -0,0 +1,146 @@ +function out = labelMarkers(data, map, opts) +% LABELMARKERS Attach categorical labels to marker codes +% +% Adds (or updates) a categorical column on the marker table that maps each +% marker Code to a human-readable label, so event codes carry meaning that +% rides along with the markers through preprocessing, splicing, and grouping. +% The mapping comes from an explicit code->label list or, when omitted, from +% data.info.eventTypes (e.g. populated from a BIDS events.tsv on import). +% +% Syntax: +% data = pf2.data.labelMarkers(data) % use marker dictionary +% data = pf2.data.labelMarkers(data, map) % explicit map +% markers = pf2.data.labelMarkers(markerTable, map) % operate on a table +% ... = pf2.data.labelMarkers(..., 'Name', Value) +% allData = pf2.data.labelMarkers(allData, ...) % cell array +% +% Inputs: +% data - fNIRS data struct with a .markers table (and optionally +% .info.eventTypes), a marker table directly, or a cell array of +% structs. +% map - (Optional) Code->label mapping as a two-column cell array +% {code1,'Label1'; code2,'Label2'; ...}. Column 1 is numeric codes, +% column 2 is char/string labels. If omitted, the dataset's marker +% dictionary is used (pf2.data.getMarkerDict: markerDict -> +% eventTypes -> COBI MarkerDict), which must yield at least one label. +% +% Name-Value Parameters: +% 'VarName' - Name of the label column to create (default: 'Label'). +% 'Ordinal' - Make the categorical ordinal, in map order (default: false). +% 'Categories' - Explicit category order (cellstr/string). Default: the +% label order in the map (unique, stable). +% +% Outputs: +% out - Same form as the input (struct, table, or cell array) with the +% marker table carrying a categorical label column. Codes with no +% mapping become . +% +% Algorithm: +% 1. Cell array -> apply recursively to each element. +% 2. Resolve the map: an explicit {code,'Label'} cell takes precedence; +% otherwise (struct input) fall back to pf2.data.getMarkerDict and use its +% labelled rows. A table input with no map is an error. +% 3. Normalize the markers, build a per-row string label by matching each +% Code against the map, then convert to a categorical with categories +% from 'Categories' (or, by default, the unique map labels in order) and +% ordinality from 'Ordinal'. Unmatched codes become . +% +% Notes: +% - Re-calling with the same 'VarName' replaces that column (MATLAB table +% assignment semantics); pass a different 'VarName' to keep multiple +% labellings (e.g. 'Condition' and 'Difficulty'). +% - 'Ordinal' lets categories be compared with < / > in map (or 'Categories') +% order; labels outside the category set become . +% - Empty markers are handled gracefully (an empty categorical column). +% +% Example: +% data = pf2.data.labelMarkers(data, {49,'Stroop'; 50,'Control'}); +% summary(data.markers.Label) % counts per condition +% isStroop = data.markers.Label == 'Stroop'; +% +% % Auto-label from BIDS events.tsv mapping captured at import +% data = pf2.import.importSNIRF('sub-01_nirs.snirf'); +% data = pf2.data.labelMarkers(data); % uses data.info.eventTypes +% +% 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 + +% --- Cell array input: apply to each element --- +if iscell(data) + fwd = namedargs2cell(opts); + out = data; + for ci = 1:numel(data) + out{ci} = pf2.data.labelMarkers(data{ci}, map, fwd{:}); + end + return; +end + +varName = char(opts.VarName); +ordinal = opts.Ordinal; +explicitCats = opts.Categories; + +% --- Resolve the marker table and (if struct) the eventTypes fallback --- +isStructInput = isstruct(data) && isfield(data, 'markers'); +if isStructInput + if isempty(map) + % Fall back to the dataset's marker dictionary (markerDict -> + % eventTypes -> COBI MarkerDict, resolved by getMarkerDict). + dict = pf2.data.getMarkerDict(data); + labeled = dict(~ismissing(dict.Label), :); + if isempty(labeled) + error('pf2:labelMarkers:noMap', ... + ['No code->label map supplied and the dataset has no marker ', ... + 'dictionary. Pass a {code,''Label''} mapping or set one via ', ... + 'pf2.data.setMarkerDict.']); + end + map = [num2cell(labeled.Code), cellstr(labeled.Label)]; + end + mt = pf2_base.normalizeMarkers(data.markers); +elseif istable(data) + if isempty(map) + error('pf2:labelMarkers:noMap', ... + 'A {code,''Label''} mapping is required when labeling a table directly.'); + end + mt = pf2_base.normalizeMarkers(data); +else + error('pf2:labelMarkers:badInput', ... + 'First argument must be an fNIRS struct with .markers, a marker table, or a cell array.'); +end + +% --- Build the categorical label column --- +mapCodes = map(:, 1); +if iscell(mapCodes) + mapCodes = cell2mat(mapCodes); +end +mapLabels = string(map(:, 2)); + +codes = mt.Code; +labelStr = strings(height(mt), 1); +labelStr(:) = missing; +[tf, loc] = ismember(codes, mapCodes(:)); +labelStr(tf) = mapLabels(loc(tf)); + +if isempty(explicitCats) + cats = unique(mapLabels, 'stable'); +else + cats = string(explicitCats); +end + +mt.(varName) = categorical(labelStr, cats, 'Ordinal', ordinal); + +% --- Return in the same form as input --- +if isStructInput + data.markers = mt; + out = data; +else + out = mt; +end + +end diff --git a/+pf2/+data/plot.m b/+pf2/+data/plot.m index af470741..01c1a89e 100644 --- a/+pf2/+data/plot.m +++ b/+pf2/+data/plot.m @@ -39,7 +39,7 @@ % See also: pf2.data.plot.oxy, pf2.data.plot.raw, pf2.data.plot.roi if(nargin<1) - error('Must provide an fNIR struct to plot'); + error('pf2:data:plot:noInput', 'Must provide an fNIR struct to plot'); end if(isfield(fNIR,'HbO')&&~isempty(fNIR.HbO)) diff --git a/+pf2/+data/removeMarkers.m b/+pf2/+data/removeMarkers.m new file mode 100644 index 00000000..4caee48f --- /dev/null +++ b/+pf2/+data/removeMarkers.m @@ -0,0 +1,151 @@ +function out = removeMarkers(data, codes, opts) +% 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 +% window, by explicit row indices, or any combination of these. Useful for +% stripping spurious device markers, trimming events outside an analysis +% window, or surgically removing known-bad triggers before epoching. Extra +% (user) columns and the canonical table class are preserved on the survivors. +% +% Reference: +% Internal pf2 implementation. +% +% Syntax: +% data = pf2.data.removeMarkers(data, codes) +% data = pf2.data.removeMarkers(data, codes, 'Time', [t1 t2]) +% data = pf2.data.removeMarkers(data, 'Time', [t1 t2]) +% data = pf2.data.removeMarkers(data, 'Indices', idx) +% markers = pf2.data.removeMarkers(markerTable, ...) +% ... = pf2.data.removeMarkers(..., 'Name', Value) +% +% Inputs: +% data - fNIRS data struct with a .markers table, or a marker table/matrix +% directly. A struct returns a struct (with .markers filtered); a +% table/matrix returns the filtered canonical table. +% codes - (Optional positional) Marker code or vector of codes to remove +% [numeric]. Rows whose Code matches any listed code are dropped. +% +% Name-Value Parameters: +% 'Time' - Time window [t1 t2] in seconds; rows with t1 <= Time <= t2 are +% removed (default: [] = off). +% 'Indices' - Row indices into the (normalized) marker table to remove +% (default: [] = off). Logical or numeric. +% 'Verbose' - Print the number of markers removed (default: true). +% +% Outputs: +% out - Same form as input (struct or table) with the selected marker rows +% removed. A row is removed if it matches ANY of the active selectors +% (codes OR Time window OR Indices). +% +% Algorithm: +% 1. Normalize markers to the canonical table so matrix/table inputs work. +% 2. Build a removal mask: union of code matches, in-window times, and the +% requested row indices. At least one selector must be supplied. +% 3. Keep the complementary rows, preserving order and extra columns. +% +% Example: +% % Remove all device markers with code 0 +% data = pf2.data.removeMarkers(data, 0); +% +% % Remove markers in the first 10 seconds, and any code-99 marker +% data = pf2.data.removeMarkers(data, 99, 'Time', [0 10]); +% +% % Remove specific rows from a marker table directly +% m = pf2.data.removeMarkers(data.markers, 'Indices', [2 5]); +% +% Notes: +% - Selectors combine by UNION (OR): a row is removed if it matches ANY of +% the supplied code / 'Time' / 'Indices' selectors, not all of them. +% - At least one selector is required; calling with none errors +% (pf2:removeMarkers:noSelector). +% - The 'Time' window is inclusive on both ends (t1 <= Time <= t2). +% - 'Indices' refer to rows of the NORMALIZED marker table (row order is +% preserved by normalizeMarkers); out-of-range indices are ignored. +% +% 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 + +% --- Cell array input: apply to each element --- +if iscell(data) + fwd = namedargs2cell(opts); + out = data; + for ci = 1:numel(data) + out{ci} = pf2.data.removeMarkers(data{ci}, codes, fwd{:}); + end + return; +end + +timeWin = opts.Time; +indices = opts.Indices; +verbose = opts.Verbose; + +if isempty(codes) && isempty(timeWin) && isempty(indices) + error('pf2:removeMarkers:noSelector', ... + ['Specify at least one selector: a code (or code vector), ', ... + '''Time'', [t1 t2], or ''Indices'', idx.']); +end + +% --- Resolve the marker table from the input form --- +isStructInput = isstruct(data) && isfield(data, 'markers'); +if isStructInput + mt = pf2_base.normalizeMarkers(data.markers); +elseif istable(data) || isnumeric(data) + mt = pf2_base.normalizeMarkers(data); +else + error('pf2:removeMarkers:badInput', ... + ['First argument must be an fNIRS struct with .markers, a marker ', ... + 'table/matrix, or a cell array.']); +end + +nBefore = height(mt); +removeMask = false(nBefore, 1); + +if nBefore > 0 + % Code selector + if ~isempty(codes) + removeMask = removeMask | ismember(mt.Code, codes(:)); + end + % Time-window selector + if ~isempty(timeWin) + lo = min(timeWin); + hi = max(timeWin); + removeMask = removeMask | (mt.Time >= lo & mt.Time <= hi); + end + % Index selector + if ~isempty(indices) + if islogical(indices) + idxMask = false(nBefore, 1); + n = min(numel(indices), nBefore); + idxMask(1:n) = indices(1:n); + else + idx = indices(indices >= 1 & indices <= nBefore); + idxMask = false(nBefore, 1); + idxMask(round(idx)) = true; + end + removeMask = removeMask | idxMask; + end +end + +mtOut = mt(~removeMask, :); +nRemoved = nBefore - height(mtOut); + +if verbose + fprintf('pf2.data.removeMarkers: removed %d of %d markers.\n', nRemoved, nBefore); +end + +if isStructInput + data.markers = mtOut; + out = data; +else + out = mtOut; +end + +end diff --git a/+pf2/+data/resample.m b/+pf2/+data/resample.m index 9566ec31..dbb348ef 100644 --- a/+pf2/+data/resample.m +++ b/+pf2/+data/resample.m @@ -1,4 +1,4 @@ -function [outFNIR, pFit] = resample(varargin) +function [outFNIR, pFit] = resample(fNIR, segmentLength, blLength, opts) % RESAMPLE Downsample and time-average fNIRS data % % Resamples fNIRS data to a lower sampling rate by averaging samples within @@ -60,38 +60,27 @@ % % See also: pf2.data.split, pf2.data.setT0, pf2.data.concatenate -p=inputParser; - -validfNIRInput = @(x) (isnumeric(x)&&length(x)>1) || (isstruct(x) && (isfield(x,'raw')||isfield(x,'time')||isfield(x,'info'))); -validScalarPosNum = @(x) isnumeric(x) && isscalar(x) && (x >= 0); -validTimeOutMode = @(x) ischar(x)&&(ismember(x,{'mid','start','end'})); -validTimepoints = @(x) isnumeric(x) && isvector(x) && issorted(x); - - -addRequired(p,'fNIR',validfNIRInput); -addOptional(p,'segmentLength',1,validScalarPosNum); -addOptional(p,'blLength',[],validScalarPosNum); -addOptional(p,'blfNIR',[],validfNIRInput); -addParameter(p,'centerOnT0',false,@islogical); -addParameter(p,'timeOutMode','start',validTimeOutMode); -addParameter(p,'nanRejectionLevel',0.7,validScalarPosNum); -addParameter(p,'averageAux',false,@islogical); -addParameter(p,'flattenAux',false,@islogical); -addParameter(p,'trimAux',false,@islogical); -addParameter(p,'polyDegree',1,validScalarPosNum); -addParameter(p,'centerOnTime',NaN,@isnumeric); -addParameter(p,'specifiedTimepoints',[],validTimepoints); - -parse(p,varargin{:}); - -fNIR=p.Results.fNIR; -segLength=p.Results.segmentLength; % How long is each segment, ie: 1 sample -blLength=p.Results.blLength; % how long is the baseline -blfNIR=p.Results.blfNIR; % a baseline fNIR struct -%getPolyAvg=p.Results.getPolyAvg; -centerOnT0=p.Results.centerOnT0; % should the resample include t=0 as the start point -centerOnTime=p.Results.centerOnTime; -specifiedTimepoints = p.Results.specifiedTimepoints; +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 + +segLength=segmentLength; % How long is each segment, ie: 1 sample +blfNIR=opts.blfNIR; % Separate baseline source struct/array (mutated as a local below) +centerOnT0=opts.centerOnT0; % should the resample include t=0 as the start point +centerOnTime=opts.centerOnTime; +specifiedTimepoints = opts.specifiedTimepoints; if(centerOnT0) centerOnTime=0; @@ -100,12 +89,12 @@ centerOnTime=nan; end -timeOutMode=p.Results.timeOutMode; % should time include or center on a point (ex: t=1 := t[0.051...1.4999] -nanRejectionLevel=p.Results.nanRejectionLevel; % number of NaNs in segment to entirely reject it -averageAux=p.Results.averageAux; % Also average/ resample the Aux channels -flattenAux=p.Results.flattenAux; % Unroll nested Aux data into tables within the Aux struct, also map times to non-time columns -trimAux=p.Results.trimAux; % Clear all Aux samples before and after fNIRS time series -polyDegree=p.Results.polyDegree; % degree for polyfit +timeOutMode=opts.timeOutMode; % should time include or center on a point (ex: t=1 := t[0.051...1.4999] +nanRejectionLevel=opts.nanRejectionLevel; % number of NaNs in segment to entirely reject it +averageAux=opts.averageAux; % Also average/ resample the Aux channels +flattenAux=opts.flattenAux; % Unroll nested Aux data into tables within the Aux struct, also map times to non-time columns +trimAux=opts.trimAux; % Clear all Aux samples before and after fNIRS time series +polyDegree=opts.polyDegree; % degree for polyfit if(~isstruct(fNIR)) @@ -132,9 +121,6 @@ fNIR.time=round(fNIR.time,5); -%minfTime=min(fNIR.time); -%maxfTime=max(fNIR.time); - fTime=fNIR.time; if(~isstruct(blfNIR)&&~isempty(blLength)&&blLength>0) @@ -165,7 +151,7 @@ if(~isfield(fNIR,'HbR')&&isfield(fNIR,'raw')) % out of principle we don't resample the raw data - error('Raw data averaging not supported'); + error('pf2:resample:rawNotSupported', 'Raw data averaging not supported'); elseif(~isfield(fNIR,'HbR')&&~isfield(fNIR,'raw')) warning('No fNIRS data'); outFNIR=fNIR; @@ -175,11 +161,6 @@ numCh=size(fNIR.HbR,2); end -%if(isfield(fNIR,'raw')&&isempty(fNIR.raw)) - %fNIR.raw=nan(size(fNIR.HbR)); - %prevent resampling of raw -%end - if(isnan(centerOnTime)) % foces time blocks to start from t=0 or if undefined, just start from where they started from centerOnTime=min(fTime); end @@ -192,12 +173,9 @@ [fTimeInd, timeSeries] = getTimeIdx(fNIR.time, segLength, centerOnTime); end -%minSegTime=timeSeries(1); -%maxSegTime=timeSeries(end); - numSegs=length(timeSeries); -if(pf2_base.isnestedfield(fNIR,'ROI.HbR')&&~isempty(fNIR.ROI.HbR)) +if isfield(fNIR,'ROI') && isstruct(fNIR.ROI) && isfield(fNIR.ROI,'HbR') && ~isempty(fNIR.ROI.HbR) calcROI=true; numROI=size(fNIR.ROI.HbR,2); else @@ -206,20 +184,6 @@ end -if(getPolyAvg) - phbr=nan([numSegs,numCh,polyDegree+1]); - phbo=nan([numSegs,numCh,polyDegree+1]); - poxy=nan([numSegs,numCh,polyDegree+1]); - ptotal=nan([numSegs,numCh,polyDegree+1]); - pcbsi=nan([numSegs,numCh,polyDegree+1]); - - phbrfit=nan(numSegs,numCh,3); - phbofit=nan(numSegs,numCh,3); - poxyfit=nan(numSegs,numCh,3); - ptotalfit=nan(numSegs,numCh,3); - pcbsifit=nan(numSegs,numCh,3); -end - if(isfield(fNIR,'Aux')) if(~averageAux) outFNIR.Aux=fNIR.Aux; @@ -232,6 +196,11 @@ end +validCh=1:numCh; +if(calcROI) + validCh_roi=1:numROI; +end + if(blLength>0) % if baseline is present bioMlist={'HbO','HbR','HbDiff','HbTotal','CBSI'}; @@ -244,12 +213,13 @@ fB=blfNIR.(curB); - blNanCheck=sum(isnan(fB),1)/length(fB)1) - warning('Baseline Period in %i channels was invalid',blRejectedCount); + warning('pf2:resample:invalidBaseline', ... + 'Baseline Period in %i channels was invalid', blRejectedCount); end validCh=find(blNanCheck==1); @@ -261,27 +231,32 @@ blfNIR.(curB)=nan; end - if(calcROI) + % Per-iteration ROI guard. Using a loop-wide calcROI mutation here + % latched false on the first missing ROI biomarker and silently + % skipped ROI baseline subtraction for every subsequent biomarker + % in the second loop. Scope the "skip" to this iteration only. + roiOK = calcROI; + if(roiOK) - if(pf2_base.isnestedfield(blfNIR,strcat('ROI.',+curB))) + if isfield(blfNIR,'ROI') && isstruct(blfNIR.ROI) && isfield(blfNIR.ROI, curB) fB=blfNIR.ROI.(curB); else warning('ROI mismatch: ROI is not defined in baseline file'); - calcROI=false; - continue; + roiOK=false; end - if(size(fB,2)~=numROI) + if(roiOK && size(fB,2)~=numROI) warning('ROI mismatch: ROI as defined in baseline not present in main fNIRS segment, calculations not performed'); - calcROI=false; - continue; + roiOK=false; end + end + if(roiOK) - blNanCheck_roi=sum(isnan(fB),1)/length(fB)1) - warning('ROI Baseline Period in %i channels was invalid',blRejectedCount_roi); + warning('pf2:resample:invalidBaseline', 'ROI Baseline Period in %i channels was invalid',blRejectedCount_roi); end validCh_roi=find(blNanCheck_roi==1); @@ -325,23 +300,10 @@ times_end=timeSeries+segLength-1e-10; end -%minSegTime=times_start(1); -%maxSegTime=times_start(end); - -%calculate index for each sample -%fTimeInd=floor((fTime-minfTime-rem(fTime-minfTime,segLength))/segLength)+1; - - if(calcROI) - %fTimeInd_numROI=repmat(fTimeInd,[numROI,1]); - %fTimeInd_numROI=fTimeInd_numROI+numSegs*repelem([0:numROI-1]',nTime,1); - outFNIR.ROI.info=fNIR.ROI.info; end - -ptime=zeros(numSegs,1); %polynomial time - bioMlist={'raw','HbO','HbR','HbDiff','HbTotal','CBSI'}; for b = 1:length(bioMlist) @@ -391,8 +353,6 @@ pFit.(curB)=pFit.(curB)-repmat(blfNIR.(curB),[numSegs,1]); elseif(~isempty(blLength)&&isnan(blLength)&&~isRaw) pFit.(curB)=nan([numSegs,numCh]); - else - %pFit.(curB)=pFit.(curB); end end @@ -401,7 +361,15 @@ fB_resample=resample_internal(fB,fTimeInd,numROI,numSegs,nanRejectionLevel); - if(~isempty(blLength)&&blLength>0) + % Only subtract a ROI baseline mean for this biomarker if the + % first loop successfully reduced blfNIR.ROI.(curB) to a 1xnROI + % row. When it didn't (missing field, size mismatch, etc.) the + % field is either absent or still holds the raw [T_bl x nROI] + % split slice — subtracting that would produce wrong-sized output. + hasROIBaseline = isfield(blfNIR,'ROI') && isstruct(blfNIR.ROI) && ... + isfield(blfNIR.ROI, curB) && size(blfNIR.ROI.(curB), 1) == 1; + + if(~isempty(blLength)&&blLength>0&&hasROIBaseline) outFNIR.ROI.(curB)=fB_resample-repmat(blfNIR.ROI.(curB),[numSegs,1]); elseif(~isempty(blLength)&&isnan(blLength)) outFNIR.ROI.(curB)=nan([numSegs,numROI]); @@ -427,12 +395,10 @@ end end - if(~isempty(blLength)&&blLength>0) + if(~isempty(blLength)&&blLength>0&&hasROIBaseline) pFit.ROI.(curB)=pFit.ROI.(curB)-repmat(blfNIR.ROI.(curB),[numSegs,1]); elseif(~isempty(blLength)&&isnan(blLength)) pFit.ROI.(curB)=nan([numSegs,numROI]); - else - %pFit.ROI.(curB)=pFit.ROI.(curB); end end end @@ -478,7 +444,7 @@ if(istable(curVar)) curTableVarNames=curVar.Properties.VariableNames; - cur_time_ind=find(~isempty(intersect(validTimeFields,curTableVarNames))); + cur_time_ind=find(ismember(validTimeFields,curTableVarNames),1); t2trim=curVar{:,cur_time_ind}; minftime=times_start(1); maxftime=times_end(end); @@ -488,7 +454,7 @@ end else - error('Trimming requires Aux series to be flattened first!'); + error('pf2:resample:trimRequiresFlatten', 'Trimming requires Aux series to be flattened first!'); end end @@ -538,20 +504,14 @@ outFNIR.fs=1/segLength; %new "effective sampling frequency if(getPolyAvg) % returns a time X channel X coefficient array - pFit=outFNIR; - pFit.HbR_poly=phbr; - pFit.HbO_poly=phbo; - pFit.HbDiff_poly=poxy; - pFit.HbTotal_poly=ptotal; - pFit.CBSI_poly=pcbsi; - pFit.time=ptime; - %pFit.time(end+1)=outFNIR.segmentTimes(end,3); - - pFit.HbR=phbrfit; - pFit.HbO=phbofit; - pFit.HbDiff=poxyfit; - pFit.HbTotal=ptotalfit; - pFit.CBSI=pcbsifit; + % Merge metadata from outFNIR into pFit (preserving polynomial data from loop) + outFields = fieldnames(outFNIR); + for fi = 1:length(outFields) + fn = outFields{fi}; + if ~isfield(pFit, fn) + pFit.(fn) = outFNIR.(fn); + end + end else pFit=[]; end @@ -592,7 +552,7 @@ validTimeFields={'time','t','Time'}; - cur_time_ind=find(~isempty(intersect(validTimeFields,auxFields))); + cur_time_ind=find(ismember(validTimeFields,auxFields),1); if(isempty(cur_time_ind)) local_time=[]; @@ -603,19 +563,25 @@ outAuxStruct.(validTimeFields{cur_time_ind})=localTime_resample; 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 timeSeries for f=1:length(auxFields) - + curFieldName=auxFields{f}; curField=aux_in.(curFieldName); + % Skip metadata fields (used for labeling, not time-series data) + if ismember(curFieldName, {'varNames', 'unit'}) || endsWith(curFieldName, '_unit') + continue; + end + if(isempty(curField)) - fprintf('Unable to average signal .Aux.%s, no data present\n',curFieldName); + warning('pf2:resample:emptyAux', ... + 'Unable to average signal .Aux.%s, no data present', curFieldName); auxFieldIsEmpty(f)=true; outAuxStruct.(curFieldName)=curField; continue; @@ -665,15 +631,14 @@ warning('Non-explicit match for Aux resampling, please use Aux.time variable or ''time'' table column '); else % maybe if the first column is constantly incrementing we use that? - possibleTimeField=all(diff(curField(:,1)>0)); + possibleTimeField=all(diff(curField(:,1))>0); if(possibleTimeField) t_aux=curField(:,1); auxFieldHasTime(f)=true; warning('Non-explicit match for Aux resampling, please use Aux.time variable or ''time'' table column '); else - %fprintf('Unable to resample this field!'); outAuxStruct.(curFieldName)=['Unable to resample this field!']; - + continue; end end @@ -681,14 +646,13 @@ nAuxChan=size(curField,2); - %create t_ind if missing - if(isempty(t_ind)) % if not using fNIR time, we have to figure out where time is logically - %calculate index for each sample - - [t_ind,t_aux_resample]=getTimeIdx(t_aux,segLength,centerOnTime); + %create t_ind if missing, always compute t_aux_resample for flattening + [t_ind_computed, t_aux_resample] = getTimeIdx(t_aux, segLength, centerOnTime); + if(isempty(t_ind)) + t_ind = t_ind_computed; end - - + + numSegs_aux=max(t_ind); auxDat=curField(:); @@ -706,19 +670,39 @@ end if(flattenAux) - newVarNames={}; - for nV=1:(nAuxChan-auxFieldHasTime(f)) - newVarNames{nV}=sprintf('val%i',nV); + nDataCols=nAuxChan-auxFieldHasTime(f); + % Use varNames from parent struct if available + if isfield(aux_in,'varNames') && iscell(aux_in.varNames) && length(aux_in.varNames)==nDataCols + newVarNames=aux_in.varNames(1:nDataCols); + else + newVarNames={}; + for nV=1:nDataCols + newVarNames{nV}=sprintf('val%i',nV); + end end + % Guard: ensure name count matches column count + nResampleCols=size(auxDat_resample,2); if(auxFieldHasTime(f)) + expectedCols=1+numel(newVarNames); + if nResampleCols~=expectedCols + newVarNames={}; + for nV=1:nResampleCols-1 + newVarNames{nV}=sprintf('val%i',nV); + end + end newVarNames=['time',newVarNames(:)]; auxDat_rsTable=array2table(auxDat_resample,'VariableNames',newVarNames); outAuxStruct.(curFieldName)=auxDat_rsTable; else - outAuxStruct.(curFieldName)=table(t_aux_resample,'VariableNames',{'time'}); - auxDat_rsTable=array2table(auxDat_resample,'VariableNames',newVarNames); - outAuxStruct.(curFieldName)=[outAuxStruct.(curFieldName),auxDat_rsTable]; + if numel(newVarNames)~=nResampleCols + newVarNames={}; + for nV=1:nResampleCols + newVarNames{nV}=sprintf('val%i',nV); + end + end + outAuxStruct.(curFieldName)=array2table([t_aux_resample, auxDat_resample], ... + 'VariableNames', [{'time'}, newVarNames(:)']); end else outAuxStruct.(curFieldName)=auxDat_resample; @@ -757,33 +741,34 @@ else % maybe if the first column is constantly incrementing we use that? - possibleTimeField=isnumeric(curField(:,1))&&all(diff(curField(:,1)>0)); + possibleTimeField=isnumeric(curField(:,1))&&all(diff(curField(:,1))>0); if(possibleTimeField&&~alreadyFlattened) t_aux=curField(:,1); auxFieldHasTime(f)=true; warning('Non-explicit match for Aux resampling, please use Aux.time variable or ''time'' table column '); else - %fprintf('Unable to resample this field!'); if(~alreadyFlattened) outAuxStruct.(curFieldName)=['Unable to resample this field!']; else outAuxStruct.(curFieldName)=curField; end - + continue; end end - + %create t_ind if missing, always compute t_aux_resample for flattening + [t_ind_computed, t_aux_resample] = getTimeIdx(t_aux, segLength, centerOnTime); + if(isempty(t_ind)) + t_ind = t_ind_computed; + end - %create t_ind if missing - if(isempty(t_ind)) % if not using fNIR time, we have to figure out where time is logically - %calculate index for each sample - - [t_ind,t_aux_resample]=getTimeIdx(t_aux,segLength,centerOnTime); - outAuxStruct.(curFieldName)=table(t_aux_resample,'VariableNames',curTimeNames); - elseif(flattenAux) - outAuxStruct.(curFieldName)=table(t_aux_resample,'VariableNames',curTimeNames); + if(auxFieldHasTime(f)||flattenAux) + if(exist('curTimeNames','var')&&~isempty(curTimeNames)) + outAuxStruct.(curFieldName)=table(t_aux_resample,'VariableNames',curTimeNames); + else + outAuxStruct.(curFieldName)=table(t_aux_resample,'VariableNames',{'time'}); + end else outAuxStruct.(curFieldName)=table(); end @@ -842,12 +827,12 @@ auxDat_resample=resample_internal(auxDat,t_ind,nAuxChan_col,numSegs_aux,nanRejectionLevel); if(flattenAux) - numCols=length(numericIdx); + numCols=sum(numericIdx); if(numCols>1) - newVarNames=cell(size(numericIdx)); - - for nName=1:length(newVarNames) + newVarNames=cell(1,numCols); + + for nName=1:numCols newVarNames{nName}=sprintf('%s_%i',curVarName,nName); end else @@ -927,7 +912,7 @@ validTimeFields={'time','t','Time'}; - cur_time_ind=find(~isempty(intersect(validTimeFields,auxFields))); + cur_time_ind=find(ismember(validTimeFields,auxFields),1); if(isempty(cur_time_ind)) local_time=[]; @@ -937,9 +922,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 timeSeries @@ -985,13 +970,12 @@ warning('Non-explicit match for Aux resampling, please use Aux.time variable or ''time'' table column '); else % maybe if the first column is constantly incrementing we use that? - possibleTimeField=all(diff(curField(:,1)>0)); + possibleTimeField=all(diff(curField(:,1))>0); if(possibleTimeField) t_aux=curField(:,1); auxFieldHasTime(f)=true; warning('Non-explicit match for Aux resampling, please use Aux.time variable or ''time'' table column '); else - %fprintf('Unable to resample this field!'); outAuxStruct.(curFieldName)=['Unable to align this field!']; continue; @@ -1047,13 +1031,12 @@ else % maybe if the first column is constantly incrementing we use that? - possibleTimeField=isnumeric(curField(:,1))&&all(diff(curField(:,1)>0)); + possibleTimeField=isnumeric(curField(:,1))&&all(diff(curField(:,1))>0); if(possibleTimeField&&~alreadyFlattened) t_aux=curField(:,1); auxFieldHasTime(f)=true; warning('Non-explicit match for Aux time variable alignment, please use Aux.time variable or ''time'' table column '); else - %fprintf('Unable to resample this field!'); if(~alreadyFlattened) outAuxStruct.(curFieldName)=['Unable to resample this field!']; else @@ -1102,13 +1085,13 @@ auxDat_resample=rsArr; - - numCols=length(numericIdx); + + numCols=sum(numericIdx); if(numCols>1) - newVarNames=cell(size(numericIdx)); - - for nName=1:length(newVarNames) + newVarNames=cell(1,numCols); + + for nName=1:numCols newVarNames{nName}=sprintf('%s_%i',curVarName,nName); end else @@ -1154,33 +1137,27 @@ % samples around that time were collected if(any(diff(times_in)<0)) - error('Time series should be increasing in order to resample!'); + error('pf2:resample:timeNotIncreasing', 'Time series should be increasing in order to resample!'); end t1=times_in(1); - %te=times_in(end); times_in=times_in(:); - %tRange_in=te-t1; - if(nargin<3) % just use min time centerTime=t1; end - - minSegTime=centerTime+floor((t1-centerTime)/segLength)*segLength; - % minimum SegTime is the first value that meets the crieria - % t0_rs = centerTime+segLength*N (where N is some number of samples - % and t0_sample > t0_rs but t0_sample < t0_rs +segLength - - %maxSegTime=centerTime+floor((te-centerTime)/segLength)*segLength; + % minimum SegTime is the first value that meets the criteria: + % t0_rs = centerTime+segLength*N (where N is some integer) + % and t0_sample >= t0_rs but t0_sample < t0_rs + segLength + % 1e-10 epsilon prevents floating-point bin-boundary misassignment + % where floor returns n-1 instead of n for values at exact boundaries fTimeInd=[floor((times_in-minSegTime)/segLength+1+1e-10)]; - % 1e-10 helps fix conditions where floor returns n-1 instead of n % fTimeInd(end) is the highest value here, faster than max() maxSegTime=minSegTime+(fTimeInd(end)-1)*segLength; @@ -1207,27 +1184,29 @@ nTime=length(fTimeInd); fTimeInd_numCh=repmat(fTimeInd,[numCh,1]); - fTimeInd_numCh=fTimeInd_numCh+numSegs*repelem([0:numCh-1]',nTime,1); + fTimeInd_numCh=fTimeInd_numCh+numSegs*repelem((0:numCh-1)',nTime,1); + + flat = rsData_in(:); + nanMask = isnan(flat); + sz = [numCh * numSegs, 1]; try - fB_isNA=accumarray(fTimeInd_numCh,isnan(rsData_in(:))); + fB_isNA=accumarray(fTimeInd_numCh, double(nanMask), sz); catch rsData=[]; return; end - fB_count=accumarray(fTimeInd_numCh,ones(size(fTimeInd_numCh))); - - % Check edge case where last sample does not include last index, pad - % with 0s - diffCheck=(numCh*numSegs)-length(fB_isNA); - if(diffCheck>0) - fB_isNA=[fB_isNA;zeros([diffCheck,1])]; - fB_count=[fB_count;zeros([diffCheck,1])]; - end + fB_count=accumarray(fTimeInd_numCh, ones(size(fTimeInd_numCh)), sz); - fB_nanCheck= reshape(fB_isNA./fB_count,[numCh,numSegs])<=nanRejectionLevel; + fB_nanCheck= reshape(fB_isNA./fB_count,[numSegs,numCh])<=nanRejectionLevel; - rsData=reshape(accumarray(fTimeInd_numCh,rsData_in(:),[numCh*numSegs',1],@(x)nanmean(x)),[numSegs,numCh]); + % Vectorized nanmean: sum non-NaN values, divide by valid count. + % Avoids 1-per-bin function calls to nanmean/mean/parseFlag. + flat(nanMask) = 0; + fB_sum = accumarray(fTimeInd_numCh, flat, sz); + fB_validCount = fB_count - fB_isNA; + fB_validCount(fB_validCount == 0) = NaN; + rsData = reshape(fB_sum ./ fB_validCount, [numSegs, numCh]); rsData(~fB_nanCheck)=NaN; @@ -1250,7 +1229,7 @@ end if(size(y,1)~=size(x,1)) - error('Size of x and y matricies must be the same'); + error('pf2:resample:xySizeMismatch', 'Size of x and y matricies must be the same'); end m = size(x,2); % number of polynomials to fit c = zeros(n+1,m); diff --git a/+pf2/+data/setMarkerDict.m b/+pf2/+data/setMarkerDict.m new file mode 100644 index 00000000..e097e898 --- /dev/null +++ b/+pf2/+data/setMarkerDict.m @@ -0,0 +1,84 @@ +function data = setMarkerDict(data, dict, opts) +% SETMARKERDICT Set or merge the dataset's marker dictionary +% +% Stores a canonical code->label dictionary at data.info.markerDict so that +% labelMarkers, defineBlocks, and plotting can give marker codes meaning. +% By default the supplied entries are merged into any existing dictionary +% (new entries win on Code conflicts); pass 'Merge', false to replace it. +% +% Syntax: +% data = pf2.data.setMarkerDict(data, dict) +% data = pf2.data.setMarkerDict(data, {code,'Label'; ...}) +% data = pf2.data.setMarkerDict(data, dict, 'Merge', false) +% allData = pf2.data.setMarkerDict(allData, dict) % cell array +% +% Inputs: +% data - fNIRS data struct or a cell array of structs. +% dict - Dictionary as a table, {code,'Label'} cell array, or +% containers.Map (normalized via pf2_base.normalizeMarkerDict). +% +% Name-Value Parameters: +% 'Merge' - Merge with the existing dictionary (default: true). When false, +% the supplied dictionary replaces any existing one. +% +% Outputs: +% data - Input with data.info.markerDict set to the canonical dictionary. +% +% Algorithm: +% 1. Cell array -> apply recursively to each element. +% 2. Normalize the supplied dictionary via pf2_base.normalizeMarkerDict. +% 3. Ensure data.info exists, then either merge with the existing dictionary +% (Merge=true, the default) or replace it (Merge=false). On a merge, the +% call is mergeMarkerDict(newDict, existing) so the SUPPLIED entries win +% on Code conflicts while previously-set codes are retained. +% +% Notes: +% - Merge direction is "new wins": re-setting a code updates its label and +% leaves other codes untouched. Use 'Merge', false to discard the old +% dictionary entirely. +% - The dictionary is dataset-level metadata (info.markerDict); it does not +% alter the markers table. Use labelMarkers to stamp labels onto markers. +% +% Example: +% data = pf2.data.setMarkerDict(data, {49,'Stroop'; 50,'Control'}); +% data = pf2.data.setMarkerDict(data, extra, 'Merge', true); +% +% See also: pf2.data.getMarkerDict, pf2.data.labelMarkers, +% pf2_base.normalizeMarkerDict + +arguments + data + dict + opts.Merge (1,1) logical = true +end + +% Cell array: apply to each element +if iscell(data) + fwd = namedargs2cell(opts); + for ci = 1:numel(data) + data{ci} = pf2.data.setMarkerDict(data{ci}, dict, fwd{:}); + end + return; +end + +doMerge = opts.Merge; + +if ~isstruct(data) + error('pf2:setMarkerDict:badInput', ... + 'First argument must be an fNIRS struct or a cell array of structs.'); +end + +newDict = pf2_base.normalizeMarkerDict(dict); + +if ~isfield(data, 'info') || ~isstruct(data.info) + data.info = struct(); +end + +if doMerge && isfield(data.info, 'markerDict') && ~isempty(data.info.markerDict) + % New entries win on Code conflicts + data.info.markerDict = pf2_base.mergeMarkerDict(newDict, data.info.markerDict); +else + data.info.markerDict = newDict; +end + +end diff --git a/+pf2/+data/setT0.m b/+pf2/+data/setT0.m index 0a576ce0..de9ceed1 100644 --- a/+pf2/+data/setT0.m +++ b/+pf2/+data/setT0.m @@ -42,6 +42,11 @@ % % See also: pf2.data.split, pf2.data.getMarkers, pf2.data.resample +arguments + fnirStruct (1,1) struct + t0time {mustBeA(t0time, ["numeric", "duration", "datetime"])} +end + outFNIR=fnirStruct; @@ -67,10 +72,10 @@ tDiff=seconds(t0time-fnirStruct.t0); else - error('All datetimes must be the same size as times'); + error('pf2:setT0:datetimeSizeMismatch', 'All datetimes must be the same size as times'); end else - error('t0 cannot be set as a datetime if fnirs struct does not have datetime measures'); + error('pf2:setT0:noDatetime', 't0 cannot be set as a datetime if fnirs struct does not have datetime measures'); end else tDiff=t0time; @@ -94,13 +99,24 @@ if(isfield(outFNIR,'markers')) - if(isfield(outFNIR.markers,'data')) + if(istable(outFNIR.markers)) + if(~isempty(outFNIR.markers)) + outFNIR.markers.Time = outFNIR.markers.Time - tDiff; + end + elseif(isfield(outFNIR.markers,'data')) outFNIR.markers.data(:,1)= outFNIR.markers.data(:,1)-tDiff; elseif(~isempty(outFNIR.markers)) outFNIR.markers(:,1)= outFNIR.markers(:,1)-tDiff; end end +if isfield(outFNIR, 'blocks') && ~isempty(outFNIR.blocks) + for bk = 1:length(outFNIR.blocks) + outFNIR.blocks(bk).startTime = outFNIR.blocks(bk).startTime - tDiff; + outFNIR.blocks(bk).endTime = outFNIR.blocks(bk).endTime - tDiff; + end +end + if(isfield(outFNIR,'raw')) %outFNIR.raw(:,1)= outFNIR.raw(:,1)-t0time; end @@ -119,6 +135,14 @@ % Only modify numeric arrays with at least 2D where column 1 might be time if isnumeric(curField) && ismatrix(curField) && size(curField,1) > 1 && size(curField,2) >= 1 outFNIR.Aux.(curFieldName)(:,1) = curField(:,1) - tDiff; + elseif istable(curField) + % Handle flattened Aux tables — shift the time column + tblVars = curField.Properties.VariableNames; + timeCol = intersect(timeFieldNames, tblVars); + if ~isempty(timeCol) && isnumeric(curField.(timeCol{1})) + outFNIR.Aux.(curFieldName).(timeCol{1}) = ... + curField.(timeCol{1}) - tDiff; + end end end end diff --git a/+pf2/+data/slidingWindows.m b/+pf2/+data/slidingWindows.m new file mode 100644 index 00000000..e237a7d9 --- /dev/null +++ b/+pf2/+data/slidingWindows.m @@ -0,0 +1,221 @@ +function blocks = slidingWindows(data, opts) +% SLIDINGWINDOWS Define fixed-length sliding-window blocks over a recording +% +% Tiles a continuous recording with fixed-length time windows at a regular +% step, producing a block definition array in the same format as +% pf2.data.defineBlocks. Unlike defineBlocks (which is event-locked to +% markers), this covers the whole recording on a regular grid - the layer +% needed for dynamic functional connectivity, resting-state analysis, +% windowed quality control, and fixed-length model input. The returned +% blocks feed straight into pf2.data.extractBlocks. +% +% Reference: +% Leonardi, N., & Van De Ville, D. (2015). On spurious and real fluctuations +% of dynamic functional connectivity during rest. NeuroImage, 104, 430-436. +% DOI: 10.1016/j.neuroimage.2014.09.007 +% +% Syntax: +% blocks = pf2.data.slidingWindows(data, 'Length', 10) +% blocks = pf2.data.slidingWindows(data, 'Length', 10, 'Step', 5) +% blocks = pf2.data.slidingWindows(data, 'Length', 10, 'Overlap', 0.5) +% blocks = pf2.data.slidingWindows(data, 'Length', 10, 'Start', 30, 'End', 300) +% data = pf2.data.slidingWindows(data, 'Length', 10, 'Embed', true) +% allData = pf2.data.slidingWindows(allData, 'Length', 10, 'Embed', true) +% +% Inputs: +% data - fNIRS data struct with a .time field, or a cell array of +% such structs (each tiled independently; requires 'Embed'). +% +% Name-Value Parameters: +% 'Length' - Window length in seconds (required, > 0). +% 'Step' - Step between consecutive window starts in seconds +% (default: Length, i.e. contiguous non-overlapping windows). +% Mutually exclusive with 'Overlap'. +% 'Overlap' - Fractional overlap in [0, 1); sets Step = Length*(1-Overlap) +% (default: []). Mutually exclusive with 'Step'. +% 'Start' - First window start time in seconds (default: min(data.time)). +% 'End' - Last allowed window end time in seconds (default: max(data.time)). +% 'Partial' - Keep a trailing window shorter than Length when the grid +% does not divide evenly (default: false). +% 'Condition' - Label stored in each window's info.(ConditionField) +% (default: '', no label). +% 'ConditionField' - Field name for the Condition label (default: 'Condition'). +% 'Embed' - Store the blocks on data.blocks and return the data struct +% instead of the blocks array (default: true). Set false to +% return just the blocks struct array. +% +% Outputs: +% blocks - Struct array [1 x N] (or the data struct if Embed=true) with the +% same fields as pf2.data.defineBlocks: +% .startTime - Window start in seconds (absolute) +% .endTime - Window end in seconds +% .duration - endTime - startTime +% .markerCode - NaN (not marker-driven) +% .markerIndex - NaN +% .amplitude - 1 +% .info - Struct with: +% .BlockNumber - Sequential 1, 2, 3... +% .WindowNumber - Same as BlockNumber +% .WindowOnset - startTime (seconds) +% .(ConditionField) - label if 'Condition' given +% +% Algorithm: +% 1. Resolve the time span [Start, End] and the step from Step/Overlap +% 2. Generate window starts Start, Start+Step, ... until a window would +% exceed End (or, with Partial, until the start passes End) +% 3. Build a defineBlocks-compatible struct array, one entry per window +% 4. Optionally embed the blocks on the data struct +% +% Example: +% % 10 s windows, 50% overlap, for dynamic connectivity +% data = pf2.import.sampleData(); +% proc = processFNIRS2(data); +% blocks = pf2.data.slidingWindows(proc, 'Length', 10, 'Overlap', 0.5, 'Embed', false); +% windows = pf2.data.extractBlocks(proc, blocks, 'PreTime', 0, 'PostTime', 0); +% +% % Contiguous 30 s windows embedded on the struct +% proc = pf2.data.slidingWindows(proc, 'Length', 30); % Embed defaults true +% windows = pf2.data.extractBlocks(proc, 'PreTime', 0, 'PostTime', 0); +% +% Notes: +% - Set 'PreTime', 0 and 'PostTime', 0 in extractBlocks so each segment is +% exactly the window (extractBlocks otherwise pads by 120 s by default). +% - For dynamic functional connectivity, the window must be long enough to +% resolve the lowest frequency of interest: 'Length' >= 1/f_low. fNIRS +% hemodynamic/neural fluctuations sit near 0.01-0.04 Hz, implying windows +% of ~30-100 s; a 10 s window mostly captures Mayer-wave/systemic activity, +% not neural co-fluctuation (Leonardi & Van De Ville, 2015, NeuroImage). +% - Overlapping windows are not statistically independent; high overlap +% inflates the effective sample size for downstream tests. +% - Windowed estimates assume approximate stationarity within each window +% and grow noisier as windows shrink (a bias-variance trade-off). +% - For event-related/task designs, use pf2.data.defineBlocks (marker-locked) +% instead; sliding windows are for resting-state/continuous recordings. +% +% 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 + +% --- Cell array input: apply to each element (requires Embed) --- +if iscell(data) + fwd = namedargs2cell(opts); + blocks = data; + for ci = 1:numel(data) + blocks{ci} = pf2.data.slidingWindows(data{ci}, fwd{:}); + end + return; +end + +winLen = opts.Length; +if isempty(winLen) + error('pf2:slidingWindows:noLength', ... + '''Length'' (window length in seconds) is required.'); +end + +% Validate data +if ~isstruct(data) || ~isfield(data, 'time') || isempty(data.time) + error('pf2:slidingWindows:noTime', ... + 'First argument must be an fNIRS struct with a non-empty .time field.'); +end + +% Resolve step from Step / Overlap (mutually exclusive) +if ~isempty(opts.Step) && ~isempty(opts.Overlap) + error('pf2:slidingWindows:stepAndOverlap', ... + 'Specify only one of ''Step'' or ''Overlap''.'); +end +if ~isempty(opts.Overlap) + step = winLen * (1 - opts.Overlap); +elseif ~isempty(opts.Step) + step = opts.Step; +else + step = winLen; % contiguous, non-overlapping +end + +% Resolve time span +tMin = min(data.time); +tMax = max(data.time); +if isempty(opts.Start); winStart0 = tMin; else; winStart0 = opts.Start; end +if isempty(opts.End); spanEnd = tMax; else; spanEnd = opts.End; end + +partial = opts.Partial; +condLabel = char(opts.Condition); +condField = char(opts.ConditionField); + +if spanEnd <= winStart0 + error('pf2:slidingWindows:emptySpan', ... + 'Resolved End (%.2f s) must be greater than Start (%.2f s).', spanEnd, winStart0); +end + +% --- Generate window start times --- +% Small tolerance so floating-point start grids reach the final full window. +tol = step * 1e-9; +if partial + starts = winStart0 : step : (spanEnd - tol); +else + starts = winStart0 : step : (spanEnd - winLen + tol); +end + +% Build empty-typed struct array template (matches defineBlocks fields) +emptyBlocks = struct('startTime', {}, 'endTime', {}, 'duration', {}, ... + 'markerCode', {}, 'markerIndex', {}, 'amplitude', {}, 'info', {}); + +if isempty(starts) + if winLen > (spanEnd - winStart0) + warning('pf2:slidingWindows:windowTooLong', ... + ['Window length (%.2f s) exceeds the span (%.2f s); no full windows. ', ... + 'Pass ''Partial'', true to keep a single short window.'], ... + winLen, spanEnd - winStart0); + end + blocks = emptyBlocks; + if opts.Embed + data.blocks = blocks; + blocks = data; + end + return; +end + +nWin = numel(starts); +blocks = repmat(struct('startTime', 0, 'endTime', 0, 'duration', 0, ... + 'markerCode', NaN, 'markerIndex', NaN, 'amplitude', 1, 'info', struct()), 1, nWin); + +for k = 1:nWin + st = starts(k); + et = st + winLen; + if partial && et > spanEnd + et = spanEnd; % clip trailing partial window + end + blocks(k).startTime = st; + blocks(k).endTime = et; + blocks(k).duration = et - st; + blocks(k).markerCode = NaN; + blocks(k).markerIndex = NaN; + blocks(k).amplitude = 1; + + info = struct(); + info.BlockNumber = k; + info.WindowNumber = k; + info.WindowOnset = st; + if ~isempty(condLabel) + info.(condField) = condLabel; + end + blocks(k).info = info; +end + +% --- Embed on the data struct if requested --- +if opts.Embed + data.blocks = blocks; + blocks = data; +end + +end diff --git a/+pf2/+data/split.m b/+pf2/+data/split.m index 0cb21dc3..265c08e9 100644 --- a/+pf2/+data/split.m +++ b/+pf2/+data/split.m @@ -59,6 +59,8 @@ % See also: pf2.data.resample, pf2.data.getMarkers, pf2.data.setT0 +pf2_base.ensureStatsFallbacks(); % ensure stats-toolbox fallbacks (nan*) are on the path before use + p=inputParser; validfNIRInput = @(x) (isnumeric(x)&&length(x)>1) || (isstruct(x) && (isfield(x,'raw')||isfield(x,'time')||isfield(x,'info'))); @@ -134,7 +136,7 @@ if(isnan(startTime)) startTime=min(fNIR.time); elseif(startTime<0) - error('Relative time cannot have a negative startTime'); + error('pf2:split:negativeRelativeStart', 'Relative time cannot have a negative startTime'); else startTime=min(fNIR.time)+startTime; %Start time is X seconds from beginning end @@ -164,17 +166,20 @@ if(endTime>max(fNIR.time)) %endTime=max(fNIR.time); - warning('End time excedes fNIR time'); + warning('pf2:split:endTimeClamped', ... + ['End time (%.1f s) exceeds the recording length (%.1f s); ' ... + 'the segment is truncated to the available data.'], ... + endTime, max(fNIR.time)); end if(endTime=minftime&t2trim<=maxftime; @@ -436,18 +441,29 @@ end if(isfield(outfNIR,'markers')&&~isempty(outfNIR.time)) - if(isfield(outfNIR.markers,'data')&&~isempty(outfNIR.markers.data)) + if(istable(outfNIR.markers)&&~isempty(outfNIR.markers)) + mTime=outfNIR.markers.Time; + validIndicies=(mTime<=max(outfNIR.time)&mTime>=min(outfNIR.time)); + outfNIR.markers=outfNIR.markers(validIndicies,:); + elseif(isfield(outfNIR.markers,'data')&&~isempty(outfNIR.markers.data)) validIndicies=(outfNIR.markers.data(:,1)<=max(outfNIR.time)&outfNIR.markers.data(:,1)>=min(outfNIR.time))==1; outfNIR.markers.data=outfNIR.markers.data(validIndicies,:); elseif(isnumeric(outfNIR.markers)&&~isempty(outfNIR.markers)) - validIndicies=(fNIR.markers(:,1)<=max(outfNIR.time)&outfNIR.markers(:,1)>=min(outfNIR.time))==1; - outfNIR.markers=fNIR.markers(validIndicies,:); + validIndicies=(outfNIR.markers(:,1)<=max(outfNIR.time)&outfNIR.markers(:,1)>=min(outfNIR.time))==1; + outfNIR.markers=outfNIR.markers(validIndicies,:); end end if(isfield(outfNIR,'ftimeChMask')) outfNIR.ftimeChMask=outfNIR.ftimeChMask(indexStart:indexEnd,:); end + +if isfield(outfNIR, 'blocks') && ~isempty(outfNIR.blocks) && ~isempty(outfNIR.time) + tMin = min(outfNIR.time); + tMax = max(outfNIR.time); + keep = arrayfun(@(b) b.startTime <= tMax && b.endTime >= tMin, outfNIR.blocks); + outfNIR.blocks = outfNIR.blocks(keep); +end end @@ -506,6 +522,11 @@ curFieldName=auxFields{f}; curField=aux_in.(curFieldName); + % Skip metadata fields (used for labeling, not time-series data) + if ismember(curFieldName, {'varNames', 'unit'}) + continue; + end + if(isempty(curField)) auxFieldIsEmpty(f)=true; outAuxStruct.(curFieldName)=curField; @@ -557,11 +578,16 @@ end nAuxChan=size(curField,2); - + nDataCols=nAuxChan-auxFieldHasTime(f); - newVarNames={}; - for nV=1:(nAuxChan-auxFieldHasTime(f)) - newVarNames{nV}=sprintf('val%i',nV); + % Use varNames from parent struct if available + if isfield(aux_in,'varNames') && iscell(aux_in.varNames) && length(aux_in.varNames)>=nDataCols + newVarNames=aux_in.varNames(1:nDataCols); + else + newVarNames={}; + for nV=1:nDataCols + newVarNames{nV}=sprintf('val%i',nV); + end end if(auxFieldHasTime(f)) diff --git a/+pf2/+export/asBIDS.m b/+pf2/+export/asBIDS.m new file mode 100644 index 00000000..12b5a6b4 --- /dev/null +++ b/+pf2/+export/asBIDS.m @@ -0,0 +1,204 @@ +function bidsRoot = asBIDS(allData, rootDir, opts) +% ASBIDS Export fNIRS recordings as a BIDS-NIRS dataset +% +% Writes a complete, validator-oriented BIDS dataset for functional NIRS +% (the SNIRF-based "BIDS-NIRS" specification). Each recording is laid out as +% sub-