Skip to content

Commit d1fb506

Browse files
MRG: Add cHPI info to MEG sidecars (#794)
* Add cHPI info to MEG sidecars Fixes #792 * Use warnings filter * Fix tests for MNE stable * Remove accidentally added version constraint * Call BIDS validator
1 parent d602f8c commit d1fb506

File tree

7 files changed

+133
-17
lines changed

7 files changed

+133
-17
lines changed

doc/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ numpydoc
66
matplotlib
77
pillow
88
pandas
9+
setuptools

doc/whats_new.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Enhancements
4040
- Add writing simultaneous EEG-iEEG recordings via :func:`mne_bids.write_raw_bids`. The desired output datatype must be specified in the :class:`mne_bids.BIDSPath` object, by `Richard Köhler`_ (:gh:`774`)
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`)
43+
- :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`)
4344

4445
API and behavior changes
4546
^^^^^^^^^^^^^^^^^^^^^^^^
@@ -53,6 +54,7 @@ Requirements
5354
^^^^^^^^^^^^
5455

5556
- For downloading `OpenNeuro <https://openneuro.org>`_ datasets, ``openneuro-py`` is now required to run the examples and build the documentation, by `Alex Rockhill`_ (:gh:`753`)
57+
- MNE-BIDS now depends on `setuptools <https://setuptools.readthedocs.io>`_. This package is normally installed by your Python distribution automatically, so we don't expect any users to be affected by this change, by `Richard Höchenberger`_ (:gh:`794`)
5658

5759
Bug fixes
5860
^^^^^^^^^

mne_bids/read.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ def _handle_scans_reading(scans_fname, raw, bids_path, verbose=False):
241241

242242

