Skip to content

NIRx import, SNIRF compliance and gaps, and the .nir counter time base (v1.2.2) - #12

Merged
AdrianCurtin merged 9 commits into
masterfrom
nirx-snirf-followups
Nov 4, 2026
Merged

AdrianCurtin merged 9 commits into
masterfrom
nirx-snirf-followups

Conversation

@AdrianCurtin

@AdrianCurtin AdrianCurtin commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Follow-ups to the v1.2.1 NIRx and SNIRF fixes, released as v1.2.2. NIRx imports use the vendor coordinate set and start time and read only the selected recording, NIRStar recordings without a .nirs file import, SNIRF exports pass the official validator and open in MNE with annotations aligned, and COBI Studio Modern .nir files are timed from their device sample counter with a fitted tick.

NIRx import

  • Vendor geometry. Positions come from <root>_probeInfo.mat, the source of the positions in NIRx's own SNIRF: 3D positions in mm, the vendor 2D layout, 10-10 optode labels, and the head model as the coordinate system (ICBM152 is declared as MNI). Landmarks (Nz, Iz, LPA, RPA, Cz) come from digpts.txt.
  • Start time and model. t0 is the first frame of the raw .roh stream, read from inside <root>.zip. A 'Model' option names the device when the .hdr only has a serial Device ID. The Aurora Sampling rate key is read.
  • NIRStar recordings without a .nirs file. Data from .wl1/.wl2, channels from the .hdr S-D-Key/S-D-Mask (0-based keys accepted), geometry from _probeInfo.mat, markers from .hdr Events or .evt triggers. Missing wavelengths default to 760/850 nm with a warning.
  • File selection. Only the files of the selected recording are read, so a folder with several recordings no longer mixes them. .hdr Events codes are kept when a .nirs file is also present.

SNIRF export and import

  • Compliance. Measurement-list indices and types are integers, dataUnit and the non-standard raw-DC label are not written, dataLabels, wavelengths and time are 1-D, and BIDS coordinate systems carry a description; other tags such as MNI go to metadata tags that importSNIRF restores.
  • Time. Time is written from 0 at the first sample, with the original origin in TimeOrigin; MeasurementDate/MeasurementTime are in UTC when the zone is known ('TimeZone' option), since MNE drops the offset.
  • Gaps. Every gap gets a BAD_gap stim. A gap of one whole missing sample is filled by interpolation; longer gaps are written as recorded with a warning ('MaxFillSamples' sets the limit; Inf fills all). In MNE, filling with 0 or NaN instead would make optical density non-finite for the whole recording.
  • Dark channels are stripped by default; MNE treats them as 0 nm channels.
  • MNE target. 'Target','mne' (off by default; also in batch export and asBIDS) fills every gap unless 'MaxFillSamples' is given, strips dark channels, leaves out landmarks, and warns about irregular sample steps.
  • Identifying metadata (names, age, sex, experimenter, contact details, comments, notes) is written only with 'IncludeIdentifying', and path values are reduced to the file name.
  • Round trip. importSNIRF restores the time origin for samples, markers, aux and t0, drops filled samples and BAD_gap stims, takes fs from SamplingRate (written at full precision) and keeps RawMax, optode labels, landmarks and the coordinate system for re-export. asBIDS writes events.tsv onsets from the first sample.
  • Known MNE issue. MNE 1.13.2 reads landmarkPos3D 1000 times too large; MNE 1.11 reads it correctly.

.nir time base

  • 2000M. The counter ticks at 32768/33 Hz, not 1 kHz, so time ran 0.70% fast and fs read 5.025 Hz. The tick is fitted per segment as the lower envelope of PC time against the counter (Moon, Skelly & Towsley, 1999), each segment is anchored to its PC latency floor, and fs is 1/(tick x mean counts per sample).
  • 2000S and 3000C. The counter indexes samples at 1 ms per count. A fit within 50 ppm is kept at exactly 1 ms and the first piece of each counter segment is anchored at its first sample, so times and rates are unchanged without counter resets.
  • Segments. Counter resets no longer stitch with a PC difference that includes the pre-dropout delay. PC clock restarts are carried across by the counter into the final PC frame, and PC jumps and counter pauses are placed on the PC clock. Short recordings check their fit against the default.
  • Markers and t0. After PC restarts, .mrk markers and the header start time move into the final PC frame. Manual _C.mrk markers use the PC QPC timer from the session start, so they move by the first frame's offset like the start time; automatic marker files are placed frame by frame, and markers that could belong to more than one frame are warned about.
  • Detection and QC. Counter detection handles zero-padded baselines, early resets and short recordings; 1200W files stay on PC time. QC goes to info.timebase; PC-timed files get a regularity check in info.timing. Device setup keeps the counter fs only while the time vector still matches.

