Skip to content
Closed
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/python_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ Projections:
annotate_flat
annotate_movement
annotate_muscle_zscore
annotate_nan
compute_average_dev_head_t
compute_current_source_density
compute_fine_calibration
Expand Down
4 changes: 2 additions & 2 deletions mne/datasets/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ def _data_path(path=None, force_update=False, update_path=True, download=True,
path = _get_path(path, key, name)
# To update the testing or misc dataset, push commits, then make a new
# release on GitHub. Then update the "releases" variable:
releases = dict(testing='0.110', misc='0.7')
releases = dict(testing='0.111', misc='0.7')
# And also update the "md5_hashes['testing']" variable below.
# To update any other dataset, update the data archive itself (upload
# an updated version) and update the md5 hash.
Expand Down Expand Up @@ -331,7 +331,7 @@ def _data_path(path=None, force_update=False, update_path=True, download=True,
sample='12b75d1cb7df9dfb4ad73ed82f61094f',
somato='32fd2f6c8c7eb0784a1de6435273c48b',
spm='9f43f67150e3b694b523a21eb929ea75',
testing='c4cd3385f321cd1151ed9de34fc4ce5a',
testing='e7ece4615882b99026edb76fb708a3ce',
multimodal='26ec847ae9ab80f58f204d09e2c08367',
fnirs_motor='c4935d19ddab35422a69f3326a01fef8',
opm='370ad1dcfd5c47e029e692c85358a374',
Expand Down
91 changes: 82 additions & 9 deletions mne/io/nirx/nirx.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@


@fill_doc
def read_raw_nirx(fname, preload=False, verbose=None):
def read_raw_nirx(fname, saturated='ignore', preload=False, verbose=None):
"""Reader for a NIRX fNIRS recording.

This function has only been tested with NIRScout devices.

Parameters
----------
fname : str
Path to the NIRX data folder or header file.
saturated : str
(Only relevant for NIRSport1 devices). If 'ignore' (default),
use *.nosatflags_wlX instead of standard *.wlX files. If 'nan',
use standard *.wlX files. Irrelevant if there is no *.nosatflags file.
%(preload)s
%(verbose)s

Expand All @@ -40,8 +42,22 @@ def read_raw_nirx(fname, preload=False, verbose=None):
See Also
--------
mne.io.Raw : Documentation of attribute and methods.

Notes
-----
This function has only been tested with NIRScout and NIRSport1 devices.

- Re: saturated flag
The NIRSport probes can saturate during the experiment. Starting from
NIRStar 14.2, those saturated values are replaced by NaN in the
standard *.wlX files. The measured values are stored in another file
called *.nosatflags_wlX, which is a copy of the corresponding *.wlX
file where the saturated data didn't get replaced. Since NaN values can
cause unexpected behaviour with mathematical functions, you can chose
to use the original, non-modified data by setting the ``saturated`` flag to
'ignore' (default) or set it to 'nan' to use NaN values.
"""
return RawNIRX(fname, preload, verbose)
return RawNIRX(fname, saturated, preload, verbose)


def _open(fname):
Expand All @@ -56,18 +72,39 @@ class RawNIRX(BaseRaw):
----------
fname : str
Path to the NIRX data folder or header file.
saturated : str
(Only relevant for NIRSport1 devices). If 'ignore' (default),
use *.nosatflags_wlX instead of standard *.wlX files. If 'nan',
use standard *.wlX files. Irrelevant if there is no *.nosatflags file.

%(preload)s
%(verbose)s

See Also
--------
mne.io.Raw : Documentation of attribute and methods.

Notes
-----
This function has only been tested with NIRScout and NIRSport1 devices.

- Re: saturated flag
The NIRSport probes can saturate during the experiment. Starting from
NIRStar 14.2, those saturated values are replaced by NaN in the
standard *.wlX files. The measured values are stored in another file
called *.nosatflags_wlX, which is a copy of the corresponding *.wlX
file where the saturated data didn't get replaced. Since NaN values can
cause unexpected behaviour with mathematical functions, you can chose
to use the original, non-modified data by setting the ``saturated`` flag of
the read_raw_nirx() method to 'ignore' (default) or set it to 'nan' to
use NaN values.
"""

@verbose
def __init__(self, fname, preload=False, verbose=None):
def __init__(self, fname, saturated, preload=False, verbose=None):
from ...externals.pymatreader import read_mat
from ...coreg import get_mni_fiducials # avoid circular import prob
from ...preprocessing import annotate_nan # avoid circular import prob
logger.info('Loading %s' % fname)

if fname.endswith('.hdr'):
Expand All @@ -83,9 +120,40 @@ def __init__(self, fname, preload=False, verbose=None):
for key in keys:
files[key] = glob.glob('%s/*%s' % (fname, key))
if len(files[key]) != 1:
raise RuntimeError('Expect one %s file, got %d' %
(key, len(files[key]),))
files[key] = files[key][0]
if (key == 'wl1' or key == 'wl2') \
and len(glob.glob('%s/*%s' %
(fname, 'nosatflags_' + key))) == 1:
if saturated == 'nan':
warn('You provided saturated data and specified '
'to use the standard *.wlX files.')
files[key] = files[key][1]
elif saturated == 'annotate':
warn('You provided saturated data and specified '
'to annotate your data with a \'nan\' flag.')
files[key] = files[key][1]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can't annotate data yet (since there is not), so I'm wondering when we should do it. Or leave the feature for later ?

Copy link
Member

Choose a reason for hiding this comment

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

Are the data actually stored on disk as NaN, or are they just pegged to the A/D max or min value? If they're actually stored on disk as NaN, for the first version of this reader I would just read these directly as np.nan, and in a separate PR we can add a separate function annotate_nan or add a kwarg to this reader (not sure which, let's decide later)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, the data is actually stored as NaN, and is read this way. The problems rise when one tries to plot such data.

Copy link
Member

Choose a reason for hiding this comment

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

In that case we shouldn't even have a saturated argument. The reader should just set these to NaN, and then people should make use of mne.preprocessing.annotate_nan or something else to fix them

Copy link
Contributor Author

@swy7ch swy7ch Jul 21, 2020

Choose a reason for hiding this comment

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

Fair enough. I'll remove the flags in read_raw_nirx() then. Shall I keep the warning concerning NaN, though ?

I also realized the data files that are read by default are the *.nosatflags_wlX (without NaN), not the *.wlX. Following your answer, I guess we shall let the user deal with these files and provide only one (be it *.wlX or *.nosatflags_wlX, it's up to them) ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

1 is already implemented (actually, the default behaviour, see this comment

2 is also implemented with the 1st commit of this PR, but leads to problems with plotting (as matplotlib cannot plot np.nan)

Maybe for now let's go with just 1 and 2, and see if we end up needing 3 eventually?

If you want to implement 1 and 2 first, only the first 2 commits are relevant. I'd just need to provide some data if it is really needed.

--

3 is dealt with in this PR's 3rd commit, so I'll finish the work (in another PR if needed, no problem !)

Regarding annotate_nan(), I'd say it's a convenient way to deal with NaN at any step of the process. We can force the annotation in the reader (even though the process would be quite the same, I think) since people will need to do that anyway if they want to plot something

Copy link
Member

Choose a reason for hiding this comment

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

We can force the annotation in the reader (even though the process would be quite the same, I think) since people will need to do that anyway if they want to plot something

The difference would be that the actual data values in the annotated ranges would be different between solution (3) and (2)-followed-by-annotate_nan. In the former they wouldn't be nan, and in the latter they would be. Not sure if this would be helpful or meaningful for people. But yes feel free to add it if you want.

It does lead to a nice way of testing things, namely that you should be able to ensure that the raw.get_data() from (1) matches that of (3); that the annotations of (2)-plus-annotate_nan match that the annotations of (3); and that the nan locations of (2) match the annotations in (2) (and implicitly (3) by the previous check).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It does lead to a nice way of testing things, namely that you should be able to ensure that the raw.get_data() from (1) matches that of (3); that the annotations of (2)-plus-annotate_nan match that the annotations of (3); and that the nan locations of (2) match the annotations in (2) (and implicitly (3) by the previous check).

Lovely :D

We could wait for @rob-luke 's opinion on the subject, but I'm in to work on it : are you OK with 1, 2 and 3 as stated above by @larsoner ?

Copy link
Member

Choose a reason for hiding this comment

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

@swy7ch I would say go forward with the plan. If we can get it done in the next couple of weeks it'll make it into the upcoming 0.21 release

Copy link
Contributor Author

@swy7ch swy7ch Aug 26, 2020

Choose a reason for hiding this comment

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

I think I've come with something usable. I did test it on several "official" datasets from one of our studies, and everything seemed OK. Unfortunately I can't provide sample data, since my tutor is not here today and I don't know how to use NIRSport. Still need to provide tests for preprocessing.annotate_nan too, I'll work on it today. Just realized we need sample data to perform tests, woops !

I'll try to get some sample data before my intership ends on Friday, and update the PR if needed. My tutor knows about the PR though, so he may be able to help you out if we're short !

else:
if saturated == 'ignore':
warn('The data you provided contains NaN entries '
'which were put by NIRStar in the *.wlX '
'files. You chose to ignore them and use the '
'*.nosatflags_wlX files instead. You can '
'change this behaviour by setting '
'``saturated`` to "nan" when calling '
'read_raw_nirx()')
else:
warn('The value specified for ``saturated`` is '
'not recognized. Falling back to default '
'behaviour and using *.nosatflags_wlX files. '
'You can change this behaviour by setting '
'``saturated`` to "nan" when calling '
'read_raw_nirx()')
files[key] = glob.glob('%s/*%s' %
(fname, 'nosatflags_' + key))[0]
else:
raise RuntimeError('Expect one %s file, got %d' %
(key, len(files[key]),))
else:
files[key] = files[key][0]
if len(glob.glob('%s/*%s' % (fname, 'dat'))) != 1:
warn("A single dat file was expected in the specified path, but "
"got %d. This may indicate that the file structure has been "
Expand All @@ -111,7 +179,8 @@ def __init__(self, fname, preload=False, verbose=None):
if hdr['GeneralInfo']['NIRStar'] not in ['"15.0"', '"15.2"', '"15.3"']:
raise RuntimeError('MNE does not support this NIRStar version'
' (%s)' % (hdr['GeneralInfo']['NIRStar'],))
if "NIRScout" not in hdr['GeneralInfo']['Device']:
if "NIRScout" not in hdr['GeneralInfo']['Device'] \
and "NIRSport" not in hdr['GeneralInfo']['Device']:
warn("Only import of data from NIRScout devices have been "
"thoroughly tested. You are using a %s device. " %
hdr['GeneralInfo']['Device'])
Expand Down Expand Up @@ -320,6 +389,10 @@ def prepend(li, str):
annot = Annotations(onset, duration, description)
self.set_annotations(annot)

if saturated == "annotate":
annot = annotate_nan(self)
self.set_annotations(annot)

def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
"""Read a segment of data from a file.

Expand Down
18 changes: 18 additions & 0 deletions mne/io/nirx/tests/test_nirx.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ def test_nirx_dat_warn(tmpdir):
read_raw_nirx(fname, preload=True)


@requires_testing_data
def test_nirx_nosatflags_v1_warn(tmpdir):
Copy link
Member

Choose a reason for hiding this comment

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

Great. Lets update with your real data once collected.

"""Test reading NIRSportv1 files with saturated data."""
shutil.copytree(fname_nirx_15_2_short, str(tmpdir) + "/data/")
shutil.copyfile(str(tmpdir) + "/data" + "/NIRS-2019-08-23_001.wl1",
str(tmpdir) + "/data" +
"/NIRS-2019-08-23_001.nosatflags_wl1")
fname = str(tmpdir) + "/data" + "/NIRS-2019-08-23_001.hdr"
with pytest.raises(RuntimeWarning, match='specified to use the standard'):
read_raw_nirx(fname, saturated='nan', preload=True)
with pytest.raises(RuntimeWarning, match='specified to annotate your'):
read_raw_nirx(fname, saturated='annotate', preload=True)
with pytest.raises(RuntimeWarning, match='You chose to ignore them'):
read_raw_nirx(fname, saturated='ignore', preload=True)
with pytest.raises(RuntimeWarning, match='Falling back to default'):
read_raw_nirx(fname, saturated='foobar', preload=True)


@requires_testing_data
def test_nirx_15_2_short():
"""Test reading NIRX files."""
Expand Down
1 change: 1 addition & 0 deletions mne/preprocessing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@
from ._regress import regress_artifact
from ._fine_cal import (compute_fine_calibration, read_fine_calibration,
write_fine_calibration)
from .annotate_nan import annotate_nan
47 changes: 47 additions & 0 deletions mne/preprocessing/annotate_nan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Author: David Julien <david.julien@ifsttar.fr>
#
# License: BSD (3-clause)

import numpy as np

import warnings

from ..annotations import Annotations
from ..utils import _mask_to_onsets_offsets


def annotate_nan(raw):
"""Detect segments with NaN and return a new Annotations instance.

Parameters
----------
raw : instance of Raw
Data to find segments with NaN values.

Returns
-------
annot : instance of Annotations
Updated annotations for raw data.
"""
annot = raw.annotations.copy()
data, times = raw.get_data(return_times=True)
sampling_duration = 1 / raw.info['sfreq']

nans = np.any(np.isnan(data), axis=0)
starts, stops = _mask_to_onsets_offsets(nans)

if len(starts) > 0:
starts, stops = np.array(starts), np.array(stops)
onsets = (starts + raw.first_samp) * sampling_duration
durations = (stops - starts) * sampling_duration
else:
warnings.warn("The dataset you provided does not contain 'NaN' "
"values. No annotation were made.")
return

if annot is None:
annot = Annotations(onsets, durations, 'bad_NAN')
else:
annot.append(onsets, durations, 'bad_NAN')

return annot