243243
def _handle_info_reading(sidecar_fname, raw, verbose=None):
244-
"""Read associated sidecar.json and populate raw.
244+
"""Read associated sidecar JSON and populate raw.
245245
246246
Handle PowerLineFrequency of recording.
247247
"""
@@ -268,6 +268,29 @@ def _handle_info_reading(sidecar_fname, raw, verbose=None):
268268
"Sidecar JSON is -> {} ".format(line_freq))
269269

270270
raw.info["line_freq"] = line_freq
271+
272+
# get cHPI info
273+
chpi = sidecar_json.get('ContinuousHeadLocalization')
274+
if chpi is None:
275+
# no cHPI info in the sidecar – leave raw.info unchanged
276+
pass
277+
elif chpi is True:
278+
hpi_freqs = sidecar_json['HeadCoilFrequency']
279+
hpi_freqs_data, _, _ = mne.chpi.get_chpi_info(raw.info)
280+
if not np.allclose(hpi_freqs, hpi_freqs_data):
281+
raise ValueError(
282+
f'The cHPI coil frequencies in the sidecar file '
283+
f'{sidecar_fname}:\n {hpi_freqs}\ndiffer from what is '
284+
f'stored in the raw data:\n {hpi_freqs_data}\n'
285+
f'Cannot proceed.'
286+
)
287+
else:
288+
if raw.info['hpi_subsystem']:
289+
logger.info('Dropping cHPI information stored in raw data, '
290+
'following specification in sidecar file')
291+
raw.info['hpi_subsystem'] = None
292+
raw.info['hpi_meas'] = []
293+
271294
return raw
272295

273296

mne_bids/tests/test_read.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
category=ImportWarning)
2323
import mne
2424
from mne.io.constants import FIFF
25-
from mne.utils import requires_nibabel, object_diff
25+
from mne.utils import requires_nibabel, object_diff, requires_version
2626
from mne.utils import assert_dig_allclose
2727
from mne.datasets import testing, somato
2828

@@ -59,11 +59,15 @@
5959
somato_raw_fname = op.join(somato_path, 'sub-01', 'meg',
6060
'sub-01_task-somato_meg.fif')
6161

62+
# Data with cHPI info
63+
raw_fname_chpi = op.join(data_path, 'SSS', 'test_move_anon_raw.fif')
64+
6265
warning_str = dict(
6366
channel_unit_changed='ignore:The unit for chann*.:RuntimeWarning:mne',
6467
meas_date_set_to_none="ignore:.*'meas_date' set to None:RuntimeWarning:"
6568
"mne",
6669
nasion_not_found='ignore:.*nasion not found:RuntimeWarning:mne',
70+
maxshield='ignore:.*Internal Active Shielding:RuntimeWarning:mne'
6771
)
6872

6973

@@ -418,7 +422,7 @@ def test_handle_scans_reading(tmpdir):
418422

419423
@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
420424
def test_handle_info_reading(tmpdir):
421-
"""Test reading information from a BIDS sidecar.json file."""
425+
"""Test reading information from a BIDS sidecar JSON file."""
422426
# read in USA dataset, so it should find 50 Hz
423427
raw = _read_raw_fif(raw_fname)
424428

@@ -502,6 +506,46 @@ def test_handle_info_reading(tmpdir):
502506
assert raw.info['line_freq'] == 55
503507

504508

509+
@requires_version('mne', '0.24.dev0')
510+
@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
511+
@pytest.mark.filterwarnings(warning_str['maxshield'])
512+
def test_handle_chpi_reading(tmpdir):
513+
"""Test reading of cHPI information."""
514+
raw = _read_raw_fif(raw_fname_chpi, allow_maxshield=True)
515+
root = tmpdir.mkdir('chpi')
516+
bids_path = BIDSPath(subject='01', session='01',
517+
task='audiovisual', run='01',
518+
root=root, datatype='meg')
519+
bids_path = write_raw_bids(raw, bids_path)
520+
521+
raw_read = read_raw_bids(bids_path)
522+
assert raw_read.info['hpi_subsystem'] is not None
523+
524+
# cause conflicts between cHPI info in sidecar and raw data
525+
meg_json_path = bids_path.copy().update(suffix='meg', extension='.json')
526+
with open(meg_json_path, 'r', encoding='utf-8') as f:
527+
meg_json_data = json.load(f)
528+
529+
# cHPI frequency mismatch
530+
meg_json_data_freq_mismatch = meg_json_data.copy()
531+
meg_json_data_freq_mismatch['HeadCoilFrequency'][0] = 123
532+
with open(meg_json_path, 'w', encoding='utf-8') as f:
533+
json.dump(meg_json_data_freq_mismatch, f)
534+
535+
with pytest.raises(ValueError, match='cHPI coil frequencies'):
536+
raw_read = read_raw_bids(bids_path)
537+
538+
# cHPI "off" according to sidecar, but present in the data
539+
meg_json_data_chpi_mismatch = meg_json_data.copy()
540+
meg_json_data_chpi_mismatch['ContinuousHeadLocalization'] = False
541+
with open(meg_json_path, 'w', encoding='utf-8') as f:
542+
json.dump(meg_json_data_chpi_mismatch, f)
543+
544+
raw_read = read_raw_bids(bids_path)
545+
assert raw_read.info['hpi_subsystem'] is None
546+
assert raw_read.info['hpi_meas'] == []
547+
548+
505549
@pytest.mark.filterwarnings(warning_str['nasion_not_found'])
506550
@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
507551
def test_handle_eeg_coords_reading(tmpdir):

mne_bids/tests/test_write.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
import json
2121
from pathlib import Path
2222
import codecs
23-
from distutils.version import LooseVersion
23+
24+
from pkg_resources import parse_version
2425

2526
import numpy as np
2627
from numpy.testing import assert_array_equal, assert_array_almost_equal
@@ -74,7 +75,8 @@
7475
'pytest.PytestUnraisableExceptionWarning',
7576
encountered_data_in='ignore:Encountered data in*.:RuntimeWarning:mne',
7677
edf_warning=r'ignore:^EDF\/EDF\+\/BDF files contain two fields .*'
77-
r':RuntimeWarning:mne'
78+
r':RuntimeWarning:mne',
79+
maxshield='ignore:.*Internal Active Shielding:RuntimeWarning:mne'
7880
)
7981

8082

@@ -374,6 +376,7 @@ def test_line_freq(line_freq, _bids_validate, tmpdir):
374376

375377
@requires_version('pybv', '0.4')
376378
@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
379+
@pytest.mark.filterwarnings(warning_str['maxshield'])
377380
def test_fif(_bids_validate, tmpdir):
378381
"""Test functionality of the write_raw_bids conversion for fif."""
379382
bids_root = tmpdir.mkdir('bids1')
@@ -606,15 +609,21 @@ def test_fif(_bids_validate, tmpdir):
606609
write_raw_bids(raw, bids_path, events_data=events, event_id=event_id,
607610
overwrite=True)
608611

609-
# test whether extra points in raw.info['dig'] are correctly used
610-
# to set DigitizedHeadShape in the json sidecar
611612
meg_json = _find_matching_sidecar(
612613
bids_path.copy().update(root=bids_root),
613614
suffix='meg', extension='.json')
615+
614616
with open(meg_json, 'r') as fin:
615617
meg_json_data = json.load(fin)
616-
# unchanged sample data includes Extra points
617-
assert meg_json_data['DigitizedHeadPoints'] is True
618+
619+
# no cHPI info is contained in the sample data
620+
assert meg_json_data['ContinuousHeadLocalization'] is False
621+
assert meg_json_data['HeadCoilFrequency'] == []
622+
623+
# test whether extra points in raw.info['dig'] are correctly used
624+
# to set DigitizedHeadShape in the json sidecar
625+
# unchanged sample data includes Extra points
626+
assert meg_json_data['DigitizedHeadPoints'] is True
618627

619628
# drop extra points from raw.info['dig'] and write again
620629
raw_no_extra_points = raw.copy()
@@ -636,6 +645,28 @@ def test_fif(_bids_validate, tmpdir):
636645
# DigitizedHeadPoints should be false
637646
assert meg_json_data['DigitizedHeadPoints'] is False
638647

648+
# test data with cHPI info
649+
data_path = testing.data_path()
650+
raw_fname = op.join(data_path, 'SSS', 'test_move_anon_raw.fif')
651+
raw = _read_raw_fif(raw_fname, allow_maxshield=True)
652+
653+
root = tmpdir.mkdir('chpi')
654+
bids_path = bids_path.copy().update(root=root, datatype='meg')
655+
bids_path = write_raw_bids(raw, bids_path)
656+
_bids_validate(bids_path.root)
657+
658+
meg_json = bids_path.copy().update(suffix='meg', extension='.json')
659+
with open(meg_json, 'r') as fin:
660+
meg_json_data = json.load(fin)
661+
662+
if parse_version(mne.__version__) > parse_version('0.23'):
663+
assert meg_json_data['ContinuousHeadLocalization'] is True
664+
assert_array_almost_equal(meg_json_data['HeadCoilFrequency'],
665+
[83., 143., 203., 263., 323.])
666+
else:
667+
assert meg_json_data['ContinuousHeadLocalization'] is False
668+
assert meg_json_data['HeadCoilFrequency'] == []
669+
639670

640671
@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
641672
def test_fif_dtype(_bids_validate, tmpdir):
@@ -1374,7 +1405,7 @@ def test_bdf(_bids_validate, tmpdir):
13741405
write_raw_bids(mne.concatenate_raws([raw.copy(), raw]), bids_path,
13751406
overwrite=True)
13761407

1377-
if LooseVersion(mne.__version__) >= LooseVersion('0.23'):
1408+
if parse_version(mne.__version__) >= parse_version('0.23'):
13781409
raw.info['sfreq'] -= 10 # changes raw.times, but retains its dimension
13791410
else:
13801411
raw._times = raw._times / 5

mne_bids/write.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import shutil
1717
from collections import defaultdict, OrderedDict
1818

19+
from pkg_resources import parse_version
20+
1921
import numpy as np
2022
from scipy import linalg
2123
import mne
@@ -654,6 +656,16 @@ def _sidecar_json(raw, task, manufacturer, fname, datatype, overwrite=False,
654656
raw.filenames[0].endswith('.fif'):
655657
digitized_head_points = True
656658

659+
# Compile cHPI information, if any.
660+
chpi = False
661+
hpi_freqs = np.array([])
662+
if (datatype == 'meg' and
663+
parse_version(mne.__version__) > parse_version('0.23')):
664+
hpi_freqs, _, _ = mne.chpi.get_chpi_info(info=raw.info,
665+
on_missing='ignore')
666+
if hpi_freqs.size > 0:
667+
chpi = True
668+
657669
# Define datatype-specific JSON dictionaries
658670
ch_info_json_common = [
659671
('TaskName', task),
@@ -668,7 +680,9 @@ def _sidecar_json(raw, task, manufacturer, fname, datatype, overwrite=False,
668680
('DigitizedLandmarks', digitized_landmark),
669681
('DigitizedHeadPoints', digitized_head_points),
670682
('MEGChannelCount', n_megchan),
671-
('MEGREFChannelCount', n_megrefchan)]
683+
('MEGREFChannelCount', n_megrefchan),
684+
('ContinuousHeadLocalization', chpi),
685+
('HeadCoilFrequency', list(hpi_freqs))]
672686
ch_info_json_eeg = [
673687
('EEGReference', 'n/a'),
674688
('EEGGround', 'n/a'),
@@ -1111,8 +1125,8 @@ def write_raw_bids(raw, bids_path, events_data=None,
11111125
events = mne.find_events(raw, min_duration=0.002)
11121126
write_raw_bids(..., events_data=events)
11131127
1114-
See the documentation of `mne.find_events` for more information on event
1115-
extraction from ``STIM`` channels.
1128+
See the documentation of :func:`mne.find_events` for more information on
1129+
event extraction from ``STIM`` channels.
11161130
11171131
When anonymizing ``.edf`` files, then the file format for EDF limits
11181132
how far back we can set the recording date. Therefore, all anonymized
@@ -1213,14 +1227,14 @@ def write_raw_bids(raw, bids_path, events_data=None,
12131227
raise ValueError(msg)
12141228

12151229
datatype = _handle_datatype(raw, bids_path.datatype, verbose)
1216-
bids_path = bids_path.copy()
1217-
bids_path = bids_path.update(
1218-
datatype=datatype, suffix=datatype, extension=ext)
1230+
bids_path = (bids_path.copy()
1231+
.update(datatype=datatype, suffix=datatype, extension=ext))
12191232

12201233
# check whether the info provided indicates that the data is emptyroom
12211234
# data
12221235
emptyroom = False
1223-
if bids_path.subject == 'emptyroom' and bids_path.task == 'noise':
1236+
if (bids_path.datatype == 'meg' and bids_path.subject == 'emptyroom' and
1237+
bids_path.task == 'noise'):
12241238
emptyroom = True
12251239
# check the session date provided is consistent with the value in raw
12261240
meas_date = raw.info.get('meas_date', None)

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ install_requires =
3939
mne >= 0.21.2
4040
numpy>=1.15.4
4141
scipy>=1.1.0
42+
setuptools
4243
packages = find:
4344
include_package_data = True
4445

0 commit comments

Comments
 (0)