Other

  • The "Time column could not be found" warning is shown once per session.
  • importNIR keeps the marker file index in MarkerSource (only with more than one .mrk file) instead of Amplitude; asNIR does not write it.

Testing

  • ExportTest, NirCounterTimebaseTest, CharacterizationTest, RoundtripTest, ImportNIRTest, FullPipelineTest, BidsExportTest, AuxRoundtripTest, MarkerPropagationTest and TensorContractTest pass. The GNU Octave subset (pf2_base.tests.runOctaveSubset) passes under Octave 11.3.0.
  • New tests use synthetic recordings with known sample times: a 2000M run with known tick, stalls, dropouts, resets and a PC restart; the drift of the previous 1 kHz assumption; an unchanged 2000S run; PC restarts during the baseline; a restart at the same time as a counter reset; zero and NaN counter rows; dropout episodes; clock steps, a backlog and a counter pause; a short file without a model; automatic and manual markers across restarts; the counter-detection rule; the fs override after resampling; the once-per-session warning; NIRx vendor geometry, NIRStar wavelength files and re-export; SNIRF time origin, gaps (marked, filled on request or by the MNE target, and opened in MNE when available), measurement-list types and identifying metadata.
  • In the combined run of the related test classes, ImportDirectoryTest/testMalformedBIDSDuplicateEntityWarns, MarkerTableTest/testSnirfTwoColumnStimReadsCodeFromCol2 and PipelineFunctionTest/testSetParamsSpecialKeyWarns fail the same way on master: an earlier test leaves warnings off. ExportTest/testExportSNIRFMultipleRuns stays skipped; multi-run export is a separate, existing limitation.

