From 9f22adbcb2dd478a8d618b8479e5765eaad49c9e Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sun, 1 Oct 2023 05:34:33 -0400 Subject: [PATCH 1/6] MAINT: Use PyPI upload action for published releases (#12037) --- .github/workflows/release.yml | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..43cfded6dad --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +# Upload a Python Package using Twine when a release is created + +name: Build +on: # yamllint disable-line rule:truthy + release: + types: [published] + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + - run: python -m build --sdist --wheel + - run: twine check --strict dist/* + - uses: actions/upload-artifact@v3 + with: + name: dist + path: dist + + pypi-upload: + needs: package + runs-on: ubuntu-latest + if: github.event_name == 'release' + permissions: + id-token: write # for trusted publishing + environment: + name: pypi + url: https://pypi.org/p/mne + steps: + - uses: actions/download-artifact@v3 + with: + name: dist + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 + if: github.event_name == 'release' From 56587f33e2cd3fd253a29784cbe1781235aa0447 Mon Sep 17 00:00:00 2001 From: Maksym Balatsko Date: Sun, 1 Oct 2023 18:50:10 +0200 Subject: [PATCH 2/6] Mark tests as network tests (#12041) --- doc/changes/devel.rst | 1 + doc/changes/names.inc | 2 ++ mne/channels/tests/test_channels.py | 2 ++ mne/datasets/tests/test_datasets.py | 1 + 4 files changed, 6 insertions(+) diff --git a/doc/changes/devel.rst b/doc/changes/devel.rst index a47f500bc5f..994cd842707 100644 --- a/doc/changes/devel.rst +++ b/doc/changes/devel.rst @@ -40,6 +40,7 @@ Bugs ~~~~ - Fix bugs with :func:`mne.preprocessing.realign_raw` where the start of ``other`` was incorrectly cropped; and onsets and durations in ``other.annotations`` were left unsynced with the resampled data (:gh:`11950` by :newcontrib:`Qian Chu`) - Fix bug where ``encoding`` argument was ignored when reading annotations from an EDF file (:gh:`11958` by :newcontrib:`Andrew Gilbert`) +- Mark tests ``test_adjacency_matches_ft`` and ``test_fetch_uncompressed_file`` as network tests (:gh:`12041` by :newcontrib:`Maksym Balatsko`) - Fix bugs with saving splits for :class:`~mne.Epochs` (:gh:`11876` by `Dmitrii Altukhov`_) - Fix bug with multi-plot 3D rendering where only one plot was updated (:gh:`11896` by `Eric Larson`_) - Fix bug where subject birthdays were not correctly read by :func:`mne.io.read_raw_snirf` (:gh:`11912` by `Eric Larson`_) diff --git a/doc/changes/names.inc b/doc/changes/names.inc index 3ebfc3e3ca5..389583fe616 100644 --- a/doc/changes/names.inc +++ b/doc/changes/names.inc @@ -320,6 +320,8 @@ .. _Mainak Jas: https://jasmainak.github.io +.. _Maksym Balatsko: https://github.com/mbalatsko + .. _Marcin Koculak: https://github.com/mkoculak .. _Marian Dovgialo: https://github.com/mdovgialo diff --git a/mne/channels/tests/test_channels.py b/mne/channels/tests/test_channels.py index 835be5432bb..42695ae76bf 100644 --- a/mne/channels/tests/test_channels.py +++ b/mne/channels/tests/test_channels.py @@ -51,6 +51,7 @@ ) from mne.datasets import testing from mne.parallel import parallel_func +from mne.utils import requires_good_network io_dir = Path(__file__).parent.parent.parent / "io" base_dir = io_dir / "tests" / "data" @@ -362,6 +363,7 @@ def _download_one_ft_neighbor(neighbor: _BuiltinChannelAdjacency): @pytest.mark.slowtest +@requires_good_network def test_adjacency_matches_ft(tmp_path): """Test correspondence of built-in adjacency matrices with FT repo.""" builtin_neighbors_dir = Path(__file__).parents[1] / "data" / "neighbors" diff --git a/mne/datasets/tests/test_datasets.py b/mne/datasets/tests/test_datasets.py index e3599fe8e6f..6b43565cf33 100644 --- a/mne/datasets/tests/test_datasets.py +++ b/mne/datasets/tests/test_datasets.py @@ -306,6 +306,7 @@ def test_phantom(tmp_path, monkeypatch): assert op.isfile(tmp_path / "phantom_otaniemi" / "mri" / "T1.mgz") +@requires_good_network def test_fetch_uncompressed_file(tmp_path): """Test downloading an uncompressed file with our fetch function.""" dataset_dict = dict( From e4afb0aa211ca2990ead7f4bd93adbf99ad007ef Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sun, 1 Oct 2023 13:10:15 -0400 Subject: [PATCH 3/6] DOC: Remove make test (#12042) --- Makefile | 2 -- doc/install/advanced.rst | 8 ++++++++ doc/install/contributing.rst | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index c0e47ada7fb..b0b61e8370c 100644 --- a/Makefile +++ b/Makefile @@ -33,8 +33,6 @@ sample_data: testing_data: @python -c "import mne; mne.datasets.testing.data_path(verbose=True);" -pytest: test - test-no-network: in sudo unshare -n -- sh -c 'MNE_SKIP_NETWORK_TESTS=1 py.test mne' diff --git a/doc/install/advanced.rst b/doc/install/advanced.rst index 36c4e440fd9..065b9c1f9e7 100644 --- a/doc/install/advanced.rst +++ b/doc/install/advanced.rst @@ -209,6 +209,14 @@ or by doing :func:`mne.viz.set_3d_options(antialias=False) ` within a given Python session. +Some hardware-accelerated graphics on linux (e.g., some Intel graphics cards) +provide an insufficient implementation of OpenGL, and in those cases it can help to +force software rendering instead with something like: + +.. code-block:: console + + $ export LIBGL_ALWAYS_SOFTWARE=true + Another issue that may come up is that the MESA software itself may be out of date in certain operating systems, for example CentOS. This may lead to incomplete rendering of some 3D plots. A solution is described in this `Github comment `_. diff --git a/doc/install/contributing.rst b/doc/install/contributing.rst index 21fb16b337d..d741c540479 100644 --- a/doc/install/contributing.rst +++ b/doc/install/contributing.rst @@ -872,7 +872,7 @@ Running the test suite The ``--pdb`` flag will automatically start the python debugger upon test failure. -The full test suite can be run by calling ``make test`` from the +The full test suite can be run by calling ``pytest -m "not ultraslowtest" mne`` from the ``mne-python`` root folder. Testing the entire module can be quite slow, however, so to run individual tests while working on a new feature, you can run the following line:: From bd4d1d6df252e499f52598733da15d0757bce173 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sun, 1 Oct 2023 14:58:19 -0400 Subject: [PATCH 4/6] ENH: Add Forward.save and hdf5 support (#12036) --- doc/changes/devel.rst | 1 + mne/_fiff/meas_info.py | 3 ++ mne/_fiff/tests/test_meas_info.py | 8 ++++++ mne/forward/forward.py | 47 +++++++++++++++++++++++++------ mne/forward/tests/test_forward.py | 9 ++++++ mne/utils/docs.py | 9 ++++++ 6 files changed, 69 insertions(+), 8 deletions(-) diff --git a/doc/changes/devel.rst b/doc/changes/devel.rst index 994cd842707..aac7801085d 100644 --- a/doc/changes/devel.rst +++ b/doc/changes/devel.rst @@ -33,6 +33,7 @@ Enhancements - Add helpful error messages when using methods on empty :class:`mne.Epochs`-objects (:gh:`11306` by `Martin Schulz`_) - Add inferring EEGLAB files' montage unit automatically based on estimated head radius using :func:`read_raw_eeglab(..., montage_units="auto") ` (:gh:`11925` by `Jack Zhang`_, :gh:`11951` by `Eric Larson`_) - Add :class:`~mne.time_frequency.EpochsSpectrumArray` and :class:`~mne.time_frequency.SpectrumArray` to support creating power spectra from :class:`NumPy array ` data (:gh:`11803` by `Alex Rockhill`_) +- Add support for writing forward solutions to HDF5 and convenience function :meth:`mne.Forward.save` (:gh:`12036` by `Eric Larson`_) - Refactored internals of :func:`mne.read_annotations` (:gh:`11964` by `Paul Roujansky`_) - Enhance :func:`~mne.viz.plot_evoked_field` with a GUI that has controls for time, colormap, and contour lines (:gh:`11942` by `Marijn van Vliet`_) diff --git a/mne/_fiff/meas_info.py b/mne/_fiff/meas_info.py index fe5c9d0d881..672f805c1b8 100644 --- a/mne/_fiff/meas_info.py +++ b/mne/_fiff/meas_info.py @@ -80,6 +80,7 @@ _check_on_missing, fill_doc, _check_fname, + check_fname, repr_html, ) from ._digitization import ( @@ -2006,6 +2007,8 @@ def read_info(fname, verbose=None): ------- %(info_not_none)s """ + check_fname(fname, "Info", (".fif", ".fif.gz")) + fname = _check_fname(fname, must_exist=True, overwrite="read") f, tree, _ = fiff_open(fname) with f as fid: info = read_meas_info(fid, tree)[0] diff --git a/mne/_fiff/tests/test_meas_info.py b/mne/_fiff/tests/test_meas_info.py index 6cee0c94d76..844d04fc624 100644 --- a/mne/_fiff/tests/test_meas_info.py +++ b/mne/_fiff/tests/test_meas_info.py @@ -345,6 +345,14 @@ def test_read_write_info(tmp_path): write_info(fname, info) +@testing.requires_testing_data +def test_dir_warning(): + """Test that trying to read a bad filename emits a warning before an error.""" + with pytest.raises(OSError, match="directory"): + with pytest.warns(RuntimeWarning, match="foo"): + read_info(ctf_fname) + + def test_io_dig_points(tmp_path): """Test Writing for dig files.""" dest = tmp_path / "test.txt" diff --git a/mne/forward/forward.py b/mne/forward/forward.py index 0fcb821ab2d..07ea99d59ce 100644 --- a/mne/forward/forward.py +++ b/mne/forward/forward.py @@ -81,6 +81,7 @@ _stamp_to_dt, _on_missing, repr_html, + _import_h5io_funcs, ) from ..label import Label @@ -165,6 +166,18 @@ def copy(self): """Copy the Forward instance.""" return Forward(deepcopy(self)) + @verbose + def save(self, fname, *, overwrite=False, verbose=None): + """Save the forward solution. + + Parameters + ---------- + %(fname_fwd)s + %(overwrite)s + %(verbose)s + """ + write_forward_solution(fname, self, overwrite=overwrite) + def _get_src_type_and_ori_for_repr(self): src_types = np.array([src["type"] for src in self["src"]]) @@ -520,7 +533,8 @@ def read_forward_solution(fname, include=(), exclude=(), *, ordered=None, verbos Parameters ---------- fname : path-like - The file name, which should end with ``-fwd.fif`` or ``-fwd.fif.gz``. + The file name, which should end with ``-fwd.fif``, ``-fwd.fif.gz``, + ``_fwd.fif``, ``_fwd.fif.gz``, ``-fwd.h5``, or ``_fwd.h5``. include : list, optional List of names of channels to include. If empty all channels are included. @@ -554,11 +568,15 @@ def read_forward_solution(fname, include=(), exclude=(), *, ordered=None, verbos forward solution with :func:`read_forward_solution`. """ check_fname( - fname, "forward", ("-fwd.fif", "-fwd.fif.gz", "_fwd.fif", "_fwd.fif.gz") + fname, + "forward", + ("-fwd.fif", "-fwd.fif.gz", "_fwd.fif", "_fwd.fif.gz", "-fwd.h5", "_fwd.h5"), ) fname = _check_fname(fname=fname, must_exist=True, overwrite="read") # Open the file, create directory logger.info("Reading forward solution from %s..." % fname) + if fname.suffix == ".h5": + return _read_forward_hdf5(fname) f, tree, _ = fiff_open(fname) with f as fid: # Find all forward solutions @@ -861,9 +879,7 @@ def write_forward_solution(fname, fwd, overwrite=False, verbose=None): Parameters ---------- - fname : path-like - File name to save the forward solution to. It should end with - ``-fwd.fif`` or ``-fwd.fif.gz``. + %(fname_fwd)s fwd : Forward Forward solution. %(overwrite)s @@ -889,13 +905,28 @@ def write_forward_solution(fname, fwd, overwrite=False, verbose=None): forward solution with :func:`read_forward_solution`. """ check_fname( - fname, "forward", ("-fwd.fif", "-fwd.fif.gz", "_fwd.fif", "_fwd.fif.gz") + fname, + "forward", + ("-fwd.fif", "-fwd.fif.gz", "_fwd.fif", "_fwd.fif.gz", "-fwd.h5", "_fwd.h5"), ) # check for file existence and expand `~` if present fname = _check_fname(fname, overwrite) - with start_and_end_file(fname) as fid: - _write_forward_solution(fid, fwd) + if fname.suffix == ".h5": + _write_forward_hdf5(fname, fwd) + else: + with start_and_end_file(fname) as fid: + _write_forward_solution(fid, fwd) + + +def _write_forward_hdf5(fname, fwd): + _, write_hdf5 = _import_h5io_funcs() + write_hdf5(fname, dict(fwd=fwd), overwrite=True) + + +def _read_forward_hdf5(fname): + read_hdf5, _ = _import_h5io_funcs() + return Forward(read_hdf5(fname)["fwd"]) def _write_forward_solution(fid, fwd): diff --git a/mne/forward/tests/test_forward.py b/mne/forward/tests/test_forward.py index d6981945ac6..ee37f11676c 100644 --- a/mne/forward/tests/test_forward.py +++ b/mne/forward/tests/test_forward.py @@ -197,6 +197,15 @@ def test_io_forward(tmp_path): fwd_read = read_forward_solution(fname_temp) assert_forward_allclose(fwd, fwd_read) + h5py = pytest.importorskip("h5py") + pytest.importorskip("h5io") + fname_h5 = fname_temp.with_suffix(".h5") + fwd.save(fname_h5) + with h5py.File(fname_h5, "r"): + pass # just checks for hdf5-ness + fwd_read = read_forward_solution(fname_h5) + assert_forward_allclose(fwd, fwd_read) + @testing.requires_testing_data def test_apply_forward(): diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 53a394022a3..7ec2dbc4534 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -1694,6 +1694,15 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): Name of the output file. """ +docdict[ + "fname_fwd" +] = """ +fname : path-like + File name to save the forward solution to. It should end with + ``-fwd.fif`` or ``-fwd.fif.gz`` to save to FIF, or ``-fwd.h5`` to save to + HDF5. +""" + docdict[ "fnirs" ] = """ From 3c3ec57feebbdddb826535fe16768db787519bae Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sun, 1 Oct 2023 14:58:42 -0400 Subject: [PATCH 5/6] MAINT: Warn when fitting cHPI amplitudes on Maxwell filtered data (#12038) --- mne/chpi.py | 10 ++++++++++ mne/tests/test_chpi.py | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/mne/chpi.py b/mne/chpi.py index 311d372384b..96ce72ee195 100644 --- a/mne/chpi.py +++ b/mne/chpi.py @@ -624,6 +624,16 @@ def _setup_hpi_amplitude_fitting( on_missing = "raise" if not allow_empty else "ignore" hpi_freqs, hpi_pick, hpi_ons = get_chpi_info(info, on_missing=on_missing) + # check for maxwell filtering + for ent in info["proc_history"]: + for key in ("sss_info", "max_st"): + if len(ent["max_info"]["sss_info"]) > 0: + warn( + "Fitting cHPI amplutudes after Maxwell filtering may not to work, " + "consider fitting on the original data" + ) + break + _validate_type(t_window, (str, "numeric"), "t_window") if info["line_freq"] is not None: line_freqs = np.arange( diff --git a/mne/tests/test_chpi.py b/mne/tests/test_chpi.py index c374917676d..1e9b249ce02 100644 --- a/mne/tests/test_chpi.py +++ b/mne/tests/test_chpi.py @@ -422,6 +422,15 @@ def test_calculate_chpi_positions_artemis(): ) +@testing.requires_testing_data +def test_warn_maxwell_filtered(): + """Test that trying to compute locations on Maxwell filtered data warns.""" + raw = read_raw_fif(sss_fif_fname).crop(0, 1) + with pytest.warns(RuntimeWarning, match="Maxwell filter"): + amps = compute_chpi_amplitudes(raw) + assert len(amps["times"]) > 0 # but for this file, it does work! + + @testing.requires_testing_data def test_initial_fit_redo(): """Test that initial fits can be redone based on moments.""" From ff6bad289da40e08b82e4db010815c607a09d2a7 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sun, 1 Oct 2023 17:29:04 -0400 Subject: [PATCH 6/6] BUG: Fix bug with validation of info["bads"] (#12039) --- doc/changes/devel.rst | 1 + mne/_fiff/meas_info.py | 74 +++++++++++++------ mne/_fiff/tests/test_meas_info.py | 36 +++++++-- mne/_fiff/tests/test_pick.py | 4 +- mne/channels/channels.py | 3 +- mne/forward/forward.py | 6 +- mne/io/artemis123/artemis123.py | 14 ++-- mne/io/cnt/cnt.py | 2 +- mne/preprocessing/__init__.py | 2 +- .../{annotate_nan.py => _annotate_nan.py} | 0 mne/preprocessing/ica.py | 2 +- mne/viz/topomap.py | 3 +- tutorials/io/60_ctf_bst_auditory.py | 2 +- 13 files changed, 99 insertions(+), 50 deletions(-) rename mne/preprocessing/{annotate_nan.py => _annotate_nan.py} (100%) diff --git a/doc/changes/devel.rst b/doc/changes/devel.rst index aac7801085d..b9ec5e5777f 100644 --- a/doc/changes/devel.rst +++ b/doc/changes/devel.rst @@ -55,6 +55,7 @@ Bugs - Fix bug with :meth:`~mne.viz.Brain.add_annotation` when reading an annotation from a file with both hemispheres shown (:gh:`11946` by `Marijn van Vliet`_) - Fix bug with axis clip box boundaries in :func:`mne.viz.plot_evoked_topo` and related functions (:gh:`11999` by `Eric Larson`_) - Fix bug with ``subject_info`` when loading data from and exporting to EDF file (:gh:`11952` by `Paul Roujansky`_) +- Fix bug with delayed checking of :class:`info["bads"] ` (:gh:`12038` by `Eric Larson`_) - Fix handling of channel information in annotations when loading data from and exporting to EDF file (:gh:`11960` :gh:`12017` by `Paul Roujansky`_) - Add missing ``overwrite`` and ``verbose`` parameters to :meth:`Transform.save() ` (:gh:`12004` by `Marijn van Vliet`_) - Correctly prune channel-specific :class:`~mne.Annotations` when creating :class:`~mne.Epochs` without the channel(s) included in the channel specific annotations (:gh:`12010` by `Mathieu Scheltienne`_) diff --git a/mne/_fiff/meas_info.py b/mne/_fiff/meas_info.py index 672f805c1b8..5f3fa7c0000 100644 --- a/mne/_fiff/meas_info.py +++ b/mne/_fiff/meas_info.py @@ -939,18 +939,52 @@ def _check_ch_keys(ch, ci, name='info["chs"]', check_min=True): ) -# As options are added here, test_meas_info.py:test_info_bad should be updated -def _check_bads(bads): +def _check_bads_info_compat(bads, info): _validate_type(bads, list, "bads") - return bads + if not len(bads): + return # e.g. in empty_info + for bi, bad in enumerate(bads): + _validate_type(bad, str, f"bads[{bi}]") + if "ch_names" not in info: # somewhere in init, or deepcopy, or _empty_info, etc. + return + missing = [bad for bad in bads if bad not in info["ch_names"]] + if len(missing) > 0: + raise ValueError(f"bad channel(s) {missing} marked do not exist in info") + + +class MNEBadsList(list): + """Subclass of bads that checks inplace operations.""" + + def __init__(self, *, bads, info): + _check_bads_info_compat(bads, info) + self._mne_info = info + super().__init__(bads) + + def extend(self, iterable): + if not isinstance(iterable, list): + iterable = list(iterable) + _check_bads_info_compat(iterable, self._mne_info) + return super().extend(iterable) + + def append(self, x): + return self.extend([x]) + def __iadd__(self, x): + self.extend(x) + return self + + +# As options are added here, test_meas_info.py:test_info_bad should be updated +def _check_bads(bads, *, info): + return MNEBadsList(bads=bads, info=info) -def _check_description(description): + +def _check_description(description, *, info): _validate_type(description, (None, str), "info['description']") return description -def _check_dev_head_t(dev_head_t): +def _check_dev_head_t(dev_head_t, *, info): from ..transforms import Transform, _ensure_trans _validate_type(dev_head_t, (Transform, None), "info['dev_head_t']") @@ -959,23 +993,23 @@ def _check_dev_head_t(dev_head_t): return dev_head_t -def _check_experimenter(experimenter): +def _check_experimenter(experimenter, *, info): _validate_type(experimenter, (None, str), "experimenter") return experimenter -def _check_line_freq(line_freq): +def _check_line_freq(line_freq, *, info): _validate_type(line_freq, (None, "numeric"), "line_freq") line_freq = float(line_freq) if line_freq is not None else line_freq return line_freq -def _check_subject_info(subject_info): +def _check_subject_info(subject_info, *, info): _validate_type(subject_info, (None, dict), "subject_info") return subject_info -def _check_device_info(device_info): +def _check_device_info(device_info, *, info): _validate_type( device_info, ( @@ -987,7 +1021,7 @@ def _check_device_info(device_info): return device_info -def _check_helium_info(helium_info): +def _check_helium_info(helium_info, *, info): _validate_type( helium_info, ( @@ -1472,7 +1506,7 @@ class Info(dict, SetChannelsMixin, MontageMixin, ContainsMixin): "sfreq": "sfreq cannot be set directly. " "Please use method inst.resample() instead.", "subject_info": _check_subject_info, - "temp": lambda x: x, + "temp": lambda x, info=None: x, "utc_offset": "utc_offset cannot be set directly.", "working_dir": "working_dir cannot be set directly.", "xplotter_layout": "xplotter_layout cannot be set directly.", @@ -1482,6 +1516,8 @@ def __init__(self, *args, **kwargs): self._unlocked = True super().__init__(*args, **kwargs) # Deal with h5io writing things as dict + if "bads" in self: + self["bads"] = MNEBadsList(bads=self["bads"], info=self) for key in ("dev_head_t", "ctf_head_t", "dev_ctf_t"): _format_trans(self, key) for res in self.get("hpi_results", []): @@ -1526,7 +1562,9 @@ def __setitem__(self, key, val): if not unlocked: raise RuntimeError(self._attributes[key]) else: - val = self._attributes[key](val) # attribute checker function + val = self._attributes[key]( + val, info=self + ) # attribute checker function else: raise RuntimeError( f"Info does not support directly setting the key {repr(key)}. " @@ -1724,16 +1762,6 @@ def __deepcopy__(self, memodict): def _check_consistency(self, prepend_error=""): """Do some self-consistency checks and datatype tweaks.""" - missing = [bad for bad in self["bads"] if bad not in self["ch_names"]] - if len(missing) > 0: - msg = "%sbad channel(s) %s marked do not exist in info" - raise RuntimeError( - msg - % ( - prepend_error, - missing, - ) - ) meas_date = self.get("meas_date") if meas_date is not None: if ( @@ -3335,7 +3363,7 @@ def _force_update_info(info_base, info_target): The Info object(s) you wish to overwrite using info_base. These objects will be modified in-place. """ - exclude_keys = ["chs", "ch_names", "nchan"] + exclude_keys = ["chs", "ch_names", "nchan", "bads"] info_target = np.atleast_1d(info_target).ravel() all_infos = np.hstack([info_base, info_target]) for ii in all_infos: diff --git a/mne/_fiff/tests/test_meas_info.py b/mne/_fiff/tests/test_meas_info.py index 844d04fc624..feb30400d42 100644 --- a/mne/_fiff/tests/test_meas_info.py +++ b/mne/_fiff/tests/test_meas_info.py @@ -59,6 +59,7 @@ _dt_to_stamp, _add_timedelta_to_stamp, _read_extended_ch_info, + MNEBadsList, ) from mne.minimum_norm import ( make_inverse_operator, @@ -495,8 +496,8 @@ def test_check_consistency(): # Bad channels that are not in the info object info2 = info.copy() - info2["bads"] = ["b", "foo", "bar"] - pytest.raises(RuntimeError, info2._check_consistency) + with pytest.raises(ValueError, match="do not exist"): + info2["bads"] = ["b", "foo", "bar"] # Bad data types info2 = info.copy() @@ -1088,21 +1089,42 @@ def test_pickle(fname_info, unlocked): def test_info_bad(): """Test our info sanity checkers.""" - info = create_info(2, 1000.0, "eeg") + info = create_info(5, 1000.0, "eeg") info["description"] = "foo" info["experimenter"] = "bar" info["line_freq"] = 50.0 info["bads"] = info["ch_names"][:1] info["temp"] = ("whatever", 1.0) - # After 0.24 these should be pytest.raises calls - check, klass = pytest.raises, RuntimeError - with check(klass, match=r"info\['temp'\]"): + with pytest.raises(RuntimeError, match=r"info\['temp'\]"): info["bad_key"] = 1.0 for key, match in [("sfreq", r"inst\.resample"), ("chs", r"inst\.add_channels")]: - with check(klass, match=match): + with pytest.raises(RuntimeError, match=match): info[key] = info[key] with pytest.raises(ValueError, match="between meg<->head"): info["dev_head_t"] = Transform("mri", "head", np.eye(4)) + assert isinstance(info["bads"], MNEBadsList) + with pytest.raises(ValueError, match="do not exist in info"): + info["bads"] = ["foo"] + assert isinstance(info["bads"], MNEBadsList) + with pytest.raises(ValueError, match="do not exist in info"): + info["bads"] += ["foo"] + assert isinstance(info["bads"], MNEBadsList) + with pytest.raises(ValueError, match="do not exist in info"): + info["bads"].append("foo") + assert isinstance(info["bads"], MNEBadsList) + with pytest.raises(ValueError, match="do not exist in info"): + info["bads"].extend(["foo"]) + assert isinstance(info["bads"], MNEBadsList) + x = info["bads"] + with pytest.raises(ValueError, match="do not exist in info"): + x.append("foo") + assert info["bads"] == info["ch_names"][:1] # unchonged + x = info["bads"] + info["ch_names"][1:2] + assert x == info["ch_names"][:2] + assert not isinstance(x, MNEBadsList) # plain list + x = info["ch_names"][1:2] + info["bads"] + assert x == info["ch_names"][1::-1] # like [1, 0] in fancy indexing + assert not isinstance(x, MNEBadsList) # plain list def test_get_montage(): diff --git a/mne/_fiff/tests/test_pick.py b/mne/_fiff/tests/test_pick.py index 51aaa6b3631..786a14a728b 100644 --- a/mne/_fiff/tests/test_pick.py +++ b/mne/_fiff/tests/test_pick.py @@ -567,8 +567,8 @@ def test_clean_info_bads(): info = pick_info(raw.info, picks_meg) info._check_consistency() - info["bads"] += ["EEG 053"] - pytest.raises(RuntimeError, info._check_consistency) + with pytest.raises(ValueError, match="do not exist"): + info["bads"] += ["EEG 053"] with pytest.raises(ValueError, match="unique"): pick_info(raw.info, [0, 0]) diff --git a/mne/channels/channels.py b/mne/channels/channels.py index b6c82f27be2..d57610d257f 100644 --- a/mne/channels/channels.py +++ b/mne/channels/channels.py @@ -976,7 +976,7 @@ def rename_channels(info, mapping, allow_duplicates=False, *, verbose=None): raise ValueError("New channel names are not unique, renaming failed") # do the remapping in info - info["bads"] = bads + info["bads"] = [] ch_names_mapping = dict() for ch, ch_name in zip(info["chs"], ch_names): ch_names_mapping[ch["ch_name"]] = ch_name @@ -989,6 +989,7 @@ def rename_channels(info, mapping, allow_duplicates=False, *, verbose=None): proj["data"]["col_names"], ch_names_mapping ) info._update_redundant() + info["bads"] = bads info._check_consistency() diff --git a/mne/forward/forward.py b/mne/forward/forward.py index 07ea99d59ce..31aa3c2bdfc 100644 --- a/mne/forward/forward.py +++ b/mne/forward/forward.py @@ -458,11 +458,9 @@ def _read_forward_meas_info(tree, fid): else: raise ValueError("MEG/head coordinate transformation not found") - info["bads"] = _read_bad_channels( - fid, parent_meg, ch_names_mapping=ch_names_mapping - ) + bads = _read_bad_channels(fid, parent_meg, ch_names_mapping=ch_names_mapping) # clean up our bad list, old versions could have non-existent bads - info["bads"] = [bad for bad in info["bads"] if bad in info["ch_names"]] + info["bads"] = [bad for bad in bads if bad in info["ch_names"]] # Check if a custom reference has been applied tag = find_tag(fid, parent_mri, FIFF.FIFF_MNE_CUSTOM_REF) diff --git a/mne/io/artemis123/artemis123.py b/mne/io/artemis123/artemis123.py index 34a0c0118a6..8d937067a5d 100644 --- a/mne/io/artemis123/artemis123.py +++ b/mne/io/artemis123/artemis123.py @@ -192,7 +192,7 @@ def _get_artemis123_info(fname, pos_fname=None): # load mne loc dictionary loc_dict = _load_mne_locs() info["chs"] = [] - info["bads"] = [] + bads = [] for i, chan in enumerate(header_info["channels"]): # build chs struct @@ -209,7 +209,7 @@ def _get_artemis123_info(fname, pos_fname=None): # a value of another ref channel to make writers/readers happy. if t["cal"] == 0: t["cal"] = 4.716e-10 - info["bads"].append(t["ch_name"]) + bads.append(t["ch_name"]) t["loc"] = loc_dict.get(chan["name"], np.zeros(12)) if chan["name"].startswith("MEG"): @@ -247,7 +247,7 @@ def _get_artemis123_info(fname, pos_fname=None): t["coil_type"] = FIFF.FIFFV_COIL_NONE t["kind"] = FIFF.FIFFV_MISC_CH t["unit"] = FIFF.FIFF_UNIT_V - info["bads"].append(t["ch_name"]) + bads.append(t["ch_name"]) elif chan["name"].startswith(("AUX", "TRG", "MIO")): t["coil_type"] = FIFF.FIFFV_COIL_NONE @@ -268,10 +268,7 @@ def _get_artemis123_info(fname, pos_fname=None): # append this channel to the info info["chs"].append(t) if chan["FLL_ResetLock"] == "TRUE": - info["bads"].append(t["ch_name"]) - - # reduce info['bads'] to unique set - info["bads"] = list(set(info["bads"])) + bads.append(t["ch_name"]) # HPI information # print header_info.keys() @@ -313,6 +310,9 @@ def _get_artemis123_info(fname, pos_fname=None): info._unlocked = False info._update_redundant() + # reduce info['bads'] to unique set + info["bads"] = list(set(bads)) + del bads return info, header_info diff --git a/mne/io/cnt/cnt.py b/mne/io/cnt/cnt.py index 7d810a0cd49..1a6aa15b7f3 100644 --- a/mne/io/cnt/cnt.py +++ b/mne/io/cnt/cnt.py @@ -404,12 +404,12 @@ def _get_cnt_info(input_fname, eog, ecg, emg, misc, data_format, date_format): meas_date=meas_date, dig=dig, description=session_label, - bads=bads, subject_info=subject_info, chs=chs, ) info._unlocked = False info._update_redundant() + info["bads"] = bads return info, cnt_info diff --git a/mne/preprocessing/__init__.py b/mne/preprocessing/__init__.py index 8358f006e09..372df010e8c 100644 --- a/mne/preprocessing/__init__.py +++ b/mne/preprocessing/__init__.py @@ -52,7 +52,7 @@ "read_fine_calibration", "write_fine_calibration", ], - "annotate_nan": ["annotate_nan"], + "_annotate_nan": ["annotate_nan"], "interpolate": ["equalize_bads", "interpolate_bridged_electrodes"], "_css": ["cortical_signal_suppression"], "hfc": ["compute_proj_hfc"], diff --git a/mne/preprocessing/annotate_nan.py b/mne/preprocessing/_annotate_nan.py similarity index 100% rename from mne/preprocessing/annotate_nan.py rename to mne/preprocessing/_annotate_nan.py diff --git a/mne/preprocessing/ica.py b/mne/preprocessing/ica.py index 1414f2a4ca5..15c1d286d6e 100644 --- a/mne/preprocessing/ica.py +++ b/mne/preprocessing/ica.py @@ -1372,8 +1372,8 @@ def _export_info(self, info, container, add_channels): ] with info._unlock(update_redundant=True, check_after=True): info["chs"] = ch_info - info["bads"] = [ch_names[k] for k in self.exclude] info["projs"] = [] # make sure projections are removed. + info["bads"] = [ch_names[k] for k in self.exclude] @verbose def score_sources( diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index ec518ec37f0..bac42416a29 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -121,11 +121,10 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None): clean_ch_names = _clean_names(info["ch_names"]) for ii, this_ch in enumerate(info["chs"]): this_ch["ch_name"] = clean_ch_names[ii] - info["bads"] = _clean_names(info["bads"]) for comp in info["comps"]: comp["data"]["col_names"] = _clean_names(comp["data"]["col_names"]) - info._update_redundant() + info["bads"] = _clean_names(info["bads"]) info._check_consistency() # special case for merging grad channels diff --git a/tutorials/io/60_ctf_bst_auditory.py b/tutorials/io/60_ctf_bst_auditory.py index 7cb970bc986..01a65ef3234 100644 --- a/tutorials/io/60_ctf_bst_auditory.py +++ b/tutorials/io/60_ctf_bst_auditory.py @@ -153,7 +153,7 @@ # plotted by adding the event list as a keyword argument. As the bad segments # and saccades were added as annotations to the raw data, they are plotted as # well. -raw.plot(block=True) +raw.plot() # %% # Typical preprocessing step is the removal of power line artifact (50 Hz or