From f0639d22bb517a2260de42406955300b2a5eaf19 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Tue, 18 May 2021 11:50:13 -0700 Subject: [PATCH 01/21] ENH: Add export evokeds to MFF function --- mne/io/egi/egimff.py | 139 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py index 152ae28cf9c..c494d5c84c2 100644 --- a/mne/io/egi/egimff.py +++ b/mne/io/egi/egimff.py @@ -9,6 +9,7 @@ from xml.dom.minidom import parse import numpy as np +import pytz from .events import _read_events, _combine_triggers from .general import (_get_signalfname, _get_ep_info, _extract, _get_blocks, @@ -18,8 +19,10 @@ from ..meas_info import _empty_info, create_info, _ensure_meas_date_none_or_dt from ..proj import setup_proj from ..utils import _create_chs, _mult_cal_one +from ..pick import pick_channels, pick_types from ...annotations import Annotations -from ...utils import verbose, logger, warn, _check_option, _check_fname +from ...utils import (verbose, logger, warn, _check_option, _check_fname, + fill_doc) from ...evoked import EvokedArray @@ -911,6 +914,140 @@ def _read_evoked_mff(fname, condition, channel_naming='E%d', verbose=None): nave=nave, verbose=verbose) +@fill_doc +def export_evokeds_to_mff(fname, evoked, device, history): + """Export evoked dataset to MFF. + + Use :func:`mne.export_evokeds` instead. + + Parameters + ---------- + %(export_params_fname)s + evoked : list of Evoked instances + List of evoked datasets to export to one file. Note that the + measurement info from the first evoked instance is used, so be sure + that information matches. + device : str + The device on which EEG was recorded (e.g. 'HydroCel GSN 256 1.0'). + history : None | list of history entries + Content to be written to history.xml. Must adhere to the format + described in mffpy.xml_files.History.content. If None, no history.xml + will be written. + + Notes + ----- + .. versionadded:: 0.24 + + Only EEG channels are written to the output file. + If no measurement date is specified, the current date/time is used. + """ + info = evoked[0].info + if np.round(info['sfreq']) != info['sfreq']: + raise ValueError('Sampling frequency must be a whole number. ' + f'sfreq: {info["sfreq"]}') + sampling_rate = int(info['sfreq']) + + # Initialize writer + mffpy = _import_mffpy('Export evokeds to MFF.') + writer = mffpy.Writer(fname) + record_time = info['meas_date'] or \ + pytz.utc.localize(datetime.datetime.utcnow()) + writer.addxml('fileInfo', recordTime=record_time) + writer.add_coordinates_and_sensor_layout(device) + + # Add EEG data + eeg_channels = pick_types(info, eeg=True, exclude=[]) + eeg_bin = mffpy.bin_writer.BinWriter(sampling_rate) + for ave in evoked: + # Signals are converted to µV + block = (ave.data[eeg_channels] * 1e6).astype(np.float32) + eeg_bin.add_block(block, offset_us=0) + writer.addbin(eeg_bin) + + # Add categories + categories_content = _categories_content_from_evokeds(evoked) + writer.addxml('categories', categories=categories_content) + + # Add history + if history: + writer.addxml('historyEntries', entries=history) + + writer.write() + + +def _categories_content_from_evokeds(evoked): + """Return categories.xml content for evoked dataset.""" + content = dict() + begin_time = 0 + for ave in evoked: + # Times are converted to microseconds + sfreq = ave.info['sfreq'] + duration = np.round(len(ave.times) / sfreq * 1e6).astype(int) + end_time = begin_time + duration + event_time = begin_time - np.round(ave.tmin * 1e6).astype(int) + eeg_bads = _get_bad_eeg_channels(ave.info) + content[ave.comment] = [ + _build_segment_content(begin_time, end_time, event_time, eeg_bads, + name='Average', nsegs=ave.nave) + ] + begin_time += duration + return content + + +def _get_bad_eeg_channels(info): + """Return a list of bad EEG channels formatted for categories.xml. + + Given a list of only the EEG channels in file, return the indices of this + list (starting at 1) that correspond to bad channels. + """ + if len(info['bads']) == 0: + return [] + eeg_channels = pick_types(info, eeg=True, exclude=[]) + bad_channels = pick_channels(info['ch_names'], info['bads']) + bads_elementwise = np.isin(eeg_channels, bad_channels) + return list(np.flatnonzero(bads_elementwise) + 1) + + +def _build_segment_content(begin_time, end_time, event_time, eeg_bads, + status='unedited', name=None, pns_bads=None, + nsegs=None): + """Build content for a single segment in categories.xml. + + Segments are sorted into categories in categories.xml. In a segmented MFF + each category can contain multiple segments, but in an averaged MFF each + category only contains one segment (the average). + """ + channel_status = [{ + 'signalBin': 1, + 'exclusion': 'badChannels', + 'channels': eeg_bads + }] + if pns_bads: + channel_status.append({ + 'signalBin': 2, + 'exclusion': 'badChannels', + 'channels': pns_bads + }) + content = { + 'status': status, + 'beginTime': begin_time, + 'endTime': end_time, + 'evtBegin': event_time, + 'evtEnd': event_time, + 'channelStatus': channel_status, + } + if name: + content['name'] = name + if nsegs: + content['keys'] = { + '#seg': { + 'type': 'long', + 'data': nsegs + } + } + return content + + def _import_mffpy(why='read averaged .mff files'): """Import and return module mffpy.""" try: From dc4cf52482034b9977ce834784e659e990cd8f87 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Tue, 18 May 2021 11:52:31 -0700 Subject: [PATCH 02/21] ENH: Add export evokeds function --- mne/__init__.py | 3 +- mne/evoked.py | 73 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/mne/__init__.py b/mne/__init__.py index 5192d5f3f7d..2cb4385bd29 100644 --- a/mne/__init__.py +++ b/mne/__init__.py @@ -77,7 +77,8 @@ events_from_annotations) from .epochs import (BaseEpochs, Epochs, EpochsArray, read_epochs, concatenate_epochs, make_fixed_length_epochs) -from .evoked import Evoked, EvokedArray, read_evokeds, write_evokeds, combine_evoked +from .evoked import (Evoked, EvokedArray, read_evokeds, write_evokeds, + export_evokeds, combine_evoked) from .label import (read_label, label_sign_flip, write_label, stc_to_label, grow_labels, Label, split_label, BiHemiLabel, read_labels_from_annot, write_labels_to_annot, diff --git a/mne/evoked.py b/mne/evoked.py index 05dcbb05fe5..1cc386e00e4 100644 --- a/mne/evoked.py +++ b/mne/evoked.py @@ -22,7 +22,7 @@ fill_doc, _check_option, ShiftTimeMixin, _build_data_frame, _check_pandas_installed, _check_pandas_index_arguments, _convert_times, _scale_dataframe_data, _check_time_format, - _check_preload) + _check_preload, _infer_check_export_fmt) from .viz import (plot_evoked, plot_evoked_topomap, plot_evoked_field, plot_evoked_image, plot_evoked_topo) from .viz.evoked import plot_evoked_white, plot_evoked_joint @@ -1436,6 +1436,77 @@ def _write_evokeds(fname, evoked, check=True): end_file(fid) +@fill_doc +def export_evokeds(fname, evoked, fmt='auto', device=None, history=None): + """Export evoked dataset to external formats. + + Supported formats: MFF (mff, uses module mffpy) + + .. warning:: + Since we are exporting to external formats, there's no guarantee that + all the info will be preserved in the external format. To save in + native MNE format (``.fif``) without information loss, use + :func:`mne.write_evokeds` instead. + + Parameters + ---------- + %(export_params_fname)s + evoked : Evoked instance, or list of Evoked instances + The evoked dataset, or list of evoked datasets, to export to one file. + Note that the measurement info from the first evoked instance is used, + so be sure that information matches. + fmt : 'auto' | 'mff' + Format of the export. Defaults to ``'auto'``, which will infer the + format from the filename extension. See supported formats above for + more information. + device : None (default) | str + If exporting to MFF format, specify the device on which EEG was + recorded (e.g. 'HydroCel GSN 256 1.0'). This is necessary for + determining the sensor layout and coordinates specs. + history : None (default) | list of history entries + If exporting to MFF format, provide the content to be written to + history.xml. Must adhere to the format described in + mffpy.xml_files.History.content. If None, no history.xml will be + written. + + See Also + -------- + write_evokeds + + Notes + ----- + .. versionadded:: 0.24 + + MFF exports + Only EEG channels are written to the output file. + If no measurement date is specified, the current date/time is used. + """ + supported_export_formats = { + 'mff': ('mff',), + 'eeglab': ('set',), + 'edf': ('edf',), + 'brainvision': ('eeg', 'vmrk', 'vhdr',) + } + fmt = _infer_check_export_fmt(fmt, fname, supported_export_formats) + + if not isinstance(evoked, list): + evoked = [evoked] + + if fmt == 'mff': + if device is None: + raise ValueError('Export to MFF requires a device specification.') + from .io.egi.egimff import export_evokeds_to_mff + export_evokeds_to_mff(fname, evoked, device, history) + elif fmt == 'eeglab': + raise NotImplementedError('Export to EEGLAB not implemented.') + elif fmt == 'edf': + raise NotImplementedError('Export to EDF not implemented.') + elif fmt == 'brainvision': + raise NotImplementedError('Export to BrainVision not implemented.') + + print(f'Exporting evoked dataset to {fname}...') + + def _get_peak(data, times, tmin=None, tmax=None, mode='abs'): """Get feature-index and time of maximum signal from 2D array. From c5884733c7231c530df4eac1cad8ddca21ec7d3d Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Tue, 18 May 2021 11:53:38 -0700 Subject: [PATCH 03/21] Add tests for export evokeds --- mne/tests/test_evoked.py | 83 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/mne/tests/test_evoked.py b/mne/tests/test_evoked.py index 46ac50ec47a..0f75e53a706 100644 --- a/mne/tests/test_evoked.py +++ b/mne/tests/test_evoked.py @@ -16,12 +16,14 @@ import pytest from mne import (equalize_channels, pick_types, read_evokeds, write_evokeds, - combine_evoked, create_info, read_events, + export_evokeds, combine_evoked, create_info, read_events, Epochs, EpochsArray) from mne.evoked import _get_peak, Evoked, EvokedArray -from mne.io import read_raw_fif +from mne.io import read_raw_fif, read_evokeds_mff from mne.io.constants import FIFF -from mne.utils import requires_pandas, grand_average +from mne.utils import (requires_pandas, grand_average, object_diff, + requires_version) +from mne.datasets.testing import data_path, requires_testing_data base_dir = op.join(op.dirname(__file__), '..', 'io', 'tests', 'data') fname = op.join(base_dir, 'test-ave.fif') @@ -240,6 +242,81 @@ def test_io_evoked(tmpdir): assert (all(ave.info['maxshield'] is True for ave in aves)) +@requires_version('mffpy', '0.5.7') +@requires_testing_data +@pytest.mark.parametrize('fmt', ('auto', 'mff')) +def test_export_evokeds_to_mff(tmpdir, fmt): + """Test exporting evoked dataset to MFF.""" + egi_path = op.join(data_path(download=False), 'EGI') + egi_evoked_fname = op.join(egi_path, 'test_egi_evoked.mff') + evoked = read_evokeds_mff(egi_evoked_fname) + export_fname = op.join(str(tmpdir), 'evoked.mff') + history = [ + { + 'name': 'Test Segmentation', + 'method': 'Segmentation', + 'settings': ['Setting 1', 'Setting 2'], + 'results': ['Result 1', 'Result 2'] + }, + { + 'name': 'Test Averaging', + 'method': 'Averaging', + 'settings': ['Setting 1', 'Setting 2'], + 'results': ['Result 1', 'Result 2'] + } + ] + export_evokeds(export_fname, evoked, fmt=fmt, + device='HydroCel GSN 256 1.0', history=history) + # Drop non-EEG channels + evoked = [ave.drop_channels(['ECG', 'EMG']) for ave in evoked] + evoked_exported = read_evokeds_mff(export_fname) + for i in range(len(evoked_exported)): + ave_exported = evoked_exported[i] + ave = evoked[i] + # Compare infos + assert object_diff(ave_exported.info, ave.info) == '' + # Compare data + assert_allclose(ave_exported.data, ave.data) + # Compare properties + assert_equal(ave_exported.nave, ave.nave) + assert_equal(ave_exported.kind, ave.kind) + assert_equal(ave_exported.comment, ave.comment) + assert_equal(ave_exported.times, ave.times) + + +def test_export_to_mff_no_device(): + """Test no device specification throws ValueError.""" + evoked = read_evokeds(fname) + with pytest.raises(ValueError) as exc_info: + export_evokeds('output.mff', evoked) + message = 'Export to MFF requires a device specification.' + assert str(exc_info.value) == message + + +@requires_version('mffpy', '0.5.7') +def test_export_to_mff_incompatible_sfreq(): + """Test non-whole number sampling frequency throws ValueError.""" + evoked = read_evokeds(fname) + with pytest.raises(ValueError) as exc_info: + export_evokeds('output.mff', evoked, device='HydroCel GSN 256 1.0') + message = 'Sampling frequency must be a whole number. ' \ + f'sfreq: {evoked[0].info["sfreq"]}' + assert str(exc_info.value) == message + + +@pytest.mark.parametrize('fmt,ext', [ + ('EEGLAB', 'set'), + ('EDF', 'edf'), + ('BrainVision', 'eeg') +]) +def test_export_evokeds_unsupported_format(fmt, ext): + """Test exporting evoked dataset to non-supported formats.""" + evoked = read_evokeds(fname) + with pytest.raises(NotImplementedError) as exc_info: + export_evokeds(f'output.{ext}', evoked) + assert str(exc_info.value) == f'Export to {fmt} not implemented.' + + def test_shift_time_evoked(tmpdir): """Test for shifting of time scale.""" tempdir = str(tmpdir) From 3d374517631e6b1362a4d6d45c652f3f2631032b Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Tue, 18 May 2021 11:54:45 -0700 Subject: [PATCH 04/21] DOC: Add export_evokeds to python reference --- doc/file_io.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/file_io.rst b/doc/file_io.rst index a1445571309..076a324c64a 100644 --- a/doc/file_io.rst +++ b/doc/file_io.rst @@ -44,6 +44,7 @@ File I/O write_cov write_events write_evokeds + export_evokeds write_forward_solution write_label write_proj From afb1fe94e26c9f4020d3f2bef1478feac946df69 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Wed, 19 May 2021 08:47:36 -0700 Subject: [PATCH 05/21] DOC: Fix history argument type --- mne/evoked.py | 16 ++++++++-------- mne/io/egi/egimff.py | 9 +++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/mne/evoked.py b/mne/evoked.py index 1cc386e00e4..7ef56283aaf 100644 --- a/mne/evoked.py +++ b/mne/evoked.py @@ -1460,14 +1460,14 @@ def export_evokeds(fname, evoked, fmt='auto', device=None, history=None): format from the filename extension. See supported formats above for more information. device : None (default) | str - If exporting to MFF format, specify the device on which EEG was - recorded (e.g. 'HydroCel GSN 256 1.0'). This is necessary for - determining the sensor layout and coordinates specs. - history : None (default) | list of history entries - If exporting to MFF format, provide the content to be written to - history.xml. Must adhere to the format described in - mffpy.xml_files.History.content. If None, no history.xml will be - written. + If exporting to MFF format, it is required to specify the device on + which EEG was recorded (e.g. 'HydroCel GSN 256 1.0'). This is necessary + for determining the sensor layout and coordinates specs. + history : None (default) | list of dict + If exporting to MFF format, it is optional to provide a list of history + entries (dictionaries) to be written to history.xml. This must adhere + to the format described in mffpy.xml_files.History.content. If None, no + history.xml will be written. See Also -------- diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py index c494d5c84c2..015fe8147e2 100644 --- a/mne/io/egi/egimff.py +++ b/mne/io/egi/egimff.py @@ -929,10 +929,11 @@ def export_evokeds_to_mff(fname, evoked, device, history): that information matches. device : str The device on which EEG was recorded (e.g. 'HydroCel GSN 256 1.0'). - history : None | list of history entries - Content to be written to history.xml. Must adhere to the format - described in mffpy.xml_files.History.content. If None, no history.xml - will be written. + history : None | list of dict + Optional list of history entries (dictionaries) to be written to + history.xml. This must adhere to the format described in + mffpy.xml_files.History.content. If None, no history.xml will be + written. Notes ----- From 0976e53117d23683450b95137363a4a4e3ac02d1 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Wed, 19 May 2021 09:00:19 -0700 Subject: [PATCH 06/21] STY: Use logger for user message --- mne/evoked.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne/evoked.py b/mne/evoked.py index 7ef56283aaf..bb38aba165d 100644 --- a/mne/evoked.py +++ b/mne/evoked.py @@ -1492,6 +1492,8 @@ def export_evokeds(fname, evoked, fmt='auto', device=None, history=None): if not isinstance(evoked, list): evoked = [evoked] + logger.info(f'Exporting evoked dataset to {fname}...') + if fmt == 'mff': if device is None: raise ValueError('Export to MFF requires a device specification.') @@ -1504,8 +1506,6 @@ def export_evokeds(fname, evoked, fmt='auto', device=None, history=None): elif fmt == 'brainvision': raise NotImplementedError('Export to BrainVision not implemented.') - print(f'Exporting evoked dataset to {fname}...') - def _get_peak(data, times, tmin=None, tmax=None, mode='abs'): """Get feature-index and time of maximum signal from 2D array. From 287a67253433312d67272be0c66aafe6e98a9bf0 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Wed, 19 May 2021 09:51:41 -0700 Subject: [PATCH 07/21] FIX: Nest pytz import --- mne/io/egi/egimff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py index 015fe8147e2..72b79deecef 100644 --- a/mne/io/egi/egimff.py +++ b/mne/io/egi/egimff.py @@ -9,7 +9,6 @@ from xml.dom.minidom import parse import numpy as np -import pytz from .events import _read_events, _combine_triggers from .general import (_get_signalfname, _get_ep_info, _extract, _get_blocks, @@ -950,6 +949,7 @@ def export_evokeds_to_mff(fname, evoked, device, history): # Initialize writer mffpy = _import_mffpy('Export evokeds to MFF.') + import pytz writer = mffpy.Writer(fname) record_time = info['meas_date'] or \ pytz.utc.localize(datetime.datetime.utcnow()) From 24a4c9c76eae1d5c880c9396febac2dc458c3fa4 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Wed, 19 May 2021 17:00:20 -0700 Subject: [PATCH 08/21] Clarify MFF specific arguments --- mne/evoked.py | 11 ++++++----- mne/tests/test_evoked.py | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/mne/evoked.py b/mne/evoked.py index bb38aba165d..fd4eb38b618 100644 --- a/mne/evoked.py +++ b/mne/evoked.py @@ -1437,7 +1437,8 @@ def _write_evokeds(fname, evoked, check=True): @fill_doc -def export_evokeds(fname, evoked, fmt='auto', device=None, history=None): +def export_evokeds(fname, evoked, fmt='auto', *, mff_device=None, + mff_history=None): """Export evoked dataset to external formats. Supported formats: MFF (mff, uses module mffpy) @@ -1459,11 +1460,11 @@ def export_evokeds(fname, evoked, fmt='auto', device=None, history=None): Format of the export. Defaults to ``'auto'``, which will infer the format from the filename extension. See supported formats above for more information. - device : None (default) | str + mff_device : None (default) | str If exporting to MFF format, it is required to specify the device on which EEG was recorded (e.g. 'HydroCel GSN 256 1.0'). This is necessary for determining the sensor layout and coordinates specs. - history : None (default) | list of dict + mff_history : None (default) | list of dict If exporting to MFF format, it is optional to provide a list of history entries (dictionaries) to be written to history.xml. This must adhere to the format described in mffpy.xml_files.History.content. If None, no @@ -1495,10 +1496,10 @@ def export_evokeds(fname, evoked, fmt='auto', device=None, history=None): logger.info(f'Exporting evoked dataset to {fname}...') if fmt == 'mff': - if device is None: + if mff_device is None: raise ValueError('Export to MFF requires a device specification.') from .io.egi.egimff import export_evokeds_to_mff - export_evokeds_to_mff(fname, evoked, device, history) + export_evokeds_to_mff(fname, evoked, mff_device, mff_history) elif fmt == 'eeglab': raise NotImplementedError('Export to EEGLAB not implemented.') elif fmt == 'edf': diff --git a/mne/tests/test_evoked.py b/mne/tests/test_evoked.py index 0f75e53a706..95dc9e7e6bb 100644 --- a/mne/tests/test_evoked.py +++ b/mne/tests/test_evoked.py @@ -266,7 +266,7 @@ def test_export_evokeds_to_mff(tmpdir, fmt): } ] export_evokeds(export_fname, evoked, fmt=fmt, - device='HydroCel GSN 256 1.0', history=history) + mff_device='HydroCel GSN 256 1.0', mff_history=history) # Drop non-EEG channels evoked = [ave.drop_channels(['ECG', 'EMG']) for ave in evoked] evoked_exported = read_evokeds_mff(export_fname) @@ -298,7 +298,7 @@ def test_export_to_mff_incompatible_sfreq(): """Test non-whole number sampling frequency throws ValueError.""" evoked = read_evokeds(fname) with pytest.raises(ValueError) as exc_info: - export_evokeds('output.mff', evoked, device='HydroCel GSN 256 1.0') + export_evokeds('output.mff', evoked, mff_device='HydroCel GSN 256 1.0') message = 'Sampling frequency must be a whole number. ' \ f'sfreq: {evoked[0].info["sfreq"]}' assert str(exc_info.value) == message From 3bdee2e6135465aa9a3d3f4de5b7bb44beb0016e Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Wed, 19 May 2021 17:15:18 -0700 Subject: [PATCH 09/21] DOC: Use shared warning message --- mne/epochs.py | 2 +- mne/evoked.py | 7 +------ mne/io/base.py | 2 +- mne/utils/docs.py | 2 +- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/mne/epochs.py b/mne/epochs.py index 5b35bf22e86..8ca386989ad 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -1824,7 +1824,7 @@ def export(self, fname, fmt='auto', verbose=None): """Export Epochs to external formats. Supported formats: EEGLAB (set, uses :mod:`eeglabio`) - %(export_warning)s + %(export_warning)s :meth:`save` instead. Parameters ---------- diff --git a/mne/evoked.py b/mne/evoked.py index fd4eb38b618..d2257f455a1 100644 --- a/mne/evoked.py +++ b/mne/evoked.py @@ -1442,12 +1442,7 @@ def export_evokeds(fname, evoked, fmt='auto', *, mff_device=None, """Export evoked dataset to external formats. Supported formats: MFF (mff, uses module mffpy) - - .. warning:: - Since we are exporting to external formats, there's no guarantee that - all the info will be preserved in the external format. To save in - native MNE format (``.fif``) without information loss, use - :func:`mne.write_evokeds` instead. + %(export_warning)s :func:`mne.write_evokeds` instead. Parameters ---------- diff --git a/mne/io/base.py b/mne/io/base.py index 397836440fd..92dd546dfdc 100644 --- a/mne/io/base.py +++ b/mne/io/base.py @@ -1458,7 +1458,7 @@ def export(self, fname, fmt='auto', verbose=None): """Export Raw to external formats. Supported formats: EEGLAB (set, uses :mod:`eeglabio`) - %(export_warning)s + %(export_warning)s :meth:`save` instead. Parameters ---------- diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 75990ab6e48..a40ff7c3b83 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -2326,7 +2326,7 @@ .. warning:: Since we are exporting to external formats, there's no guarantee that all the info will be preserved in the external format. To save in native MNE - format (``.fif``) without information loss, use :func:`save` instead. + format (``.fif``) without information loss, use """ docdict['export_params_fname'] = """ fname : str From 8ad46dcd048904c93816695622691d60332d08bb Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Wed, 19 May 2021 17:32:59 -0700 Subject: [PATCH 10/21] FIX: Test digitization data from categories.xml --- mne/io/egi/tests/test_egi.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne/io/egi/tests/test_egi.py b/mne/io/egi/tests/test_egi.py index 7f3e55e38ef..d68fa34f1d5 100644 --- a/mne/io/egi/tests/test_egi.py +++ b/mne/io/egi/tests/test_egi.py @@ -369,6 +369,7 @@ def test_io_egi_evokeds_mff(idx, cond, tmax, signals, bads): assert evoked_cond.info['nchan'] == 259 assert evoked_cond.info['sfreq'] == 250.0 assert not evoked_cond.info['custom_ref_applied'] + assert len(evoked_cond.info['dig']) == 261 @requires_version('mffpy', '0.5.7') From 0942b4553bddc92b6cc0f3c2ba0ae29ab6f4e749 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Wed, 19 May 2021 17:51:07 -0700 Subject: [PATCH 11/21] STY: Use regex match for pytest.raises --- mne/tests/test_evoked.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/mne/tests/test_evoked.py b/mne/tests/test_evoked.py index 95dc9e7e6bb..ac2a190893c 100644 --- a/mne/tests/test_evoked.py +++ b/mne/tests/test_evoked.py @@ -287,21 +287,16 @@ def test_export_evokeds_to_mff(tmpdir, fmt): def test_export_to_mff_no_device(): """Test no device specification throws ValueError.""" evoked = read_evokeds(fname) - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match='Export to MFF requires a device'): export_evokeds('output.mff', evoked) - message = 'Export to MFF requires a device specification.' - assert str(exc_info.value) == message @requires_version('mffpy', '0.5.7') def test_export_to_mff_incompatible_sfreq(): """Test non-whole number sampling frequency throws ValueError.""" evoked = read_evokeds(fname) - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match=f'sfreq: {evoked[0].info["sfreq"]}'): export_evokeds('output.mff', evoked, mff_device='HydroCel GSN 256 1.0') - message = 'Sampling frequency must be a whole number. ' \ - f'sfreq: {evoked[0].info["sfreq"]}' - assert str(exc_info.value) == message @pytest.mark.parametrize('fmt,ext', [ @@ -312,9 +307,8 @@ def test_export_to_mff_incompatible_sfreq(): def test_export_evokeds_unsupported_format(fmt, ext): """Test exporting evoked dataset to non-supported formats.""" evoked = read_evokeds(fname) - with pytest.raises(NotImplementedError) as exc_info: + with pytest.raises(NotImplementedError, match=f'Export to {fmt} not imp'): export_evokeds(f'output.{ext}', evoked) - assert str(exc_info.value) == f'Export to {fmt} not implemented.' def test_shift_time_evoked(tmpdir): From bf1bc6d29afec127bd68fd7141e912b25e163387 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Wed, 19 May 2021 17:59:33 -0700 Subject: [PATCH 12/21] STY: Use zipped lists --- mne/tests/test_evoked.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mne/tests/test_evoked.py b/mne/tests/test_evoked.py index ac2a190893c..36d1345d2dd 100644 --- a/mne/tests/test_evoked.py +++ b/mne/tests/test_evoked.py @@ -270,9 +270,8 @@ def test_export_evokeds_to_mff(tmpdir, fmt): # Drop non-EEG channels evoked = [ave.drop_channels(['ECG', 'EMG']) for ave in evoked] evoked_exported = read_evokeds_mff(export_fname) - for i in range(len(evoked_exported)): - ave_exported = evoked_exported[i] - ave = evoked[i] + assert len(evoked) == len(evoked_exported) + for ave, ave_exported in zip(evoked, evoked_exported): # Compare infos assert object_diff(ave_exported.info, ave.info) == '' # Compare data From 3f2220aab8961d8449d78242f26a71121a30acd2 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Wed, 19 May 2021 18:07:02 -0700 Subject: [PATCH 13/21] Refactor assert statements --- mne/tests/test_evoked.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mne/tests/test_evoked.py b/mne/tests/test_evoked.py index 36d1345d2dd..119069f138d 100644 --- a/mne/tests/test_evoked.py +++ b/mne/tests/test_evoked.py @@ -277,10 +277,10 @@ def test_export_evokeds_to_mff(tmpdir, fmt): # Compare data assert_allclose(ave_exported.data, ave.data) # Compare properties - assert_equal(ave_exported.nave, ave.nave) - assert_equal(ave_exported.kind, ave.kind) - assert_equal(ave_exported.comment, ave.comment) - assert_equal(ave_exported.times, ave.times) + assert ave_exported.nave == ave.nave + assert ave_exported.kind == ave.kind + assert ave_exported.comment == ave.comment + assert_allclose(ave_exported.times, ave.times) def test_export_to_mff_no_device(): From 6a09e41331a94792a9e35737b5e49b71bf79f19e Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Fri, 21 May 2021 11:00:35 -0700 Subject: [PATCH 14/21] ENH: Store MFF device type in info --- mne/io/egi/egimff.py | 4 ++++ mne/io/egi/tests/test_egi.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py index 72b79deecef..2c908128822 100644 --- a/mne/io/egi/egimff.py +++ b/mne/io/egi/egimff.py @@ -106,6 +106,8 @@ def _read_mff_header(filepath): # Add the sensor info. sensor_layout_file = op.join(filepath, 'sensorLayout.xml') sensor_layout_obj = parse(sensor_layout_file) + summaryinfo['device'] = (sensor_layout_obj.getElementsByTagName('name') + [0].firstChild.data) sensors = sensor_layout_obj.getElementsByTagName('sensor') chan_type = list() chan_unit = list() @@ -458,6 +460,7 @@ def __init__(self, input_fname, eog=None, misc=None, egi_info['hour'], egi_info['minute'], egi_info['second']) my_timestamp = time.mktime(my_time.timetuple()) info['meas_date'] = _ensure_meas_date_none_or_dt((my_timestamp, 0)) + info['device_info'] = dict(type=egi_info['device']) # First: EEG ch_names = [channel_naming % (i + 1) for i in @@ -847,6 +850,7 @@ def _read_evoked_mff(fname, condition, channel_naming='E%d', verbose=None): range(mff.num_channels['EEG'])] ch_names.extend(egi_info['pns_names']) info = create_info(ch_names, mff.sampling_rates['EEG'], ch_types) + info['device_info'] = dict(type=egi_info['device']) info['nchan'] = sum(mff.num_channels.values()) # Add individual channel info diff --git a/mne/io/egi/tests/test_egi.py b/mne/io/egi/tests/test_egi.py index d68fa34f1d5..ca601da55b3 100644 --- a/mne/io/egi/tests/test_egi.py +++ b/mne/io/egi/tests/test_egi.py @@ -128,6 +128,7 @@ def test_io_egi_mff(): loc = raw.info['chs'][i]['loc'] assert loc[:3].any(), loc[:3] assert_array_equal(loc[3:6], ref_loc, err_msg=f'{i}') + assert raw.info['device_info']['type'] == 'HydroCel GSN 128 1.0' assert 'eeg' in raw eeg_chan = [c for c in raw.ch_names if 'EEG' in c] @@ -370,6 +371,7 @@ def test_io_egi_evokeds_mff(idx, cond, tmax, signals, bads): assert evoked_cond.info['sfreq'] == 250.0 assert not evoked_cond.info['custom_ref_applied'] assert len(evoked_cond.info['dig']) == 261 + assert evoked_cond.info['device_info']['type'] == 'HydroCel GSN 256 1.0' @requires_version('mffpy', '0.5.7') From 20e97b7e5203967084e7d087c3c60a0d07cc901c Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Fri, 21 May 2021 14:37:37 -0700 Subject: [PATCH 15/21] Determine device type from info --- mne/io/egi/egimff.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py index 2c908128822..c87e4dfa31b 100644 --- a/mne/io/egi/egimff.py +++ b/mne/io/egi/egimff.py @@ -918,7 +918,7 @@ def _read_evoked_mff(fname, condition, channel_naming='E%d', verbose=None): @fill_doc -def export_evokeds_to_mff(fname, evoked, device, history): +def export_evokeds_to_mff(fname, evoked, history=None): """Export evoked dataset to MFF. Use :func:`mne.export_evokeds` instead. @@ -930,9 +930,7 @@ def export_evokeds_to_mff(fname, evoked, device, history): List of evoked datasets to export to one file. Note that the measurement info from the first evoked instance is used, so be sure that information matches. - device : str - The device on which EEG was recorded (e.g. 'HydroCel GSN 256 1.0'). - history : None | list of dict + history : None (default) | list of dict Optional list of history entries (dictionaries) to be written to history.xml. This must adhere to the format described in mffpy.xml_files.History.content. If None, no history.xml will be @@ -944,6 +942,9 @@ def export_evokeds_to_mff(fname, evoked, device, history): Only EEG channels are written to the output file. If no measurement date is specified, the current date/time is used. + ``info['device_info']['type']`` must be a valid MFF recording device + (e.g. 'HydroCel GSN 256 1.0'). This field is automatically populated when + using MFF read functions. """ info = evoked[0].info if np.round(info['sfreq']) != info['sfreq']: @@ -958,6 +959,10 @@ def export_evokeds_to_mff(fname, evoked, device, history): record_time = info['meas_date'] or \ pytz.utc.localize(datetime.datetime.utcnow()) writer.addxml('fileInfo', recordTime=record_time) + try: + device = info['device_info']['type'] + except (TypeError, KeyError): + raise ValueError('No device type. Cannot determine sensor layout.') writer.add_coordinates_and_sensor_layout(device) # Add EEG data From 0d773e724e886c0ad89e94ef1f31c577841d1951 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Fri, 21 May 2021 14:39:52 -0700 Subject: [PATCH 16/21] Use kwargs for format-specific exports --- mne/evoked.py | 31 ++++++++++++------------------- mne/tests/test_evoked.py | 16 ++++++++++------ 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/mne/evoked.py b/mne/evoked.py index d2257f455a1..af8661312ac 100644 --- a/mne/evoked.py +++ b/mne/evoked.py @@ -1437,11 +1437,15 @@ def _write_evokeds(fname, evoked, check=True): @fill_doc -def export_evokeds(fname, evoked, fmt='auto', *, mff_device=None, - mff_history=None): +def export_evokeds(fname, evoked, fmt='auto', **kwargs): """Export evoked dataset to external formats. - Supported formats: MFF (mff, uses module mffpy) + This function is a wrapper for format-specific export functions. The export + function is selected based on the inferred file format. All arguments are + passed to the respective export function. + + Supported formats + MFF (mff, uses `mne.io.egi.egimff.export_evokeds_to_mff`) %(export_warning)s :func:`mne.write_evokeds` instead. Parameters @@ -1455,15 +1459,10 @@ def export_evokeds(fname, evoked, fmt='auto', *, mff_device=None, Format of the export. Defaults to ``'auto'``, which will infer the format from the filename extension. See supported formats above for more information. - mff_device : None (default) | str - If exporting to MFF format, it is required to specify the device on - which EEG was recorded (e.g. 'HydroCel GSN 256 1.0'). This is necessary - for determining the sensor layout and coordinates specs. - mff_history : None (default) | list of dict - If exporting to MFF format, it is optional to provide a list of history - entries (dictionaries) to be written to history.xml. This must adhere - to the format described in mffpy.xml_files.History.content. If None, no - history.xml will be written. + **kwargs + Additional keyword arguments to pass to the underlying export function. + For details, see the arguments of the export function for the + respective file format. See Also -------- @@ -1472,10 +1471,6 @@ def export_evokeds(fname, evoked, fmt='auto', *, mff_device=None, Notes ----- .. versionadded:: 0.24 - - MFF exports - Only EEG channels are written to the output file. - If no measurement date is specified, the current date/time is used. """ supported_export_formats = { 'mff': ('mff',), @@ -1491,10 +1486,8 @@ def export_evokeds(fname, evoked, fmt='auto', *, mff_device=None, logger.info(f'Exporting evoked dataset to {fname}...') if fmt == 'mff': - if mff_device is None: - raise ValueError('Export to MFF requires a device specification.') from .io.egi.egimff import export_evokeds_to_mff - export_evokeds_to_mff(fname, evoked, mff_device, mff_history) + export_evokeds_to_mff(fname, evoked, **kwargs) elif fmt == 'eeglab': raise NotImplementedError('Export to EEGLAB not implemented.') elif fmt == 'edf': diff --git a/mne/tests/test_evoked.py b/mne/tests/test_evoked.py index 119069f138d..58ae3126956 100644 --- a/mne/tests/test_evoked.py +++ b/mne/tests/test_evoked.py @@ -265,8 +265,7 @@ def test_export_evokeds_to_mff(tmpdir, fmt): 'results': ['Result 1', 'Result 2'] } ] - export_evokeds(export_fname, evoked, fmt=fmt, - mff_device='HydroCel GSN 256 1.0', mff_history=history) + export_evokeds(export_fname, evoked, fmt=fmt, history=history) # Drop non-EEG channels evoked = [ave.drop_channels(['ECG', 'EMG']) for ave in evoked] evoked_exported = read_evokeds_mff(export_fname) @@ -283,10 +282,15 @@ def test_export_evokeds_to_mff(tmpdir, fmt): assert_allclose(ave_exported.times, ave.times) +@requires_version('mffpy', '0.5.7') +@requires_testing_data def test_export_to_mff_no_device(): - """Test no device specification throws ValueError.""" - evoked = read_evokeds(fname) - with pytest.raises(ValueError, match='Export to MFF requires a device'): + """Test no device type throws ValueError.""" + egi_path = op.join(data_path(download=False), 'EGI') + egi_evoked_fname = op.join(egi_path, 'test_egi_evoked.mff') + evoked = read_evokeds_mff(egi_evoked_fname, condition='Category 1') + evoked.info['device_info'] = None + with pytest.raises(ValueError, match='No device type.'): export_evokeds('output.mff', evoked) @@ -295,7 +299,7 @@ def test_export_to_mff_incompatible_sfreq(): """Test non-whole number sampling frequency throws ValueError.""" evoked = read_evokeds(fname) with pytest.raises(ValueError, match=f'sfreq: {evoked[0].info["sfreq"]}'): - export_evokeds('output.mff', evoked, mff_device='HydroCel GSN 256 1.0') + export_evokeds('output.mff', evoked) @pytest.mark.parametrize('fmt,ext', [ From 1132c5706398a8b8139004a56e54bbff39a22d42 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Fri, 21 May 2021 14:44:56 -0700 Subject: [PATCH 17/21] Use current time for record time Because averaged files are absolute time agnostic, we can simplify by using plugging in the current time for the record time. This is also the behavior when running an averaging tool in EGI Net Station software. --- mne/io/egi/egimff.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py index c87e4dfa31b..a96470e34de 100644 --- a/mne/io/egi/egimff.py +++ b/mne/io/egi/egimff.py @@ -941,7 +941,6 @@ def export_evokeds_to_mff(fname, evoked, history=None): .. versionadded:: 0.24 Only EEG channels are written to the output file. - If no measurement date is specified, the current date/time is used. ``info['device_info']['type']`` must be a valid MFF recording device (e.g. 'HydroCel GSN 256 1.0'). This field is automatically populated when using MFF read functions. @@ -956,9 +955,8 @@ def export_evokeds_to_mff(fname, evoked, history=None): mffpy = _import_mffpy('Export evokeds to MFF.') import pytz writer = mffpy.Writer(fname) - record_time = info['meas_date'] or \ - pytz.utc.localize(datetime.datetime.utcnow()) - writer.addxml('fileInfo', recordTime=record_time) + current_time = pytz.utc.localize(datetime.datetime.utcnow()) + writer.addxml('fileInfo', recordTime=current_time) try: device = info['device_info']['type'] except (TypeError, KeyError): From 6a2ff0cbe4e2115be19c2402537f606ca7988277 Mon Sep 17 00:00:00 2001 From: Evan Hathaway Date: Mon, 24 May 2021 07:34:08 -0700 Subject: [PATCH 18/21] Add MFF export funtion to python reference --- doc/file_io.rst | 1 + mne/evoked.py | 4 ++-- mne/io/__init__.py | 2 +- mne/io/egi/__init__.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/file_io.rst b/doc/file_io.rst index 076a324c64a..2b987a31c64 100644 --- a/doc/file_io.rst +++ b/doc/file_io.rst @@ -54,6 +54,7 @@ File I/O what io.read_info io.show_fiff + io.export_evokeds_to_mff Base class: diff --git a/mne/evoked.py b/mne/evoked.py index af8661312ac..cc4e1d91df9 100644 --- a/mne/evoked.py +++ b/mne/evoked.py @@ -1445,7 +1445,7 @@ def export_evokeds(fname, evoked, fmt='auto', **kwargs): passed to the respective export function. Supported formats - MFF (mff, uses `mne.io.egi.egimff.export_evokeds_to_mff`) + MFF (mff, uses :func:`mne.io.export_evokeds_to_mff`) %(export_warning)s :func:`mne.write_evokeds` instead. Parameters @@ -1486,7 +1486,7 @@ def export_evokeds(fname, evoked, fmt='auto', **kwargs): logger.info(f'Exporting evoked dataset to {fname}...') if fmt == 'mff': - from .io.egi.egimff import export_evokeds_to_mff + from .io import export_evokeds_to_mff export_evokeds_to_mff(fname, evoked, **kwargs) elif fmt == 'eeglab': raise NotImplementedError('Export to EEGLAB not implemented.') diff --git a/mne/io/__init__.py b/mne/io/__init__.py index 6c52b9c5c7a..c7333c48280 100644 --- a/mne/io/__init__.py +++ b/mne/io/__init__.py @@ -40,7 +40,7 @@ from .ctf import read_raw_ctf from .curry import read_raw_curry from .edf import read_raw_edf, read_raw_bdf, read_raw_gdf -from .egi import read_raw_egi, read_evokeds_mff +from .egi import read_raw_egi, read_evokeds_mff, export_evokeds_to_mff from .kit import read_raw_kit, read_epochs_kit from .fiff import read_raw_fif from .nedf import read_raw_nedf diff --git a/mne/io/egi/__init__.py b/mne/io/egi/__init__.py index dccf8e6e6bf..1154622015f 100644 --- a/mne/io/egi/__init__.py +++ b/mne/io/egi/__init__.py @@ -3,4 +3,4 @@ # Author: Denis A. Engemann from .egi import read_raw_egi -from .egimff import read_evokeds_mff +from .egimff import read_evokeds_mff, export_evokeds_to_mff From 19b6b93580a1def571609738839bf75feb59cc08 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Wed, 2 Jun 2021 09:45:28 -0400 Subject: [PATCH 19/21] ENH: Reorganize --- doc/export.rst | 2 + doc/file_io.rst | 2 - mne/__init__.py | 2 +- mne/evoked.py | 62 +----------- mne/export/__init__.py | 3 +- mne/export/_export.py | 60 +++++++++++- mne/export/tests/test_export.py | 86 +++++++++++++++- mne/io/__init__.py | 2 +- mne/io/egi/__init__.py | 2 +- mne/io/egi/egimff.py | 168 +++----------------------------- mne/tests/test_evoked.py | 80 +-------------- 11 files changed, 169 insertions(+), 300 deletions(-) diff --git a/doc/export.rst b/doc/export.rst index a8349143ba8..32f58f230bf 100644 --- a/doc/export.rst +++ b/doc/export.rst @@ -14,4 +14,6 @@ Exporting :toctree: generated/ export_epochs + export_evokeds + export_evokeds_mff export_raw diff --git a/doc/file_io.rst b/doc/file_io.rst index 2b987a31c64..a1445571309 100644 --- a/doc/file_io.rst +++ b/doc/file_io.rst @@ -44,7 +44,6 @@ File I/O write_cov write_events write_evokeds - export_evokeds write_forward_solution write_label write_proj @@ -54,7 +53,6 @@ File I/O what io.read_info io.show_fiff - io.export_evokeds_to_mff Base class: diff --git a/mne/__init__.py b/mne/__init__.py index 8f7cd1b99ab..b7fc1b99612 100644 --- a/mne/__init__.py +++ b/mne/__init__.py @@ -78,7 +78,7 @@ from .epochs import (BaseEpochs, Epochs, EpochsArray, read_epochs, concatenate_epochs, make_fixed_length_epochs) from .evoked import (Evoked, EvokedArray, read_evokeds, write_evokeds, - export_evokeds, combine_evoked) + combine_evoked) from .label import (read_label, label_sign_flip, write_label, stc_to_label, grow_labels, Label, split_label, BiHemiLabel, read_labels_from_annot, write_labels_to_annot, diff --git a/mne/evoked.py b/mne/evoked.py index cc4e1d91df9..05dcbb05fe5 100644 --- a/mne/evoked.py +++ b/mne/evoked.py @@ -22,7 +22,7 @@ fill_doc, _check_option, ShiftTimeMixin, _build_data_frame, _check_pandas_installed, _check_pandas_index_arguments, _convert_times, _scale_dataframe_data, _check_time_format, - _check_preload, _infer_check_export_fmt) + _check_preload) from .viz import (plot_evoked, plot_evoked_topomap, plot_evoked_field, plot_evoked_image, plot_evoked_topo) from .viz.evoked import plot_evoked_white, plot_evoked_joint @@ -1436,66 +1436,6 @@ def _write_evokeds(fname, evoked, check=True): end_file(fid) -@fill_doc -def export_evokeds(fname, evoked, fmt='auto', **kwargs): - """Export evoked dataset to external formats. - - This function is a wrapper for format-specific export functions. The export - function is selected based on the inferred file format. All arguments are - passed to the respective export function. - - Supported formats - MFF (mff, uses :func:`mne.io.export_evokeds_to_mff`) - %(export_warning)s :func:`mne.write_evokeds` instead. - - Parameters - ---------- - %(export_params_fname)s - evoked : Evoked instance, or list of Evoked instances - The evoked dataset, or list of evoked datasets, to export to one file. - Note that the measurement info from the first evoked instance is used, - so be sure that information matches. - fmt : 'auto' | 'mff' - Format of the export. Defaults to ``'auto'``, which will infer the - format from the filename extension. See supported formats above for - more information. - **kwargs - Additional keyword arguments to pass to the underlying export function. - For details, see the arguments of the export function for the - respective file format. - - See Also - -------- - write_evokeds - - Notes - ----- - .. versionadded:: 0.24 - """ - supported_export_formats = { - 'mff': ('mff',), - 'eeglab': ('set',), - 'edf': ('edf',), - 'brainvision': ('eeg', 'vmrk', 'vhdr',) - } - fmt = _infer_check_export_fmt(fmt, fname, supported_export_formats) - - if not isinstance(evoked, list): - evoked = [evoked] - - logger.info(f'Exporting evoked dataset to {fname}...') - - if fmt == 'mff': - from .io import export_evokeds_to_mff - export_evokeds_to_mff(fname, evoked, **kwargs) - elif fmt == 'eeglab': - raise NotImplementedError('Export to EEGLAB not implemented.') - elif fmt == 'edf': - raise NotImplementedError('Export to EDF not implemented.') - elif fmt == 'brainvision': - raise NotImplementedError('Export to BrainVision not implemented.') - - def _get_peak(data, times, tmin=None, tmax=None, mode='abs'): """Get feature-index and time of maximum signal from 2D array. diff --git a/mne/export/__init__.py b/mne/export/__init__.py index 8e4ab0dad9b..9d7abae0aff 100644 --- a/mne/export/__init__.py +++ b/mne/export/__init__.py @@ -1 +1,2 @@ -from ._export import export_raw, export_epochs +from ._export import export_raw, export_epochs, export_evokeds +from ._egimff import export_evokeds_mff diff --git a/mne/export/_export.py b/mne/export/_export.py index 52afb4f8d4f..13272752d74 100644 --- a/mne/export/_export.py +++ b/mne/export/_export.py @@ -5,7 +5,8 @@ import os.path as op -from ..utils import verbose, _validate_type +from ._egimff import export_evokeds_mff +from ..utils import verbose, logger, _validate_type @verbose @@ -78,6 +79,63 @@ def export_epochs(fname, epochs, fmt='auto', verbose=None): raise NotImplementedError('Export to BrainVision not implemented.') +@verbose +def export_evokeds(fname, evoked, fmt='auto', verbose=None): + """Export evoked dataset to external formats. + + This function is a wrapper for format-specific export functions. The export + function is selected based on the inferred file format. For additional + options, use the format-specific functions. + + Supported formats + MFF (mff, uses :func:`mne.export.export_evokeds_mff`) + %(export_warning)s :func:`mne.write_evokeds` instead. + + Parameters + ---------- + %(export_params_fname)s + evoked : Evoked instance, or list of Evoked instances + The evoked dataset, or list of evoked datasets, to export to one file. + Note that the measurement info from the first evoked instance is used, + so be sure that information matches. + fmt : 'auto' | 'mff' + Format of the export. Defaults to ``'auto'``, which will infer the + format from the filename extension. See supported formats above for + more information. + %(verbose)s + + See Also + -------- + mne.write_evokeds + mne.export.export_evokeds_mff + + Notes + ----- + .. versionadded:: 0.24 + """ + supported_export_formats = { + 'mff': ('mff',), + 'eeglab': ('set',), + 'edf': ('edf',), + 'brainvision': ('eeg', 'vmrk', 'vhdr',) + } + fmt = _infer_check_export_fmt(fmt, fname, supported_export_formats) + + if not isinstance(evoked, list): + evoked = [evoked] + + logger.info(f'Exporting evoked dataset to {fname}...') + + if fmt == 'mff': + export_evokeds_mff(fname, evoked) + elif fmt == 'eeglab': + raise NotImplementedError('Export to EEGLAB not implemented.') + elif fmt == 'edf': + raise NotImplementedError('Export to EDF not implemented.') + elif fmt == 'brainvision': + raise NotImplementedError('Export to BrainVision not implemented.') + + def _infer_check_export_fmt(fmt, fname, supported_formats): """Infer export format from filename extension if auto. diff --git a/mne/export/tests/test_export.py b/mne/export/tests/test_export.py index ef7ce429384..2765805ca9b 100644 --- a/mne/export/tests/test_export.py +++ b/mne/export/tests/test_export.py @@ -11,10 +11,18 @@ import numpy as np from numpy.testing import assert_allclose, assert_array_equal -from mne import read_epochs_eeglab, Epochs -from mne.tests.test_epochs import _get_data +from mne import read_epochs_eeglab, Epochs, read_evokeds, read_evokeds_mff +from mne.datasets import testing +from mne.export import export_evokeds, export_evokeds_mff from mne.io import read_raw_fif, read_raw_eeglab -from mne.utils import _check_eeglabio_installed +from mne.utils import _check_eeglabio_installed, requires_version, object_diff +from mne.tests.test_epochs import _get_data + +base_dir = op.join(op.dirname(__file__), '..', '..', 'io', 'tests', 'data') +fname_evoked = op.join(base_dir, 'test-ave.fif') + +data_path = testing.data_path(download=False) +egi_evoked_fname = op.join(data_path, 'EGI', 'test_egi_evoked.mff') @pytest.mark.skipif(not _check_eeglabio_installed(strict=False), @@ -62,3 +70,75 @@ def test_export_epochs_eeglab(tmpdir, preload): assert epochs.event_id.keys() == epochs_read.event_id.keys() # just keys assert_allclose(epochs.times, epochs_read.times) assert_allclose(epochs.get_data(), epochs_read.get_data()) + + +@requires_version('mffpy', '0.5.7') +@testing.requires_testing_data +@pytest.mark.parametrize('fmt', ('auto', 'mff')) +@pytest.mark.parametrize('do_history', (True, False)) +def test_export_evokeds_to_mff(tmpdir, fmt, do_history): + """Test exporting evoked dataset to MFF.""" + evoked = read_evokeds_mff(egi_evoked_fname) + export_fname = op.join(str(tmpdir), 'evoked.mff') + history = [ + { + 'name': 'Test Segmentation', + 'method': 'Segmentation', + 'settings': ['Setting 1', 'Setting 2'], + 'results': ['Result 1', 'Result 2'] + }, + { + 'name': 'Test Averaging', + 'method': 'Averaging', + 'settings': ['Setting 1', 'Setting 2'], + 'results': ['Result 1', 'Result 2'] + } + ] + if do_history: + export_evokeds_mff(export_fname, evoked, history=history) + else: + export_evokeds(export_fname, evoked) + # Drop non-EEG channels + evoked = [ave.drop_channels(['ECG', 'EMG']) for ave in evoked] + evoked_exported = read_evokeds_mff(export_fname) + assert len(evoked) == len(evoked_exported) + for ave, ave_exported in zip(evoked, evoked_exported): + # Compare infos + assert object_diff(ave_exported.info, ave.info) == '' + # Compare data + assert_allclose(ave_exported.data, ave.data) + # Compare properties + assert ave_exported.nave == ave.nave + assert ave_exported.kind == ave.kind + assert ave_exported.comment == ave.comment + assert_allclose(ave_exported.times, ave.times) + + +@requires_version('mffpy', '0.5.7') +@testing.requires_testing_data +def test_export_to_mff_no_device(): + """Test no device type throws ValueError.""" + evoked = read_evokeds_mff(egi_evoked_fname, condition='Category 1') + evoked.info['device_info'] = None + with pytest.raises(ValueError, match='No device type.'): + export_evokeds('output.mff', evoked) + + +@requires_version('mffpy', '0.5.7') +def test_export_to_mff_incompatible_sfreq(): + """Test non-whole number sampling frequency throws ValueError.""" + evoked = read_evokeds(fname_evoked) + with pytest.raises(ValueError, match=f'sfreq: {evoked[0].info["sfreq"]}'): + export_evokeds('output.mff', evoked) + + +@pytest.mark.parametrize('fmt,ext', [ + ('EEGLAB', 'set'), + ('EDF', 'edf'), + ('BrainVision', 'eeg') +]) +def test_export_evokeds_unsupported_format(fmt, ext): + """Test exporting evoked dataset to non-supported formats.""" + evoked = read_evokeds(fname_evoked) + with pytest.raises(NotImplementedError, match=f'Export to {fmt} not imp'): + export_evokeds(f'output.{ext}', evoked) diff --git a/mne/io/__init__.py b/mne/io/__init__.py index c7333c48280..6c52b9c5c7a 100644 --- a/mne/io/__init__.py +++ b/mne/io/__init__.py @@ -40,7 +40,7 @@ from .ctf import read_raw_ctf from .curry import read_raw_curry from .edf import read_raw_edf, read_raw_bdf, read_raw_gdf -from .egi import read_raw_egi, read_evokeds_mff, export_evokeds_to_mff +from .egi import read_raw_egi, read_evokeds_mff from .kit import read_raw_kit, read_epochs_kit from .fiff import read_raw_fif from .nedf import read_raw_nedf diff --git a/mne/io/egi/__init__.py b/mne/io/egi/__init__.py index 1154622015f..dccf8e6e6bf 100644 --- a/mne/io/egi/__init__.py +++ b/mne/io/egi/__init__.py @@ -3,4 +3,4 @@ # Author: Denis A. Engemann from .egi import read_raw_egi -from .egimff import read_evokeds_mff, export_evokeds_to_mff +from .egimff import read_evokeds_mff diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py index a96470e34de..ac6fb5bdcdb 100644 --- a/mne/io/egi/egimff.py +++ b/mne/io/egi/egimff.py @@ -18,10 +18,8 @@ from ..meas_info import _empty_info, create_info, _ensure_meas_date_none_or_dt from ..proj import setup_proj from ..utils import _create_chs, _mult_cal_one -from ..pick import pick_channels, pick_types from ...annotations import Annotations -from ...utils import (verbose, logger, warn, _check_option, _check_fname, - fill_doc) +from ...utils import verbose, logger, warn, _check_option, _check_fname from ...evoked import EvokedArray @@ -893,16 +891,21 @@ def _read_evoked_mff(fname, condition, channel_naming='E%d', verbose=None): # Add EEG reference to info # Initialize 'custom_ref_applied' to False info['custom_ref_applied'] = False - with mff.directory.filepointer('history') as fp: - history = mffpy.XML.from_file(fp) - for entry in history.entries: - if entry['method'] == 'Montage Operations Tool': - if 'Average Reference' in entry['settings']: - # Average reference has been applied - projector, info = setup_proj(info) - else: - # Custom reference has been applied that is not an average - info['custom_ref_applied'] = True + try: + fp = mff.directory.filepointer('history') + except ValueError: # should probably be FileNotFoundError upstream... + pass + else: + with fp: + history = mffpy.XML.from_file(fp) + for entry in history.entries: + if entry['method'] == 'Montage Operations Tool': + if 'Average Reference' in entry['settings']: + # Average reference has been applied + projector, info = setup_proj(info) + else: + # Custom reference has been applied that is not an average + info['custom_ref_applied'] = True # Get nave from categories.xml try: @@ -917,145 +920,6 @@ def _read_evoked_mff(fname, condition, channel_naming='E%d', verbose=None): nave=nave, verbose=verbose) -@fill_doc -def export_evokeds_to_mff(fname, evoked, history=None): - """Export evoked dataset to MFF. - - Use :func:`mne.export_evokeds` instead. - - Parameters - ---------- - %(export_params_fname)s - evoked : list of Evoked instances - List of evoked datasets to export to one file. Note that the - measurement info from the first evoked instance is used, so be sure - that information matches. - history : None (default) | list of dict - Optional list of history entries (dictionaries) to be written to - history.xml. This must adhere to the format described in - mffpy.xml_files.History.content. If None, no history.xml will be - written. - - Notes - ----- - .. versionadded:: 0.24 - - Only EEG channels are written to the output file. - ``info['device_info']['type']`` must be a valid MFF recording device - (e.g. 'HydroCel GSN 256 1.0'). This field is automatically populated when - using MFF read functions. - """ - info = evoked[0].info - if np.round(info['sfreq']) != info['sfreq']: - raise ValueError('Sampling frequency must be a whole number. ' - f'sfreq: {info["sfreq"]}') - sampling_rate = int(info['sfreq']) - - # Initialize writer - mffpy = _import_mffpy('Export evokeds to MFF.') - import pytz - writer = mffpy.Writer(fname) - current_time = pytz.utc.localize(datetime.datetime.utcnow()) - writer.addxml('fileInfo', recordTime=current_time) - try: - device = info['device_info']['type'] - except (TypeError, KeyError): - raise ValueError('No device type. Cannot determine sensor layout.') - writer.add_coordinates_and_sensor_layout(device) - - # Add EEG data - eeg_channels = pick_types(info, eeg=True, exclude=[]) - eeg_bin = mffpy.bin_writer.BinWriter(sampling_rate) - for ave in evoked: - # Signals are converted to µV - block = (ave.data[eeg_channels] * 1e6).astype(np.float32) - eeg_bin.add_block(block, offset_us=0) - writer.addbin(eeg_bin) - - # Add categories - categories_content = _categories_content_from_evokeds(evoked) - writer.addxml('categories', categories=categories_content) - - # Add history - if history: - writer.addxml('historyEntries', entries=history) - - writer.write() - - -def _categories_content_from_evokeds(evoked): - """Return categories.xml content for evoked dataset.""" - content = dict() - begin_time = 0 - for ave in evoked: - # Times are converted to microseconds - sfreq = ave.info['sfreq'] - duration = np.round(len(ave.times) / sfreq * 1e6).astype(int) - end_time = begin_time + duration - event_time = begin_time - np.round(ave.tmin * 1e6).astype(int) - eeg_bads = _get_bad_eeg_channels(ave.info) - content[ave.comment] = [ - _build_segment_content(begin_time, end_time, event_time, eeg_bads, - name='Average', nsegs=ave.nave) - ] - begin_time += duration - return content - - -def _get_bad_eeg_channels(info): - """Return a list of bad EEG channels formatted for categories.xml. - - Given a list of only the EEG channels in file, return the indices of this - list (starting at 1) that correspond to bad channels. - """ - if len(info['bads']) == 0: - return [] - eeg_channels = pick_types(info, eeg=True, exclude=[]) - bad_channels = pick_channels(info['ch_names'], info['bads']) - bads_elementwise = np.isin(eeg_channels, bad_channels) - return list(np.flatnonzero(bads_elementwise) + 1) - - -def _build_segment_content(begin_time, end_time, event_time, eeg_bads, - status='unedited', name=None, pns_bads=None, - nsegs=None): - """Build content for a single segment in categories.xml. - - Segments are sorted into categories in categories.xml. In a segmented MFF - each category can contain multiple segments, but in an averaged MFF each - category only contains one segment (the average). - """ - channel_status = [{ - 'signalBin': 1, - 'exclusion': 'badChannels', - 'channels': eeg_bads - }] - if pns_bads: - channel_status.append({ - 'signalBin': 2, - 'exclusion': 'badChannels', - 'channels': pns_bads - }) - content = { - 'status': status, - 'beginTime': begin_time, - 'endTime': end_time, - 'evtBegin': event_time, - 'evtEnd': event_time, - 'channelStatus': channel_status, - } - if name: - content['name'] = name - if nsegs: - content['keys'] = { - '#seg': { - 'type': 'long', - 'data': nsegs - } - } - return content - - def _import_mffpy(why='read averaged .mff files'): """Import and return module mffpy.""" try: diff --git a/mne/tests/test_evoked.py b/mne/tests/test_evoked.py index 58ae3126956..46ac50ec47a 100644 --- a/mne/tests/test_evoked.py +++ b/mne/tests/test_evoked.py @@ -16,14 +16,12 @@ import pytest from mne import (equalize_channels, pick_types, read_evokeds, write_evokeds, - export_evokeds, combine_evoked, create_info, read_events, + combine_evoked, create_info, read_events, Epochs, EpochsArray) from mne.evoked import _get_peak, Evoked, EvokedArray -from mne.io import read_raw_fif, read_evokeds_mff +from mne.io import read_raw_fif from mne.io.constants import FIFF -from mne.utils import (requires_pandas, grand_average, object_diff, - requires_version) -from mne.datasets.testing import data_path, requires_testing_data +from mne.utils import requires_pandas, grand_average base_dir = op.join(op.dirname(__file__), '..', 'io', 'tests', 'data') fname = op.join(base_dir, 'test-ave.fif') @@ -242,78 +240,6 @@ def test_io_evoked(tmpdir): assert (all(ave.info['maxshield'] is True for ave in aves)) -@requires_version('mffpy', '0.5.7') -@requires_testing_data -@pytest.mark.parametrize('fmt', ('auto', 'mff')) -def test_export_evokeds_to_mff(tmpdir, fmt): - """Test exporting evoked dataset to MFF.""" - egi_path = op.join(data_path(download=False), 'EGI') - egi_evoked_fname = op.join(egi_path, 'test_egi_evoked.mff') - evoked = read_evokeds_mff(egi_evoked_fname) - export_fname = op.join(str(tmpdir), 'evoked.mff') - history = [ - { - 'name': 'Test Segmentation', - 'method': 'Segmentation', - 'settings': ['Setting 1', 'Setting 2'], - 'results': ['Result 1', 'Result 2'] - }, - { - 'name': 'Test Averaging', - 'method': 'Averaging', - 'settings': ['Setting 1', 'Setting 2'], - 'results': ['Result 1', 'Result 2'] - } - ] - export_evokeds(export_fname, evoked, fmt=fmt, history=history) - # Drop non-EEG channels - evoked = [ave.drop_channels(['ECG', 'EMG']) for ave in evoked] - evoked_exported = read_evokeds_mff(export_fname) - assert len(evoked) == len(evoked_exported) - for ave, ave_exported in zip(evoked, evoked_exported): - # Compare infos - assert object_diff(ave_exported.info, ave.info) == '' - # Compare data - assert_allclose(ave_exported.data, ave.data) - # Compare properties - assert ave_exported.nave == ave.nave - assert ave_exported.kind == ave.kind - assert ave_exported.comment == ave.comment - assert_allclose(ave_exported.times, ave.times) - - -@requires_version('mffpy', '0.5.7') -@requires_testing_data -def test_export_to_mff_no_device(): - """Test no device type throws ValueError.""" - egi_path = op.join(data_path(download=False), 'EGI') - egi_evoked_fname = op.join(egi_path, 'test_egi_evoked.mff') - evoked = read_evokeds_mff(egi_evoked_fname, condition='Category 1') - evoked.info['device_info'] = None - with pytest.raises(ValueError, match='No device type.'): - export_evokeds('output.mff', evoked) - - -@requires_version('mffpy', '0.5.7') -def test_export_to_mff_incompatible_sfreq(): - """Test non-whole number sampling frequency throws ValueError.""" - evoked = read_evokeds(fname) - with pytest.raises(ValueError, match=f'sfreq: {evoked[0].info["sfreq"]}'): - export_evokeds('output.mff', evoked) - - -@pytest.mark.parametrize('fmt,ext', [ - ('EEGLAB', 'set'), - ('EDF', 'edf'), - ('BrainVision', 'eeg') -]) -def test_export_evokeds_unsupported_format(fmt, ext): - """Test exporting evoked dataset to non-supported formats.""" - evoked = read_evokeds(fname) - with pytest.raises(NotImplementedError, match=f'Export to {fmt} not imp'): - export_evokeds(f'output.{ext}', evoked) - - def test_shift_time_evoked(tmpdir): """Test for shifting of time scale.""" tempdir = str(tmpdir) From 792b79e5a3d48f690c4b8deec9070b3e1fecb883 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Wed, 2 Jun 2021 13:59:01 -0400 Subject: [PATCH 20/21] FIX: Missed a file --- mne/export/_egimff.py | 150 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 mne/export/_egimff.py diff --git a/mne/export/_egimff.py b/mne/export/_egimff.py new file mode 100644 index 00000000000..f7998243ebc --- /dev/null +++ b/mne/export/_egimff.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# Authors: MNE Developers +# +# License: BSD (3-clause) + +import datetime + +import numpy as np + +from ..io.egi.egimff import _import_mffpy +from ..io.pick import pick_types, pick_channels +from ..utils import verbose + + +@verbose +def export_evokeds_mff(fname, evoked, history=None, *, verbose=None): + """Export evoked dataset to MFF. + + Parameters + ---------- + %(export_params_fname)s + evoked : list of Evoked instances + List of evoked datasets to export to one file. Note that the + measurement info from the first evoked instance is used, so be sure + that information matches. + history : None (default) | list of dict + Optional list of history entries (dictionaries) to be written to + history.xml. This must adhere to the format described in + mffpy.xml_files.History.content. If None, no history.xml will be + written. + %(verbose)s + + Notes + ----- + .. versionadded:: 0.24 + + Only EEG channels are written to the output file. + ``info['device_info']['type']`` must be a valid MFF recording device + (e.g. 'HydroCel GSN 256 1.0'). This field is automatically populated when + using MFF read functions. + """ + mffpy = _import_mffpy('Export evokeds to MFF.') + import pytz + info = evoked[0].info + if np.round(info['sfreq']) != info['sfreq']: + raise ValueError('Sampling frequency must be a whole number. ' + f'sfreq: {info["sfreq"]}') + sampling_rate = int(info['sfreq']) + + # Initialize writer + writer = mffpy.Writer(fname) + current_time = pytz.utc.localize(datetime.datetime.utcnow()) + writer.addxml('fileInfo', recordTime=current_time) + try: + device = info['device_info']['type'] + except (TypeError, KeyError): + raise ValueError('No device type. Cannot determine sensor layout.') + writer.add_coordinates_and_sensor_layout(device) + + # Add EEG data + eeg_channels = pick_types(info, eeg=True, exclude=[]) + eeg_bin = mffpy.bin_writer.BinWriter(sampling_rate) + for ave in evoked: + # Signals are converted to µV + block = (ave.data[eeg_channels] * 1e6).astype(np.float32) + eeg_bin.add_block(block, offset_us=0) + writer.addbin(eeg_bin) + + # Add categories + categories_content = _categories_content_from_evokeds(evoked) + writer.addxml('categories', categories=categories_content) + + # Add history + if history: + writer.addxml('historyEntries', entries=history) + + writer.write() + + +def _categories_content_from_evokeds(evoked): + """Return categories.xml content for evoked dataset.""" + content = dict() + begin_time = 0 + for ave in evoked: + # Times are converted to microseconds + sfreq = ave.info['sfreq'] + duration = np.round(len(ave.times) / sfreq * 1e6).astype(int) + end_time = begin_time + duration + event_time = begin_time - np.round(ave.tmin * 1e6).astype(int) + eeg_bads = _get_bad_eeg_channels(ave.info) + content[ave.comment] = [ + _build_segment_content(begin_time, end_time, event_time, eeg_bads, + name='Average', nsegs=ave.nave) + ] + begin_time += duration + return content + + +def _get_bad_eeg_channels(info): + """Return a list of bad EEG channels formatted for categories.xml. + + Given a list of only the EEG channels in file, return the indices of this + list (starting at 1) that correspond to bad channels. + """ + if len(info['bads']) == 0: + return [] + eeg_channels = pick_types(info, eeg=True, exclude=[]) + bad_channels = pick_channels(info['ch_names'], info['bads']) + bads_elementwise = np.isin(eeg_channels, bad_channels) + return list(np.flatnonzero(bads_elementwise) + 1) + + +def _build_segment_content(begin_time, end_time, event_time, eeg_bads, + status='unedited', name=None, pns_bads=None, + nsegs=None): + """Build content for a single segment in categories.xml. + + Segments are sorted into categories in categories.xml. In a segmented MFF + each category can contain multiple segments, but in an averaged MFF each + category only contains one segment (the average). + """ + channel_status = [{ + 'signalBin': 1, + 'exclusion': 'badChannels', + 'channels': eeg_bads + }] + if pns_bads: + channel_status.append({ + 'signalBin': 2, + 'exclusion': 'badChannels', + 'channels': pns_bads + }) + content = { + 'status': status, + 'beginTime': begin_time, + 'endTime': end_time, + 'evtBegin': event_time, + 'evtEnd': event_time, + 'channelStatus': channel_status, + } + if name: + content['name'] = name + if nsegs: + content['keys'] = { + '#seg': { + 'type': 'long', + 'data': nsegs + } + } + return content From 493953b5349a6af19788d51ebd6e080a68dcb6a9 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 3 Jun 2021 09:08:11 -0400 Subject: [PATCH 21/21] ENH: For newer mffpy --- mne/io/egi/egimff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py index ac6fb5bdcdb..3034212a61b 100644 --- a/mne/io/egi/egimff.py +++ b/mne/io/egi/egimff.py @@ -893,7 +893,7 @@ def _read_evoked_mff(fname, condition, channel_naming='E%d', verbose=None): info['custom_ref_applied'] = False try: fp = mff.directory.filepointer('history') - except ValueError: # should probably be FileNotFoundError upstream... + except (ValueError, FileNotFoundError): # old (<=0.6.3) vs new mffpy pass else: with fp: