From 743269d3efe7751ab3a52e417eacefdec2d19e11 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 09:21:13 +0000 Subject: [PATCH 1/2] Fix pop_select dialog crash on numpy chanlocs pop_select_dialog_spec used `EEG.get("chanlocs", []) or []`, which raises `ValueError: The truth value of an array with more than one element is ambiguous` on the numpy object array that eeg_checkset stores. Loading sample_data/eeglab_data.set and opening the Select data dialog hit this path. Route the chanlocs lookup through the shared chanlocs_as_list helper and cover the ndarray storage with a regression test. Fixes #229 --- src/eegprep/functions/popfunc/pop_select.py | 5 ++++- tests/test_gui_pop_select.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/eegprep/functions/popfunc/pop_select.py b/src/eegprep/functions/popfunc/pop_select.py index 0b419c66..b5023621 100644 --- a/src/eegprep/functions/popfunc/pop_select.py +++ b/src/eegprep/functions/popfunc/pop_select.py @@ -8,6 +8,7 @@ from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import CallbackSpec, ControlSpec, DialogSpec +from eegprep.functions.popfunc._chanutils import chanlocs_as_list from eegprep.functions.popfunc._pop_utils import ( format_history_value, parse_key_value_args, @@ -578,7 +579,9 @@ def _clip_time_matrix(mat): def pop_select_dialog_spec(EEG) -> DialogSpec: """Return the EEGLAB-like dialog spec for ``pop_select``.""" - chanlocs = list(EEG.get("chanlocs", []) or []) + # eeg_checkset stores EEG['chanlocs'] as a numpy object array, so use the + # shared helper instead of `or []`, which raises on ndarray truth checks. + chanlocs = chanlocs_as_list(EEG.get("chanlocs")) channel_labels = tuple(str(chan.get("labels", "")) for chan in chanlocs if isinstance(chan, dict)) channel_types = tuple( value diff --git a/tests/test_gui_pop_select.py b/tests/test_gui_pop_select.py index a428d308..1e84186a 100644 --- a/tests/test_gui_pop_select.py +++ b/tests/test_gui_pop_select.py @@ -85,6 +85,17 @@ def test_gui_channel_picker_exposes_labels_and_types(self): self.assertEqual(controls["chans_button"].callback.params["channels"], ("Fz", "Cz", "HEOG", "VEOG")) self.assertEqual(controls["chantype_button"].callback.params["channels"], ("EEG", "EOG")) + def test_gui_dialog_spec_accepts_numpy_chanlocs(self): + # eeg_checkset normalises EEG['chanlocs'] to a numpy array of dicts; + # the dialog builder must accept that storage form (regression for #229). + eeg = _eeg() + eeg["chanlocs"] = np.asarray(eeg["chanlocs"], dtype=object) + + controls = controls_by_tag(pop_select_dialog_spec(eeg)) + + self.assertEqual(controls["chans_button"].callback.params["channels"], ("Fz", "Cz", "HEOG", "VEOG")) + self.assertEqual(controls["chantype_button"].callback.params["channels"], ("EEG", "EOG")) + def test_gui_result_runs_selection_and_history(self): class Renderer: def run(self, spec, initial_values=None): From 10d766c7dee07b5a306fffe9b38dd1e19faab7a6 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Sun, 14 Jun 2026 03:00:20 -0700 Subject: [PATCH 2/2] Harden chanlocs handling in GUI helpers --- src/eegprep/functions/popfunc/pop_rejcont.py | 3 +- src/eegprep/functions/popfunc/pop_rmbase.py | 6 ++-- src/eegprep/plugins/ICLabel/_prop_numerics.py | 3 +- .../plugins/clean_rawdata/vis_artifacts.py | 8 ++--- src/eegprep/plugins/firfilt/_filtering.py | 6 ++-- src/eegprep/plugins/firfilt/_pop_common.py | 6 ++-- tests/test_gui_pop_clean_rawdata.py | 1 + tests/test_gui_pop_firfilt.py | 29 +++++++++++++++++++ tests/test_pop_prop_extended.py | 10 +++++++ tests/test_pop_rmbase.py | 17 +++++++++++ tests/test_rejection_workflows.py | 2 ++ 11 files changed, 71 insertions(+), 20 deletions(-) diff --git a/src/eegprep/functions/popfunc/pop_rejcont.py b/src/eegprep/functions/popfunc/pop_rejcont.py index 9c875a47..3c283232 100644 --- a/src/eegprep/functions/popfunc/pop_rejcont.py +++ b/src/eegprep/functions/popfunc/pop_rejcont.py @@ -8,6 +8,7 @@ from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import ControlSpec, DialogSpec +from eegprep.functions.popfunc._chanutils import chanlocs_as_list from eegprep.functions.popfunc._pop_utils import format_history_value, parse_key_value_args from eegprep.functions.popfunc._rejection import copy_eeg, one_based_indices, parse_numeric_sequence from eegprep.functions.popfunc.pop_select import pop_select @@ -245,7 +246,7 @@ def _rejcont_winrej(selected: np.ndarray, channel_count: int) -> np.ndarray: def _selected_chanlocs(EEG: dict[str, Any], selected_rows: np.ndarray) -> list[dict[str, Any]]: - chanlocs = list(EEG.get("chanlocs", []) or []) + chanlocs = chanlocs_as_list(EEG.get("chanlocs")) return [chanlocs[index] for index in selected_rows if index < len(chanlocs)] diff --git a/src/eegprep/functions/popfunc/pop_rmbase.py b/src/eegprep/functions/popfunc/pop_rmbase.py index d82dafa2..8808658f 100644 --- a/src/eegprep/functions/popfunc/pop_rmbase.py +++ b/src/eegprep/functions/popfunc/pop_rmbase.py @@ -11,6 +11,7 @@ from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import CallbackSpec, ControlSpec, DialogSpec from eegprep.functions.miscfunc.misc import round_mat +from eegprep.functions.popfunc._chanutils import chanlocs_as_list from eegprep.functions.popfunc._pop_utils import format_history_value, parse_text_tokens from eegprep.functions.popfunc.eeg_findboundaries import eeg_findboundaries from eegprep.functions.sigprocfunc.rmbase import rmbase @@ -418,10 +419,7 @@ def _channel_field_values(EEG: dict[str, Any], field: str, *, unique: bool = Fal def _chanlocs(EEG: dict[str, Any]) -> list[dict[str, Any]]: - chanlocs = EEG.get("chanlocs", []) - if isinstance(chanlocs, np.ndarray): - chanlocs = chanlocs.tolist() - return [chan if isinstance(chan, dict) else {} for chan in list(chanlocs or [])] + return [chan if isinstance(chan, dict) else {} for chan in chanlocs_as_list(EEG.get("chanlocs"))] def _default_baseline_timerange(EEG: dict[str, Any]) -> str: diff --git a/src/eegprep/plugins/ICLabel/_prop_numerics.py b/src/eegprep/plugins/ICLabel/_prop_numerics.py index 2c27ae71..619419c8 100644 --- a/src/eegprep/plugins/ICLabel/_prop_numerics.py +++ b/src/eegprep/plugins/ICLabel/_prop_numerics.py @@ -17,6 +17,7 @@ numeric_vector, parse_plot_options_text, ) +from eegprep.functions.popfunc._chanutils import chanlocs_as_list from eegprep.functions.popfunc._rejection import component_rejection_flags, one_based_indices from eegprep.functions.sigprocfunc.spectopo import compute_spectra from eegprep.plugins.dipfit._utils import normalize_model_list @@ -263,7 +264,7 @@ def _channel_dashboard_data( figure_title=f"Channel {label} - pop_prop_extended()", topography_title=f"Channel {label}", topography_values=index, - topography_chanlocs=list(EEG.get("chanlocs", []) or []), + topography_chanlocs=chanlocs_as_list(EEG.get("chanlocs")), activity=activity, times_ms=times_ms, activity_title="Channel Time Series", diff --git a/src/eegprep/plugins/clean_rawdata/vis_artifacts.py b/src/eegprep/plugins/clean_rawdata/vis_artifacts.py index 6e163e62..cdfd2205 100644 --- a/src/eegprep/plugins/clean_rawdata/vis_artifacts.py +++ b/src/eegprep/plugins/clean_rawdata/vis_artifacts.py @@ -7,6 +7,7 @@ import numpy as np +from eegprep.functions.popfunc._chanutils import chanlocs_as_list from eegprep.functions.sigprocfunc.eegplot import eegplot from eegprep.plugins.clean_rawdata.private.masks import mask_to_intervals @@ -142,9 +143,4 @@ def _nbchan(eeg: dict[str, Any]) -> int: def _chanlocs(eeg: dict[str, Any]) -> list[dict[str, Any]]: - chanlocs = eeg.get("chanlocs", []) - if isinstance(chanlocs, np.ndarray): - chanlocs = chanlocs.tolist() - if isinstance(chanlocs, dict): - chanlocs = [chanlocs] - return [chan if isinstance(chan, dict) else {} for chan in list(chanlocs or [])] + return [chan if isinstance(chan, dict) else {} for chan in chanlocs_as_list(eeg.get("chanlocs"))] diff --git a/src/eegprep/plugins/firfilt/_filtering.py b/src/eegprep/plugins/firfilt/_filtering.py index a86f745b..20300af0 100644 --- a/src/eegprep/plugins/firfilt/_filtering.py +++ b/src/eegprep/plugins/firfilt/_filtering.py @@ -9,6 +9,7 @@ from scipy.signal import filtfilt, firls, firwin, lfilter, minimum_phase, remez from scipy.signal import windows as signal_windows +from eegprep.functions.popfunc._chanutils import chanlocs_as_list from eegprep.plugins.firfilt.findboundaries import findboundaries from eegprep.plugins.firfilt.fir_filterdcpadded import fir_filterdcpadded from eegprep.plugins.firfilt.firws import firws @@ -475,7 +476,4 @@ def _has_values(value: Any) -> bool: def _chanlocs(EEG: dict[str, Any]) -> list[dict[str, Any]]: - chanlocs = EEG.get("chanlocs", []) - if isinstance(chanlocs, np.ndarray): - chanlocs = chanlocs.tolist() - return [chan if isinstance(chan, dict) else {} for chan in list(chanlocs or [])] + return [chan if isinstance(chan, dict) else {} for chan in chanlocs_as_list(EEG.get("chanlocs"))] diff --git a/src/eegprep/plugins/firfilt/_pop_common.py b/src/eegprep/plugins/firfilt/_pop_common.py index 695c1800..29fc1e29 100644 --- a/src/eegprep/plugins/firfilt/_pop_common.py +++ b/src/eegprep/plugins/firfilt/_pop_common.py @@ -7,6 +7,7 @@ import numpy as np from eegprep.functions.guifunc.spec import CallbackSpec, ControlSpec +from eegprep.functions.popfunc._chanutils import chanlocs_as_list from eegprep.functions.popfunc._pop_utils import format_history_value, parse_key_value_args @@ -168,7 +169,4 @@ def _channel_field_values(EEG: dict[str, Any], field: str, *, unique: bool = Fal def _chanlocs(EEG: dict[str, Any]) -> list[dict[str, Any]]: - chanlocs = EEG.get("chanlocs", []) - if isinstance(chanlocs, np.ndarray): - chanlocs = chanlocs.tolist() - return [chan if isinstance(chan, dict) else {} for chan in list(chanlocs or [])] + return [chan if isinstance(chan, dict) else {} for chan in chanlocs_as_list(EEG.get("chanlocs"))] diff --git a/tests/test_gui_pop_clean_rawdata.py b/tests/test_gui_pop_clean_rawdata.py index 9c7e3f3f..89b829b4 100644 --- a/tests/test_gui_pop_clean_rawdata.py +++ b/tests/test_gui_pop_clean_rawdata.py @@ -160,6 +160,7 @@ def run(self, spec, initial_values=None): def test_vis_artifacts_diagnostics_summarizes_samples_and_channels(self): old = _eeg() + old["chanlocs"] = np.asarray(old["chanlocs"], dtype=object) new = dict( old, data=old["data"][:, :30], diff --git a/tests/test_gui_pop_firfilt.py b/tests/test_gui_pop_firfilt.py index f8bcff44..0e90ebb0 100644 --- a/tests/test_gui_pop_firfilt.py +++ b/tests/test_gui_pop_firfilt.py @@ -54,6 +54,15 @@ def test_pop_eegfiltnew_dialog_matches_eeglab_sections(self): self.assertIn(("text", "Channel type(s)", None), labels) self.assertIn(("text", "OR channel labels or indices", None), labels) + def test_pop_eegfiltnew_dialog_accepts_numpy_chanlocs(self): + eeg = _eeg() + eeg["chanlocs"] = np.asarray(eeg["chanlocs"], dtype=object) + + controls = controls_by_tag(pop_eegfiltnew_dialog_spec(eeg)) + + self.assertEqual(controls["chantype_button"].callback.params["channels"], ["EEG", "EOG"]) + self.assertEqual(controls["channels_button"].callback.params["channels"], ["Cz", "Pz", "EOG"]) + def test_pop_eegfiltnew_gui_result_filters_and_returns_history(self): class Renderer: def run(self, spec, initial_values=None): @@ -100,6 +109,26 @@ def run(self, spec, initial_values=None): np.testing.assert_allclose(out["data"][2], before[2]) self.assertIn("'channels', {'Cz' 'Pz'}", command) + def test_pop_eegfiltnew_accepts_numpy_chanlocs_for_channel_type_filtering(self): + eeg = _eeg() + eeg["chanlocs"] = np.asarray(eeg["chanlocs"], dtype=object) + before = eeg["data"].copy() + + out, command = pop_eegfiltnew( + eeg, + "hicutoff", + 30, + "filtorder", + 80, + "chantype", + ["EOG"], + return_com=True, + ) + + np.testing.assert_allclose(out["data"][:2], before[:2]) + self.assertFalse(np.allclose(out["data"][2], before[2])) + self.assertIn("'chantype', {'EOG'}", command) + def test_legacy_pop_eegfilt_dialog_and_gui_result(self): spec = pop_eegfilt_dialog_spec(_eeg()) diff --git a/tests/test_pop_prop_extended.py b/tests/test_pop_prop_extended.py index 2f5bb433..b5c2d399 100644 --- a/tests/test_pop_prop_extended.py +++ b/tests/test_pop_prop_extended.py @@ -73,6 +73,16 @@ def test_classifier_data_parsing_defaults_to_iclabel_and_standard_classes() -> N assert classifier_names(eeg) == ["Other", "ICLabel"] +def test_channel_property_data_accepts_numpy_chanlocs() -> None: + eeg = _iclabel_eeg() + eeg["chanlocs"] = np.asarray(eeg["chanlocs"], dtype=object) + + dashboard = build_extended_property_data(eeg, 1, 1) + + assert len(dashboard.topography_chanlocs) == 4 + assert dashboard.topography_chanlocs[0]["labels"] == "Ch1" + + def test_classifier_name_from_gui_matches_string_values_case_insensitively() -> None: eeg = _iclabel_eeg() diff --git a/tests/test_pop_rmbase.py b/tests/test_pop_rmbase.py index 35f29baa..8eb46d04 100644 --- a/tests/test_pop_rmbase.py +++ b/tests/test_pop_rmbase.py @@ -227,6 +227,23 @@ def test_pop_rmbase_dialog_disables_channel_controls_for_multiple_datasets(): assert controls["channels_button"].enabled is False +def test_pop_rmbase_dialog_accepts_numpy_chanlocs(): + eeg = create_test_eeg(n_channels=2, n_samples=50, srate=50.0, n_trials=2) + eeg["chanlocs"] = np.asarray( + [ + {"labels": "Cz", "type": "EEG"}, + {"labels": "EOG", "type": "EOG"}, + ], + dtype=object, + ) + + spec = pop_rmbase_dialog_spec(eeg) + controls = {control.tag: control for control in spec.controls if control.tag} + + assert controls["chantypes_button"].callback.params["channels"] == ["EEG", "EOG"] + assert controls["channels_button"].callback.params["channels"] == ["Cz", "EOG"] + + def test_pop_rmbase_sample_data_zeroes_selected_baseline_channels_without_warnings(): eeg = pop_loadset(SAMPLE_DATASET_PATH) diff --git a/tests/test_rejection_workflows.py b/tests/test_rejection_workflows.py index 1dc0855f..09c470f6 100644 --- a/tests/test_rejection_workflows.py +++ b/tests/test_rejection_workflows.py @@ -308,12 +308,14 @@ def fake_eegplot(_data, *args, **kwargs): def test_pop_rejcont_display_accept_removes_continuous_regions(monkeypatch): accepted = [] eeg = create_test_eeg(n_channels=2, n_samples=120, n_trials=1, srate=100) + eeg["chanlocs"] = np.asarray(eeg["chanlocs"], dtype=object) time = np.arange(120) / 100 eeg["data"][0] = 100 * np.sin(2 * np.pi * 30 * time) def fake_eegplot(data, *args, **kwargs): del args assert np.asarray(data).shape[0] == 1 + assert kwargs["eloc_file"][0]["labels"] == "Ch1" kwargs["command_callback"](kwargs["winrej"]) return "window"