Skip to content

Commit

Permalink
MRG: Add "description" entity to BIDSPath (#1057)
Browse files Browse the repository at this point in the history
* Add "description" entity to BIDSPath

Fixes #1049

* Remove unused import

* Apply suggestions from code review

* Docstrings

* Update changelog

* Fix _filter_fnames()

* Fix logic / typos

* Update mne_bids/path.py

Co-authored-by: Alexandre Gramfort <alexandre.gramfort@m4x.org>

* Update docstring

* desc comes last

* Update mne_bids/path.py

* Update mne_bids/path.py

* Update mne_bids/path.py

* Fix

* Add test for setting entities via = assignment

* Add newline

Co-authored-by: Alexandre Gramfort <alexandre.gramfort@m4x.org>
  • Loading branch information
hoechenberger and agramfort committed Aug 29, 2022
1 parent 7eb37ef commit c5c6ab9
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 46 deletions.
2 changes: 2 additions & 0 deletions doc/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ Detailed list of changes

- TSV files that are empty (i.e., only a header row is present) are now handled more robustly and a warning is issued, by `Stefan Appelhoff`_ (:gh:`1038`)

- :class:`~mne_bids.BIDSPath` now supports the BIDS "description" entity ``desc``, used in derivative data, by `Richard Höchenberger`_ (:gh:`1049`)

🧐 API and behavior changes
^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
7 changes: 4 additions & 3 deletions mne_bids/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,13 @@
# allowed BIDSPath entities
ALLOWED_PATH_ENTITIES = ('subject', 'session', 'task', 'run',
'processing', 'recording', 'space',
'acquisition', 'split',
'acquisition', 'split', 'description',
'suffix', 'extension')
ALLOWED_PATH_ENTITIES_SHORT = {'sub': 'subject', 'ses': 'session',
'task': 'task', 'acq': 'acquisition',
'run': 'run', 'proc': 'processing',
'space': 'space', 'rec': 'recording',
'split': 'split'}
'split': 'split', 'desc': 'description'}

# Annotations to never remove during reading or writing
ANNOTATIONS_TO_KEEP = ('BAD_ACQ_SKIP',)
Expand Down Expand Up @@ -246,8 +246,9 @@
'space': 'label',
'acquisition': 'label',
'split': 'index',
'description': 'label',
'suffix': 'label',
'extension': 'label'
'extension': 'label',
}

# mapping from supported BIDs coordinate frames -> MNE
Expand Down
85 changes: 56 additions & 29 deletions mne_bids/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import re
from io import StringIO
import shutil as sh
from collections import OrderedDict
from copy import deepcopy
from os import path as op
from pathlib import Path
Expand Down Expand Up @@ -182,6 +181,12 @@ class BIDSPath(object):
split : int | None
The split of the continuous recording file for ``.fif`` data.
Corresponds to "split".
description : str | None
This corresponds to the BIDS entity ``desc``. It is used to provide
additional information for derivative data, e.g., preprocessed data
may be assigned ``description='cleaned'``.
.. versionadded:: 0.11
suffix : str | None
The filename suffix. This is the entity after the
last ``_`` before the extension. E.g., ``'channels'``.
Expand All @@ -203,10 +208,10 @@ class BIDSPath(object):
Attributes
----------
entities : dict
The dictionary of the BIDS entities and their values:
A dictionary of the BIDS entities and their values:
``subject``, ``session``, ``task``, ``acquisition``,
``run``, ``processing``, ``space``, ``recording``, ``split``,
``suffix``, and ``extension``.
``run``, ``processing``, ``space``, ``recording``,
``split``, ``description``, ``suffix``, and ``extension``.
datatype : str | None
The data type, i.e., one of ``'meg'``, ``'eeg'``, ``'ieeg'``,
``'anat'``.
Expand Down Expand Up @@ -288,36 +293,38 @@ class BIDSPath(object):

def __init__(self, subject=None, session=None,
task=None, acquisition=None, run=None, processing=None,
recording=None, space=None, split=None, root=None,
suffix=None, extension=None, datatype=None, check=True):
recording=None, space=None, split=None, description=None,
root=None, suffix=None, extension=None,
datatype=None, check=True):
if all(ii is None for ii in [subject, session, task,
acquisition, run, processing,
recording, space, root, suffix,
extension]):
recording, space, description,
root, suffix, extension]):
raise ValueError("At least one parameter must be given.")

self.check = check

self.update(subject=subject, session=session, task=task,
acquisition=acquisition, run=run, processing=processing,
recording=recording, space=space, split=split,
root=root, datatype=datatype,
description=description, root=root, datatype=datatype,
suffix=suffix, extension=extension)

@property
def entities(self):
"""Return dictionary of the BIDS entities."""
return OrderedDict([
('subject', self.subject),
('session', self.session),
('task', self.task),
('acquisition', self.acquisition),
('run', self.run),
('processing', self.processing),
('space', self.space),
('recording', self.recording),
('split', self.split)
])
return {
'subject': self.subject,
'session': self.session,
'task': self.task,
'acquisition': self.acquisition,
'run': self.run,
'processing': self.processing,
'space': self.space,
'recording': self.recording,
'split': self.split,
'description': self.description,
}

@property
def basename(self):
Expand Down Expand Up @@ -442,6 +449,15 @@ def space(self) -> Optional[str]:
def space(self, value):
self.update(space=value)

@property
def description(self) -> Optional[str]:
"""The description entity."""
return self._description

@description.setter
def description(self, value):
self.update(description=value)

@property
def suffix(self) -> Optional[str]:
"""The filename suffix."""
Expand Down Expand Up @@ -617,9 +633,8 @@ def fpath(self):
def update(self, *, check=None, **kwargs):
"""Update inplace BIDS entity key/value pairs in object.
``run`` and ``split`` are auto-parsed to have two
numbers when passed in. For example, if ``run=1``, then it will
become ``run='01'``.
``run`` and ``split`` are auto-converted to have two
digits. For example, if ``run=1``, then it will nbecome ``run='01'``.
Also performs error checks on various entities to
adhere to the BIDS specification. Specifically:
Expand Down Expand Up @@ -1353,7 +1368,8 @@ def get_entities_from_fname(fname, on_error='raise', verbose=None):
'processing': None, \
'space': None, \
'recording': None, \
'split': None}
'split': None, \
'description': None}
"""
if on_error not in ('warn', 'raise', 'ignore'):
raise ValueError(f'Acceptable values for on_error are: warn, raise, '
Expand Down Expand Up @@ -1540,7 +1556,8 @@ def get_entity_vals(root, entity_key, *, ignore_subjects='emptyroom',
ignore_sessions=None, ignore_tasks=None, ignore_runs=None,
ignore_processings=None, ignore_spaces=None,
ignore_acquisitions=None, ignore_splits=None,
ignore_modalities=None, ignore_datatypes=None,
ignore_descriptions=None, ignore_modalities=None,
ignore_datatypes=None,
ignore_dirs=('derivatives', 'sourcedata'), with_key=False,
verbose=None):
"""Get list of values associated with an `entity_key` in a BIDS dataset.
Expand Down Expand Up @@ -1585,6 +1602,10 @@ def get_entity_vals(root, entity_key, *, ignore_subjects='emptyroom',
Acquisition(s) to ignore. If ``None``, include all acquisitions.
ignore_splits : str | array-like of str | None
Split(s) to ignore. If ``None``, include all splits.
ignore_descriptions : str | array-like of str | None
Description(s) to ignore. If ``None``, include all descriptions.
.. versionadded:: 0.11
ignore_modalities : str | array-like of str | None
Modalities(s) to ignore. If ``None``, include all modalities.
ignore_datatypes : str | array-like of str | None
Expand Down Expand Up @@ -1637,9 +1658,9 @@ def get_entity_vals(root, entity_key, *, ignore_subjects='emptyroom',
root = Path(root).expanduser()

entities = ('subject', 'task', 'session', 'run', 'processing', 'space',
'acquisition', 'split', 'suffix')
'acquisition', 'split', 'description', 'suffix')
entities_abbr = ('sub', 'task', 'ses', 'run', 'proc', 'space', 'acq',
'split', 'suffix')
'split', 'desc', 'suffix')
entity_long_abbr_map = dict(zip(entities, entities_abbr))

if entity_key not in entities:
Expand All @@ -1654,6 +1675,7 @@ def get_entity_vals(root, entity_key, *, ignore_subjects='emptyroom',
ignore_spaces = _ensure_tuple(ignore_spaces)
ignore_acquisitions = _ensure_tuple(ignore_acquisitions)
ignore_splits = _ensure_tuple(ignore_splits)
ignore_descriptions = _ensure_tuple(ignore_descriptions)
ignore_modalities = _ensure_tuple(ignore_modalities)

ignore_dirs = _ensure_tuple(ignore_dirs)
Expand Down Expand Up @@ -1702,6 +1724,9 @@ def get_entity_vals(root, entity_key, *, ignore_subjects='emptyroom',
if ignore_splits and any([f'_split-{s}_' in filename.stem
for s in ignore_splits]):
continue
if ignore_descriptions and any([f'_desc-{d}_' in filename.stem
for d in ignore_descriptions]):
continue
if ignore_modalities and any([f'_{k}' in filename.stem
for k in ignore_modalities]):
continue
Expand Down Expand Up @@ -1827,7 +1852,8 @@ def _path_to_str(var):

def _filter_fnames(fnames, *, subject=None, session=None, task=None,
acquisition=None, run=None, processing=None, recording=None,
space=None, split=None, suffix=None, extension=None):
space=None, split=None, description=None, suffix=None,
extension=None):
"""Filter a list of BIDS filenames / paths based on BIDS entity values.
Parameters
Expand All @@ -1849,14 +1875,15 @@ def _filter_fnames(fnames, *, subject=None, session=None, task=None,
rec_str = f'_rec-{recording}' if recording else r'(|_rec-([^_]+))'
space_str = f'_space-{space}' if space else r'(|_space-([^_]+))'
split_str = f'_split-{split}' if split else r'(|_split-([^_]+))'
desc_str = f'_desc-{description}' if description else r'(|_desc-([^_]+))'
suffix_str = (f'_{suffix}' if suffix
else r'_(' + '|'.join(ALLOWED_FILENAME_SUFFIX) + ')')
ext_str = extension if extension else r'.([^_]+)'

regexp = (
leading_path_str +
sub_str + ses_str + task_str + acq_str + run_str + proc_str +
rec_str + space_str + split_str + suffix_str + ext_str
rec_str + space_str + split_str + desc_str + suffix_str + ext_str
)

# Convert to str so we can apply the regexp ...
Expand Down
50 changes: 36 additions & 14 deletions mne_bids/tests/test_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,11 @@ def test_get_bids_path_from_fname(fname):


@pytest.mark.parametrize('fname', [
'sub-01_ses-02_task-test_run-3_split-01_meg.fif',
'sub-01_ses-02_task-test_run-3_split-01.fif',
'sub-01_ses-02_task-test_run-3_split-01',
'sub-01_ses-02_task-test_run-3_split-01_desc-filtered_meg.fif',
'sub-01_ses-02_task-test_run-3_split-01_desc-filtered.fif',
'sub-01_ses-02_task-test_run-3_split-01_desc-filtered',
('/bids_root/sub-01/ses-02/meg/' +
'sub-01_ses-02_task-test_run-3_split-01_meg.fif'),
'sub-01_ses-02_task-test_run-3_split-01_desc-filtered_meg.fif'),
])
def test_get_entities_from_fname(fname):
"""Test parsing entities from a bids filename."""
Expand All @@ -292,25 +292,28 @@ def test_get_entities_from_fname(fname):
assert params['session'] == '02'
assert params['run'] == '3'
assert params['task'] == 'test'
assert params['description'] == 'filtered'
assert params['split'] == '01'
assert list(params.keys()) == ['subject', 'session', 'task',
'acquisition', 'run', 'processing',
'space', 'recording', 'split']
assert list(params.keys()) == [
'subject', 'session', 'task',
'acquisition', 'run', 'processing',
'space', 'recording', 'split', 'description',
]


@pytest.mark.parametrize('fname', [
'sub-01_ses-02_task-test_run-3_split-01_meg.fif',
('/bids_root/sub-01/ses-02/meg/'
'sub-01_ses-02_task-test_run-3_split-01_meg.fif'),
'sub-01_ses-02_task-test_run-3_split-01_desc-tfr_meg.fif',
'sub-01_ses-02_task-test_run-3_split-01_foo-tfr_meg.fif',
])
def test_get_entities_from_fname_errors(fname):
"""Test parsing entities from bids filename.
Extends utility for not supported BIDS entities, such
as 'description'.
as 'foo'.
"""
if 'desc' in fname:
if 'foo' in fname:
with pytest.raises(KeyError, match='Unexpected entity'):
params = get_entities_from_fname(fname, on_error='raise')
with pytest.warns(RuntimeWarning, match='Unexpected entity'):
Expand All @@ -321,16 +324,16 @@ def test_get_entities_from_fname_errors(fname):

expected_keys = ['subject', 'session', 'task',
'acquisition', 'run', 'processing',
'space', 'recording', 'split']
'space', 'recording', 'split', 'description']

assert params['subject'] == '01'
assert params['session'] == '02'
assert params['run'] == '3'
assert params['task'] == 'test'
assert params['split'] == '01'
if 'desc' in fname:
assert params['desc'] == 'tfr'
expected_keys.append('desc')
if 'foo' in fname:
assert params['foo'] == 'tfr'
expected_keys.append('foo')
assert list(params.keys()) == expected_keys


Expand Down Expand Up @@ -1142,3 +1145,22 @@ def test_update_fail_check_no_change():
except Exception:
pass
assert bids_path.suffix is None


def test_setting_entities():
"""Test setting entities via assignment."""
bids_path = BIDSPath(subject='test', check=False)
for entity_name in bids_path.entities:
if entity_name in ['dataype', 'suffix']:
continue

if entity_name in ['run', 'split']:
value = '1'
else:
value = 'foo'

setattr(bids_path, entity_name, value)
assert getattr(bids_path, entity_name) == value

setattr(bids_path, entity_name, None)
assert getattr(bids_path, entity_name) is None

0 comments on commit c5c6ab9

Please sign in to comment.