Skip to content

Auxiliary Signals

Adrian Curtin edited this page Jul 24, 2026 · 1 revision

Auxiliary Signals

fNIRS recordings often come with companion physiological signals — heart rate, ECG/PPG waveforms, accelerometer/IMU, skin conductance, EEG, respiration, temperature. processFNIRS2 treats these as first-class typed Aux signals: recognized by family, alignable to the fNIRS time grid, and usable as nuisance regressors, covariates, motion gates, and event-related overlays. This page is for anyone with a data.Aux container who wants to align, extract features from, or model with those signals.

The typed Aux model

Every named signal in data.Aux (e.g. data.Aux.heartRate, data.Aux.accelerometer) is normalized to a canonical struct with these fields:

sig.data      % [T x C] numeric
sig.time      % [T x 1] seconds
sig.unit      % e.g. 'bpm', 'g', 'uS', 'uV'
sig.varNames  % {1 x C} channel labels, e.g. {'X','Y','Z'}
sig.type      % inferred family: 'HR'|'EKG'|'PPG'|'ACCEL'|'GSR'|'EEG'|'RESP'|'TEMP'|''
sig.kind      % 'feature' | 'waveform' | ''
Family Meaning Typical role
HR Heart rate (bpm) Covariate of arousal (derived feature)
EKG Electrocardiogram waveform Cardiac nuisance regressor + HR source
PPG Photoplethysmography waveform Cardiac nuisance regressor + HR source
ACCEL Accelerometer / IMU Motion quality gate + nuisance regressor
GSR / EDA Electrodermal activity Covariate of arousal
EEG Electroencephalography Covariate / fusion modality, own per-band typing
RESP Respiration (belt / RIP) Low-frequency nuisance conditioning signal
TEMP Skin/ambient temperature Covariate

Normalizing and typing

% Coerce a whole Aux container (any mix of struct/table/numeric signals)
% into the canonical form above
data.Aux = pf2_base.normalizeAux(data.Aux);

% Resolve a signal name (and optional unit) to its type descriptor
info = pf2_base.auxSignalType('heartRate');   % info.type == 'HR'
info = pf2_base.auxSignalType('chan1', 'uS'); % info.type == 'GSR' (resolved by unit)
% info also carries: .kind, .defaultUnit, .role, .roles, .band, .bands (EEG), .matchedBy

normalizeAux is idempotent (re-normalizing a canonical container is a no-op) and recognizes common field-name synonyms (values/signal/y → data; t/timestamps → time; units → unit; names/labels/ channels → varNames). Type is inferred from the signal's field name and falls back to its unit; unrecognized signals get type = '' and still flow through the Aux machinery normally.

Aligning a signal onto the fNIRS grid

pf2.data.auxOnGrid is the single alignment primitive everything else in this page builds on — it resamples a named Aux signal (nested or flattened form) onto an arbitrary target time base, handling anti-aliasing, clock offset, and NaN gaps.

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); % device clock skew + gap handling

Key parameters: 'Time' (target grid, default data.time), 'Channels' (subset by index or name), 'Offset' (seconds, corrects device clock skew), 'AntiAlias' (default true, ignored when upsampling), 'MaxGap' (seconds — target samples inside a source gap wider than this come back NaN rather than being interpolated across).

Type-aware feature extractors

These turn a raw waveform into a derived feature series (or scalar summary). All are self-contained (no Signal Processing Toolbox dependency).

% PPG/EKG waveform -> instantaneous heart rate (bpm)
[hr, info] = pf2.data.aux.heartRateFrom(ppg, fs);
%   'Band' (default [0.5 5]), 'MinBPM' (30), 'MaxBPM' (220)

% GSR/EDA -> tonic (SCL) / phasic (SCR) decomposition
[scl, scr, info] = pf2.data.aux.edaDecompose(gsr, fs);
%   'TonicCutoff' (default 0.05 Hz); reference: Boucsein (2012), DOI: 10.1007/978-1-4614-1126-0

% Accelerometer/IMU -> motion features
[feat, info] = pf2.data.aux.accelFeatures(acc, fs);
%   feat.norm (gravity-removed vector magnitude), feat.jerk (its derivative)

% EEG -> canonical band-power envelopes (delta..gamma)
bp = pf2.data.aux.eegBandPower(eeg, fs);
%   bp.delta, bp.theta, bp.alpha, bp.beta, bp.gamma  — each [T x C]

% Respiration -> rate (breaths/min) and RVT nuisance regressor
rf = pf2.data.aux.respFeatures(resp, fs);
%   rf.rate, rf.rvt; reference: Birn et al. (2006), DOI: 10.1016/j.neuroimage.2006.02.048

% PPG/EKG (or beat times, or NN intervals) -> scalar HRV metrics
hrv = pf2.data.aux.hrvFeatures(ppg, fs);
%   hrv.meanHR, .SDNN, .RMSSD, .pNN50, .LF, .HF, .LFHF
%   'Input': 'waveform' (default) | 'beats' | 'ibi'
%   reference: Task Force ESC/NASPE (1996), DOI: 10.1161/01.CIR.93.5.1043

