Skip to content

Block Averaging and Epoching

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

Block Averaging and Epoching

This page covers the single-subject recipe for turning a continuous processed recording into event-related epochs and a trial/grand-averaged waveform — marker lookup, block definition, epoch extraction, and averaging — plus marker-free sliding-window epoching for resting-state and connectivity work. It is for anyone going from a processed recording to a per-condition response curve.

The canonical recipe

data = pf2.import.sampleData();     % recording WITH markers (fNIR1200, marker code 50)
proc = processFNIRS2(data);         % -> HbO/HbR/...

% 1. Define blocks from a marker code (Embed=false returns the block ARRAY;
%    the default Embed=true returns the data struct with .blocks embedded)
blocks = pf2.data.defineBlocks(proc, 50, 15, 'Embed', false);   % code 50, 15 s blocks

% 2. Cut time-locked epochs
segments = pf2.data.extractBlocks(proc, blocks, ...
    'PreTime', 5, 'PostTime', 15, 'SetT0', true);   % onset at t = 0

% 3. Trial/grand average onto a common grid (one call)
ga = pf2.data.blockAverage(segments);        % or pf2.data.grandAverage (alias)
plot(ga.time, ga.HbO.Mean(:, 1));            % averaged HbO, channel 1

pf2.import.sampleData() (fNIR1200, marker code 50) and pf2.import.sampleData.fNIR2000() (synthesized TaskA/TaskB blocks, codes 1/2) both carry markers, so either can be used to run this recipe with no external files.

Finding markers

pf2.data.getMarkers searches data.markers (or a marker table/matrix directly) for codes or code sequences, and is a useful way to inspect a recording before committing to a block definition:

times = pf2.data.getMarkers(proc, 50);         % all onset times of code 50
times = pf2.data.getMarkers(proc, [50; 51]);   % column vector = code 50 OR 51 (any match)
times = pf2.data.getMarkers(proc, 50, 51);     % paired start/end onsets
times = pf2.data.getMarkers(proc, {25, [50 51]}); % pattern: code 25 AND sequence 50 then 51
idx   = pf2.data.getMarkers(proc, 50, 'returnIndices', true);  % row indices instead of times

A row vector like [50, 51] means a sequential pattern (50 followed by 51); a column vector like [50; 51] means "either code" (OR logic).

Defining blocks

pf2.data.defineBlocks turns marker codes into a block definition — one struct per epoch, with startTime, endTime, duration, markerCode, markerIndex, and an .info struct for condition labels and per-trial metadata.

% Fixed duration from a marker code
blocks = pf2.data.defineBlocks(data, [49, 50], 30, 'Embed', false);

% Auto-detect duration from the marker's own .Duration column (omit duration)
blocks = pf2.data.defineBlocks(data, 49, 'Embed', false);

% Marker + end marker (duration derived from the terminating marker)
blocks = pf2.data.defineBlocks(data, [49, 50], 'EndMarker', 51, 'Embed', false);

% Start/end pairs with condition labels
blocks = pf2.data.defineBlocks(data, ...
    'StartMarker', [50; 51], 'EndMarker', [52; 53], ...
    'ConditionMap', {50, 'Natural'; 51, 'Synthetic'}, 'Embed', false);

'Embed' controls the return value: false returns just the block struct array; the default true returns the data struct with the blocks stored on data.blocks, so a subsequent extractBlocks(data) call needs no second argument. Both forms accept a cell array of subjects (with 'Embed', true) to stamp blocks onto every element in one call.

When data was imported from SNIRF with a companion BIDS events.tsv, ConditionMap is auto-populated from data.info.eventTypes — no manual mapping is required:

data   = pf2.import.importSNIRF('sub-01_nirs.snirf');   % reads events.tsv
blocks = pf2.data.defineBlocks(data, [1, 2, 3]);         % auto-labeled conditions

Extracting epochs

pf2.data.extractBlocks cuts each block (with padding) out of the continuous recording and returns a cell array of fNIRS structs — one per epoch, ready for blockAverage or exploreFNIRS.core.Experiment.

segments = pf2.data.extractBlocks(proc, blocks, ...
    'PreTime', 5, 'PostTime', 15, 'BaselineWindow', [-5, 0], 'SetT0', true);

Always set PreTime/PostTime explicitly. If neither PreTime, PostTime, nor Buffer is given, each side falls back to a small default Buffer of 2 s (a one-time pf2:extractBlocks:defaultBuffer note is emitted the first time this default path is taken in a session) — set them deliberately to size the epoch you actually want. 'Buffer' applies the same padding symmetrically to both sides when the corresponding PreTime/PostTime is not given.

Other useful options:

