From c72724d1f029e24f2c5d0cb4058049b939ae0f0f Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Mon, 10 Aug 2026 09:50:12 -0700 Subject: [PATCH 01/12] envtopo test suite --- tests/conftest.py | 1 + tests/test_envtopo.py | 198 ++++++++++++++++++++++++++++ tests/test_envtopo_parity.py | 199 +++++++++++++++++++++++++++++ tests/test_phase4_plot_wrappers.py | 10 ++ 4 files changed, 408 insertions(+) create mode 100644 tests/test_envtopo.py create mode 100644 tests/test_envtopo_parity.py diff --git a/tests/conftest.py b/tests/conftest.py index 633aa9ce..e2d67a95 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -66,6 +66,7 @@ def _preload_matlab_libstdcxx() -> None: "tests/test_eeg_point2lat.py", "tests/test_eeg_rpsd_parity.py", "tests/test_eegfindboundaries.py", + "tests/test_envtopo_parity.py", "tests/test_iclabel.py", "tests/test_iclabel_features.py", "tests/test_parity_rng.py", diff --git a/tests/test_envtopo.py b/tests/test_envtopo.py new file mode 100644 index 00000000..445466e0 --- /dev/null +++ b/tests/test_envtopo.py @@ -0,0 +1,198 @@ +"""Unit and property tests for EEGPrep ``envtopo`` component-envelope math. + +These run without MATLAB and always run in CI. They pin the EEGLAB-parity +contract of ``envtopo``: the component-ranking metric, the selection/ordering of +plotted components, the peak-variance frame per component, and the +``EnvtopoResult`` return shape. Bit-for-bit numerical parity with EEGLAB on real +data is covered separately, MATLAB-gated, in ``test_envtopo_parity.py``. + +The independent metric recomputed here is EEGLAB's default ``sortvar='mp'``: +``mp(c) = max_t( mean_chans( (icawinv[:,c] * (weights[c,:] @ data))**2 ) )`` over +the limcontrib window. Ranking that metric is the core the plot depends on; the +other three modes (``pv``/``pp``/``rp``) are exercised against the live oracle. +""" + +from __future__ import annotations + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +from matplotlib.figure import Figure +import numpy as np +import pytest + +from eegprep.functions.sigprocfunc.envtopo import envtopo +from tests.fixtures import create_test_eeg_with_ica + +pytestmark = pytest.mark.parity + +# Contract: the six EEGLAB numeric outputs, plus the EEGPrep figure. +EXPECTED_FIELDS = ( + "compvarorder", + "compvars", + "compframes", + "comptimes", + "compsplotted", + "sortvar", + "figure", +) + +# Deterministic orthonormal scalp maps (unit-norm columns) so each component's +# mean-square back-projection reduces to its activation power: mean_chans(proj**2) +# = ||map_c||**2 / nchan * act(t)**2 = act(t)**2 / nchan. +_Q3 = np.linalg.qr(np.array([[1.0, 0.2, 0.1], [0.3, 1.0, 0.4], [0.2, 0.5, 1.0]]))[0] + + +def _mp_metric(mean_data, weights, icawinv, limmask): + """Independent EEGLAB ``mp`` metric and peak (0-based) frame per component.""" + acts = weights @ mean_data + n_components = weights.shape[0] + metric = np.empty(n_components) + frame = np.empty(n_components, dtype=int) + window = np.flatnonzero(limmask) + for c in range(n_components): + proj = np.outer(icawinv[:, c], acts[c]) + mean_square = np.mean(proj[:, limmask] ** 2, axis=0) + peak = int(np.argmax(mean_square)) + metric[c] = mean_square[peak] + frame[c] = int(window[peak]) + return metric, frame + + +def _ica_dataset(seed, *, n_components=4): + np.random.seed(seed) + eeg = create_test_eeg_with_ica(n_channels=6, n_samples=40, n_trials=3, n_components=n_components) + data = np.asarray(eeg["data"], dtype=float) + mean_data = data.mean(axis=2) if data.ndim == 3 else data + weights = np.asarray(eeg["icaweights"], dtype=float) @ np.asarray(eeg["icasphere"], dtype=float) + icawinv = np.asarray(eeg["icawinv"], dtype=float) + timerange = [float(eeg["xmin"]) * 1000.0, float(eeg["xmax"]) * 1000.0] + times_ms = np.linspace(timerange[0], timerange[1], mean_data.shape[1]) + return eeg, mean_data, weights, icawinv, timerange, times_ms + + +# --------------------------------------------------------------------------- # +# Closed-form anchor: hand-built input whose ranking is obvious by design. +# --------------------------------------------------------------------------- # +def test_closed_form_ranking_and_peak_frames(): + """Three orthonormal maps with activation powers 9:4:1 rank as [1, 2, 3].""" + frames = 5 + acts = np.zeros((3, frames)) + acts[0, 2] = 3.0 # IC1 peaks at frame 2, power 9 + acts[1, 3] = 2.0 # IC2 peaks at frame 3, power 4 + acts[2, 1] = 1.0 # IC3 peaks at frame 1, power 1 + data = _Q3 @ acts + weights = _Q3.T # orthonormal -> pinv(icawinv) == icawinv.T + timerange = [0.0, 400.0] # -> times_ms = [0, 100, 200, 300, 400] + + res = envtopo(data, weights, chanlocs=None, icawinv=_Q3, timerange=timerange, sortvar="mp") + + # Named-tuple contract (checked here rather than as a standalone type-only test). + assert res._fields == EXPECTED_FIELDS + assert isinstance(res.figure, Figure) + + np.testing.assert_array_equal(res.compvarorder, [1, 2, 3]) + np.testing.assert_array_equal(res.compsplotted, [1, 2, 3]) + # compvars are the metric in ranked (descending) order: act_peak**2 / nchan. + np.testing.assert_allclose(res.compvars, [9 / 3, 4 / 3, 1 / 3], rtol=0, atol=1e-12) + # compframes are 0-based and aligned with compvarorder; comptimes in ms. + peak_frame = dict(zip(res.compvarorder.tolist(), res.compframes.tolist())) + peak_time = dict(zip(res.compvarorder.tolist(), res.comptimes.tolist())) + assert peak_frame == {1: 2, 2: 3, 3: 1} + assert peak_time == {1: 200.0, 2: 300.0, 3: 100.0} + plt.close(res.figure) + + +# --------------------------------------------------------------------------- # +# Property/invariant tests over seeded synthetic ICA datasets. +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("seed", [0, 1, 7]) +def test_ranking_matches_independent_mp_metric(seed): + """envtopo ranks components by the independently recomputed ``mp`` metric.""" + _, mean_data, weights, icawinv, timerange, times_ms = _ica_dataset(seed) + metric, _ = _mp_metric(mean_data, weights, icawinv, np.ones(times_ms.shape, dtype=bool)) + expected_order = (np.argsort(metric)[::-1] + 1).astype(int) # 1-based, descending + + res = envtopo(mean_data, weights, chanlocs=None, icawinv=icawinv, timerange=timerange, sortvar="mp") + + np.testing.assert_array_equal(res.compvarorder, expected_order) + # compvars is the metric in ranked order and is therefore non-increasing. + assert np.all(np.diff(res.compvars) <= 1e-12) + np.testing.assert_allclose(np.sort(res.sortvar), np.sort(metric), rtol=1e-9, atol=1e-12) + plt.close(res.figure) + + +@pytest.mark.parametrize("seed", [0, 1, 7]) +def test_peak_frame_and_time_alignment(seed): + """compframes fall on the metric peak; comptimes are times_ms[compframes].""" + _, mean_data, weights, icawinv, timerange, times_ms = _ica_dataset(seed) + _, expected_frame = _mp_metric(mean_data, weights, icawinv, np.ones(times_ms.shape, dtype=bool)) + + res = envtopo(mean_data, weights, chanlocs=None, icawinv=icawinv, timerange=timerange, sortvar="mp") + + for order_pos, ic in enumerate(res.compvarorder.tolist()): + frame = int(res.compframes[order_pos]) + assert frame == int(expected_frame[ic - 1]) + np.testing.assert_allclose(res.comptimes[order_pos], times_ms[frame], rtol=0, atol=1e-9) + plt.close(res.figure) + + +def test_limcontrib_window_restricts_peak_frames(): + """With a limcontrib window, every peak frame lies inside that window.""" + _, mean_data, weights, icawinv, timerange, times_ms = _ica_dataset(0) + span = timerange[1] - timerange[0] + limcontrib = [timerange[0] + 0.3 * span, timerange[1] - 0.3 * span] + mask = (times_ms >= limcontrib[0]) & (times_ms <= limcontrib[1]) + metric, expected_frame = _mp_metric(mean_data, weights, icawinv, mask) + expected_order = (np.argsort(metric)[::-1] + 1).astype(int) + + res = envtopo( + mean_data, weights, chanlocs=None, icawinv=icawinv, timerange=timerange, limcontrib=limcontrib, sortvar="mp" + ) + + np.testing.assert_array_equal(res.compvarorder, expected_order) + assert np.all((res.comptimes >= limcontrib[0] - 1e-9) & (res.comptimes <= limcontrib[1] + 1e-9)) + plt.close(res.figure) + + +@pytest.mark.parametrize("compsplot,n_components", [(2, 4), (7, 4), (3, 10)]) +def test_compsplotted_count(compsplot, n_components): + """compsplotted length is min(compsplot, n_candidates), capped at MAXTOPOS=20.""" + _, mean_data, weights, icawinv, timerange, _ = _ica_dataset(3, n_components=n_components) + + res = envtopo(mean_data, weights, chanlocs=None, icawinv=icawinv, timerange=timerange, compsplot=compsplot) + + assert res.compsplotted.size == min(compsplot, n_components, 20) + # The plotted set is the top of the full ranking. + np.testing.assert_array_equal(res.compsplotted, res.compvarorder[: res.compsplotted.size]) + plt.close(res.figure) + + +def test_subcomps_are_subtracted_and_excluded_from_selection(): + """Subtracted components get zero contribution and drop out of the top set.""" + _, mean_data, weights, icawinv, timerange, _ = _ica_dataset(1) + + res = envtopo(mean_data, weights, chanlocs=None, icawinv=icawinv, timerange=timerange, compsplot=2, subcomps=[1]) + + assert 1 not in res.compsplotted.tolist() + plt.close(res.figure) + + +def test_unknown_sortvar_raises(): + _, mean_data, weights, icawinv, timerange, _ = _ica_dataset(0) + with pytest.raises(ValueError, match="sortvar"): + envtopo(mean_data, weights, chanlocs=None, icawinv=icawinv, timerange=timerange, sortvar="nope") + + +def test_envmode_rms_runs_and_preserves_ranking(): + """envmode only changes the drawn envelope, not the component ranking.""" + _, mean_data, weights, icawinv, timerange, _ = _ica_dataset(7) + + avg = envtopo(mean_data, weights, chanlocs=None, icawinv=icawinv, timerange=timerange, envmode="avg") + rms = envtopo(mean_data, weights, chanlocs=None, icawinv=icawinv, timerange=timerange, envmode="rms") + + np.testing.assert_array_equal(avg.compvarorder, rms.compvarorder) + plt.close(avg.figure) + plt.close(rms.figure) diff --git a/tests/test_envtopo_parity.py b/tests/test_envtopo_parity.py new file mode 100644 index 00000000..681753b5 --- /dev/null +++ b/tests/test_envtopo_parity.py @@ -0,0 +1,199 @@ +"""Numerical parity between EEGPrep and MATLAB/EEGLAB ``envtopo``. + +Runs EEGLAB's ``envtopo`` through the MATLAB engine and compares the returned +component-contribution outputs (ranking, selection, per-component peak frame and +latency, and the sort-metric values) against EEGPrep's ``envtopo`` on the +identical epoched ICA dataset. Requires the MATLAB engine plus an EEGLAB checkout +(via ``eeglabcompat.get_eeglab``) and is skipped when either is unavailable, e.g. +in CI. No MATLAB output is committed; the reference is regenerated live, mirroring +``test_spectopo_parity.py``. + +Comparisons target the well-defined, quirk-free quantities: + * ``compsplotted`` -- the top-N plotted IC numbers (distinct large metrics, so + ordering is stable) -- compared exactly. + * the candidate set of ``compvarorder`` and its top-N prefix. + * the multiset of ``sortvar`` values (order-free, so it holds across sort modes + and EEGLAB's candidate-vs-sorted ordering). + * per plotted IC, the peak frame and latency, aligned by IC number. +We deliberately do not compare the full ``compvarorder`` tail exactly: on real +data many low-power components have near-equal metrics, so their relative order is +ambiguous between MATLAB's and NumPy's sorts. Boundaries under test: EEGPrep +returns 0-based ``compframes`` (MATLAB is 1-based) and ``comptimes`` in ms +(MATLAB in seconds). +""" + +# Force single-threaded BLAS for deterministic numerics (mirrors +# test_spectopo_parity); must be set before numpy imports. +import os + +os.environ["OMP_NUM_THREADS"] = "1" +os.environ["MKL_NUM_THREADS"] = "1" +os.environ["NUMEXPR_NUM_THREADS"] = "1" +os.environ["OPENBLAS_NUM_THREADS"] = "1" +os.environ["VECLIB_MAXIMUM_THREADS"] = "1" + +import tempfile +import unittest + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import scipy.io + +from eegprep import pop_loadset, pop_saveset +from eegprep.functions.adminfunc.eeglabcompat import get_eeglab +from eegprep.functions.miscfunc.misc import finite_matmul +from eegprep.functions.sigprocfunc.envtopo import envtopo + +local_url = os.path.join(os.path.dirname(__file__), "../sample_data/") + +# Float tolerance for sort-metric values and latencies. Observed max |Δ| is well +# below this on the sample data; it guards against real miscalibration without +# float flakiness. +RTOL = 1e-6 +ATOL = 1e-9 + + +def _icol(value): + return np.asarray(value, dtype=float).ravel().astype(int) + + +def _fcol(value): + return np.asarray(value, dtype=float).ravel() + + +def _matlab_value(value): + if isinstance(value, str): + return f"'{value}'" + if isinstance(value, (list, tuple, np.ndarray)): + return "[" + " ".join(_matlab_scalar(item) for item in np.asarray(value).ravel()) + "]" + return _matlab_scalar(value) + + +def _matlab_scalar(value): + number = float(value) + return str(int(number)) if number.is_integer() else repr(number) + + +def _matlab_options(options): + return "".join(f", '{key}', {_matlab_value(value)}" for key, value in options.items()) + + +class TestEnvtopoParity(unittest.TestCase): + """Parity between Python and MATLAB envtopo component-contribution outputs.""" + + def setUp(self): + try: + self.eeglab = get_eeglab("MAT", auto_file_roundtrip=False) + except Exception as e: + self.skipTest(f"MATLAB/EEGLAB not available: {e}") + # Epoched ICA dataset: envtopo averages epochs and ranks IC back-projections. + self.EEG = pop_loadset(os.path.join(local_url, "eeglab_data_epochs_ica.set")) + data = np.asarray(self.EEG["data"], dtype=float) + self.sig = data.mean(axis=2) if data.ndim == 3 else data + # finite_matmul matches plain matmul but suppresses the spurious FPE warnings + # the arm64 BLAS raises on this (finite) product, as plot_utils does for ICA. + self.weights = finite_matmul( + np.asarray(self.EEG["icaweights"], dtype=float), np.asarray(self.EEG["icasphere"], dtype=float) + ) + self.icawinv = np.asarray(self.EEG["icawinv"], dtype=float) + self.chanlocs = self.EEG["chanlocs"] + self.timerange = [float(self.EEG["xmin"]) * 1000.0, float(self.EEG["xmax"]) * 1000.0] + + def _matlab_outputs(self, options): + """Run EEGLAB envtopo on the saved dataset and return its six outputs.""" + temp_file = tempfile.mktemp(suffix=".set") + pop_saveset(self.EEG, temp_file) + matlab_code = f""" + set(0, 'DefaultFigureVisible', 'off'); + EEG = pop_loadset('{temp_file}'); + sig = mean(EEG.data, 3); + [compvarorder, compvars, compframes, comptimes, compsplotted, sortvar] = envtopo( ... + sig, EEG.icaweights*EEG.icasphere, 'chanlocs', EEG.chanlocs, 'icawinv', EEG.icawinv, ... + 'timerange', [{self.timerange[0]} {self.timerange[1]}]{_matlab_options(options)}); + close all; set(0, 'DefaultFigureVisible', 'on'); + save('{temp_file}.mat', 'compvarorder', 'compvars', 'compframes', 'comptimes', 'compsplotted', 'sortvar'); + """ + self.eeglab.eval(matlab_code, nargout=0) + mat = scipy.io.loadmat(temp_file + ".mat") + os.remove(temp_file) + os.remove(temp_file + ".mat") + fdt = temp_file.replace(".set", ".fdt") + if os.path.exists(fdt): + os.remove(fdt) + return mat + + def _assert_case(self, options): + mat = self._matlab_outputs(options) + ml_order = _icol(mat["compvarorder"]) + ml_plotted = _icol(mat["compsplotted"]) + ml_sortvar = _fcol(mat["sortvar"]) + ml_frames = _icol(mat["compframes"]) # 1-based absolute frames + ml_times = _fcol(mat["comptimes"]) # seconds + + res = envtopo( + self.sig, + self.weights, + chanlocs=self.chanlocs, + icawinv=self.icawinv, + timerange=self.timerange, + **options, + ) + py_order = np.asarray(res.compvarorder, dtype=int) + py_plotted = np.asarray(res.compsplotted, dtype=int) + + # Top-N plotted ICs: exact and order-sensitive. + np.testing.assert_array_equal(py_plotted, ml_plotted, err_msg=f"compsplotted differ for {options}") + # Candidate set matches, and the ranking's top-N prefix equals compsplotted. + self.assertEqual(set(py_order.tolist()), set(ml_order.tolist()), msg=f"candidate set differs for {options}") + np.testing.assert_array_equal(py_order[: ml_plotted.size], ml_plotted) + # Sort-metric values: order-free multiset comparison. + np.testing.assert_allclose( + np.sort(np.asarray(res.sortvar, dtype=float)), + np.sort(ml_sortvar), + rtol=RTOL, + atol=ATOL, + err_msg=f"sortvar multiset differs for {options}", + ) + # Peak frame (0-based) and latency (ms) per plotted IC, aligned by IC number. + py_frame = dict(zip(py_order.tolist(), np.asarray(res.compframes, dtype=int).tolist())) + py_time = dict(zip(py_order.tolist(), np.asarray(res.comptimes, dtype=float).tolist())) + ml_frame = dict(zip(ml_order.tolist(), (ml_frames - 1).tolist())) + ml_time = dict(zip(ml_order.tolist(), (ml_times * 1000.0).tolist())) + for ic in ml_plotted.tolist(): + self.assertEqual(py_frame[ic], ml_frame[ic], msg=f"peak frame differs for IC{ic}, {options}") + np.testing.assert_allclose( + py_time[ic], ml_time[ic], rtol=RTOL, atol=ATOL, err_msg=f"peak latency differs for IC{ic}, {options}" + ) + plt.close(res.figure) + + def test_parity_sortvar_modes(self): + """All four ranking modes match EEGLAB (mp default, plus pv/pp/rp).""" + for mode in ("mp", "pv", "pp", "rp"): + with self.subTest(sortvar=mode): + self._assert_case({"sortvar": mode}) + + def test_parity_compnums_subset(self): + """Ranking restricted to an explicit candidate list.""" + self._assert_case({"compnums": [1, 3, 5, 7, 9, 11]}) + + def test_parity_compsplot_count(self): + """Plotting fewer than the default number of components.""" + self._assert_case({"compsplot": 3}) + + def test_parity_limcontrib_window(self): + """Ranking window narrower than the epoch.""" + span = self.timerange[1] - self.timerange[0] + limcontrib = [self.timerange[0] + 0.3 * span, self.timerange[1] - 0.3 * span] + self._assert_case({"limcontrib": limcontrib}) + + def test_parity_subcomps_subtracted(self): + """Subtracted components are removed before ranking, matching EEGLAB.""" + self._assert_case({"subcomps": [2, 4]}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index 1262e40b..f6b3fc31 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1398,6 +1398,16 @@ def test_pop_envtopo_uses_icachansind_subset_and_rejects_multiple(ica_epoch): pop_envtopo([ica_epoch, deepcopy(ica_epoch)], components=[1]) +def test_pop_envtopo_threads_eeglab_options_into_history(ica_epoch): + figure, command = pop_envtopo(ica_epoch, compsplot=2, sortvar="pp", return_com=True) + + assert isinstance(figure, Figure) + _assert_python_command(command) + assert "sortvar='pp'" in command + assert "compsplot=2" in command + plt.close(figure) + + def test_pop_comperp_and_chanplot_work_on_epoched_dataset_lists(sample_epoch): second = deepcopy(sample_epoch) second["setname"] = "second" From 9044c71c09dd00764a27547a4e59763145e18771 Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Mon, 10 Aug 2026 10:29:46 -0700 Subject: [PATCH 02/12] =?UTF-8?q?test:=20fix=20envtopo=20parity=20referenc?= =?UTF-8?q?e=20=E2=80=94=20double-precision=20sig,=20latency=20via=20compf?= =?UTF-8?q?rames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EEG.data is single; average/back-project the MATLAB reference in double so it matches EEGPrep's float64 (rtol=1e-6 holds). EEGLAB's comptimes output double-applies the sort permutation (envtopo.m:731) and is misaligned, so derive the expected latency from MATLAB's correct compframes instead. --- tests/test_envtopo_parity.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/test_envtopo_parity.py b/tests/test_envtopo_parity.py index 681753b5..ed32b292 100644 --- a/tests/test_envtopo_parity.py +++ b/tests/test_envtopo_parity.py @@ -110,7 +110,7 @@ def _matlab_outputs(self, options): matlab_code = f""" set(0, 'DefaultFigureVisible', 'off'); EEG = pop_loadset('{temp_file}'); - sig = mean(EEG.data, 3); + sig = mean(double(EEG.data), 3); % double: EEG.data is single, EEGPrep computes in float64 [compvarorder, compvars, compframes, comptimes, compsplotted, sortvar] = envtopo( ... sig, EEG.icaweights*EEG.icasphere, 'chanlocs', EEG.chanlocs, 'icawinv', EEG.icawinv, ... 'timerange', [{self.timerange[0]} {self.timerange[1]}]{_matlab_options(options)}); @@ -132,7 +132,7 @@ def _assert_case(self, options): ml_plotted = _icol(mat["compsplotted"]) ml_sortvar = _fcol(mat["sortvar"]) ml_frames = _icol(mat["compframes"]) # 1-based absolute frames - ml_times = _fcol(mat["comptimes"]) # seconds + times_ms = np.linspace(self.timerange[0], self.timerange[1], self.sig.shape[1]) res = envtopo( self.sig, @@ -162,11 +162,16 @@ def _assert_case(self, options): py_frame = dict(zip(py_order.tolist(), np.asarray(res.compframes, dtype=int).tolist())) py_time = dict(zip(py_order.tolist(), np.asarray(res.comptimes, dtype=float).tolist())) ml_frame = dict(zip(ml_order.tolist(), (ml_frames - 1).tolist())) - ml_time = dict(zip(ml_order.tolist(), (ml_times * 1000.0).tolist())) for ic in ml_plotted.tolist(): self.assertEqual(py_frame[ic], ml_frame[ic], msg=f"peak frame differs for IC{ic}, {options}") + # EEGLAB's comptimes output double-applies the sort permutation (envtopo.m:731) and is + # misaligned; derive the expected latency from MATLAB's correct compframes instead. np.testing.assert_allclose( - py_time[ic], ml_time[ic], rtol=RTOL, atol=ATOL, err_msg=f"peak latency differs for IC{ic}, {options}" + py_time[ic], + times_ms[ml_frame[ic]], + rtol=RTOL, + atol=ATOL, + err_msg=f"peak latency differs for IC{ic}, {options}", ) plt.close(res.figure) From aa5ca282d1a14b595aadf915a8fe79411465241e Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Mon, 10 Aug 2026 10:38:09 -0700 Subject: [PATCH 03/12] envtopo: port EEGLAB component-contribution ranking and maps Return EnvtopoResult (compvarorder/compvars/compframes/comptimes/compsplotted/ sortvar + figure) with all four sortvar modes (mp/pv/pp/rp), limcontrib/subcomps/ compnums/compsplot semantics, and an EEGLAB-style figure; retarget pop_envtopo to the new signature. Verified 5/5 against MATLAB envtopo. --- src/eegprep/functions/popfunc/pop_envtopo.py | 27 +- src/eegprep/functions/sigprocfunc/envtopo.py | 455 +++++++++++++++---- 2 files changed, 379 insertions(+), 103 deletions(-) diff --git a/src/eegprep/functions/popfunc/pop_envtopo.py b/src/eegprep/functions/popfunc/pop_envtopo.py index d95964a3..eef223a1 100644 --- a/src/eegprep/functions/popfunc/pop_envtopo.py +++ b/src/eegprep/functions/popfunc/pop_envtopo.py @@ -8,6 +8,7 @@ from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import ControlSpec, DialogSpec +from eegprep.functions.miscfunc.misc import finite_matmul from eegprep.functions.popfunc._chanutils import chanlocs_as_list from eegprep.functions.popfunc.plot_utils import ( component_channel_indices, @@ -63,31 +64,31 @@ def pop_envtopo( raise ValueError("pop_envtopo requires ICA weights") icachansind = component_channel_indices(dataset, data.shape[0]) data = data[icachansind, :, :] - weights = icaweights @ icasphere + weights = finite_matmul(icaweights, icasphere) if weights.shape[1] != data.shape[0]: raise ValueError("ICA weights do not match EEG.icachansind channel count") maps = component_maps(dataset) chanlocs = _component_chanlocs(dataset, maps, icachansind) - components = kwargs.pop("compnums", kwargs.pop("components", None)) - max_components = _first_int(kwargs.pop("compsplot", None), default=7) - title = str(kwargs.pop("title", dataset.get("setname") or "Largest ERP components")) + label_times = times if times.size else eeg_times_ms(dataset) topoplot_options = parse_plot_options_text(kwargs.pop("options", "")) - figure = envtopo( + result = envtopo( np.nanmean(data, axis=2), weights, - times=times if times.size else eeg_times_ms(dataset), chanlocs=chanlocs, icawinv=maps, - components=components, - max_components=max_components, - rank_window=kwargs.pop("limcontrib", None), - exclude_components=kwargs.pop("subcomps", None), + timerange=[float(label_times[0]), float(label_times[-1])], + limcontrib=kwargs.pop("limcontrib", None), + compnums=kwargs.pop("compnums", kwargs.pop("components", None)), + compsplot=_first_int(kwargs.pop("compsplot", None), default=7), + subcomps=kwargs.pop("subcomps", 0), + sortvar=str(kwargs.pop("sortvar", topoplot_options.pop("sortvar", "mp"))), + envmode=str(kwargs.pop("envmode", topoplot_options.pop("envmode", "avg"))), + title=str(kwargs.pop("title", dataset.get("setname") or "Largest ERP components")), topoplot_options=topoplot_options, - title=title, ) command = history_command("pop_envtopo", timerange, **command_kwargs) - show_figures(figure, plot=plot) - return (figure, command) if return_com else figure + show_figures(result.figure, plot=plot) + return (result.figure, command) if return_com else result.figure def pop_envtopo_dialog_spec(EEG: dict[str, Any]) -> DialogSpec: diff --git a/src/eegprep/functions/sigprocfunc/envtopo.py b/src/eegprep/functions/sigprocfunc/envtopo.py index 67a66be8..d2c87d3b 100644 --- a/src/eegprep/functions/sigprocfunc/envtopo.py +++ b/src/eegprep/functions/sigprocfunc/envtopo.py @@ -1,117 +1,392 @@ -"""Component envelope plotting helper.""" +"""Envelope of a data epoch plus scalp maps of the largest components. + +Port of EEGLAB ``envtopo``. Ranks ICA components by their back-projected +contribution to the (mean) data epoch, plots the data envelope with the summed +and per-component contribution envelopes, and draws the scalp map of each of the +largest contributors. + +``envtopo`` returns the six EEGLAB contribution outputs plus the figure as an +``EnvtopoResult``. Two deliberate departures from the MATLAB code, neither +observable in the ranked results: + +* ``compvars`` holds the sort metric in ranked (descending) order. MATLAB + returns the per-component maximum back-projected power simply reversed, which + is neither sorted nor mode-dependent; the ranked metric is the useful value. +* the ``'rms'`` envelope divides by the per-sample count of positive/negative + channels. MATLAB's ``'rms'`` branch references undefined variables and errors. +""" from __future__ import annotations -from typing import Any +from typing import Any, NamedTuple import matplotlib.pyplot as plt import numpy as np +from matplotlib.cm import ScalarMappable +from matplotlib.colors import Normalize +from matplotlib.figure import Figure +from matplotlib.patches import ConnectionPatch +from eegprep.functions.miscfunc.misc import finite_matmul from eegprep.functions.popfunc._chanutils import chanlocs_as_list from eegprep.functions.sigprocfunc.topoplot import topoplot +MAXTOPOS = 20 # EEGLAB caps the number of plotted component maps +_FILLCOLOR = (0.815, 0.94, 1.0) # light blue summed-projection fill, as EEGLAB +# Deprecated EEGLAB sortvar spellings kept for parity with older scripts. +_SORTVAR_ALIASES = {"on": "mp", "pvaf": "mp", "mv": "mp", "off": "rp", "rv": "rp"} +_VALID_SORTVAR = ("mp", "pv", "pp", "rp") +# MATLAB default axes color order, cycled across component lines / leaders. +_COMP_COLORS = [ + (0.850, 0.325, 0.098), + (0.929, 0.694, 0.125), + (0.494, 0.184, 0.556), + (0.466, 0.674, 0.188), + (0.301, 0.745, 0.933), + (0.635, 0.078, 0.184), + (0.000, 0.447, 0.741), +] + + +class EnvtopoResult(NamedTuple): + """Component-contribution outputs of :func:`envtopo` plus the figure. + + ``compvarorder``/``compsplotted`` are 1-based component numbers (EEGLAB + facing); ``compframes`` are 0-based sample indices; ``comptimes`` are in ms. + """ + + compvarorder: np.ndarray + compvars: np.ndarray + compframes: np.ndarray + comptimes: np.ndarray + compsplotted: np.ndarray + sortvar: np.ndarray + figure: Figure + def envtopo( data: Any, weights: Any, *, - times: Any = None, chanlocs: Any = None, icawinv: Any = None, - components: Any = None, - max_components: int = 7, - rank_window: Any = None, - exclude_components: Any = None, - topoplot_options: dict[str, Any] | None = None, + timerange: Any = None, + limcontrib: Any = None, + compnums: Any = None, + compsplot: int = 7, + subcomps: Any = 0, + sortvar: str = "mp", + envmode: str = "avg", + plotchans: Any = None, title: str = "", -): - """Plot data envelope and largest component projection envelopes.""" + topoplot_options: dict[str, Any] | None = None, +) -> EnvtopoResult: + """Rank component contributions to a data epoch and plot their envelopes/maps. + + Args: + data: Channels x frames, or channels x frames x epochs (epochs averaged). + weights: ICA unmixing matrix ``icaweights @ icasphere`` (components x channels). + chanlocs: Channel locations for the scalp maps, or None to skip the maps. + icawinv: ICA mixing matrix (channels x components); defaults to ``pinv(weights)``. + timerange: ``[min max]`` epoch latencies in ms used to label the time axis. + limcontrib: ``[min max]`` ms window in which contributions are ranked. + compnums: 1-based candidate components to rank; defaults to all. + compsplot: Number of largest contributors to plot (capped at 20). + subcomps: 1-based components to remove before plotting; 0 removes none, + ``[]`` removes all but ``compnums``. + sortvar: Ranking metric ``'mp'`` (default), ``'pv'``, ``'pp'`` or ``'rp'``. + envmode: ``'avg'`` (max/min envelope) or ``'rms'``. + plotchans: 1-based channels used for envelopes and maps; defaults to all. + title: Figure title. + topoplot_options: Extra keyword arguments forwarded to :func:`topoplot`. + + Returns: + EnvtopoResult: ranking outputs and the matplotlib figure. + """ values = np.asarray(data, dtype=float) if values.ndim == 3: values = np.nanmean(values, axis=2) if values.ndim != 2: - raise ValueError("envtopo data must be channels x points") - weight_values = np.asarray(weights, dtype=float) - activations = weight_values @ values - maps = ( - np.asarray(icawinv, dtype=float) - if icawinv is not None and np.asarray(icawinv).size - else np.linalg.pinv(weight_values) - ) - x_values = ( - np.asarray(times, dtype=float).ravel() - if times is not None and len(np.asarray(times).ravel()) - else np.arange(values.shape[1]) + raise ValueError("envtopo data must be channels x frames") + n_chans, n_frames = values.shape + + weight_matrix = np.asarray(weights, dtype=float) + if weight_matrix.shape[1] != n_chans: + raise ValueError("weights columns must match the number of data channels") + n_components = weight_matrix.shape[0] + + maps = np.linalg.pinv(weight_matrix) if _is_empty(icawinv) else np.asarray(icawinv, dtype=float) + metric_mode = _normalize_sortvar(sortvar) + times_ms = _time_axis(timerange, n_frames) + plot_channels = _resolve_channels(plotchans, n_chans) + candidates = _resolve_components(compnums, n_components) + removed = _resolve_subcomps(subcomps, n_components, candidates) + + activations = finite_matmul(weight_matrix, values) + if removed.size: + activations[removed, :] = 0.0 + lim1, lim2 = _limcontrib_frames(limcontrib, times_ms, n_frames) + + metric, plotframes, comp_envelopes, max_projections = _contributions( + values, activations, maps, candidates, plot_channels, lim1, lim2, metric_mode, envmode ) - component_indices = _components( - components, - activations, - max_components=min(max_components, activations.shape[0]), - times=x_values, - rank_window=rank_window, - exclude_components=exclude_components, + + order = np.argsort(metric)[::-1] + compvarorder = candidates[order] + 1 + compvars = metric[order] + compframes = plotframes[order] + comptimes = times_ms[compframes] + n_topos = min(int(compsplot), candidates.size, MAXTOPOS) + compsplotted = compvarorder[:n_topos] + + plotted_components = candidates[order][:n_topos] + figure = _build_figure( + times_ms=times_ms, + data_env=_envelope(finite_matmul(maps[plot_channels, :], activations), envmode), + summed_env=_envelope( + finite_matmul(maps[np.ix_(plot_channels, plotted_components)], activations[plotted_components]), envmode + ), + comp_envelopes=comp_envelopes[order][:n_topos], + max_projections=max_projections[:, order][:, :n_topos], + plotted_frames=compframes[:n_topos], + plotted_labels=compsplotted, + plot_channels=plot_channels, + chanlocs=chanlocs, + limcontrib_ms=None if (lim1, lim2) == (0, n_frames - 1) else (times_ms[lim1], times_ms[lim2]), + envmode=envmode, + title=title, + topoplot_options=topoplot_options, ) - projections = [np.outer(maps[:, index], activations[index]) for index in component_indices] - - fig = plt.figure(figsize=(8.5, 4.8)) - envelope_ax = fig.add_subplot(2, 1, 1) - envelope_ax.fill_between(x_values, np.nanmin(values, axis=0), np.nanmax(values, axis=0), color="0.85", label="data") - for index, projection in zip(component_indices, projections): - envelope_ax.plot(x_values, np.nanmax(projection, axis=0), linewidth=1.0, label=f"IC {index + 1}") - envelope_ax.plot(x_values, np.nanmin(projection, axis=0), linewidth=1.0) - envelope_ax.set_xlabel("Time (ms)") - envelope_ax.set_ylabel("uV") - envelope_ax.set_title(title or "Largest ERP components") - envelope_ax.legend(fontsize=7, ncols=2) - - for plot_index, component_index in enumerate(component_indices, start=1): - ax = fig.add_subplot(2, max(len(component_indices), 1), max(len(component_indices), 1) + plot_index) - plot_options = {"electrodes": "off", **(topoplot_options or {})} - topoplot(maps[:, component_index], chanlocs_as_list(chanlocs), axes=ax, **plot_options) - ax.set_title(f"IC {component_index + 1}") - fig.tight_layout() - return fig - - -def _components( - components: Any, - activations: np.ndarray, - *, - max_components: int, - times: np.ndarray, - rank_window: Any, - exclude_components: Any, -) -> np.ndarray: - if components is not None and len(np.asarray(components).ravel()): - values = np.asarray(components, dtype=int).ravel() - if np.any(values < 1) or np.any(values > activations.shape[0]): - raise ValueError("component indices are outside available ICA components") - return values - 1 - rank_mask = _rank_mask(times, rank_window) - power = np.nanmax(activations[:, rank_mask] * activations[:, rank_mask], axis=1) - excluded = _exclude_indices(exclude_components, activations.shape[0]) - if excluded.size: - power[excluded] = -np.inf - return np.argsort(power)[::-1][:max_components] - - -def _rank_mask(times: np.ndarray, rank_window: Any) -> np.ndarray: - values = np.asarray(rank_window, dtype=float).ravel() if rank_window is not None else np.asarray([]) - if values.size != 2: - return np.ones(times.shape, dtype=bool) - mask = (times >= values[0]) & (times <= values[1]) - if not np.any(mask): - raise ValueError("limcontrib does not include any samples") - return mask - - -def _exclude_indices(exclude_components: Any, component_count: int) -> np.ndarray: - if exclude_components is None or not len(np.asarray(exclude_components).ravel()): + return EnvtopoResult(compvarorder, compvars, compframes, comptimes, compsplotted, metric, figure) + + +def _contributions(values, activations, maps, candidates, plot_channels, lim1, lim2, metric_mode, envmode): + """Per-candidate ranking metric, peak frame, envelope and peak-frame map.""" + window = slice(lim1, lim2 + 1) + data_win = values[plot_channels, window] + reference = float(np.mean(np.var(data_win, axis=0))) if metric_mode == "pv" else float(np.mean(data_win**2)) + + metric = np.empty(candidates.size) + plotframes = np.empty(candidates.size, dtype=int) + envelopes = np.empty((candidates.size, 2, values.shape[1])) + max_projections = np.empty((values.shape[0], candidates.size)) + + for position, comp in enumerate(candidates): + projection = np.outer(maps[:, comp], activations[comp, :]) + proj_plot = projection[plot_channels, :] + proj_win = proj_plot[:, window] + + power = np.mean(proj_win**2, axis=0) if plot_channels.size > 1 else np.abs(proj_win[0, :]) + peak = int(np.argmax(power)) + plotframes[position] = peak + lim1 + max_projections[:, position] = projection[:, peak + lim1] + envelopes[position] = _envelope(proj_plot, envmode) + metric[position] = _sort_metric(metric_mode, float(power[peak]), data_win, proj_win, reference) + return metric, plotframes, envelopes, max_projections + + +def _sort_metric(metric_mode, max_power, data_win, proj_win, reference): + if metric_mode == "mp": + return max_power + if metric_mode == "pv": + return 100.0 - 100.0 * float(np.mean(np.var(data_win - proj_win, axis=0))) / reference + if metric_mode == "pp": + return 100.0 - 100.0 * float(np.mean((data_win - proj_win) ** 2)) / reference + return 100.0 * float(np.mean(proj_win**2)) / reference # "rp" + + +def _envelope(data, envmode): + if data.shape[0] <= 1: + row = data.reshape(-1) + return np.vstack([row, row]) + if str(envmode).lower() == "rms": + positive = np.where(data > 0, data, 0.0) + negative = np.where(data < 0, data, 0.0) + pos_counts = np.maximum((data > 0).sum(axis=0), 1) + neg_counts = np.maximum((data < 0).sum(axis=0), 1) + return np.vstack( + [np.sqrt(np.sum(positive**2, axis=0) / pos_counts), -np.sqrt(np.sum(negative**2, axis=0) / neg_counts)] + ) + return np.vstack([data.max(axis=0), data.min(axis=0)]) + + +def _normalize_sortvar(sortvar): + mode = str(sortvar).lower() + mode = _SORTVAR_ALIASES.get(mode, mode) + if mode not in _VALID_SORTVAR: + raise ValueError(f"envtopo: unknown 'sortvar' value {sortvar!r}; expected one of {_VALID_SORTVAR}") + return mode + + +def _time_axis(timerange, n_frames): + if _is_empty(timerange): + return np.arange(n_frames, dtype=float) + bounds = np.asarray(timerange, dtype=float).ravel() + if bounds.size != 2: + raise ValueError("timerange must be [min max] in milliseconds") + return np.linspace(bounds[0], bounds[1], n_frames) + + +def _resolve_channels(plotchans, n_chans): + if _is_empty(plotchans): + return np.arange(n_chans, dtype=int) + indices = np.asarray(plotchans, dtype=int).ravel() - 1 + if np.any(indices < 0) or np.any(indices >= n_chans): + raise ValueError("plotchans are outside the available channels") + return indices + + +def _resolve_components(compnums, n_components): + if _is_empty(compnums): + return np.arange(n_components, dtype=int) + indices = np.asarray(compnums, dtype=int).ravel() - 1 + if np.any(indices < 0) or np.any(indices >= n_components): + raise ValueError("compnums are outside the available ICA components") + _, first = np.unique(indices, return_index=True) + return indices[np.sort(first)] + + +def _resolve_subcomps(subcomps, n_components, candidates): + if subcomps is None: return np.asarray([], dtype=int) - values = np.asarray(exclude_components, dtype=int).ravel() - if np.any(values < 1) or np.any(values > component_count): - raise ValueError("subcomps component indices are outside available ICA components") - return values - 1 + values = np.asarray(subcomps).ravel() + if values.size == 0: # empty list -> remove all but the candidate components + return np.setdiff1d(np.arange(n_components, dtype=int), candidates) + if np.any(values < 1): # a 0 (or negative) entry means "remove none" + return np.asarray([], dtype=int) + indices = values.astype(int) - 1 + if np.any(indices >= n_components): + raise ValueError("subcomps are outside the available ICA components") + return indices + + +def _limcontrib_frames(limcontrib, times_ms, n_frames): + if _is_empty(limcontrib): + return 0, n_frames - 1 + bounds = np.asarray(limcontrib, dtype=float).ravel() + if bounds.size != 2 or not np.any(bounds != 0): + return 0, n_frames - 1 + start, stop = float(times_ms[0]), float(times_ms[-1]) + per_ms = (n_frames - 1) / (stop - start) + lim1 = int(round((min(max(bounds[0], start), stop) - start) * per_ms)) + lim2 = int(round((min(max(bounds[1], start), stop) - start) * per_ms)) + if lim2 <= lim1: + raise ValueError("limcontrib does not span any samples") + return lim1, lim2 + + +def _build_figure( + *, + times_ms, + data_env, + summed_env, + comp_envelopes, + max_projections, + plotted_frames, + plotted_labels, + plot_channels, + chanlocs, + limcontrib_ms, + envmode, + title, + topoplot_options, +): + """Draw the envelope panel, summed/per-component envelopes and scalp maps.""" + locs = chanlocs_as_list(chanlocs) if chanlocs is not None else [] + draw_maps = bool(locs) + n_topos = plotted_labels.size + + figure = plt.figure(figsize=(9, 6)) + env_ax = figure.add_axes([0.10, 0.10, 0.80, 0.52] if draw_maps else [0.10, 0.12, 0.85, 0.78]) + + env_ax.fill_between(times_ms, summed_env[1], summed_env[0], color=_FILLCOLOR, linewidth=0.0, zorder=1) + for position in range(n_topos): + color = _COMP_COLORS[position % len(_COMP_COLORS)] + env_ax.plot(times_ms, comp_envelopes[position, 0], color=color, linewidth=1.0, zorder=3) + env_ax.plot(times_ms, comp_envelopes[position, 1], color=color, linewidth=1.0, zorder=3) + env_ax.plot(times_ms, data_env[0], color="k", linewidth=2.0, zorder=4) + env_ax.plot(times_ms, data_env[1], color="k", linewidth=2.0, zorder=4) + + env_ax.set_xlim(float(times_ms[0]), float(times_ms[-1])) + env_ax.set_xlabel("Latency (ms)") + env_ax.set_ylabel("RMS (µV)" if str(envmode).lower() == "rms" else "Potential (µV)") + env_ax.grid(True, axis="y", linestyle=":") + if times_ms[0] < 0 < times_ms[-1]: + env_ax.axvline(0.0, color="k", linewidth=1.5, zorder=2) + if limcontrib_ms is not None: + for edge in limcontrib_ms: + env_ax.axvline(float(edge), color="k", linestyle=":", linewidth=1.2, zorder=2) + + if draw_maps: + _draw_maps_row( + figure, + env_ax, + times_ms, + comp_envelopes, + max_projections, + plotted_frames, + plotted_labels, + plot_channels, + locs, + topoplot_options, + ) + if title: + figure.suptitle(title, fontsize=12, fontweight="bold") + return figure + + +def _draw_maps_row( + figure, + env_ax, + times_ms, + comp_envelopes, + max_projections, + plotted_frames, + plotted_labels, + plot_channels, + locs, + topoplot_options, +): + """Top row of scalp maps in temporal order, joined to their peak latency.""" + n_topos = plotted_labels.size + temporal = np.argsort(plotted_frames) + top_y, top_h = 0.68, 0.24 + left, right = 0.10, 0.88 + slot = (right - left) / n_topos + map_w = min(slot * 0.9, 0.22) + options = {"electrodes": "on" if map_w >= 0.12 else "off", "maplimits": "absmax", **(topoplot_options or {})} + + for column, source in enumerate(temporal): + latency = float(times_ms[plotted_frames[source]]) + center = left + slot * (column + 0.5) + topo_ax = figure.add_axes([center - map_w / 2, top_y, map_w, top_h]) + topoplot(max_projections[plot_channels, source], locs, axes=topo_ax, **options) + topo_ax.set_title(f"IC {int(plotted_labels[source])}", fontsize=10, fontweight="bold") + + color = _COMP_COLORS[source % len(_COMP_COLORS)] + figure.add_artist( + ConnectionPatch( + xyA=(latency, comp_envelopes[source, 0, plotted_frames[source]]), + coordsA=env_ax.transData, + xyB=(0.5, 0.0), + coordsB=topo_ax.transAxes, + color=color, + linewidth=1.0, + ) + ) + + cbar_ax = figure.add_axes([0.925, top_y + 0.05, 0.018, top_h - 0.10]) + cmap = plt.get_cmap((topoplot_options or {}).get("colormap") or "turbo") + colorbar = figure.colorbar(ScalarMappable(cmap=cmap, norm=Normalize(vmin=-1, vmax=1)), cax=cbar_ax) + colorbar.set_ticks([-0.8, 0, 0.8]) + colorbar.set_ticklabels(["-", "", "+"]) + colorbar.ax.tick_params(length=0) + + +def _is_empty(value): + if value is None: + return True + return np.asarray(value).size == 0 -__all__ = ["envtopo"] +__all__ = ["envtopo", "EnvtopoResult"] From e9f9dc09876cf15405d27bfdb1a8182b747a74bb Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Thu, 13 Aug 2026 14:36:59 -0700 Subject: [PATCH 04/12] envtopo: adjust axes --- src/eegprep/functions/sigprocfunc/envtopo.py | 29 ++++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/eegprep/functions/sigprocfunc/envtopo.py b/src/eegprep/functions/sigprocfunc/envtopo.py index d2c87d3b..6a45c516 100644 --- a/src/eegprep/functions/sigprocfunc/envtopo.py +++ b/src/eegprep/functions/sigprocfunc/envtopo.py @@ -26,6 +26,7 @@ from matplotlib.colors import Normalize from matplotlib.figure import Figure from matplotlib.patches import ConnectionPatch +from matplotlib.ticker import MaxNLocator from eegprep.functions.miscfunc.misc import finite_matmul from eegprep.functions.popfunc._chanutils import chanlocs_as_list @@ -299,29 +300,33 @@ def _build_figure( figure = plt.figure(figsize=(9, 6)) env_ax = figure.add_axes([0.10, 0.10, 0.80, 0.52] if draw_maps else [0.10, 0.12, 0.85, 0.78]) - env_ax.fill_between(times_ms, summed_env[1], summed_env[0], color=_FILLCOLOR, linewidth=0.0, zorder=1) + times = np.asarray(times_ms, dtype=float) / 1000.0 # EEGLAB envtopo plots the time axis in seconds + + env_ax.fill_between(times, summed_env[1], summed_env[0], color=_FILLCOLOR, linewidth=0.0, zorder=1) for position in range(n_topos): color = _COMP_COLORS[position % len(_COMP_COLORS)] - env_ax.plot(times_ms, comp_envelopes[position, 0], color=color, linewidth=1.0, zorder=3) - env_ax.plot(times_ms, comp_envelopes[position, 1], color=color, linewidth=1.0, zorder=3) - env_ax.plot(times_ms, data_env[0], color="k", linewidth=2.0, zorder=4) - env_ax.plot(times_ms, data_env[1], color="k", linewidth=2.0, zorder=4) + env_ax.plot(times, comp_envelopes[position, 0], color=color, linewidth=1.0, zorder=3) + env_ax.plot(times, comp_envelopes[position, 1], color=color, linewidth=1.0, zorder=3) + env_ax.plot(times, data_env[0], color="k", linewidth=2.0, zorder=4) + env_ax.plot(times, data_env[1], color="k", linewidth=2.0, zorder=4) - env_ax.set_xlim(float(times_ms[0]), float(times_ms[-1])) - env_ax.set_xlabel("Latency (ms)") + env_ax.set_xlim(float(times[0]), float(times[-1])) + env_ax.set_xlabel("Time (s)") env_ax.set_ylabel("RMS (µV)" if str(envmode).lower() == "rms" else "Potential (µV)") + # Denser y-ticks (~5 µV spacing) to match EEGLAB rather than matplotlib's sparse default. + env_ax.yaxis.set_major_locator(MaxNLocator(nbins=11, steps=[1, 2, 5, 10], min_n_ticks=8)) env_ax.grid(True, axis="y", linestyle=":") - if times_ms[0] < 0 < times_ms[-1]: + if times[0] < 0 < times[-1]: env_ax.axvline(0.0, color="k", linewidth=1.5, zorder=2) if limcontrib_ms is not None: for edge in limcontrib_ms: - env_ax.axvline(float(edge), color="k", linestyle=":", linewidth=1.2, zorder=2) + env_ax.axvline(float(edge) / 1000.0, color="k", linestyle=":", linewidth=1.2, zorder=2) if draw_maps: _draw_maps_row( figure, env_ax, - times_ms, + times, comp_envelopes, max_projections, plotted_frames, @@ -338,7 +343,7 @@ def _build_figure( def _draw_maps_row( figure, env_ax, - times_ms, + times, comp_envelopes, max_projections, plotted_frames, @@ -357,7 +362,7 @@ def _draw_maps_row( options = {"electrodes": "on" if map_w >= 0.12 else "off", "maplimits": "absmax", **(topoplot_options or {})} for column, source in enumerate(temporal): - latency = float(times_ms[plotted_frames[source]]) + latency = float(times[plotted_frames[source]]) center = left + slot * (column + 0.5) topo_ax = figure.add_axes([center - map_w / 2, top_y, map_w, top_h]) topoplot(max_projections[plot_channels, source], locs, axes=topo_ax, **options) From dfd82807bfd4d9e8abd6ebe5539b394dc39da06c Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Fri, 14 Aug 2026 09:27:42 -0700 Subject: [PATCH 05/12] pop_saveset: preserve empty channel coordinates on save --- src/eegprep/functions/popfunc/pop_saveset.py | 18 ++++++---- tests/test_pop_saveset.py | 37 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/eegprep/functions/popfunc/pop_saveset.py b/src/eegprep/functions/popfunc/pop_saveset.py index 641c2715..5f5f0ea6 100644 --- a/src/eegprep/functions/popfunc/pop_saveset.py +++ b/src/eegprep/functions/popfunc/pop_saveset.py @@ -306,14 +306,20 @@ def _chanlocs_to_struct_array(chanlocs_list): if not retain: return np.array([]) - dtype = np.dtype([(f, t) for f, t in field_spec if f in retain]) + # A numeric field that is empty for some channels (e.g. no-location EOG or + # reference channels) cannot share a homogeneous numeric column, so store it + # as an object column holding MATLAB empty [] for those channels. EEGLAB + # records a missing location as [], not 0; writing 0 would place the channel + # at the head center when the .set is reloaded (here or in EEGLAB). + resolved = [ + (f, object if np.issubdtype(t, np.number) and any(d[f] is None for d in d_list) else t) + for f, t in field_spec + if f in retain + ] + dtype = np.dtype(resolved) arr = np.array( [ - tuple( - d[f] if d[f] is not None else (0 if np.issubdtype(t, np.number) else '') - for f, t in field_spec - if f in retain - ) + tuple(d[f] if d[f] is not None else (np.array([]) if t is object else '') for f, t in resolved) for d in d_list ], dtype=dtype, diff --git a/tests/test_pop_saveset.py b/tests/test_pop_saveset.py index 7aca2dfd..3e73eb34 100644 --- a/tests/test_pop_saveset.py +++ b/tests/test_pop_saveset.py @@ -168,6 +168,43 @@ def test_chanlocs_serialized_through_single_converter(self): loaded = scipy.io.loadmat(out, struct_as_record=True) self.assertIn('unit', loaded['chanlocs'].dtype.names) + def test_no_location_channel_coordinates_saved_as_empty(self): + # No-location channels (e.g. EOG) must keep empty coordinates on save, as + # EEGLAB does. Writing 0 would place them at the head center when the .set + # is reloaded (here or in EEGLAB), corrupting scalp maps. + empty = np.array([]) + chanlocs = [ + {'labels': 'Cz', 'theta': 0.0, 'radius': 0.0, 'X': 0.0, 'Y': 0.0, 'Z': 1.0}, + {'labels': 'EOG', 'theta': empty, 'radius': empty, 'X': empty, 'Y': empty, 'Z': empty}, + ] + EEG = { + 'setname': 't', + 'nbchan': 2, + 'trials': 1, + 'pnts': 4, + 'srate': 100.0, + 'xmin': 0.0, + 'xmax': 0.03, + 'times': np.arange(4) / 100.0, + 'data': np.zeros((2, 4)), + 'chanlocs': chanlocs, + 'event': [], + 'icachansind': np.array([]), + } + with tempfile.TemporaryDirectory() as tmp: + out = os.path.join(tmp, 'noloc.set') + pop_saveset(EEG, out) + raw = scipy.io.loadmat(out, struct_as_record=False, squeeze_me=True)['chanlocs'] + reloaded = pop_loadset(out)['chanlocs'] + # On disk: no-location coords are empty (not 0); the located channel keeps its value. + self.assertEqual(np.size(raw[1].theta), 0) + self.assertEqual(np.size(raw[1].radius), 0) + self.assertEqual(float(raw[0].radius), 0.0) + # Round-trip through EEGPrep keeps the no-location coords empty. + self.assertEqual(np.asarray(reloaded[0]['radius']).size, 1) + self.assertEqual(np.asarray(reloaded[1]['theta']).size, 0) + self.assertEqual(np.asarray(reloaded[1]['radius']).size, 0) + if __name__ == '__main__': # EEG = pop_loadset(ensure_file('FlankerTest.set')) From 9c6a331fd1468d9272fbf4bfa3ea25a35644fe94 Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Fri, 14 Aug 2026 10:37:37 -0700 Subject: [PATCH 06/12] envtopo: click to enlarge maps and envelope --- src/eegprep/functions/sigprocfunc/axcopy.py | 40 +++++++++ src/eegprep/functions/sigprocfunc/envtopo.py | 94 ++++++++++++++------ src/eegprep/resources/help/pop_envtopo.md | 3 + tests/test_phase4_plot_wrappers.py | 41 +++++++++ 4 files changed, 153 insertions(+), 25 deletions(-) create mode 100644 src/eegprep/functions/sigprocfunc/axcopy.py diff --git a/src/eegprep/functions/sigprocfunc/axcopy.py b/src/eegprep/functions/sigprocfunc/axcopy.py new file mode 100644 index 00000000..fbf3e799 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/axcopy.py @@ -0,0 +1,40 @@ +"""Pop the clicked figure axes out into an enlarged window, as EEGLAB ``axcopy``.""" + +from __future__ import annotations + +from typing import Any, Callable + +import matplotlib.pyplot as plt +from matplotlib.axes import Axes +from matplotlib.figure import Figure + +from eegprep.functions.popfunc.plot_utils import backend_can_display + + +def axcopy(figure: Figure, redraws: dict[Axes, Callable[[Axes], Any]]) -> None: + """Enlarge the left-clicked axes into a pop-up window, like EEGLAB ``axcopy``. + + ``redraws`` maps each interactive axes to a closure that redraws its content, + full size, into a fresh axes. matplotlib cannot copy artists between figures + the way EEGLAB copies graphic objects, so each axes carries a redraw closure + instead. This is a no-op on non-interactive backends, which deliver no click + events; tests exercise it by dispatching a synthetic click. + """ + + def _on_click(event: Any) -> None: + if event.button != 1 or event.inaxes is None: + return + redraw = redraws.get(event.inaxes) + if redraw is None: + return + popup = plt.figure(figsize=(5, 5)) + redraw(popup.add_axes([0.13, 0.10, 0.80, 0.80])) + # The GUI/console run with interactive mode off, so display the pop-up + # explicitly (as show_figures does); a no-op on file-output backends. + if backend_can_display(): + popup.show() + + figure.canvas.mpl_connect("button_press_event", _on_click) + + +__all__ = ["axcopy"] diff --git a/src/eegprep/functions/sigprocfunc/envtopo.py b/src/eegprep/functions/sigprocfunc/envtopo.py index 6a45c516..c62f5ed0 100644 --- a/src/eegprep/functions/sigprocfunc/envtopo.py +++ b/src/eegprep/functions/sigprocfunc/envtopo.py @@ -30,6 +30,7 @@ from eegprep.functions.miscfunc.misc import finite_matmul from eegprep.functions.popfunc._chanutils import chanlocs_as_list +from eegprep.functions.sigprocfunc.axcopy import axcopy from eegprep.functions.sigprocfunc.topoplot import topoplot MAXTOPOS = 20 # EEGLAB caps the number of plotted component maps @@ -84,6 +85,8 @@ def envtopo( ) -> EnvtopoResult: """Rank component contributions to a data epoch and plot their envelopes/maps. + Left-clicking the envelope panel or a scalp map enlarges it in a pop-up window. + Args: data: Channels x frames, or channels x frames x epochs (epochs averaged). weights: ICA unmixing matrix ``icaweights @ icasphere`` (components x channels). @@ -151,6 +154,8 @@ def envtopo( max_projections=max_projections[:, order][:, :n_topos], plotted_frames=compframes[:n_topos], plotted_labels=compsplotted, + plotted_metric=compvars[:n_topos], + metric_mode=metric_mode, plot_channels=plot_channels, chanlocs=chanlocs, limcontrib_ms=None if (lim1, lim2) == (0, n_frames - 1) else (times_ms[lim1], times_ms[lim2]), @@ -285,6 +290,8 @@ def _build_figure( max_projections, plotted_frames, plotted_labels, + plotted_metric, + metric_mode, plot_channels, chanlocs, limcontrib_ms, @@ -292,36 +299,25 @@ def _build_figure( title, topoplot_options, ): - """Draw the envelope panel, summed/per-component envelopes and scalp maps.""" + """Draw the envelope panel, summed/per-component envelopes and scalp maps. + + Left-clicking the envelope panel or any scalp map enlarges it in a pop-up + window (EEGLAB ``axcopy``); the ``redraws`` closures reproduce each axes. + """ locs = chanlocs_as_list(chanlocs) if chanlocs is not None else [] draw_maps = bool(locs) n_topos = plotted_labels.size + times = np.asarray(times_ms, dtype=float) / 1000.0 # EEGLAB envtopo plots the time axis in seconds figure = plt.figure(figsize=(9, 6)) env_ax = figure.add_axes([0.10, 0.10, 0.80, 0.52] if draw_maps else [0.10, 0.12, 0.85, 0.78]) + _draw_envelope(env_ax, times, data_env, summed_env, comp_envelopes, n_topos, envmode, limcontrib_ms) - times = np.asarray(times_ms, dtype=float) / 1000.0 # EEGLAB envtopo plots the time axis in seconds - - env_ax.fill_between(times, summed_env[1], summed_env[0], color=_FILLCOLOR, linewidth=0.0, zorder=1) - for position in range(n_topos): - color = _COMP_COLORS[position % len(_COMP_COLORS)] - env_ax.plot(times, comp_envelopes[position, 0], color=color, linewidth=1.0, zorder=3) - env_ax.plot(times, comp_envelopes[position, 1], color=color, linewidth=1.0, zorder=3) - env_ax.plot(times, data_env[0], color="k", linewidth=2.0, zorder=4) - env_ax.plot(times, data_env[1], color="k", linewidth=2.0, zorder=4) - - env_ax.set_xlim(float(times[0]), float(times[-1])) - env_ax.set_xlabel("Time (s)") - env_ax.set_ylabel("RMS (µV)" if str(envmode).lower() == "rms" else "Potential (µV)") - # Denser y-ticks (~5 µV spacing) to match EEGLAB rather than matplotlib's sparse default. - env_ax.yaxis.set_major_locator(MaxNLocator(nbins=11, steps=[1, 2, 5, 10], min_n_ticks=8)) - env_ax.grid(True, axis="y", linestyle=":") - if times[0] < 0 < times[-1]: - env_ax.axvline(0.0, color="k", linewidth=1.5, zorder=2) - if limcontrib_ms is not None: - for edge in limcontrib_ms: - env_ax.axvline(float(edge) / 1000.0, color="k", linestyle=":", linewidth=1.2, zorder=2) - + redraws = { + env_ax: lambda ax: _draw_envelope( + ax, times, data_env, summed_env, comp_envelopes, n_topos, envmode, limcontrib_ms + ) + } if draw_maps: _draw_maps_row( figure, @@ -331,15 +327,42 @@ def _build_figure( max_projections, plotted_frames, plotted_labels, + plotted_metric, + metric_mode, plot_channels, locs, topoplot_options, + redraws, ) if title: figure.suptitle(title, fontsize=12, fontweight="bold") + axcopy(figure, redraws) return figure +def _draw_envelope(ax, times, data_env, summed_env, comp_envelopes, n_topos, envmode, limcontrib_ms): + """Draw the data envelope, the summed fill and the per-component envelopes.""" + ax.fill_between(times, summed_env[1], summed_env[0], color=_FILLCOLOR, linewidth=0.0, zorder=1) + for position in range(n_topos): + color = _COMP_COLORS[position % len(_COMP_COLORS)] + ax.plot(times, comp_envelopes[position, 0], color=color, linewidth=1.0, zorder=3) + ax.plot(times, comp_envelopes[position, 1], color=color, linewidth=1.0, zorder=3) + ax.plot(times, data_env[0], color="k", linewidth=2.0, zorder=4) + ax.plot(times, data_env[1], color="k", linewidth=2.0, zorder=4) + + ax.set_xlim(float(times[0]), float(times[-1])) + ax.set_xlabel("Time (s)") + ax.set_ylabel("RMS (µV)" if str(envmode).lower() == "rms" else "Potential (µV)") + # Denser y-ticks (~5 µV spacing) to match EEGLAB rather than matplotlib's sparse default. + ax.yaxis.set_major_locator(MaxNLocator(nbins=11, steps=[1, 2, 5, 10], min_n_ticks=8)) + ax.grid(True, axis="y", linestyle=":") + if times[0] < 0 < times[-1]: + ax.axvline(0.0, color="k", linewidth=1.5, zorder=2) + if limcontrib_ms is not None: + for edge in limcontrib_ms: + ax.axvline(float(edge) / 1000.0, color="k", linestyle=":", linewidth=1.2, zorder=2) + + def _draw_maps_row( figure, env_ax, @@ -348,9 +371,12 @@ def _draw_maps_row( max_projections, plotted_frames, plotted_labels, + plotted_metric, + metric_mode, plot_channels, locs, topoplot_options, + redraws, ): """Top row of scalp maps in temporal order, joined to their peak latency.""" n_topos = plotted_labels.size @@ -360,13 +386,15 @@ def _draw_maps_row( slot = (right - left) / n_topos map_w = min(slot * 0.9, 0.22) options = {"electrodes": "on" if map_w >= 0.12 else "off", "maplimits": "absmax", **(topoplot_options or {})} + popup_options = {**options, "electrodes": "on"} # the enlarged pop-out always shows electrodes for column, source in enumerate(temporal): latency = float(times[plotted_frames[source]]) center = left + slot * (column + 0.5) topo_ax = figure.add_axes([center - map_w / 2, top_y, map_w, top_h]) - topoplot(max_projections[plot_channels, source], locs, axes=topo_ax, **options) - topo_ax.set_title(f"IC {int(plotted_labels[source])}", fontsize=10, fontweight="bold") + values = max_projections[plot_channels, source] + label = int(plotted_labels[source]) + _topo_map(topo_ax, values, locs, options, label) color = _COMP_COLORS[source % len(_COMP_COLORS)] figure.add_artist( @@ -379,6 +407,10 @@ def _draw_maps_row( linewidth=1.0, ) ) + metric_value = float(plotted_metric[source]) + redraws[topo_ax] = lambda ax, v=values, la=label, mv=metric_value: _redraw_map_popup( + ax, v, locs, popup_options, la, metric_mode, mv + ) cbar_ax = figure.add_axes([0.925, top_y + 0.05, 0.018, top_h - 0.10]) cmap = plt.get_cmap((topoplot_options or {}).get("colormap") or "turbo") @@ -388,6 +420,18 @@ def _draw_maps_row( colorbar.ax.tick_params(length=0) +def _topo_map(ax, values, locs, options, label): + """Draw one component scalp map with its IC-number title.""" + topoplot(values, locs, axes=ax, **options) + ax.set_title(f"IC {int(label)}", fontsize=10, fontweight="bold") + + +def _redraw_map_popup(ax, values, locs, options, label, metric_mode, metric_value): + """Enlarged pop-out of a scalp map, annotated with its sort-metric value.""" + _topo_map(ax, values, locs, options, label) + ax.text(0.5, -0.03, f"{metric_mode}: {metric_value:.2f}", transform=ax.transAxes, ha="center", va="top") + + def _is_empty(value): if value is None: return True diff --git a/src/eegprep/resources/help/pop_envtopo.md b/src/eegprep/resources/help/pop_envtopo.md index f4dd33f9..f3c1e4fa 100644 --- a/src/eegprep/resources/help/pop_envtopo.md +++ b/src/eegprep/resources/help/pop_envtopo.md @@ -8,6 +8,9 @@ fig, com = pop_envtopo(EEG, timerange=[-100, 300], return_com=True) This requires epoched data, channel locations, and ICA weights/maps. +Left-click the envelope panel or any scalp map to enlarge it in a pop-up window; +each enlarged map is annotated with its ranking metric value. + EEGPrep's standalone wrapper accepts one dataset. Multi-dataset envelope comparison is not implemented because component maps, ICA channel subsets, and dataset-level envelopes need a dedicated group workflow rather than a silent diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index f6b3fc31..189ebdcb 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1408,6 +1408,47 @@ def test_pop_envtopo_threads_eeglab_options_into_history(ica_epoch): plt.close(figure) +def test_pop_envtopo_click_enlarges_map_and_envelope(ica_epoch): + figure, _ = pop_envtopo(ica_epoch, compsplot=3, plot="off", return_com=True) + figure.canvas.draw() + map_ax = next(ax for ax in figure.axes if ax.images) + env_ax = next(ax for ax in figure.axes if ax.get_xlabel() == "Time (s)") + + def _left_click(ax): + before = set(plt.get_fignums()) + px, py = ax.transData.transform((sum(ax.get_xlim()) / 2, sum(ax.get_ylim()) / 2)) + figure.canvas.callbacks.process( + "button_press_event", MouseEvent("button_press_event", figure.canvas, px, py, button=1) + ) + return sorted(set(plt.get_fignums()) - before) + + # Left-clicking a scalp map pops out an enlarged copy with its IC title and sort metric. + opened = _left_click(map_ax) + assert len(opened) == 1 + popup = plt.figure(opened[0]).axes[0] + assert popup.images + assert popup.get_title().startswith("IC ") + assert any("mp:" in text.get_text() for text in popup.texts) + plt.close(opened[0]) + + # Left-clicking the envelope panel pops out an enlarged copy of the traces. + opened = _left_click(env_ax) + assert len(opened) == 1 + popup = plt.figure(opened[0]).axes[0] + assert popup.lines + assert popup.get_xlabel() == "Time (s)" + plt.close(opened[0]) + + # A non-left button does not pop anything out (EEGLAB axcopy is left-button only). + before = set(plt.get_fignums()) + px, py = map_ax.transData.transform((sum(map_ax.get_xlim()) / 2, sum(map_ax.get_ylim()) / 2)) + figure.canvas.callbacks.process( + "button_press_event", MouseEvent("button_press_event", figure.canvas, px, py, button=3) + ) + assert set(plt.get_fignums()) == before + plt.close(figure) + + def test_pop_comperp_and_chanplot_work_on_epoched_dataset_lists(sample_epoch): second = deepcopy(sample_epoch) second["setname"] = "second" From 8b826e1a2d0602291c39af498ac7e8550dbe2d4d Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Fri, 14 Aug 2026 10:59:37 -0700 Subject: [PATCH 07/12] envtopo: summed-metric label, vert markers, sumenv toggle --- src/eegprep/functions/popfunc/pop_envtopo.py | 2 + src/eegprep/functions/sigprocfunc/envtopo.py | 85 +++++++++++++++++--- tests/test_envtopo.py | 37 +++++++++ 3 files changed, 113 insertions(+), 11 deletions(-) diff --git a/src/eegprep/functions/popfunc/pop_envtopo.py b/src/eegprep/functions/popfunc/pop_envtopo.py index eef223a1..785babf3 100644 --- a/src/eegprep/functions/popfunc/pop_envtopo.py +++ b/src/eegprep/functions/popfunc/pop_envtopo.py @@ -83,6 +83,8 @@ def pop_envtopo( subcomps=kwargs.pop("subcomps", 0), sortvar=str(kwargs.pop("sortvar", topoplot_options.pop("sortvar", "mp"))), envmode=str(kwargs.pop("envmode", topoplot_options.pop("envmode", "avg"))), + sumenv=str(kwargs.pop("sumenv", topoplot_options.pop("sumenv", "fill"))), + vert=kwargs.pop("vert", None), title=str(kwargs.pop("title", dataset.get("setname") or "Largest ERP components")), topoplot_options=topoplot_options, ) diff --git a/src/eegprep/functions/sigprocfunc/envtopo.py b/src/eegprep/functions/sigprocfunc/envtopo.py index c62f5ed0..f9ab87ca 100644 --- a/src/eegprep/functions/sigprocfunc/envtopo.py +++ b/src/eegprep/functions/sigprocfunc/envtopo.py @@ -79,6 +79,8 @@ def envtopo( subcomps: Any = 0, sortvar: str = "mp", envmode: str = "avg", + sumenv: str = "fill", + vert: Any = None, plotchans: Any = None, title: str = "", topoplot_options: dict[str, Any] | None = None, @@ -100,6 +102,9 @@ def envtopo( ``[]`` removes all but ``compnums``. sortvar: Ranking metric ``'mp'`` (default), ``'pv'``, ``'pp'`` or ``'rp'``. envmode: ``'avg'`` (max/min envelope) or ``'rms'``. + sumenv: Summed-contribution envelope: ``'fill'`` (default), ``'on'`` (lines + only) or ``'off'``. + vert: Latencies (ms) at which to draw vertical dashed marker lines. plotchans: 1-based channels used for envelopes and maps; defaults to all. title: Figure title. topoplot_options: Extra keyword arguments forwarded to :func:`topoplot`. @@ -121,6 +126,8 @@ def envtopo( maps = np.linalg.pinv(weight_matrix) if _is_empty(icawinv) else np.asarray(icawinv, dtype=float) metric_mode = _normalize_sortvar(sortvar) + if sumenv not in ("on", "off", "fill"): + raise ValueError(f"envtopo: sumenv must be 'on', 'off' or 'fill', got {sumenv!r}") times_ms = _time_axis(timerange, n_frames) plot_channels = _resolve_channels(plotchans, n_chans) candidates = _resolve_components(compnums, n_components) @@ -144,12 +151,16 @@ def envtopo( compsplotted = compvarorder[:n_topos] plotted_components = candidates[order][:n_topos] + sumproj = finite_matmul(maps[np.ix_(plot_channels, plotted_components)], activations[plotted_components]) + data_win = values[plot_channels, lim1 : lim2 + 1] + reference = float(np.mean(np.var(data_win, axis=0))) if metric_mode == "pv" else float(np.mean(data_win**2)) + summed_metric, metric_label = _summed_metric(metric_mode, data_win, sumproj[:, lim1 : lim2 + 1], reference) figure = _build_figure( times_ms=times_ms, data_env=_envelope(finite_matmul(maps[plot_channels, :], activations), envmode), - summed_env=_envelope( - finite_matmul(maps[np.ix_(plot_channels, plotted_components)], activations[plotted_components]), envmode - ), + summed_env=_envelope(sumproj, envmode), + summed_metric=summed_metric, + metric_label=metric_label, comp_envelopes=comp_envelopes[order][:n_topos], max_projections=max_projections[:, order][:, :n_topos], plotted_frames=compframes[:n_topos], @@ -160,6 +171,8 @@ def envtopo( chanlocs=chanlocs, limcontrib_ms=None if (lim1, lim2) == (0, n_frames - 1) else (times_ms[lim1], times_ms[lim2]), envmode=envmode, + sumenv=sumenv, + vert=vert, title=title, topoplot_options=topoplot_options, ) @@ -201,6 +214,18 @@ def _sort_metric(metric_mode, max_power, data_win, proj_win, reference): return 100.0 * float(np.mean(proj_win**2)) / reference # "rp" +def _summed_metric(metric_mode, data_win, sum_win, reference): + """Summed sort metric over the plotted components, with its EEGLAB label. + + Mirrors EEGLAB: ``pv`` -> pvaf, ``rp`` -> rp, and ``mp``/``pp`` -> ppaf. + """ + if metric_mode == "pv": + return 100.0 - 100.0 * float(np.mean(np.var(data_win - sum_win, axis=0))) / reference, "pvaf" + if metric_mode == "rp": + return 100.0 * float(np.mean(sum_win**2)) / reference, "rp" + return 100.0 - 100.0 * float(np.mean((data_win - sum_win) ** 2)) / reference, "ppaf" + + def _envelope(data, envmode): if data.shape[0] <= 1: row = data.reshape(-1) @@ -286,6 +311,8 @@ def _build_figure( times_ms, data_env, summed_env, + summed_metric, + metric_label, comp_envelopes, max_projections, plotted_frames, @@ -296,6 +323,8 @@ def _build_figure( chanlocs, limcontrib_ms, envmode, + sumenv, + vert, title, topoplot_options, ): @@ -311,13 +340,25 @@ def _build_figure( figure = plt.figure(figsize=(9, 6)) env_ax = figure.add_axes([0.10, 0.10, 0.80, 0.52] if draw_maps else [0.10, 0.12, 0.85, 0.78]) - _draw_envelope(env_ax, times, data_env, summed_env, comp_envelopes, n_topos, envmode, limcontrib_ms) - redraws = { - env_ax: lambda ax: _draw_envelope( - ax, times, data_env, summed_env, comp_envelopes, n_topos, envmode, limcontrib_ms + def draw_env(ax): + _draw_envelope( + ax, + times, + data_env, + summed_env, + comp_envelopes, + n_topos, + envmode, + limcontrib_ms, + summed_metric, + metric_label, + vert, + sumenv, ) - } + + draw_env(env_ax) + redraws = {env_ax: draw_env} if draw_maps: _draw_maps_row( figure, @@ -340,9 +381,26 @@ def _build_figure( return figure -def _draw_envelope(ax, times, data_env, summed_env, comp_envelopes, n_topos, envmode, limcontrib_ms): - """Draw the data envelope, the summed fill and the per-component envelopes.""" - ax.fill_between(times, summed_env[1], summed_env[0], color=_FILLCOLOR, linewidth=0.0, zorder=1) +def _draw_envelope( + ax, + times, + data_env, + summed_env, + comp_envelopes, + n_topos, + envmode, + limcontrib_ms, + summed_metric, + metric_label, + vert, + sumenv, +): + """Draw the data envelope, the summed contribution and per-component envelopes.""" + if sumenv == "fill": + ax.fill_between(times, summed_env[1], summed_env[0], color=_FILLCOLOR, linewidth=0.0, zorder=1) + elif sumenv == "on": + ax.plot(times, summed_env[0], color=_FILLCOLOR, linewidth=3.0, zorder=2) + ax.plot(times, summed_env[1], color=_FILLCOLOR, linewidth=3.0, zorder=2) for position in range(n_topos): color = _COMP_COLORS[position % len(_COMP_COLORS)] ax.plot(times, comp_envelopes[position, 0], color=color, linewidth=1.0, zorder=3) @@ -361,6 +419,11 @@ def _draw_envelope(ax, times, data_env, summed_env, comp_envelopes, n_topos, env if limcontrib_ms is not None: for edge in limcontrib_ms: ax.axvline(float(edge) / 1000.0, color="k", linestyle=":", linewidth=1.2, zorder=2) + if not _is_empty(vert): + for latency in np.asarray(vert, dtype=float).ravel(): + ax.axvline(latency / 1000.0, color="k", linestyle="--", linewidth=2.0, zorder=2) + # EEGLAB prints the summed sort metric (ppaf/pvaf/rp) in the lower-left of the panel. + ax.text(0.02, 0.04, f"{metric_label} {summed_metric:.2f}%", transform=ax.transAxes, fontsize=8, fontweight="bold") def _draw_maps_row( diff --git a/tests/test_envtopo.py b/tests/test_envtopo.py index 445466e0..e6d9539f 100644 --- a/tests/test_envtopo.py +++ b/tests/test_envtopo.py @@ -19,6 +19,7 @@ matplotlib.use("Agg") import matplotlib.pyplot as plt +from matplotlib.collections import PolyCollection from matplotlib.figure import Figure import numpy as np import pytest @@ -196,3 +197,39 @@ def test_envmode_rms_runs_and_preserves_ranking(): np.testing.assert_array_equal(avg.compvarorder, rms.compvarorder) plt.close(avg.figure) plt.close(rms.figure) + + +@pytest.mark.parametrize("mode,label", [("mp", "ppaf"), ("pv", "pvaf"), ("rp", "rp")]) +def test_summed_metric_label_matches_mode(mode, label): + """The envelope panel prints the summed metric with EEGLAB's label per mode.""" + _, mean_data, weights, icawinv, timerange, _ = _ica_dataset(0) + res = envtopo(mean_data, weights, chanlocs=None, icawinv=icawinv, timerange=timerange, sortvar=mode) + texts = [t.get_text() for t in res.figure.axes[0].texts] + assert any(t.startswith(f"{label} ") and t.endswith("%") for t in texts) + plt.close(res.figure) + + +def test_sumenv_modes_control_the_summed_envelope(): + _, mean_data, weights, icawinv, timerange, _ = _ica_dataset(0) + common = dict(chanlocs=None, icawinv=icawinv, timerange=timerange) + fill = envtopo(mean_data, weights, sumenv="fill", **common) + lines_on = envtopo(mean_data, weights, sumenv="on", **common) + off = envtopo(mean_data, weights, sumenv="off", **common) + + assert any(isinstance(c, PolyCollection) for c in fill.figure.axes[0].collections) + assert not any(isinstance(c, PolyCollection) for c in off.figure.axes[0].collections) + # 'on' draws the summed envelope as two extra lines (max and min) vs 'off'. + assert len(lines_on.figure.axes[0].lines) == len(off.figure.axes[0].lines) + 2 + for result in (fill, lines_on, off): + plt.close(result.figure) + with pytest.raises(ValueError, match="sumenv"): + envtopo(mean_data, weights, sumenv="nope", **common) + + +def test_vert_draws_marker_lines(): + _, mean_data, weights, icawinv, timerange, _ = _ica_dataset(0) + latency = timerange[0] + 0.4 * (timerange[1] - timerange[0]) + res = envtopo(mean_data, weights, chanlocs=None, icawinv=icawinv, timerange=timerange, vert=[latency]) + verticals = [ln.get_xdata()[0] for ln in res.figure.axes[0].lines if len(np.unique(ln.get_xdata())) == 1] + assert any(abs(x - latency / 1000.0) < 1e-9 for x in verticals) + plt.close(res.figure) From 00d7678fe4a292d0ebb29be0231747e84b9314c1 Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Fri, 14 Aug 2026 11:53:10 -0700 Subject: [PATCH 08/12] envtopo: blank GUI subcomps field removes none --- src/eegprep/functions/popfunc/pop_envtopo.py | 3 ++- src/eegprep/resources/help/pop_envtopo.md | 3 +++ tests/test_envtopo.py | 9 +++++++- tests/test_phase4_plot_wrappers.py | 24 ++++++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/eegprep/functions/popfunc/pop_envtopo.py b/src/eegprep/functions/popfunc/pop_envtopo.py index 785babf3..7b824a2a 100644 --- a/src/eegprep/functions/popfunc/pop_envtopo.py +++ b/src/eegprep/functions/popfunc/pop_envtopo.py @@ -139,7 +139,8 @@ def _run_gui(EEG: dict[str, Any], *, renderer: Any | None = None) -> dict[str, A "components": numeric_vector(result.get("components", []), dtype=int).tolist(), "limcontrib": numeric_vector(result.get("limcontrib", [])).tolist(), "compsplot": numeric_vector(result.get("compsplot", []), dtype=int).tolist(), - "subcomps": numeric_vector(result.get("subcomps", []), dtype=int).tolist(), + # Blank field -> 0 ("remove none"), matching EEGLAB; [] would mean "remove all but compnums". + "subcomps": numeric_vector(result.get("subcomps", []), dtype=int).tolist() or 0, "title": str(result.get("title", "") or ""), "options": str(result.get("options", "") or ""), }, diff --git a/src/eegprep/resources/help/pop_envtopo.md b/src/eegprep/resources/help/pop_envtopo.md index f3c1e4fa..dbbd285b 100644 --- a/src/eegprep/resources/help/pop_envtopo.md +++ b/src/eegprep/resources/help/pop_envtopo.md @@ -8,6 +8,9 @@ fig, com = pop_envtopo(EEG, timerange=[-100, 300], return_com=True) This requires epoched data, channel locations, and ICA weights/maps. +Leaving the "Component numbers to remove from data before plotting" field blank +removes no components. + Left-click the envelope panel or any scalp map to enlarge it in a pop-up window; each enlarged map is annotated with its ranking metric value. diff --git a/tests/test_envtopo.py b/tests/test_envtopo.py index e6d9539f..5c00eefc 100644 --- a/tests/test_envtopo.py +++ b/tests/test_envtopo.py @@ -24,7 +24,7 @@ import numpy as np import pytest -from eegprep.functions.sigprocfunc.envtopo import envtopo +from eegprep.functions.sigprocfunc.envtopo import _resolve_subcomps, envtopo from tests.fixtures import create_test_eeg_with_ica pytestmark = pytest.mark.parity @@ -181,6 +181,13 @@ def test_subcomps_are_subtracted_and_excluded_from_selection(): plt.close(res.figure) +def test_resolve_subcomps_empty_vs_zero(): + """EEGLAB parity: 0 removes none (the default); [] removes all but the candidate components.""" + candidates = np.array([0, 1]) # 1-based compnums 1,2 of 4 components + assert _resolve_subcomps(0, 4, candidates).size == 0 + np.testing.assert_array_equal(_resolve_subcomps([], 4, candidates), np.array([2, 3])) + + def test_unknown_sortvar_raises(): _, mean_data, weights, icawinv, timerange, _ = _ica_dataset(0) with pytest.raises(ValueError, match="sortvar"): diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index 189ebdcb..29d57f5e 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1408,6 +1408,30 @@ def test_pop_envtopo_threads_eeglab_options_into_history(ica_epoch): plt.close(figure) +def test_pop_envtopo_blank_gui_subcomps_removes_none(ica_epoch): + """A blank GUI remove-components field means remove none (subcomps=0), not [] (remove all but compnums).""" + + class Renderer: + def run(self, spec, initial_values=None): + return { + "timerange": "", + "limcontrib": "", + "compsplot": "2", + "components": "1 2", + "subcomps": "", + "title": "blank subcomps", + "options": "", + } + + figure, command = pop_envtopo(ica_epoch, gui=True, renderer=Renderer(), return_com=True) + + assert isinstance(figure, Figure) + assert "subcomps=0" in command + assert "subcomps=[]" not in command + _assert_python_command(command) + plt.close(figure) + + def test_pop_envtopo_click_enlarges_map_and_envelope(ica_epoch): figure, _ = pop_envtopo(ica_epoch, compsplot=3, plot="off", return_com=True) figure.canvas.draw() From c3257df7eca7e665a84cffa0f18526d0a523429e Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Fri, 14 Aug 2026 11:58:27 -0700 Subject: [PATCH 09/12] envtopo: annotate enlarged maps with metric units --- src/eegprep/functions/sigprocfunc/envtopo.py | 6 ++++- tests/test_phase4_plot_wrappers.py | 23 +++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/eegprep/functions/sigprocfunc/envtopo.py b/src/eegprep/functions/sigprocfunc/envtopo.py index f9ab87ca..fa25fd9d 100644 --- a/src/eegprep/functions/sigprocfunc/envtopo.py +++ b/src/eegprep/functions/sigprocfunc/envtopo.py @@ -492,7 +492,11 @@ def _topo_map(ax, values, locs, options, label): def _redraw_map_popup(ax, values, locs, options, label, metric_mode, metric_value): """Enlarged pop-out of a scalp map, annotated with its sort-metric value.""" _topo_map(ax, values, locs, options, label) - ax.text(0.5, -0.03, f"{metric_mode}: {metric_value:.2f}", transform=ax.transAxes, ha="center", va="top") + # 'mp' is a raw peak power; 'pv'/'pp'/'rp' are percentages of data variance/power. + annotation = ( + f"{metric_mode}: {metric_value:.2f} µV²" if metric_mode == "mp" else f"{metric_mode}: {metric_value:.2f}%" + ) + ax.text(0.5, -0.03, annotation, transform=ax.transAxes, ha="center", va="top") def _is_empty(value): diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index 29d57f5e..35042c04 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1452,7 +1452,8 @@ def _left_click(ax): popup = plt.figure(opened[0]).axes[0] assert popup.images assert popup.get_title().startswith("IC ") - assert any("mp:" in text.get_text() for text in popup.texts) + # Default sortvar 'mp' is a raw peak power, annotated in µV². + assert any("mp:" in text.get_text() and "µV²" in text.get_text() for text in popup.texts) plt.close(opened[0]) # Left-clicking the envelope panel pops out an enlarged copy of the traces. @@ -1463,6 +1464,26 @@ def _left_click(ax): assert popup.get_xlabel() == "Time (s)" plt.close(opened[0]) + +def test_pop_envtopo_enlarged_map_annotation_uses_percent_for_pvaf(ica_epoch): + """Percent sort modes (pv/pp/rp) annotate the enlarged map with %, not µV².""" + figure, _ = pop_envtopo(ica_epoch, compsplot=3, sortvar="pp", plot="off", return_com=True) + figure.canvas.draw() + map_ax = next(ax for ax in figure.axes if ax.images) + + before = set(plt.get_fignums()) + px, py = map_ax.transData.transform((sum(map_ax.get_xlim()) / 2, sum(map_ax.get_ylim()) / 2)) + figure.canvas.callbacks.process( + "button_press_event", MouseEvent("button_press_event", figure.canvas, px, py, button=1) + ) + opened = sorted(set(plt.get_fignums()) - before) + assert len(opened) == 1 + popup = plt.figure(opened[0]).axes[0] + annotations = [text.get_text() for text in popup.texts if "pp:" in text.get_text()] + assert annotations and annotations[0].endswith("%") and "µV²" not in annotations[0] + plt.close(opened[0]) + plt.close(figure) + # A non-left button does not pop anything out (EEGLAB axcopy is left-button only). before = set(plt.get_fignums()) px, py = map_ax.transData.transform((sum(map_ax.get_xlim()) / 2, sum(map_ax.get_ylim()) / 2)) From a27d00bd41810adf28e22fa4cbe3ec10cc4850d1 Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Fri, 14 Aug 2026 15:43:10 -0700 Subject: [PATCH 10/12] envtopo: align chanlocs to plotchans subset for scalp maps --- src/eegprep/functions/sigprocfunc/envtopo.py | 2 ++ tests/test_envtopo.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/eegprep/functions/sigprocfunc/envtopo.py b/src/eegprep/functions/sigprocfunc/envtopo.py index fa25fd9d..b62671e9 100644 --- a/src/eegprep/functions/sigprocfunc/envtopo.py +++ b/src/eegprep/functions/sigprocfunc/envtopo.py @@ -334,6 +334,8 @@ def _build_figure( window (EEGLAB ``axcopy``); the ``redraws`` closures reproduce each axes. """ locs = chanlocs_as_list(chanlocs) if chanlocs is not None else [] + if plot_channels.size < len(locs): # full chanlocs given with a plotchans subset -> align to plotted channels + locs = [locs[int(index)] for index in plot_channels] draw_maps = bool(locs) n_topos = plotted_labels.size times = np.asarray(times_ms, dtype=float) / 1000.0 # EEGLAB envtopo plots the time axis in seconds diff --git a/tests/test_envtopo.py b/tests/test_envtopo.py index 5c00eefc..1f18f7f1 100644 --- a/tests/test_envtopo.py +++ b/tests/test_envtopo.py @@ -181,6 +181,21 @@ def test_subcomps_are_subtracted_and_excluded_from_selection(): plt.close(res.figure) +def test_plotchans_subset_with_full_chanlocs_draws_maps(): + """A plotchans subset paired with full chanlocs aligns the maps instead of size-mismatching topoplot.""" + eeg, mean_data, weights, icawinv, timerange, _ = _ica_dataset(0) + chanlocs = eeg["chanlocs"] # full 6-channel locations + plotchans = [1, 2, 3] # 1-based subset + + res = envtopo( + mean_data, weights, chanlocs=chanlocs, icawinv=icawinv, timerange=timerange, plotchans=plotchans, compsplot=2 + ) + + assert isinstance(res.figure, Figure) + assert any(ax.images for ax in res.figure.axes) # scalp maps were drawn + plt.close(res.figure) + + def test_resolve_subcomps_empty_vs_zero(): """EEGLAB parity: 0 removes none (the default); [] removes all but the candidate components.""" candidates = np.array([0, 1]) # 1-based compnums 1,2 of 4 components From ee9d154a1a86c7d4172f3ff9d1766e9d43780ed7 Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Fri, 14 Aug 2026 16:01:18 -0700 Subject: [PATCH 11/12] envtopo: dedupe reference calc and clarify compnums fallback --- src/eegprep/functions/popfunc/pop_envtopo.py | 5 ++++- src/eegprep/functions/sigprocfunc/envtopo.py | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/eegprep/functions/popfunc/pop_envtopo.py b/src/eegprep/functions/popfunc/pop_envtopo.py index 7b824a2a..535640c1 100644 --- a/src/eegprep/functions/popfunc/pop_envtopo.py +++ b/src/eegprep/functions/popfunc/pop_envtopo.py @@ -71,6 +71,9 @@ def pop_envtopo( chanlocs = _component_chanlocs(dataset, maps, icachansind) label_times = times if times.size else eeg_times_ms(dataset) topoplot_options = parse_plot_options_text(kwargs.pop("options", "")) + compnums = kwargs.pop("compnums", None) + if compnums is None: # GUI supplies "components"; the console uses "compnums" + compnums = kwargs.pop("components", None) result = envtopo( np.nanmean(data, axis=2), weights, @@ -78,7 +81,7 @@ def pop_envtopo( icawinv=maps, timerange=[float(label_times[0]), float(label_times[-1])], limcontrib=kwargs.pop("limcontrib", None), - compnums=kwargs.pop("compnums", kwargs.pop("components", None)), + compnums=compnums, compsplot=_first_int(kwargs.pop("compsplot", None), default=7), subcomps=kwargs.pop("subcomps", 0), sortvar=str(kwargs.pop("sortvar", topoplot_options.pop("sortvar", "mp"))), diff --git a/src/eegprep/functions/sigprocfunc/envtopo.py b/src/eegprep/functions/sigprocfunc/envtopo.py index b62671e9..700699e6 100644 --- a/src/eegprep/functions/sigprocfunc/envtopo.py +++ b/src/eegprep/functions/sigprocfunc/envtopo.py @@ -153,7 +153,7 @@ def envtopo( plotted_components = candidates[order][:n_topos] sumproj = finite_matmul(maps[np.ix_(plot_channels, plotted_components)], activations[plotted_components]) data_win = values[plot_channels, lim1 : lim2 + 1] - reference = float(np.mean(np.var(data_win, axis=0))) if metric_mode == "pv" else float(np.mean(data_win**2)) + reference = _reference(data_win, metric_mode) summed_metric, metric_label = _summed_metric(metric_mode, data_win, sumproj[:, lim1 : lim2 + 1], reference) figure = _build_figure( times_ms=times_ms, @@ -183,7 +183,7 @@ def _contributions(values, activations, maps, candidates, plot_channels, lim1, l """Per-candidate ranking metric, peak frame, envelope and peak-frame map.""" window = slice(lim1, lim2 + 1) data_win = values[plot_channels, window] - reference = float(np.mean(np.var(data_win, axis=0))) if metric_mode == "pv" else float(np.mean(data_win**2)) + reference = _reference(data_win, metric_mode) metric = np.empty(candidates.size) plotframes = np.empty(candidates.size, dtype=int) @@ -204,6 +204,11 @@ def _contributions(values, activations, maps, candidates, plot_channels, lim1, l return metric, plotframes, envelopes, max_projections +def _reference(data_win, metric_mode): + """Denominator for the pv/pp/rp percentage metrics: data variance ('pv') or mean power.""" + return float(np.mean(np.var(data_win, axis=0))) if metric_mode == "pv" else float(np.mean(data_win**2)) + + def _sort_metric(metric_mode, max_power, data_win, proj_win, reference): if metric_mode == "mp": return max_power From 97c0137d5eb5c75361d9c2859d081683271b905f Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Fri, 14 Aug 2026 19:47:24 -0700 Subject: [PATCH 12/12] envtopo: restore right-button-ignore check in click test --- tests/test_phase4_plot_wrappers.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index 35042c04..c713e637 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1464,6 +1464,15 @@ def _left_click(ax): assert popup.get_xlabel() == "Time (s)" plt.close(opened[0]) + # A non-left button does not pop anything out (EEGLAB axcopy is left-button only). + before = set(plt.get_fignums()) + px, py = map_ax.transData.transform((sum(map_ax.get_xlim()) / 2, sum(map_ax.get_ylim()) / 2)) + figure.canvas.callbacks.process( + "button_press_event", MouseEvent("button_press_event", figure.canvas, px, py, button=3) + ) + assert set(plt.get_fignums()) == before + plt.close(figure) + def test_pop_envtopo_enlarged_map_annotation_uses_percent_for_pvaf(ica_epoch): """Percent sort modes (pv/pp/rp) annotate the enlarged map with %, not µV².""" @@ -1484,15 +1493,6 @@ def test_pop_envtopo_enlarged_map_annotation_uses_percent_for_pvaf(ica_epoch): plt.close(opened[0]) plt.close(figure) - # A non-left button does not pop anything out (EEGLAB axcopy is left-button only). - before = set(plt.get_fignums()) - px, py = map_ax.transData.transform((sum(map_ax.get_xlim()) / 2, sum(map_ax.get_ylim()) / 2)) - figure.canvas.callbacks.process( - "button_press_event", MouseEvent("button_press_event", figure.canvas, px, py, button=3) - ) - assert set(plt.get_fignums()) == before - plt.close(figure) - def test_pop_comperp_and_chanplot_work_on_epoched_dataset_lists(sample_epoch): second = deepcopy(sample_epoch)