Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/14055.newfeature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added support for newer recording software in :func:`mne.io.read_raw_nirx`, by `Emma Bailey`_.
2 changes: 1 addition & 1 deletion doc/changes/names.inc
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
.. _Eberhard Eich: https://github.com/ebeich
.. _Eduard Ort: https://github.com/eort
.. _Emily Stephen: https://github.com/emilyps14
.. _Emma Bailey: https://www.cbs.mpg.de/employees/bailey
.. _Emma Bailey: https://github.com/emma-bailey
.. _Emma Zhang: https://portfolio-production-ed03.up.railway.app/
.. _Emmanuel Ferdman: https://github.com/emmanuel-ferdman
.. _Emrecan Çelik: https://github.com/emrecncelik
Expand Down
56 changes: 44 additions & 12 deletions mne/io/nirx/nirx.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,6 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No
"description.json",
"wl1",
"wl2",
"probeInfo.mat",
"tri",
)
else:
# NIRScout devices and NIRSport1 devices
Expand All @@ -132,7 +130,6 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No
"wl1",
"wl2",
"config.txt",
"probeInfo.mat",
)
n_dat = len(glob.glob(f"{fname}/*{'dat'}"))
if n_dat != 1:
Expand Down Expand Up @@ -175,6 +172,21 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No
nan_mask[key] = files[key][0 if noidx == 1 else 1]
files[key] = files[key][fidx]

# Locate probeInfo file — either .mat (legacy) or .json (newer Aurora)
probe_mat = glob.glob(f"{fname}/*probeInfo.mat")
probe_json = glob.glob(f"{fname}/*probeInfo.json")
if len(probe_json) == 1:
files["probeInfo"] = probe_json[0]
probe_format = "json"
elif len(probe_mat) == 1:
files["probeInfo"] = probe_mat[0]
probe_format = "mat"
else:
raise RuntimeError(
f"Need one probeInfo.mat or probeInfo.json file, "
f"got {len(probe_mat)} .mat and {len(probe_json)} .json"
)

# Read number of rows/samples of wavelength data
with _open(files["wl1"]) as fid:
last_sample = fid.read().count("\n") - 1
Expand All @@ -197,6 +209,7 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No
"2021.4.0-34-ge9fdbbc8",
"2021.9.0-5-g3eb32851",
"2021.9.0-6-g14ef4a71",
"2025.2.0-17-geea6971f",
]:
warn(
"MNE has not been tested with Aurora version "
Expand Down Expand Up @@ -361,12 +374,28 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No
# Sources and detectors are both called optodes
# Each source - detector pair produces a channel
# Channels are defined as the midpoint between source and detector
mat_data = loadmat(files["probeInfo.mat"])
probes = mat_data["probeInfo"]["probes"][0, 0]
requested_channels = probes["index_c"][0, 0]
src_locs = probes["coords_s3"][0, 0] / 100.0
det_locs = probes["coords_d3"][0, 0] / 100.0
ch_locs = probes["coords_c3"][0, 0] / 100.0
if probe_format == "json":
with open(files["probeInfo"], encoding="utf-8") as _f:
_probe_json = json.load(_f)
probes = _probe_json["probeInfo"]["probes"]
requested_channels = np.array(probes["index_c"])
src_locs = np.array(probes["coords_s3"]) / 1000.0
det_locs = np.array(probes["coords_d3"]) / 1000.0
ch_locs = np.array(probes["coords_c3"]) / 1000.0
if _probe_json["probeInfo"]["head_circumference"] != 610.0:
warn(
"The head circumference in the probeInfo.json file is "
f"{_probe_json['probeInfo']['head_circumference']} mm, "
"as opposed to standard 610.0 mm. This may cause misalignment of "
"the optode locations with the subject's head in later steps."
)
else:
mat_data = loadmat(files["probeInfo"])
probes = mat_data["probeInfo"]["probes"][0, 0]
requested_channels = probes["index_c"][0, 0]
src_locs = probes["coords_s3"][0, 0] / 100.0
det_locs = probes["coords_d3"][0, 0] / 100.0
ch_locs = probes["coords_c3"][0, 0] / 100.0

# These are all in MNI coordinates, so let's transform them to
# the Neuromag head coordinate frame
Expand Down Expand Up @@ -425,11 +454,11 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No
# The detector location is stored in the third 3 entries of loc.
# NIRx NIRSite uses MNI coordinates.
# Also encode the light frequency in the structure.
for ch_idx2 in range(requested_channels.shape[0]):
for ch_idx2, wl_idx in enumerate(req_ind):
# Find source and store location
src = int(requested_channels[ch_idx2, 0]) - 1
src = int(sources[wl_idx]) - 1
# Find detector and store location
det = int(requested_channels[ch_idx2, 1]) - 1
det = int(detectors[wl_idx]) - 1
# Store channel location as midpoint between source and detector.
midpoint = (src_locs[src, :] + det_locs[det, :]) / 2
for ii in range(2):
Expand Down Expand Up @@ -508,6 +537,9 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No
# Read triggers from event file
if not is_aurora:
files["tri"] = files["hdr"][:-3] + "evt"
else:
tri_files = glob.glob(f"{fname}/*.tri")
files["tri"] = tri_files[0] if len(tri_files) == 1 else ""
if op.isfile(files["tri"]):
with _open(files["tri"]) as fid:
t = [re.findall(r"(\d+)", line) for line in fid]
Expand Down
36 changes: 36 additions & 0 deletions mne/io/nirx/tests/test_nirx.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
nirsport2 = testing_path / "NIRx" / "nirsport_v2" / "aurora_recording _w_short_and_acc"
nirsport2_2021_9 = testing_path / "NIRx" / "nirsport_v2" / "aurora_2021_9"
nirsport2_2021_9_6 = testing_path / "NIRx" / "nirsport_v2" / "aurora_2021_9_6"
nirsport2_2025_2 = testing_path / "NIRx" / "nirsport_v2" / "aurora_2025_2"


def test_nirsport_v2_matches_snirf(nirx_snirf):
Expand Down Expand Up @@ -551,6 +552,41 @@ def test_nirx_aurora_2021_9_6():
assert raw.annotations.description[2] == "3.0"


@requires_testing_data
@pytest.mark.filterwarnings("ignore:.*Extraction of measurement.*:")
def test_nirx_aurora_2025_2():
"""Test reading Aurora dataset with probeInfo.json."""
raw = read_raw_nirx(nirsport2_2025_2, preload=True)

# 50 channels × 2 wavelengths
assert raw._data.shape[0] == 100

# Test triggers
assert len(raw.annotations) == 10
assert (raw.annotations.description == "1.0").sum() == 5
assert (raw.annotations.description == "2.0").sum() == 5

# Test detector locations derived from probeInfo.json (MNI coords in mm)
allowed_dist_error = 0.0002
locs = [ch["loc"][6:9] for ch in raw.info["chs"]]
head_mri_t, _ = _get_trans("fsaverage", "head", "mri")
mni_locs = apply_trans(head_mri_t, locs)

assert raw.info["ch_names"][0][3:5] == "D1"
assert_allclose(
mni_locs[0], [-0.035971, 0.027644, 0.077821], atol=allowed_dist_error
)

# Test source locations
locs = [ch["loc"][3:6] for ch in raw.info["chs"]]
mni_locs = apply_trans(head_mri_t, locs)

assert raw.info["ch_names"][0][:2] == "S1"
assert_allclose(
mni_locs[0], [-0.02948, 0.060438, 0.057351], atol=allowed_dist_error
)


@requires_testing_data
def test_nirx_15_0():
"""Test reading NIRX files."""
Expand Down
Loading