Skip to content

Commit ff6bad2

Browse files
authored
BUG: Fix bug with validation of info["bads"] (mne-tools#12039)
1 parent 3c3ec57 commit ff6bad2

File tree

13 files changed

+99
-50
lines changed

13 files changed

+99
-50
lines changed

doc/changes/devel.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Bugs
5555
- 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`_)
5656
- Fix bug with axis clip box boundaries in :func:`mne.viz.plot_evoked_topo` and related functions (:gh:`11999` by `Eric Larson`_)
5757
- Fix bug with ``subject_info`` when loading data from and exporting to EDF file (:gh:`11952` by `Paul Roujansky`_)
58+
- Fix bug with delayed checking of :class:`info["bads"] <mne.Info>` (:gh:`12038` by `Eric Larson`_)
5859
- Fix handling of channel information in annotations when loading data from and exporting to EDF file (:gh:`11960` :gh:`12017` by `Paul Roujansky`_)
5960
- Add missing ``overwrite`` and ``verbose`` parameters to :meth:`Transform.save() <mne.transforms.Transform.save>` (:gh:`12004` by `Marijn van Vliet`_)
6061
- 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`_)

mne/_fiff/meas_info.py

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -939,18 +939,52 @@ def _check_ch_keys(ch, ci, name='info["chs"]', check_min=True):
939939
)
940940

941941

942-
# As options are added here, test_meas_info.py:test_info_bad should be updated
943-
def _check_bads(bads):
942+
def _check_bads_info_compat(bads, info):
944943
_validate_type(bads, list, "bads")
945-
return bads
944+
if not len(bads):
945+
return # e.g. in empty_info
946+
for bi, bad in enumerate(bads):
947+
_validate_type(bad, str, f"bads[{bi}]")
948+
if "ch_names" not in info: # somewhere in init, or deepcopy, or _empty_info, etc.
949+
return
950+
missing = [bad for bad in bads if bad not in info["ch_names"]]
951+
if len(missing) > 0:
952+
raise ValueError(f"bad channel(s) {missing} marked do not exist in info")
953+
954+
955+
class MNEBadsList(list):
956+
"""Subclass of bads that checks inplace operations."""
957+
958+
def __init__(self, *, bads, info):
959+
_check_bads_info_compat(bads, info)
960+
self._mne_info = info
961+
super().__init__(bads)
962+
963+
def extend(self, iterable):
964+
if not isinstance(iterable, list):
965+
iterable = list(iterable)
966+
_check_bads_info_compat(iterable, self._mne_info)
967+
return super().extend(iterable)
968+
969+
def append(self, x):
970+
return self.extend([x])
946971

972+
def __iadd__(self, x):
973+
self.extend(x)
974+
return self
975+
976+
977+
# As options are added here, test_meas_info.py:test_info_bad should be updated
978+
def _check_bads(bads, *, info):
979+
return MNEBadsList(bads=bads, info=info)
947980

948-
def _check_description(description):
981+
982+
def _check_description(description, *, info):
949983
_validate_type(description, (None, str), "info['description']")
950984
return description
951985

952986

953-
def _check_dev_head_t(dev_head_t):
987+
def _check_dev_head_t(dev_head_t, *, info):
954988
from ..transforms import Transform, _ensure_trans
955989

956990
_validate_type(dev_head_t, (Transform, None), "info['dev_head_t']")
@@ -959,23 +993,23 @@ def _check_dev_head_t(dev_head_t):
959993
return dev_head_t
960994

961995

962-
def _check_experimenter(experimenter):
996+
def _check_experimenter(experimenter, *, info):
963997
_validate_type(experimenter, (None, str), "experimenter")
964998
return experimenter
965999

9661000

967-
def _check_line_freq(line_freq):
1001+
def _check_line_freq(line_freq, *, info):
9681002
_validate_type(line_freq, (None, "numeric"), "line_freq")
9691003
line_freq = float(line_freq) if line_freq is not None else line_freq
9701004
return line_freq
9711005

9721006