Parameter Purpose
'SetT0' Shift time so block start = 0 (default true)
'BaselineWindow' [start, end] relative to block start for baseline subtraction, e.g. [-5, 0]
'OverwriteInfo' Allow block.info to overwrite parent data.info fields of the same name (default true)
'SkipInvalid' Skip blocks that fall outside the recording's time range (default true)
'RejectByAux' Drop epochs overlapping accelerometer-flagged motion — pass an Aux signal name, or true to auto-detect an ACCEL-type signal

If you accidentally pass an Embed=true data struct (rather than a block array) as the second argument, extractBlocks detects it and uses its .blocks field.

% Using embedded blocks — no second argument needed
data     = pf2.data.defineBlocks(data, [49, 50], 30, 'Embed', true);
segments = pf2.data.extractBlocks(data, 'PreTime', 5, 'PostTime', 15);

% Cell array of subjects, each with blocks already embedded
segments = pf2.data.extractBlocks(allData, 'PreTime', 5, 'PostTime', 15);

Trial and grand averaging

Marker-locked segments share a sampling rate but generally start at different sub-sample phases, so their time vectors do not line up sample-for-sample — averaging them directly would yield an almost entirely NaN result. pf2.data.blockAverage (aliased as pf2.data.grandAverage) first resamples every segment onto one shared time grid anchored at t = 0, then averages:

ga = pf2.data.blockAverage(segments);
% or, identically:
ga = pf2.data.grandAverage(segments);

The output ga gives, for each biomarker HbO, HbR, HbTotal, HbDiff, CBSI:

ga.HbO.Mean     % [T x C] mean across segments
ga.HbO.SEM      % [T x C] standard error of the mean
ga.HbO.SD       % [T x C] standard deviation
ga.HbO.N        % [T x C] count of contributing segments (per timepoint/channel)
ga.HbO.Median   % [T x C]
ga.HbO.Max      % [T x C]
ga.HbO.Min      % [T x C]
ga.HbO.data     % [T x C x N] per-segment aligned data
ga.time         % [T x 1] shared time grid
ga.units        % biomarker units

'ResampleInterval' overrides the shared grid's sample interval (default: the median of the segments' native intervals). 'AverageAux' also averages any auxiliary signals present on the segments.

Sliding-window epoching (marker-free)

For dynamic functional connectivity, resting-state analysis, windowed quality control, or fixed-length model input, pf2.data.slidingWindows tiles the whole recording on a regular grid instead of locking to markers. It produces defineBlocks-compatible blocks that feed straight into extractBlocks:

blocks  = pf2.data.slidingWindows(proc, 'Length', 10, 'Overlap', 0.5, 'Embed', false);
windows = pf2.data.extractBlocks(proc, blocks, 'PreTime', 0, 'PostTime', 0);

Set PreTime/PostTime to 0 so each extracted segment is exactly the window (otherwise extractBlocks applies its own default padding). Key options:

Parameter Purpose
'Length' Window length in seconds (required)
'Step' Step between window starts in seconds (default: Length, i.e. contiguous, non-overlapping); mutually exclusive with 'Overlap'
'Overlap' Fractional overlap in [0, 1); sets Step = Length*(1-Overlap)
'Start' / 'End' Bound the tiled span in seconds (default: the full recording)
'Partial' Keep a trailing short window when the grid doesn't divide evenly (default false)
'Condition' Label stored in each window's .info.Condition (or 'ConditionField')
'Embed' Store blocks on data.blocks and return the data struct (default true)
% Contiguous 30 s windows, embedded directly on the struct
proc    = pf2.data.slidingWindows(proc, 'Length', 30);
windows = pf2.data.extractBlocks(proc, 'PreTime', 0, 'PostTime', 0);

fNIRS hemodynamic/neural fluctuations of interest sit near 0.01–0.04 Hz, which implies windows of roughly 30–100 s (Length >= 1/f_low); a much shorter window mostly captures Mayer-wave/systemic activity rather than neural co-fluctuation. Use pf2.data.defineBlocks instead for event-related/task designs.

Multi-condition and group averaging

blockAverage/grandAverage are the single-subject, flat-average endpoint. For multi-condition comparisons, between-group statistics, or averaging across multiple subjects, feed the extracted segments to exploreFNIRS.core.Experiment instead:

ex = exploreFNIRS.core.Experiment(segments);
ex.groupby({'Condition'});
ex.aggregate();          % default 'hierarchy' mode avoids pseudoreplication
ex.plotTemporal('Biomarkers', {'HbO'});

See Group Analysis for groupby/aggregate semantics (including the 'hierarchy' vs 'flat' vs 'none' averaging modes) and group-level plotting and statistics.

See also

Clone this wiki locally