Skip to content

Commit e05fa2f

Browse files
MRG: Make use of AssociatedEmptyRoom field in *_meg.json (#795)
* Make use of AssociatedEmptyRoom field in *_meg.json Fixes #493 * Remove new property * flake * Apply suggestions from code review * Fix * Missing whitespace * Update changelog * Try to fix Windows * Another attempt to fix Windows * Hopefully fix Windows tests * Reformat
1 parent d1fb506 commit e05fa2f

File tree

5 files changed

+184
-21
lines changed

5 files changed

+184
-21
lines changed

doc/whats_new.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Enhancements
4141
- :func:`mne_bids.write_raw_bids` gained a new keyword argument ``symlink``, which allows to create symbolic links to the original data files instead of copying them over. Currently works for ``FIFF`` files on macOS and Linux, by `Richard Höchenberger`_ (:gh:`778`)
4242
- :class:`mne_bids.BIDSPath` now has property getter and setter methods for all BIDS entities, i.e., you can now do things like ``bids_path.subject = 'foo'`` and don't have to resort to ``bids_path.update()``. This also ensures you'll get proper completion suggestions from your favorite Python IDE, by `Richard Höchenberger`_ (:gh:`786`)
4343
- :func:`mne_bids.write_raw_bids` now stores information about continuous head localization measurements (e.g., Elekta/Neuromag cHPI) in the MEG sidecar file, by `Richard Höchenberger`_ (:gh:`794`)
44+
- :func:`mne_bids.write_raw_bids` gained a new parameter `empty_room` that allows to specify an associated empty-room recording when writing an MEG data file. This information will be stored in the ``AssociatedEmptyRoom`` field of the MEG JSON sidecar file, by `Richard Höchenberger`_ (:gh:`795`)
4445

4546
API and behavior changes
4647
^^^^^^^^^^^^^^^^^^^^^^^^
@@ -49,6 +50,7 @@ API and behavior changes
4950
- When writing BIDS datasets, MNE-BIDS now tags them as BIDS 1.6.0 (we previously tagged them as BIDS 1.4.0), by `Richard Höchenberger`_ (:gh:`782`)
5051
- :func:`mne_bids.read_raw_bids` now passes ``allow_maxshield=True`` to the MNE-Python reader function by default when reading FIFF files. Previously, ``extra_params=dict(allow_maxshield=True)`` had to be passed explicitly, by `Richard Höchenberger`_ (:gh:`#787`)
5152
- The ``raw_to_bids`` command has lost its ``--allow_maxshield`` parameter. If writing a FIFF file, we will now always assume that writing data before applying a Maxwell filter is fine, by `Richard Höchenberger`_ (:gh:`#787`)
53+
- :meth:`mne_bids.BIDSPath.find_empty_room` now first looks for an ``AssociatedEmptyRoom`` field in the MEG JSON sidecar file to retrieve the empty-room recording; only if this information is missing, it will proceed to try and find the best-matching empty-room recording based on measurement date (i.e., fall back to the previous behavior), by `Richard Höchenberger`_ (:gh:`#795`)
5254

5355
Requirements
5456
^^^^^^^^^^^^

mne_bids/path.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from os import path as op
1313
from pathlib import Path
1414
from datetime import datetime
15+
import json
1516
from typing import Optional, Union
1617

1718
import numpy as np
@@ -27,7 +28,7 @@
2728
param_regex, _ensure_tuple)
2829

2930