Storing a derived feature back as typed Aux

[hr, ~] = pf2.data.aux.heartRateFrom(proc.Aux.ppg.data, proc.Aux.ppg.fs);
proc = pf2.data.aux.addFeature(proc, 'heartRate', hr, 'Unit', 'bpm');
% -> proc.Aux.heartRate as a canonical {data,time,unit,varNames,type,kind} signal,
%    typed HR from the field name, and it survives SNIRF export/re-import.

Accelerometer-informed motion handling

An independent motion reference disambiguates true head movement from neural transients, rather than inferring motion from the optical signal itself.

% Flag motion samples/windows from an accelerometer aux signal
[mask, mi] = pf2_base.fnirs.accelMotionDetect(proc);
%   'Metric': 'norm' (default) | 'jerk'
%   'Threshold' (default [] -> adaptive median + MADScale*MAD)
%   mi.windows [W x 2] start/end times (s), mi.fractionFlagged

% Regress accelerometer-explained variance out of HbO/HbR (mean preserved)
proc = pf2_base.fnirs.accelRegress(proc);
%   'IncludeAxes'/'IncludeNorm'/'IncludeJerk' (all default true)
%   Reference: Metz et al. (2015), DOI: 10.3390/a8041052

% Drop epochs that overlap accelerometer-flagged motion during block extraction
seg = pf2.data.extractBlocks(proc, blocks, 'PreTime', 5, 'PostTime', 15, ...
    'RejectByAux', 'accelerometer');
%   'RejectByAux': signal name, or true to auto-detect an ACCEL-type signal
%   'AuxMotionFraction' (default 0.2): max tolerated flagged-sample fraction
%   'AuxMotionThresh' (default [] -> adaptive)

When both HbO and HbR are corrected, accelRegress recomputes the derived HbTotal/HbDiff/CBSI fields so they stay consistent, and records a .accelRegressInfo field on the output.

Adaptive cardiac (RETROICOR-style) regression

pf2_base.fnirs.physioRegress removes cardiac oscillations using the measured heart rate (from an HR feature signal, or derived from PPG/EKG) rather than a fixed notch band — useful when heart rate drifts or is elevated (e.g. aroused participants).

proc = pf2_base.fnirs.physioRegress(proc, 'Harmonics', 2);
%   'Signal': auto-detects HR, then PPG, then EKG if unspecified
%   'Biomarkers' (default {'HbO','HbR'})
%   Reference: Glover, Li & Ress (2000), DOI: 10.1002/1522-2594(200007)44:1<162::AID-MRM23>3.0.CO;2-E

Builds sine/cosine regressors at the instantaneous cardiac phase and its harmonics, then removes them by least squares (channel means preserved). If no cardiac-type signal is present, the input is returned unchanged with a warning (graceful no-op).

Aux as GLM nuisance / LME covariate

% GLMExperiment: declare aux signals as nuisance regressors in the design
gx = exploreFNIRS.core.GLMExperiment(allData);
gx.glm.auxNuisance = {'heartRate'};

% Group LME: promote an aux_* signal to a model covariate
ex.statsAutoLME('AuxCovariates', {'heartRate'});

See Group Analysis for the full GLMExperiment/LME workflow these plug into.

Event-related Aux overlay

exploreFNIRS.core.Experiment.plotTemporal can overlay an aux signal (right-hand y-axis), time-locked to the same epoch grid as the biomarker trace:

ex.plotTemporal('Biomarkers', {'HbO'}, 'AuxOverlay', {'heartRate'});

Hyperscanning physiology controls

Shared physiology (e.g. cardiac/respiratory coupling) can masquerade as inter-brain synchrony; the Aux model plugs into hyperscanning coupling and QC to control for it — see Group Analysis for the full hyperscanning workflow:

r  = exploreFNIRS.coupling.partialCoherence(A_ch, B_ch, sharedAux, fs); % control shared physio
qc = exploreFNIRS.hyperscanning.physioConfoundQC(A, B);                 % LFO/VLFO shared-aux flag

Exporting aux into the foundation-model tensor contract

Aligned (and derived) aux signals can be written into the self-describing HDF5 tensor export alongside HbO/HbR:

pf2.export.asTensor(proc, 'out.h5', 'Features', {'HbO','HbR'}, 'Aux', 'all');
% 'Aux': cellstr of aux base names, or true/'all' for every present signal
% -> written to /aux, aligned to /time, with auxNames/auxUnits attributes

See Export and Interoperability for the full tensor/BIDS/SNIRF export contract.

Canonical data.Aux structure at a glance

data.Aux.heartRate.data      % [T x 1]
data.Aux.heartRate.time      % [T x 1]
data.Aux.heartRate.unit      % 'bpm'
data.Aux.heartRate.varNames  % {'HR'}

data.Aux.accelerometer.data      % [T x 3]
data.Aux.accelerometer.time      % [T x 1]
data.Aux.accelerometer.unit      % 'g'
data.Aux.accelerometer.varNames  % {'X','Y','Z'}

varNames propagates through pf2.data.split/pf2.data.resample as table column names and through grand-averaging to plot subplot titles.

See also

Clone this wiki locally