973-
def _check_subject_info(subject_info):
1007+
def _check_subject_info(subject_info, *, info):
9741008
_validate_type(subject_info, (None, dict), "subject_info")
9751009
return subject_info
9761010

9771011

978-
def _check_device_info(device_info):
1012+
def _check_device_info(device_info, *, info):
9791013
_validate_type(
9801014
device_info,
9811015
(
@@ -987,7 +1021,7 @@ def _check_device_info(device_info):
9871021
return device_info
9881022

9891023

990-
def _check_helium_info(helium_info):
1024+
def _check_helium_info(helium_info, *, info):
9911025
_validate_type(
9921026
helium_info,
9931027
(
@@ -1472,7 +1506,7 @@ class Info(dict, SetChannelsMixin, MontageMixin, ContainsMixin):
14721506
"sfreq": "sfreq cannot be set directly. "
14731507
"Please use method inst.resample() instead.",
14741508
"subject_info": _check_subject_info,
1475-
"temp": lambda x: x,
1509+
"temp": lambda x, info=None: x,
14761510
"utc_offset": "utc_offset cannot be set directly.",
14771511
"working_dir": "working_dir cannot be set directly.",
14781512
"xplotter_layout": "xplotter_layout cannot be set directly.",
@@ -1482,6 +1516,8 @@ def __init__(self, *args, **kwargs):
14821516
self._unlocked = True
14831517
super().__init__(*args, **kwargs)
14841518
# Deal with h5io writing things as dict
1519+
if "bads" in self:
1520+
self["bads"] = MNEBadsList(bads=self["bads"], info=self)
14851521
for key in ("dev_head_t", "ctf_head_t", "dev_ctf_t"):
14861522
_format_trans(self, key)
14871523
for res in self.get("hpi_results", []):
@@ -1526,7 +1562,9 @@ def __setitem__(self, key, val):
15261562
if not unlocked:
15271563
raise RuntimeError(self._attributes[key])
15281564
else:
1529-
val = self._attributes[key](val) # attribute checker function
1565+
val = self._attributes[key](
1566+
val, info=self
1567+
) # attribute checker function
15301568
else:
15311569
raise RuntimeError(
15321570
f"Info does not support directly setting the key {repr(key)}. "
@@ -1724,16 +1762,6 @@ def __deepcopy__(self, memodict):
17241762

17251763
def _check_consistency(self, prepend_error=""):
17261764
"""Do some self-consistency checks and datatype tweaks."""
1727-
missing = [bad for bad in self["bads"] if bad not in self["ch_names"]]
1728-
if len(missing) > 0:
1729-
msg = "%sbad channel(s) %s marked do not exist in info"
1730-
raise RuntimeError(
1731-
msg
1732-
% (
1733-
prepend_error,
1734-
missing,
1735-
)
1736-
)
17371765
meas_date = self.get("meas_date")
17381766
if meas_date is not None:
17391767
if (
@@ -3335,7 +3363,7 @@ def _force_update_info(info_base, info_target):
33353363
The Info object(s) you wish to overwrite using info_base. These objects
33363364
will be modified in-place.
33373365
"""
3338-
exclude_keys = ["chs", "ch_names", "nchan"]
3366+
exclude_keys = ["chs", "ch_names", "nchan", "bads"]
33393367
info_target = np.atleast_1d(info_target).ravel()
33403368
all_infos = np.hstack([info_base, info_target])
33413369
for ii in all_infos:

mne/_fiff/tests/test_meas_info.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
_dt_to_stamp,
6060
_add_timedelta_to_stamp,
6161
_read_extended_ch_info,
62+
MNEBadsList,
6263
)
6364
from mne.minimum_norm import (
6465
make_inverse_operator,
@@ -495,8 +496,8 @@ def test_check_consistency():
495496

496497
# Bad channels that are not in the info object
497498
info2 = info.copy()
498-
info2["bads"] = ["b", "foo", "bar"]
499-
pytest.raises(RuntimeError, info2._check_consistency)
499+
with pytest.raises(ValueError, match="do not exist"):
500+
info2["bads"] = ["b", "foo", "bar"]
500501

501502
# Bad data types
502503
info2 = info.copy()
@@ -1088,21 +1089,42 @@ def test_pickle(fname_info, unlocked):
10881089

10891090
def test_info_bad():
10901091
"""Test our info sanity checkers."""
1091-
info = create_info(2, 1000.0, "eeg")
1092+
info = create_info(5, 1000.0, "eeg")
10921093
info["description"] = "foo"
10931094
info["experimenter"] = "bar"
10941095
info["line_freq"] = 50.0
10951096
info["bads"] = info["ch_names"][:1]
10961097
info["temp"] = ("whatever", 1.0)
1097-
# After 0.24 these should be pytest.raises calls
1098-
check, klass = pytest.raises, RuntimeError
1099-
with check(klass, match=r"info\['temp'\]"):
1098+
with pytest.raises(RuntimeError, match=r"info\['temp'\]"):
11001099
info["bad_key"] = 1.0
11011100
for key, match in [("sfreq", r"inst\.resample"), ("chs", r"inst\.add_channels")]:
1102-
with check(klass, match=match):
1101+
with pytest.raises(RuntimeError, match=match):
11031102
info[key] = info[key]
11041103
with pytest.raises(ValueError, match="between meg<->head"):
11051104
info["dev_head_t"] = Transform("mri", "head", np.eye(4))
1105+
assert isinstance(info["bads"], MNEBadsList)
1106+
with pytest.raises(ValueError, match="do not exist in info"):
1107+
info["bads"] = ["foo"]
1108+
assert isinstance(info["bads"], MNEBadsList)
1109+
with pytest.raises(ValueError, match="do not exist in info"):
1110+
info["bads"] += ["foo"]
1111+
assert isinstance(info["bads"], MNEBadsList)
1112+
with pytest.raises(ValueError, match="do not exist in info"):
1113+
info["bads"].append("foo")
1114+
assert isinstance(info["bads"], MNEBadsList)
1115+
with pytest.raises(ValueError, match="do not exist in info"):
1116+
info["bads"].extend(["foo"])
1117+
assert isinstance(info["bads"], MNEBadsList)
1118+
x = info["bads"]
1119+
with pytest.raises(ValueError, match="do not exist in info"):
1120+
x.append("foo")
1121+
assert info["bads"] == info["ch_names"][:1] # unchonged
1122+
x = info["bads"] + info["ch_names"][1:2]
1123+
assert x == info["ch_names"][:2]
1124+
assert not isinstance(x, MNEBadsList) # plain list
1125+
x = info["ch_names"][1:2] + info["bads"]
1126+
assert x == info["ch_names"][1::-1] # like [1, 0] in fancy indexing
1127+
assert not isinstance(x, MNEBadsList) # plain list
11061128

11071129

11081130
def test_get_montage():

mne/_fiff/tests/test_pick.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -567,8 +567,8 @@ def test_clean_info_bads():
567567

568568
info = pick_info(raw.info, picks_meg)
569569
info._check_consistency()
570-
info["bads"] += ["EEG 053"]
571-
pytest.raises(RuntimeError, info._check_consistency)
570+
with pytest.raises(ValueError, match="do not exist"):
571+
info["bads"] += ["EEG 053"]
572572
with pytest.raises(ValueError, match="unique"):
573573
pick_info(raw.info, [0, 0])
574574

mne/channels/channels.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -976,7 +976,7 @@ def rename_channels(info, mapping, allow_duplicates=False, *, verbose=None):
976976
raise ValueError("New channel names are not unique, renaming failed")
977977

978978
# do the remapping in info
979-
info["bads"] = bads
979+
info["bads"] = []
980980
ch_names_mapping = dict()
981981
for ch, ch_name in zip(info["chs"], ch_names):
982982
ch_names_mapping[ch["ch_name"]] = ch_name
@@ -989,6 +989,7 @@ def rename_channels(info, mapping, allow_duplicates=False, *, verbose=None):
989989
proj["data"]["col_names"], ch_names_mapping
990990
)
991991
info._update_redundant()
992+
info["bads"] = bads
992993
info._check_consistency()
993994

994995

mne/forward/forward.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -458,11 +458,9 @@ def _read_forward_meas_info(tree, fid):
458458
else:
459459
raise ValueError("MEG/head coordinate transformation not found")
460460

461-
info["bads"] = _read_bad_channels(
462-
fid, parent_meg, ch_names_mapping=ch_names_mapping
463-
)
461+
bads = _read_bad_channels(fid, parent_meg, ch_names_mapping=ch_names_mapping)
464462
# clean up our bad list, old versions could have non-existent bads
465-
info["bads"] = [bad for bad in info["bads"] if bad in info["ch_names"]]
463+
info["bads"] = [bad for bad in bads if bad in info["ch_names"]]
466464

467465
# Check if a custom reference has been applied
468466
tag = find_tag(fid, parent_mri, FIFF.FIFF_MNE_CUSTOM_REF)

mne/io/artemis123/artemis123.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def _get_artemis123_info(fname, pos_fname=None):
192192
# load mne loc dictionary
193193
loc_dict = _load_mne_locs()
194194
info["chs"] = []
195-
info["bads"] = []
195+
bads = []
196196

197197
for i, chan in enumerate(header_info["channels"]):
198198
# build chs struct
@@ -209,7 +209,7 @@ def _get_artemis123_info(fname, pos_fname=None):
209209
# a value of another ref channel to make writers/readers happy.
210210
if t["cal"] == 0:
211211
t["cal"] = 4.716e-10
212-
info["bads"].append(t["ch_name"])
212+
bads.append(t["ch_name"])
213213
t["loc"] = loc_dict.get(chan["name"], np.zeros(12))
214214

215215
if chan["name"].startswith("MEG"):
@@ -247,7 +247,7 @@ def _get_artemis123_info(fname, pos_fname=None):
247247
t["coil_type"] = FIFF.FIFFV_COIL_NONE
248248
t["kind"] = FIFF.FIFFV_MISC_CH
249249
t["unit"] = FIFF.FIFF_UNIT_V
250-
info["bads"].append(t["ch_name"])
250+
bads.append(t["ch_name"])
251251

252252
elif chan["name"].startswith(("AUX", "TRG", "MIO")):
253253
t["coil_type"] = FIFF.FIFFV_COIL_NONE
@@ -268,10 +268,7 @@ def _get_artemis123_info(fname, pos_fname=None):
268268
# append this channel to the info
269269
info["chs"].append(t)
270270
if chan["FLL_ResetLock"] == "TRUE":
271-
info["bads"].append(t["ch_name"])
272-
273-
# reduce info['bads'] to unique set
274-
info["bads"] = list(set(info["bads"]))
271+
bads.append(t["ch_name"])
275272

276273
# HPI information
277274
# print header_info.keys()
@@ -313,6 +310,9 @@ def _get_artemis123_info(fname, pos_fname=None):
313310

314311
info._unlocked = False
315312
info._update_redundant()
313+
# reduce info['bads'] to unique set
314+
info["bads"] = list(set(bads))
315+
del bads
316316
return info, header_info
317317

318318

mne/io/cnt/cnt.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,12 +404,12 @@ def _get_cnt_info(input_fname, eog, ecg, emg, misc, data_format, date_format):
404404
meas_date=meas_date,
405405
dig=dig,
406406
description=session_label,
407-
bads=bads,
408407
subject_info=subject_info,
409408
chs=chs,
410409
)
411410
info._unlocked = False
412411
info._update_redundant()
412+
info["bads"] = bads
413413
return info, cnt_info
414414

415415

mne/preprocessing/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"read_fine_calibration",
5353
"write_fine_calibration",
5454
],
55-
"annotate_nan": ["annotate_nan"],
55+
"_annotate_nan": ["annotate_nan"],
5656
"interpolate": ["equalize_bads", "interpolate_bridged_electrodes"],
5757
"_css": ["cortical_signal_suppression"],
5858
"hfc": ["compute_proj_hfc"],

0 commit comments

Comments
 (0)