30-
def _get_matched_empty_room(bids_path):
31+
def _find_matched_empty_room(bids_path):
3132
"""Get matching empty-room file for an MEG recording."""
3233
# Check whether we have a BIDS root.
3334
bids_root = bids_path.root
@@ -43,11 +44,7 @@ def _get_matched_empty_room(bids_path):
4344
bids_fname = bids_path.update(suffix=datatype,
4445
root=bids_root).fpath
4546
_, ext = _parse_ext(bids_fname)
46-
extra_params = None
47-
if ext == '.fif':
48-
extra_params = dict(allow_maxshield=True)
49-
50-
raw = read_raw_bids(bids_path=bids_path, extra_params=extra_params)
47+
raw = read_raw_bids(bids_path=bids_path)
5148
if raw.info['meas_date'] is None:
5249
raise ValueError('The provided recording does not have a measurement '
5350
'date set. Cannot get matching empty-room file.')
@@ -863,14 +860,49 @@ def find_empty_room(self):
863860
This will only work if the ``.root`` attribute of the
864861
:class:`mne_bids.BIDSPath` instance has been set.
865862
863+
.. note:: If the sidecar JSON file contains an ``AssociatedEmptyRoom``
864+
entry, the empty-room recording specified there will be used.
865+
Otherwise, this method will try to find the best-matching
866+
empty-room recording based on measurement date.
867+
866868
Returns
867869
-------
868870
BIDSPath | None
869871
The path corresponding to the best-matching empty-room measurement.
870-
Returns None if none was found.
871-
872+
Returns ``None`` if none was found.
872873
"""
873-
return _get_matched_empty_room(self)
874+
if self.datatype not in ('meg', None):
875+
raise ValueError('Empty-room data is only supported for MEG '
876+
'datasets')
877+
878+
if self.root is None:
879+
raise ValueError('The root of the "bids_path" must be set. '
880+
'Please use `bids_path.update(root="<root>")` '
881+
'to set the root of the BIDS folder to read.')
882+
883+
sidecar_fname = _find_matching_sidecar(self, extension='.json')
884+
with open(sidecar_fname, 'r', encoding='utf-8') as f:
885+
sidecar_json = json.load(f)
886+
887+
if 'AssociatedEmptyRoom' in sidecar_json:
888+
logger.info('Using "AssociatedEmptyRoom" entry from MEG sidecar '
889+
'file to retrieve empty-room path.')
890+
emptytoom_path = sidecar_json['AssociatedEmptyRoom']
891+
emptyroom_entities = get_entities_from_fname(emptytoom_path)
892+
er_bids_path = BIDSPath(root=self.root, datatype='meg',
893+
**emptyroom_entities)
894+
else:
895+
logger.info(
896+
'The MEG sidecar file does not contain an '
897+
'"AssociatedEmptyRoom" entry. Will try to find a matching '
898+
'empty-room recording based on the measurement date …'
899+
)
900+
er_bids_path = _find_matched_empty_room(self)
901+
902+
if er_bids_path is not None:
903+
assert er_bids_path.fpath.exists()
904+
905+
return er_bids_path
874906

875907
@property
876908
def meg_calibration_fpath(self):

mne_bids/tests/test_path.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -821,7 +821,7 @@ def test_find_empty_room(return_bids_test_dir, tmpdir):
821821
match='The root of the "bids_path" must be set'):
822822
bids_path.copy().update(root=None).find_empty_room()
823823

824-
# assert that we get error if meas_date is not available.
824+
# assert that we get an error if meas_date is not available.
825825
raw = read_raw_bids(bids_path=bids_path)
826826
raw.set_meas_date(None)
827827
anonymize_info(raw.info)
@@ -830,6 +830,51 @@ def test_find_empty_room(return_bids_test_dir, tmpdir):
830830
'have a measurement date set'):
831831
bids_path.find_empty_room()
832832

833+
# test that the `AssociatedEmptyRoom` key in MEG sidecar is respected
834+
835+
bids_root = tmpdir.mkdir('associated-empty-room')
836+
raw = _read_raw_fif(raw_fname)
837+
meas_date = datetime(year=2020, month=1, day=10, tzinfo=timezone.utc)
838+
er_date = datetime(year=2010, month=1, day=1, tzinfo=timezone.utc)
839+
raw.set_meas_date(meas_date)
840+
841+
er_raw_matching_date = er_raw.copy().set_meas_date(meas_date)
842+
er_raw_associated = er_raw.copy().set_meas_date(er_date)
843+
844+
# First write empty-room data
845+
# We write two empty-room recordings: one with a date matching exactly the
846+
# experimental measurement date, and one dated approx. 10 years earlier
847+
# We will want to enforce using the older recording via
848+
# `AssociatedEmptyRoom` (without AssociatedEmptyRoom, find_empty_room()
849+
# would return the recording with the matching date instead)
850+
er_matching_date_bids_path = BIDSPath(
851+
subject='emptyroom', session='20200110', task='noise', root=bids_root,
852+
datatype='meg', suffix='meg', extension='.fif')
853+
write_raw_bids(er_raw_matching_date, bids_path=er_matching_date_bids_path)
854+
855+
er_associated_bids_path = (er_matching_date_bids_path.copy()
856+
.update(session='20100101'))
857+
write_raw_bids(er_raw_associated, bids_path=er_associated_bids_path)
858+
859+
# Now we write experimental data and associate it with the earlier
860+
# empty-room recording
861+
bids_path = (er_matching_date_bids_path.copy()
862+
.update(subject='01', session=None, task='task'))
863+
write_raw_bids(raw, bids_path=bids_path,
864+
empty_room=er_associated_bids_path)
865+
866+
# Retrieve empty-room BIDSPath
867+
assert bids_path.find_empty_room() == er_associated_bids_path
868+
869+
# Should only work for MEG
870+
with pytest.raises(ValueError, match='only supported for MEG'):
871+
bids_path.copy().update(datatype='eeg').find_empty_room()
872+
873+
# Don't create `AssociatedEmptyRoom` entry in sidecar – we should now
874+
# retrieve the empty-room recording closer in time
875+
write_raw_bids(raw, bids_path=bids_path, empty_room=None, overwrite=True)
876+
assert bids_path.find_empty_room() == er_matching_date_bids_path
877+
833878

834879
@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
835880
def test_find_emptyroom_ties(tmpdir):

mne_bids/tests/test_write.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ def test_fif(_bids_validate, tmpdir):
493493
# test that an incorrect date raises an error.
494494
er_bids_basename_bad = BIDSPath(subject='emptyroom', session='19000101',
495495
task='noise', root=bids_root)
496-
with pytest.raises(ValueError, match='Date provided'):
496+
with pytest.raises(ValueError, match='The date provided'):
497497
write_raw_bids(raw, er_bids_basename_bad, overwrite=False)
498498

499499
# test that the acquisition time was written properly
@@ -2736,3 +2736,38 @@ def test_symlink(tmpdir):
27362736
p = write_raw_bids(raw=raw, bids_path=bids_path, symlink=True)
27372737
raw = read_raw_bids(p)
27382738
assert len(raw.filenames) == 2
2739+
2740+
2741+
@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
2742+
def test_write_associated_emptyroom(_bids_validate, tmpdir):
2743+
"""Test functionality of the write_raw_bids conversion for fif."""
2744+
bids_root = tmpdir.mkdir('bids1')
2745+
data_path = testing.data_path()
2746+
raw_fname = op.join(data_path, 'MEG', 'sample',
2747+
'sample_audvis_trunc_raw.fif')
2748+
raw = _read_raw_fif(raw_fname)
2749+
meas_date = datetime(year=2020, month=1, day=10, tzinfo=timezone.utc)
2750+
raw.set_meas_date(meas_date)
2751+
2752+
# First write "empty-room" data
2753+
bids_path_er = BIDSPath(subject='emptyroom', session='20200110',
2754+
task='noise', root=bids_root, datatype='meg',
2755+
suffix='meg', extension='.fif')
2756+
write_raw_bids(raw, bids_path=bids_path_er)
2757+
2758+
# Now we write experimental data and associate it with the empty-room
2759+
# recording
2760+
bids_path = bids_path_er.copy().update(subject='01', session=None,
2761+
task='task')
2762+
write_raw_bids(raw, bids_path=bids_path, empty_room=bids_path_er)
2763+
_bids_validate(bids_path.root)
2764+
2765+
meg_json_path = bids_path.copy().update(extension='.json')
2766+
with open(meg_json_path, 'r') as fin:
2767+
meg_json_data = json.load(fin)
2768+
2769+
assert 'AssociatedEmptyRoom' in meg_json_data
2770+
assert (bids_path_er.fpath
2771+
.as_posix() # make test work on Windows, too
2772+
.endswith(meg_json_data['AssociatedEmptyRoom']))
2773+
assert meg_json_data['AssociatedEmptyRoom'].startswith('/')

mne_bids/write.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -564,8 +564,8 @@ def _mri_scanner_ras_to_mri_voxels(ras_landmarks, img_mgh):
564564
return vox_landmarks
565565

566566

567-
def _sidecar_json(raw, task, manufacturer, fname, datatype, overwrite=False,
568-
verbose=True):
567+
def _sidecar_json(raw, task, manufacturer, fname, datatype,
568+
emptyroom_fname=None, overwrite=False, verbose=True):
569569
"""Create a sidecar json file depending on the suffix and save it.
570570
571571
The sidecar json file provides meta data about the data
@@ -584,6 +584,9 @@ def _sidecar_json(raw, task, manufacturer, fname, datatype, overwrite=False,
584584
Filename to save the sidecar json to.
585585
datatype : str
586586
Type of the data as in ALLOWED_ELECTROPHYSIO_DATATYPE.
587+
emptyroom_fname : str | mne_bids.BIDSPath
588+
For MEG recordings, the path to an empty-room data file to be
589+
associated with ``raw``. Only supported for MEG.
587590
overwrite : bool
588591
Whether to overwrite the existing file.
589592
Defaults to False.
@@ -675,6 +678,7 @@ def _sidecar_json(raw, task, manufacturer, fname, datatype, overwrite=False,
675678
('SoftwareFilters', 'n/a'),
676679
('RecordingDuration', raw.times[-1]),
677680
('RecordingType', rec_type)]
681+
678682
ch_info_json_meg = [
679683
('DewarPosition', 'n/a'),
680684
('DigitizedLandmarks', digitized_landmark),
@@ -683,15 +687,21 @@ def _sidecar_json(raw, task, manufacturer, fname, datatype, overwrite=False,
683687
('MEGREFChannelCount', n_megrefchan),
684688
('ContinuousHeadLocalization', chpi),
685689
('HeadCoilFrequency', list(hpi_freqs))]
690+
691+
if emptyroom_fname is not None:
692+
ch_info_json_meg.append(('AssociatedEmptyRoom', str(emptyroom_fname)))
693+
686694
ch_info_json_eeg = [
687695
('EEGReference', 'n/a'),
688696
('EEGGround', 'n/a'),
689697
('EEGPlacementScheme', _infer_eeg_placement_scheme(raw)),
690698
('Manufacturer', manufacturer)]
699+
691700
ch_info_json_ieeg = [
692701
('iEEGReference', 'n/a'),
693702
('ECOGChannelCount', n_ecogchan),
694703
('SEEGChannelCount', n_seegchan)]
704+
695705
ch_info_ch_counts = [
696706
('EEGChannelCount', n_eegchan),
697707
('EOGChannelCount', n_eogchan),
@@ -960,6 +970,7 @@ def make_dataset_description(path, name, data_license=None,
960970
def write_raw_bids(raw, bids_path, events_data=None,
961971
event_id=None, anonymize=None,
962972
format='auto', symlink=False,
973+
empty_room=None,
963974
overwrite=False, verbose=True):
964975
"""Save raw data to a BIDS-compliant folder structure.
965976
@@ -1083,6 +1094,12 @@ def write_raw_bids(raw, bids_path, events_data=None,
10831094
Symlinks are currently only supported on macOS and Linux. We will
10841095
add support for Windows 10 at a later time.
10851096
1097+
empty_room : mne_bids.BIDSPath | None
1098+
The empty-room recording to be associated with this file. This is
1099+
only supported for MEG data, and only if the ``root`` attributes of
1100+
``bids_path`` and ``empty_room`` are the same. Pass ``None``
1101+
(default) if you do not wish to specify an associated empty-room
1102+
recording.
10861103
overwrite : bool
10871104
Whether to overwrite existing files or data in files.
10881105
Defaults to ``False``.
@@ -1189,6 +1206,9 @@ def write_raw_bids(raw, bids_path, events_data=None,
11891206
raise RuntimeError('You passed event_id, but no events_data NumPy '
11901207
'array. You need to pass both, or neither.')
11911208

1209+
_validate_type(item=empty_room, item_name='empty_room',
1210+
types=(BIDSPath, None))
1211+
11921212
raw = raw.copy()
11931213

11941214
raw_fname = raw.filenames[0]
@@ -1232,10 +1252,10 @@ def write_raw_bids(raw, bids_path, events_data=None,
12321252

12331253
# check whether the info provided indicates that the data is emptyroom
12341254
# data
1235-
emptyroom = False
1255+
data_is_emptyroom = False
12361256
if (bids_path.datatype == 'meg' and bids_path.subject == 'emptyroom' and
12371257
bids_path.task == 'noise'):
1238-
emptyroom = True
1258+
data_is_emptyroom = True
12391259
# check the session date provided is consistent with the value in raw
12401260
meas_date = raw.info.get('meas_date', None)
12411261
if meas_date is not None:
@@ -1244,13 +1264,40 @@ def write_raw_bids(raw, bids_path, events_data=None,
12441264
tz=timezone.utc)
12451265
er_date = meas_date.strftime('%Y%m%d')
12461266
if er_date != bids_path.session:
1247-
raise ValueError("Date provided for session doesn't match "
1248-
"session date.")
1267+
raise ValueError(
1268+
f"The date provided for the empty-room session "
1269+
f"({bids_path.session}) doesn't match the empty-room "
1270+
f"recording date found in the data's info structure "
1271+
f"({er_date})."
1272+
)
12491273
if anonymize is not None and 'daysback' in anonymize:
12501274
meas_date = meas_date - timedelta(anonymize['daysback'])
12511275
session = meas_date.strftime('%Y%m%d')
12521276
bids_path = bids_path.copy().update(session=session)
12531277

1278+
associated_er_path = None
1279+
if empty_room is not None:
1280+
if bids_path.datatype != 'meg':
1281+
raise ValueError('"empty_room" is only supported for '
1282+
'MEG data.')
1283+
if data_is_emptyroom:
1284+
raise ValueError('You cannot write empty-room data and pass '
1285+
'"empty_room" at the same time.')
1286+
if bids_path.root != empty_room.root:
1287+
raise ValueError('The MEG data and its associated empty-room '
1288+
'recording must share the same BIDS root.')
1289+
1290+
associated_er_path = empty_room.fpath
1291+
if not associated_er_path.exists():
1292+
raise FileNotFoundError(f'Empty-room data file not found: '
1293+
f'{associated_er_path}')
1294+
1295+
# Turn it into a path relative to the BIDS root
1296+
associated_er_path = Path(str(associated_er_path)
1297+
.replace(str(empty_room.root), ''))
1298+
# Ensure it works on Windows too
1299+
associated_er_path = associated_er_path.as_posix()
1300+
12541301
data_path = bids_path.mkdir().directory
12551302

12561303
# In case of an "emptyroom" subject, BIDSPath() will raise
@@ -1317,7 +1364,7 @@ def write_raw_bids(raw, bids_path, events_data=None,
13171364
_participants_json(participants_json_fname, True, verbose)
13181365

13191366
# for MEG, we only write coordinate system
1320-
if bids_path.datatype == 'meg' and not emptyroom:
1367+
if bids_path.datatype == 'meg' and not data_is_emptyroom:
13211368
_write_coordsystem_json(raw=raw, unit=unit, hpi_coord_system=orient,
13221369
sensor_coord_system=orient,
13231370
fname=coordsystem_path.fpath,
@@ -1333,7 +1380,7 @@ def write_raw_bids(raw, bids_path, events_data=None,
13331380
f'for data type "{bids_path.datatype}". Skipping ...')
13341381

13351382
# Write events.
1336-
if not emptyroom:
1383+
if not data_is_emptyroom:
13371384
events_array, event_dur, event_desc_id_map = _read_events(
13381385
events_data, event_id, raw, verbose=False
13391386
)
@@ -1351,8 +1398,10 @@ def write_raw_bids(raw, bids_path, events_data=None,
13511398
make_dataset_description(bids_path.root, name=" ", overwrite=False,
13521399
verbose=verbose)
13531400

1354-
_sidecar_json(raw, bids_path.task, manufacturer, sidecar_path.fpath,
1355-
bids_path.datatype, overwrite, verbose)
1401+
_sidecar_json(raw, task=bids_path.task, manufacturer=manufacturer,
1402+
fname=sidecar_path.fpath, datatype=bids_path.datatype,
1403+
emptyroom_fname=associated_er_path,
1404+
overwrite=overwrite, verbose=verbose)
13561405
_channels_tsv(raw, channels_path.fpath, overwrite, verbose)
13571406

13581407
# create parent directories if needed

0 commit comments

Comments
 (0)