Skip to content

Conversation

zEdS15B3GCwq
Copy link

Reference issue (if any)

Started discussion in #13401. Most of the description is copied from there with changes.

The same idea has also been raised in issue #9816, but that discussion did not result in a PR. In short, (some) Shimadzu fNIRS devices work with 3 wavelengths, and in the future there might be other devices with more, but mne's SNIRF I/O and processing functions are coded to accept only 2 wavelengths.

From my perspective, these changes would be mostly beneficial as I'm using a Shimadzu device. While the device doesn't support .snirf output, there are ways to convert the data to that format. And while it's possible to preprocess and convert to Hb data first in some other tool(s) and then import to mne, it would be ideal to keep the entire processing pipeline to one tool.

Describe the new feature or enhancement

Goal: extend MNE-Python's NIRS processing to support an arbitrary number of wavelengths (≥2) instead of being limited (hard-coded in several places) to two.

I'd like to discuss implementation details and to explore devs' opinion on how feasible such changes would be. For this reason, this a draft PR, and some important points (like tests) haven't been addressed (at all) yet.

Describe your proposed implementation

I've done some exploration in the codebase to identify relevant bits of code, both manually and with the help of Claude 4. Based on this, it seems that relevant parts of the code are in SNIRF I/O (mne.io.snirf) and NIRS preprocessing (mne.preprocessing.nirs).

Major changes

  • Channel data structure: with 2 wavelengths, channel data is stored as ABAB... . All functions handling OD and amplitude data will need to be updated not to rely on a fixed [::2], [1::2] sequence. I listed this as a major change due to the difficulty of locating every function that works this way. Claude thinks it's been able to find all of them (see list of files to be changed below). It still requires devs' experience to know if there are some I might not have been able to find.
  • Beer-Lambert calculation (algorithmic change): The current implementation uses pseudo-inverse pinv() to calculate ΔOD = E × L × PPF × Δc. I think it's possible to update this with minimal changes, just changing the EL matrix to n x 2 where n is the number of wavelengths, instead of the current 2 x 2. After Beer-Lambert, excess channels will need to be dropped.
  • Scalp coupling index (algorithmic change): I'm not aware of any reports where SCI was defined for >2 wavelengths. My idea here is to calculate all possible pairs of correlation, and use the minimum value. Intuitively, this should be correct as all wavelength channels should be correlated.

Minor changes

  • SNIRF file reading: checks and validation steps need to be updated. While there's a number of validation checks, the overall complexity of the code changes isn't high.

List of affected files and functions:

I'm hoping to receive feedback on the correctness of the above proposed algorithmic changes. For n=2 wavelengths, the new calculation methods wouldn't introduce any new changes (E in Beer-Lambert would still be 2x2, and there is only one correlation pair so the minimum is irrelevant), and if necessary, the current implementation could be preserved in a separate code path. Current tests for n=2 should also work.

Describe possible alternatives

The algorithmic changes (Beer-Lambert and SCI) could be implemented in another way. The Beer-Lambert pseudo-inverse should work with n>2 with minimal changes and the change seems mathematically correct. The SCI calculation using the minumum of all correlations appers to be the most conservative in terms of being able to spot any coupling issues, but without studies to rely on, it's a choice that should be discussed.

Additional context

I'll open another PR for the mne-nirs project once this PR has advanced a bit. In particular, the scalp coupling algorithm should be agreed on, since that project has a very similar, windowed implementation of it that probably should be updated with the changes here in mind.

As I mentioned above, the current code is untested, as in I haven't actually run it with any .snirf files yet. I'm sorry for this, I'll try to do some testing as soon as possible.

Copy link

welcome bot commented Sep 4, 2025

Hello! 👋 Thanks for opening your first pull request here! ❤️ We will try to get back to you soon. 🚴

Copy link
Member

@larsoner larsoner left a comment

Choose a reason for hiding this comment

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

Looks like a reasonable start to me!


def _load_absorption(freqs):
"""Load molar extinction coefficients."""
"""Load molar extinction coefficients"""
Copy link
Member

Choose a reason for hiding this comment

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

This is a minor style regression

Suggested change
"""Load molar extinction coefficients"""
"""Load molar extinction coefficients."""

Comment on lines +62 to +64

# freqs = np.array([raw.info["chs"][pick]["loc"][9] for pick in picks], float)
# n_wavelengths = len(set(unique_freqs))
Copy link
Member

Choose a reason for hiding this comment

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

Cruft?

Suggested change
# freqs = np.array([raw.info["chs"][pick]["loc"][9] for pick in picks], float)
# n_wavelengths = len(set(unique_freqs))

Comment on lines +78 to +79
# For multiple wavelengths: calculate all pairwise correlations within each group
# and use the minimum as the quality metric
Copy link
Member

Choose a reason for hiding this comment

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

I don't understand why you need a conditional here... if n_wavelengths==2 then "all pairwise correlations" should be a single correlation and give an equivalent output to the branch above... so we shouldn't need it

or (ch1_value != pair_vals[0])
or (ch2_value != pair_vals[1])
# Validate channel grouping (same source-detector pairs, all pair_vals match)
for i in range(0, len(picks), len(pair_vals)):
Copy link
Member

Choose a reason for hiding this comment

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

We prefer ii or pi to i, more clearly different from 1j and readable etc.

@larsoner
Copy link
Member

Looks like style CIs are unhappy -- until they are, most tests won't run

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