importNIRX takes positions, the 2D layout, optode labels and the head
model from <root>_probeInfo.mat (the source of NIRx's own SNIRF), and
landmarks from digpts.txt. t0 comes from the first .roh frame, read
from inside the .zip. A 'Model' option names the device when the .hdr
only has a serial Device ID. NIRStar recordings without a .nirs file
import from the .wl files, the .hdr S-D-Key/S-D-Mask and the .evt
triggers instead of erroring. .hdr Events no longer read the frame
number as the duration.

asSNIRF writes optode labels, landmarks and the coordinate system, and
writes MeasurementDate/MeasurementTime in UTC with the zone in
MeasurementTimeZone, since MNE drops the UTC offset.

importSNIRF converts UnixTime to the recording's zone instead of
relabelling the UTC clock time, parses a MeasurementTime offset, keeps
a time with no zone zone-less, and reads fixed-width landmark labels,
which made NIRx's own SNIRF files fail to import.

importNIR keeps the marker file index in a MarkerSource column, only
when markers come from more than one .mrk file, instead of in
Amplitude, and documents the .mrk columns as read.
Device setup warned "Time column could not be found" for every
recording from a device whose raw data has no time column, such as
NIRx. Both copies of the warning now fire once per session and carry
the pf2:updateCurrentDevice:noTimeColumn identifier.
…ing metadata

asSNIRF writes the measurementList index and type fields as int32
(doubles fail the SNIRF validator) and no longer writes dataUnit 'au',
which MNE 1.13 rejects, or the non-standard dataTypeLabel 'raw-DC'.
Non-BIDS coordinate systems such as MNI go to the CoordinateSystem
metadata tags instead of probe/coordinateSystem 'Other', which drew a
validator warning.

.info fields that identify or describe a person (names, date of birth,
age, sex, experimenter, contact details, notes) are written only with
the new IncludeIdentifying option, and path values are reduced to the
file name; the .nir filename included the full local path.

importSNIRF reads integer measurementList fields as doubles, finds raw
channels by dataType when de-normalizing, and restores the coordinate
system from the metadata tags.
importNIR read the COBI Studio Modern sample counter as 1 kHz. On the
2000M it ticks at 32768/33 Hz, so time ran 0.70% fast and fs came out
5.025 Hz, and segments between counter resets were stitched with a PC
time difference that includes the delivery delay before each dropout.

pf2_base.nirCounterTimebase splits the recording at counter resets, PC
clock restarts, sustained PC clock steps and counter pauses at dropouts.
It fits the counter tick per segment as the lower envelope of PC time
against the counter (Moon, Skelly & Towsley, 1999), anchors each segment
to its PC latency floor, carries time across PC restarts with the
counter, and sets fs from the tick and the mean counts per sample.
Segment, gap and latency QC goes to info.timebase, and device setup in
processFNIRS2 keeps that fs instead of 1/median(diff(time)). Short
2000M, 2000S and 3000C recordings are recognised by their counter step;
1200W files stay on the PC time column.
@AdrianCurtin AdrianCurtin changed the title NIRx vendor geometry, NIRStar import and SNIRF start times (v1.2.2) NIRx vendor geometry, NIRStar import, SNIRF compliance and .nir time base (v1.2.2) Oct 30, 2026
…lection

Time base: 1 ms counters (2000S, 3000C) keep exactly 1 ms when the fit is
within 50 ppm, and the first piece of each counter segment is anchored at
its first sample with the earlier arithmetic, so their times and rates are
unchanged without counter resets. fs is the median step except on the
2000M. Step and pause detection uses a provisional fitted tick, a counter
pause needs a level raised floor over 30 s, and short split pieces are
merged back without steering the neighbouring fit or anchor. Short
recordings check their fit against the default, runs of rows without a
counter stay together, and fewer than two rows return no rate. Frames,
offsets and t0Shift are returned.

importNIR moves .mrk markers and t0 into the final PC frame, placing each
marker file in a consistent sequence of frames and warning on ambiguous
markers. Counter detection moves to pf2_base.isNirCounterColumn (zero-padded
baselines, early resets, short counters, 1200W excluded), samples at PC
time 0 or below are kept, PC-timed files get a regularity check
(pf2_base.nirTimingCheck), and per-sample QC arrays stay out of info.
Device setup keeps the counter fs only while the time vector matches
(pf2_base.timebaseMatches). asNIR does not write MarkerSource.

asSNIRF strips dark channels by default, writes time from 0 at the first
sample with TimeOrigin, marks every gap with a BAD_gap stim and fills only
gaps of up to MaxFillSamples (default 1) whole missing samples, writes
SamplingRate at full precision, labels gap stims, maps optode labels on
per-optode devices, describes BIDS coordinate systems and omits more
identifying fields. importSNIRF restores the time origin for samples,
markers, aux and t0, drops filled samples, takes fs from SamplingRate and
RawMax from its tag, and keeps labels, landmarks and the coordinate system
for re-export. asBIDS writes events.tsv onsets from the first sample.

importNIRX reads only the files of the selected recording, keeps .hdr
Events codes over the .nirs stim matrix, accepts 0-based S-D-Key indices
and defaults Aurora wavelengths to 760/850 nm, and handles dotted file
names, space-separated wavelengths, string option names and Octave line
splitting.

Release date moves to 2026-11-04.
Copilot AI balanced review requested due to automatic review settings November 4, 2026 18:50
@AdrianCurtin AdrianCurtin changed the title NIRx vendor geometry, NIRStar import, SNIRF compliance and .nir time base (v1.2.2) NIRx import, SNIRF compliance and gaps, and the .nir counter time base (v1.2.2) Nov 4, 2026
asSNIRF 'Target','mne' (also through batch export and asBIDS) prepares
files for MNE-Python: dark channels are stripped, every gap is filled
unless MaxFillSamples is given, landmarks are left out (MNE 1.13 reads
landmarkPos3D 1000 times too large), and irregular sample steps are
warned about. The default target, 'snirf', writes the data as recorded.

importNIR moves manual <name>_C.mrk markers by the first PC frame's
offset after PC clock restarts: they are timed by the PC QPC timer from
the session start, which the restarts do not reset.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved defects can import the wrong recording, expose identifying metadata despite opt-out, and produce incorrect timestamps and sampling rates.

8 open issues
What changed in this PR

This v1.2.2 update improves recording import, timing accuracy, and SNIRF interoperability across processFNIRS2.

Changes:

  • Adds recording-specific NIRx loading, vendor geometry, and NIRStar wavelength-file support.
  • Updates SNIRF compliance, metadata privacy, gap handling, and time-origin round trips.
  • Introduces counter-based COBI .nir timing with diagnostic metadata and regression tests.
File Description
README.md Updates the version badge and citation.
docs/​README.md Updates the release citation.
docs/​CITING.md Updates APA and BibTeX versions.
CITATION.cff Updates release metadata.
CHANGELOG.md Documents v1.2.2 changes.
+pf2/​+import/​importSNIRF.m Restores timing, sampling rate, labels, and geometry metadata.
+pf2/​+import/​importNIRX.m Adds vendor geometry, recording selection, and wavelength-file imports.
+pf2/​+import/​importNIR.m Integrates counter timing and marker-frame alignment.
+pf2/​+export/​asSNIRF.m Updates compliance, privacy, timestamps, and gap handling.
+pf2/​+export/​asNIR.m Excludes marker-source bookkeeping from exported markers.
+pf2_base/​timebaseMatches.m Checks whether stored timing metadata still applies.
+pf2_base/​pf2version.m Updates the runtime version and release date.
+pf2_base/​pf2_updateCurrentDevice.m Preserves matching counter rates and limits repeated warnings.
+pf2_base/​nirTimingCheck.m Reports irregular PC-based sampling.
+pf2_base/​nirCounterTimebase.m Fits counter timing and handles clock discontinuities.
+pf2_base/​isNirCounterColumn.m Detects supported device counter columns.
+pf2_base/​+tests/​+unit/​NirCounterTimebaseTest.m Tests counter timing, resets, markers, and rate preservation.
+pf2_base/​+tests/​+unit/​ExportTest.m Expands SNIRF and NIRx regression coverage.
+pf2_base/​+tests/​+integration/​RoundtripTest.m Supplies explicit time zones for export checks.
+pf2_base/​+gui/​updateCurrentDevice.m Preserves matching counter rates in shared device setup.
+pf2_base/​+bids/​writeEventsTsv.m Makes event onsets relative to the first sample.

Review effort: Balanced

Comment thread +pf2/+import/importNIRX.m Resolved
Comment thread +pf2_base/nirCounterTimebase.m Resolved
Comment thread +pf2/+export/asSNIRF.m Resolved
Comment thread +pf2/+import/importSNIRF.m Resolved
Comment thread +pf2/+import/importSNIRF.m Resolved
Comment thread +pf2/+import/importSNIRF.m Resolved
Comment thread +pf2_base/+bids/writeEventsTsv.m Resolved
Comment thread +pf2_base/timebaseMatches.m Resolved
- importNIRX reads only files named with the selected recording's exact
  root and a NIRx file suffix, so rec_2.* no longer leaks into rec.
- A recording without a usable counter keeps its PC times and their rate.
- timebaseMatches compares sample count, span and median step, so a
  same-length resample onto a regular grid no longer revives a stale rate;
  setT0 still keeps it.
- importSNIRF records the SamplingRate tag with its time vector, so the
  imported rate survives processFNIRS2 until the data are resampled or cut.
- importSNIRF reads fixed-width optode labels stored one per column, and
  shifts only aux signals with their own time by the time origin.
- asSNIRF with MaxFillSamples Inf fills every gap, including steps that are
  not a whole number of sample periods.
- writeEventsTsv uses origin 0 when the time vector does not match the
  data, as asSNIRF does.
@AdrianCurtin
AdrianCurtin merged commit 785d4a3 into master Nov 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants