From e189813ea5a9fa85f7b525c6c0fbb4d6bf7b3887 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:43 -0700 Subject: [PATCH 01/45] [Tests] Pin current EEGLAB test-port provenance (#329) * Add provenance for current EEGLAB test ports * Add MATLAB fixture loader for test ports --- docs/source/development.rst | 35 ++++++++++++ tests/eeglab_tests/__init__.py | 76 ++++++++++++++++++++++++++ tests/test_eeglab_test_provenance.py | 80 ++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 tests/eeglab_tests/__init__.py create mode 100644 tests/test_eeglab_test_provenance.py diff --git a/docs/source/development.rst b/docs/source/development.rst index 0e188eef..1410a462 100644 --- a/docs/source/development.rst +++ b/docs/source/development.rst @@ -79,6 +79,41 @@ This installs: Running Tests ============= +Porting The Current EEGLAB Tests +-------------------------------- + +Ports of the upstream MATLAB tests use only the current +`sccn/eeglab_tests `_ repository. The +older ``sccn/eeglab-testcases`` repository is stale and must not be used. +EEGPrep currently pins ``eeglab_tests`` commit +``ff605546f3f70868916fb8d49c007472b3257b50`` and the EEGLAB submodule commit +``8ac485f654d6bbb1a6acb8dc9ef3f2eaf3d409ba``. + +Translate the behavior and assertions of each MATLAB scenario into the closest +existing pytest module. Decorate the Python test with its upstream path and +test name so coverage remains traceable without a separate conversion matrix: + +.. code-block:: python + + from tests.eeglab_tests import eeglab_test + + @eeglab_test("regression_tests/t_pop_selectevent.m", "testRetainsMatchingEpochs") + def test_pop_selectevent_retains_matching_epochs(): + ... + +One Python test may carry more than one decorator when it genuinely covers +multiple equivalent upstream scenarios. Do not combine tests merely to reduce +the number of ports. Preserve input shapes, dtypes, empty values, indexing, +warnings, errors, and all scientifically relevant output fields. Use a live +MATLAB comparison when practical or small expected data generated from the +pinned suite when ordinary CI must run without MATLAB. + +When a faithful port exposes missing behavior or a defect, keep the failing +scenario visible and create a Bead for the implementation work. Pure MATLAB +runtime behavior may be excluded only with a concrete technical rationale. +Never replace an applicable assertion with a no-crash smoke test or broaden a +numerical tolerance simply to make the port pass. + Test Discovery -------------- diff --git a/tests/eeglab_tests/__init__.py b/tests/eeglab_tests/__init__.py new file mode 100644 index 00000000..2e5a3a44 --- /dev/null +++ b/tests/eeglab_tests/__init__.py @@ -0,0 +1,76 @@ +"""Shared provenance helpers for ports of the current EEGLAB test suite.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from pathlib import PurePosixPath +from typing import Any, Callable, TypeVar + +import pytest +from scipy.io import loadmat + + +EEGLAB_TESTS_REPOSITORY = "https://github.com/sccn/eeglab_tests.git" +EEGLAB_TESTS_COMMIT = "ff605546f3f70868916fb8d49c007472b3257b50" +EEGLAB_TESTS_EEGLAB_COMMIT = "8ac485f654d6bbb1a6acb8dc9ef3f2eaf3d409ba" +STALE_EEGLAB_TESTS_REPOSITORY = "https://github.com/sccn/eeglab-testcases.git" + +_TestCallable = TypeVar("_TestCallable", bound=Callable) +_REFERENCE_ATTRIBUTE = "__eeglab_test_references__" + + +@dataclass(frozen=True) +class EeglabTestReference: + """Identify one MATLAB test scenario ported by a Python test.""" + + source: str + test: str + suite_commit: str = EEGLAB_TESTS_COMMIT + eeglab_commit: str = EEGLAB_TESTS_EEGLAB_COMMIT + + +def eeglab_test(source: str, test: str) -> Callable[[_TestCallable], _TestCallable]: + """Attach current-suite provenance and the parity marker to a pytest test.""" + reference = _validated_reference(source, test) + + def decorate(test_function: _TestCallable) -> _TestCallable: + references = upstream_references(test_function) + if reference in references: + raise ValueError(f"duplicate EEGLAB test reference: {source}::{test}") + setattr(test_function, _REFERENCE_ATTRIBUTE, (*references, reference)) + return pytest.mark.parity(test_function) + + return decorate + + +def upstream_references(test_function: Callable) -> tuple[EeglabTestReference, ...]: + """Return the upstream MATLAB tests represented by a Python test.""" + return tuple(getattr(test_function, _REFERENCE_ATTRIBUTE, ())) + + +def load_matlab_test_fixture(file: str | Path) -> dict[str, Any]: + """Load a MATLAB v4-v7.2 test fixture without squeezing or casting values.""" + loaded = loadmat(file, struct_as_record=True, squeeze_me=False) + return {key: value for key, value in loaded.items() if not key.startswith("__")} + + +def _validated_reference(source: str, test: str) -> EeglabTestReference: + source_path = PurePosixPath(source) + if source_path.is_absolute() or ".." in source_path.parts or source_path.suffix != ".m": + raise ValueError(f"EEGLAB test source must be a relative .m path: {source!r}") + if not test or "::" in test: + raise ValueError(f"EEGLAB test name must be a non-empty MATLAB test name: {test!r}") + return EeglabTestReference(source=source_path.as_posix(), test=test) + + +__all__ = [ + "EEGLAB_TESTS_COMMIT", + "EEGLAB_TESTS_EEGLAB_COMMIT", + "EEGLAB_TESTS_REPOSITORY", + "STALE_EEGLAB_TESTS_REPOSITORY", + "EeglabTestReference", + "eeglab_test", + "load_matlab_test_fixture", + "upstream_references", +] diff --git a/tests/test_eeglab_test_provenance.py b/tests/test_eeglab_test_provenance.py new file mode 100644 index 00000000..698d0cac --- /dev/null +++ b/tests/test_eeglab_test_provenance.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from scipy.io import savemat + +from tests.eeglab_tests import ( + EEGLAB_TESTS_COMMIT, + EEGLAB_TESTS_EEGLAB_COMMIT, + EEGLAB_TESTS_REPOSITORY, + STALE_EEGLAB_TESTS_REPOSITORY, + eeglab_test, + load_matlab_test_fixture, + upstream_references, +) + + +def test_current_eeglab_test_suite_is_pinned_without_the_stale_repository() -> None: + assert EEGLAB_TESTS_REPOSITORY == "https://github.com/sccn/eeglab_tests.git" + assert EEGLAB_TESTS_COMMIT == "ff605546f3f70868916fb8d49c007472b3257b50" + assert EEGLAB_TESTS_EEGLAB_COMMIT == "8ac485f654d6bbb1a6acb8dc9ef3f2eaf3d409ba" + assert STALE_EEGLAB_TESTS_REPOSITORY not in { + EEGLAB_TESTS_REPOSITORY, + EEGLAB_TESTS_COMMIT, + EEGLAB_TESTS_EEGLAB_COMMIT, + } + + +def test_eeglab_test_records_multiple_upstream_scenarios() -> None: + @eeglab_test("regression_tests/t_statcond.m", "test_1") + @eeglab_test("regression_tests/t_statcond.m", "test_2") + def translated_test() -> None: + pass + + assert [(reference.source, reference.test) for reference in upstream_references(translated_test)] == [ + ("regression_tests/t_statcond.m", "test_2"), + ("regression_tests/t_statcond.m", "test_1"), + ] + assert any(marker.name == "parity" for marker in getattr(translated_test, "pytestmark")) + + +@pytest.mark.parametrize( + ("source", "test"), + [ + ("/tmp/t_statcond.m", "test_1"), + ("../t_statcond.m", "test_1"), + ("regression_tests/t_statcond.py", "test_1"), + ("regression_tests/t_statcond.m", ""), + ("regression_tests/t_statcond.m", "class::test_1"), + ], +) +def test_eeglab_test_rejects_invalid_provenance(source: str, test: str) -> None: + with pytest.raises(ValueError): + eeglab_test(source, test) + + +def test_eeglab_test_rejects_duplicate_references() -> None: + decorator = eeglab_test("regression_tests/t_statcond.m", "test_1") + + @decorator + def translated_test() -> None: + pass + + with pytest.raises(ValueError, match="duplicate EEGLAB test reference"): + decorator(translated_test) + + +def test_load_matlab_test_fixture_preserves_shape_and_dtype(tmp_path: Path) -> None: + fixture_path = tmp_path / "fixture.mat" + expected = np.arange(6, dtype=np.float32).reshape(2, 3) + savemat(fixture_path, {"values": expected}) + + loaded = load_matlab_test_fixture(fixture_path) + + assert set(loaded) == {"values"} + assert loaded["values"].shape == (2, 3) + assert loaded["values"].dtype == np.float32 + np.testing.assert_array_equal(loaded["values"], expected) From c96f1c069d70c673f59cafabd242beaaf2243a7d Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:43 -0700 Subject: [PATCH 02/45] [Tests] Port current popfunc regressions (#330) * Port current pop regression tests * Port core popfunc conversion tests * Port ICA rejection helper tests * Port current pop utility workflows --- docs/source/changelog.rst | 6 + .../functions/popfunc/eeg_decodechan.py | 18 +- .../functions/popfunc/eeg_point2lat.py | 2 +- .../functions/popfunc/eeg_rejsuperpose.py | 3 + src/eegprep/functions/popfunc/pop_select.py | 5 +- .../functions/popfunc/pop_selectevent.py | 5 +- src/eegprep/functions/popfunc/pop_topoplot.py | 41 ++++- src/eegprep/resources/help/pop_selectevent.md | 4 + src/eegprep/resources/help/pop_topoplot.md | 3 + tests/test_eeg_decodechan.py | 11 ++ tests/test_eeg_emptyset.py | 2 + tests/test_eeg_lat2point.py | 48 +++++ tests/test_eeg_point2lat.py | 46 +++++ tests/test_phase1b_file_edit_pop_functions.py | 169 ++++++++++++++++++ tests/test_phase2_file_channel_location.py | 66 +++++++ tests/test_phase7_long_tail_helpers.py | 15 ++ tests/test_pop_adjustevents.py | 44 +++++ tests/test_pop_comments.py | 39 ++++ tests/test_pop_topoplot.py | 106 +++++++++++ tests/test_rejection_workflows.py | 90 ++++++++++ 20 files changed, 705 insertions(+), 18 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index c42c5aa8..0a4a12b9 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,12 @@ the `GitHub Releases `_ page. Unreleased ========== +- ``pop_selectevent`` now supports ``erroronempty='off'`` when a selection removes every + epoch, returning an empty EEG instead of raising. Component ``pop_topoplot`` colorbars + now use polarity labels only for scales that span zero; positive-only and negative-only + scales show their exact numeric ``maplimits`` endpoints. ``eeg_decodechan`` now accepts + EEGLAB's whitespace-separated channel-name form as well as Python sequences. ICA rejection + marks can also derive their row count from ``icachansind`` when stored ICA matrices are absent. - ``pop_autorej`` (Tools > Automatic epoch rejection) now runs EEGLAB's probability loop exactly: a pass rejects its flagged epochs only when they are fewer than ``maxrej`` percent of the remaining epochs (5% of 80 epochs is not fewer, so the threshold is diff --git a/src/eegprep/functions/popfunc/eeg_decodechan.py b/src/eegprep/functions/popfunc/eeg_decodechan.py index f3296e54..ef5d5059 100644 --- a/src/eegprep/functions/popfunc/eeg_decodechan.py +++ b/src/eegprep/functions/popfunc/eeg_decodechan.py @@ -11,7 +11,7 @@ def eeg_decodechan( Supports: - chanlocs as a list-like of dicts, or a dict with key "chanlocs". - - chanstr as an iterable of strings and/or integers. + - chanstr as an iterable of strings/integers or a space-separated string. - Matching on the specified field (e.g., "labels" or "type"). - Numeric 0-based indices as input (returned directly after validation). - Empty chanlocs with purely numeric input (indices passthrough). @@ -20,8 +20,9 @@ def eeg_decodechan( ---------- chanlocs : list of dict or dict Channel locations or {'chanlocs': [...]} - chanstr : iterable - Channel identifiers (strings or ints) + chanstr : iterable or str + Channel identifiers (strings or ints). A string contains + whitespace-separated channel names. field : str, optional Field to match on (default 'labels') ignoremissing : bool, optional @@ -44,10 +45,13 @@ def eeg_decodechan( nchan = len(chanlocs) # Normalize chanstr into a flat Python list - try: - seq = list(chanstr) - except Exception as e: - raise TypeError("chanstr must be an iterable of strings/integers") from e + if isinstance(chanstr, str): + seq = chanstr.split() + else: + try: + seq = list(chanstr) + except Exception as e: + raise TypeError("chanstr must be an iterable of strings/integers") from e # Detect numeric-only request (ints or strings that are pure integers) numeric_req = [] diff --git a/src/eegprep/functions/popfunc/eeg_point2lat.py b/src/eegprep/functions/popfunc/eeg_point2lat.py index fdb75955..487fff55 100644 --- a/src/eegprep/functions/popfunc/eeg_point2lat.py +++ b/src/eegprep/functions/popfunc/eeg_point2lat.py @@ -49,7 +49,7 @@ def eeg_point2lat(lat_array, epoch_array=None, srate=None, timewin=None, timeuni if lat_array.size != epoch_array.size: if epoch_array.size != 1: raise ValueError("eeg_point2lat: latency and epoch arrays must have the same length") - epoch_array = np.ones(lat_array.shape, dtype=float) * float(epoch_array) + epoch_array = np.ones(lat_array.shape, dtype=float) * epoch_array.item() timewin = np.atleast_1d(np.array(timewin, dtype=float)) * float(timeunit) if timewin.size != 2: diff --git a/src/eegprep/functions/popfunc/eeg_rejsuperpose.py b/src/eegprep/functions/popfunc/eeg_rejsuperpose.py index 17dadd87..a0ea71ea 100644 --- a/src/eegprep/functions/popfunc/eeg_rejsuperpose.py +++ b/src/eegprep/functions/popfunc/eeg_rejsuperpose.py @@ -137,4 +137,7 @@ def _component_count(EEG: dict[str, Any]) -> int: winv = np.asarray(EEG.get("icawinv", [])) if winv.ndim == 2 and winv.size: return int(winv.shape[1]) + channels = np.asarray(EEG.get("icachansind", [])) + if channels.size: + return int(channels.size) return 0 diff --git a/src/eegprep/functions/popfunc/pop_select.py b/src/eegprep/functions/popfunc/pop_select.py index ce7650cd..78aa8a78 100644 --- a/src/eegprep/functions/popfunc/pop_select.py +++ b/src/eegprep/functions/popfunc/pop_select.py @@ -80,6 +80,7 @@ def _pop_select_apply(EEG, **kwargs): 'sort': kwargs.get('sort', None), 'sorttrial': kwargs.get('sorttrial', 'on'), 'checkchans': kwargs.get('checkchans', 'on'), + 'erroronempty': kwargs.get('erroronempty', 'on'), } # alias normalization @@ -143,7 +144,7 @@ def _get(key, default=None): keep = np.setdiff1d(trial_set, notrial_set, assume_unique=False) keep.sort() g['trial'] = keep.tolist() - if len(g['trial']) == 0: + if len(g['trial']) == 0 and str(g['erroronempty']).lower() == 'on': fname = _get('filename', '') raise ValueError(f'Error: dataset {fname} is empty') else: @@ -154,7 +155,7 @@ def _get(key, default=None): _, idx = np.unique(trial_seq, return_index=True) g['trial'] = trial_seq[np.sort(idx)].tolist() - if min(g['trial']) < 1 or max(g['trial']) > trials: + if g['trial'] and (min(g['trial']) < 1 or max(g['trial']) > trials): raise ValueError('Wrong trial range') # 2) Channel selection by name or type, with mutual exclusion diff --git a/src/eegprep/functions/popfunc/pop_selectevent.py b/src/eegprep/functions/popfunc/pop_selectevent.py index f8861704..a284f39a 100644 --- a/src/eegprep/functions/popfunc/pop_selectevent.py +++ b/src/eegprep/functions/popfunc/pop_selectevent.py @@ -261,11 +261,12 @@ def _apply_selectevent_one(EEG: dict[str, Any], options: dict[str, Any]) -> tupl if str(options.get("invertepochs", "off")).lower() == "on": all_epochs = set(range(1, int(output.get("trials", 1) or 1) + 1)) epochs = sorted(all_epochs - set(epochs)) - if not epochs: + erroronempty = str(options.get("erroronempty", "on")).lower() + if not epochs and erroronempty == "on": raise ValueError("Empty dataset: all epochs have been removed") if deleteevents: output["event"] = [events[index] for index in selected_indices] - output = pop_select(output, "trial", epochs) + output = pop_select(output, "trial", epochs, "erroronempty", erroronempty) elif deleteevents: output["event"] = [events[index] for index in selected_indices] else: diff --git a/src/eegprep/functions/popfunc/pop_topoplot.py b/src/eegprep/functions/popfunc/pop_topoplot.py index 82834ae2..52ec04ba 100644 --- a/src/eegprep/functions/popfunc/pop_topoplot.py +++ b/src/eegprep/functions/popfunc/pop_topoplot.py @@ -8,6 +8,8 @@ import matplotlib.pyplot as plt import numpy as np +from matplotlib.cm import ScalarMappable +from matplotlib.colors import Normalize from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import ControlSpec, DialogSpec @@ -263,20 +265,47 @@ def _plot_map_pages( fig.text(0.5, 0.02, topotitle, ha="center", va="bottom", fontweight="bold") fig.tight_layout(rect=(0, 0.05, 1, 1) if topotitle else (0, 0, 1, 1)) if colorbar and colorbar_image is not None: - _add_map_colorbar(fig, colorbar_image, plotted_axes, component=component) + _add_map_colorbar( + fig, + colorbar_image, + plotted_axes, + component=component, + maplimits=maplimits, + ) figures.append(fig) return figures -def _add_map_colorbar(fig: Any, image: Any, axes: list[Any], *, component: bool) -> None: - """Draw the shared scalp-map colorbar, marking component maps with -/0/+ polarity labels.""" - cbar = fig.colorbar(image, ax=axes, shrink=0.7) - if component: - low, high = image.get_clim() +def _add_map_colorbar( + fig: Any, + image: Any, + axes: list[Any], + *, + component: bool, + maplimits: Any, +) -> None: + """Draw the shared scalp-map colorbar using EEGLAB's displayed limits.""" + limits = _numeric_maplimits(maplimits) + mappable = image + if limits is not None: + mappable = ScalarMappable(norm=Normalize(vmin=limits[0], vmax=limits[1]), cmap=image.get_cmap()) + mappable.set_array([]) + cbar = fig.colorbar(mappable, ax=axes, shrink=0.7) + low, high = cbar.mappable.get_clim() + if component and low < 0 < high: cbar.set_ticks([low, 0.0, high]) cbar.set_ticklabels(["-", "0", "+"]) +def _numeric_maplimits(maplimits: Any) -> tuple[float, float] | None: + if isinstance(maplimits, str) or maplimits is None: + return None + values = np.asarray(maplimits, dtype=float).ravel() + if values.size < 2 or not np.all(np.isfinite(values[:2])): + return None + return float(values[0]), float(values[1]) + + def _erp_maps(EEG: dict[str, Any], latencies_ms: np.ndarray) -> tuple[list[np.ndarray | None], list[str]]: _require_chanlocs(EEG) data = np.asarray(EEG.get("data")) diff --git a/src/eegprep/resources/help/pop_selectevent.md b/src/eegprep/resources/help/pop_selectevent.md index 6ca4dc93..b659552b 100644 --- a/src/eegprep/resources/help/pop_selectevent.md +++ b/src/eegprep/resources/help/pop_selectevent.md @@ -7,5 +7,9 @@ custom fields. Event indices are 1-based. Continuous data keeps boundary events when deleting non-selected events, matching EEGLAB's expectation that boundaries preserve discontinuity information. +Epoch selection raises when every epoch would be removed. Pass +`erroronempty="off"` to allow an empty result instead; the returned EEG then has +zero trials, empty data, and no events. + The function returns selected event indices for programmatic calls, and returns an EEG plus replayable command when `return_com=True`. diff --git a/src/eegprep/resources/help/pop_topoplot.md b/src/eegprep/resources/help/pop_topoplot.md index e10659d5..b519b7a5 100644 --- a/src/eegprep/resources/help/pop_topoplot.md +++ b/src/eegprep/resources/help/pop_topoplot.md @@ -19,6 +19,9 @@ pop_topoplot(EEG, typeplot=0, items=[1, -2, float("nan"), 3], topotitle="IC maps Additional `topoplot` options can be passed as keyword arguments, for example `electrodes="on"`, `colorbar="off"`, or `maplimits=[-5, 5]`. +Component colorbars use `-`, `0`, and `+` labels only when their limits span +both negative and positive values. One-sided scales retain numeric labels and +the exact requested endpoints. DIPFIT dipole overlays and 3-D head plots are handled by later Phase 4 work. diff --git a/tests/test_eeg_decodechan.py b/tests/test_eeg_decodechan.py index 7da7e0f4..87b954f8 100644 --- a/tests/test_eeg_decodechan.py +++ b/tests/test_eeg_decodechan.py @@ -3,6 +3,7 @@ # Bring in the function under test from eegprep import eeg_decodechan +from tests.eeglab_tests import eeglab_test class TestEEGDecodeChan(unittest.TestCase): @@ -78,6 +79,16 @@ def test_non_iterable_chanstr_raises_typeerror(self): with self.assertRaises(TypeError): eeg_decodechan(self.chanlocs, 123) + @eeglab_test("unittesting_popfunc/eeg_decodechan/popfunc_eeg_decodechan_wrapperTest.m", "test_test_eeg_decodechan") + def test_cell_and_space_separated_channel_labels_match(self): + cell_indices, cell_labels = eeg_decodechan(self.chanlocs, ["cz", "pz", "fz"]) + text_indices, text_labels = eeg_decodechan(self.chanlocs, "cz pz fz") + + self.assertEqual(cell_indices, [0, 1, 2]) + self.assertEqual(cell_labels, ["Fz", "Cz", "Pz"]) + self.assertEqual(text_indices, cell_indices) + self.assertEqual(text_labels, cell_labels) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_eeg_emptyset.py b/tests/test_eeg_emptyset.py index 7047a797..365720a8 100644 --- a/tests/test_eeg_emptyset.py +++ b/tests/test_eeg_emptyset.py @@ -1,6 +1,8 @@ from eegprep.functions.popfunc.eeg_emptyset import eeg_emptyset +from tests.eeglab_tests import eeglab_test +@eeglab_test("unittesting_popfunc/eeg_emptyset/popfunc_eeg_emptyset_wrapperTest.m", "test_pass_general") def test_eeg_emptyset_includes_eeglab_reference_defaults(): eeg = eeg_emptyset() diff --git a/tests/test_eeg_lat2point.py b/tests/test_eeg_lat2point.py index 2837f101..1d2c708e 100644 --- a/tests/test_eeg_lat2point.py +++ b/tests/test_eeg_lat2point.py @@ -5,6 +5,7 @@ from eegprep.functions.adminfunc.eeglabcompat import get_eeglab from eegprep.functions.popfunc.eeg_lat2point import eeg_lat2point +from tests.eeglab_tests import eeglab_test @unittest.skipIf(os.getenv('EEGPREP_SKIP_MATLAB') == '1', "MATLAB not available") @@ -113,3 +114,50 @@ def test_eeg_lat2point_outrange_error(self): if __name__ == '__main__': # test only test_parity_continuous unittest.main() + + +def _assert_lat2point(latencies, epochs, expected, *, time_limits=(-1000, 2000), time_unit=1 / 1000): + actual, _ = eeg_lat2point(latencies, epochs, 1, time_limits, time_unit) + np.testing.assert_allclose(actual, expected) + + +@eeglab_test("unittesting_popfunc/eeg_lat2point/popfunc_eeg_lat2point_wrapperTest.m", "test_pass_general") +def test_eeg_lat2point_current_suite_general_case(): + _assert_lat2point([0, 300, 0, 0, 400, 0, 250], [1, 1, 2, 3, 3, 4, 4], [2, 2.3, 6, 10, 10.4, 14, 14.25]) + + +@eeglab_test("unittesting_popfunc/eeg_lat2point/popfunc_eeg_lat2point_wrapperTest.m", "test_pass_cell_epoch") +def test_eeg_lat2point_current_suite_cell_epoch_case(): + epochs = np.array([1, 1, 2, 3, 3, 4, 4], dtype=object) + _assert_lat2point([0, 300, 0, 0, 400, 0, 250], epochs, [2, 2.3, 6, 10, 10.4, 14, 14.25]) + + +@eeglab_test("unittesting_popfunc/eeg_lat2point/popfunc_eeg_lat2point_wrapperTest.m", "test_pass_cell_lat") +def test_eeg_lat2point_current_suite_cell_latency_case(): + latencies = np.array([0, 300, 0, 0, 400, 0, 250], dtype=object) + _assert_lat2point(latencies, [1, 1, 2, 3, 3, 4, 4], [2, 2.3, 6, 10, 10.4, 14, 14.25]) + + +@eeglab_test("unittesting_popfunc/eeg_lat2point/popfunc_eeg_lat2point_wrapperTest.m", "test_pass_one_epoch") +def test_eeg_lat2point_current_suite_single_epoch_broadcast(): + _assert_lat2point([0, 300, 0, 0, 400, 0, 250], [1], [2, 2.3, 2, 2, 2.4, 2, 2.25]) + + +@eeglab_test("unittesting_popfunc/eeg_lat2point/popfunc_eeg_lat2point_wrapperTest.m", "test_pass_outbound") +def test_eeg_lat2point_current_suite_clamps_out_of_range_latency(): + _assert_lat2point( + [0, 300, 0, 0, 400, 0, 250, 3500], + [1, 1, 2, 3, 3, 4, 4, 4], + [2, 2.3, 6, 10, 10.4, 14, 14.25, 16], + ) + + +@eeglab_test("unittesting_popfunc/eeg_lat2point/popfunc_eeg_lat2point_wrapperTest.m", "test_pass_std_timeunit") +def test_eeg_lat2point_current_suite_default_time_unit(): + _assert_lat2point( + [0, 0.3, 0, 0, 0.4, 0, 0.25], + [1, 1, 2, 3, 3, 4, 4], + [2, 2.3, 6, 10, 10.4, 14, 14.25], + time_limits=(-1, 2), + time_unit=1, + ) diff --git a/tests/test_eeg_point2lat.py b/tests/test_eeg_point2lat.py index b83e61a1..e740efa1 100644 --- a/tests/test_eeg_point2lat.py +++ b/tests/test_eeg_point2lat.py @@ -2,6 +2,7 @@ import numpy as np from eegprep.functions.adminfunc.eeglabcompat import get_eeglab from eegprep.functions.popfunc.eeg_point2lat import eeg_point2lat +from tests.eeglab_tests import eeglab_test import unittest @@ -11,6 +12,51 @@ def _matlab_row(x): return [[v for v in x]] +def _assert_point2lat(points, epochs, expected, *, time_limits=(-1000, 2000), time_unit=1 / 1000): + actual = eeg_point2lat(points, epochs, 1, time_limits, time_unit) + np.testing.assert_allclose(actual, expected) + + +@eeglab_test("unittesting_popfunc/eeg_point2lat/popfunc_eeg_point2lat_wrapperTest.m", "test_pass_general") +def test_eeg_point2lat_current_suite_general_case(): + _assert_point2lat([2, 2.3, 6, 10, 10.4, 14, 14.25], [1, 1, 2, 3, 3, 4, 4], [0, 300, 0, 0, 400, 0, 250]) + + +@eeglab_test("unittesting_popfunc/eeg_point2lat/popfunc_eeg_point2lat_wrapperTest.m", "test_pass_cell_epoch") +def test_eeg_point2lat_current_suite_cell_epoch_case(): + epochs = np.array([1, 1, 2, 3, 3, 4, 4], dtype=object) + _assert_point2lat([2, 2.3, 6, 10, 10.4, 14, 14.25], epochs, [0, 300, 0, 0, 400, 0, 250]) + + +@eeglab_test("unittesting_popfunc/eeg_point2lat/popfunc_eeg_point2lat_wrapperTest.m", "test_pass_cell_lat") +def test_eeg_point2lat_current_suite_cell_latency_case(): + points = np.array([2, 2.3, 6, 10, 10.4, 14, 14.25], dtype=object) + _assert_point2lat(points, [1, 1, 2, 3, 3, 4, 4], [0, 300, 0, 0, 400, 0, 250]) + + +@eeglab_test("unittesting_popfunc/eeg_point2lat/popfunc_eeg_point2lat_wrapperTest.m", "test_pass_no_epoch") +def test_eeg_point2lat_current_suite_empty_epoch_defaults_to_one(): + expected = [0, 300, 4000, 8000, 8400, 12000, 12250] + _assert_point2lat([2, 2.3, 6, 10, 10.4, 14, 14.25], [], expected) + + +@eeglab_test("unittesting_popfunc/eeg_point2lat/popfunc_eeg_point2lat_wrapperTest.m", "test_pass_one_epoch") +def test_eeg_point2lat_current_suite_single_epoch_broadcast(): + expected = [0, 300, 4000, 8000, 8400, 12000, 12250] + _assert_point2lat([2, 2.3, 6, 10, 10.4, 14, 14.25], [1], expected) + + +@eeglab_test("unittesting_popfunc/eeg_point2lat/popfunc_eeg_point2lat_wrapperTest.m", "test_pass_std_timeunit") +def test_eeg_point2lat_current_suite_default_time_unit(): + _assert_point2lat( + [2, 2.3, 6, 10, 10.4, 14, 14.25], + [1, 1, 2, 3, 3, 4, 4], + [0, 0.3, 0, 0, 0.4, 0, 0.25], + time_limits=(-1, 2), + time_unit=1, + ) + + @unittest.skipIf(os.getenv('EEGPREP_SKIP_MATLAB') == '1', "MATLAB not available") class TestEegPoint2LatParity(unittest.TestCase): def setUp(self): diff --git a/tests/test_phase1b_file_edit_pop_functions.py b/tests/test_phase1b_file_edit_pop_functions.py index 52f43b92..28c100fc 100644 --- a/tests/test_phase1b_file_edit_pop_functions.py +++ b/tests/test_phase1b_file_edit_pop_functions.py @@ -22,6 +22,7 @@ from eegprep.functions.popfunc.pop_mergeset import pop_mergeset from eegprep.functions.popfunc.pop_rmdat import pop_rmdat from eegprep.functions.popfunc.pop_selectevent import pop_selectevent +from tests.eeglab_tests import eeglab_test from tests.fixtures import SAMPLE_DATASET_PATH, matlab_engine_available EEGLAB_REFERENCE_ROOT = Path(__file__).resolve().parents[1] / "src" / "eegprep" / "eeglab" @@ -240,6 +241,149 @@ def test_pop_selectevent_keeps_numeric_boundary_when_deleting_continuous_events( assert [event["type"] for event in out["event"]] == ["stim", -99] +def _selection_regression_eeg() -> dict: + eeg = _eeg("selection regression") + eeg.update( + { + "data": np.arange(1, 41, dtype=np.float32).reshape((1, 10, 4), order="F"), + "nbchan": 1, + "pnts": 10, + "trials": 4, + "srate": 1000.0, + "xmin": 0.0, + "xmax": 0.009, + "times": np.arange(10, dtype=float), + "chanlocs": [eeg["chanlocs"][0]], + "event": [], + "urevent": [], + "epoch": [], + } + ) + for trial in range(1, 5): + event_type = "target" if trial % 2 else "other" + eeg["event"].extend( + [ + {"type": event_type, "latency": (trial - 1) * 10 + 3, "epoch": trial}, + {"type": "distractor", "latency": (trial - 1) * 10 + 7, "epoch": trial}, + ] + ) + return eeg + + +@eeglab_test("regression_tests/t_pop_selectevent.m", "testRetainsMatchingEpochs") +def test_pop_selectevent_retains_matching_epochs(): + eeg = _selection_regression_eeg() + + selected, _ = pop_selectevent(eeg, "type", "target", "deleteepochs", "on") + + assert selected["trials"] == 2 + np.testing.assert_array_equal(selected["data"], eeg["data"][:, :, [0, 2]]) + assert len(selected["event"]) == 4 + assert [event["epoch"] for event in selected["event"]] == [1, 1, 2, 2] + + +@eeglab_test("regression_tests/t_pop_selectevent.m", "testEmptySelectionErrorsByDefault") +def test_pop_selectevent_empty_selection_errors_by_default(): + with pytest.raises(ValueError, match="empty|Empty"): + pop_selectevent(_selection_regression_eeg(), "type", "absent", "deleteepochs", "on") + + +@eeglab_test("regression_tests/t_pop_selectevent.m", "testEmptySelectionAllowed") +def test_pop_selectevent_empty_selection_can_be_allowed(): + selected, _ = pop_selectevent( + _selection_regression_eeg(), + "type", + "absent", + "deleteepochs", + "on", + "erroronempty", + "off", + ) + + assert selected["data"].size == 0 + assert len(selected["event"]) == 0 + + +@eeglab_test("regression_tests/t_pop_selectevent.m", "testInverseEpochSelection") +def test_pop_selectevent_can_invert_epoch_selection(): + eeg = _selection_regression_eeg() + + selected, _ = pop_selectevent( + eeg, + "type", + "target", + "deleteepochs", + "on", + "invertepochs", + "on", + ) + + np.testing.assert_array_equal(selected["data"], eeg["data"][:, :, [1, 3]]) + assert selected["trials"] == 2 + + +@eeglab_test("regression_tests/t_pop_selectevent.m", "testDeleteUnselectedEvents") +def test_pop_selectevent_deletes_unselected_events_when_requested(): + selected, _ = pop_selectevent( + _selection_regression_eeg(), + "type", + "target", + "deleteepochs", + "on", + "deleteevents", + "on", + ) + + assert [event["type"] for event in selected["event"]] == ["target", "target"] + assert [event["epoch"] for event in selected["event"]] == [1, 2] + + +@eeglab_test("regression_tests/t_pop_selectevent.m", "testKeepEpochsWhenDeletingOnlyEvents") +def test_pop_selectevent_keeps_epochs_when_deleting_only_events(): + eeg = _selection_regression_eeg() + + selected, _ = pop_selectevent( + eeg, + "type", + "target", + "deleteepochs", + "off", + "deleteevents", + "on", + ) + + np.testing.assert_array_equal(selected["data"], eeg["data"]) + assert len(selected["event"]) == 2 + + +@eeglab_test("regression_tests/t_pop_selectevent.m", "testExplicitErrorOptionWithNonemptySelection") +def test_pop_selectevent_explicit_error_option_allows_nonempty_selection(): + eeg = _selection_regression_eeg() + + selected, _ = pop_selectevent( + eeg, + "type", + "target", + "deleteepochs", + "on", + "erroronempty", + "on", + ) + + np.testing.assert_array_equal(selected["data"], eeg["data"][:, :, [0, 2]]) + + +@eeglab_test("regression_tests/t_pop_selectevent.m", "testDatasetArray") +def test_pop_selectevent_applies_selection_to_dataset_lists(): + eeg = _selection_regression_eeg() + + selected, _ = pop_selectevent([eeg, deepcopy(eeg)], "type", "target", "deleteepochs", "on") + + assert [dataset["trials"] for dataset in selected] == [2, 2] + np.testing.assert_array_equal(selected[0]["data"], eeg["data"][:, :, [0, 2]]) + np.testing.assert_array_equal(selected[1]["data"], eeg["data"][:, :, [0, 2]]) + + def test_pop_rmdat_removes_or_keeps_continuous_windows_around_events(): eeg = _eeg() @@ -434,6 +578,31 @@ def test_pop_copyset_uses_one_based_indices_and_preserves_source_order(): _assert_python_echo_is_parseable(command) +@eeglab_test("unittesting_popfunc/pop_copyset/popfunc_pop_copyset_wrapperTest.m", "test_pass_set_out") +def test_pop_copyset_current_suite_overwrites_requested_output_slot(): + first = _eeg("first") + second = _eeg("second") + + alleeg, eeg, current_set = pop_copyset([first, second], 2, 1) + + assert current_set == 1 + assert eeg["setname"] == "second" + assert alleeg[0]["setname"] == "second" + assert alleeg[1]["setname"] == "second" + + +@eeglab_test("unittesting_popfunc/pop_copyset/popfunc_pop_copyset_wrapperTest.m", "test_test_pop_copyset") +def test_pop_copyset_current_suite_supports_copy_and_same_slot_copy(): + eeg = _eeg("source") + + alleeg, copied, current_set = pop_copyset([eeg, deepcopy(eeg)], 1, 2) + alleeg, copied, current_set = pop_copyset(alleeg, 1, 1) + + assert current_set == 1 + assert copied["setname"] == "source" + assert [dataset["setname"] for dataset in alleeg] == ["source", "source"] + + def test_pop_mergeset_continuous_offsets_events_and_inserts_boundary(): first = _eeg("first") second = _eeg("second") diff --git a/tests/test_phase2_file_channel_location.py b/tests/test_phase2_file_channel_location.py index 20795220..e2dba20f 100644 --- a/tests/test_phase2_file_channel_location.py +++ b/tests/test_phase2_file_channel_location.py @@ -25,6 +25,7 @@ snapread, writelocs, ) +from tests.eeglab_tests import eeglab_test def _eeg() -> dict: @@ -182,6 +183,71 @@ def test_pop_chancenter_uses_one_based_omit_indices_and_console_return_shape() - _assert_parseable(command) +def _chancenter_suite_locations(last_x=0.0) -> list[dict]: + return [ + {"labels": "", "X": 0.0, "Y": 0.0, "Z": 0.0, "theta": 0.0, "radius": 0.0}, + {"labels": "", "X": 1.0, "Y": 0.0, "Z": 0.0, "theta": 0.0, "radius": 0.0}, + {"labels": "", "X": last_x, "Y": 1.0, "Z": 1.0, "theta": 0.0, "radius": 0.0}, + ] + + +@eeglab_test("unittesting_popfunc/pop_chancenter/popfunc_pop_chancenter_wrapperTest.m", "test_pass_empty_center") +def test_pop_chancenter_current_suite_empty_center(): + locations = _chancenter_suite_locations() + locations[0].update({"Y": 1.95}) + locations[1].update({"X": 2.0}) + locations[2].update({"Y": 0.0, "Z": 2.0}) + + centered = pop_chancenter(locations, []) + + np.testing.assert_allclose( + [[location[axis] for axis in ("X", "Y", "Z")] for location in centered], + [[0.0, 1.95, 0.0], [2.0, 0.0, 0.0], [0.0, 0.0, 2.0]], + atol=0.11, + ) + + +@eeglab_test("unittesting_popfunc/pop_chancenter/popfunc_pop_chancenter_wrapperTest.m", "test_pass_no_omitchans") +def test_pop_chancenter_current_suite_known_center(): + centered = pop_chancenter(_chancenter_suite_locations(), [1, -1, 0]) + + np.testing.assert_allclose( + [[location[axis] for axis in ("X", "Y", "Z")] for location in centered], + [[-1, 1, 0], [0, 1, 0], [-1, 2, 1]], + ) + + +@eeglab_test("unittesting_popfunc/pop_chancenter/popfunc_pop_chancenter_wrapperTest.m", "test_pass_with_omitchans") +def test_pop_chancenter_current_suite_omits_one_based_channels(): + centered = pop_chancenter(_chancenter_suite_locations(last_x=1.0), [1, -1, 0], [1]) + + np.testing.assert_allclose( + [[location[axis] for axis in ("X", "Y", "Z")] for location in centered], + [[0, 0, 0], [0, 1, 0], [0, 2, 1]], + ) + + +@eeglab_test("unittesting_popfunc/pop_chancenter/popfunc_pop_chancenter_wrapperTest.m", "test_test_pop_chancenter") +def test_pop_chancenter_current_suite_center_and_omit_smoke_cases(): + locations = _eeg()["chanlocs"] + cases = [ + ([], None), + ([0, 0, 0], None), + ([1, 1, 1], None), + ([-1, 0, 1], None), + ([100000, -1000000, 100], None), + ([1, 1, 1], [1]), + ([1, 1, 1], [1, 2, 3, 4]), + ([1, 1, 1], [0]), + ([1, 1, 1], [1, 2, 3, 4, 5]), + ] + + for center, omitted in cases: + output = pop_chancenter(locations, center, omitted) + assert len(output) == len(locations) + assert all(np.isfinite(location[axis]) for location in output for axis in ("X", "Y", "Z")) + + @pytest.mark.gui def test_pop_chancenter_gui_cancel_path_returns_original_without_history() -> None: eeg = _eeg() diff --git a/tests/test_phase7_long_tail_helpers.py b/tests/test_phase7_long_tail_helpers.py index d931c265..9d92cb16 100644 --- a/tests/test_phase7_long_tail_helpers.py +++ b/tests/test_phase7_long_tail_helpers.py @@ -16,6 +16,7 @@ from eegprep.functions.sigprocfunc.kurt import kurt from eegprep.functions.sigprocfunc.realproba import realproba from eegprep.functions.sigprocfunc.rejtrend import rejtrend +from tests.eeglab_tests import eeglab_test def _eeg(data: np.ndarray) -> dict: @@ -187,6 +188,20 @@ def test_ica_helpers_match_simple_projection_identities(): assert variances.shape == (2,) +@eeglab_test("unittesting_popfunc/eeg_getica/popfunc_eeg_getica_wrapperTest.m", "test_test_eeg_getica") +def test_eeg_getica_current_suite_all_single_and_multiple_components(): + data = np.arange(48, dtype=float).reshape(6, 4, 2) + eeg = _eeg(data) + + all_components = eeg_getica(eeg) + first_component = eeg_getica(eeg, 1) + selected_components = eeg_getica(eeg, [5, 6]) + + np.testing.assert_array_equal(all_components, data) + np.testing.assert_array_equal(first_component, data[[0]]) + np.testing.assert_array_equal(selected_components, data[[4, 5]]) + + def test_eeg_pvaf_maps_full_channel_selection_to_icachansind_subset(): data = np.array( [ diff --git a/tests/test_pop_adjustevents.py b/tests/test_pop_adjustevents.py index 42744dee..443f489a 100644 --- a/tests/test_pop_adjustevents.py +++ b/tests/test_pop_adjustevents.py @@ -4,6 +4,7 @@ from eegprep import pop_adjustevents from eegprep.functions.adminfunc.eeg_options import EEG_OPTIONS +from tests.eeglab_tests import eeglab_test def demo_eeg(): @@ -161,5 +162,48 @@ def run(self, spec, initial_values=None): self.assertIs(out, eeg) +def _assert_current_adjustevents_workflow(eeg): + original = np.asarray([event["latency"] for event in eeg["event"]]) + + shifted_ms = pop_adjustevents(eeg, "addms", 20) + selected_ms = pop_adjustevents(eeg, "addms", 20, "eventtypes", ["resp"]) + shifted_samples = pop_adjustevents(eeg, "addsamples", 30) + selected_samples = pop_adjustevents(eeg, "addsamples", 30, "eventtypes", ["resp"]) + + np.testing.assert_array_equal( + [event["latency"] for event in shifted_ms["event"]], + original + 20 / 1000 * eeg["srate"], + ) + np.testing.assert_array_equal( + [event["latency"] for event in shifted_samples["event"]], + original + 30, + ) + assert selected_ms["event"][1]["latency"] == original[1] + 20 / 1000 * eeg["srate"] + assert selected_samples["event"][1]["latency"] == original[1] + 30 + assert selected_ms["event"][0]["latency"] == original[0] + assert selected_samples["event"][0]["latency"] == original[0] + + +@eeglab_test( + "unittesting_popfunc/pop_adjustevents/popfunc_pop_adjustevents_wrapperTest.m", + "test_test_pop_adjustevents1", +) +def test_pop_adjustevents_current_suite_epoched_workflow(): + eeg = demo_eeg() + eeg["data"] = eeg["data"].reshape(1, 500, 2) + eeg["pnts"] = 500 + eeg["trials"] = 2 + + _assert_current_adjustevents_workflow(eeg) + + +@eeglab_test( + "unittesting_popfunc/pop_adjustevents/popfunc_pop_adjustevents_wrapperTest.m", + "test_test_pop_adjustevents2", +) +def test_pop_adjustevents_current_suite_continuous_workflow(): + _assert_current_adjustevents_workflow(demo_eeg()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_pop_comments.py b/tests/test_pop_comments.py index d36f6ffc..ade9581f 100644 --- a/tests/test_pop_comments.py +++ b/tests/test_pop_comments.py @@ -11,6 +11,7 @@ from eegprep.functions.adminfunc.eeglabcompat import get_eeglab from eegprep.functions.popfunc.pop_comments import pop_comments from eegprep.functions.popfunc.pop_loadset import pop_loadset +from tests.eeglab_tests import eeglab_test from tests.fixtures import SAMPLE_DATASET_PATH @@ -90,6 +91,44 @@ def test_pop_comments_accepts_sample_data_comments(): assert str(eeg.get("comments", "")) != "sample-data note" +@eeglab_test("unittesting_popfunc/pop_comments/popfunc_pop_comments_wrapperTest.m", "test_pass_newcomments") +def test_pop_comments_current_suite_replaces_cell_comments(): + old_comments = ["Line nr. 1", "This is line nr.2", " ", "And this is the final line"] + + result = pop_comments(old_comments, "", ["New line 1", "And this one is also new"]) + + assert result == "New line 1\nAnd this one is also new" + + +@eeglab_test("unittesting_popfunc/pop_comments/popfunc_pop_comments_wrapperTest.m", "test_pass_newcomments_concat") +def test_pop_comments_current_suite_concatenates_cell_comments(): + old_comments = ["Line nr. 1", "This is line nr.2", " ", "And this is the final line"] + + result = pop_comments(old_comments, "", ["New line 1", "And this one is also new"], 1) + + assert result == ( + "Line nr. 1\nThis is line nr.2\n\nAnd this is the final line\nNew line 1\nAnd this one is also new" + ) + + +@eeglab_test("unittesting_popfunc/pop_comments/popfunc_pop_comments_wrapperTest.m", "test_test_pop_comments") +def test_pop_comments_current_suite_string_cell_and_dataset_workflow(): + assert pop_comments("", "Testing!", "test pass!", 0) == "test pass!" + assert pop_comments("", "Testing!", "test pass!", 1) == "test pass!" + assert pop_comments("another ", "Testing!", "test pass!", 0) == "test pass!" + assert pop_comments("another ", "Testing!", "test pass!", 1) == "another\ntest pass!" + assert pop_comments("another ", "Testing!", ["test pass!", "pass again!"], 0) == "test pass!\npass again!" + assert pop_comments("another ", "Testing!", ["test pass!", "pass again!"], 1) == ( + "another\ntest pass!\npass again!" + ) + + eeg = _eeg() + eeg["comments"] = pop_comments(eeg["comments"], "", ["un exemple", " ", "de nouveau dataset"], 1) + assert eeg["comments"].endswith("un exemple\n\nde nouveau dataset") + eeg["comments"] = pop_comments(eeg["comments"], "", ["un exemple", " ", "de nouveau dataset"], 0) + assert eeg["comments"] == "un exemple\n\nde nouveau dataset" + + @unittest.skipIf(os.getenv("EEGPREP_SKIP_MATLAB") == "1", "MATLAB not available") class TestPopCommentsMatlabParity(unittest.TestCase): def setUp(self): diff --git a/tests/test_pop_topoplot.py b/tests/test_pop_topoplot.py index 1e0e4be7..3d3bca40 100644 --- a/tests/test_pop_topoplot.py +++ b/tests/test_pop_topoplot.py @@ -14,6 +14,7 @@ pop_topoplot_dialog_spec, ) from eegprep.functions.sigprocfunc.topoplot import topoplot +from tests.eeglab_tests import eeglab_test from tests.fixtures import SAMPLE_DATASET_PATH, create_test_eeg_with_ica @@ -145,6 +146,111 @@ def test_pop_topoplot_component_colorbar_uses_polarity_labels(): plt.close(erp_figs[0]) +def _component_colorbar(limits, components=2, *, eeg=None): + eeg = eeg or create_test_eeg_with_ica(n_channels=6, n_samples=30, n_components=3) + options = {"maplimits": limits} if limits is not None else {} + figures = pop_topoplot( + eeg, + typeplot=0, + items=components, + topotitle="Component", + rowcols=[], + electrodes="off", + **options, + ) + figure = figures[0] + figure.canvas.draw() + return figure, figure.axes[-1] + + +def _assert_component_colorbar_scale(limits, components, *, signed): + figure, colorbar = _component_colorbar(limits, components) + ticks = np.asarray(colorbar.get_yticks(), dtype=float) + assert np.all(np.isfinite(ticks)) + assert np.all(np.diff(ticks) > 0) + labels = [label.get_text() for label in colorbar.get_yticklabels()] + if signed: + assert labels == ["-", "0", "+"] + np.testing.assert_allclose(ticks[1], 0.0, atol=1e-12) + else: + values = np.asarray([float(label.replace("−", "-")) for label in labels]) + np.testing.assert_allclose(values[[0, -1]], limits, atol=1e-12) + plt.close(figure) + + +@eeglab_test("regression_tests/t_pop_topoplot_colorbar.m", "testPositiveLimits") +def test_pop_topoplot_component_colorbar_with_positive_limits_is_numeric(): + _assert_component_colorbar_scale([1, 2], 2, signed=False) + + +@eeglab_test("regression_tests/t_pop_topoplot_colorbar.m", "testNegativeLimits") +def test_pop_topoplot_component_colorbar_with_negative_limits_is_numeric(): + _assert_component_colorbar_scale([-2, -1], 2, signed=False) + + +@eeglab_test("regression_tests/t_pop_topoplot_colorbar.m", "testSymmetricLimits") +def test_pop_topoplot_component_colorbar_with_symmetric_limits_is_signed(): + _assert_component_colorbar_scale([-2, 2], 2, signed=True) + + +@eeglab_test("regression_tests/t_pop_topoplot_colorbar.m", "testAsymmetricLimits") +def test_pop_topoplot_component_colorbar_with_asymmetric_limits_maps_zero(): + _assert_component_colorbar_scale([-1, 3], 2, signed=True) + + +@eeglab_test("regression_tests/t_pop_topoplot_colorbar.m", "testZeroLowerEndpoint") +def test_pop_topoplot_component_colorbar_with_zero_lower_endpoint_is_numeric(): + _assert_component_colorbar_scale([0, 2], 2, signed=False) + + +@eeglab_test("regression_tests/t_pop_topoplot_colorbar.m", "testZeroUpperEndpoint") +def test_pop_topoplot_component_colorbar_with_zero_upper_endpoint_is_numeric(): + _assert_component_colorbar_scale([-2, 0], 2, signed=False) + + +@eeglab_test("regression_tests/t_pop_topoplot_colorbar.m", "testMultiplePositiveMaps") +def test_pop_topoplot_multiple_component_maps_share_positive_numeric_colorbar(): + _assert_component_colorbar_scale([1, 2], [1, 2], signed=False) + + +@eeglab_test("regression_tests/t_pop_topoplot_colorbar.m", "testMultipleSymmetricMaps") +def test_pop_topoplot_multiple_component_maps_share_signed_colorbar(): + _assert_component_colorbar_scale([-2, 2], [1, 2], signed=True) + + +@eeglab_test("regression_tests/t_pop_topoplot_colorbar.m", "testDefaultLimits") +def test_pop_topoplot_default_component_colorbar_has_signed_labels(): + figure, colorbar = _component_colorbar(None) + + ticks = np.asarray(colorbar.get_yticks(), dtype=float) + assert np.all(np.diff(ticks) > 0) + assert [label.get_text() for label in colorbar.get_yticklabels()] == ["-", "0", "+"] + plt.close(figure) + + +@eeglab_test("regression_tests/t_pop_topoplot_colorbar.m", "testZeroComponent") +def test_pop_topoplot_zero_component_colorbar_has_increasing_ticks(): + eeg = create_test_eeg_with_ica(n_channels=6, n_samples=30, n_components=3) + eeg["icawinv"][:, 1] = 0 + + figure, colorbar = _component_colorbar(None, eeg=eeg) + + assert np.all(np.diff(np.asarray(colorbar.get_yticks(), dtype=float)) > 0) + plt.close(figure) + + +@eeglab_test("regression_tests/t_pop_topoplot_colorbar.m", "testUnrelatedAxesUnchanged") +def test_pop_topoplot_component_colorbar_does_not_change_unrelated_axes(): + unrelated_figure, unrelated_axes = plt.subplots() + unrelated_axes.set_yticks([10, 20, 30]) + + figure, _ = _component_colorbar([-2, 2]) + + np.testing.assert_array_equal(unrelated_axes.get_yticks(), [10, 20, 30]) + plt.close(figure) + plt.close(unrelated_figure) + + def test_pop_topoplot_plots_component_maps_with_inverted_and_blank_items(): eeg = create_test_eeg_with_ica(n_channels=6, n_samples=50, n_components=3) diff --git a/tests/test_rejection_workflows.py b/tests/test_rejection_workflows.py index 09c470f6..80d4d0b7 100644 --- a/tests/test_rejection_workflows.py +++ b/tests/test_rejection_workflows.py @@ -34,6 +34,7 @@ from eegprep.functions.popfunc.pop_rejtrend import pop_rejtrend from eegprep.functions.popfunc.pop_selectcomps import pop_selectcomps from eegprep.plugins.ICLabel.pop_viewprops import pop_viewprops +from tests.eeglab_tests import eeglab_test from tests.fixtures import SAMPLE_DATASET_PATH, create_test_eeg @@ -556,6 +557,95 @@ def test_eeg_rejsuperpose_only_crosses_trial_marks_between_data_and_ica_families assert not marked["reject"]["rejglobalE"].any() +def _suite_rejection_eeg(*, trials=5, components=False): + eeg = create_test_eeg(n_channels=2, n_samples=100, n_trials=trials) + eeg["reject"] = {} + if components: + eeg["icachansind"] = np.array([0, 1]) + return eeg + + +@eeglab_test("unittesting_popfunc/eeg_rejsuperpose/popfunc_eeg_rejsuperpose_wrapperTest.m", "test_pass_empty") +def test_eeg_rejsuperpose_current_suite_empty_marks(): + eeg = _suite_rejection_eeg() + + output = eeg_rejsuperpose(eeg, 1, 0, 0, 0, 0, 0, 0, 0) + + np.testing.assert_array_equal(output["reject"]["rejglobal"], np.zeros(5, dtype=bool)) + np.testing.assert_array_equal(output["reject"]["rejglobalE"], np.zeros((2, 5), dtype=bool)) + + +@eeglab_test("unittesting_popfunc/eeg_rejsuperpose/popfunc_eeg_rejsuperpose_wrapperTest.m", "test_pass_zero") +def test_eeg_rejsuperpose_current_suite_zero_marks(): + eeg = _suite_rejection_eeg() + eeg["reject"].update( + { + "rejmanual": np.zeros(5, dtype=bool), + "rejfreq": np.zeros(5, dtype=bool), + "rejmanualE": np.zeros((2, 5), dtype=bool), + "rejfreqE": np.zeros((2, 5), dtype=bool), + } + ) + + output = eeg_rejsuperpose(eeg, 1, 1, 0, 0, 0, 0, 1, 0) + + np.testing.assert_array_equal(output["reject"]["rejglobal"], np.zeros(5, dtype=bool)) + np.testing.assert_array_equal(output["reject"]["rejglobalE"], np.zeros((2, 5), dtype=bool)) + + +@eeglab_test("unittesting_popfunc/eeg_rejsuperpose/popfunc_eeg_rejsuperpose_wrapperTest.m", "test_pass_general") +def test_eeg_rejsuperpose_current_suite_selected_mark_families(): + eeg = _suite_rejection_eeg() + eeg["reject"].update( + { + "rejmanual": np.array([0, 0, 0, 1, 0], dtype=bool), + "rejfreq": np.array([1, 0, 0, 1, 0], dtype=bool), + "rejmanualE": np.array([[0, 0, 0, 1, 0], [0, 1, 0, 0, 0]], dtype=bool), + "rejfreqE": np.array([[0, 0, 0, 0, 1], [0, 0, 0, 0, 1]], dtype=bool), + } + ) + + output = eeg_rejsuperpose(eeg, 1, 1, 0, 0, 0, 0, 1, 0) + + np.testing.assert_array_equal(output["reject"]["rejglobal"], [1, 0, 0, 1, 0]) + np.testing.assert_array_equal(output["reject"]["rejglobalE"], [[0, 0, 0, 1, 1], [0, 1, 0, 0, 1]]) + + +def _all_rejection_marks(prefix=""): + marks = {} + for index, name in enumerate(("rejmanual", "rejthresh", "rejconst", "rejjp", "rejkurt", "rejfreq")): + trial_marks = np.zeros(6, dtype=bool) + trial_marks[index] = True + row_marks = np.zeros((2, 6), dtype=bool) + row_marks[0, index] = True + row_marks[1, 5 - index] = True + marks[f"{prefix}{name}"] = trial_marks + marks[f"{prefix}{name}E"] = row_marks + return marks + + +@eeglab_test("unittesting_popfunc/eeg_rejsuperpose/popfunc_eeg_rejsuperpose_wrapperTest.m", "test_pass_all") +def test_eeg_rejsuperpose_current_suite_all_data_mark_families(): + eeg = _suite_rejection_eeg(trials=6) + eeg["reject"] = _all_rejection_marks() + + output = eeg_rejsuperpose(eeg, 1, 1, 1, 1, 1, 1, 1, 0) + + np.testing.assert_array_equal(output["reject"]["rejglobal"], np.ones(6, dtype=bool)) + np.testing.assert_array_equal(output["reject"]["rejglobalE"], np.ones((2, 6), dtype=bool)) + + +@eeglab_test("unittesting_popfunc/eeg_rejsuperpose/popfunc_eeg_rejsuperpose_wrapperTest.m", "test_pass_all_ica") +def test_eeg_rejsuperpose_current_suite_all_component_mark_families(): + eeg = _suite_rejection_eeg(trials=6, components=True) + eeg["reject"] = _all_rejection_marks("ica") + + output = eeg_rejsuperpose(eeg, 0, 1, 1, 1, 1, 1, 1, 0) + + np.testing.assert_array_equal(output["reject"]["rejglobal"], np.ones(6, dtype=bool)) + np.testing.assert_array_equal(output["reject"]["rejglobalE"], np.ones((2, 6), dtype=bool)) + + @pytest.mark.matlab def test_eeg_rejsuperpose_matches_eeglab_for_deterministic_marks(tmp_path): if os.environ.get("EEGPREP_SKIP_MATLAB") == "1": From 8e7d4e76d94953c260dab44f2adbf0d624a42d80 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:44 -0700 Subject: [PATCH 03/45] Port EEGLAB misc and statcond tests (#331) --- docs/source/api/statistics.rst | 11 + src/eegprep/functions/miscfunc/numdim.py | 6 +- tests/test_eeg_ms2f.py | 37 ++ tests/test_numdim.py | 44 ++ tests/test_statcond_eeglab_tests.py | 521 +++++++++++++++++++++++ 5 files changed, 617 insertions(+), 2 deletions(-) create mode 100644 tests/test_statcond_eeglab_tests.py diff --git a/docs/source/api/statistics.rst b/docs/source/api/statistics.rst index ef87c154..ca92fd43 100644 --- a/docs/source/api/statistics.rst +++ b/docs/source/api/statistics.rst @@ -43,3 +43,14 @@ Data Helpers eegprep.functions.statistics.concatdata eegprep.functions.statistics.corrcoef_cell eegprep.functions.statistics.teststat + +Effective Dimensionality +======================== + +``numdim`` evaluates zero eigenvalue entropy terms by their analytic limit, +so exactly rank-deficient inputs return a finite effective dimension. + +.. autosummary:: + :toctree: generated/ + + eegprep.functions.miscfunc.numdim.numdim diff --git a/src/eegprep/functions/miscfunc/numdim.py b/src/eegprep/functions/miscfunc/numdim.py index 029b167b..0af40b78 100644 --- a/src/eegprep/functions/miscfunc/numdim.py +++ b/src/eegprep/functions/miscfunc/numdim.py @@ -26,8 +26,10 @@ def numdim(data: Any) -> float: eigenvalues = np.linalg.eigvals(b) # MATLAB: [v d] = eig(b); weights = (eigenvalues / np.sum(eigenvalues)).astype(complex) # Complex log mirrors MATLAB's real(exp(...)): tiny/negative eigenvalues - # from finite precision contribute ~0 instead of producing NaN. - lambda_ = np.exp(-np.sum(weights * np.log(weights))) + # from finite precision contribute ~0 instead of producing NaN. Exact + # zero weights have the entropy limit 0*log(0) == 0. + nonzero_weights = weights != 0 + lambda_ = np.exp(-np.sum(weights[nonzero_weights] * np.log(weights[nonzero_weights]))) return float(np.real(lambda_)) diff --git a/tests/test_eeg_ms2f.py b/tests/test_eeg_ms2f.py index ce7eaa12..466381e8 100644 --- a/tests/test_eeg_ms2f.py +++ b/tests/test_eeg_ms2f.py @@ -10,6 +10,7 @@ import pytest from eegprep.functions.miscfunc.eeg_ms2f import eeg_ms2f +from tests.eeglab_tests import eeglab_test pytestmark = pytest.mark.parity @@ -51,3 +52,39 @@ def test_below_range_raises(): def test_above_range_raises(): with pytest.raises(ValueError, match="out of range"): eeg_ms2f(_eeg(0, 1, 1001), 2000) + + +@eeglab_test( + "unittesting_miscfunc/eeg_ms2f/miscfunc_eeg_ms2f_wrapperTest.m", + "test_pass_center", +) +def test_current_eeglab_suite_rounds_a_half_frame_up(): + assert eeg_ms2f(_eeg(0, 2, 3), 500) == 2 + + +@eeglab_test( + "unittesting_miscfunc/eeg_ms2f/miscfunc_eeg_ms2f_wrapperTest.m", + "test_pass_exact", +) +def test_current_eeglab_suite_maps_an_exact_latency(): + assert eeg_ms2f(_eeg(0, 2, 3), 1000) == 2 + + +@eeglab_test( + "unittesting_miscfunc/eeg_ms2f/miscfunc_eeg_ms2f_wrapperTest.m", + "test_pass_rounded", +) +def test_current_eeglab_suite_rounds_to_the_nearest_frame(): + assert eeg_ms2f(_eeg(0, 2, 3), 1653) == 3 + + +@eeglab_test( + "unittesting_miscfunc/eeg_ms2f/miscfunc_eeg_ms2f_wrapperTest.m", + "test_fail_outside", +) +def test_current_eeglab_suite_rejects_latency_after_epoch(): + # The current MATLAB scenario is commented out, but its wrapper still + # discovers the test. Preserve the intended boundary check rather than + # translating that accidental no-op. + with pytest.raises(ValueError, match="out of range"): + eeg_ms2f(_eeg(0, 2, 3), 3000) diff --git a/tests/test_numdim.py b/tests/test_numdim.py index 427f75b3..dc18da7a 100644 --- a/tests/test_numdim.py +++ b/tests/test_numdim.py @@ -16,6 +16,7 @@ import pytest from eegprep.functions.miscfunc.numdim import numdim +from tests.eeglab_tests import eeglab_test pytestmark = pytest.mark.parity @@ -67,3 +68,46 @@ def test_full_rank_matches_matlab(): v = numdim(a) assert np.isreal(v) np.testing.assert_allclose(v, 2.147746217856, rtol=1e-6) + + +@eeglab_test( + "unittesting_miscfunc/numdim/miscfunc_numdim_wrapperTest.m", + "test_pass_general", +) +def test_current_eeglab_suite_matches_closed_form_entropy(): + data = np.array([[2.0, 1.0], [-1.0, -2.0]]) + expected = 1 / (0.1**0.1) / (0.9**0.9) + + np.testing.assert_allclose(numdim(data), expected, rtol=1e-12) + + +@eeglab_test( + "unittesting_miscfunc/numdim/miscfunc_numdim_wrapperTest.m", + "test_pass_row_vector", +) +def test_current_eeglab_suite_row_vector_is_one_dimensional(): + assert numdim(np.array([[1.0, 2.0, 0.0, -6.0]])) == pytest.approx(1.0) + + +@eeglab_test( + "unittesting_miscfunc/numdim/miscfunc_numdim_wrapperTest.m", + "test_pass_column_vector", +) +def test_current_eeglab_suite_preserves_column_vector_degeneracy(): + # The upstream test allows NaN or 1 because numdim.m evaluates 0*log(0) + # literally. The analytic entropy limit is one effective source. + result = numdim(np.array([[1.0], [2.0], [0.0], [-6.0]])) + + assert result == pytest.approx(1.0) + + +@eeglab_test( + "unittesting_miscfunc/numdim/miscfunc_numdim_wrapperTest.m", + "test_pass_singular_matrix", +) +def test_current_eeglab_suite_singular_matrix_scenario_is_executable(): + # The upstream assertion is commented out, but its intended result and + # the analytic entropy of a rank-one matrix are both one. + result = numdim(np.array([[2.0, 2.0], [-1.0, -1.0]])) + + assert result == pytest.approx(1.0) diff --git a/tests/test_statcond_eeglab_tests.py b/tests/test_statcond_eeglab_tests.py new file mode 100644 index 00000000..53ab8164 --- /dev/null +++ b/tests/test_statcond_eeglab_tests.py @@ -0,0 +1,521 @@ +"""Ports of the current EEGLAB ``statcond`` MATLAB tests.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np +from scipy import stats as scipy_stats + +from eegprep.functions.statistics import StatcondResult, SurrogateDistribution, TwoWayEffects, statcond +from tests.eeglab_tests import eeglab_test + + +STATCOND_CLASS = "unittesting_statistics/statcond/statcondTest.m" +STATCOND_WRAPPER = "unittesting_statistics/statcond/statistics_statcond_wrapperTest.m" +STATCOND_REGRESSION = "regression_tests/t_statcond.m" + + +def _reference_conditions() -> tuple[np.ndarray, np.ndarray, np.ndarray]: + rng = np.random.default_rng(114) + first = rng.random((1, 10)) + second = rng.random((1, 10)) + 0.5 + third = rng.random((1, 10)) + 0.2 + return first, second, third + + +def _assert_paired_t_reference() -> None: + first, second, _third = _reference_conditions() + result = statcond([first, second], method="param", paired="on") + expected = scipy_stats.ttest_rel(first, second, axis=-1) + + np.testing.assert_allclose(result.stat, expected.statistic, rtol=1e-13, atol=1e-13) + assert result.df == 9 + np.testing.assert_allclose(result.pvalue, expected.pvalue, rtol=1e-13, atol=1e-13) + assert result.surrogate is None + + +def _assert_unpaired_t_reference() -> None: + first, second, _third = _reference_conditions() + result = statcond([first, second], method="param", paired="off", variance="homogenous") + expected = scipy_stats.ttest_ind(first, second, axis=-1, equal_var=True) + + np.testing.assert_allclose(result.stat, expected.statistic, rtol=1e-13, atol=1e-13) + assert result.df == 18 + np.testing.assert_allclose(result.pvalue, expected.pvalue, rtol=1e-13, atol=1e-13) + assert result.surrogate is None + + +def _one_way_repeated_reference(groups: Sequence[np.ndarray]) -> tuple[np.ndarray, tuple[int, int], np.ndarray]: + values = np.stack(groups, axis=-2) + n_conditions, n_subjects = values.shape[-2:] + grand_mean = np.mean(values, axis=(-2, -1)) + condition_ss = n_subjects * np.sum((np.mean(values, axis=-1) - grand_mean[..., np.newaxis]) ** 2, axis=-1) + subject_ss = n_conditions * np.sum((np.mean(values, axis=-2) - grand_mean[..., np.newaxis]) ** 2, axis=-1) + total_ss = np.sum((values - grand_mean[..., np.newaxis, np.newaxis]) ** 2, axis=(-2, -1)) + error_ss = total_ss - condition_ss - subject_ss + df = (n_conditions - 1, (n_conditions - 1) * (n_subjects - 1)) + statistic = (condition_ss / df[0]) / (error_ss / df[1]) + return statistic, df, scipy_stats.f.sf(statistic, *df) + + +def _assert_paired_one_way_reference() -> None: + groups = _reference_conditions() + result = statcond(groups, method="param", paired="on") + expected_stat, expected_df, expected_pvalue = _one_way_repeated_reference(groups) + + np.testing.assert_allclose(result.stat, expected_stat, rtol=1e-13, atol=1e-13) + assert result.df == expected_df + np.testing.assert_allclose(result.pvalue, expected_pvalue, rtol=1e-13, atol=1e-13) + + +def _assert_unpaired_one_way_reference() -> None: + groups = _reference_conditions() + result = statcond(groups, method="param", paired="off") + expected = scipy_stats.f_oneway(*groups, axis=-1) + + np.testing.assert_allclose(result.stat, expected.statistic, rtol=1e-13, atol=1e-13) + assert result.df == (2, 27) + np.testing.assert_allclose(result.pvalue, expected.pvalue, rtol=1e-13, atol=1e-13) + + +def _two_way_unpaired_reference( + grid: Sequence[Sequence[np.ndarray]], +) -> tuple[TwoWayEffects, TwoWayEffects, TwoWayEffects]: + values = np.stack([np.stack(row, axis=-2) for row in grid], axis=-3) + n_rows, n_columns, n_cases = values.shape[-3:] + cell_means = np.mean(values, axis=-1) + grand_mean = np.mean(cell_means, axis=(-2, -1)) + row_means = np.mean(cell_means, axis=-1) + column_means = np.mean(cell_means, axis=-2) + error_ss = np.sum((values - cell_means[..., np.newaxis]) ** 2, axis=(-3, -2, -1)) + row_ss = n_columns * n_cases * np.sum((row_means - grand_mean[..., np.newaxis]) ** 2, axis=-1) + column_ss = n_rows * n_cases * np.sum((column_means - grand_mean[..., np.newaxis]) ** 2, axis=-1) + interaction_ss = n_cases * np.sum( + ( + cell_means + - row_means[..., :, np.newaxis] + - column_means[..., np.newaxis, :] + + grand_mean[..., np.newaxis, np.newaxis] + ) + ** 2, + axis=(-2, -1), + ) + error_df = n_rows * n_columns * (n_cases - 1) + dfs = TwoWayEffects( + (n_rows - 1, error_df), + (n_columns - 1, error_df), + ((n_rows - 1) * (n_columns - 1), error_df), + ) + statistics = TwoWayEffects( + (row_ss / dfs.rows[0]) / (error_ss / error_df), + (column_ss / dfs.columns[0]) / (error_ss / error_df), + (interaction_ss / dfs.interaction[0]) / (error_ss / error_df), + ) + pvalues = TwoWayEffects( + scipy_stats.f.sf(statistics.rows, *dfs.rows), + scipy_stats.f.sf(statistics.columns, *dfs.columns), + scipy_stats.f.sf(statistics.interaction, *dfs.interaction), + ) + return statistics, dfs, pvalues + + +def _two_way_repeated_reference( + grid: Sequence[Sequence[np.ndarray]], +) -> tuple[TwoWayEffects, TwoWayEffects, TwoWayEffects]: + values = np.stack([np.stack(row, axis=-2) for row in grid], axis=-3) + n_rows, n_columns, n_subjects = values.shape[-3:] + ab_sums = np.sum(values, axis=-1) + row_subject_sums = np.sum(values, axis=-2) + column_subject_sums = np.sum(values, axis=-3) + row_sums = np.sum(ab_sums, axis=-1) + column_sums = np.sum(ab_sums, axis=-2) + subject_sums = np.sum(row_subject_sums, axis=-2) + total = np.sum(values, axis=(-3, -2, -1)) + + expected_rows = np.sum(row_sums**2, axis=-1) / (n_columns * n_subjects) + expected_columns = np.sum(column_sums**2, axis=-1) / (n_rows * n_subjects) + expected_ab = np.sum(ab_sums**2, axis=(-2, -1)) / n_subjects + expected_subjects = np.sum(subject_sums**2, axis=-1) / (n_rows * n_columns) + expected_row_subject = np.sum(row_subject_sums**2, axis=(-2, -1)) / n_columns + expected_column_subject = np.sum(column_subject_sums**2, axis=(-2, -1)) / n_rows + expected_y = np.sum(values**2, axis=(-3, -2, -1)) + expected_total = total**2 / (n_rows * n_columns * n_subjects) + + row_ss = expected_rows - expected_total + column_ss = expected_columns - expected_total + interaction_ss = expected_ab - expected_rows - expected_columns + expected_total + row_subject_ss = expected_row_subject - expected_rows - expected_subjects + expected_total + column_subject_ss = expected_column_subject - expected_columns - expected_subjects + expected_total + interaction_subject_ss = ( + expected_y + - expected_ab + - expected_row_subject + - expected_column_subject + + expected_rows + + expected_columns + + expected_subjects + - expected_total + ) + dfs = TwoWayEffects( + (n_rows - 1, (n_rows - 1) * (n_subjects - 1)), + (n_columns - 1, (n_columns - 1) * (n_subjects - 1)), + ( + (n_rows - 1) * (n_columns - 1), + (n_rows - 1) * (n_columns - 1) * (n_subjects - 1), + ), + ) + statistics = TwoWayEffects( + (row_ss / dfs.rows[0]) / (row_subject_ss / dfs.rows[1]), + (column_ss / dfs.columns[0]) / (column_subject_ss / dfs.columns[1]), + (interaction_ss / dfs.interaction[0]) / (interaction_subject_ss / dfs.interaction[1]), + ) + pvalues = TwoWayEffects( + scipy_stats.f.sf(statistics.rows, *dfs.rows), + scipy_stats.f.sf(statistics.columns, *dfs.columns), + scipy_stats.f.sf(statistics.interaction, *dfs.interaction), + ) + return statistics, dfs, pvalues + + +def _reference_grid() -> tuple[tuple[np.ndarray, ...], tuple[np.ndarray, ...]]: + first, second, third = _reference_conditions() + return ( + (first / 2, second, third), + (first.copy(), second.copy(), third.copy()), + ) + + +def _assert_effects(result: StatcondResult, expected: tuple[TwoWayEffects, TwoWayEffects, TwoWayEffects]) -> None: + expected_statistics, expected_dfs, expected_pvalues = expected + assert isinstance(result.stat, TwoWayEffects) + assert isinstance(result.df, TwoWayEffects) + assert isinstance(result.pvalue, TwoWayEffects) + for actual, reference in zip(result.stat, expected_statistics, strict=True): + np.testing.assert_allclose(actual, reference, rtol=1e-12, atol=1e-12) + for actual, reference in zip(result.df, expected_dfs, strict=True): + assert actual == reference + for actual, reference in zip(result.pvalue, expected_pvalues, strict=True): + np.testing.assert_allclose(actual, reference, rtol=1e-12, atol=1e-12) + + +def _assert_paired_two_way_reference() -> None: + grid = _reference_grid() + _assert_effects(statcond(grid, method="param", paired="on"), _two_way_repeated_reference(grid)) + + +def _assert_unpaired_two_way_reference() -> None: + grid = _reference_grid() + _assert_effects(statcond(grid, method="param", paired="off"), _two_way_unpaired_reference(grid)) + + +@eeglab_test(STATCOND_REGRESSION, "test_1") +@eeglab_test(STATCOND_CLASS, "pairedTTest") +def test_statcond_paired_t_matches_independent_reference(): + _assert_paired_t_reference() + + +@eeglab_test(STATCOND_REGRESSION, "test_2") +@eeglab_test(STATCOND_CLASS, "unpairedTTest") +def test_statcond_unpaired_t_matches_independent_reference(): + _assert_unpaired_t_reference() + + +@eeglab_test(STATCOND_REGRESSION, "test_3") +@eeglab_test(STATCOND_CLASS, "paired1Anova") +def test_statcond_paired_one_way_anova_matches_independent_reference(): + _assert_paired_one_way_reference() + + +@eeglab_test(STATCOND_REGRESSION, "test_4") +@eeglab_test(STATCOND_CLASS, "paired2Anova") +def test_statcond_paired_two_way_anova_matches_independent_reference(): + _assert_paired_two_way_reference() + + +@eeglab_test(STATCOND_REGRESSION, "test_5") +@eeglab_test(STATCOND_CLASS, "unpaired1Anova") +def test_statcond_unpaired_one_way_anova_matches_independent_reference(): + _assert_unpaired_one_way_reference() + + +@eeglab_test(STATCOND_REGRESSION, "test_6") +@eeglab_test(STATCOND_CLASS, "unpaired2Anova") +def test_statcond_unpaired_two_way_anova_matches_independent_reference(): + # t_statcond.mat records the first two effects in column/row order here, + # contrary to statcond.m's documented row/column order and its paired + # branch. Check the mathematical factors rather than copying that defect. + _assert_unpaired_two_way_reference() + + +def _dimensional_conditions() -> tuple[tuple[tuple[np.ndarray, ...], tuple[int, ...]], ...]: + rng = np.random.default_rng(518) + base = tuple(rng.random((1, 10)) + offset for offset in (0.0, 0.5, 0.2)) + matrix = tuple(rng.random((10, 10)) + offset for offset in (0.0, 0.5, 0.2)) + cube = tuple(rng.random((5, 10, 10)) + offset for offset in (0.0, 0.5, 0.2)) + hypercube = tuple(rng.random((2, 5, 10, 10)) + offset for offset in (0.0, 0.5, 0.2)) + for index in range(3): + matrix[index][3, :] = base[index][0] + cube[index][1, 3, :] = base[index][0] + hypercube[index][0, 1, 3, :] = base[index][0] + return ( + (base, (0,)), + (matrix, (3,)), + (cube, (1, 3)), + (hypercube, (0, 1, 3)), + ) + + +def _value_at(value: np.ndarray | float, index: tuple[int, ...]) -> float: + array = np.asarray(value) + return float(array[index] if index else array) + + +def _assert_dimension_invariance(*, paired: str, design: str) -> None: + conditions_by_dimension = _dimensional_conditions() + results: list[tuple[StatcondResult, tuple[int, ...]]] = [] + for conditions, index in conditions_by_dimension: + data: Sequence[Sequence[np.ndarray]] | Sequence[np.ndarray] + if design == "t": + data = conditions[:2] + elif design == "one-way": + data = conditions + else: + data = ( + (conditions[0] / 2, conditions[1], conditions[2]), + tuple(condition.copy() for condition in conditions), + ) + result = statcond(data, method="param", paired=paired, variance="homogenous") + expected_shape = conditions[0].shape[:-1] + if design == "two-way": + assert all(np.asarray(effect).shape == expected_shape for effect in result.stat) + else: + assert np.asarray(result.stat).shape == expected_shape + results.append((result, index)) + + baseline, _baseline_index = results[0] + for result, index in results[1:]: + if design == "two-way": + for actual, expected in zip(result.stat, baseline.stat, strict=True): + np.testing.assert_allclose(_value_at(actual, index), expected, rtol=1e-12, atol=1e-12) + for actual, expected in zip(result.pvalue, baseline.pvalue, strict=True): + np.testing.assert_allclose(_value_at(actual, index), expected, rtol=1e-12, atol=1e-12) + assert result.df == baseline.df + else: + np.testing.assert_allclose(_value_at(result.stat, index), baseline.stat, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(_value_at(result.pvalue, index), baseline.pvalue, rtol=1e-12, atol=1e-12) + assert result.df == baseline.df + + +@eeglab_test(STATCOND_REGRESSION, "test_10") +@eeglab_test(STATCOND_REGRESSION, "test_9") +@eeglab_test(STATCOND_REGRESSION, "test_8") +@eeglab_test(STATCOND_REGRESSION, "test_7") +@eeglab_test(STATCOND_CLASS, "pairedDimTTest") +def test_statcond_paired_t_is_invariant_across_feature_dimensions(): + _assert_dimension_invariance(paired="on", design="t") + + +@eeglab_test(STATCOND_REGRESSION, "test_14") +@eeglab_test(STATCOND_REGRESSION, "test_13") +@eeglab_test(STATCOND_REGRESSION, "test_12") +@eeglab_test(STATCOND_REGRESSION, "test_11") +@eeglab_test(STATCOND_CLASS, "unpairedDimTTest") +def test_statcond_unpaired_t_is_invariant_across_feature_dimensions(): + _assert_dimension_invariance(paired="off", design="t") + + +@eeglab_test(STATCOND_REGRESSION, "test_18") +@eeglab_test(STATCOND_REGRESSION, "test_17") +@eeglab_test(STATCOND_REGRESSION, "test_16") +@eeglab_test(STATCOND_REGRESSION, "test_15") +@eeglab_test(STATCOND_CLASS, "pairedDim1Anova") +def test_statcond_paired_one_way_is_invariant_across_feature_dimensions(): + _assert_dimension_invariance(paired="on", design="one-way") + + +@eeglab_test(STATCOND_REGRESSION, "test_22") +@eeglab_test(STATCOND_REGRESSION, "test_21") +@eeglab_test(STATCOND_REGRESSION, "test_20") +@eeglab_test(STATCOND_REGRESSION, "test_19") +@eeglab_test(STATCOND_CLASS, "unpairedDim1Anova") +def test_statcond_unpaired_one_way_is_invariant_across_feature_dimensions(): + _assert_dimension_invariance(paired="off", design="one-way") + + +@eeglab_test(STATCOND_REGRESSION, "test_26") +@eeglab_test(STATCOND_REGRESSION, "test_25") +@eeglab_test(STATCOND_REGRESSION, "test_24") +@eeglab_test(STATCOND_REGRESSION, "test_23") +@eeglab_test(STATCOND_CLASS, "pairedDim2Anova") +def test_statcond_paired_two_way_is_invariant_across_feature_dimensions(): + _assert_dimension_invariance(paired="on", design="two-way") + + +@eeglab_test(STATCOND_REGRESSION, "test_30") +@eeglab_test(STATCOND_REGRESSION, "test_29") +@eeglab_test(STATCOND_REGRESSION, "test_28") +@eeglab_test(STATCOND_REGRESSION, "test_27") +@eeglab_test(STATCOND_CLASS, "unpairedDim2Anova") +def test_statcond_unpaired_two_way_is_invariant_across_feature_dimensions(): + _assert_dimension_invariance(paired="off", design="two-way") + + +def _resampling_conditions(feature_shape: tuple[int, ...], n_conditions: int) -> tuple[np.ndarray, ...]: + offsets = (0, 100, 1000) + arrays = [] + for offset in offsets[:n_conditions]: + values = np.zeros((*feature_shape, 10), dtype=np.float64) + values.reshape(-1, 10)[-1] = np.arange(1, 11) + offset + arrays.append(values) + return tuple(arrays) + + +def _last_feature_trace(array: np.ndarray) -> np.ndarray: + return np.asarray(array).reshape(-1, array.shape[-1])[-1] + + +def _assert_resampling_case( + conditions: tuple[np.ndarray, ...], + *, + method: str, + paired: str, + naccu: int, + seed: int, +) -> None: + originals = tuple(condition.copy() for condition in conditions) + result = statcond( + conditions, + method=method, + paired=paired, + naccu=naccu, + rng=seed, + return_resampling_array=True, + ) + repeated = statcond( + conditions, + method=method, + paired=paired, + naccu=naccu, + rng=seed, + return_resampling_array=True, + ) + assert isinstance(result, SurrogateDistribution) + assert isinstance(repeated, SurrogateDistribution) + assert len(result) == naccu + source_traces = np.stack([_last_feature_trace(condition) for condition in conditions]) + pooled_source = source_traces.ravel() + + for sample, repeated_sample in zip(result, repeated, strict=True): + output_conditions = sample[0] + repeated_conditions = repeated_sample[0] + assert len(output_conditions) == len(conditions) + for source, output, repeated_output in zip(conditions, output_conditions, repeated_conditions, strict=True): + assert output.shape == source.shape + assert output.dtype == source.dtype + np.testing.assert_array_equal(output, repeated_output) + output_traces = np.stack([_last_feature_trace(output) for output in output_conditions]) + + if paired == "on": + for case_index in range(source_traces.shape[1]): + assert np.isin(output_traces[:, case_index], source_traces[:, case_index]).all() + if method == "perm": + np.testing.assert_array_equal(np.sort(output_traces, axis=0), np.sort(source_traces, axis=0)) + else: + assert np.isin(output_traces, pooled_source).all() + if method == "perm": + np.testing.assert_array_equal(np.sort(output_traces.ravel()), np.sort(pooled_source)) + else: + assert all(np.unique(trace).size > 3 for trace in output_traces) + + for original, condition in zip(originals, conditions, strict=True): + np.testing.assert_array_equal(condition, original) + + +def _assert_resampling_suite() -> None: + seed = 720 + for feature_shape in ((1,), (10,), (9, 8)): + for n_conditions in (2, 3): + conditions = _resampling_conditions(feature_shape, n_conditions) + for vectorized in (True, False): + for method, paired in ( + ("bootstrap", "on"), + ("perm", "on"), + ("bootstrap", "off"), + ("perm", "off"), + ): + seed += 1 + _assert_resampling_case( + conditions, + method=method, + paired=paired, + naccu=10 if vectorized else 1, + seed=seed, + ) + + +@eeglab_test(STATCOND_REGRESSION, "test_78") +@eeglab_test(STATCOND_REGRESSION, "test_77") +@eeglab_test(STATCOND_REGRESSION, "test_76") +@eeglab_test(STATCOND_REGRESSION, "test_75") +@eeglab_test(STATCOND_REGRESSION, "test_74") +@eeglab_test(STATCOND_REGRESSION, "test_73") +@eeglab_test(STATCOND_REGRESSION, "test_72") +@eeglab_test(STATCOND_REGRESSION, "test_71") +@eeglab_test(STATCOND_REGRESSION, "test_70") +@eeglab_test(STATCOND_REGRESSION, "test_69") +@eeglab_test(STATCOND_REGRESSION, "test_68") +@eeglab_test(STATCOND_REGRESSION, "test_67") +@eeglab_test(STATCOND_REGRESSION, "test_66") +@eeglab_test(STATCOND_REGRESSION, "test_65") +@eeglab_test(STATCOND_REGRESSION, "test_64") +@eeglab_test(STATCOND_REGRESSION, "test_63") +@eeglab_test(STATCOND_REGRESSION, "test_62") +@eeglab_test(STATCOND_REGRESSION, "test_61") +@eeglab_test(STATCOND_REGRESSION, "test_60") +@eeglab_test(STATCOND_REGRESSION, "test_59") +@eeglab_test(STATCOND_REGRESSION, "test_58") +@eeglab_test(STATCOND_REGRESSION, "test_57") +@eeglab_test(STATCOND_REGRESSION, "test_56") +@eeglab_test(STATCOND_REGRESSION, "test_55") +@eeglab_test(STATCOND_REGRESSION, "test_54") +@eeglab_test(STATCOND_REGRESSION, "test_53") +@eeglab_test(STATCOND_REGRESSION, "test_52") +@eeglab_test(STATCOND_REGRESSION, "test_51") +@eeglab_test(STATCOND_REGRESSION, "test_50") +@eeglab_test(STATCOND_REGRESSION, "test_49") +@eeglab_test(STATCOND_REGRESSION, "test_48") +@eeglab_test(STATCOND_REGRESSION, "test_47") +@eeglab_test(STATCOND_REGRESSION, "test_46") +@eeglab_test(STATCOND_REGRESSION, "test_45") +@eeglab_test(STATCOND_REGRESSION, "test_44") +@eeglab_test(STATCOND_REGRESSION, "test_43") +@eeglab_test(STATCOND_REGRESSION, "test_42") +@eeglab_test(STATCOND_REGRESSION, "test_41") +@eeglab_test(STATCOND_REGRESSION, "test_40") +@eeglab_test(STATCOND_REGRESSION, "test_39") +@eeglab_test(STATCOND_REGRESSION, "test_38") +@eeglab_test(STATCOND_REGRESSION, "test_37") +@eeglab_test(STATCOND_REGRESSION, "test_36") +@eeglab_test(STATCOND_REGRESSION, "test_35") +@eeglab_test(STATCOND_REGRESSION, "test_34") +@eeglab_test(STATCOND_REGRESSION, "test_33") +@eeglab_test(STATCOND_REGRESSION, "test_32") +@eeglab_test(STATCOND_REGRESSION, "test_31") +@eeglab_test(STATCOND_CLASS, "shuffleAndPermutation") +def test_statcond_resampling_preserves_assignment_invariants(): + _assert_resampling_suite() + + +@eeglab_test(STATCOND_WRAPPER, "test_test_statcond") +def test_legacy_statcond_suite_workflow_is_preserved(): + _assert_paired_t_reference() + _assert_unpaired_t_reference() + _assert_paired_one_way_reference() + _assert_unpaired_one_way_reference() + _assert_paired_two_way_reference() + _assert_unpaired_two_way_reference() + for paired in ("on", "off"): + for design in ("t", "one-way", "two-way"): + _assert_dimension_invariance(paired=paired, design=design) + _assert_resampling_suite() From 0b6f3fa27b400c1c441bc653211725ec77e3ee4b Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:44 -0700 Subject: [PATCH 04/45] Port core preprocessing workflow tests (#332) --- docs/source/changelog.rst | 3 + src/eegprep/functions/popfunc/pop_reref.py | 7 +- src/eegprep/functions/sigprocfunc/reref.py | 6 +- src/eegprep/resources/help/pop_reref.md | 1 + tests/test_pop_epoch.py | 49 ++++++++++++++ tests/test_pop_reref.py | 38 +++++++++++ tests/test_pop_resample_python.py | 76 ++++++++++++++++++++++ tests/test_pop_rmbase.py | 25 +++++++ tests/test_sample_data_pop_functions.py | 20 ++++++ 9 files changed, 223 insertions(+), 2 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 0a4a12b9..b4c53c7d 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,9 @@ the `GitHub Releases `_ page. Unreleased ========== +- ``pop_reref`` now accepts EEGLAB's ``method='standard'`` option explicitly. Other + rereferencing methods fail clearly instead of being silently treated as standard, + and NumPy 2's spurious finite-matrix warnings no longer leak from average reference. - ``pop_selectevent`` now supports ``erroronempty='off'`` when a selection removes every epoch, returning an empty EEG instead of raising. Component ``pop_topoplot`` colorbars now use polarity labels only for scales that span zero; positive-only and negative-only diff --git a/src/eegprep/functions/popfunc/pop_reref.py b/src/eegprep/functions/popfunc/pop_reref.py index 165f04e0..79f5b9b2 100644 --- a/src/eegprep/functions/popfunc/pop_reref.py +++ b/src/eegprep/functions/popfunc/pop_reref.py @@ -34,6 +34,7 @@ "refica", "interpchan", "huber", + "method", } @@ -62,7 +63,8 @@ def pop_reref( renderer: Optional GUI renderer for tests. return_com: Return ``(EEG, command)`` when true. **kwargs: Options such as ``exclude``, ``keepref``, ``refloc``, - ``refica``, and ``huber``. + ``refica``, ``huber``, and ``method``. Only EEGLAB's standard + rereferencing method is currently supported. Returns: dict or tuple: Re-referenced EEG, and optionally the EEGLAB-style @@ -294,6 +296,9 @@ def _validate_eeg(EEG: dict) -> None: def _resolve_options(EEG: dict, ref: Any, options: dict[str, Any]) -> dict[str, Any]: + method = str(options.get("method", "standard")).lower() + if method != "standard": + raise ValueError("EEGPrep currently supports only method='standard'") ref_indices = _resolve_channels(EEG, ref) exclude_indices = _resolve_channels(EEG, options.get("exclude", [])) refica = str(options.get("refica", "on")).lower() diff --git a/src/eegprep/functions/sigprocfunc/reref.py b/src/eegprep/functions/sigprocfunc/reref.py index 2edf4d44..94cfbe5e 100644 --- a/src/eegprep/functions/sigprocfunc/reref.py +++ b/src/eegprep/functions/sigprocfunc/reref.py @@ -91,7 +91,11 @@ def reref( dt = original_dtype refmatrix = np.eye(n, dtype=dt) - np.ones((n, n), dtype=dt) / dt.type(n) block = np.ascontiguousarray(work[chansin_array, :].astype(dt).T) - work[chansin_array, :] = (block @ refmatrix).T + # NumPy 2 can report spurious floating-point warnings for this finite + # float32 BLAS product. The result remains finite and is the deliberate + # MATLAB-order accumulation above, so contain only those matmul flags. + with np.errstate(divide="ignore", over="ignore", invalid="ignore"): + work[chansin_array, :] = (block @ refmatrix).T mean_data = None if locs is not None: diff --git a/src/eegprep/resources/help/pop_reref.md b/src/eegprep/resources/help/pop_reref.md index 4f1ac427..54171f53 100644 --- a/src/eegprep/resources/help/pop_reref.md +++ b/src/eegprep/resources/help/pop_reref.md @@ -34,6 +34,7 @@ Optional inputs: - `refloc`: previous reference-channel location to reconstruct. - `refica`: `"on"`, `"off"`, `"backwardcomp"`, or `"remove"` for ICA handling. - `huber`: Huber threshold in microvolts. +- `method`: rereferencing algorithm. EEGPrep currently supports EEGLAB's `"standard"` method. Output: diff --git a/tests/test_pop_epoch.py b/tests/test_pop_epoch.py index 7e241076..094e1a47 100644 --- a/tests/test_pop_epoch.py +++ b/tests/test_pop_epoch.py @@ -19,7 +19,56 @@ from eegprep.functions.adminfunc.eeglabcompat import get_eeglab from eegprep.functions.guifunc.qt import QtDialogRenderer from eegprep.functions.popfunc.pop_epoch import pop_epoch, pop_epoch_dialog_spec +from eegprep.functions.popfunc.pop_loadset import pop_loadset from eegprep.functions.sigprocfunc.floatwrite import floatwrite +from tests.eeglab_tests import eeglab_test + + +@eeglab_test("unittesting_popfunc/pop_epoch/popfunc_pop_epoch_wrapperTest.m", "test_test_pop_epoch") +def test_pop_epoch_current_suite_square_event_workflow(): + eeg = pop_loadset("sample_data/eeglab_data.set") + + output, indices = pop_epoch( + eeg, + ["square"], + [-1, 2], + "newname", + "ee114 continuous (h.p. 1Hz) epochs", + "epochinfo", + "yes", + ) + + assert output["setname"] == "ee114 continuous (h.p. 1Hz) epochs" + assert output["trials"] == 80 + assert output["data"].shape == (eeg["nbchan"], output["pnts"], 80) + assert len(indices) == 80 + assert all("eventlatency" in epoch for epoch in output["epoch"]) + + +@eeglab_test("unittesting_popfunc/pop_epoch/popfunc_pop_epoch_wrapperTest.m", "test_pass_bugzilla_455") +def test_pop_epoch_current_suite_late_epoch_locking_event_has_zero_latency(): + srate = 1.0 + events = [{"type": "lock", "latency": float(2 + 40 * index)} for index in range(97)] + eeg = { + "data": np.arange(3900, dtype=np.float32)[np.newaxis, :], + "nbchan": 1, + "pnts": 3900, + "trials": 1, + "srate": srate, + "xmin": 0.0, + "xmax": 3899.0, + "times": np.arange(3900, dtype=float) * 1000, + "setname": "bugzilla 455", + "event": events, + "urevent": [], + "epoch": [], + "chanlocs": [], + } + + output, _ = pop_epoch(eeg, [], [-1, 32]) + + assert output["trials"] == 97 + assert 0 in np.asarray(output["epoch"][96]["eventlatency"], dtype=float) @unittest.skipIf(os.getenv('EEGPREP_SKIP_MATLAB') == '1', "MATLAB not available") diff --git a/tests/test_pop_reref.py b/tests/test_pop_reref.py index bf0d5485..38ee305d 100644 --- a/tests/test_pop_reref.py +++ b/tests/test_pop_reref.py @@ -19,11 +19,49 @@ from eegprep.functions.popfunc.pop_reref import pop_reref from eegprep.functions.adminfunc.eeglabcompat import get_eeglab from eegprep.utils.testing import DebuggableTestCase +from tests.eeglab_tests import eeglab_test import importlib eeg_checkset_module = importlib.import_module('eegprep.functions.adminfunc.eeg_checkset') +@eeglab_test("unittesting_popfunc/pop_reref/popfunc_pop_reref_wrapperTest.m", "test_pass_bugzilla_270") +def test_pop_reref_current_suite_standard_method_with_multiple_references(): + eeg = { + "data": np.arange(1, 61, dtype=float).reshape(3, 20), + "nbchan": 3, + "pnts": 20, + "trials": 1, + "srate": 1.0, + "xmin": 0.0, + "xmax": 2.0, + "times": np.arange(20, dtype=float), + "chanlocs": [{"labels": f"Ch{index + 1}"} for index in range(3)], + "event": [], + "urevent": [], + "epoch": [], + "icaweights": np.array([]), + "icasphere": np.array([]), + "icawinv": np.array([]), + "icaact": np.array([]), + "icachansind": np.array([], dtype=int), + } + + output = pop_reref(eeg, [0, 1], "method", "standard") + + expected_reference = eeg["data"][[0, 1]].mean(axis=0) + np.testing.assert_allclose(output["data"], eeg["data"][[2]] - expected_reference) + + +@eeglab_test("unittesting_popfunc/pop_reref/popfunc_pop_reref_wrapperTest.m", "test_test_pop_reref") +def test_pop_reref_current_suite_average_reference_workflow(): + eeg = pop_loadset("sample_data/eeglab_data.set") + + output = pop_reref(eeg, []) + + np.testing.assert_allclose(output["data"].mean(axis=0), 0, atol=1e-5) + + class PopRerefIcaRegressionTests(unittest.TestCase): def test_float_icachansind_values_update_ica_without_list_index_error(self): """MATLAB-loaded float ICA channel indices should stay usable internally.""" diff --git a/tests/test_pop_resample_python.py b/tests/test_pop_resample_python.py index 7c4a13d3..c768664b 100644 --- a/tests/test_pop_resample_python.py +++ b/tests/test_pop_resample_python.py @@ -4,6 +4,7 @@ from eegprep.functions.adminfunc.eeg_options import EEG_OPTIONS from eegprep.functions.popfunc.pop_resample import pop_resample +from tests.eeglab_tests import eeglab_test def _continuous_eeg(): @@ -91,5 +92,80 @@ def test_epoched_data_resamples_each_epoch_and_clears_urevents(self): self.assertAlmostEqual(out["event"][0]["duration"], 1.0) +def _epoched_resample_eeg(): + eeg = _continuous_eeg() + eeg["data"] = np.arange(80, dtype=np.float32).reshape(2, 20, 2) + eeg["trials"] = 2 + eeg["event"] = [ + {"type": "stim", "latency": 6.0, "duration": 10.0, "epoch": 1}, + {"type": "resp", "latency": 26.0, "duration": 20.0, "epoch": 2}, + ] + eeg["urevent"] = [] + return eeg + + +@eeglab_test("unittesting_popfunc/pop_resample/popfunc_pop_resample_wrapperTest.m", "test_test_pop_resample") +def test_pop_resample_current_suite_epoched_and_continuous_rates(): + for eeg in (_epoched_resample_eeg(), _continuous_eeg()): + low_rate = pop_resample(eeg, 10) + high_rate = pop_resample(eeg, 1000) + + assert low_rate["srate"] == 10 + assert high_rate["srate"] == 1000 + assert low_rate["trials"] == eeg["trials"] + assert high_rate["trials"] == eeg["trials"] + + +@eeglab_test("unittesting_popfunc/pop_resample/popfunc_pop_resample_wrapperTest.m", "test_test_pop_resample2") +def test_pop_resample_current_suite_preserves_event_duration_seconds(): + epoched = _epoched_resample_eeg() + continuous = _continuous_eeg() + continuous["event"] = [ + {"type": "stim", "latency": 6.0, "duration": 10000.0}, + {"type": "resp", "latency": 16.0, "duration": 20000.0}, + ] + continuous["urevent"] = [] + + for eeg, expected_seconds in ((epoched, [0.1, 0.2]), (continuous, [100.0, 200.0])): + for rate in (10, 1000): + output = pop_resample(eeg, rate) + durations = np.asarray([event["duration"] for event in output["event"]]) + np.testing.assert_allclose(durations / output["srate"], expected_seconds) + + +@eeglab_test("unittesting_popfunc/pop_resample/popfunc_pop_resample_wrapperTest.m", "test_testcase_boundary") +def test_pop_resample_current_suite_preserves_half_sample_boundaries(): + eeg = { + "data": np.zeros((1, 10000), dtype=np.float32), + "nbchan": 1, + "pnts": 10000, + "trials": 1, + "srate": 500.0, + "xmin": 0.0, + "xmax": 19.998, + "times": np.arange(10000, dtype=float) / 500 * 1000, + "setname": "boundary resampling", + "event": [ + {"type": "boundary", "latency": 0.5}, + {"type": "boundary", "latency": 500.5}, + ], + "urevent": [], + "epoch": [], + "chanlocs": [], + "icaweights": np.array([]), + "icasphere": np.array([]), + "icawinv": np.array([]), + "icaact": np.array([]), + "icachansind": np.array([], dtype=int), + } + + for rate in (200, 250, 300, 350, 450, 550, 600, 650, 700): + output = pop_resample(eeg, rate) + np.testing.assert_allclose( + [output["event"][0]["latency"], output["event"][1]["latency"]], + [0.5, rate + 0.5], + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_pop_rmbase.py b/tests/test_pop_rmbase.py index d600a5c1..29facc2e 100644 --- a/tests/test_pop_rmbase.py +++ b/tests/test_pop_rmbase.py @@ -12,6 +12,7 @@ from eegprep.functions.popfunc.pop_loadset import pop_loadset from eegprep.functions.popfunc.pop_rmbase import pop_rmbase, pop_rmbase_dialog_spec from eegprep.functions.sigprocfunc.rmbase import rmbase +from tests.eeglab_tests import eeglab_test try: from .fixtures import SAMPLE_DATASET_PATH, create_test_eeg @@ -359,6 +360,30 @@ def test_pop_rmbase_sample_data_zeroes_selected_baseline_channels_without_warnin assert command == ("EEG = pop_rmbase( EEG, [], [1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20], [1 2]);") +@eeglab_test("unittesting_popfunc/pop_rmbase/popfunc_pop_rmbase_wrapperTest.m", "test_test_pop_rmbase") +def test_pop_rmbase_current_suite_time_point_and_whole_epoch_baselines(): + eeg = pop_loadset("sample_data/eeglab_data_epochs_ica.set") + + by_time, time_command = pop_rmbase(eeg, [-1000, 0], return_com=True) + by_point, point_command = pop_rmbase(eeg, [], range(1, 51), return_com=True) + whole_epoch, whole_command = pop_rmbase(eeg, [], [], return_com=True) + time_wins = pop_rmbase(eeg, [-1000, 0], range(1, 52)) + + times = np.asarray(eeg["times"]) + baseline = np.flatnonzero((times >= -1000) & (times <= 0)) + data = eeg["data"][0, :, 1] + expected_time = (data.astype(np.float64) - np.mean(data[baseline], dtype=np.float64)).astype(data.dtype) + expected_point = (data.astype(np.float64) - np.mean(data[:50], dtype=np.float64)).astype(data.dtype) + expected_whole = (data.astype(np.float64) - np.mean(data, dtype=np.float64)).astype(data.dtype) + np.testing.assert_array_equal(by_time["data"][0, :, 1], expected_time) + np.testing.assert_array_equal(by_point["data"][0, :, 1], expected_point) + np.testing.assert_array_equal(whole_epoch["data"][0, :, 1], expected_whole) + np.testing.assert_array_equal(time_wins["data"], by_time["data"]) + assert "[-1000 0]" in time_command + assert "[1 2 3" in point_command + assert "[], []" in whole_command + + class _CancelRenderer: def run(self, _spec, initial_values=None): del initial_values diff --git a/tests/test_sample_data_pop_functions.py b/tests/test_sample_data_pop_functions.py index a0e99739..1ee7ae5f 100644 --- a/tests/test_sample_data_pop_functions.py +++ b/tests/test_sample_data_pop_functions.py @@ -15,6 +15,7 @@ from eegprep.functions.adminfunc.pop_delset import pop_delset from eegprep.functions.adminfunc.pop_editoptions import pop_editoptions from eegprep.functions.popfunc.eeg_emptyset import eeg_emptyset +from eegprep.functions.popfunc.eeg_lat2point import eeg_lat2point from eegprep.functions.popfunc.eeg_runica import eeg_runica from eegprep.functions.popfunc.pop_adjustevents import pop_adjustevents from eegprep.functions.popfunc.pop_biosig import pop_biosig @@ -61,6 +62,7 @@ from eegprep.plugins.clean_rawdata.clean_channels import clean_channels from eegprep.plugins.clean_rawdata.clean_windows import clean_windows from eegprep.plugins.clean_rawdata.pop_clean_rawdata import pop_clean_rawdata +from tests.eeglab_tests import eeglab_test SAMPLE_SET = Path("sample_data/eeglab_data.set") @@ -128,6 +130,24 @@ def test_pop_select_keeps_named_sample_channels(sample_eeg): assert command == "EEG = pop_select( EEG, 'channel', {'FPz' 'F3'});" +@eeglab_test("unittesting_popfunc/pop_select/popfunc_pop_select_wrapperTest.m", "test_test_pop_select") +def test_pop_select_current_suite_combines_time_trial_and_channel_selection(): + eeg = pop_loadset("sample_data/eeglab_data_epochs_ica.set") + + selected = pop_select(eeg, time=[0.5, 1.0], notrial=[2, 3, 4], nochannel=[30]) + + points, _ = eeg_lat2point([0.5, 1.0], [1, 1], eeg["srate"], [eeg["xmin"], eeg["xmax"]]) + start, stop = int(points[0]) - 1, int(points[1]) + keep_channels = [index for index in range(eeg["nbchan"]) if index != 30] + keep_trials = [index for index in range(eeg["trials"]) if index not in {1, 2, 3}] + expected = eeg["data"][keep_channels, start:stop, :][:, :, keep_trials] + np.testing.assert_array_equal(selected["data"], expected) + assert selected["nbchan"] == eeg["nbchan"] - 1 + assert selected["trials"] == eeg["trials"] - 3 + assert selected["xmin"] == 0.5 + assert selected["xmax"] == 1.0 + + def test_pop_resample_halves_sample_rate_and_event_latencies(sample_eeg): resampled, command = pop_resample(sample_eeg, 64, return_com=True) From 7390d028dbca9ec620228ed961a0e198932a88e6 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:45 -0700 Subject: [PATCH 05/45] Port interpolation and event editing tests (#333) --- docs/source/changelog.rst | 4 + src/eegprep/functions/popfunc/_event_utils.py | 7 +- src/eegprep/functions/popfunc/eeg_interp.py | 11 +- .../functions/popfunc/pop_editeventfield.py | 40 +++- .../resources/help/pop_editeventfield.md | 9 +- tests/test_eeg_interp.py | 36 +++ tests/test_pop_editeventfield_current.py | 207 ++++++++++++++++++ tests/test_pop_editeventvals_current.py | 28 +++ tests/test_pop_interp.py | 18 ++ 9 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 tests/test_pop_editeventfield_current.py create mode 100644 tests/test_pop_editeventvals_current.py diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index b4c53c7d..2cb5ccdf 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,10 @@ the `GitHub Releases `_ page. Unreleased ========== +- ``eeg_interp`` now accepts MATLAB-loaded object arrays of channel locations when + restoring a full montage. ``pop_editeventfield`` now reads values from delimited + text files, accepts MATLAB colon expressions for event indices, rebuilds ``urevent`` + after ``delold='yes'``, and ignores invalid rename/description requests like EEGLAB. - ``pop_reref`` now accepts EEGLAB's ``method='standard'`` option explicitly. Other rereferencing methods fail clearly instead of being silently treated as standard, and NumPy 2's spurious finite-matrix warnings no longer leak from average reference. diff --git a/src/eegprep/functions/popfunc/_event_utils.py b/src/eegprep/functions/popfunc/_event_utils.py index 4c5aec1d..28c05b62 100644 --- a/src/eegprep/functions/popfunc/_event_utils.py +++ b/src/eegprep/functions/popfunc/_event_utils.py @@ -8,7 +8,10 @@ import numpy as np from eegprep.functions.miscfunc.event_utils import is_boundary_event as _is_boundary -from eegprep.functions.miscfunc.value_parsing import is_empty_value as _is_empty +from eegprep.functions.miscfunc.value_parsing import ( + is_empty_value as _is_empty, + parse_numeric_sequence, +) def events_as_list(events: Any) -> list[dict[str, Any]]: @@ -107,7 +110,7 @@ def _as_flat_list(value: Any) -> list[Any]: if isinstance(value, np.ndarray): return value.ravel().tolist() if isinstance(value, (str, bytes)): - return [int(token) for token in str(value).strip().strip("[]").replace(",", " ").split() if token] + return parse_numeric_sequence(value, dtype=int) if isinstance(value, Iterable): return list(value) return [value] diff --git a/src/eegprep/functions/popfunc/eeg_interp.py b/src/eegprep/functions/popfunc/eeg_interp.py index 8ddf8eb8..b1cca3da 100644 --- a/src/eegprep/functions/popfunc/eeg_interp.py +++ b/src/eegprep/functions/popfunc/eeg_interp.py @@ -10,6 +10,8 @@ from scipy.special import lpmv from copy import deepcopy +from eegprep.functions.popfunc._chanutils import chanlocs_as_list + def eeg_interp(EEG, bad_chans, method='spherical', t_range=None, params=None, dtype='float32'): """Interpolate missing or bad EEG channels using spherical spline. @@ -70,13 +72,20 @@ def eeg_interp(EEG, bad_chans, method='spherical', t_range=None, params=None, dt original_data_shape = EEG['data'].shape # ensure channel locations present - locs = EEG['chanlocs'] + locs = chanlocs_as_list(EEG['chanlocs']) + EEG['chanlocs'] = locs # check if locs is null or empty if locs is None or len(locs) == 0: raise RuntimeError("Channel locations required for interpolation") if 'X' not in locs[0] or 'Y' not in locs[0] or 'Z' not in locs[0]: raise RuntimeError("Channel locations required for interpolation") + # MATLAB-loaded channel-location structures are commonly object arrays. + # Normalize them before distinguishing structures from numeric indices. + candidate_chanlocs = chanlocs_as_list(bad_chans) if isinstance(bad_chans, (dict, list, tuple, np.ndarray)) else [] + if candidate_chanlocs and isinstance(candidate_chanlocs[0], dict): + bad_chans = candidate_chanlocs + # convert bad_chans from labels to indices if needed # Handle empty lists first if isinstance(bad_chans, list) and len(bad_chans) == 0: diff --git a/src/eegprep/functions/popfunc/pop_editeventfield.py b/src/eegprep/functions/popfunc/pop_editeventfield.py index 4cac68c0..53b222a6 100644 --- a/src/eegprep/functions/popfunc/pop_editeventfield.py +++ b/src/eegprep/functions/popfunc/pop_editeventfield.py @@ -3,6 +3,8 @@ from __future__ import annotations from copy import deepcopy +from pathlib import Path +import re from typing import Any import numpy as np @@ -35,7 +37,9 @@ def pop_editeventfield( User-facing event indices are 1-based, matching EEGLAB. Event latencies passed through the ``latency`` field are interpreted as seconds by default - and converted to EEGLAB sample latencies. + and converted to EEGLAB sample latencies. Field values may be sequences, + scalars, or paths to delimited text files; ``skipline`` and ``delim`` + control file parsing. """ if not isinstance(EEG, dict): raise ValueError("pop_editeventfield: EEG must be a dataset dictionary") @@ -146,8 +150,14 @@ def _apply_options(EEG: dict[str, Any], options: list[tuple[str, Any]]) -> dict[ ) option_map = {str(key).lower(): value for key, value in options} indices_value = option_map.get("indices") - delold = str(option_map.get("delold", "no")).lower() in {"yes", "on", "1", "true"} + delold_value = str(option_map.get("delold", "no")).lower() + if delold_value not in {"yes", "no", "on", "off", "1", "0", "true", "false"}: + return output + delold = delold_value in {"yes", "on", "1", "true"} + replace_events = delold timeunit = float(option_map.get("timeunit", 1) or 1) + skipline = int(option_map.get("skipline", 0) or 0) + delim = str(option_map.get("delim", "") or "") srate = float(output.get("srate", 1) or 1) for key, value in options: @@ -176,7 +186,7 @@ def _apply_options(EEG: dict[str, Any], options: list[tuple[str, Any]]) -> dict[ _convert_field_type(events, key_text[:-4], str(value)) continue if delold: - values = _coerce_field_values(value) + values = _coerce_field_values(value, skipline=skipline, delim=delim) events = [{} for _item in values] indices = list(range(len(values))) eventdescription = [""] @@ -185,7 +195,7 @@ def _apply_options(EEG: dict[str, Any], options: list[tuple[str, Any]]) -> dict[ indices = normalize_event_indices(indices_value, len(events), allow_empty=True) if not indices: indices = list(range(len(events))) if events else [] - values = _coerce_field_values(value) + values = _coerce_field_values(value, skipline=skipline, delim=delim) if not indices: indices = list(range(len(values))) while len(events) < max(indices, default=-1) + 1: @@ -196,10 +206,17 @@ def _apply_options(EEG: dict[str, Any], options: list[tuple[str, Any]]) -> dict[ _update_matching_urevent(output, events[event_index], key_text) _ensure_description_slot(eventdescription, event_field_names(events, include_urevent=True), key_text) + if replace_events: + output["urevent"] = [] + for index, event in enumerate(events): + urevent = deepcopy(event) + urevent.pop("urevent", None) + output["urevent"].append(urevent) + event["urevent"] = index output["event"] = sort_events(events) output["eventdescription"] = eventdescription output["saved"] = "no" - return eeg_checkset(output) + return eeg_checkset(output, "eventconsistency") def _ordered_options(args: tuple[Any, ...], kwargs: dict[str, Any]) -> list[tuple[str, Any]]: @@ -232,11 +249,11 @@ def _description_list(value: Any, fields: list[str]) -> list[str]: def _rename_field(events: list[dict[str, Any]], rename: str, eventdescription: list[str]) -> None: if "->" not in rename: - raise ValueError("rename must use 'old->new' syntax") + return old, new = [part.strip() for part in rename.split("->", 1)] fields = event_field_names(events, include_urevent=True) if old not in fields: - raise ValueError(f"event field not found: {old}") + return for event in events: if old in event: event[new] = event.pop(old) @@ -256,6 +273,8 @@ def _set_description( description: str, ) -> None: fields = event_field_names(events, include_urevent=True) + if field not in fields: + return _ensure_description_slot(eventdescription, fields, field) fields = event_field_names(events, include_urevent=True) eventdescription[fields.index(field)] = description @@ -288,7 +307,12 @@ def _delete_field(events: list[dict[str, Any]], field: str, eventdescription: li eventdescription.pop(index) -def _coerce_field_values(value: Any) -> list[Any]: +def _coerce_field_values(value: Any, *, skipline: int, delim: str) -> list[Any]: + if isinstance(value, (str, Path)) and Path(value).is_file(): + lines = Path(value).read_text(encoding="utf-8").splitlines()[skipline:] + text = "\n".join(lines) + tokens = re.split(f"[{re.escape(delim)}]+", text) if delim else text.split() + return [_to_number(token) for token in tokens if token] if isinstance(value, np.ndarray): return value.ravel().tolist() if isinstance(value, tuple): diff --git a/src/eegprep/resources/help/pop_editeventfield.md b/src/eegprep/resources/help/pop_editeventfield.md index 7e67cfd9..45e72aaf 100644 --- a/src/eegprep/resources/help/pop_editeventfield.md +++ b/src/eegprep/resources/help/pop_editeventfield.md @@ -2,10 +2,11 @@ Add, remove, rename, or type-convert fields in `EEG.event`. -User-facing event indices are 1-based. Field values may be scalar or a list -matching the selected event indices. Latency values supplied through the -`latency` field are interpreted in seconds by default and converted to EEGLAB -sample latencies. +User-facing event indices are 1-based. Field values may be scalar, a list +matching the selected event indices, or a path to a delimited text file. Use +`skipline` to ignore leading rows and `delim` to choose delimiter characters. +Latency values supplied through the `latency` field are interpreted in seconds +by default and converted to EEGLAB sample latencies. The helper updates matching `EEG.urevent` entries when an event has a valid `urevent` pointer. diff --git a/tests/test_eeg_interp.py b/tests/test_eeg_interp.py index 1618c74b..d7ad0488 100644 --- a/tests/test_eeg_interp.py +++ b/tests/test_eeg_interp.py @@ -5,6 +5,9 @@ from eegprep.functions.popfunc.eeg_interp import eeg_interp, spheric_spline, computeg from eegprep.functions.adminfunc.eeglabcompat import get_eeglab +from eegprep.functions.popfunc.pop_loadset import pop_loadset +from eegprep.functions.popfunc.pop_select import pop_select +from tests.eeglab_tests import eeglab_test # Test Case (Python vs. MATLAB) Max Absolute Max Relative Scenario # test_parity_multiple_trials 5.11e-04 1.99e-02 (1.99%) 3 trials, 3 channels, 1 trial @@ -17,6 +20,39 @@ # test_parity_custom_time_range 2.49e-04 2.02e-03 (0.20%) Custom time range, 3 channels +@eeglab_test("unittesting_popfunc/eeg_interp/popfunc_eeg_interp_wrapperTest.m", "test_checkinterp") +def test_current_suite_checkinterp_preserves_existing_channels_when_restoring_montage(): + eeg = pop_loadset("sample_data/eeglab_data.set") + eeg["data"] = eeg["data"][:, :1000] + eeg["pnts"] = 1000 + original_locs = eeg["chanlocs"] + reduced = pop_select(eeg, nochannel=list(range(0, eeg["nbchan"], 4))) + + restored = eeg_interp(reduced, original_locs) + + restored_labels = [loc["labels"] for loc in restored["chanlocs"]] + reduced_labels = [loc["labels"] for loc in reduced["chanlocs"]] + for reduced_index, label in enumerate(reduced_labels): + restored_index = restored_labels.index(label) + np.testing.assert_array_equal(restored["data"][restored_index], reduced["data"][reduced_index]) + + +@eeglab_test("unittesting_popfunc/eeg_interp/popfunc_eeg_interp_wrapperTest.m", "test_test_eeg_interp") +def test_eeg_interp_current_suite_sample_channel_and_montage_workflows(): + eeg = pop_loadset("sample_data/eeglab_data.set") + eeg["data"] = eeg["data"][:, :1000] + eeg["pnts"] = 1000 + + for method in ("spherical", "invdist"): + interpolated = eeg_interp(eeg, list(range(16)), method) + unchanged = eeg_interp(eeg, [], method) + + assert interpolated["data"].shape == eeg["data"].shape + assert np.isfinite(interpolated["data"]).all() + np.testing.assert_array_equal(interpolated["data"][16:], eeg["data"][16:]) + np.testing.assert_array_equal(unchanged["data"], eeg["data"]) + + class TestEegInterpPlanarGeometry(unittest.TestCase): def test_planar_theta_radius_degrees_match_xy_fallback(self): n_channels = 6 diff --git a/tests/test_pop_editeventfield_current.py b/tests/test_pop_editeventfield_current.py new file mode 100644 index 00000000..fe87540e --- /dev/null +++ b/tests/test_pop_editeventfield_current.py @@ -0,0 +1,207 @@ +"""Current eeglab_tests coverage for event-field editing.""" + +from pathlib import Path + +import numpy as np + +from eegprep.functions.popfunc.pop_editeventfield import pop_editeventfield +from eegprep.functions.popfunc.pop_loadset import pop_loadset +from tests.eeglab_tests import eeglab_test + + +UPSTREAM = "unittesting_popfunc/pop_editeventfield/popfunc_pop_editeventfield_wrapperTest.m" + + +def _eeg(event_count: int = 10) -> dict: + events = [ + {"type": "stim", "position": index % 2 + 1, "latency": float(index * 10 + 1), "urevent": index} + for index in range(event_count) + ] + return { + "data": np.zeros((1, 1000), dtype=np.float32), + "nbchan": 1, + "pnts": 1000, + "trials": 1, + "srate": 100.0, + "xmin": 0.0, + "xmax": 9.99, + "times": np.arange(1000, dtype=float) * 10, + "chanlocs": [{"labels": "Cz"}], + "event": events, + "urevent": [dict(event) for event in events], + "epoch": [], + "eventdescription": {"type": "kind", "position": "target position", "latency": "time"}, + } + + +def _write_values(path: Path, text: str = "0 -5.33 14.7 10") -> Path: + path.write_text(text, encoding="utf-8") + return path + + +def _events_list(events): + return events.tolist() if isinstance(events, np.ndarray) else events + + +@eeglab_test(UPSTREAM, "test_pass_add_field_file") +def test_current_add_field_from_file(tmp_path): + values = _write_values(tmp_path / "addfieldindices.txt") + + output = pop_editeventfield(_eeg(4), "test", values) + + np.testing.assert_allclose([event["test"] for event in output["event"]], [0, -5.33, 14.7, 10]) + + +@eeglab_test(UPSTREAM, "test_pass_add_field_file_indices") +def test_current_add_field_from_file_at_indices(tmp_path): + values = _write_values(tmp_path / "addfieldindices.txt") + indices = [2, 3, 6, 10] + + output = pop_editeventfield(_eeg(), "test", values, "indices", indices) + + np.testing.assert_allclose([output["event"][index - 1]["test"] for index in indices], [0, -5.33, 14.7, 10]) + assert all("test" not in output["event"][index] for index in {0, 3, 4, 6, 7, 8}) + + +@eeglab_test(UPSTREAM, "test_pass_add_field_vector_indices") +def test_current_add_field_from_numeric_vector(): + output = pop_editeventfield(_eeg(3), "test", [-7, 2, 19.5]) + + np.testing.assert_allclose([event["test"] for event in output["event"]], [-7, 2, 19.5]) + + +@eeglab_test(UPSTREAM, "test_pass_delim") +def test_current_file_import_honors_delimiters(tmp_path): + values = _write_values(tmp_path / "delim.txt", "0,-5.33,14.7,10") + + output = pop_editeventfield(_eeg(4), "test", values, "delim", "\t ,") + + np.testing.assert_allclose([event["test"] for event in output["event"]], [0, -5.33, 14.7, 10]) + + +@eeglab_test(UPSTREAM, "test_pass_delold_invalid") +def test_current_invalid_delold_leaves_events_unchanged(): + eeg = _eeg() + + output = pop_editeventfield(eeg, "delold", "abcd", "test", 54321) + + assert _events_list(output["event"]) == eeg["event"] + + +@eeglab_test(UPSTREAM, "test_pass_delold_yes") +def test_current_delold_replaces_events_and_rebuilds_urevents(): + output = pop_editeventfield(_eeg(), "delold", "yes", "test", 54321) + + assert output["event"] == [{"test": 54321, "urevent": 0}] + assert output["urevent"] == [{"test": 54321}] + + +@eeglab_test(UPSTREAM, "test_pass_info") +def test_current_adds_description_to_existing_field(): + output = pop_editeventfield(_eeg(), "typeinfo", "new comment") + + assert output["eventdescription"][0] == "new comment" + + +@eeglab_test(UPSTREAM, "test_pass_info_field_not_exist") +def test_current_ignores_description_for_missing_field(): + eeg = _eeg() + + output = pop_editeventfield(eeg, "abcdinfo", "new comment") + + assert output["eventdescription"] == ["kind", "target position", "time", ""] + + +@eeglab_test(UPSTREAM, "test_pass_latency_timeunit") +def test_current_converts_latency_timeunit_and_keeps_field_rows_together(): + output = pop_editeventfield( + _eeg(4), + "type", + [1, 1, 1, 1], + "latency", + [100, 130, 123, 400], + "timeunit", + 1e-3, + "test", + [54, 321, 123, 45], + ) + + np.testing.assert_allclose([event["latency"] for event in output["event"]], [11, 13.3, 14, 41]) + assert sorted(event["test"] for event in output["event"]) == [45, 54, 123, 321] + + +@eeglab_test(UPSTREAM, "test_pass_modify_field") +def test_current_modifies_existing_field(): + output = pop_editeventfield(_eeg(1), "type", 54321) + + assert output["event"][0]["type"] == 54321 + + +@eeglab_test(UPSTREAM, "test_pass_modify_field_indices") +def test_current_modifies_existing_field_at_indices(): + output = pop_editeventfield(_eeg(), "type", 54321, "indices", [2]) + + assert output["event"][1]["type"] == 54321 + assert output["event"][0]["type"] == "stim" + + +@eeglab_test(UPSTREAM, "test_pass_remove_field_not_exist") +def test_current_ignores_removal_of_missing_field(): + eeg = _eeg() + + output = pop_editeventfield(eeg, "test", []) + + assert _events_list(output["event"]) == eeg["event"] + + +@eeglab_test(UPSTREAM, "test_pass_rename_field") +def test_current_renames_existing_field(): + eeg = _eeg() + + output = pop_editeventfield(eeg, "rename", "position->place") + + assert all("position" not in event for event in output["event"]) + assert [event["place"] for event in output["event"]] == [event["position"] for event in eeg["event"]] + + +@eeglab_test(UPSTREAM, "test_pass_rename_field_invalid_format") +def test_current_ignores_invalid_rename_syntax(): + eeg = _eeg() + + output = pop_editeventfield(eeg, "rename", "position=>place") + + assert _events_list(output["event"]) == eeg["event"] + + +@eeglab_test(UPSTREAM, "test_pass_rename_field_not_exist") +def test_current_ignores_rename_of_missing_field(): + eeg = _eeg() + + output = pop_editeventfield(eeg, "rename", "abcd->place") + + assert _events_list(output["event"]) == eeg["event"] + + +@eeglab_test(UPSTREAM, "test_pass_skipline") +def test_current_file_import_honors_leading_lines(tmp_path): + values = _write_values(tmp_path / "skipline.txt", "header one\nheader two\nheader three\n0 -5.33 14.7 10") + + output = pop_editeventfield(_eeg(4), "test", values, "skipline", 3) + + np.testing.assert_allclose([event["test"] for event in output["event"]], [0, -5.33, 14.7, 10]) + + +@eeglab_test(UPSTREAM, "test_test_pop_editeventfield") +def test_current_sample_description_accepts_matlab_colon_indices(): + eeg = pop_loadset("sample_data/eeglab_data.set") + + output = pop_editeventfield( + eeg, + "indices", + "1:154", + "positioninfo", + ["Position of the target", "Can be 1 or 2"], + ) + + assert len(output["event"]) == 154 + assert "Position of the target" in output["eventdescription"][1] diff --git a/tests/test_pop_editeventvals_current.py b/tests/test_pop_editeventvals_current.py new file mode 100644 index 00000000..7b0e2d68 --- /dev/null +++ b/tests/test_pop_editeventvals_current.py @@ -0,0 +1,28 @@ +"""Current eeglab_tests coverage for editing individual event values.""" + +import numpy as np + +from eegprep.functions.popfunc.pop_editeventvals import pop_editeventvals +from eegprep.functions.popfunc.pop_loadset import pop_loadset +from tests.eeglab_tests import eeglab_test + + +@eeglab_test( + "unittesting_popfunc/pop_editeventvals/popfunc_pop_editeventvals_wrapperTest.m", "test_test_pop_editeventvals" +) +def test_pop_editeventvals_current_suite_changes_multiple_fields_on_one_event(): + eeg = pop_loadset("sample_data/eeglab_data.set") + + output = pop_editeventvals( + eeg, + "changefield", + [1, "latency", 1.1], + "changefield", + [1, "position", 2], + ) + + expected_latency = (1.1 - eeg["xmin"]) * eeg["srate"] + 1 + changed = [event for event in output["event"] if np.isclose(event["latency"], expected_latency)] + assert len(changed) == 1 + assert changed[0]["position"] == 2 + assert output["urevent"][changed[0]["urevent"]]["latency"] == expected_latency diff --git a/tests/test_pop_interp.py b/tests/test_pop_interp.py index f65fbc5b..69b8e8a6 100644 --- a/tests/test_pop_interp.py +++ b/tests/test_pop_interp.py @@ -8,6 +8,8 @@ from eegprep.functions.guifunc.qt import QtDialogRenderer from eegprep.functions.guifunc.spec import controls_by_tag from eegprep.functions.popfunc.pop_interp import pop_interp_dialog_spec +from eegprep.functions.popfunc.pop_loadset import pop_loadset +from tests.eeglab_tests import eeglab_test def _eeg(n_channels=8, n_points=50, trials=1): @@ -44,6 +46,22 @@ def _eeg(n_channels=8, n_points=50, trials=1): return eeg +@eeglab_test("unittesting_popfunc/pop_interp/popfunc_pop_interp_wrapperTest.m", "test_test_pop_interp") +def test_pop_interp_current_suite_sample_channel_workflows(): + eeg = pop_loadset("sample_data/eeglab_data.set") + eeg["data"] = eeg["data"][:, :1000] + eeg["pnts"] = 1000 + + for method in ("spherical", "invdist"): + interpolated = pop_interp(eeg, list(range(16)), method) + unchanged = pop_interp(eeg, [], method) + + assert interpolated["data"].shape == eeg["data"].shape + assert np.isfinite(interpolated["data"]).all() + np.testing.assert_array_equal(interpolated["data"][16:], eeg["data"][16:]) + np.testing.assert_array_equal(unchanged["data"], eeg["data"]) + + class PopInterpTests(unittest.TestCase): def test_no_input_returns_like_eeglab_help_path(self): self.assertIsNone(pop_interp()) From 108b46a1dd5ddd6f00b5655130b1f01ef7f62281 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:45 -0700 Subject: [PATCH 06/45] Port current EEGLAB admin and GUI tests (#334) * Port current EEGLAB admin and GUI tests * Remove internal implementation notes --- docs/source/api/core_and_session.rst | 5 +- docs/source/api/gui_and_console.rst | 8 +- docs/source/changelog.rst | 8 + docs/source/user_guide/contracts.rst | 3 + docs/source/user_guide/storage.rst | 4 + src/eegprep/__init__.py | 7 + .../functions/adminfunc/eeg_readoptions.py | 71 +++++ .../functions/adminfunc/eeg_retrieve.py | 10 +- src/eegprep/functions/adminfunc/eegh.py | 25 +- src/eegprep/functions/adminfunc/gethelpvar.py | 71 +++++ src/eegprep/functions/adminfunc/getkeyval.py | 109 ++++++++ src/eegprep/functions/adminfunc/vararg2str.py | 58 ++++ src/eegprep/functions/guifunc/__init__.py | 3 + src/eegprep/functions/guifunc/errordlg2.py | 33 +++ src/eegprep/functions/guifunc/finputcheck.py | 187 +++++++++++++ src/eegprep/functions/guifunc/inputdlg2.py | 92 +++++++ src/eegprep/functions/guifunc/listdlg2.py | 15 +- src/eegprep/functions/guifunc/session.py | 8 +- tests/conftest.py | 1 + tests/test_console_workspace.py | 2 + tests/test_eeg_options.py | 81 ++++++ tests/test_eeg_retrieve.py | 44 ++- tests/test_eeg_store.py | 48 +++- tests/test_eegh.py | 75 ++++- tests/test_eegobj.py | 20 ++ tests/test_finputcheck.py | 144 ++++++++++ tests/test_gethelpvar.py | 85 ++++++ tests/test_getkeyval.py | 86 ++++++ tests/test_gui_rejection_dialogs.py | 15 + tests/test_guifunc_pophelp_chansel.py | 30 +- tests/test_guifunc_primitives.py | 258 ++++++++++++++++++ tests/test_pop_delset.py | 11 + tests/test_session_contracts.py | 2 + tests/test_vararg2str.py | 15 + 34 files changed, 1598 insertions(+), 36 deletions(-) create mode 100644 src/eegprep/functions/adminfunc/eeg_readoptions.py create mode 100644 src/eegprep/functions/adminfunc/gethelpvar.py create mode 100644 src/eegprep/functions/adminfunc/getkeyval.py create mode 100644 src/eegprep/functions/adminfunc/vararg2str.py create mode 100644 src/eegprep/functions/guifunc/errordlg2.py create mode 100644 src/eegprep/functions/guifunc/finputcheck.py create mode 100644 src/eegprep/functions/guifunc/inputdlg2.py create mode 100644 tests/test_eeg_options.py create mode 100644 tests/test_finputcheck.py create mode 100644 tests/test_gethelpvar.py create mode 100644 tests/test_getkeyval.py create mode 100644 tests/test_guifunc_primitives.py create mode 100644 tests/test_vararg2str.py diff --git a/docs/source/api/core_and_session.rst b/docs/source/api/core_and_session.rst index eabbb0f2..2618935f 100644 --- a/docs/source/api/core_and_session.rst +++ b/docs/source/api/core_and_session.rst @@ -10,6 +10,10 @@ Dataset containers, validation, and the shared session that the GUI and ``eegpre eegprep.EEGPrepSession eegprep.EEG_OPTIONS + eegprep.eeg_readoptions + eegprep.gethelpvar + eegprep.getkeyval + eegprep.vararg2str eegprep.EEGobj eegprep.checkset eegprep.compare @@ -22,4 +26,3 @@ Dataset containers, validation, and the shared session that the GUI and ``eegpre eegprep.eegh eegprep.eeglab eegprep.options - diff --git a/docs/source/api/gui_and_console.rst b/docs/source/api/gui_and_console.rst index c0aa0f11..566ab7b4 100644 --- a/docs/source/api/gui_and_console.rst +++ b/docs/source/api/gui_and_console.rst @@ -5,6 +5,10 @@ GUI and Console Qt dialogs, console workspace objects, and the entry points that open the graphical interface. +``listdlg2`` accepts either a sequence of labels or one scalar string. A scalar +string is displayed as one choice, and returned selections use EEGLAB-facing +1-based positions. + .. autosummary:: :toctree: generated/ @@ -17,12 +21,14 @@ Qt dialogs, console workspace objects, and the entry points that open the graphi eegprep.bundled_plugins eegprep.eeg_multieegplot eegprep.eegplot + eegprep.errordlg2 + eegprep.finputcheck eegprep.format_plugin_menu eegprep.gui + eegprep.inputdlg2 eegprep.inputgui eegprep.listdlg2 eegprep.plugin_menu eegprep.plugin_status eegprep.pophelp eegprep.select_multiple_datasets - diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 2cb5ccdf..e422757d 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -23,6 +23,14 @@ Unreleased scales show their exact numeric ``maplimits`` endpoints. ``eeg_decodechan`` now accepts EEGLAB's whitespace-separated channel-name form as well as Python sequences. ICA rejection marks can also derive their row count from ``icachansind`` when stored ICA matrices are absent. +- Current EEGLAB admin/GUI test ports exposed and fixed three parity defects: + ``eeg_retrieve(ALLEEG, 0)`` now returns the empty no-current dataset, + multi-dataset commands receive EEGLAB's marker in each dataset history, and + ``listdlg2`` treats a scalar string as one selectable item while preserving + its 1-based initial selection. +- Compatibility helpers now cover EEGLAB key/value validation, option-file and + help-header parsing, history-value extraction and formatting, and the generic + ``inputdlg2`` and ``errordlg2`` dialogs without runtime access to EEGLAB. - ``pop_autorej`` (Tools > Automatic epoch rejection) now runs EEGLAB's probability loop exactly: a pass rejects its flagged epochs only when they are fewer than ``maxrej`` percent of the remaining epochs (5% of 80 epochs is not fewer, so the threshold is diff --git a/docs/source/user_guide/contracts.rst b/docs/source/user_guide/contracts.rst index 0ae191a9..c413e3e9 100644 --- a/docs/source/user_guide/contracts.rst +++ b/docs/source/user_guide/contracts.rst @@ -58,6 +58,9 @@ EEGLAB's callback behavior. replayable Python input before progress messages or warnings from the same action. ``eegh`` presents history newest-first like EEGLAB, while ``EEGPrepSession.ALLCOM`` stays chronological internally. +When one command is applied to a multi-dataset selection, each selected EEG +history records it with the ``% multiple datasets command:`` marker used by +EEGLAB, while the session records the command once in ``ALLCOM``. EEGPrep does not emulate EEGLAB's one-dataset-in-memory ``option_storedisk`` behavior. Saved non-current datasets are represented by explicit offloaded disk diff --git a/docs/source/user_guide/storage.rst b/docs/source/user_guide/storage.rst index 66bb3a9a..ebf84512 100644 --- a/docs/source/user_guide/storage.rst +++ b/docs/source/user_guide/storage.rst @@ -70,6 +70,10 @@ retrieve the dataset first: ALLEEG, EEG, CURRENTSET = eeg_store(ALLEEG, EEG, 0) EEG, ALLEEG, CURRENTSET = eeg_retrieve(ALLEEG, 1) +Calling ``eeg_retrieve(ALLEEG, 0)`` represents the no-current-dataset state: it +returns an empty EEG dataset and ``CURRENTSET == 0`` without deleting or +renumbering ``ALLEEG``. + The GUI, ``EEGPrepSession``, and ``eegprep-console`` use the same ``eeg_store``/``eeg_retrieve`` path, so ``EEG``, ``ALLEEG``, ``CURRENTSET``, history, and dataset menus stay synchronized. Unsaved resident datasets cannot diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 7fc5809e..3c2a1e62 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -104,11 +104,13 @@ "eeg_pvaf": ("eegprep.functions.sigprocfunc.ica_helpers", "eeg_pvaf"), "eeg_rejsuperpose": ("eegprep.functions.popfunc.eeg_rejsuperpose", "eeg_rejsuperpose"), "eeg_retrieve": ("eegprep.functions.adminfunc.eeg_retrieve", "eeg_retrieve"), + "eeg_readoptions": ("eegprep.functions.adminfunc.eeg_readoptions", "eeg_readoptions"), "eeg_rpsd": ("eegprep.plugins.ICLabel.eeg_rpsd", "eeg_rpsd"), "eeg_runica": ("eegprep.functions.popfunc.eeg_runica", "eeg_runica"), "eeg_store": ("eegprep.functions.adminfunc.eeg_store", "eeg_store"), "eegh": ("eegprep.functions.adminfunc.eegh", "eegh"), "eeglab": ("eegprep.functions.adminfunc.eeglab", "eeglab"), + "errordlg2": ("eegprep.functions.guifunc.errordlg2", "errordlg2"), "eeglab_clean_artifacts": ("eegprep.functions.adminfunc.eeglabcompat", "clean_artifacts"), "eegrej": ("eegprep.functions.sigprocfunc.eegrej", "eegrej"), "eegthresh": ("eegprep.functions.sigprocfunc.eegthresh", "eegthresh"), @@ -126,13 +128,17 @@ "invkaiserbeta": ("eegprep.plugins.firfilt.invkaiserbeta", "invkaiserbeta"), "floatread": ("eegprep.functions.sigprocfunc.floatread", "floatread"), "floatwrite": ("eegprep.functions.sigprocfunc.floatwrite", "floatwrite"), + "finputcheck": ("eegprep.functions.guifunc.finputcheck", "finputcheck"), "format_plugin_menu": ("eegprep.functions.adminfunc.plugin_menu", "format_plugin_menu"), "gui": ("eegprep.functions.adminfunc.eeglab", "gui"), + "gethelpvar": ("eegprep.functions.adminfunc.gethelpvar", "gethelpvar"), + "getkeyval": ("eegprep.functions.adminfunc.getkeyval", "getkeyval"), "icaact": ("eegprep.functions.sigprocfunc.ica_helpers", "icaact"), "icaproj": ("eegprep.functions.sigprocfunc.ica_helpers", "icaproj"), "icavar": ("eegprep.functions.sigprocfunc.ica_helpers", "icavar"), "iclabel": ("eegprep.plugins.ICLabel.iclabel", "iclabel"), "eeg_icflag": ("eegprep.plugins.ICLabel.eeg_icflag", "eeg_icflag"), + "inputdlg2": ("eegprep.functions.guifunc.inputdlg2", "inputdlg2"), "inputgui": ("eegprep.functions.guifunc.inputgui", "inputgui"), "interp": ("eegprep.functions.redefine_functions", "interp"), "jointprob": ("eegprep.functions.sigprocfunc.jointprob", "jointprob"), @@ -170,6 +176,7 @@ "tf_cycle_calc": ("eegprep.functions.timefreqfunc.tf_cycle_calc", "tf_cycle_calc"), "vis_artifacts": ("eegprep.plugins.clean_rawdata.vis_artifacts", "vis_artifacts"), "vis_artifacts_diagnostics": ("eegprep.plugins.clean_rawdata.vis_artifacts", "vis_artifacts_diagnostics"), + "vararg2str": ("eegprep.functions.adminfunc.vararg2str", "vararg2str"), "options": ("eegprep.functions.redefine_functions", "options"), "picard": ("eegprep.functions.redefine_functions", "picard"), "plugin_menu": ("eegprep.functions.adminfunc.plugin_menu", "plugin_menu"), diff --git a/src/eegprep/functions/adminfunc/eeg_readoptions.py b/src/eegprep/functions/adminfunc/eeg_readoptions.py new file mode 100644 index 00000000..7d38d184 --- /dev/null +++ b/src/eegprep/functions/adminfunc/eeg_readoptions.py @@ -0,0 +1,71 @@ +"""Read EEGLAB ``eeg_options.m`` files without executing MATLAB code.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +import numpy as np + + +_OPTION_LINE = re.compile(r"^\s*(option_[A-Za-z0-9_]+)\s*=\s*(.*?)\s*;\s*(?:%\s*(.*))?$") + + +def eeg_readoptions( + filename: str | Path, + option_backup: Sequence[Mapping[str, Any]] | None = None, +) -> tuple[str, list[dict[str, Any]]]: + """Parse an EEGLAB option script into header text and option records. + + The parser reads assignments as data and never executes the MATLAB file. + When ``option_backup`` is supplied, matching records receive values from + the file while the backup's shape and order are preserved. + """ + lines = Path(filename).read_text(encoding="utf-8").splitlines() + header_lines: list[str] = [] + options: list[dict[str, Any]] = [] + in_header = True + for line in lines: + if in_header and (not line.strip() or line.lstrip().startswith("%")): + header_lines.append(line) + else: + in_header = False + match = _OPTION_LINE.match(line) + if match is None: + continue + name, raw_value, description = match.groups() + options.append( + { + "varname": name, + "value": _parse_matlab_value(raw_value), + "description": description or "", + } + ) + + if option_backup is not None: + values = {option["varname"]: option["value"] for option in options} + backed_up = [dict(option) for option in option_backup] + for option in backed_up: + name = str(option.get("varname", "")) + if name in values: + option["value"] = values[name] + options = backed_up + return "\n".join(header_lines), options + + +def _parse_matlab_value(value: str) -> Any: + text = value.strip() + if text.startswith("'") and text.endswith("'"): + return text[1:-1].replace("''", "'") + if text.startswith("[") and text.endswith("]"): + values = np.fromstring(text[1:-1].replace(",", " "), sep=" ") + return values.tolist() + try: + return int(text) + except ValueError: + try: + return float(text) + except ValueError: + return text diff --git a/src/eegprep/functions/adminfunc/eeg_retrieve.py b/src/eegprep/functions/adminfunc/eeg_retrieve.py index 404cbb46..c3f1801f 100644 --- a/src/eegprep/functions/adminfunc/eeg_retrieve.py +++ b/src/eegprep/functions/adminfunc/eeg_retrieve.py @@ -13,7 +13,11 @@ def eeg_retrieve( ALLEEG: list[dict[str, Any]] | None, index: int | list[int] | tuple[int, ...], ) -> tuple[dict[str, Any] | list[dict[str, Any]], list[dict[str, Any]], int | list[int]]: - """Return dataset(s) from ``ALLEEG`` using EEGLAB-facing 1-based indices.""" + """Return dataset(s) from ``ALLEEG`` using EEGLAB-facing 1-based indices. + + Scalar index ``0`` returns an empty EEG dataset and leaves ``ALLEEG`` + unchanged, matching EEGLAB's no-current-dataset state. + """ alleeg = [] if ALLEEG is None else list(ALLEEG) if isinstance(index, (list, tuple)): indices = [int(item) for item in index] @@ -41,7 +45,9 @@ def _is_occupied(alleeg: list[dict[str, Any]], index: int) -> bool: def _dataset_at(alleeg: list[dict[str, Any]], index: int) -> dict[str, Any]: - if index < 1: + if index == 0: + return eeg_emptyset() + if index < 0: raise ValueError("EEGLAB dataset indices are 1-based") try: dataset = alleeg[index - 1] diff --git a/src/eegprep/functions/adminfunc/eegh.py b/src/eegprep/functions/adminfunc/eegh.py index 5c6d37a7..970000ee 100644 --- a/src/eegprep/functions/adminfunc/eegh.py +++ b/src/eegprep/functions/adminfunc/eegh.py @@ -5,7 +5,10 @@ from typing import Any -def eegh(command: Any = None, history: list[str] | dict[str, Any] | None = None) -> str: +def eegh( + command: Any = None, + history: list[str] | dict[str, Any] | list[dict[str, Any]] | None = None, +) -> str: """Display or update EEGLAB-style command history. Args: @@ -18,15 +21,19 @@ def eegh(command: Any = None, history: list[str] | dict[str, Any] | None = None) The normalized command, rendered history text, or matched history item. """ if isinstance(command, str) and command.strip().lower() == "find": - return _find_history(history if isinstance(history, list) else [], None) + return _find_history(history if _is_command_history(history) else [], None) if command is None: - return _render_history(history if isinstance(history, list) else []) + return _render_history(history if _is_command_history(history) else []) if isinstance(command, (int, float)) and not isinstance(command, bool): - return _numeric_history(command, history if isinstance(history, list) else []) + return _numeric_history(command, history if _is_command_history(history) else []) normalized = "" if command is None else str(command).strip() if not normalized: return "" - if isinstance(history, list): + if _is_eeg_collection(history): + multiple_command = f"% multiple datasets command: {normalized}" + for eeg in history: + _append_eeg_history(eeg, multiple_command) + elif isinstance(history, list): _append_history(history, normalized) elif isinstance(history, dict): _append_eeg_history(history, normalized) @@ -44,6 +51,14 @@ def _append_history(history: list[str], command: str) -> None: history.append(command) +def _is_command_history(history: Any) -> bool: + return isinstance(history, list) and not any(isinstance(item, dict) for item in history) + + +def _is_eeg_collection(history: Any) -> bool: + return isinstance(history, list) and bool(history) and all(isinstance(item, dict) for item in history) + + def _append_eeg_history(eeg: dict[str, Any], command: str) -> None: history_value = eeg.get("history") existing = "" if history_value is None or getattr(history_value, "size", 1) == 0 else str(history_value) diff --git a/src/eegprep/functions/adminfunc/gethelpvar.py b/src/eegprep/functions/adminfunc/gethelpvar.py new file mode 100644 index 00000000..068ba19f --- /dev/null +++ b/src/eegprep/functions/adminfunc/gethelpvar.py @@ -0,0 +1,71 @@ +"""Extract documented variables from a MATLAB help header.""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Sequence +from pathlib import Path + + +logger = logging.getLogger(__name__) +_TITLE = re.compile( + r"^(usage|authors?|notes?|inputs?|outputs?|examples?|see also)\s*:", + re.IGNORECASE, +) +_VARIABLE = re.compile(r"^'?([A-Za-z][A-Za-z0-9_]*)'?\s+[-=]\s*(.*)$") + + +def gethelpvar(filename: str | Path, variables: str | Sequence[str] | None = None) -> tuple[list[str], list[str]]: + """Return variable descriptions and all variable names from an M-file. + + Args: + filename: MATLAB source file whose leading help comments are parsed. + variables: Optional name or ordered names whose descriptions should be + returned. Unknown names produce an empty description. + """ + lines = Path(filename).read_text(encoding="utf-8").splitlines() + help_lines = _leading_help_lines(lines) + names, descriptions = _parse_variables(help_lines) + if variables is None: + return descriptions, names + requested = [variables] if isinstance(variables, str) else list(variables) + by_name = dict(zip(names, descriptions)) + selected: list[str] = [] + for name in requested: + if name not in by_name: + logger.warning("Variable '%s' not found in %s", name, filename) + selected.append(by_name.get(name, "")) + return selected, names + + +def _leading_help_lines(lines: list[str]) -> list[str]: + if lines and not lines[0].lstrip().startswith("%"): + lines = lines[1:] + result: list[str] = [] + for line in lines: + stripped = line.lstrip() + if not stripped.startswith("%"): + break + result.append(stripped[1:].strip()) + return result + + +def _parse_variables(lines: list[str]) -> tuple[list[str], list[str]]: + names: list[str] = [] + descriptions: list[str] = [] + current: int | None = None + for line in lines: + if _TITLE.match(line): + current = None + continue + match = _VARIABLE.match(line) + if match: + name, description = match.groups() + names.append(name) + descriptions.append(description.rstrip()) + current = len(names) - 1 + continue + if current is not None and line: + descriptions[current] = f"{descriptions[current]}\n{line.rstrip()}" + return names, descriptions diff --git a/src/eegprep/functions/adminfunc/getkeyval.py b/src/eegprep/functions/adminfunc/getkeyval.py new file mode 100644 index 00000000..adbf3ae4 --- /dev/null +++ b/src/eegprep/functions/adminfunc/getkeyval.py @@ -0,0 +1,109 @@ +"""Extract values from EEGLAB command-history strings.""" + +from __future__ import annotations + +from collections.abc import Sequence +from numbers import Integral +from typing import Any + +import numpy as np + + +def getkeyval(command: str, variable: str | int, mode: str | int | Sequence[int] = "", default: Any = "") -> Any: + """Return an argument from an EEGLAB command-history expression. + + String ``variable`` values address key/value arguments. Integer values use + EEGLAB-facing 1-based argument positions. ``mode="present"`` returns a + boolean-like integer, ``mode="full"`` includes the key in the returned + command fragment, and integer modes select 1-based elements from a numeric + vector value. + """ + if not command: + return default + arguments = _split_arguments(command) + if isinstance(variable, Integral): + index = int(variable) - 1 + return arguments[index].strip() if 0 <= index < len(arguments) else default + + key_index = _find_key(arguments, variable) + if isinstance(mode, str) and mode.lower() == "present": + return int(key_index is not None) + if key_index is None: + if isinstance(mode, str) and mode.lower() == "full": + parent_index = next((index for index, argument in enumerate(arguments) if variable in argument), None) + if parent_index is not None and parent_index + 1 < len(arguments): + return f"'{variable}', {arguments[parent_index + 1].strip()}" + return default + if key_index + 1 >= len(arguments): + return default + + raw_value = arguments[key_index + 1].strip() + if isinstance(mode, str) and mode.lower() == "full": + return f"'{variable}', {raw_value}" + if isinstance(mode, Integral) or (isinstance(mode, Sequence) and not isinstance(mode, (str, bytes))): + indices = [int(mode)] if isinstance(mode, Integral) else [int(index) for index in mode] + return _select_numeric_value(raw_value, indices, default) + return _unquote(raw_value) + + +def _split_arguments(command: str) -> list[str]: + open_paren = command.find("(") + close_paren = command.rfind(")") + if open_paren < 0 or close_paren < open_paren: + return [] + body = command[open_paren + 1 : close_paren] + arguments: list[str] = [] + start = 0 + depths = {"(": 0, "[": 0, "{": 0} + closing = {")": "(", "]": "[", "}": "{"} + quoted = False + index = 0 + while index < len(body): + char = body[index] + if char == "'": + if quoted and index + 1 < len(body) and body[index + 1] == "'": + index += 2 + continue + quoted = not quoted + elif not quoted and char in depths: + depths[char] += 1 + elif not quoted and char in closing: + depths[closing[char]] -= 1 + elif not quoted and char == "," and not any(depths.values()): + arguments.append(body[start:index]) + start = index + 1 + index += 1 + if body[start:].strip(): + arguments.append(body[start:]) + return arguments + + +def _find_key(arguments: list[str], variable: str) -> int | None: + for index in range(0, len(arguments) - 1): + if _unquote(arguments[index].strip()) == variable: + return index + return None + + +def _unquote(value: str) -> str: + if len(value) >= 2 and value.startswith("'") and value.endswith("'"): + return value[1:-1].replace("''", "'") + return value + + +def _select_numeric_value(raw_value: str, indices: list[int], default: Any) -> Any: + if not indices: + return raw_value + text = raw_value.strip() + if text.startswith("[") and text.endswith("]"): + text = text[1:-1] + values = np.fromstring(text.replace(",", " "), sep=" ") + available = [index for index in indices if 1 <= index <= values.size] + if not available: + return default + selected = values[np.asarray(available, dtype=int) - 1] + return " ".join(_format_number(value) for value in selected) + + +def _format_number(value: float) -> str: + return str(int(value)) if float(value).is_integer() else str(float(value)) diff --git a/src/eegprep/functions/adminfunc/vararg2str.py b/src/eegprep/functions/adminfunc/vararg2str.py new file mode 100644 index 00000000..88fb6a1e --- /dev/null +++ b/src/eegprep/functions/adminfunc/vararg2str.py @@ -0,0 +1,58 @@ +"""Format arguments for replayable EEGLAB command history.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._pop_utils import format_history_value + + +def vararg2str( + arguments: Any, + inputnames: Sequence[str] | None = None, + inputnum: Sequence[int] | None = None, + nostrconv: Sequence[int | bool] | None = None, +) -> str: + """Return a comma-separated EEGLAB expression for function arguments. + + ``inputnames`` can preserve caller variable names instead of serializing + their values. ``nostrconv`` leaves selected string expressions unquoted. + ``inputnum`` is accepted for compatibility with EEGLAB, whose current + implementation likewise does not use it during conversion. + """ + del inputnum + values = list(arguments) if isinstance(arguments, (list, tuple)) else [arguments] + names = _padded(inputnames, len(values), "") + raw_strings = _padded(nostrconv, len(values), False) + rendered = [] + for value, name, raw_string in zip(values, names, raw_strings): + if name: + rendered.append(str(name)) + else: + rendered.append(_format_value(value, raw_string=bool(raw_string))) + return ",".join(rendered) + + +def _padded(values: Sequence[Any] | None, length: int, default: Any) -> list[Any]: + result = [] if values is None else list(values) + return (result + [default] * length)[:length] + + +def _format_value(value: Any, *, raw_string: bool = False) -> str: + if isinstance(value, str): + return value if raw_string else format_history_value(value) + if isinstance(value, dict): + fields: list[str] = [] + for name, field_value in value.items(): + fields.extend((format_history_value(str(name)), _format_value(field_value))) + return f"struct({','.join(fields)})" if fields else "struct([])" + if isinstance(value, (list, tuple)): + return "{" + vararg2str(value) + "}" + if isinstance(value, np.ndarray): + return format_history_value(value, cell_for_sequence=None) + if isinstance(value, (bool, np.bool_)): + return "1" if value else "0" + return format_history_value(value, cell_for_sequence=None) diff --git a/src/eegprep/functions/guifunc/__init__.py b/src/eegprep/functions/guifunc/__init__.py index 7965dcdf..5c6f2e13 100644 --- a/src/eegprep/functions/guifunc/__init__.py +++ b/src/eegprep/functions/guifunc/__init__.py @@ -12,6 +12,9 @@ "EEGPrepMainWindow": ("eegprep.functions.guifunc.main_window", "EEGPrepMainWindow"), "build_main_window": ("eegprep.functions.guifunc.main_window", "build_main_window"), "controls_by_tag": ("eegprep.functions.guifunc.spec", "controls_by_tag"), + "errordlg2": ("eegprep.functions.guifunc.errordlg2", "errordlg2"), + "finputcheck": ("eegprep.functions.guifunc.finputcheck", "finputcheck"), + "inputdlg2": ("eegprep.functions.guifunc.inputdlg2", "inputdlg2"), "inputgui": ("eegprep.functions.guifunc.inputgui", "inputgui"), "listdlg2": ("eegprep.functions.guifunc.listdlg2", "listdlg2"), "pophelp": ("eegprep.functions.guifunc.pophelp", "pophelp"), diff --git a/src/eegprep/functions/guifunc/errordlg2.py b/src/eegprep/functions/guifunc/errordlg2.py new file mode 100644 index 00000000..b8dabdf4 --- /dev/null +++ b/src/eegprep/functions/guifunc/errordlg2.py @@ -0,0 +1,33 @@ +"""EEGLAB ``errordlg2``-style error dialog.""" + +from __future__ import annotations + +from typing import Any + +try: # pragma: no cover - depends on optional GUI dependency + from PySide6 import QtWidgets +except ImportError: # pragma: no cover - depends on optional GUI dependency + QtWidgets = None + + +def errordlg2(prompt: str, title: str = "Error", parent: Any | None = None) -> int: + """Display a modal error message and return the Qt dialog result.""" + _app, dialog = build_errordlg2(prompt, title, parent) + return int(dialog.exec()) + + +def build_errordlg2(prompt: str, title: str = "Error", parent: Any | None = None) -> tuple[Any, Any]: + """Build an error dialog without executing it, for GUI tests and capture.""" + if QtWidgets is None: + raise RuntimeError( + "PySide6 is required for EEGPrep GUI error dialogs. Install it with " + "`pip install -e .[gui]` or `pip install eegprep[gui]`." + ) + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + dialog = QtWidgets.QMessageBox(parent) + dialog.setIcon(QtWidgets.QMessageBox.Icon.Critical) + dialog.setWindowTitle(title) + dialog.setText(prompt) + dialog.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok) + dialog.setObjectName("errordlg2") + return app, dialog diff --git a/src/eegprep/functions/guifunc/finputcheck.py b/src/eegprep/functions/guifunc/finputcheck.py new file mode 100644 index 00000000..4406d61e --- /dev/null +++ b/src/eegprep/functions/guifunc/finputcheck.py @@ -0,0 +1,187 @@ +"""Validation for EEGLAB-style key/value arguments.""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping, Sequence +from numbers import Real +from typing import Any + +import numpy as np + + +FieldRule = tuple[str, str | Sequence[str], Any, Any] +logger = logging.getLogger(__name__) + + +def finputcheck( + arguments: Mapping[str, Any] | Sequence[Any], + fieldlist: Sequence[FieldRule], + callingfunc: str = "", + mode: str = "error", + verbose: str = "verbose", + *, + return_unrecognized: bool = False, +) -> dict[str, Any] | str | tuple[dict[str, Any] | str, list[Any]]: + """Validate EEGLAB-style key/value arguments against field rules. + + Each field rule is ``(name, type, allowed_values, default)``. Supported + types match EEGLAB's ``finputcheck``: ``boolean``, ``integer``, ``real``, + ``float``, ``string``, ``cell``, ``struct``, and ``function_handle``. + Validation failures are returned as strings, as in EEGLAB. + + Args: + arguments: Mapping or flat key/value sequence to validate. + fieldlist: Validation rules in EEGLAB's four-column order. + callingfunc: Optional function name prefixed to error messages. + mode: Use ``"ignore"`` to retain unrecognized arguments. + verbose: Use ``"quiet"`` to suppress duplicate-key notices. + return_unrecognized: Also return a flat key/value list of arguments + not declared by ``fieldlist``. This is Python's explicit + equivalent of requesting EEGLAB's second output. + + Returns: + A validated dictionary or an error string. When + ``return_unrecognized`` is true, also returns residual key/value pairs. + """ + values_or_error = _argument_dict(arguments, verbose) + if isinstance(values_or_error, str): + return _with_residual(values_or_error, [], return_unrecognized) + values = values_or_error + prefix = f"{callingfunc} " if callingfunc else "" + + rule_names: set[str] = set() + for rule in fieldlist: + if len(rule) != 4: + raise ValueError("finputcheck field rules must have four entries") + name, expected_types, allowed, default = rule + rule_names.add(name) + if name not in values: + values[name] = default + error = _validate_field(name, values[name], expected_types, allowed, prefix) + if error: + return _with_residual(error, [], return_unrecognized) + + residual: list[Any] = [] + for name, value in values.items(): + if name in rule_names: + continue + if mode.lower() != "ignore": + error = f"{prefix}error: undefined argument '{name}'" + return _with_residual(error, [], return_unrecognized) + residual.extend((name, value)) + return _with_residual(values, residual, return_unrecognized) + + +def _argument_dict(arguments: Mapping[str, Any] | Sequence[Any], verbose: str) -> dict[str, Any] | str: + if isinstance(arguments, Mapping): + return dict(arguments) + if isinstance(arguments, (str, bytes)): + return "error: bad 'key', 'val' sequence" + items = list(arguments) + if len(items) % 2: + return "error: bad 'key', 'val' sequence" + result: dict[str, Any] = {} + duplicates: list[str] = [] + for index in range(0, len(items), 2): + name = items[index] + if not isinstance(name, str): + return "error: bad 'key', 'val' sequence" + if name in result: + duplicates.append(name) + result[name] = items[index + 1] + if duplicates and verbose.lower() == "verbose": + names = ", ".join(dict.fromkeys(duplicates)) + logger.info("Duplicate '%s' parameter(s); keeping the last value", names) + return result + + +def _validate_field( + name: str, + value: Any, + expected_types: str | Sequence[str], + allowed: Any, + prefix: str, +) -> str: + if isinstance(expected_types, str): + types = [expected_types] + else: + types = list(expected_types) + allowed_by_type = _allowed_by_type(allowed, len(types)) + errors = [ + _validate_one_type(name, value, expected_type, accepted, prefix) + for expected_type, accepted in zip(types, allowed_by_type) + ] + if any(not error for error in errors): + return "" + return "\nor ".join(errors) + + +def _allowed_by_type(allowed: Any, count: int) -> list[Any]: + if count == 1: + return [allowed] + if isinstance(allowed, (list, tuple)) and len(allowed) == count: + return list(allowed) + return [allowed] * count + + +def _validate_one_type(name: str, value: Any, expected_type: str, allowed: Any, prefix: str) -> str: + field_type = expected_type.lower() + if field_type in {"boolean", "integer", "real", "float"}: + if not _is_numeric(value): + return f"{prefix}error: argument '{name}' must be numeric" + array = np.asarray(value) + if field_type == "boolean" and np.any((array != 0) & (array != 1)): + return f"{prefix}error: argument '{name}' must be 0 or 1" + if field_type == "integer" and np.any(array != np.floor(array)): + return f"{prefix}error: argument '{name}' must contain integers" + if field_type == "integer" and not _is_empty(allowed) and array.size: + bounds = np.asarray(allowed).reshape(-1) + if np.any(array < bounds[0]) or np.any(array > bounds[-1]): + return f"{prefix}error: value out of range for argument '{name}'" + if field_type in {"real", "float"} and not _is_empty(allowed) and array.size: + bounds = np.asarray(allowed).reshape(-1) + if np.any(array < bounds[0]) or np.any(array > bounds[1]): + return f"{prefix}error: value out of range for argument '{name}'" + return "" + if field_type == "string": + if not isinstance(value, str) and not _is_empty(value): + return f"{prefix}error: argument '{name}' must be a string" + if not _is_empty(allowed): + choices = [str(item).lower() for item in np.asarray(allowed, dtype=object).reshape(-1)] + if str(value).lower() not in choices: + return f"{prefix}error: wrong value for argument '{name}'" + return "" + if field_type == "cell": + return "" if isinstance(value, (list, tuple)) else f"{prefix}error: argument '{name}' must be a cell array" + if field_type == "struct": + return "" if isinstance(value, Mapping) else f"{prefix}error: argument '{name}' must be a structure" + if field_type == "function_handle": + return "" if callable(value) else f"{prefix}error: argument '{name}' must be a function handle" + if field_type == "": + return "" + raise ValueError(f"finputcheck error: unrecognized type '{expected_type}'") + + +def _is_numeric(value: Any) -> bool: + if isinstance(value, (Real, np.number, bool)): + return True + if not isinstance(value, (list, tuple, np.ndarray)): + return False + return np.asarray(value).dtype.kind in "biufc" + + +def _is_empty(value: Any) -> bool: + if value is None: + return True + if isinstance(value, (str, bytes, Sequence, Mapping)): + return len(value) == 0 + return isinstance(value, np.ndarray) and value.size == 0 + + +def _with_residual( + result: dict[str, Any] | str, + residual: list[Any], + return_unrecognized: bool, +) -> dict[str, Any] | str | tuple[dict[str, Any] | str, list[Any]]: + return (result, residual) if return_unrecognized else result diff --git a/src/eegprep/functions/guifunc/inputdlg2.py b/src/eegprep/functions/guifunc/inputdlg2.py new file mode 100644 index 00000000..d419d5fe --- /dev/null +++ b/src/eegprep/functions/guifunc/inputdlg2.py @@ -0,0 +1,92 @@ +"""EEGLAB ``inputdlg2``-style text entry dialog.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from eegprep.functions.guifunc.inputgui import inputgui +from eegprep.functions.guifunc.spec import ControlSpec, DialogSpec + + +def inputdlg2( + prompt: str | Sequence[Any], + title: str, + numlines: int | Sequence[int] = 1, + defaultanswer: Sequence[str] | None = None, + funcname: str = "", + *, + renderer: Any | None = None, +) -> list[str]: + """Prompt for one or more text values and return answers in prompt order. + + An empty list means the user cancelled the dialog. ``numlines`` is accepted + for call compatibility; like EEGLAB's helper, EEGPrep renders one editable + value per prompt. + """ + spec = inputdlg2_dialog_spec(prompt, title, numlines, defaultanswer, funcname) + result = inputgui(spec, renderer=renderer) + if result is None: + return [] + return [str(result[f"answer{index}"]) for index in range(len(_prompts(prompt)))] + + +def inputdlg2_dialog_spec( + prompt: str | Sequence[Any], + title: str, + numlines: int | Sequence[int] = 1, + defaultanswer: Sequence[str] | None = None, + funcname: str = "", +) -> DialogSpec: + """Build the renderer-independent specification for ``inputdlg2``.""" + prompts = _prompts(prompt) + defaults = [""] * len(prompts) if defaultanswer is None else [str(item) for item in defaultanswer] + if len(prompts) != len(defaults): + raise ValueError("inputdlg2 prompts and default answers must have the same length") + _validate_numlines(numlines, len(prompts)) + + multiline = ["\n" in item for item in prompts] + controls: list[ControlSpec] = [] + geometry: list[tuple[float, ...]] = [] + geomvert: list[float] = [] + for index, (label, default, is_multiline) in enumerate(zip(prompts, defaults, multiline)): + controls.extend((ControlSpec("text", label), ControlSpec("edit", tag=f"answer{index}", value=default))) + if is_multiline: + geometry.extend(((1,), (1,))) + geomvert.extend((max(label.count("\n") + 1, 1), 1)) + else: + geometry.append((1, 0.6)) + + return DialogSpec( + title=title, + controls=tuple(controls), + geometry=tuple(geometry), + geomvert=tuple(geomvert) or None, + function_name=funcname or "inputdlg2", + eeglab_source="functions/guifunc/inputdlg2.m", + help_text=funcname or None, + show_help_button=bool(funcname), + ) + + +def _prompts(prompt: str | Sequence[Any]) -> list[str]: + raw = [prompt] if isinstance(prompt, str) else list(prompt) + return [_prompt_text(item) for item in raw] + + +def _prompt_text(prompt: Any) -> str: + if isinstance(prompt, str): + return prompt + if isinstance(prompt, Sequence): + return "\n".join(str(line) for line in prompt) + return str(prompt) + + +def _validate_numlines(numlines: int | Sequence[int], count: int) -> None: + if isinstance(numlines, int): + if numlines != 1: + raise ValueError("inputdlg2 supports one editable line per prompt") + return + values = [int(value) for value in numlines] + if len(values) != count or any(value != 1 for value in values): + raise ValueError("inputdlg2 supports one editable line per prompt") diff --git a/src/eegprep/functions/guifunc/listdlg2.py b/src/eegprep/functions/guifunc/listdlg2.py index 973c8ab2..f3f32ea7 100644 --- a/src/eegprep/functions/guifunc/listdlg2.py +++ b/src/eegprep/functions/guifunc/listdlg2.py @@ -32,7 +32,7 @@ def _require_qt() -> tuple[Any, Any]: def listdlg2( *, promptstring: str = "", - liststring: Sequence[str], + liststring: Sequence[str] | str, selectionmode: str = "multiple", initialvalue: Sequence[int] | None = None, listsize: tuple[int, int] | None = None, @@ -42,7 +42,8 @@ def listdlg2( """Open an EEGLAB-like list selector. Returns 1-based selected list positions, an OK flag, and the selected - display strings joined by spaces, matching EEGLAB ``listdlg2``. + display strings joined by spaces, matching EEGLAB ``listdlg2``. A scalar + string is one list item rather than a sequence of characters. """ qt_core, qt_widgets = _require_qt() @@ -68,7 +69,7 @@ def listdlg2( def build_listdlg2_dialog( *, promptstring: str = "", - liststring: Sequence[str], + liststring: Sequence[str] | str, selectionmode: str = "multiple", initialvalue: Sequence[int] | None = None, listsize: tuple[int, int] | None = None, @@ -98,14 +99,14 @@ def _create_dialog( QtWidgets: Any, *, promptstring: str, - liststring: Sequence[str], + liststring: Sequence[str] | str, selectionmode: str, initialvalue: Sequence[int] | None, listsize: tuple[int, int] | None, name: str, parent: Any | None, ) -> tuple[Any, Any, list[str]]: - list_items = [str(item) for item in liststring] + list_items = [liststring] if isinstance(liststring, str) else [str(item) for item in liststring] initial = _normalise_initial(initialvalue, len(list_items), selectionmode) dialog = QtWidgets.QDialog(parent) dialog.setObjectName("listdlg2") @@ -127,16 +128,16 @@ def _create_dialog( list_widget = QtWidgets.QListWidget(dialog) list_widget.setObjectName("listboxvals") - if selectionmode.lower() == "single" or len(list_items) == 1: + if selectionmode.lower() != "multiple" or len(list_items) == 1: list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) else: list_widget.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) for index, item_text in enumerate(list_items, start=1): item = QtWidgets.QListWidgetItem(item_text) item.setData(QtCore.Qt.UserRole, index) + list_widget.addItem(item) if index in initial: item.setSelected(True) - list_widget.addItem(item) list_widget.setGeometry(18, 64 if promptstring else 15, width - 36, visible_rows * 20 + 8) cancel = QtWidgets.QPushButton("Cancel", dialog) diff --git a/src/eegprep/functions/guifunc/session.py b/src/eegprep/functions/guifunc/session.py index 5ca1e093..cefdbdc2 100644 --- a/src/eegprep/functions/guifunc/session.py +++ b/src/eegprep/functions/guifunc/session.py @@ -459,9 +459,11 @@ def _append_current_dataset_history(self, command: str | None) -> None: if not command: return current = self.EEG if isinstance(self.EEG, list) else [self.EEG] - for eeg in current: - if isinstance(eeg, dict): - eegh(command, eeg) + if len(current) > 1: + eegh(command, current) + return + if current and isinstance(current[0], dict): + eegh(command, current[0]) def mark_current_saved(self) -> None: """Mark the current dataset selection as saved in EEG and ALLEEG.""" diff --git a/tests/conftest.py b/tests/conftest.py index e2d67a95..13c1d9bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -53,6 +53,7 @@ def _preload_matlab_libstdcxx() -> None: "tests/test_gui_long_task.py", "tests/test_gui_main_window.py", "tests/test_eegplot_gui.py", + "tests/test_guifunc_primitives.py", ) GUI_NODEID_PARTS = ("::test_gui_",) diff --git a/tests/test_console_workspace.py b/tests/test_console_workspace.py index 9067960e..1645f895 100644 --- a/tests/test_console_workspace.py +++ b/tests/test_console_workspace.py @@ -31,6 +31,7 @@ from eegprep.functions.guifunc.menu_actions import MenuActionDispatcher from eegprep.functions.guifunc.session import EEGPrepSession from eegprep.functions.popfunc.pop_newtimef import pop_newtimef +from tests.eeglab_tests import eeglab_test def _demo_eeg(setname: str = "demo"): @@ -461,6 +462,7 @@ def test_default_console_eegh_uses_session_history_after_public_exports_bind(): workspace.close() +@eeglab_test("unittesting_adminfunc/eegh/pass_execute_command.m", "test_pass_execute_command") def test_console_eegh_positive_index_replays_command_through_workspace(): session = EEGPrepSession() session.store_current(_demo_eeg(), new=True) diff --git a/tests/test_eeg_options.py b/tests/test_eeg_options.py new file mode 100644 index 00000000..6ef53e0e --- /dev/null +++ b/tests/test_eeg_options.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from importlib import resources + +import pytest + +from eegprep.functions.adminfunc.eeg_options import EEG_OPTIONS, EEGOptions +from eegprep.functions.adminfunc.eeg_readoptions import eeg_readoptions +from eegprep.functions.adminfunc.pop_editoptions import pop_editoptions +from tests.eeglab_tests import eeglab_test + + +@pytest.fixture(autouse=True) +def restore_eeg_options(): + original = dict(EEG_OPTIONS) + try: + yield + finally: + EEG_OPTIONS.clear() + EEG_OPTIONS.update(original) + + +@eeglab_test("unittesting_adminfunc/eeg_options/pass_general.m", "test_pass_general") +@eeglab_test("unittesting_adminfunc/eeg_optionsbackup/pass_general.m", "test_pass_general") +@eeglab_test("unittesting_adminfunc/eeglab_options/pass_general.m", "test_pass_general") +def test_default_options_expose_the_processing_and_storage_contract(): + defaults = EEGOptions().to_dict() + + assert EEG_OPTIONS == defaults + assert EEG_OPTIONS["option_storedisk"] == 0 + assert EEG_OPTIONS["option_memmapdata"] == 0 + assert EEG_OPTIONS["option_single"] == 1 + assert EEG_OPTIONS["option_computeica"] == 1 + + +@eeglab_test("unittesting_adminfunc/pop_editoptions/test_pop_editoptions.m", "test_test_pop_editoptions") +def test_pop_editoptions_updates_known_options_and_returns_history_command(): + command = pop_editoptions(option_computeica=0, option_storedisk=1) + + assert EEG_OPTIONS["option_computeica"] == 0 + assert EEG_OPTIONS["option_storedisk"] == 1 + assert command == "LASTCOM = pop_editoptions();" + + +def test_pop_editoptions_rejects_unknown_options_without_partial_mutation(): + original = dict(EEG_OPTIONS) + + with pytest.raises(KeyError, match="Unknown EEGPrep option"): + pop_editoptions(option_does_not_exist=1) + + assert EEG_OPTIONS == original + + +@eeglab_test("unittesting_adminfunc/eeg_readoptions/pass_general.m", "test_pass_general") +def test_eeg_readoptions_parses_packaged_matlab_option_template(): + option_file = resources.files("eegprep.resources").joinpath("eeg_options_64bit.m") + + header, options = eeg_readoptions(option_file) + + values = {option["varname"]: option["value"] for option in options} + assert "Do not edit or remove this file" in header + assert values["option_storedisk"] == 0 + assert values["option_single"] == 0 + assert values["option_cachesize"] == 500 + assert all(set(option) == {"varname", "value", "description"} for option in options) + + +@eeglab_test("unittesting_adminfunc/eeg_readoptions/pass_backup.m", "test_pass_backup") +def test_eeg_readoptions_fills_requested_backup_records_only(): + option_file = resources.files("eegprep.resources").joinpath("eeg_options_64bit.m") + backup = [ + {"varname": "option_storedisk", "description": "stored", "value": None}, + {"varname": "option_rememberfolder", "description": "folder", "value": None}, + ] + + _header, options = eeg_readoptions(option_file, backup) + + assert options == [ + {"varname": "option_storedisk", "description": "stored", "value": 0}, + {"varname": "option_rememberfolder", "description": "folder", "value": 1}, + ] diff --git a/tests/test_eeg_retrieve.py b/tests/test_eeg_retrieve.py index 69abe5a0..e23a2fe3 100644 --- a/tests/test_eeg_retrieve.py +++ b/tests/test_eeg_retrieve.py @@ -3,6 +3,7 @@ from eegprep.functions.adminfunc.eeg_retrieve import eeg_retrieve from eegprep.functions.popfunc.eeg_emptyset import eeg_emptyset +from tests.eeglab_tests import eeglab_test def _eeg(*, name: str = "demo") -> dict: @@ -27,21 +28,25 @@ def _eeg(*, name: str = "demo") -> dict: return eeg +@eeglab_test("unittesting_adminfunc/eeg_retrieve/pass_general.m", "test_pass_general") def test_eeg_retrieve_returns_deepcopy_and_one_based_index(): - source = _eeg(name="source") - selected, alleeg, current = eeg_retrieve([source], 1) + first = _eeg(name="first") + second = _eeg(name="second") + selected, alleeg, current = eeg_retrieve([first, second], 2) selected["setname"] = "changed" - assert current == 1 - assert alleeg[0]["setname"] == "source" + assert current == 2 + assert alleeg[0]["setname"] == "first" + assert alleeg[1]["setname"] == "second" +@eeglab_test("unittesting_adminfunc/eeg_retrieve/pass_multiple.m", "test_pass_multiple") def test_eeg_retrieve_handles_multiple_indices_and_empty_slots(): - selected, _alleeg, current = eeg_retrieve([_eeg(name="first"), {}, _eeg(name="third")], [1, 2, 3]) + selected, _alleeg, current = eeg_retrieve([_eeg(name="first"), {}, _eeg(name="third")], [3, 2, 1]) - assert current == [1, 2, 3] - assert [eeg["setname"] for eeg in selected] == ["first", "", "third"] + assert current == [3, 2, 1] + assert [eeg["setname"] for eeg in selected] == ["third", "", "first"] assert selected[1]["ref"] == "common" @@ -52,13 +57,34 @@ def test_eeg_retrieve_accepts_tuple_indices(): assert [eeg["setname"] for eeg in selected] == ["second"] -def test_eeg_retrieve_rejects_zero_and_missing_indices(): +@eeglab_test("unittesting_adminfunc/eeg_retrieve/fail_outside.m", "test_fail_outside") +def test_eeg_retrieve_rejects_negative_and_missing_indices(): with pytest.raises(ValueError, match="1-based"): - eeg_retrieve([_eeg()], 0) + eeg_retrieve([_eeg()], -1) with pytest.raises(IndexError, match="No dataset"): eeg_retrieve([_eeg()], 2) +@eeglab_test("unittesting_adminfunc/eeg_retrieve/pass_zero.m", "test_pass_zero") +def test_eeg_retrieve_zero_returns_empty_dataset_without_changing_alleeg(): + source = [_eeg(name="first"), _eeg(name="second")] + + selected, alleeg, current = eeg_retrieve(source, 0) + + assert current == 0 + assert selected.keys() == eeg_emptyset().keys() + assert selected["setname"] == "" + assert selected["nbchan"] == 0 + assert np.asarray(selected["data"]).size == 0 + assert [eeg["setname"] for eeg in alleeg] == ["first", "second"] + + +@eeglab_test("unittesting_adminfunc/eeg_retrieve/fail_no_arg.m", "test_fail_no_arg") +def test_eeg_retrieve_requires_a_dataset_index(): + with pytest.raises(TypeError): + eeg_retrieve([_eeg()]) + + def test_eeg_retrieve_leaves_a_deleted_slot_empty(): # Retrieving a deleted slot yields an empty EEG, but must not turn the slot into a dataset. alleeg = [_eeg(name="first"), {}, _eeg(name="third")] diff --git a/tests/test_eeg_store.py b/tests/test_eeg_store.py index 8c7ffb98..4c2564b6 100644 --- a/tests/test_eeg_store.py +++ b/tests/test_eeg_store.py @@ -3,6 +3,7 @@ from eegprep.functions.adminfunc.eeg_store import eeg_store from eegprep.functions.popfunc.eeg_emptyset import eeg_emptyset +from tests.eeglab_tests import eeglab_test def _eeg(*, name: str = "demo", saved: str = "no") -> dict: @@ -33,22 +34,24 @@ def _eeg(*, name: str = "demo", saved: str = "no") -> dict: return eeg +@eeglab_test("unittesting_adminfunc/eeg_store/pass_general.m", "test_pass_general") def test_eeg_store_appends_modified_dataset_as_unsaved(): - alleeg, checked, index = eeg_store([], _eeg(saved="no"), 0) + alleeg, checked, index = eeg_store([_eeg(name="first")], _eeg(name="second", saved="no")) - assert index == 1 + assert index == 2 assert checked["saved"] == "no" - assert alleeg[0]["saved"] == "no" + assert [dataset["setname"] for dataset in alleeg] == ["first", "second"] +@eeglab_test("unittesting_adminfunc/eeg_store/pass_multiple.m", "test_pass_multiple") def test_eeg_store_fills_lowest_empty_slot(): # EEGLAB eeg_store puts a new dataset into the first slot whose data is empty. alleeg = [_eeg(name="first"), {}, _eeg(name="third")] - alleeg, _checked, index = eeg_store(alleeg, _eeg(name="second"), 0) + alleeg, _checked, index = eeg_store(alleeg, [_eeg(name="second"), _eeg(name="fourth")]) - assert index == 2 - assert [eeg["setname"] for eeg in alleeg] == ["first", "second", "third"] + assert index == [2, 4] + assert [eeg["setname"] for eeg in alleeg] == ["first", "second", "third", "fourth"] def test_eeg_store_preserves_justloaded_dataset_as_saved(): @@ -67,6 +70,7 @@ def test_eeg_store_marks_saved_dataset_unsaved_without_justloaded_marker(): assert alleeg[0]["saved"] == "no" +@eeglab_test("unittesting_adminfunc/eeg_store/pass_new.m", "test_pass_new") def test_eeg_store_handles_multiple_eeg_inputs_with_one_based_indices(): alleeg, current, indices = eeg_store([], [_eeg(name="first"), _eeg(name="second")], [0, 0]) @@ -75,6 +79,18 @@ def test_eeg_store_handles_multiple_eeg_inputs_with_one_based_indices(): assert [eeg["setname"] for eeg in alleeg] == ["first", "second"] +@eeglab_test("unittesting_adminfunc/eeg_store/pass_multiple_new.m", "test_pass_multiple_new") +def test_eeg_store_appends_three_datasets_to_existing_collection(): + alleeg, current, indices = eeg_store( + [_eeg(name="first")], + [_eeg(name="second"), _eeg(name="third"), _eeg(name="fourth")], + ) + + assert indices == [2, 3, 4] + assert [eeg["setname"] for eeg in current] == ["second", "third", "fourth"] + assert [eeg["setname"] for eeg in alleeg] == ["first", "second", "third", "fourth"] + + def test_eeg_store_replaces_existing_one_based_slot(): existing = [_eeg(name="first", saved="yes"), _eeg(name="second", saved="yes")] @@ -94,11 +110,31 @@ def test_eeg_store_appends_when_index_omitted_or_none(): assert alleeg[0]["setname"] == "first" +@eeglab_test("unittesting_adminfunc/eeg_store/fail_num_index.m", "test_fail_num_index") def test_eeg_store_rejects_mismatched_multiple_indices(): with pytest.raises(ValueError, match="Length of EEG list"): eeg_store([], [_eeg(name="first"), _eeg(name="second")], [1]) +@eeglab_test("unittesting_adminfunc/eeg_store/fail_negative_index.m", "test_fail_negative_index") def test_eeg_store_rejects_non_positive_explicit_index(): with pytest.raises(ValueError, match="1-based"): eeg_store([], _eeg(), -1) + + +@eeglab_test("unittesting_adminfunc/eeg_store/fail_no_arg.m", "test_fail_no_arg") +def test_eeg_store_requires_a_dataset(): + with pytest.raises(TypeError): + eeg_store([]) + + +@eeglab_test("unittesting_adminfunc/eeg_store/pass_bugzilla_17.m", "test_pass_bugzilla_17") +def test_eeg_store_has_no_legacy_two_hundred_dataset_limit(): + alleeg = [] + eeg = _eeg() + + for _ in range(202): + alleeg, eeg, current = eeg_store(alleeg, eeg) + + assert len(alleeg) == 202 + assert current == 202 diff --git a/tests/test_eegh.py b/tests/test_eegh.py index 4e4e552d..f79565bf 100644 --- a/tests/test_eegh.py +++ b/tests/test_eegh.py @@ -1,6 +1,11 @@ -from eegprep.functions.adminfunc.eegh import eegh +import pytest +from eegprep.functions.adminfunc.eegh import eegh, eegh_find +from tests.eeglab_tests import eeglab_test + +@eeglab_test("unittesting_adminfunc/eegh/pass_new_command.m", "test_pass_new_command") +@eeglab_test("unittesting_adminfunc/eegh/pass_empty_command.m", "test_pass_empty_command") def test_eegh_records_only_non_empty_commands(): history = [] @@ -10,6 +15,7 @@ def test_eegh_records_only_non_empty_commands(): assert history == ["EEG = pop_reref(EEG);"] +@eeglab_test("unittesting_adminfunc/eegh/pass_return_commands.m", "test_pass_return_commands") def test_eegh_displays_finds_removes_and_clears_newest_first(): history = [] eegh("EEG = pop_loadset('sample.set');", history) @@ -30,6 +36,7 @@ def test_eegh_displays_finds_removes_and_clears_newest_first(): assert history == [] +@eeglab_test("unittesting_adminfunc/eegh/pass_add_history.m", "test_pass_add_history") def test_eegh_command_appends_to_eeg_history(): eeg = {"history": "EEG = pop_loadset('sample.set');"} @@ -41,6 +48,15 @@ def test_eegh_command_appends_to_eeg_history(): ] +@eeglab_test("unittesting_adminfunc/eeg_hist/pass_general.m", "test_pass_general") +def test_eegh_initializes_empty_dataset_history_with_the_command(): + eeg = {} + + eegh("a command", eeg) + + assert eeg["history"] == "a command;" + + def test_eegh_eeg_history_dedup_compares_last_line_exactly(): eeg = {"history": "AEEG = pop_reref(EEG);"} @@ -48,3 +64,60 @@ def test_eegh_eeg_history_dedup_compares_last_line_exactly(): eegh("EEG = pop_reref(EEG);", eeg) assert eeg["history"].splitlines() == ["AEEG = pop_reref(EEG);", "EEG = pop_reref(EEG);"] + + +@eeglab_test("unittesting_adminfunc/eegh/pass_insert_command.m", "test_pass_insert_command") +def test_eegh_inserts_a_new_command_after_existing_history(): + history = ["command1", "command2"] + + eegh("command3", history) + + assert history == ["command1", "command2", "command3"] + + +@eeglab_test("unittesting_adminfunc/eegh/pass_find_command.m", "test_pass_find_command") +@eeglab_test("unittesting_adminfunc/eegh/pass_find_command2.m", "test_pass_find_command2") +def test_eegh_find_returns_most_recent_match_or_empty(): + history = ["command1", "command2", "command3", "comm4"] + + assert eegh_find(history, "comma") == "command3" + assert eegh_find(history, "command4") == "" + + +@eeglab_test("unittesting_adminfunc/eegh/pass_unstack_command.m", "test_pass_unstack_command") +def test_eegh_removes_the_requested_number_of_recent_commands(): + history = ["command1", "command2", "command3"] + + assert eegh(-2, history) == "" + assert history == ["command1"] + + +@eeglab_test("unittesting_adminfunc/eegh/pass_delete_commands.m", "test_pass_delete_commands") +def test_eegh_zero_clears_command_history(): + history = ["command1", "command2"] + + assert eegh(0, history) == "" + assert history == [] + + +@eeglab_test("unittesting_adminfunc/eegh/fail_get_empty.m", "test_fail_get_empty") +def test_eegh_selecting_from_empty_history_returns_empty(): + assert eegh(1, []) == "" + + +@eeglab_test("unittesting_adminfunc/eegh/pass_add_multiple_history.m", "test_pass_add_multiple_history") +@eeglab_test("unittesting_adminfunc/eegh/pass_add_multiple_history2.m", "test_pass_add_multiple_history2") +@pytest.mark.parametrize("include_history", [False, True]) +def test_eegh_marks_commands_applied_to_multiple_datasets(include_history): + datasets = [{"data": [1]}, {"data": [2]}] + if include_history: + for eeg in datasets: + eeg["history"] = "" + + assert eegh("command3", datasets) == "command3" + + assert [eeg["data"] for eeg in datasets] == [[1], [2]] + assert [eeg["history"] for eeg in datasets] == [ + "% multiple datasets command: command3;", + "% multiple datasets command: command3;", + ] diff --git a/tests/test_eegobj.py b/tests/test_eegobj.py index c5d125d7..b95f5f3a 100644 --- a/tests/test_eegobj.py +++ b/tests/test_eegobj.py @@ -3,8 +3,10 @@ import os import tempfile import shutil +import copy from eegprep.functions.eegobj.eegobj import EEGobj from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset +from tests.eeglab_tests import eeglab_test # Helper function to create a dummy EEG dictionary @@ -42,6 +44,24 @@ def tearDown(self): if os.path.exists(self.temp_dir): shutil.rmtree(self.temp_dir) + @eeglab_test("unittesting_adminfunc/eegobj/eegobj_simpletests.m", "test_eegobj_simpletests") + def test_collection_assignment_and_field_mutation_use_python_list_semantics(self): + first = EEGobj(create_test_eeg(n_channels=2, n_samples=3)) + datasets = [first] + + datasets.append(EEGobj(copy.deepcopy(first.EEG))) + datasets.extend(EEGobj(copy.deepcopy(first.EEG)) for _ in range(2)) + del datasets[:3] + datasets.extend(EEGobj(copy.deepcopy(first.EEG)) for _ in range(3)) + datasets[1].filename = "test" + datasets[0].chanlocs[0]["labels"] = "E1" + datasets.append(EEGobj(copy.deepcopy(datasets[0].EEG))) + datasets.pop() + + self.assertEqual(len(datasets), 4) + self.assertEqual(datasets[1].filename, "test") + self.assertEqual(datasets[0].chanlocs[0]["labels"], "E1") + def test_init_from_dict_and_repr(self): eeg = create_test_eeg() obj = EEGobj(eeg) diff --git a/tests/test_finputcheck.py b/tests/test_finputcheck.py new file mode 100644 index 00000000..b9781ed1 --- /dev/null +++ b/tests/test_finputcheck.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import math + +import pytest + +from eegprep.functions.guifunc.finputcheck import finputcheck +from tests.eeglab_tests import eeglab_test + + +RULES = ( + ("the2test", "string", [], "bar"), + ("key1", "integer", [1, 5], 2), + ("p3rcent", "real", [0, 1], 1 / math.sqrt(2)), + ("forth", "cell", [], []), +) + + +@eeglab_test("unittesting_guifunc/finputcheck/fail_no_arg.m", "test_fail_no_arg") +def test_finputcheck_requires_arguments_and_field_rules(): + with pytest.raises(TypeError): + finputcheck() + + +@eeglab_test("unittesting_guifunc/finputcheck/fail_no_key_val.m", "test_fail_no_key_val") +def test_finputcheck_reports_an_incomplete_key_value_sequence(): + result = finputcheck(["key1", 3, "the2test"], RULES) + + assert result == "error: bad 'key', 'val' sequence" + + +@eeglab_test("regression_tests/t_finputcheck.m", "test_1") +@eeglab_test("unittesting_guifunc/finputcheck/pass_empty.m", "test_pass_empty") +def test_finputcheck_uses_defaults_for_an_empty_argument_list(): + rules = (*RULES[:3], ("forth", "cell", [], ["test", 4])) + + result = finputcheck([], rules) + + assert result == { + "the2test": "bar", + "key1": 2, + "p3rcent": 1 / math.sqrt(2), + "forth": ["test", 4], + } + + +@eeglab_test("regression_tests/t_finputcheck.m", "test_2") +@eeglab_test("unittesting_guifunc/finputcheck/pass_general.m", "test_pass_general") +def test_finputcheck_validates_all_supported_rule_values(): + arguments = ["key1", 3, "the2test", "foo", "p3rcent", 0.4937, "forth", ["a", 2, "11b", "D"]] + + result = finputcheck(arguments, RULES) + + assert result == {"key1": 3, "the2test": "foo", "p3rcent": 0.4937, "forth": ["a", 2, "11b", "D"]} + + +@eeglab_test("regression_tests/t_finputcheck.m", "test_3") +@eeglab_test("unittesting_guifunc/finputcheck/pass_multiple_types.m", "test_pass_multiple_types") +def test_finputcheck_accepts_a_value_matching_any_declared_type(): + rules = ( + RULES[0], + ("key1", ("integer", "string"), ([1, 5], ["test"]), 2), + *RULES[2:], + ) + arguments = ["key1", "test", "the2test", "foo", "p3rcent", 0.4937, "forth", ["a", 2, "11b", "D"]] + + result = finputcheck(arguments, rules) + + assert result == {"key1": "test", "the2test": "foo", "p3rcent": 0.4937, "forth": ["a", 2, "11b", "D"]} + + +@eeglab_test("regression_tests/t_finputcheck.m", "test_4") +@eeglab_test("unittesting_guifunc/finputcheck/pass_standard.m", "test_pass_standard") +def test_finputcheck_fills_only_omitted_values_from_defaults(): + rules = (*RULES[:3], ("forth", "cell", [], ["w", 8, "y", 2])) + + result = finputcheck(["key1", 3, "the2test", "foo"], rules) + + assert result == { + "key1": 3, + "the2test": "foo", + "p3rcent": 1 / math.sqrt(2), + "forth": ["w", 8, "y", 2], + } + + +@eeglab_test("regression_tests/t_finputcheck.m", "test_5") +@eeglab_test("unittesting_guifunc/finputcheck/pass_strings.m", "test_pass_strings") +def test_finputcheck_accepts_declared_string_choices_case_insensitively(): + rules = (("the2test", "string", ["foo", "bar"], "baz"), *RULES[1:]) + arguments = ["key1", 3, "the2test", "foo", "p3rcent", 0.4937, "forth", ["a", 2, "11b", "D"]] + + result = finputcheck(arguments, rules) + + assert result == {"key1": 3, "the2test": "foo", "p3rcent": 0.4937, "forth": ["a", 2, "11b", "D"]} + + +@eeglab_test("regression_tests/t_finputcheck.m", "test_6") +@eeglab_test("unittesting_guifunc/finputcheck/pass_unknown.m", "test_pass_unknown") +def test_finputcheck_can_return_unrecognized_arguments_in_ignore_mode(): + arguments = [ + "key1", + 3, + "the2test", + "foo", + "p3rcent", + 0.4937, + "forth", + ["a", 2, "11b", "D"], + "invisible", + "true", + ] + + result, residual = finputcheck( + arguments, + RULES, + "testfunction", + "ignore", + return_unrecognized=True, + ) + + assert result == { + "key1": 3, + "the2test": "foo", + "p3rcent": 0.4937, + "forth": ["a", 2, "11b", "D"], + "invisible": "true", + } + assert residual == ["invisible", "true"] + + +def test_finputcheck_returns_eeglab_error_strings_for_invalid_values(): + assert finputcheck(["p3rcent", 2], RULES) == "error: value out of range for argument 'p3rcent'" + assert finputcheck(["the2test", 2], RULES) == "error: argument 'the2test' must be a string" + assert finputcheck(["unknown", 1], RULES) == "error: undefined argument 'unknown'" + + +def test_finputcheck_keeps_the_last_duplicate_value(caplog): + caplog.set_level("INFO") + + result = finputcheck(["key1", 2, "key1", 3], RULES) + + assert result["key1"] == 3 + assert "keeping the last" in caplog.text diff --git a/tests/test_gethelpvar.py b/tests/test_gethelpvar.py new file mode 100644 index 00000000..1f9e4f64 --- /dev/null +++ b/tests/test_gethelpvar.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from eegprep.functions.adminfunc.gethelpvar import gethelpvar +from tests.eeglab_tests import eeglab_test + + +HELP_HEADER = """% test_eeglab() - tests eeglab +% +% Usage: +% >> test_eeglab(functions, silent) +% +% Inputs: +% functions - function names to test (without extension '.m') +% silent - 'noninteractive' for silent run +% without user interaction +% +% Outputs: +% report - new report in ./reports directory +% +% Notes: see eeglab/functions directory for function names +function pass_general() +""" + + +@pytest.fixture +def help_file(tmp_path: Path) -> Path: + filename = tmp_path / "pass_general.m" + filename.write_text(HELP_HEADER, encoding="utf-8") + return filename + + +@eeglab_test("unittesting_adminfunc/gethelpvar/fail_no_arg.m", "test_fail_no_arg") +def test_gethelpvar_requires_a_filename(): + with pytest.raises(TypeError): + gethelpvar() + + +@eeglab_test("unittesting_adminfunc/gethelpvar/fail_no_file.m", "test_fail_no_file") +def test_gethelpvar_rejects_a_missing_file(tmp_path: Path): + with pytest.raises(FileNotFoundError): + gethelpvar(tmp_path / "DOES_NOT_EXIST.m", ["report"]) + + +@eeglab_test("unittesting_adminfunc/gethelpvar/pass_all.m", "test_pass_all") +def test_gethelpvar_returns_all_documented_variables(help_file: Path): + descriptions, names = gethelpvar(help_file) + + assert names == ["functions", "silent", "report"] + assert descriptions == [ + "function names to test (without extension '.m')", + "'noninteractive' for silent run\nwithout user interaction", + "new report in ./reports directory", + ] + + +@eeglab_test("unittesting_adminfunc/gethelpvar/pass_general.m", "test_pass_general") +def test_gethelpvar_returns_one_requested_description_and_all_names(help_file: Path): + descriptions, names = gethelpvar(help_file, "functions") + + assert descriptions == ["function names to test (without extension '.m')"] + assert names == ["functions", "silent", "report"] + + +@eeglab_test("unittesting_adminfunc/gethelpvar/pass_no_var.m", "test_pass_no_var") +def test_gethelpvar_returns_empty_text_for_an_unknown_variable(help_file: Path, caplog): + descriptions, names = gethelpvar(help_file, ["report", "DOES_NOT_EXIST"]) + + assert descriptions == ["new report in ./reports directory", ""] + assert names == ["functions", "silent", "report"] + assert "DOES_NOT_EXIST" in caplog.text + + +@eeglab_test("unittesting_adminfunc/gethelpvar/pass_some.m", "test_pass_some") +def test_gethelpvar_preserves_requested_variable_order(help_file: Path): + descriptions, names = gethelpvar(help_file, ["report", "silent"]) + + assert descriptions == [ + "new report in ./reports directory", + "'noninteractive' for silent run\nwithout user interaction", + ] + assert names == ["functions", "silent", "report"] diff --git a/tests/test_getkeyval.py b/tests/test_getkeyval.py new file mode 100644 index 00000000..b9b91d1a --- /dev/null +++ b/tests/test_getkeyval.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import pytest + +from eegprep.functions.adminfunc.getkeyval import getkeyval +from tests.eeglab_tests import eeglab_test + + +COMMAND = "testfunction('key', 'val', 'foo', 'bar', 'eeglab', 'test');" +ARRAY_COMMAND = "testfunction('key', [3 1 4 1 6], 'foo', 'bar', 'eeglab', 'test');" + + +@eeglab_test("unittesting_adminfunc/getkeyval/fail_no_arg.m", "test_fail_no_arg") +def test_getkeyval_requires_a_command_and_variable(): + with pytest.raises(TypeError): + getkeyval() + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_general.m", "test_pass_general") +def test_getkeyval_returns_a_named_string_value_without_quotes(): + assert getkeyval(COMMAND, "eeglab") == "test" + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_numeric.m", "test_pass_numeric") +def test_getkeyval_returns_a_named_numeric_literal(): + command = "testfunction('key', 'val', 'foo', 13, 'eeglab', 'test');" + + assert getkeyval(command, "foo") == "13" + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_default.m", "test_pass_default") +@eeglab_test("unittesting_adminfunc/getkeyval/pass_empty.m", "test_pass_empty") +def test_getkeyval_returns_the_default_for_empty_or_missing_values(): + assert getkeyval(COMMAND, "DOES_NOT_EXIST", "", "standard value") == "standard value" + assert getkeyval("", "eeglab", "", "standard value") == "standard value" + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_present.m", "test_pass_present") +@eeglab_test("unittesting_adminfunc/getkeyval/pass_not_present.m", "test_pass_not_present") +def test_getkeyval_reports_whether_a_named_key_is_present(): + assert getkeyval(COMMAND, "eeglab", "present") == 1 + assert getkeyval(COMMAND, "DOES_NOT_EXIST", "present") == 0 + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_full.m", "test_pass_full") +def test_getkeyval_can_return_the_full_key_value_fragment(): + assert getkeyval(COMMAND, "eeglab", "full") == "'eeglab', 'test'" + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_full_parent.m", "test_pass_full_parent") +def test_getkeyval_full_mode_handles_a_key_named_inside_a_parent_cell(): + command = "testfunction({'bar', 'eeglab', 'test'}, 'key', 'val', 'foo');" + + assert getkeyval(command, "eeglab", "full") == "'eeglab', 'key'" + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_array.m", "test_pass_array") +def test_getkeyval_selects_a_one_based_numeric_vector_element(): + assert getkeyval(ARRAY_COMMAND, "key", 3) == "4" + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_array_default.m", "test_pass_array_default") +def test_getkeyval_uses_default_for_an_out_of_range_vector_element(): + assert getkeyval(ARRAY_COMMAND, "key", 7, "standard value") == "standard value" + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_multi_array.m", "test_pass_multi_array") +def test_getkeyval_uses_the_first_available_requested_vector_element(): + assert getkeyval(ARRAY_COMMAND, "key", [3, 7]) == "4" + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_num_key.m", "test_pass_num_key") +def test_getkeyval_accepts_a_one_based_argument_position(): + assert getkeyval(COMMAND, 3) == "'foo'" + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_num_key_array.m", "test_pass_num_key_array") +def test_getkeyval_preserves_a_positional_matrix_expression(): + command = "testfunction('key', 'val', 'foo', [['foo'];['bar']], 'eeglab', 'test');" + + assert getkeyval(command, 4) == "[['foo'];['bar']]" + + +@eeglab_test("unittesting_adminfunc/getkeyval/pass_num_key_default.m", "test_pass_num_key_default") +def test_getkeyval_uses_default_for_an_out_of_range_argument_position(): + assert getkeyval(COMMAND, 8, "", "standard value") == "standard value" diff --git a/tests/test_gui_rejection_dialogs.py b/tests/test_gui_rejection_dialogs.py index f9726444..2e2ee9bc 100644 --- a/tests/test_gui_rejection_dialogs.py +++ b/tests/test_gui_rejection_dialogs.py @@ -18,6 +18,7 @@ from eegprep.functions.popfunc.pop_rejtrend import pop_rejtrend_dialog_spec from eegprep.functions.popfunc.pop_selectcomps import pop_selectcomps_dialog_spec from eegprep.plugins.ICLabel.pop_viewprops import pop_viewprops, pop_viewprops_dialog_spec +from tests.eeglab_tests import eeglab_test from tests.fixtures import create_test_eeg @@ -37,6 +38,20 @@ def _epoched_ica_eeg(): class RejectionDialogTests(unittest.TestCase): + @eeglab_test("unittesting_adminfunc/pop_rejmenu/test_pop_rejmenu.m", "test_test_pop_rejmenu") + def test_rejection_menu_builds_data_and_component_dialogs(self): + eeg = _epoched_ica_eeg() + + data_spec = pop_rejmenu_dialog_spec(eeg, 1) + component_spec = pop_rejmenu_dialog_spec(eeg, 0) + + self.assertEqual(data_spec.title, "Reject trials using data statistics - pop_rejmenu()") + self.assertEqual(component_spec.title, "Reject trials using component activity statistics - pop_rejmenu()") + self.assertEqual(controls_by_tag(data_spec)["threshelec"].value, "1:3") + self.assertEqual(controls_by_tag(component_spec)["threshelec"].value, "1:3") + self.assertEqual(controls_by_tag(data_spec)["scrollmanual"].string, "Scroll Data") + self.assertEqual(controls_by_tag(component_spec)["scrollmanual"].string, "Scroll Acts.") + def test_dialog_specs_keep_eeglab_source_and_key_defaults(self): eeg = _epoched_ica_eeg() specs = [ diff --git a/tests/test_guifunc_pophelp_chansel.py b/tests/test_guifunc_pophelp_chansel.py index cc12d8eb..6455b97a 100644 --- a/tests/test_guifunc_pophelp_chansel.py +++ b/tests/test_guifunc_pophelp_chansel.py @@ -16,6 +16,7 @@ pop_chansel_selected_string, ) from eegprep.functions.popfunc.pop_reref import pop_reref_dialog_spec +from tests.eeglab_tests import eeglab_test REPO_ROOT = Path(__file__).resolve().parents[1] @@ -38,6 +39,15 @@ def test_pophelp_accepts_pophelp_expression(self): self.assertIn("resources/help", Path(source_path).as_posix()) self.assertTrue(source_path.endswith("pop_reref.md")) + @eeglab_test("unittesting_guifunc/pophelp/test_pophelp.m", "test_test_pophelp") + def test_pophelp_accepts_function_name_with_or_without_matlab_suffix(self): + plain_text, plain_source = pophelp_text("pop_editoptions") + matlab_text, matlab_source = pophelp_text("pop_editoptions.m") + + self.assertEqual(matlab_text, plain_text) + self.assertEqual(matlab_source, plain_source) + self.assertIn("POP_EDITOPTIONS", plain_text.upper()) + def test_pophelp_reads_pop_interp_packaged_resource(self): text, source_path = pophelp_text("pop_interp") @@ -76,11 +86,29 @@ def test_dialog_help_targets_have_packaged_resources(self): self.assertIn(spec.function_name.upper(), text) self.assertIn("resources/help", Path(source_path).as_posix()) + @eeglab_test("unittesting_adminfunc/eeg_helpadmin/test_eeg_helpadmin.m", "test_test_eeg_helpadmin") + @eeglab_test("unittesting_adminfunc/eeg_helphelp/pass_general.m", "test_pass_general") + @eeglab_test("unittesting_adminfunc/eeg_helpmenu/pass_general.m", "test_pass_general") + @eeglab_test("unittesting_adminfunc/eeg_helppop/pass_general.m", "test_pass_general") + @eeglab_test("unittesting_adminfunc/eeg_helpsigproc/pass_general.m", "test_pass_general") + @eeglab_test("unittesting_adminfunc/eeg_helpstudy/pass_general.m", "test_pass_general") def test_help_resources_are_packaged_importlib_resources(self): help_files = resources.files("eegprep.resources.help") + for function_name in ( + "eeg_helpadmin", + "eeg_helphelp", + "eeg_helpmenu", + "eeg_helppop", + "eeg_helpsigproc", + "eeg_helpstudy", + ): + with self.subTest(function_name=function_name): + text, source_path = pophelp_text(function_name) + self.assertTrue(help_files.joinpath(f"{function_name}.md").is_file()) + self.assertTrue(text.strip()) + self.assertTrue(source_path.endswith(f"{function_name}.md")) self.assertTrue(help_files.joinpath("eegprep.md").is_file()) - self.assertTrue(help_files.joinpath("eeg_helpadmin.md").is_file()) self.assertIn("EEGPrep", help_files.joinpath("eegprep.md").read_text(encoding="utf-8")) def test_help_resources_are_declared_as_package_data(self): diff --git a/tests/test_guifunc_primitives.py b/tests/test_guifunc_primitives.py new file mode 100644 index 00000000..061e13b3 --- /dev/null +++ b/tests/test_guifunc_primitives.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import os + +import pytest + +from eegprep.functions.guifunc.errordlg2 import build_errordlg2 +from eegprep.functions.guifunc.inputdlg2 import inputdlg2, inputdlg2_dialog_spec +from eegprep.functions.guifunc.inputgui import inputgui +from eegprep.functions.guifunc.listdlg2 import build_listdlg2_dialog, listdlg2 +from eegprep.functions.guifunc.spec import ControlSpec, DialogSpec +from tests.eeglab_tests import eeglab_test + + +def _spec(*, geomvert: tuple[float, ...] | None = None, help_text: str | None = None) -> DialogSpec: + return DialogSpec( + title="MyGUI", + controls=( + ControlSpec("radiobutton", "you have the choice", tag="choice", value=False), + ControlSpec("pushbutton", "pushme"), + ControlSpec("edit", tag="entry", value="PLEASE PRESS [Ok]"), + ), + geometry=((1, 1), (1,)), + geomvert=geomvert, + function_name="test_inputgui", + eeglab_source="functions/guifunc/inputgui.m", + help_text=help_text, + ) + + +class RecordingRenderer: + def __init__(self, result=None): + self.result = result + self.calls = [] + + def run(self, spec, initial_values=None): + self.calls.append((spec, initial_values)) + return self.result + + +@eeglab_test("unittesting_guifunc/inputgui/fail_no_arg.m", "test_fail_no_arg") +def test_inputgui_requires_a_dialog_spec(): + with pytest.raises(TypeError): + inputgui() + + +@eeglab_test("unittesting_guifunc/inputgui/i_pass_general.m", "test_i_pass_general") +def test_inputgui_returns_tagged_values_from_renderer(): + renderer = RecordingRenderer({"choice": True, "entry": "accepted"}) + spec = _spec() + + result = inputgui(spec, initial_values={"entry": "initial"}, renderer=renderer) + + assert result == {"choice": True, "entry": "accepted"} + assert renderer.calls == [(spec, {"entry": "initial"})] + + +@eeglab_test("unittesting_guifunc/inputgui/i_pass_geomvert.m", "test_i_pass_geomvert") +def test_inputgui_preserves_explicit_vertical_geometry(): + renderer = RecordingRenderer({}) + spec = _spec(geomvert=(4, 1)) + + inputgui(spec, renderer=renderer) + + assert renderer.calls[0][0].geomvert == (4, 1) + + +@eeglab_test("unittesting_guifunc/inputgui/i_pass_help.m", "test_i_pass_help") +def test_inputgui_preserves_help_target_for_the_renderer(): + renderer = RecordingRenderer({}) + spec = _spec(help_text="pophelp('pop_editoptions')") + + inputgui(spec, renderer=renderer) + + assert renderer.calls[0][0].help_text == "pophelp('pop_editoptions')" + assert renderer.calls[0][0].show_help_button + + +@eeglab_test("unittesting_guifunc/inputgui/i_pass_reuse.m", "test_i_pass_reuse") +def test_inputgui_renderer_can_be_reused_without_retaining_initial_values(): + renderer = RecordingRenderer({"entry": "accepted"}) + spec = _spec() + + inputgui(spec, initial_values={"entry": "first"}, renderer=renderer) + inputgui(spec, initial_values={"entry": "second"}, renderer=renderer) + + assert renderer.calls == [(spec, {"entry": "first"}), (spec, {"entry": "second"})] + + +@eeglab_test("unittesting_guifunc/supergui/test_supergui.m", "test_test_supergui") +def test_dialog_spec_represents_supergui_geometry_controls_and_layout_options(): + spec = DialogSpec( + title="MyGUI", + controls=(ControlSpec("radiobutton", "radio"), ControlSpec("pushbutton", "push")), + geometry=((1, 1),), + geomvert=(3, 2), + function_name="test_supergui", + eeglab_source="functions/guifunc/supergui.m", + content_margins=(10, 8, 14, 12), + row_spacing=2, + ) + + assert spec.title == "MyGUI" + assert spec.geometry == ((1, 1),) + assert spec.geomvert == (3, 2) + assert [(control.style, control.string) for control in spec.controls] == [ + ("radiobutton", "radio"), + ("pushbutton", "push"), + ] + assert spec.content_margins == (10, 8, 14, 12) + assert spec.row_spacing == 2 + + +@eeglab_test("unittesting_guifunc/inputdlg2/fail_no_arg.m", "test_fail_no_arg") +def test_inputdlg2_requires_a_prompt_and_title(): + with pytest.raises(TypeError): + inputdlg2() + + +@eeglab_test("unittesting_guifunc/inputdlg2/fail_invalid_length.m", "test_fail_invalid_length") +def test_inputdlg2_rejects_mismatched_prompts_and_defaults(): + with pytest.raises(ValueError, match="same length"): + inputdlg2_dialog_spec(["testcase", "another test"], "inputdlg2 testcase", 1, ["this"]) + + +@eeglab_test("unittesting_guifunc/inputdlg2/i_pass_general.m", "test_i_pass_general") +def test_inputdlg2_returns_answers_in_prompt_order(): + renderer = RecordingRenderer({"answer0": "this", "answer1": "that"}) + + answer = inputdlg2( + ["testcase", "another test"], + "inputdlg2 testcase", + 1, + ["this", "that"], + "i_pass_general", + renderer=renderer, + ) + + spec, initial_values = renderer.calls[0] + assert answer == ["this", "that"] + assert initial_values is None + assert spec.title == "inputdlg2 testcase" + assert spec.help_text == "i_pass_general" + assert [control.value for control in spec.controls if control.style == "edit"] == ["this", "that"] + + +@eeglab_test("unittesting_guifunc/inputdlg2/i_pass_horizontal.m", "test_i_pass_horizontal") +def test_inputdlg2_uses_vertical_rows_for_a_multiline_prompt(): + spec = inputdlg2_dialog_spec([["test", "case"], "another test"], "inputdlg2 testcase", 1, ["this", "that"]) + + assert spec.controls[0].string == "test\ncase" + assert spec.geometry == ((1,), (1,), (1, 0.6)) + assert spec.geomvert == (2, 1) + + +@eeglab_test("unittesting_guifunc/inputdlg2/i_pass_no_function.m", "test_i_pass_no_function") +def test_inputdlg2_omits_help_when_no_function_name_is_given(): + spec = inputdlg2_dialog_spec(["testcase", "another test"], "inputdlg2 testcase", 1, ["this", "that"]) + + assert spec.function_name == "inputdlg2" + assert spec.help_text is None + assert not spec.show_help_button + + +@pytest.fixture +def qt_widgets(): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + widgets = pytest.importorskip("PySide6.QtWidgets") + app = widgets.QApplication.instance() or widgets.QApplication([]) + yield widgets + app.processEvents() + + +@eeglab_test("unittesting_guifunc/errordlg2/i_pass_general.m", "test_i_pass_general") +def test_errordlg2_builds_a_critical_message_with_requested_text(qt_widgets): + _app, dialog = build_errordlg2("Explanation of error", "testcase for errordlg2") + + assert dialog.text() == "Explanation of error" + assert dialog.icon() == qt_widgets.QMessageBox.Icon.Critical + assert dialog.standardButtons() & qt_widgets.QMessageBox.StandardButton.Ok + dialog.close() + + +@eeglab_test("unittesting_guifunc/listdlg2/fail_no_arg.m", "test_fail_no_arg") +def test_listdlg2_requires_list_items(): + with pytest.raises(TypeError): + listdlg2() + + +@eeglab_test("unittesting_guifunc/listdlg2/i_pass_general.m", "test_i_pass_general") +def test_listdlg2_defaults_to_multiple_selection(qt_widgets): + _app, dialog = build_listdlg2_dialog(liststring=["This", "is", "a", "testcase"]) + list_widget = dialog.findChild(qt_widgets.QListWidget, "listboxvals") + + assert list_widget.count() == 4 + assert list_widget.selectionMode() == qt_widgets.QAbstractItemView.ExtendedSelection + dialog.close() + + +@eeglab_test("unittesting_guifunc/listdlg2/i_pass_InitialValue.m", "test_i_pass_InitialValue") +def test_listdlg2_selects_one_based_initial_values(qt_widgets): + _app, dialog = build_listdlg2_dialog( + liststring=["This", "is", "a", "testcase"], + initialvalue=[1, 3], + ) + list_widget = dialog.findChild(qt_widgets.QListWidget, "listboxvals") + + assert [index + 1 for index in range(list_widget.count()) if list_widget.item(index).isSelected()] == [1, 3] + dialog.close() + + +@eeglab_test("unittesting_guifunc/listdlg2/i_pass_PromptString.m", "test_i_pass_PromptString") +def test_listdlg2_displays_prompt_text(qt_widgets): + _app, dialog = build_listdlg2_dialog(liststring=["one", "two"], promptstring="Choose values") + prompt = dialog.findChild(qt_widgets.QLabel, "prompt") + + assert prompt.text() == "Choose values" + dialog.close() + + +@eeglab_test("unittesting_guifunc/listdlg2/i_pass_listsize.m", "test_i_pass_listsize") +def test_listdlg2_uses_requested_window_size(qt_widgets): + _app, dialog = build_listdlg2_dialog(liststring=["one", "two"], listsize=(420, 260)) + + assert (dialog.width(), dialog.height()) == (420, 260) + dialog.close() + + +@eeglab_test("unittesting_guifunc/listdlg2/i_pass_name.m", "test_i_pass_name") +def test_listdlg2_uses_requested_window_title(qt_widgets): + _app, dialog = build_listdlg2_dialog(liststring=["one", "two"], name="My list") + + assert dialog.windowTitle() == "My list" + dialog.close() + + +@eeglab_test("unittesting_guifunc/listdlg2/i_pass_single.m", "test_i_pass_single") +def test_listdlg2_single_mode_selects_only_one_item(qt_widgets): + _app, dialog = build_listdlg2_dialog( + liststring=["This", "is", "a", "testcase"], + selectionmode="single", + ) + list_widget = dialog.findChild(qt_widgets.QListWidget, "listboxvals") + + assert list_widget.selectionMode() == qt_widgets.QAbstractItemView.SingleSelection + assert list_widget.item(0).isSelected() + dialog.close() + + +@eeglab_test("unittesting_guifunc/listdlg2/i_pass_string.m", "test_i_pass_string") +def test_listdlg2_treats_a_string_as_one_list_item(qt_widgets): + _app, dialog = build_listdlg2_dialog(liststring="This is a testcase") + list_widget = dialog.findChild(qt_widgets.QListWidget, "listboxvals") + + assert list_widget.count() == 1 + assert list_widget.item(0).text() == "This is a testcase" + assert list_widget.selectionMode() == qt_widgets.QAbstractItemView.SingleSelection + dialog.close() diff --git a/tests/test_pop_delset.py b/tests/test_pop_delset.py index e79c043f..bf6ebe2c 100644 --- a/tests/test_pop_delset.py +++ b/tests/test_pop_delset.py @@ -1,12 +1,14 @@ import pytest from eegprep.functions.adminfunc.pop_delset import pop_delset +from tests.eeglab_tests import eeglab_test def _eeg(name: str = "demo") -> dict: return {"setname": name} +@eeglab_test("unittesting_adminfunc/pop_delset/i_pass_set_in_negative.m", "test_i_pass_set_in_negative") def test_pop_delset_rejects_non_positive_indices(): with pytest.raises(ValueError, match="1-based"): pop_delset([_eeg()], -1) @@ -19,6 +21,8 @@ def test_pop_delset_rejects_missing_dataset(): pop_delset([_eeg()], 2) +@eeglab_test("unittesting_adminfunc/pop_delset/pass_set_in.m", "test_pass_set_in") +@eeglab_test("unittesting_adminfunc/pop_delset/i_pass_general.m", "test_i_pass_general") def test_pop_delset_empties_slots_in_place_and_drops_trailing_empties(): # EEGLAB pop_delset blanks the slot, so dataset 2 keeps its number; the emptied # last slot is dropped like `eeglab redraw` does. @@ -33,3 +37,10 @@ def test_pop_delset_of_only_dataset_leaves_empty_list(): alleeg, _command = pop_delset([_eeg()], 1) assert alleeg == [] + + +@eeglab_test("unittesting_adminfunc/pop_delset/fail_alleeg_empty.m", "test_fail_alleeg_empty") +@eeglab_test("unittesting_adminfunc/pop_delset/fail_no_arg.m", "test_fail_no_arg") +def test_pop_delset_requires_dataset_indices(): + with pytest.raises(TypeError): + pop_delset([_eeg()]) diff --git a/tests/test_session_contracts.py b/tests/test_session_contracts.py index ecfd46bc..9cc65fc3 100644 --- a/tests/test_session_contracts.py +++ b/tests/test_session_contracts.py @@ -16,6 +16,7 @@ fresh_sample_eeg, fresh_session_with_sample, ) +from tests.eeglab_tests import eeglab_test def test_sample_data_satisfies_core_eeg_contract(): @@ -57,6 +58,7 @@ def test_sample_data_satisfies_core_eeg_contract(): assert np.asarray(eeg["icachansind"]).dtype.kind in {"i", "u"} +@eeglab_test("unittesting_adminfunc/eeg_global/pass_general.m", "test_pass_general") def test_sample_session_and_console_namespace_stay_synced(): command = "EEG = pop_loadset('eeglab_data.set');" session = fresh_session_with_sample(command) diff --git a/tests/test_vararg2str.py b/tests/test_vararg2str.py new file mode 100644 index 00000000..5e10a62d --- /dev/null +++ b/tests/test_vararg2str.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from eegprep.functions.adminfunc.vararg2str import vararg2str +from tests.eeglab_tests import eeglab_test + + +@eeglab_test("unittesting_adminfunc/vararg2str/test_vararg2str.m", "test_test_vararg2str") +def test_vararg2str_formats_values_names_and_raw_string_arguments(): + arguments = ["arg1", 2, {"a": "a", "b": 3}] + + assert vararg2str(arguments) == "'arg1',2,struct('a','a','b',3)" + assert vararg2str(arguments, ["key1", "key2", "key3"]) == "key1,key2,key3" + assert vararg2str(arguments, ["key1", "", "key2"]) == "key1,2,key2" + assert vararg2str(arguments, ["", "key2", ""], [1, 3]) == "'arg1',key2,struct('a','a','b',3)" + assert vararg2str(arguments, nostrconv=[1, 1, 1]) == "arg1,2,struct('a','a','b',3)" From 6f566bb09c22f93a7e130ef3e021f5c720bc79d9 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:45 -0700 Subject: [PATCH 07/45] Port current EEGLAB signal-processing tests (#335) * Port current EEGLAB sigprocfunc tests * Contain topoplot finite matrix warnings * style: format pop reref tests --- docs/source/api/data_loading_and_saving.rst | 2 + docs/source/api/interactive_pop_workflows.rst | 6 +- .../api/preprocessing_and_artifacts.rst | 3 + docs/source/changelog.rst | 2 + src/eegprep/functions/popfunc/_rejection.py | 2 + .../functions/sigprocfunc/chancenter.py | 7 +- .../functions/sigprocfunc/entropy_rej.py | 6 +- src/eegprep/functions/sigprocfunc/erpimage.py | 17 +- src/eegprep/functions/sigprocfunc/kurt.py | 6 +- .../functions/sigprocfunc/readegilocs.py | 13 +- src/eegprep/functions/sigprocfunc/readlocs.py | 12 +- .../functions/sigprocfunc/realproba.py | 8 +- src/eegprep/functions/sigprocfunc/runica.py | 15 + src/eegprep/functions/sigprocfunc/topoplot.py | 5 +- .../functions/timefreqfunc/angtimewarp.py | 3 - .../functions/timefreqfunc/newtimef.py | 3 + .../resources/montages/egi-gsn-65-v2.sfp | 68 ++++ .../montages/egi-gsn-hydrocell-32.sfp | 36 +++ tests/test_cart2topo.py | 25 ++ tests/test_eegplot.py | 19 ++ tests/test_envtopo.py | 2 + tests/test_epoch.py | 98 ++++++ tests/test_erpimage.py | 60 ++++ tests/test_phase2_file_channel_location.py | 294 +++++++++++++++++- tests/test_phase4_plot_wrappers.py | 74 +++++ tests/test_phase4_timefreq_statistics.py | 116 +++++++ tests/test_phase7_long_tail_helpers.py | 285 ++++++++++++++++- tests/test_pop_reref.py | 44 +++ tests/test_pop_rmbase.py | 22 ++ tests/test_rejection_workflows.py | 121 +++++++ tests/test_runica.py | 97 +++++- tests/test_sigproc_eegrej.py | 56 ++++ tests/test_statistics_package.py | 33 ++ tests/test_topoplot.py | 17 + 34 files changed, 1534 insertions(+), 43 deletions(-) create mode 100644 src/eegprep/resources/montages/egi-gsn-65-v2.sfp create mode 100644 src/eegprep/resources/montages/egi-gsn-hydrocell-32.sfp diff --git a/docs/source/api/data_loading_and_saving.rst b/docs/source/api/data_loading_and_saving.rst index 8f5b9b3d..29c291cd 100644 --- a/docs/source/api/data_loading_and_saving.rst +++ b/docs/source/api/data_loading_and_saving.rst @@ -31,3 +31,5 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.snapread eegprep.writelocs +``readegilocs`` includes packaged EGI montages for 32/33, 64/65, 128/129, +and 256/257-channel nets. diff --git a/docs/source/api/interactive_pop_workflows.rst b/docs/source/api/interactive_pop_workflows.rst index 3b7448cb..7b1391bf 100644 --- a/docs/source/api/interactive_pop_workflows.rst +++ b/docs/source/api/interactive_pop_workflows.rst @@ -97,6 +97,11 @@ Plotting and Review eegprep.pop_topochansel eegprep.pop_topoplot +The low-level ``erpimage`` helper accepts either one time value per sample or +EEGLAB's compact ``[start_ms, frames, sampling_rate]`` time specification. +``newtimef`` accepts EEGLAB's default ``outputformat='plot'`` option; other +output layouts are not yet implemented. + STUDY ----- @@ -168,4 +173,3 @@ Other eegprep.pop_snapread eegprep.pop_timef eegprep.pop_writelocs - diff --git a/docs/source/api/preprocessing_and_artifacts.rst b/docs/source/api/preprocessing_and_artifacts.rst index 425c7578..603435db 100644 --- a/docs/source/api/preprocessing_and_artifacts.rst +++ b/docs/source/api/preprocessing_and_artifacts.rst @@ -42,3 +42,6 @@ Artifact detection and repair, referencing, resampling, interpolation, and rejec eegprep.rspfunc eegprep.signalstat +``realproba`` follows EEGLAB's default discretization of one probability bin +per five input values, rounded to the nearest integer. Pass ``bins`` explicitly +when a fixed histogram resolution is required. diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index e422757d..5b817441 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,8 @@ the `GitHub Releases `_ page. Unreleased ========== +- Topographic interpolation no longer leaks NumPy 2 floating-point warnings for + finite biharmonic matrix products. - ``eeg_interp`` now accepts MATLAB-loaded object arrays of channel locations when restoring a full montage. ``pop_editeventfield`` now reads values from delimited text files, accepts MATLAB colon expressions for event indices, rebuilds ``urevent`` diff --git a/src/eegprep/functions/popfunc/_rejection.py b/src/eegprep/functions/popfunc/_rejection.py index 6d51c6b6..227650f2 100644 --- a/src/eegprep/functions/popfunc/_rejection.py +++ b/src/eegprep/functions/popfunc/_rejection.py @@ -373,6 +373,8 @@ def _as_rows_points_trials(signal: np.ndarray) -> np.ndarray: if arr.ndim == 1: arr = arr.reshape(1, arr.size, 1) elif arr.ndim == 2: + if arr.shape[1] == 1: + arr = arr.T arr = arr[:, :, np.newaxis] elif arr.ndim != 3: raise ValueError("Signal must be 1-D, 2-D, or 3-D") diff --git a/src/eegprep/functions/sigprocfunc/chancenter.py b/src/eegprep/functions/sigprocfunc/chancenter.py index 2992308b..1b5a824c 100644 --- a/src/eegprep/functions/sigprocfunc/chancenter.py +++ b/src/eegprep/functions/sigprocfunc/chancenter.py @@ -32,7 +32,12 @@ def objective(center: np.ndarray) -> float: radii = np.sqrt((x[finite] - center[0]) ** 2 + (y[finite] - center[1]) ** 2 + (z[finite] - center[2]) ** 2) return float(np.std(radii)) - result = minimize(objective, initial, method="Nelder-Mead", options={"maxfev": 1000}) + result = minimize( + objective, + initial, + method="Nelder-Mead", + options={"maxfev": 10_000, "xatol": 1e-12, "fatol": 1e-12}, + ) return np.asarray(result.x if result.success else initial, dtype=float) diff --git a/src/eegprep/functions/sigprocfunc/entropy_rej.py b/src/eegprep/functions/sigprocfunc/entropy_rej.py index 140ca278..9cfe6443 100644 --- a/src/eegprep/functions/sigprocfunc/entropy_rej.py +++ b/src/eegprep/functions/sigprocfunc/entropy_rej.py @@ -41,6 +41,8 @@ def _as_rows_points_trials(signal: Any) -> np.ndarray: if arr.ndim == 1: arr = arr.reshape(1, arr.size, 1) elif arr.ndim == 2: + if arr.shape[1] == 1: + arr = arr.T arr = arr[:, :, np.newaxis] elif arr.ndim != 3: raise ValueError("signal must be 1-D, 2-D, or 3-D") @@ -49,11 +51,11 @@ def _as_rows_points_trials(signal: Any) -> np.ndarray: def _normalize(scores: np.ndarray, *, signal_ndim: int) -> np.ndarray: if signal_ndim == 2: - std = float(np.std(scores)) + std = float(np.std(scores, ddof=1)) if scores.size > 1 else 0.0 if std == 0 or not np.isfinite(std): std = 1.0 return (scores - np.mean(scores)) / std - std = np.std(scores, axis=1, keepdims=True) + std = np.std(scores, axis=1, ddof=1 if scores.shape[1] > 1 else 0, keepdims=True) std[~np.isfinite(std) | (std == 0)] = 1.0 return (scores - scores.mean(axis=1, keepdims=True)) / std diff --git a/src/eegprep/functions/sigprocfunc/erpimage.py b/src/eegprep/functions/sigprocfunc/erpimage.py index 0403aa67..9f2cc371 100644 --- a/src/eegprep/functions/sigprocfunc/erpimage.py +++ b/src/eegprep/functions/sigprocfunc/erpimage.py @@ -48,11 +48,7 @@ def erpimage( if values.ndim != 2: raise ValueError("erpimage data must be points x trials") points, trials = values.shape - x_values = ( - np.asarray(times, dtype=float).ravel() - if times is not None and len(np.asarray(times).ravel()) - else np.arange(points) - ) + x_values = _time_values(times, points) if x_values.size != points: raise ValueError("times must match the number of data points") order = _trial_order(values, sort_values) @@ -140,6 +136,17 @@ def erpimage( return fig, image +def _time_values(times: Any, points: int) -> np.ndarray: + if times is None or not len(np.asarray(times).ravel()): + return np.arange(points, dtype=float) + values = np.asarray(times, dtype=float).ravel() + if values.size == 3 and points != 3 and int(values[1]) == points: + if values[2] <= 0: + raise ValueError("sampling rate in [start, frames, srate] must be positive") + return values[0] + np.arange(points, dtype=float) * 1000.0 / values[2] + return values + + def _trial_order(values: np.ndarray, sort_values: Any) -> np.ndarray: if sort_values is None or (isinstance(sort_values, (list, tuple)) and not sort_values): return np.arange(values.shape[1]) diff --git a/src/eegprep/functions/sigprocfunc/kurt.py b/src/eegprep/functions/sigprocfunc/kurt.py index 15a7f56d..58b7a20e 100644 --- a/src/eegprep/functions/sigprocfunc/kurt.py +++ b/src/eegprep/functions/sigprocfunc/kurt.py @@ -17,9 +17,9 @@ def kurt(data: Any) -> np.ndarray: if values.shape[0] == 1: values = values.T centered = values - np.mean(values, axis=0, keepdims=True) - m2 = np.mean(centered**2, axis=0) - m2[m2 == 0] = np.inf - return np.mean(centered**4, axis=0) / m2**2 - 3.0 + sample_std = np.std(values, axis=0, ddof=1) + sample_std[sample_std == 0] = np.inf + return np.sum(centered**4, axis=0) / sample_std**4 / values.shape[0] - 3.0 __all__ = ["kurt"] diff --git a/src/eegprep/functions/sigprocfunc/readegilocs.py b/src/eegprep/functions/sigprocfunc/readegilocs.py index 1f88fc7b..160b597e 100644 --- a/src/eegprep/functions/sigprocfunc/readegilocs.py +++ b/src/eegprep/functions/sigprocfunc/readegilocs.py @@ -10,6 +10,10 @@ EGI_MONTAGE_BY_CHANNELS = { + 32: "egi-gsn-hydrocell-32.sfp", + 33: "egi-gsn-hydrocell-32.sfp", + 64: "egi-gsn-65-v2.sfp", + 65: "egi-gsn-65-v2.sfp", 128: "egi-gsn-hydrocell-129.locs", 129: "egi-gsn-hydrocell-129.locs", 256: "egi-gsn-hydrocell-257.locs", @@ -27,10 +31,15 @@ def readegilocs(EEG: dict[str, Any], fileloc: str | None = None) -> dict[str, An path = _montage_path(resource) locs = readlocs(path) chaninfo: dict[str, Any] = {"filename": str(path)} - if nbchan in {128, 256}: + if nbchan == 256: + chaninfo["nodatchans"] = locs[-1:] + locs = locs[:-1] + elif nbchan == 257: + chaninfo["nodatchans"] = [] + elif nbchan in {32, 64, 128}: chaninfo["nodatchans"] = locs[:3] + locs[-1:] locs = locs[3:-1] - elif nbchan in {129, 257}: + elif nbchan in {33, 65, 129}: chaninfo["nodatchans"] = locs[:3] locs = locs[3:] output["chanlocs"] = locs[:nbchan] diff --git a/src/eegprep/functions/sigprocfunc/readlocs.py b/src/eegprep/functions/sigprocfunc/readlocs.py index bbcf4cbd..f1e5d077 100644 --- a/src/eegprep/functions/sigprocfunc/readlocs.py +++ b/src/eegprep/functions/sigprocfunc/readlocs.py @@ -139,10 +139,14 @@ def readeetraklocs(filename: str | Path) -> list[dict[str, Any]]: rows = _read_text_rows(Path(filename), 0) labels_at = _first_row(rows, "Labels") positions_at = _first_row(rows, "Positions") - if labels_at is None or positions_at is None or labels_at <= positions_at: + if labels_at is None or positions_at is None: raise ValueError("Could not find 'Labels' and 'Positions' sections") - position_rows = rows[positions_at + 1 : labels_at] - label_rows = rows[labels_at + 1 :] + if positions_at < labels_at: + position_rows = rows[positions_at + 1 : labels_at] + label_rows = rows[labels_at + 1 :] + else: + label_rows = rows[labels_at + 1 : positions_at] + position_rows = rows[positions_at + 1 :] labels = [item for row in label_rows for item in row] locs = [] for index, row in enumerate(position_rows): @@ -355,7 +359,7 @@ def _canonical_field(field: str) -> str: def _parse_channel_value(field: str, value: str) -> Any: if field == "labels" or field == "type": - return str(value) + return str(value).strip("'\"") return _coerce_value(value) diff --git a/src/eegprep/functions/sigprocfunc/realproba.py b/src/eegprep/functions/sigprocfunc/realproba.py index a5cf1dd6..7a6b8599 100644 --- a/src/eegprep/functions/sigprocfunc/realproba.py +++ b/src/eegprep/functions/sigprocfunc/realproba.py @@ -8,11 +8,15 @@ def realproba(data: Any, bins: int | None = None) -> tuple[np.ndarray, np.ndarray]: - """Compute per-sample empirical probabilities and the distribution.""" + """Compute per-sample empirical probabilities and the distribution. + + When ``bins`` is omitted, EEGLAB uses one bin per five input values, + rounded to the nearest integer. + """ values = np.asarray(data, dtype=float) flat = values.ravel() if bins is None: - bins = 1000 + bins = int(np.floor(flat.size / 5 + 0.5)) if flat.size == 0: return np.asarray(values, dtype=float), np.asarray([], dtype=float) if int(bins) <= 0: diff --git a/src/eegprep/functions/sigprocfunc/runica.py b/src/eegprep/functions/sigprocfunc/runica.py index 0279abe8..712caa84 100644 --- a/src/eegprep/functions/sigprocfunc/runica.py +++ b/src/eegprep/functions/sigprocfunc/runica.py @@ -246,6 +246,7 @@ def runica(data, **kwargs): # Initialize all parameters with defaults pcaflag = DEFAULT_PCAFLAG sphering = DEFAULT_SPHEREFLAG + posactflag = DEFAULT_POSACTFLAG verbose = DEFAULT_VERBOSE # Heuristic defaults that depend on data size @@ -276,6 +277,8 @@ def runica(data, **kwargs): wts_passed = 0 if isinstance(weights, int) and weights == 0 else 1 ncomps = kwargs_lower.get('ncomps', chans) + if ncomps is None or np.asarray(ncomps).size == 0: + ncomps = chans biasflag = DEFAULT_BIASFLAG interrupt = kwargs_lower.get('interrupt', DEFAULT_INTERRUPT) @@ -369,6 +372,7 @@ def runica(data, **kwargs): posact_val = kwargs_lower['posact'] if posact_val not in ['on', 'off']: raise ValueError('runica(): posact value must be on or off') + posactflag = posact_val # ========================================================================= # 3. SPECIAL PARAMETER ADJUSTMENTS @@ -1350,6 +1354,17 @@ def runica(data, **kwargs): windex = sortvar[::-1] # order large to small meanvar = meanvar[windex] + # EEGLAB's ``posact`` convention orients each component so its largest + # absolute activation is positive. Flip the component map first, then + # reconstruct the unmixing matrix exactly as runica.m does. + if posactflag == 'on': + peak_frames = np.argmax(np.abs(activations_unsorted), axis=1) + peak_values = activations_unsorted[np.arange(ncomps), peak_frames] + flip = np.sign(peak_values) < 0 + if np.any(flip): + winv[:, flip] *= -1 + weights = _matmul(finite_pinv(winv, solver=pinv), np.linalg.inv(sphere)) + # ========================================================================= # Permute activations and reorder weights (MATLAB lines 1521-1528) # ========================================================================= diff --git a/src/eegprep/functions/sigprocfunc/topoplot.py b/src/eegprep/functions/sigprocfunc/topoplot.py index ba604412..8f3d1ad0 100644 --- a/src/eegprep/functions/sigprocfunc/topoplot.py +++ b/src/eegprep/functions/sigprocfunc/topoplot.py @@ -57,7 +57,10 @@ def griddata_v4(x, y, v, xq, yq): g_q[d_q == 0] = 0 # Handle Green's function at zero # Weights is (L,), g_q is (M, N, L) -> vq is (M, N) - vq = g_q @ weights + # NumPy 2 may expose spurious BLAS floating-point flags for this finite + # product; preserve the computed values without leaking those flags. + with np.errstate(divide="ignore", over="ignore", invalid="ignore"): + vq = g_q @ weights return vq diff --git a/src/eegprep/functions/timefreqfunc/angtimewarp.py b/src/eegprep/functions/timefreqfunc/angtimewarp.py index 834246e8..552066ab 100644 --- a/src/eegprep/functions/timefreqfunc/angtimewarp.py +++ b/src/eegprep/functions/timefreqfunc/angtimewarp.py @@ -24,9 +24,6 @@ def angtimewarp(ev_latency: Any, new_latency: Any, angdata: Any) -> np.ndarray: if ev_frames[0] != 1: ev_frames = np.sort(np.concatenate([ev_frames, np.asarray([1], dtype=int)])) new_frames = np.sort(np.concatenate([new_frames, np.asarray([1], dtype=int)])) - if np.any(np.diff(ev_frames) == 0) or np.any(np.diff(new_frames) == 0): - raise ValueError("timewarp event latencies must be unique after adding the synchronized first frame") - angles = np.asarray(angdata, dtype=float).ravel() old_count = int(np.max(ev_frames)) if angles.size < old_count: diff --git a/src/eegprep/functions/timefreqfunc/newtimef.py b/src/eegprep/functions/timefreqfunc/newtimef.py index ffbf20db..b115f7e2 100644 --- a/src/eegprep/functions/timefreqfunc/newtimef.py +++ b/src/eegprep/functions/timefreqfunc/newtimef.py @@ -101,12 +101,15 @@ def newtimef( timewarpidx: Any = None, vert: Any = None, verbose: str = "off", + outputformat: str = "plot", ) -> TimeFrequencyResult: """Compute an EEGLAB-like ERSP/ITC time-frequency decomposition.""" if overlap is not None: raise NotImplementedError("newtimef does not implement the 'overlap' option") if str(boottype).lower() != "shuffle": raise NotImplementedError("newtimef only implements the 'shuffle' boottype") + if str(outputformat).lower() != "plot": + raise NotImplementedError("newtimef only implements outputformat='plot'") if not _is_on(plotphase): plotphasesign = plotphase # EEGLAB: plotphase='off' turns off the ITC phase-sign (newtimef.m line 603) if freqs is None and freqrange is not None: diff --git a/src/eegprep/resources/montages/egi-gsn-65-v2.sfp b/src/eegprep/resources/montages/egi-gsn-65-v2.sfp new file mode 100644 index 00000000..65d6af3b --- /dev/null +++ b/src/eegprep/resources/montages/egi-gsn-65-v2.sfp @@ -0,0 +1,68 @@ +FidNz 0.00000 10.3855 -1.81526 +FidT9 -7.00399 0.368267 -3.69381 +FidT10 7.00399 0.368267 -3.69381 +E1 4.82147 8.46376 -0.0639843 +E2 3.44999 9.06441 2.97064 +E3 1.90275 7.64198 6.40285 +E4 0.00000 5.21914 8.19540 +E5 -1.72628 2.48578 8.55637 +E6 1.59679 10.5184 0.515771 +E7 0.00000 9.90900 4.03918 +E8 -1.90275 7.64198 6.40285 +E9 -3.44910 4.88753 7.22782 +E10 0.00000 10.2056 -1.61817 +E11 -1.59679 10.5184 0.515771 +E12 -3.44999 9.06441 2.97064 +E13 -4.77336 6.69969 4.57236 +E14 -4.82147 8.46376 -0.0639843 +E15 -6.08393 6.13847 1.57273 +E16 -6.30320 3.70148 4.22719 +E17 -5.28740 1.30272 6.65006 +E18 -2.89239 -1.49571 8.18118 +E19 -6.49644 4.76669 -1.91820 +E20 -7.25734 2.59912 0.903444 +E21 -7.34336 0.287481 3.19674 +E22 -5.94121 -2.42769 5.97948 +E23 -6.70321 2.34743 -5.19168 +E24 -7.64626 -1.18501 0.271229 +E25 -7.24521 -2.89502 2.68804 +E26 -7.06353 -3.24230 -2.70838 +E27 -6.78539 -4.61900 -0.234471 +E28 -5.98166 -5.75781 2.97462 +E29 -3.38310 -5.87378 6.25655 +E30 0.00000 -4.05271 8.13937 +E31 -5.64139 -6.07135 -3.51430 +E32 -4.88083 -7.67925 -0.352208 +E33 -3.16769 -8.12261 3.13635 +E34 0.00000 -7.14834 5.87509 +E35 -4.27348 -6.69422 -6.35512 +E36 -3.08195 -8.59131 -3.53248 +E37 -1.79973 -9.42935 -0.256676 +E38 0.00000 -8.99684 2.56482 +E39 0.00000 -9.12413 -3.87835 +E40 1.79973 -9.42935 -0.256676 +E41 3.16769 -8.12261 3.13635 +E42 3.38310 -5.87378 6.25655 +E43 2.89239 -1.49571 8.18118 +E44 3.08195 -8.59131 -3.53248 +E45 4.88083 -7.67925 -0.352208 +E46 5.98166 -5.75781 2.97462 +E47 5.94121 -2.42769 5.97948 +E48 5.64139 -6.07135 -3.51430 +E49 6.78539 -4.61900 -0.234471 +E50 7.24521 -2.89502 2.68804 +E51 7.06353 -3.24230 -2.70838 +E52 7.64626 -1.18501 0.271229 +E53 7.34336 0.287481 3.19674 +E54 5.28740 1.30272 6.65006 +E55 1.72628 2.48578 8.55637 +E56 7.25734 2.59912 0.903444 +E57 6.30320 3.70148 4.22719 +E58 3.44910 4.88753 7.22782 +E59 6.70321 2.34743 -5.19168 +E60 6.49644 4.76669 -1.91820 +E61 6.08393 6.13847 1.57273 +E62 4.77336 6.69969 4.57236 +E63 3.82315 7.90175 -7.06073 +E64 -3.82315 7.90175 -7.06073 +Cz 0.00000 0.00000 8.94282 diff --git a/src/eegprep/resources/montages/egi-gsn-hydrocell-32.sfp b/src/eegprep/resources/montages/egi-gsn-hydrocell-32.sfp new file mode 100644 index 00000000..214fb1b6 --- /dev/null +++ b/src/eegprep/resources/montages/egi-gsn-hydrocell-32.sfp @@ -0,0 +1,36 @@ +FidNz 0 9.071585155 -2.359754454 +FidT9 -6.711765 0.040402876 -3.251600355 +FidT10 6.711765 0.040402876 -3.251600355 +E1 -2.695405558 8.884820317 1.088308144 +E2 2.695405558 8.884820317 1.088308144 +E3 -4.459387187 6.021159964 4.365321482 +E4 4.459387187 6.021159964 4.365321482 +E5 -5.47913021 0.284948655 6.38332782 +E6 5.47913021 0.284948655 6.38332782 +E7 -5.831241498 -4.494821698 4.955347697 +E8 5.831241498 -4.494821698 4.955347697 +E9 -2.738838019 -8.607966849 0.239368223 +E10 2.738838019 -8.607966849 0.239368223 +E11 -6.399087198 4.127248875 -0.356852241 +E12 6.399087198 4.127248875 -0.356852241 +E13 -7.304625099 -1.866238006 -0.629182006 +E14 7.304625099 -1.866238006 -0.629182006 +E15 -6.034746843 -5.755782196 0.051843011 +E16 6.034746843 -5.755782196 0.051843011 +E17 0 7.96264703 5.044718001 +E18 0 9.271139705 -2.211516434 +E19 0 -6.676694032 6.465208258 +E20 0 -8.996686498 0.487952047 +E21 -6.518995129 2.417299399 -5.253637073 +E22 6.518995129 2.417299399 -5.253637073 +E23 -6.174969392 -2.458138877 -5.637380998 +E24 6.174969392 -2.458138877 -5.637380998 +E25 -3.784983913 -6.401014415 -5.260040689 +E26 3.784983913 -6.401014415 -5.260040689 +E27 0 9.087440894 1.333345013 +E28 0 3.806770224 7.891304964 +E29 -3.743504949 6.649204911 -6.530243068 +E30 3.743504949 6.649204911 -6.530243068 +E31 -6.118458137 4.523870113 -4.409174427 +E32 6.118458137 4.523870113 -4.409174427 +Cz 0 0 8.899186843 diff --git a/tests/test_cart2topo.py b/tests/test_cart2topo.py index abc58b88..1724c9fb 100644 --- a/tests/test_cart2topo.py +++ b/tests/test_cart2topo.py @@ -3,9 +3,34 @@ import numpy as np from eegprep import cart2topo +from tests.eeglab_tests import eeglab_test class TestCart2Topo(unittest.TestCase): + @eeglab_test( + "unittesting_sigprocfunc/cart2topo/sigprocfunc_cart2topo_wrapperTest.m", + "test_pass_xy", + ) + def test_diagonal_cartesian_points_match_upstream_orientation(self): + xyz = np.asarray([[0, 1, 0], [0, -1, 0], [1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0]]) + + theta, radius, *_ = cart2topo(xyz) + + np.testing.assert_allclose(theta, [-90, 90, -45, -135, 45, 135]) + np.testing.assert_allclose(radius, np.full(6, 0.5)) + + @eeglab_test( + "unittesting_sigprocfunc/cart2topo/sigprocfunc_cart2topo_wrapperTest.m", + "test_pass_zero_y", + ) + def test_near_zero_y_preserves_upstream_signed_angles(self): + xyz = np.asarray([[1, 0.000001000001, 0], [-1, 0.000001000001, 0]]) + + theta, radius, *_ = cart2topo(xyz) + + np.testing.assert_allclose(theta, [0, -180], atol=1e-4) + np.testing.assert_allclose(radius, [0.5, 0.5]) + def test_cardinal_cartesian_points_match_eeglab_orientation(self): xyz = np.array( [ diff --git a/tests/test_eegplot.py b/tests/test_eegplot.py index d31a1796..65291bf2 100644 --- a/tests/test_eegplot.py +++ b/tests/test_eegplot.py @@ -29,9 +29,13 @@ winrej_to_array, ) from tests.fixtures import create_test_eeg, matlab_engine_available +from tests.eeglab_tests import eeglab_test SAMPLE_DATASET = Path(__file__).resolve().parents[1] / "sample_data" / "eeglab_data.set" +_EEGPLOT_SOURCE = "unittesting_sigprocfunc/eegplot/sigprocfunc_eegplot_wrapperTest.m" +_EEGPLOT2EVENT_SOURCE = "unittesting_sigprocfunc/eegplot2event/sigprocfunc_eegplot2event_wrapperTest.m" +_EEGPLOT2TRIAL_SOURCE = "unittesting_sigprocfunc/eegplot2trial/sigprocfunc_eegplot2trial_wrapperTest.m" def test_parse_eegplot_options_accepts_eeglab_key_value_pairs() -> None: @@ -50,6 +54,8 @@ def test_eegplot_rejects_internal_plotdata2_option() -> None: eegplot(np.zeros((1, 10)), plotdata2="on", show=False) +@eeglab_test(_EEGPLOT_SOURCE, "test_pass_general") +@eeglab_test(_EEGPLOT_SOURCE, "test_pass_one_arg") def test_continuous_data_normalization_defaults_and_bounds() -> None: data = np.arange(20, dtype=float).reshape(2, 10) model = build_eegplot_model(data, srate=10, winlength=0.4, spacing=2, show=False) @@ -67,6 +73,7 @@ def test_empty_spacing_uses_eeglab_default_spacing() -> None: assert model.state.spacing == pytest.approx(1.0) +@eeglab_test(_EEGPLOT_SOURCE, "test_pass_epochs") def test_epoched_data_flattens_in_eeglab_trial_order_and_clamps_window() -> None: data = np.zeros((1, 4, 3), dtype=float) data[0, :, 0] = [1, 2, 3, 4] @@ -117,6 +124,7 @@ def test_spectral_and_overlay_inputs_are_normalized_together() -> None: np.testing.assert_array_equal(model.data.flat_data2, overlay[:, 2:7]) +@eeglab_test(_EEGPLOT_SOURCE, "test_pass_noui") def test_noui_option_sets_publication_state_without_showing_qt() -> None: model = build_eegplot_model(np.zeros((2, 10)), spacing=1, noui="on", show=False) @@ -181,6 +189,11 @@ def test_trial2eegplot_converts_epoch_and_channel_marks() -> None: np.testing.assert_array_equal(rows[:, 5:], [[1, 0], [0, 1]]) +@eeglab_test(_EEGPLOT2TRIAL_SOURCE, "test_pass_general") +@eeglab_test(_EEGPLOT2TRIAL_SOURCE, "test_pass_multiple") +@eeglab_test(_EEGPLOT2TRIAL_SOURCE, "test_pass_multiple_color") +@eeglab_test(_EEGPLOT2TRIAL_SOURCE, "test_pass_multiple_colorin") +@eeglab_test(_EEGPLOT2TRIAL_SOURCE, "test_pass_multiple_colorout") def test_eegplot2trial_filters_colors_and_handles_first_epoch_boundary() -> None: rows = np.array( [ @@ -199,6 +212,12 @@ def test_eegplot2trial_filters_colors_and_handles_first_epoch_boundary() -> None np.testing.assert_array_equal(excluded_rows, [[False, False, False], [False, True, False]]) +@eeglab_test(_EEGPLOT2EVENT_SOURCE, "test_pass_general") +@eeglab_test(_EEGPLOT2EVENT_SOURCE, "test_pass_multiple_events") +@eeglab_test(_EEGPLOT2EVENT_SOURCE, "test_pass_multiple_events_colorin") +@eeglab_test(_EEGPLOT2EVENT_SOURCE, "test_pass_multiple_events_colorout") +@eeglab_test(_EEGPLOT2EVENT_SOURCE, "test_pass_multiple_events_notfound") +@eeglab_test(_EEGPLOT2EVENT_SOURCE, "test_pass_one_arg") def test_eegplot2event_converts_continuous_marks_for_eeg_eegrej() -> None: rows = np.array([[2.2, 5.8, 0.7, 1.0, 0.9, 1], [8, 9, 0.1, 0.2, 0.3, 1]]) diff --git a/tests/test_envtopo.py b/tests/test_envtopo.py index 1f18f7f1..03c00c2b 100644 --- a/tests/test_envtopo.py +++ b/tests/test_envtopo.py @@ -25,6 +25,7 @@ import pytest from eegprep.functions.sigprocfunc.envtopo import _resolve_subcomps, envtopo +from tests.eeglab_tests import eeglab_test from tests.fixtures import create_test_eeg_with_ica pytestmark = pytest.mark.parity @@ -77,6 +78,7 @@ def _ica_dataset(seed, *, n_components=4): # --------------------------------------------------------------------------- # # Closed-form anchor: hand-built input whose ranking is obvious by design. # --------------------------------------------------------------------------- # +@eeglab_test("unittesting_sigprocfunc/envtopo/sigprocfunc_envtopo_wrapperTest.m", "test_test_envtopo") def test_closed_form_ranking_and_peak_frames(): """Three orthonormal maps with activation powers 9:4:1 rank as [1, 2, 3].""" frames = 5 diff --git a/tests/test_epoch.py b/tests/test_epoch.py index 5ac7c586..dc75d064 100644 --- a/tests/test_epoch.py +++ b/tests/test_epoch.py @@ -5,6 +5,7 @@ from eegprep.functions.adminfunc.eeglabcompat import get_eeglab from eegprep.functions.sigprocfunc.epoch import epoch # Python translation under test +from tests.eeglab_tests import eeglab_test def _ml_list_of_arrays_to_0_based(list_of_arrays): @@ -28,6 +29,103 @@ def _ml_list_of_arrays_to_0_based(list_of_arrays): return out +def _upstream_epoch_data(): + return np.arange(1, 41, dtype=float).reshape(2, 20) + + +@eeglab_test( + "unittesting_sigprocfunc/epoch/sigprocfunc_epoch_wrapperTest.m", + "test_pass_general", +) +@eeglab_test( + "unittesting_sigprocfunc/epoch/sigprocfunc_epoch_wrapperTest.m", + "test_pass_no_srate", +) +def test_epoch_upstream_default_and_explicit_sampling_rate_match_exact_slices(): + data = _upstream_epoch_data() + events = [2, 5, 6, 9, 12, 18] + expected = np.stack([data[:, event - 2 : event + 2] for event in events], axis=2) + + explicit = epoch(data, events, [-1, 3], srate=1, verbose="off") + default = epoch(data, events, [-1, 3], verbose="off") + + for result in (explicit, default): + epoched, newtime, indices, allevents, latencies, reallim = result + np.testing.assert_array_equal(epoched, expected) + np.testing.assert_array_equal(newtime, [-1, 2]) + np.testing.assert_array_equal(indices, np.arange(6)) + assert allevents == [] + assert latencies == [] + np.testing.assert_array_equal(reallim, [-1, 2]) + + +@eeglab_test( + "unittesting_sigprocfunc/epoch/sigprocfunc_epoch_wrapperTest.m", + "test_pass_boundary", +) +def test_epoch_upstream_boundary_case_drops_only_out_of_bounds_window(): + data = _upstream_epoch_data() + + epoched, newtime, indices, *_ = epoch(data, [2, 5, 6, 9, 12, 19], [-1, 3], srate=1, verbose="off") + + np.testing.assert_array_equal( + epoched, np.stack([data[:, event - 2 : event + 2] for event in [2, 5, 6, 9, 12]], axis=2) + ) + np.testing.assert_array_equal(newtime, [-1, 2]) + np.testing.assert_array_equal(indices, np.arange(5)) + + +@eeglab_test( + "unittesting_sigprocfunc/epoch/sigprocfunc_epoch_wrapperTest.m", + "test_pass_valuelim", +) +def test_epoch_upstream_value_limits_filter_on_all_channels(): + data = _upstream_epoch_data() + + epoched, newtime, indices, *_ = epoch( + data, + [2, 5, 6, 9, 12, 18], + [-1, 3], + srate=1, + valuelim=[7, 35], + verbose="off", + ) + + expected = np.stack([data[:, 7:11], data[:, 10:14]], axis=2) + np.testing.assert_array_equal(epoched, expected) + np.testing.assert_array_equal(newtime, [-1, 2]) + np.testing.assert_array_equal(indices, [3, 4]) + + +@eeglab_test( + "unittesting_sigprocfunc/epoch/sigprocfunc_epoch_wrapperTest.m", + "test_pass_allevents", +) +def test_epoch_upstream_rereferences_all_events_with_zero_based_indices(): + data = _upstream_epoch_data() + + epoched, newtime, indices, event_indices, event_latencies, _ = epoch( + data, + [2, 5, 6, 9, 12, 18], + [-1, 3], + srate=1, + allevents=[2, 5, 6, 9, 10, 12, 17, 18], + verbose="off", + ) + + np.testing.assert_array_equal( + epoched, np.stack([data[:, event - 2 : event + 2] for event in [2, 5, 6, 9, 12, 18]], axis=2) + ) + np.testing.assert_array_equal(newtime, [-1, 2]) + np.testing.assert_array_equal(indices, np.arange(6)) + expected_indices = [[0], [1, 2], [1, 2], [3, 4], [5], [6, 7]] + expected_latencies = [[0], [0, 1], [-1, 0], [0, 1], [0], [-1, 0]] + for actual, expected in zip(event_indices, expected_indices): + np.testing.assert_array_equal(actual, expected) + for actual, expected in zip(event_latencies, expected_latencies): + np.testing.assert_array_equal(actual, expected) + + @unittest.skipIf(os.getenv('EEGPREP_SKIP_MATLAB') == '1', "MATLAB not available") class TestEpochParity(unittest.TestCase): def setUp(self): diff --git a/tests/test_erpimage.py b/tests/test_erpimage.py index 1b585b24..4dbbd981 100644 --- a/tests/test_erpimage.py +++ b/tests/test_erpimage.py @@ -18,8 +18,13 @@ import matplotlib.pyplot as plt import numpy as np +import pytest from eegprep.functions.sigprocfunc.erpimage import erpimage +from tests.eeglab_tests import eeglab_test + + +_ERPIMAGE_SOURCE = "unittesting_sigprocfunc/erpimage/sigprocfunc_erpimage_wrapperTest.m" def _ramp_trials(points: int = 40, trials: int = 12) -> np.ndarray: @@ -52,3 +57,58 @@ def test_target_draws_into_existing_subfigure() -> None: assert result_fig is target assert len(target.axes) >= 2 # ERP image + average ERP plt.close("all") + + +@eeglab_test(_ERPIMAGE_SOURCE, "test_fail_continuous") +@eeglab_test(_ERPIMAGE_SOURCE, "test_fail_no_arg") +def test_erpimage_requires_points_by_trials_data() -> None: + with pytest.raises(ValueError, match="points x trials"): + erpimage(np.arange(20)) + with pytest.raises(TypeError): + erpimage() # ty: ignore[missing-argument] + + +@eeglab_test(_ERPIMAGE_SOURCE, "test_fail_one_trial") +@eeglab_test(_ERPIMAGE_SOURCE, "test_fail_trial_div") +def test_erpimage_requires_one_sort_value_per_trial() -> None: + data = np.array( + [ + [21, 24, 25, 28, 31, 37], + [22, 25, 26, 29, 32, 38], + [23, 26, 27, 30, 33, 39], + [24, 27, 28, 31, 34, 40], + ] + ) + for sort_values in ([1], [1, 2, 3, 4, 5]): + with pytest.raises(ValueError, match="one value per trial"): + erpimage(data, sort_values=sort_values) + + +@eeglab_test(_ERPIMAGE_SOURCE, "test_pass_general") +@eeglab_test(_ERPIMAGE_SOURCE, "test_pass_many_args") +def test_erpimage_preserves_default_order_and_computes_erp() -> None: + data = np.array( + [ + [21, 24, 25, 28, 31, 37], + [22, 25, 26, 29, 32, 38], + [23, 26, 27, 30, 33, 39], + [24, 27, 28, 31, 34, 40], + ] + ) + fig, image = erpimage(data, title="testcase", smooth=1, decimate=1, cbar=True) + + np.testing.assert_array_equal(image, data.T) + expected_erp = [27 + 2 / 3, 28 + 2 / 3, 29 + 2 / 3, 30 + 2 / 3] + np.testing.assert_allclose(fig.axes[-1].lines[0].get_ydata(), expected_erp) + assert fig.axes[0].get_title() == "testcase" + plt.close(fig) + + +@eeglab_test(_ERPIMAGE_SOURCE, "test_pass_times") +def test_erpimage_expands_eeglab_compact_time_specification() -> None: + data = np.arange(24, dtype=float).reshape(4, 6) + fig, _image = erpimage(data, times=[0, 4, 1]) + + np.testing.assert_allclose(fig.axes[0].get_xlim(), [0.0, 3000.0]) + np.testing.assert_allclose(fig.axes[-1].lines[0].get_xdata(), [0.0, 1000.0, 2000.0, 3000.0]) + plt.close(fig) diff --git a/tests/test_phase2_file_channel_location.py b/tests/test_phase2_file_channel_location.py index e2dba20f..1b136e20 100644 --- a/tests/test_phase2_file_channel_location.py +++ b/tests/test_phase2_file_channel_location.py @@ -25,6 +25,7 @@ snapread, writelocs, ) +from eegprep.functions.sigprocfunc.readlocs import readelp, readeetraklocs from tests.eeglab_tests import eeglab_test @@ -155,6 +156,274 @@ def test_convertlocs_and_chancenter_match_expected_geometry() -> None: assert optimized is False +@eeglab_test( + "unittesting_sigprocfunc/chancenter/sigprocfunc_chancenter_wrapperTest.m", + "test_pass_2d", +) +@eeglab_test( + "unittesting_sigprocfunc/chancenter/sigprocfunc_chancenter_wrapperTest.m", + "test_pass_scalar", +) +def test_chancenter_explicit_center_matches_upstream_translation() -> None: + x, y, z, center, optimized = chancenter([1, 1, 0, 0], [1, 0, 0, 1], [0, 0, 0, 0], [1, 1, 0]) + + np.testing.assert_allclose(x, [0, 0, -1, -1]) + np.testing.assert_allclose(y, [0, -1, -1, 0]) + np.testing.assert_allclose(z, np.zeros(4)) + np.testing.assert_allclose(center, [1, 1, 0]) + assert optimized is False + + scalar = chancenter(1, 1, 0, [1, 1, 0]) + np.testing.assert_allclose(scalar[0], [0]) + np.testing.assert_allclose(scalar[1], [0]) + np.testing.assert_allclose(scalar[2], [0]) + + +@eeglab_test( + "unittesting_sigprocfunc/chancenter/sigprocfunc_chancenter_wrapperTest.m", + "test_pass_all_negative", +) +@eeglab_test( + "unittesting_sigprocfunc/chancenter/sigprocfunc_chancenter_wrapperTest.m", + "test_pass_zero_radius", +) +def test_chancenter_negative_center_and_zero_radius_match_upstream() -> None: + x, y, z, center, _ = chancenter([-1, 2, -1, 2], [1, 1, -2, -2], [0, -1, 0, 1], [3, -2, 0.5]) + np.testing.assert_allclose(x, [-4, -1, -4, -1]) + np.testing.assert_allclose(y, [3, 3, 0, 0]) + np.testing.assert_allclose(z, [-0.5, -1.5, -0.5, 0.5]) + np.testing.assert_allclose(center, [3, -2, 0.5]) + + zero = chancenter(np.ones(4), np.ones(4), np.ones(4), [1, 1, 1]) + for coordinates in zero[:3]: + np.testing.assert_allclose(coordinates, np.zeros(4)) + + +@eeglab_test( + "unittesting_sigprocfunc/chancenter/sigprocfunc_chancenter_wrapperTest.m", + "test_pass_optimize", +) +def test_chancenter_automatic_sphere_fit_matches_symmetric_upstream_case() -> None: + x, y, z, center, optimized = chancenter( + [1, 1, 0, 0, 0.5, 0.5], + [1, 0, 0, 1, 0.5, 0.5], + [0, 0, 0, 0, 1, -1], + [], + ) + + np.testing.assert_allclose(x, [0.5, 0.5, -0.5, -0.5, 0, 0], atol=1e-5) + np.testing.assert_allclose(y, [0.5, -0.5, -0.5, 0.5, 0, 0], atol=1e-5) + np.testing.assert_allclose(z, [0, 0, 0, 0, 1, -1], atol=1e-5) + np.testing.assert_allclose(center, [0.5, 0.5, 0], atol=1e-5) + assert optimized is True + + +@eeglab_test( + "unittesting_sigprocfunc/convertlocs/sigprocfunc_convertlocs_wrapperTest.m", + "test_pass_cart2all", +) +def test_convertlocs_cart2all_matches_upstream_complete_coordinate_fields() -> None: + root = np.sqrt(2) / 2 + locs = [ + {"labels": "a", "X": -root, "Y": root, "Z": 0, "type": "EEG"}, + {"labels": "b", "X": 1, "Y": 0, "Z": 0, "type": "EEG"}, + {"labels": "c", "X": 0, "Y": -1, "Z": 0, "type": "EEG"}, + {"labels": "d", "X": root, "Y": -root, "Z": 0, "type": "EEG"}, + ] + + converted = convertlocs(locs, "cart2all") + + np.testing.assert_allclose([loc["theta"] for loc in converted], [-135, 0, 90, 45]) + np.testing.assert_allclose([loc["radius"] for loc in converted], np.full(4, 0.5)) + np.testing.assert_allclose([loc["sph_theta"] for loc in converted], [135, 0, -90, -45]) + np.testing.assert_allclose([loc["sph_phi"] for loc in converted], np.zeros(4)) + np.testing.assert_allclose([loc["sph_radius"] for loc in converted], np.ones(4)) + np.testing.assert_allclose([loc["sph_theta_besa"] for loc in converted], [-90, 90, 90, 90]) + np.testing.assert_allclose([loc["sph_phi_besa"] for loc in converted], [45, 90, 0, 45]) + + +@eeglab_test( + "unittesting_sigprocfunc/floatwrite/sigprocfunc_floatwrite_wrapperTest.m", + "test_pass_general", +) +@eeglab_test( + "unittesting_sigprocfunc/floatwrite/sigprocfunc_floatwrite_wrapperTest.m", + "test_pass_native", +) +def test_floatwrite_matches_upstream_four_byte_column_major_encoding(tmp_path: Path) -> None: + data = np.asarray([[1.23, 0.12], [4.56, 3.45], [7.89, 6.78]]) + big_endian = tmp_path / "big.fdt" + native = tmp_path / "native.fdt" + + floatwrite(data, big_endian, "ieee-be") + floatwrite(data, native) + + expected_big = np.ravel(data.astype(">f4"), order="F").tobytes() + expected_native = np.ravel(data.astype("f4"), order="F").tobytes() + assert big_endian.read_bytes() == expected_big + assert native.read_bytes() == expected_native + + +@eeglab_test( + "unittesting_sigprocfunc/floatread/sigprocfunc_floatread_wrapperTest.m", + "test_pass_general", +) +@eeglab_test( + "unittesting_sigprocfunc/floatread/sigprocfunc_floatread_wrapperTest.m", + "test_pass_no_size", +) +@eeglab_test( + "unittesting_sigprocfunc/floatread/sigprocfunc_floatread_wrapperTest.m", + "test_pass_offset", +) +def test_floatread_matches_upstream_shape_inference_and_cell_offset(tmp_path: Path) -> None: + general = np.asarray([[1.23, 0.12], [4.56, 3.45], [7.89, 6.78]]) + general_path = tmp_path / "general.fdt" + general_path.write_bytes(np.ravel(general.astype(">f4"), order="F").tobytes()) + + offset_data = np.asarray([[1.23, 7.89, 3.45], [4.56, 0.12, 6.78]]) + offset_path = tmp_path / "offset.fdt" + offset_path.write_bytes(np.ravel(offset_data.astype(">f4"), order="F").tobytes()) + + np.testing.assert_allclose(floatread(general_path, [3, 2], "ieee-be"), general) + np.testing.assert_allclose(floatread(offset_path, [2, np.inf], "ieee-be"), offset_data) + np.testing.assert_allclose( + floatread(offset_path, [2, 2], "ieee-be", ([2, 3], [1, 2])), + offset_data[:, 1:], + ) + + +@eeglab_test( + "unittesting_sigprocfunc/floatread/sigprocfunc_floatread_wrapperTest.m", + "test_pass_square", +) +def test_floatread_square_shape_matches_upstream(tmp_path: Path) -> None: + data = np.asarray([[1.23, 0.12, 9.01], [4.56, 3.45, 2.34], [7.89, 6.78, 5.67]]) + path = tmp_path / "square.fdt" + path.write_bytes(np.ravel(data.astype(">f4"), order="F").tobytes()) + + np.testing.assert_allclose(floatread(path, "square", "ieee-be"), data, rtol=1e-6) + + +@eeglab_test( + "unittesting_sigprocfunc/floatread/sigprocfunc_floatread_wrapperTest.m", + "test_pass_nan_inf", +) +def test_floatread_preserves_upstream_nonfinite_values(tmp_path: Path) -> None: + data = np.asarray([[np.nan, -np.inf], [np.nan, np.inf], [np.nan, 0], [np.nan, 0]]) + path = tmp_path / "nonfinite.fdt" + path.write_bytes(np.ravel(data.astype(">f4"), order="F").tobytes()) + + np.testing.assert_allclose(floatread(path, [4, 2], "ieee-be"), data, equal_nan=True) + + +@eeglab_test( + "unittesting_sigprocfunc/readeetraklocs/sigprocfunc_readeetraklocs_wrapperTest.m", + "test_pass_general", +) +def test_readeetraklocs_matches_upstream_labels_and_coordinates(tmp_path: Path) -> None: + path = tmp_path / "test.elc" + path.write_text( + "NumberPositions 4\nUnitPosition mm\nPositions\n" + "1 -2 3\n0.1 2.5 -4\n3 -4 -8.5\n-11 0 19\n" + "Labels\nNr1 Nr2 Nr3 Ch4\n", + encoding="utf-8", + ) + + locs = readeetraklocs(path) + + assert [loc["labels"] for loc in locs] == ["Nr1", "Nr2", "Nr3", "Ch4"] + np.testing.assert_allclose( + [[loc[axis] for axis in "XYZ"] for loc in locs], [[1, -2, 3], [0.1, 2.5, -4], [3, -4, -8.5], [-11, 0, 19]] + ) + + +@eeglab_test( + "unittesting_sigprocfunc/readeetraklocs/sigprocfunc_readeetraklocs_wrapperTest.m", + "test_pass_labels_positions_exchanged", +) +def test_readeetraklocs_accepts_upstream_exchanged_section_order(tmp_path: Path) -> None: + """Strengthen the upstream script, whose intended call is currently empty.""" + path = tmp_path / "exchanged.elc" + path.write_text( + "NumberPositions 4\nUnitPosition mm\nLabels\nNr1 Nr2 Nr3 Ch4\n" + "Positions\n1 -2 3\n0.1 2.5 -4\n3 -4 -8.5\n-11 0 19\n", + encoding="utf-8", + ) + + locs = readeetraklocs(path) + + assert [loc["labels"] for loc in locs] == ["Nr1", "Nr2", "Nr3", "Ch4"] + np.testing.assert_allclose( + [[loc[axis] for axis in "XYZ"] for loc in locs], [[1, -2, 3], [0.1, 2.5, -4], [3, -4, -8.5], [-11, 0, 19]] + ) + + +@eeglab_test( + "unittesting_sigprocfunc/readelp/sigprocfunc_readelp_wrapperTest.m", + "test_pass_general", +) +def test_readelp_matches_upstream_fiducials_labels_and_coordinates(tmp_path: Path) -> None: + path = tmp_path / "test.elp" + path.write_text( + "%F 0.1011 0.0000 0.0000\n%F -0.0135 0.0731 0.0000\n%F 0.0135 -0.0731 0.0000\n" + "%N REF\n-0.0092 -0.0779 -0.0036\n%N FP1\n0.1091 0.0102 0.0583\n" + "%N FPZ\n0.1176 -0.0184 0.0595\n%N FP2\n0.1179 -0.0470 0.0565\n", + encoding="utf-8", + ) + + locs = readelp(path) + + assert [loc["labels"] for loc in locs] == ["Nz", "LPA", "RPA", "REF", "FP1", "FPZ", "FP2"] + assert [loc["type"] for loc in locs] == ["FID", "FID", "FID", "EEG", "EEG", "EEG", "EEG"] + np.testing.assert_allclose([loc["X"] for loc in locs], [0.1011, -0.0135, 0.0135, -0.0092, 0.1091, 0.1176, 0.1179]) + np.testing.assert_allclose([loc["Y"] for loc in locs], [0, 0.0731, -0.0731, -0.0779, 0.0102, -0.0184, -0.0470]) + np.testing.assert_allclose([loc["Z"] for loc in locs], [0, 0, 0, -0.0036, 0.0583, 0.0595, 0.0565]) + + +@eeglab_test( + "unittesting_sigprocfunc/readlocs/sigprocfunc_readlocs_wrapperTest.m", + "test_pass_bugzilla_339", +) +def test_readlocs_custom_besa_columns_accept_upstream_regression_values(tmp_path: Path) -> None: + path = tmp_path / "bugzilla_339.txt" + path.write_text( + "1 7.3 120.4 163.5\n2 7.6 107.1 165.1\n3 8.1 98.3 170\n4 8.7 103 -170.4\n", + encoding="utf-8", + ) + + locs = readlocs( + path, + "filetype", + "custom", + "format", + ["channum", "sph_radius", "sph_theta_besa", "sph_phi_besa"], + ) + + assert len(locs) == 4 + np.testing.assert_allclose([loc["sph_radius"] for loc in locs], [7.3, 7.6, 8.1, 8.7]) + assert all(np.isfinite([loc["X"], loc["Y"], loc["Z"]]).all() for loc in locs) + + +@eeglab_test( + "unittesting_sigprocfunc/readlocs/sigprocfunc_readlocs_wrapperTest.m", + "test_pass_bugzilla_72", +) +def test_readlocs_ced_preserves_upstream_quoted_channel_type(tmp_path: Path) -> None: + path = tmp_path / "bugzilla_72.ced" + path.write_text( + "Number labels theta radius X Y Z sph_theta sph_phi sph_radius type\n" + "1 A2 0 0.04 0.0219 0 0.174 0 82.8 0.175 'meg'\n" + "2 A3 0 0.0794 0.044 0 0.172 0 75.7 0.178 'meg'\n", + encoding="utf-8", + ) + + locs = readlocs(path) + + assert [loc["labels"] for loc in locs] == ["A2", "A3"] + assert [loc["type"] for loc in locs] == ["meg", "meg"] + + def test_convertlocs_besa_spherical_matches_eeglab_angle_convention() -> None: lateral = convertlocs([{"labels": "Right", "sph_theta": 90.0, "sph_phi": 0.0}], "sph2sphbesa")[0] anterior = convertlocs([{"labels": "Front", "sph_theta": 0.0, "sph_phi": 0.0}], "sph2sphbesa")[0] @@ -336,11 +605,20 @@ def test_snapread_and_pop_snapread_import_binary_file(tmp_path: Path) -> None: _assert_parseable(command) -def test_readegilocs_uses_packaged_egi_montage() -> None: - eeg = {"nbchan": 129, "chanlocs": [], "chaninfo": {}} - - out = readegilocs(eeg) - - assert len(out["chanlocs"]) == 129 - assert len(out["chaninfo"]["nodatchans"]) == 3 - assert out["chanlocs"][0]["labels"] == "E1" +@eeglab_test( + "unittesting_sigprocfunc/readegilocs/sigprocfunc_readegilocs_wrapperTest.m", + "test_test_readegilocs", +) +def test_readegilocs_uses_packaged_egi_montages_for_upstream_channel_counts() -> None: + for channel_count in (32, 33, 64, 65, 128, 129, 256, 257): + eeg = {"nbchan": channel_count, "chanlocs": [], "chaninfo": {}} + + out = readegilocs(eeg) + + assert len(out["chanlocs"]) == channel_count + expected_nondata = {256: 1, 257: 0}.get( + channel_count, + 4 if channel_count in {32, 64, 128} else 3, + ) + assert len(out["chaninfo"]["nodatchans"]) == expected_nondata + assert out["chanlocs"][0]["labels"] == "E1" diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index f3b31db4..91e17cfc 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -50,6 +50,8 @@ from eegprep.functions.popfunc.pop_timtopo import pop_timtopo from eegprep.functions.popfunc.pop_topoplot import plot_channel_locations, pop_topoplot from eegprep.functions.popfunc._chanutils import chanlocs_as_list +from eegprep.functions.sigprocfunc.axcopy import axcopy +from eegprep.functions.sigprocfunc.timtopo import timtopo from eegprep.functions.sigprocfunc.topoplot import plot_channel_location, topoplot from eegprep.functions.studyfunc.pop_chanplot import pop_chanplot, pop_chanplot_dialog_spec from eegprep.functions.sigprocfunc.coregister import ( @@ -73,6 +75,12 @@ _interpolate_values, ) from tests.fixtures import SAMPLE_DATASET_PATH, create_test_eeg_with_ica +from tests.eeglab_tests import eeglab_test + + +_AXCOPY_SOURCE = "unittesting_sigprocfunc/axcopy/sigprocfunc_axcopy_wrapperTest.m" +_HEADPLOT_SOURCE = "unittesting_sigprocfunc/headplot/sigprocfunc_headplot_wrapperTest.m" +_TIMTOPO_SOURCE = "unittesting_sigprocfunc/timtopo/sigprocfunc_timtopo_wrapperTest.m" @pytest.fixture(scope="module") @@ -91,6 +99,51 @@ def ica_epoch(): return create_test_eeg_with_ica(n_channels=6, n_samples=40, n_trials=4, n_components=4) +@eeglab_test(_AXCOPY_SOURCE, "test_pass_existing_figure") +@eeglab_test(_AXCOPY_SOURCE, "test_pass_general") +def test_axcopy_enlarges_a_clicked_existing_axes() -> None: + figure, axes = plt.subplots() + axes.plot([1, 2, 3], [1, 2, 3]) + before = set(plt.get_fignums()) + axcopy(figure, {axes: lambda target: target.plot([1, 2, 3], [1, 2, 3])}) + figure.canvas.draw() + x, y = axes.transData.transform((2, 2)) + + figure.canvas.callbacks.process( + "button_press_event", MouseEvent("button_press_event", figure.canvas, x, y, button=1) + ) + + created = set(plt.get_fignums()) - before + assert len(created) == 1 + popup = plt.figure(next(iter(created))) + np.testing.assert_array_equal(popup.axes[0].lines[0].get_ydata(), [1, 2, 3]) + plt.close("all") + + +@eeglab_test(_AXCOPY_SOURCE, "test_pass_one_arg") +def test_axcopy_redraw_callback_can_create_tickless_popup() -> None: + figure, axes = plt.subplots() + axes.plot([1, 2, 3], [1, 2, 3]) + + def redraw(target): + target.plot([1, 2, 3], [1, 2, 3]) + target.set_xticks([]) + target.set_yticks([]) + + before = set(plt.get_fignums()) + axcopy(figure, {axes: redraw}) + figure.canvas.draw() + x, y = axes.transData.transform((2, 2)) + figure.canvas.callbacks.process( + "button_press_event", MouseEvent("button_press_event", figure.canvas, x, y, button=1) + ) + + popup = plt.figure(next(iter(set(plt.get_fignums()) - before))) + assert not popup.axes[0].get_xticks().size + assert not popup.axes[0].get_yticks().size + plt.close("all") + + def test_pop_spectopo_plots_sample_data_headlessly(sample_eeg): result, command = pop_spectopo(sample_eeg, dataflag=1, freqs=[6, 10], return_com=True) @@ -308,6 +361,7 @@ def test_pop_prop_plots_sample_channel_properties(sample_eeg): plt.close(figure) +@eeglab_test(_HEADPLOT_SOURCE, "test_pass_general") def test_pop_headplot_plots_sample_latency_map_with_spline_setup(sample_eeg, tmp_path): eeg = deepcopy(sample_eeg) splinefile = tmp_path / "sample.spl" @@ -342,6 +396,7 @@ def test_pop_headplot_does_not_mutate_caller_eeg(sample_eeg, tmp_path): plt.close(fig) +@eeglab_test(_HEADPLOT_SOURCE, "test_pass_options") def test_pop_headplot_single_map_has_eeglab_like_title_and_surface(sample_eeg, tmp_path): eeg = deepcopy(sample_eeg) title = "ERP scalp maps of dataset: eeglab_data" @@ -385,6 +440,7 @@ def test_headplot_setup_file_can_be_reused_for_sample_data(sample_eeg, tmp_path) plt.close(figure) +@eeglab_test(_HEADPLOT_SOURCE, "test_pass_wireframe") def test_headplot_setup_plotmeshonly_and_orilocs_options(sample_eeg, tmp_path): transform = [0, -10, 0, -0.1, 0, -1.6, 1100, 1100, 1100] preview_file = tmp_path / "preview.spl" @@ -1894,6 +1950,24 @@ def test_plot_history_preserves_effective_options(sample_epoch, ica_epoch): plt.close(envtopo_fig) +@eeglab_test(_TIMTOPO_SOURCE, "test_test_timtopo") +def test_timtopo_accepts_eeglab_plotting_scenarios(sample_epoch): + data, times = data_time_slice(sample_epoch, None) + erp = np.nanmean(data, axis=2) + cases = ( + {}, + {"plottimes": [float("nan")], "title": "ERP data and scalp maps"}, + {"plottimes": [0, 100, 200, 300]}, + {"plottimes": [-1001, 0, 100]}, + {"plottimes": [-1000, 0, 100, 1500]}, + ) + for options in cases: + fig = timtopo(erp, sample_epoch["chanlocs"], times=times, **options) + assert fig.axes[0].get_xlabel() == "Latency (ms)" + assert len(fig.axes) >= 2 + plt.close(fig) + + def test_timtopo_auto_latency_uses_peak_global_power(sample_epoch): """Default (NaN) latency is the frame of peak global power (sum of squares across channels), as EEGLAB timtopo picks it -- not the max mean-removed variance frame.""" diff --git a/tests/test_phase4_timefreq_statistics.py b/tests/test_phase4_timefreq_statistics.py index 20e6fc90..d38715ef 100644 --- a/tests/test_phase4_timefreq_statistics.py +++ b/tests/test_phase4_timefreq_statistics.py @@ -19,6 +19,7 @@ from scipy import stats import eegprep +from tests.eeglab_tests import eeglab_test from eegprep.functions.guifunc.menu_actions import MenuActionDispatcher, action_kind from eegprep.functions.guifunc.spec import controls_by_tag from eegprep.functions.guifunc.session import EEGPrepSession @@ -94,6 +95,7 @@ def ica_epoch(): return create_test_eeg_with_ica(n_channels=6, n_samples=96, n_trials=5, n_components=4) +@eeglab_test("unittesting_sigprocfunc/newtimef/sigprocfunc_newtimef_wrapperTest.m", "test_test_newtimef") def test_newtimef_synthetic_returns_deterministic_shapes(): srate = 128 times = np.arange(0, 1, 1 / srate) @@ -108,6 +110,85 @@ def test_newtimef_synthetic_returns_deterministic_shapes(): assert np.all(np.abs(result.itc) <= 1 + 1e-12) +@eeglab_test("unittesting_sigprocfunc/newtimef/sigprocfunc_newtimef_wrapperTest.m", "test_test_newtimef2") +def test_newtimef_upstream_fft_wavelet_and_baseline_formulas_match_exactly(): + data = np.random.RandomState(0).rand(100, 10) + output_times = np.arange(20, 81, 10) * 10 + common = dict( + timesout=output_times, + padratio=1, + winsize=32, + plotitc="off", + plotersp="off", + verbose="off", + outputformat="plot", + plot="off", + ) + window = np.hanning(34)[1:-1] + + def fft_window(center): + segment = data[center - 16 : center + 16] + segment = segment - segment.mean(axis=0) + return np.fft.fft(segment * window[:, None], axis=0)[1:17] * 2 / 0.375 / 32 + + fft_data = [fft_window(center) for center in (21, 31, 41)] + power = [np.mean(np.abs(values) ** 2, axis=1) for values in fft_data] + + absolute = newtimef(data, 100, [0, 990], 100, 0, baseline=np.nan, scale="abs", **common) + logarithmic = newtimef(data, 100, [0, 990], 100, 0, baseline=np.nan, scale="log", **common) + np.testing.assert_allclose(absolute.ersp[:, 0], power[0], rtol=0, atol=1e-14) + np.testing.assert_allclose(logarithmic.ersp[:, 0], 10 * np.log10(power[0]), rtol=0, atol=1e-13) + expected_itc = np.mean(fft_data[0] / np.abs(fft_data[0]), axis=1) + np.testing.assert_allclose(np.abs(absolute.itc[:, 0]), np.abs(expected_itc), rtol=0, atol=1e-14) + + baseline_abs = newtimef(data, 100, [0, 990], 100, 0, baseline=250, scale="abs", **common) + baseline_log = newtimef(data, 100, [0, 990], 100, 0, baseline=250, scale="log", **common) + np.testing.assert_allclose(baseline_abs.ersp[:, 1], power[1] / power[0], rtol=0, atol=1e-12) + np.testing.assert_allclose(baseline_abs.powbase, power[0], rtol=0, atol=1e-14) + np.testing.assert_allclose(baseline_log.ersp[:, 1], 10 * np.log10(power[1] / power[0]), rtol=0, atol=1e-12) + np.testing.assert_allclose(baseline_log.powbase, 10 * np.log10(power[0]), rtol=0, atol=1e-13) + + trial_abs = newtimef(data, 100, [0, 990], 100, 0, baseline=250, scale="abs", trialbase="on", **common) + trial_log = newtimef(data, 100, [0, 990], 100, 0, baseline=250, scale="log", trialbase="on", **common) + trial_ratio = np.mean(np.abs(fft_data[1] / fft_data[0]) ** 2, axis=1) + np.testing.assert_allclose(trial_abs.ersp[:, 1], trial_ratio, rtol=0, atol=1e-12) + np.testing.assert_allclose(trial_log.ersp[:, 1], 10 * np.log10(trial_ratio), rtol=0, atol=1e-12) + + mean_base = (power[0] + power[1]) / 2 + std_base = np.sqrt((power[0] - mean_base) ** 2 + (power[1] - mean_base) ** 2) + normalized = newtimef(data, 100, [0, 990], 100, 0, baseline=350, scale="abs", basenorm="on", **common) + np.testing.assert_allclose(normalized.ersp[:, 2], (power[2] - mean_base) / std_base, rtol=0, atol=1e-11) + + trial_mean = np.abs(fft_data[0]) ** 2 / 2 + np.abs(fft_data[1]) ** 2 / 2 + trial_std = np.sqrt((np.abs(fft_data[0]) ** 2 - trial_mean) ** 2 + (np.abs(fft_data[1]) ** 2 - trial_mean) ** 2) + trial_expected = np.mean((np.abs(fft_data[2]) ** 2 - trial_mean) / trial_std, axis=1) + trial_normalized = newtimef( + data, + 100, + [0, 990], + 100, + 0, + baseline=350, + scale="abs", + trialbase="on", + basenorm="on", + **common, + ) + np.testing.assert_allclose(trial_normalized.ersp[:, 2], trial_expected, rtol=0, atol=1e-11) + + frequencies = [10, 12, 14, 16, 18] + wavelets, *_ = dftfilt3(frequencies, 3, 100, cycleinc="linear") + wavelet_values = [] + for wavelet in wavelets: + half = (len(wavelet) - 1) // 2 + segment = data[np.arange(-half, half + 1) + 30] + segment = segment - segment.mean(axis=0) + wavelet_values.append(np.sum(segment * wavelet[:, None], axis=0)) + wavelet_power = np.mean(np.abs(wavelet_values) ** 2, axis=1) + wavelet_result = newtimef(data, 100, [0, 990], 100, 3, baseline=np.nan, scale="abs", freqs=frequencies, **common) + np.testing.assert_allclose(wavelet_result.ersp[:, 1], wavelet_power, rtol=0, atol=1e-14) + + def test_newtimef_rejects_unknown_options(): signal = np.sin(2 * np.pi * 10 * np.arange(128) / 128) @@ -462,6 +543,41 @@ def test_angtimewarp_interpolates_and_wraps_like_eeglab(): np.testing.assert_allclose(warped, [0, np.pi, 0, -np.pi / 3, 0], rtol=1e-12, atol=1e-12) +@eeglab_test( + "unittesting_sigprocfunc/angtimewarp/sigprocfunc_angtimewarp_wrapperTest.m", + "test_pass_5point_sinus", +) +def test_angtimewarp_upstream_five_point_compression(): + warped = angtimewarp([1, 3, 5], [1, 5, 5], [0, 1, 0, -1, 0]) + + np.testing.assert_allclose(warped, [0, 0.5, 1, 0.5, 0]) + + +@eeglab_test( + "unittesting_sigprocfunc/angtimewarp/sigprocfunc_angtimewarp_wrapperTest.m", + "test_pass_diff_start", +) +def test_angtimewarp_upstream_implicit_synchronized_start(): + """Strengthen the upstream script, whose numerical assertion is disabled.""" + warped = angtimewarp([2, 3, 4], [1, 3, 5], [0, 1, 0, -1, 0]) + + np.testing.assert_allclose(warped, [0, 0.5, 0, -0.5, -1]) + + +@eeglab_test( + "unittesting_sigprocfunc/angtimewarp/sigprocfunc_angtimewarp_wrapperTest.m", + "test_pass_spike", +) +def test_angtimewarp_upstream_repeated_marker_wraps_large_angles(): + """Strengthen the upstream script, whose numerical assertion is disabled.""" + warped = angtimewarp([1, 3, 5], [1, 1, 5], [0, 5, 1000, -1000, 0]) + unwrapped = np.asarray([0, 0, -1000, -500, 0], dtype=float) + expected = np.mod(unwrapped, 2 * np.pi) + expected[expected > np.pi] -= 2 * np.pi + + np.testing.assert_allclose(warped, expected) + + def test_tf_cycle_calc_converts_width_units_and_dialog_inventory(): result = tf_cycle_calc(freqs=[10, 20], width=0.2, width_unit="fwhm_t") sigma2fwhm = 2 * np.sqrt(2 * np.log(2)) diff --git a/tests/test_phase7_long_tail_helpers.py b/tests/test_phase7_long_tail_helpers.py index 9d92cb16..cb98029b 100644 --- a/tests/test_phase7_long_tail_helpers.py +++ b/tests/test_phase7_long_tail_helpers.py @@ -3,6 +3,7 @@ import copy import numpy as np +import pytest from eegprep.functions.popfunc.pop_averef import pop_averef from eegprep.functions.popfunc.pop_findmatchingcomps import pop_findmatchingcomps @@ -12,6 +13,7 @@ from eegprep.functions.popfunc.pop_chansel import pop_chansel_resolve from eegprep.functions.popfunc.pop_topochansel import pop_topochansel from eegprep.functions.sigprocfunc.eegthresh import eegthresh +from eegprep.functions.sigprocfunc.entropy_rej import entropy_rej from eegprep.functions.sigprocfunc.ica_helpers import compvar, eeg_getica, eeg_pvaf, icaact, icaproj, icavar from eegprep.functions.sigprocfunc.kurt import kurt from eegprep.functions.sigprocfunc.realproba import realproba @@ -223,20 +225,260 @@ def test_eeg_pvaf_maps_full_channel_selection_to_icachansind_subset(): np.testing.assert_allclose(variances, [np.var(data[2])]) -def test_kurt_uses_eeglab_population_moment_formula(): +def test_kurt_uses_eeglab_sample_standard_deviation_formula(): values = np.array([[0.0, 1.0], [2.0, 3.0], [4.0, 5.0]]) centered = values - values.mean(axis=0, keepdims=True) - expected = np.mean(centered**4, axis=0) / np.mean(centered**2, axis=0) ** 2 - 3.0 + expected = np.sum(centered**4, axis=0) / np.std(values, axis=0, ddof=1) ** 4 / values.shape[0] - 3.0 np.testing.assert_allclose(kurt(values), expected) - np.testing.assert_allclose(expected, [-1.5, -1.5]) - - + np.testing.assert_allclose(expected, [-7 / 3, -7 / 3]) + + +@eeglab_test( + "unittesting_sigprocfunc/kurt/sigprocfunc_kurt_wrapperTest.m", + "test_pass_general", +) +@eeglab_test( + "unittesting_sigprocfunc/kurt/sigprocfunc_kurt_wrapperTest.m", + "test_pass_column", +) +def test_kurt_row_and_column_vectors_match_upstream_moment_formula(): + values = np.arange(1.0, 10.0) + expected = (708.0 / (225.0 / 4.0)) / 9.0 - 3.0 + + assert kurt(values) == pytest.approx(expected) + assert kurt(values[:, np.newaxis]) == pytest.approx(expected) + + +@eeglab_test( + "unittesting_sigprocfunc/kurt/sigprocfunc_kurt_wrapperTest.m", + "test_pass_bernoulli", +) +@eeglab_test( + "unittesting_sigprocfunc/kurt/sigprocfunc_kurt_wrapperTest.m", + "test_pass_positive", +) +def test_kurt_matches_upstream_bernoulli_cases(): + balanced = np.concatenate([np.zeros(10_000), np.ones(10_000)]) + sparse_zero = np.asarray([1, 1, 1, 1, 1, 1, 0, 1, 1, 1], dtype=float) + + for values in (balanced, sparse_zero): + centered = values - values.mean() + expected = np.sum(centered**4) / np.std(values, ddof=1) ** 4 / values.size - 3 + assert kurt(values) == pytest.approx(expected) + + +@eeglab_test( + "unittesting_sigprocfunc/realproba/sigprocfunc_realproba_wrapperTest.m", + "test_pass_general", +) def test_realproba_default_bin_count_matches_eeglab(): - probabilities, distribution = realproba(np.array([0.0, 1.0])) + probabilities, distribution = realproba(np.array([1.0, 2.0, 3.0])) + + np.testing.assert_allclose(probabilities, np.ones(3)) + np.testing.assert_allclose(distribution, np.ones(1)) + + +@eeglab_test( + "unittesting_sigprocfunc/realproba/sigprocfunc_realproba_wrapperTest.m", + "test_pass_discrete", +) +def test_realproba_explicit_discretization_matches_upstream_bins(): + probabilities, distribution = realproba(np.array([1.0, 2.0, 3.0]), 10) + + expected = np.zeros(10) + expected[[0, 4, 9]] = 1 / 3 + np.testing.assert_allclose(probabilities, np.full(3, 1 / 3)) + np.testing.assert_allclose(distribution, expected) + + +@eeglab_test( + "unittesting_sigprocfunc/realproba/sigprocfunc_realproba_wrapperTest.m", + "test_pass_equal", +) +def test_realproba_equal_values_have_well_defined_probabilities(): + """Strengthen the upstream case whose constant-data assertions are disabled.""" + probabilities, distribution = realproba(np.ones(3), 3) + + np.testing.assert_allclose(probabilities, np.ones(3)) + np.testing.assert_allclose(distribution, np.full(3, 1 / 3)) + + +@eeglab_test( + "unittesting_sigprocfunc/entropy_rej/sigprocfunc_entropy_rej_wrapperTest.m", + "test_pass_1d", +) +@eeglab_test( + "unittesting_sigprocfunc/entropy_rej/sigprocfunc_entropy_rej_wrapperTest.m", + "test_pass_one_arg", +) +@eeglab_test( + "unittesting_sigprocfunc/entropy_rej/sigprocfunc_entropy_rej_wrapperTest.m", + "test_pass_transposed", +) +def test_entropy_rej_vector_orientation_and_defaults_match_upstream(): + expected = -np.sum(np.asarray([2 / 3, 2 / 3, 1 / 3]) * np.log([2 / 3, 2 / 3, 1 / 3])) + + for data in (np.asarray([1, 1, 2]), np.asarray([[1], [1], [2]])): + entropy, rejected = entropy_rej(data) + np.testing.assert_allclose(entropy, [[expected]]) + np.testing.assert_array_equal(rejected, [[False]]) + + +@eeglab_test( + "unittesting_sigprocfunc/entropy_rej/sigprocfunc_entropy_rej_wrapperTest.m", + "test_pass_2d", +) +@eeglab_test( + "unittesting_sigprocfunc/entropy_rej/sigprocfunc_entropy_rej_wrapperTest.m", + "test_pass_2d_norm", +) +def test_entropy_rej_two_dimensional_scores_and_sample_normalization_match_upstream(): + data = np.asarray([[1, 1, 2], [1, 2, 3]]) + expected = np.asarray( + [ + -np.sum(np.asarray([2 / 3, 2 / 3, 1 / 3]) * np.log([2 / 3, 2 / 3, 1 / 3])), + -np.sum(np.full(3, 1 / 3) * np.log(np.full(3, 1 / 3))), + ] + )[:, np.newaxis] + + entropy, rejected = entropy_rej(data, 3, None, 0, 1000) + normalized, normalized_rejected = entropy_rej(data, 3, None, 1, 1000) + + np.testing.assert_allclose(entropy, expected) + np.testing.assert_allclose(normalized, [[-np.sqrt(2) / 2], [np.sqrt(2) / 2]]) + np.testing.assert_array_equal(rejected, np.zeros((2, 1), dtype=bool)) + np.testing.assert_array_equal(normalized_rejected, np.zeros((2, 1), dtype=bool)) + + +@eeglab_test( + "unittesting_sigprocfunc/entropy_rej/sigprocfunc_entropy_rej_wrapperTest.m", + "test_pass_3d", +) +@eeglab_test( + "unittesting_sigprocfunc/entropy_rej/sigprocfunc_entropy_rej_wrapperTest.m", + "test_pass_3d_norm", +) +def test_entropy_rej_three_dimensional_trials_match_upstream(): + data = np.empty((2, 3, 2), dtype=float) + data[:, :, 0] = [[1, 1, 2], [1, 2, 3]] + data[:, :, 1] = [[2, 1, 1], [2, 1, 3]] + expected_raw = np.asarray( + [ + -np.sum(np.asarray([2 / 3, 2 / 3, 1 / 3]) * np.log([2 / 3, 2 / 3, 1 / 3])), + -np.sum(np.full(3, 1 / 3) * np.log(np.full(3, 1 / 3))), + ] + ) - np.testing.assert_allclose(probabilities, [0.5, 0.5]) - assert distribution.shape == (1000,) + raw, _ = entropy_rej(data, 3, None, 0, 1000) + normalized, rejected = entropy_rej(data, 3, None, 1, 1000) + + np.testing.assert_allclose(raw, [[expected_raw[0], expected_raw[0]], [expected_raw[1], expected_raw[1]]]) + # Both trials have the same entropy per channel. The historical MATLAB test + # expected finite +/- values despite dividing zero deviations by zero std; + # EEGPrep keeps the scientifically useful finite result. + np.testing.assert_allclose(normalized, np.zeros((2, 2))) + np.testing.assert_array_equal(rejected, np.zeros((2, 2), dtype=bool)) + + +@eeglab_test( + "unittesting_sigprocfunc/entropy_rej/sigprocfunc_entropy_rej_wrapperTest.m", + "test_pass_reject", +) +def test_entropy_rej_precomputed_scores_only_apply_threshold(): + expected = -np.sum(np.asarray([2 / 3, 2 / 3, 1 / 3]) * np.log([2 / 3, 2 / 3, 1 / 3])) + + entropy, rejected = entropy_rej([1, 1, 2], 3, [expected], 0, 1000) + + np.testing.assert_allclose(entropy, [expected]) + np.testing.assert_array_equal(rejected, [False]) + + +@eeglab_test( + "unittesting_sigprocfunc/eegthresh/sigprocfunc_eegthresh_wrapperTest.m", + "test_pass_general", +) +@eeglab_test( + "unittesting_sigprocfunc/eegthresh/sigprocfunc_eegthresh_wrapperTest.m", + "test_pass_two_epochs", +) +def test_eegthresh_matches_upstream_selected_and_rejected_trials(): + data = np.empty((2, 3, 5), dtype=float) + data[0] = [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]] + data[1] = [[1, 4, 7, 10, 13], [2, 5, 8, 11, 14], [3, 6, 9, 12, 15]] + + accepted, rejected, selected, electrodes = eegthresh(data, 3, [1, 2], 2, 13, [1, 15], 1, 15) + + np.testing.assert_array_equal(accepted, [2, 3]) + np.testing.assert_array_equal(rejected, [1, 4, 5]) + np.testing.assert_array_equal(selected, data[:, :, [1, 2]]) + np.testing.assert_array_equal(electrodes, [[True, True, True], [True, False, True]]) + + accepted_two, rejected_two, selected_two, electrodes_two = eegthresh(data[:, :, :2], 3, [1, 2], 2, 13, [1, 6], 1, 6) + np.testing.assert_array_equal(accepted_two, [2]) + np.testing.assert_array_equal(rejected_two, [1]) + np.testing.assert_array_equal(selected_two, data[:, :, [1]]) + np.testing.assert_array_equal(electrodes_two, [[True], [True]]) + + +@eeglab_test( + "unittesting_sigprocfunc/eegthresh/sigprocfunc_eegthresh_wrapperTest.m", + "test_pass_one_elec", +) +def test_eegthresh_preserves_all_channels_when_testing_one_electrode(): + data = np.empty((2, 3, 5), dtype=float) + data[0] = [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 13]] + data[1] = [[1, 4, 7, 10, 13], [2, 5, 8, 11, 14], [3, 6, 9, 12, 15]] + + accepted, rejected, selected, electrodes = eegthresh(data, 3, [2], 2, 13, [1, 15], 1, 15) + + np.testing.assert_array_equal(accepted, [2, 3, 4]) + np.testing.assert_array_equal(rejected, [1, 5]) + np.testing.assert_array_equal(selected, data[:, :, [1, 2, 3]]) + np.testing.assert_array_equal(electrodes, [[True, True]]) + + +@eeglab_test( + "unittesting_sigprocfunc/eegthresh/sigprocfunc_eegthresh_wrapperTest.m", + "test_pass_rej_all", +) +@eeglab_test( + "unittesting_sigprocfunc/eegthresh/sigprocfunc_eegthresh_wrapperTest.m", + "test_pass_rej_nothing", +) +def test_eegthresh_handles_all_and_no_rejections(): + data = np.empty((2, 3, 5), dtype=float) + data[0] = [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]] + data[1] = [[1, 4, 7, 10, 13], [2, 5, 8, 11, 14], [3, 6, 9, 12, 15]] + + accepted, rejected, selected, _ = eegthresh(data, 3, [1, 2], 2, 11, [1, 15], 1, 15) + assert accepted.size == 0 + np.testing.assert_array_equal(rejected, [1, 2, 3, 4, 5]) + assert selected.shape == (2, 3, 0) + + accepted, rejected, selected, electrodes = eegthresh(data, 3, [1, 2], 1, 15, [1, 15], 1, 15) + np.testing.assert_array_equal(accepted, [1, 2, 3, 4, 5]) + assert rejected.size == 0 + np.testing.assert_array_equal(selected, data) + assert electrodes.shape == (2, 0) + + +@eeglab_test( + "unittesting_sigprocfunc/eegthresh/sigprocfunc_eegthresh_wrapperTest.m", + "test_pass_continuous", +) +@eeglab_test( + "unittesting_sigprocfunc/eegthresh/sigprocfunc_eegthresh_wrapperTest.m", + "test_pass_one_epoch", +) +def test_eegthresh_accepts_continuous_and_single_epoch_shapes(): + data = np.arange(12, dtype=float).reshape(2, 6) + + continuous = eegthresh(data, 3, [1], -1, 20, [1, 6], 1, 6) + one_epoch = eegthresh(data[:, :, np.newaxis], 6, [1], -1, 20, [1, 6], 1, 6) + + assert continuous[2].shape == (2, 6) + assert one_epoch[2].shape == (2, 6, 1) def test_rejection_helper_compatibility_outputs_are_eeglab_facing(): @@ -254,3 +496,30 @@ def test_rejection_helper_compatibility_outputs_are_eeglab_facing(): assert trend_rows.shape == (1, 2) np.testing.assert_allclose(probabilities, [0.5, 0.5, 0.5, 0.5]) np.testing.assert_allclose(distribution, [0.5, 0.5]) + + +@eeglab_test( + "unittesting_sigprocfunc/rejtrend/sigprocfunc_rejtrend_wrapperTest.m", + "test_test_rejtrend", +) +def test_rejtrend_upstream_parameter_combinations_preserve_trial_contract(): + rng = np.random.default_rng(14) + signal = rng.normal(size=(6, 1000, 9)) + signal[0, :, 2] += np.linspace(0, 20, 1000) + + for pointrange, maxslope, min_r, step in ( + (384, 0.5, 0.3, None), + (384, 0.5, 1, None), + (384, 0.5, 0, None), + (384, 10, 0.3, None), + (1000, 0.5, 0.3, None), + (384, 0.5, 0.3, 2), + (384, 0.5, 1, 3), + (384, 0.5, 0, 10), + (384, 10, 0.3, 100), + (1000, 0.5, 0.3, 1), + ): + rejected, row_marks = rejtrend(signal, pointrange, maxslope, min_r, step) + assert rejected.shape == (9,) + assert row_marks.shape == (6, 9) + np.testing.assert_array_equal(rejected, row_marks.any(axis=0)) diff --git a/tests/test_pop_reref.py b/tests/test_pop_reref.py index 38ee305d..c4b7cd04 100644 --- a/tests/test_pop_reref.py +++ b/tests/test_pop_reref.py @@ -17,6 +17,7 @@ sys.path.insert(0, 'src') from eegprep.functions.popfunc.pop_loadset import pop_loadset from eegprep.functions.popfunc.pop_reref import pop_reref +from eegprep.functions.sigprocfunc.reref import reref from eegprep.functions.adminfunc.eeglabcompat import get_eeglab from eegprep.utils.testing import DebuggableTestCase from tests.eeglab_tests import eeglab_test @@ -62,6 +63,49 @@ def test_pop_reref_current_suite_average_reference_workflow(): np.testing.assert_allclose(output["data"].mean(axis=0), 0, atol=1e-5) +@eeglab_test( + "unittesting_sigprocfunc/reref/sigprocfunc_reref_wrapperTest.m", + "test_test_reref", +) +def test_low_level_reref_upstream_continuous_and_epoched_contracts(): + rng = np.random.default_rng(42) + locs = [{"labels": f"Ch{index + 1}"} for index in range(32)] + + for data in (rng.normal(size=(32, 200)), rng.normal(size=(32, 40, 5))): + average, *_ = reref(data, [], keepref="on") + explicit_average, *_ = reref(data, np.arange(32), keepref="on") + np.testing.assert_allclose(average, explicit_average, rtol=1e-12, atol=1e-12) + + removed, *_ = reref(data, [1, 4, 25], keepref="off") + assert removed.shape == (29, *data.shape[1:]) + + kept, *_ = reref(data, [4], keepref="on") + assert kept.shape == data.shape + np.testing.assert_allclose(kept[4], 0, atol=1e-12) + + excluded, out_locs, *_ = reref( + data, + [1, 4, 25], + exclude=[0, 31], + keepref="off", + elocs=locs, + ) + assert excluded.shape == (29, *data.shape[1:]) + np.testing.assert_array_equal(excluded[[0, -1]], data[[0, 31]]) + assert len(out_locs) == 29 + + restored, restored_locs, *_ = reref( + data, + [1, 4, 25], + exclude=[0, 31], + keepref="off", + elocs=locs, + refloc={"labels": "old-reference"}, + ) + assert restored.shape == (30, *data.shape[1:]) + assert len(restored_locs) == 30 + + class PopRerefIcaRegressionTests(unittest.TestCase): def test_float_icachansind_values_update_ica_without_list_index_error(self): """MATLAB-loaded float ICA channel indices should stay usable internally.""" diff --git a/tests/test_pop_rmbase.py b/tests/test_pop_rmbase.py index 29facc2e..23964f35 100644 --- a/tests/test_pop_rmbase.py +++ b/tests/test_pop_rmbase.py @@ -35,6 +35,28 @@ def test_rmbase_removes_epoch_baseline_and_returns_means(): assert out.shape == data.shape +@eeglab_test( + "unittesting_sigprocfunc/rmbase/sigprocfunc_rmbase_wrapperTest.m", + "test_test_rmbase", +) +def test_rmbase_upstream_frame_and_baseline_vector_call_forms(): + rng = np.random.default_rng(12) + epoched = rng.normal(size=(32, 384, 8)).astype(np.float32) + + for frames, baseline in ((None, 0), (384, 0), (192, 0), (384, np.arange(1, 129))): + corrected, means = rmbase(epoched, frames, baseline, return_mean=True) + assert corrected.shape == epoched.shape + expected_epochs = epoched.size // epoched.shape[0] // int(frames or epoched.shape[1] * epoched.shape[2]) + assert means.shape == (32, expected_epochs) + + continuous = rng.normal(size=(32, 30504)).astype(np.float32) + for frames, baseline in ((None, 0), (30504, 0), (3813, 0), (3813, np.arange(1, 1001))): + corrected, means = rmbase(continuous, frames, baseline, return_mean=True) + assert corrected.shape == continuous.shape + expected_epochs = continuous.shape[1] // int(frames or continuous.shape[1]) + assert means.shape == (32, expected_epochs) + + def _legacy_rmbase( data: np.ndarray, frames: int, diff --git a/tests/test_rejection_workflows.py b/tests/test_rejection_workflows.py index 80d4d0b7..dc05f568 100644 --- a/tests/test_rejection_workflows.py +++ b/tests/test_rejection_workflows.py @@ -436,6 +436,105 @@ def test_jointprob_global_marks_match_eeglab_trial_rows_for_duplicate_channels() np.testing.assert_array_equal(row_marks[2], expected_local[2]) +@eeglab_test( + "unittesting_sigprocfunc/jointprob/sigprocfunc_jointprob_wrapperTest.m", + "test_pass_1d_row", +) +@eeglab_test( + "unittesting_sigprocfunc/jointprob/sigprocfunc_jointprob_wrapperTest.m", + "test_pass_1d_col", +) +@eeglab_test( + "unittesting_sigprocfunc/jointprob/sigprocfunc_jointprob_wrapperTest.m", + "test_pass_general", +) +def test_jointprob_vector_orientation_and_defaults_match_upstream(): + expected = -np.sum(np.log([2 / 3, 2 / 3, 1 / 3])) + + for signal in (np.asarray([1, 1, 3]), np.asarray([[1], [1], [3]])): + scores, rejected = jointprob(signal) + np.testing.assert_allclose(scores, [[expected]]) + np.testing.assert_array_equal(rejected, [[False]]) + + +@eeglab_test( + "unittesting_sigprocfunc/jointprob/sigprocfunc_jointprob_wrapperTest.m", + "test_pass_3d", +) +def test_jointprob_three_dimensional_scores_match_upstream(): + signal = np.empty((3, 4, 2), dtype=float) + signal[:, :, 0] = [[1, 1, 3, 4], [1, 2, 1, 4], [1, 2, 3, 4]] + signal[:, :, 1] = [[1, 2, 3, 4], [1, 2, 1, 4], [2, 2, 3, 4]] + expected = np.asarray( + [ + [-np.sum(np.log([3 / 8, 3 / 8, 2 / 8, 2 / 8])), -np.sum(np.log([3 / 8, 1 / 8, 2 / 8, 2 / 8]))], + [-np.sum(np.log([4 / 8, 2 / 8, 4 / 8, 2 / 8])), -np.sum(np.log([4 / 8, 2 / 8, 4 / 8, 2 / 8]))], + [-np.sum(np.log([1 / 8, 3 / 8, 2 / 8, 2 / 8])), -np.sum(np.log([3 / 8, 3 / 8, 2 / 8, 2 / 8]))], + ] + ) + + scores, rejected = jointprob(signal) + + np.testing.assert_allclose(scores, expected) + np.testing.assert_array_equal(rejected, np.zeros((3, 2), dtype=bool)) + + +@eeglab_test( + "unittesting_sigprocfunc/jointprob/sigprocfunc_jointprob_wrapperTest.m", + "test_pass_threshold", +) +@eeglab_test( + "unittesting_sigprocfunc/jointprob/sigprocfunc_jointprob_wrapperTest.m", + "test_pass_jp_threshold", +) +def test_jointprob_computed_and_precomputed_thresholds_match_upstream(): + signal = np.asarray([[1, 1, 5], [1, 2, 1], [1, 2, 5]]) + expected = np.asarray( + [ + -np.sum(np.log([2 / 3, 2 / 3, 1 / 3])), + -np.sum(np.log([2 / 3, 1 / 3, 2 / 3])), + -np.sum(np.log([1 / 3, 1 / 3, 1 / 3])), + ] + )[:, np.newaxis] + + scores, rejected = jointprob(signal, 2) + reused, reused_rejected = jointprob(signal, 2, expected) + + np.testing.assert_allclose(scores, expected) + np.testing.assert_allclose(reused, expected) + np.testing.assert_array_equal(rejected, [[False], [False], [True]]) + np.testing.assert_array_equal(reused_rejected, rejected) + + +@eeglab_test( + "unittesting_sigprocfunc/jointprob/sigprocfunc_jointprob_wrapperTest.m", + "test_pass_normalize_2d", +) +@eeglab_test( + "unittesting_sigprocfunc/jointprob/sigprocfunc_jointprob_wrapperTest.m", + "test_pass_normalize_3d", +) +def test_jointprob_uses_matlab_sample_standard_deviation_for_normalization(): + signal = np.asarray([[1, 1, 3], [1, 2, 1], [1, 2, 3]]) + raw, _ = jointprob(signal) + + normalized, rejected = jointprob(signal, 0, normalize=1) + expected = (raw - raw.mean()) / raw.std(ddof=1) + + np.testing.assert_allclose(normalized, expected) + np.testing.assert_array_equal(rejected, np.zeros((3, 1), dtype=bool)) + + trials = np.stack([signal, signal[:, ::-1]], axis=2) + raw_3d, _ = jointprob(trials) + normalized_3d, _ = jointprob(trials, 0, normalize=1) + expected_3d = raw_3d - raw_3d.mean(axis=1, keepdims=True) + std_3d = raw_3d.std(axis=1, ddof=1, keepdims=True) + std_3d[std_3d == 0] = 1 + expected_3d /= std_3d + assert normalized_3d.shape == (3, 2) + np.testing.assert_allclose(normalized_3d, expected_3d) + + def test_jointprob_global_threshold_can_reject_when_local_threshold_does_not(): data = np.array( [ @@ -497,6 +596,28 @@ def test_kurtosis_global_marks_match_eeglab_trial_rows_for_duplicate_channels(): np.testing.assert_array_equal(row_marks[1], expected_local[2]) +@eeglab_test( + "unittesting_sigprocfunc/rejkurt/sigprocfunc_rejkurt_wrapperTest.m", + "test_test_rejkurt", +) +def test_rejkurt_upstream_parameter_combinations_return_finite_trial_marks(): + rng = np.random.default_rng(8) + signal = rng.normal(size=(8, 80, 12)) + + calls = ( + (0, None, 0), + (2, None, 0), + (1.5, np.ones((8, 12)), 0), + (0.5, None, 1), + (0.5, None, 2), + ) + for threshold, old_scores, normalize in calls: + scores, rejected = rejkurt(signal, threshold, old_scores, normalize) + assert scores.shape == rejected.shape == (8, 12) + assert np.isfinite(scores).all() + np.testing.assert_array_equal(rejected, np.abs(scores) > threshold if threshold else np.zeros_like(rejected)) + + def test_kurtosis_global_threshold_can_reject_when_local_threshold_does_not(): rng = np.random.default_rng(0) data = rng.normal(size=(2, 12, 2)) diff --git a/tests/test_runica.py b/tests/test_runica.py index d4b0ee60..92ec1a5b 100644 --- a/tests/test_runica.py +++ b/tests/test_runica.py @@ -19,11 +19,16 @@ from eegprep.functions.sigprocfunc.runica import runica from eegprep.functions.adminfunc.eeglabcompat import get_eeglab from eegprep.functions.popfunc.pop_loadset import pop_loadset +from tests.eeglab_tests import eeglab_test + + +_RUNICA_SOURCE = "unittesting_sigprocfunc/runica/sigprocfunc_runica_wrapperTest.m" class TestRunicaFunctionality(unittest.TestCase): """Test runica functionality without MATLAB dependency.""" + @eeglab_test(_RUNICA_SOURCE, "test_pass_general") def test_basic_ica(self): """Test basic ICA decomposition with default parameters.""" # Set seed for reproducibility @@ -59,6 +64,7 @@ def test_runica_does_not_mutate_input_array(self): # The float64 input array passed by the caller must be untouched. self.assertTrue(np.array_equal(data, original)) + @eeglab_test(_RUNICA_SOURCE, "test_pass_extended") def test_extended_ica(self): """Test extended-ICA mode.""" np.random.seed(42) @@ -90,6 +96,7 @@ def test_sample_data_extended_ica_does_not_surface_finite_matmul_warnings(self): self.assertTrue(np.isfinite(signs).all()) self.assertFalse([warning for warning in captured if "matmul" in str(warning.message)]) + @eeglab_test(_RUNICA_SOURCE, "test_pass_pca") def test_pca_reduction(self): """Test PCA dimension reduction.""" np.random.seed(42) @@ -166,15 +173,99 @@ def test_bias_on(self): # Bias should be non-zero after training self.assertTrue(np.any(bias != 0)) + @eeglab_test(_RUNICA_SOURCE, "test_pass_extended_nobias") + @eeglab_test(_RUNICA_SOURCE, "test_pass_general_nobias") def test_bias_off(self): """Test with bias='off'.""" np.random.seed(42) data = np.random.randn(5, 500) - w, s, cv, bias, sg, lr = runica(data, bias='off', maxsteps=5, verbose=False, rndreset='off') + for extended in (0, 1): + _w, _s, _cv, bias, _sg, _lr = runica( + data, + bias="off", + extended=extended, + maxsteps=5, + verbose=False, + rndreset="off", + ) + self.assertTrue(np.all(bias == 0)) + + @eeglab_test(_RUNICA_SOURCE, "test_pass_ncomps") + def test_empty_ncomps_uses_all_channels(self): + rng = np.random.default_rng(5) + + weights, sphere, *_ = runica( + rng.standard_normal((3, 400)), + ncomps=[], + maxsteps=2, + verbose=False, + rndreset="off", + ) + + self.assertEqual(weights.shape, (3, 3)) + self.assertEqual(sphere.shape, (3, 3)) + + @eeglab_test(_RUNICA_SOURCE, "test_pass_extended_noise10dB") + @eeglab_test(_RUNICA_SOURCE, "test_pass_extended_noise20dB") + @eeglab_test(_RUNICA_SOURCE, "test_pass_general_noise10dB") + @eeglab_test(_RUNICA_SOURCE, "test_pass_general_noise20dB") + def test_noisy_two_source_mixtures_produce_finite_full_rank_decompositions(self): + rng = np.random.default_rng(6) + sources = np.vstack( + [ + np.sin(np.linspace(0, 50, 1000)), + np.sin(np.linspace(0, 37, 1000) + 5), + ] + ) + mixed = np.array([[1, -2], [1.73, 3.41]]) @ sources + + for extended in (0, 1): + for signal_to_noise_db in (10, 20): + noise = rng.standard_normal(mixed.shape) + scale = np.sqrt(np.mean(mixed**2) / np.mean(noise**2) / 10 ** (signal_to_noise_db / 10)) + weights, sphere, *_ = runica( + mixed + noise * scale, + extended=extended, + verbose=False, + rndreset="off", + ) + unmixing = weights @ sphere + self.assertTrue(np.isfinite(unmixing).all()) + self.assertEqual(np.linalg.matrix_rank(unmixing), 2) + + @eeglab_test(_RUNICA_SOURCE, "test_pass_posact") + def test_posact_orients_largest_absolute_activation_positive(self): + sources = np.vstack( + [ + np.sin(np.linspace(0, 50, 1000)), + np.sin(np.linspace(0, 37, 1000) + 5), + ] + ) + data = np.vstack([sources[0] - 2 * sources[1], 1.73 * sources[0] + 3.41 * sources[1]]) + + weights, sphere, *_ = runica(data, posact="on", extended=1, verbose=False, rndreset="off") + activations = weights @ sphere @ data + peak_frames = np.argmax(np.abs(activations), axis=1) - # Bias should remain zero - self.assertTrue(np.all(bias == 0)) + self.assertTrue(np.all(activations[np.arange(activations.shape[0]), peak_frames] >= 0)) + + @eeglab_test(_RUNICA_SOURCE, "test_pass_weights") + def test_initial_weights_are_accepted_without_changing_output_contract(self): + rng = np.random.default_rng(4) + data = rng.standard_normal((3, 600)) + initial = np.array([[1.0, 0.1, 0.0], [0.0, 1.0, 0.1], [0.1, 0.0, 1.0]]) + + weights, sphere, compvars, bias, signs, lrates = runica( + data, weights=initial, maxsteps=2, verbose=False, rndreset="off" + ) + + self.assertEqual(weights.shape, (3, 3)) + self.assertEqual(sphere.shape, (3, 3)) + self.assertTrue(np.isfinite(compvars).all()) + self.assertTrue(np.isfinite(bias).all()) + self.assertTrue(np.isfinite(signs).all()) + self.assertGreater(len(lrates), 0) def test_momentum(self): """Test momentum parameter.""" diff --git a/tests/test_sigproc_eegrej.py b/tests/test_sigproc_eegrej.py index 5ce925cc..99ceee10 100644 --- a/tests/test_sigproc_eegrej.py +++ b/tests/test_sigproc_eegrej.py @@ -4,7 +4,9 @@ import numpy as np +from eegprep import eeg_eegrej from eegprep.functions.sigprocfunc.eegrej import eegrej +from tests.eeglab_tests import eeglab_test class TestSigprocEegrej(unittest.TestCase): @@ -40,17 +42,71 @@ def test_multiple_regions_without_nested_boundaries(self): _, _, _, boundevents = eegrej(self.data, [[5, 8], [12, 14]], self.timelength) np.testing.assert_array_equal(boundevents, [4.5, 7.5]) + @eeglab_test( + "unittesting_sigprocfunc/eegrej/sigprocfunc_eegrej_wrapperTest.m", + "test_pass_general", + ) + @eeglab_test( + "unittesting_sigprocfunc/eegrej/sigprocfunc_eegrej_wrapperTest.m", + "test_pass_events", + ) + def test_upstream_disjoint_regions_data_duration_and_boundaries(self): + data = np.arange(1, 16, dtype=float).reshape(3, 5) + + outdata, newtime, events, boundaries = eegrej(data, [[1, 1], [3, 4]], 5, []) + + np.testing.assert_array_equal(outdata, [[2, 5], [7, 10], [12, 15]]) + self.assertEqual(newtime, 2) + self.assertEqual(events, []) + np.testing.assert_array_equal(boundaries, [0.5, 1.5]) + def test_adjacent_regions_merge_to_single_boundary(self): # Adjacent regions excise a contiguous block; the two boundaries collapse # to one latency after the base-span shift. _, _, _, boundevents = eegrej(self.data, [[5, 8], [9, 12]], self.timelength) np.testing.assert_array_equal(boundevents, [4.5]) + @eeglab_test( + "unittesting_sigprocfunc/eegrej/sigprocfunc_eegrej_wrapperTest.m", + "test_pass_overlap", + ) def test_overlapping_regions_merge_to_single_boundary(self): # Overlapping regions are de-overlapped then excised as one contiguous block. _, _, _, boundevents = eegrej(self.data, [[5, 10], [8, 12]], self.timelength) np.testing.assert_array_equal(boundevents, [4.5]) + @eeglab_test( + "unittesting_sigprocfunc/eegrej/sigprocfunc_eegrej_wrapperTest.m", + "test_passboundary", + ) + def test_eeg_eegrej_boundaries_round_trip_to_original_intervals(self): + eeg = { + "data": np.arange(2000, dtype=float).reshape(1, 2000), + "nbchan": 1, + "pnts": 2000, + "trials": 1, + "srate": 100.0, + "xmin": 0.0, + "xmax": 19.99, + "times": np.arange(2000) / 100, + "event": [], + "urevent": [], + "epoch": [], + } + for intervals in ( + np.asarray([[1, 200], [1000, 1200]]), + np.asarray([[100, 150], [200, 250], [300, 350], [400, 500], [1000, 1200]]), + ): + rejected = eeg_eegrej(eeg, intervals) + boundaries = [event for event in rejected["event"] if event["type"] == "boundary"] + latencies = np.asarray([event["latency"] for event in boundaries], dtype=float) + durations = np.asarray([event["duration"] for event in boundaries], dtype=float) + cumulative = np.cumsum(durations) + original_starts = latencies + np.r_[0, cumulative[:-1]] + reconstructed = np.ceil(np.column_stack([original_starts, original_starts + durations - 1])).astype(int) + + np.testing.assert_array_equal(reconstructed, intervals) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_statistics_package.py b/tests/test_statistics_package.py index 0ee57430..dace5f47 100644 --- a/tests/test_statistics_package.py +++ b/tests/test_statistics_package.py @@ -11,6 +11,7 @@ from scipy import io as scipy_io from scipy import stats as scipy_stats +from tests.eeglab_tests import eeglab_test from eegprep.functions.statistics import ( TwoWayEffects, anova1_cell, @@ -47,6 +48,34 @@ def test_fdr_matches_bh_and_by_thresholds(): npt.assert_array_equal(by.mask, [True, True, False, False]) +@eeglab_test( + "unittesting_sigprocfunc/fdr/sigprocfunc_fdr_wrapperTest.m", + "test_test_fdr", +) +def test_fdr_upstream_call_forms_preserve_threshold_and_mask_shapes(): + pvals = np.asarray( + [ + [1.0, 0.2385, 0.4182, 0.0611], + [0.2385, 1.0, 0.4502, 0.0049], + [0.4182, 0.4502, 1.0, 0.0001], + [0.0611, 0.0049, 0.0001, 1.0], + ] + ) + + default = fdr(pvals) + assert np.asarray(default.threshold).shape == pvals.shape + assert default.mask.shape == pvals.shape + + for q in (0.8, 0.05, 0.5): + result = fdr(pvals, q) + assert np.isscalar(result.threshold) + npt.assert_array_equal(result.mask, pvals <= result.threshold) + + nonparametric = fdr(pvals, 0.5, "nonParametric") + assert nonparametric.threshold <= fdr(pvals, 0.5).threshold + npt.assert_array_equal(nonparametric.mask, pvals <= nonparametric.threshold) + + def test_fdr_uses_finite_pvalues_for_threshold_denominator(): pvals = np.array([0.01, 0.03, np.nan, np.inf]) @@ -101,6 +130,10 @@ def test_ttest_helpers_match_scipy_statistics(): assert pooled_df == 19 +@eeglab_test( + "unittesting_sigprocfunc/concatdata/sigprocfunc_concatdata_wrapperTest.m", + "test_test_concatdata", +) def test_corrcoef_and_concatdata_contracts(): first = np.array([[1, 2, 3, 4], [1, 3, 5, 7]], dtype=float) second = np.array([[4, 3, 2, 1], [2, 4, 6, 8]], dtype=float) diff --git a/tests/test_topoplot.py b/tests/test_topoplot.py index 95f71155..2b9e29f1 100644 --- a/tests/test_topoplot.py +++ b/tests/test_topoplot.py @@ -14,6 +14,7 @@ from matplotlib.collections import PathCollection from unittest.mock import patch import tempfile +import warnings import scipy.io # Set Agg backend before importing topoplot to avoid display issues @@ -22,6 +23,7 @@ from eegprep.functions.sigprocfunc.topoplot import _contour_levels, topoplot, griddata_v4, topo_screen_coords from eegprep import pop_loadset, pop_saveset from eegprep.functions.adminfunc.eeglabcompat import get_eeglab +from tests.eeglab_tests import eeglab_test local_url = os.path.join(os.path.dirname(__file__), '../sample_data/') @@ -66,6 +68,20 @@ def test_interpolation_finite_results(self): # All results should be finite self.assertTrue(np.all(np.isfinite(vq))) + def test_interpolation_does_not_leak_finite_matmul_warnings(self): + theta = np.linspace(0, 2 * np.pi, 32, endpoint=False) + x = np.cos(theta) + y = np.sin(theta) + values = np.linspace(-1, 1, 32) + query = np.linspace(-1, 1, 67) + xq, yq = np.meshgrid(query, query) + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + interpolated = griddata_v4(x, y, values, xq, yq) + + assert np.isfinite(interpolated).all() + def test_single_point_interpolation(self): """Test interpolation with single data point.""" x_single = np.array([0.5]) @@ -156,6 +172,7 @@ def setUp(self): ] self.minimal_data = np.array([1.0, 0.5, -0.5]) + @eeglab_test("unittesting_sigprocfunc/topoplot/sigprocfunc_topoplot_wrapperTest.m", "test_test_topoplot") def test_basic_topoplot_with_agg_backend(self): """Test basic topoplot functionality with Agg backend (no display).""" # Ensure Agg backend is set From 9dd882454603f6c4ef5c397c417d73f86a820b0e Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:46 -0700 Subject: [PATCH 08/45] Port applicable EEGLAB binary tests (#336) --- docs/source/api/interactive_pop_workflows.rst | 5 + src/eegprep/functions/popfunc/_file_io.py | 2 + src/eegprep/functions/popfunc/pop_biosig.py | 7 +- .../functions/popfunc/pop_chancoresp.py | 49 ++- .../functions/popfunc/pop_chanevent.py | 68 ++- src/eegprep/functions/popfunc/pop_fileio.py | 21 +- .../functions/popfunc/pop_importpres.py | 128 +++++- src/eegprep/resources/help/pop_biosig.md | 5 + src/eegprep/resources/help/pop_chancoresp.md | 3 +- src/eegprep/resources/help/pop_chanevent.md | 5 +- src/eegprep/resources/help/pop_fileio.md | 4 + src/eegprep/resources/help/pop_importpres.md | 9 +- tests/test_binary_eeglab_tests.py | 388 ++++++++++++++++++ tests/test_phase2_file_channel_location.py | 2 +- 14 files changed, 638 insertions(+), 58 deletions(-) create mode 100644 tests/test_binary_eeglab_tests.py diff --git a/docs/source/api/interactive_pop_workflows.rst b/docs/source/api/interactive_pop_workflows.rst index 7b1391bf..8e382f3a 100644 --- a/docs/source/api/interactive_pop_workflows.rst +++ b/docs/source/api/interactive_pop_workflows.rst @@ -8,6 +8,11 @@ User-facing ``pop_*`` wrappers. Each accepts ``return_com=True`` and returns a r Loading and Saving ------------------ +``pop_biosig`` and ``pop_fileio`` accept ``blockrange=[start, stop]`` for +half-open, seconds-based reads of continuous EDF, BDF, and other MNE-backed +formats. ``pop_importpres`` recognizes tab-delimited Presentation headers and +also accepts EEGLAB's positional event-type, time, and duration field names. + .. autosummary:: :toctree: generated/ diff --git a/src/eegprep/functions/popfunc/_file_io.py b/src/eegprep/functions/popfunc/_file_io.py index 21e1be0a..67ac84fc 100644 --- a/src/eegprep/functions/popfunc/_file_io.py +++ b/src/eegprep/functions/popfunc/_file_io.py @@ -217,6 +217,8 @@ def records_to_events( raise ValueError("Imported event records must include a latency field") event = dict(record) event["latency"] = _latency_to_samples(event["latency"], srate=srate, timeunit=timeunit) + if "duration" in event and timeunit is not None and not (isinstance(timeunit, float) and math.isnan(timeunit)): + event["duration"] = float(event["duration"]) * float(timeunit) * float(srate) if "type" not in event: event["type"] = "event" events.append(event) diff --git a/src/eegprep/functions/popfunc/pop_biosig.py b/src/eegprep/functions/popfunc/pop_biosig.py index 37cd2fce..6f6a74bf 100644 --- a/src/eegprep/functions/popfunc/pop_biosig.py +++ b/src/eegprep/functions/popfunc/pop_biosig.py @@ -15,13 +15,16 @@ def pop_biosig( filename: str | Path, *, return_com: bool = False, **kwargs: Any ) -> dict[str, Any] | tuple[dict[str, Any], str]: - """Import BIOSIG-style EDF/BDF/GDF files.""" + """Import BIOSIG-style EDF/BDF/GDF files, optionally over a time range.""" path = Path(filename) if path.suffix.lower() not in _BIOSIG_SUFFIXES: raise ValueError( "pop_biosig supports EDF, BDF, and GDF files. Use pop_fileio or pop_loadset for other formats." ) eeg, _command = pop_fileio(filename, return_com=True, **kwargs) - command = f"EEG = pop_biosig({format_history_value(Path(filename))});" + arguments = [format_history_value(path)] + for key, value in kwargs.items(): + arguments.extend([format_history_value(key), format_history_value(value)]) + command = f"EEG = pop_biosig({', '.join(arguments)});" eeg["history"] = command return (eeg, command) if return_com else eeg diff --git a/src/eegprep/functions/popfunc/pop_chancoresp.py b/src/eegprep/functions/popfunc/pop_chancoresp.py index 87e4e30f..84903fd7 100644 --- a/src/eegprep/functions/popfunc/pop_chancoresp.py +++ b/src/eegprep/functions/popfunc/pop_chancoresp.py @@ -8,7 +8,7 @@ from eegprep.functions.popfunc._pop_utils import format_history_value, parse_key_value_args, parse_numeric_sequence -FIDUCIAL_ALIASES = (("nz", "nasion", "fidnz"), ("lpa", "left", "fidt9"), ("rpa", "right", "fidt10")) +FIDUCIAL_ALIASES = (("nz", "nasion", "fidnz"), ("lpa", "left", "fidt10"), ("rpa", "right", "fidt9")) def pop_chancoresp(chans1: Any, chans2: Any, *args: Any, return_com: bool = False, **kwargs: Any) -> Any: @@ -53,16 +53,25 @@ def _subcommand(command: str, *args: Any) -> tuple[Any, ...]: labels2 = _labels(args[3]) chanlist1 = list(args[4]) if len(args) > 4 else [] chanlist2 = list(args[5]) if len(args) > 5 else [] + str1 = str(args[6]) if len(args) > 6 else "" + str2 = str(args[7]) if len(args) > 7 else "" if name == "pair": - if ind1 not in chanlist1 and ind2 not in chanlist2: - chanlist1.append(ind1) - chanlist2.append(ind2) - else: - for pos, (left, right) in reversed(list(enumerate(zip(chanlist1, chanlist2)))): - if left == ind1 or right == ind2: - chanlist1.pop(pos) - chanlist2.pop(pos) - return (*_list_text(labels1, labels2, chanlist1, chanlist2), chanlist1, chanlist2) + if ind1 in chanlist1 or ind2 in chanlist2: + return str1, str2, chanlist1, chanlist2 + chanlist1.append(ind1) + chanlist2.append(ind2) + return ( + _pair_text(ind1, ind2, labels1, labels2), + _pair_text(ind2, ind1, labels2, labels1), + chanlist1, + chanlist2, + ) + if ind1 not in chanlist1 or ind2 not in chanlist2: + return str1, str2, chanlist1, chanlist2 + position = chanlist1.index(ind1) + chanlist1.pop(position) + chanlist2.pop(position) + return _channel_text(ind1, labels1), _channel_text(ind2, labels2), chanlist1, chanlist2 raise ValueError(f"Unsupported pop_chancoresp subcommand: {command}") @@ -124,20 +133,24 @@ def _list_text( paired = dict(zip(chanlist1, chanlist2)) reverse = dict(zip(chanlist2, chanlist1)) left = [ - f"{index:2d} - {label:3s} -> {paired[index]:2d} - {labels2[paired[index] - 1]:3s}" - if index in paired - else f"{index:2d} - {label:3s}" - for index, label in enumerate(labels1, start=1) + _pair_text(index, paired[index], labels1, labels2) if index in paired else _channel_text(index, labels1) + for index in range(1, len(labels1) + 1) ] right = [ - f"{index:2d} - {label:3s} -> {reverse[index]:2d} - {labels1[reverse[index] - 1]:3s}" - if index in reverse - else f"{index:2d} - {label:3s}" - for index, label in enumerate(labels2, start=1) + _pair_text(index, reverse[index], labels2, labels1) if index in reverse else _channel_text(index, labels2) + for index in range(1, len(labels2) + 1) ] return left, right +def _channel_text(index: int, labels: list[str]) -> str: + return f"{index:2d} - {labels[index - 1]:>3s}" + + +def _pair_text(index1: int, index2: int, labels1: list[str], labels2: list[str]) -> str: + return f"{_channel_text(index1, labels1)} -> {_channel_text(index2, labels2)}" + + def _history_command(options: dict[str, Any]) -> str: pieces = [] for key, value in options.items(): diff --git a/src/eegprep/functions/popfunc/pop_chanevent.py b/src/eegprep/functions/popfunc/pop_chanevent.py index b3c96d1b..a8e8597f 100644 --- a/src/eegprep/functions/popfunc/pop_chanevent.py +++ b/src/eegprep/functions/popfunc/pop_chanevent.py @@ -36,10 +36,19 @@ def pop_chanevent( events = [] for channel in channels: x = data[channel - 1, :] + nbtype = options.get("nbtype", np.nan) + named_type = not _is_nan(nbtype) or np.unique(x).size == 2 if "oper" in options and options["oper"]: x = _apply_oper(x, str(options["oper"])) events.extend( - _events_from_channel(x, channel, edge=edge, duration=duration, edgelen=int(options.get("edgelen", 1))) + _events_from_channel( + x, + edge=edge, + duration=duration, + edgelen=int(options.get("edgelen", 1)), + typename=str(options.get("typename", f"chan{channel}")), + named_type=named_type, + ) ) out = deepcopy(EEG) delete_events = str(options.get("delevent", "on")).lower() in {"on", "yes", "true", "1"} @@ -72,23 +81,35 @@ def pop_chanevent( def _events_from_channel( - x: np.ndarray, channel: int, *, edge: str, duration: bool, edgelen: int + x: np.ndarray, + *, + edge: str, + duration: bool, + edgelen: int, + typename: str, + named_type: bool, ) -> list[dict[str, Any]]: values = np.asarray(x) - diff = np.diff(np.r_[values, values[-1]]) - leading = np.flatnonzero(diff > 0) + 1 - trailing = np.flatnonzero(diff < 0) + 2 + diff = np.diff(np.abs(np.r_[values, values[-1]])) + leading = _keep_first_of_close(np.flatnonzero(diff > 0) + 1, edgelen) + trailing = _keep_last_of_close(np.flatnonzero(diff < 0) + 2, edgelen) if edge == "leading": - latencies = _drop_close(leading, edgelen) + latencies = leading elif edge == "trailing": - latencies = _drop_close(trailing, edgelen) + latencies = trailing else: - latencies = np.sort(np.r_[_drop_close(leading, edgelen), _drop_close(trailing, edgelen)]) + latencies = np.sort(np.r_[leading, trailing]) events = [] for latency in latencies: - event = {"type": f"chan{channel}", "latency": int(latency)} + if edge == "both": + event_edge = "trailing" if latency in trailing else "leading" + else: + event_edge = edge + event_type = typename if named_type else _event_value(values, int(latency), event_edge) + event = {"type": event_type, "latency": int(latency)} if duration: - next_trailing = trailing[trailing >= latency] + falling_boundaries = trailing - 1 + next_trailing = falling_boundaries[falling_boundaries >= latency] event["duration"] = int(next_trailing[0] - latency) if next_trailing.size else int(values.size - latency) events.append(event) return events @@ -140,14 +161,29 @@ def _urevent_record(event: dict[str, Any]) -> dict[str, Any]: return record -def _drop_close(values: np.ndarray, edgelen: int) -> np.ndarray: +def _keep_first_of_close(values: np.ndarray, edgelen: int) -> np.ndarray: if values.size < 2 or edgelen <= 1: return values - keep = [values[0]] - for value in values[1:]: - if value - keep[-1] >= edgelen: - keep.append(value) - return np.asarray(keep, dtype=int) + return values[np.r_[True, np.diff(values) >= edgelen]] + + +def _keep_last_of_close(values: np.ndarray, edgelen: int) -> np.ndarray: + if values.size < 2 or edgelen <= 1: + return values + return values[np.r_[np.diff(values) >= edgelen, True]] + + +def _event_value(values: np.ndarray, latency: int, edge: str) -> Any: + if edge == "leading": + return values[latency].item() + return values[latency - 2].item() + + +def _is_nan(value: Any) -> bool: + try: + return bool(np.isnan(value)) + except TypeError: + return False def _apply_oper(x: np.ndarray, oper: str) -> np.ndarray: diff --git a/src/eegprep/functions/popfunc/pop_fileio.py b/src/eegprep/functions/popfunc/pop_fileio.py index a8b0a397..1d28c9d7 100644 --- a/src/eegprep/functions/popfunc/pop_fileio.py +++ b/src/eegprep/functions/popfunc/pop_fileio.py @@ -7,6 +7,7 @@ from typing import Any import mne +import numpy as np import scipy.io from eegprep.functions.popfunc._file_io import mne_raw_to_eeg @@ -26,6 +27,7 @@ def pop_fileio( """Import a supported EEG data file with MNE/File-IO-style readers.""" path = Path(filename) suffix = path.suffix.lower() + blockrange = kwargs.pop("blockrange", None) if suffix == ".set": eeg = pop_loadset(str(path)) elif suffix == ".mat" and kwargs.get("dataformat") != "matlab-array": @@ -40,6 +42,8 @@ def pop_fileio( else: reader = _reader_for_suffix(suffix) raw = reader(str(path), preload=True, verbose=False) + if blockrange is not None: + _crop_raw_to_blockrange(raw, blockrange) eeg = mne_raw_to_eeg(raw, setname=path.stem, filename=str(path)) command = f"EEG = pop_fileio({format_history_value(path)});" eeg["history"] = command @@ -59,8 +63,10 @@ def _mat_is_eeglab_dataset(path: Path) -> bool: def _reader_for_suffix(suffix: str): - if suffix in {".edf", ".bdf"}: + if suffix == ".edf": return mne.io.read_raw_edf + if suffix == ".bdf": + return mne.io.read_raw_bdf if suffix == ".gdf": return mne.io.read_raw_gdf if suffix == ".vhdr": @@ -72,3 +78,16 @@ def _reader_for_suffix(suffix: str): if suffix == ".eeg": return mne.io.read_raw_brainvision raise ValueError(f"Unsupported File-IO import format: {suffix or ''}") + + +def _crop_raw_to_blockrange(raw: mne.io.BaseRaw, blockrange: Any) -> None: + values = np.asarray(blockrange, dtype=float).reshape(-1) + if values.size != 2 or not np.all(np.isfinite(values)): + raise ValueError("blockrange must contain two finite times in seconds") + start, stop = (float(value) for value in values) + if start < 0 or stop <= start: + raise ValueError("blockrange must satisfy 0 <= start < stop") + recording_stop = raw.n_times / float(raw.info["sfreq"]) + if start >= recording_stop: + raise ValueError("blockrange starts after the end of the recording") + raw.crop(tmin=start, tmax=min(stop, recording_stop), include_tmax=False) diff --git a/src/eegprep/functions/popfunc/pop_importpres.py b/src/eegprep/functions/popfunc/pop_importpres.py index b70898a5..cac54cde 100644 --- a/src/eegprep/functions/popfunc/pop_importpres.py +++ b/src/eegprep/functions/popfunc/pop_importpres.py @@ -2,36 +2,132 @@ from __future__ import annotations +import csv +from pathlib import Path from typing import Any -from eegprep.functions.popfunc._pop_utils import format_history_value +from eegprep.functions.popfunc._pop_utils import format_history_value, parse_key_value_args from eegprep.functions.popfunc.pop_importevent import pop_importevent def pop_importpres( EEG: dict[str, Any], filename: str | None = None, - *, + *args: Any, return_com: bool = False, **kwargs: Any, ) -> dict[str, Any] | tuple[dict[str, Any], str]: - """Import a Presentation LOG file using EEGPrep's generic event importer.""" + """Import a Presentation LOG file using its named event columns.""" if filename is None: filename = kwargs.pop("filename", None) if filename is None: raise ValueError("pop_importpres requires a Presentation LOG filename") - out = pop_importevent( - EEG, - "event", - filename, - "fields", - kwargs.pop("fields", ["type", "latency"]), - "timeunit", - kwargs.pop("timeunit", float("nan")), - return_com=True, - **kwargs, - ) - eeg, _command = out - command = f"EEG = pop_importpres(EEG, {format_history_value(filename)});" + typefield, latfield, durfield, align, remaining, legacy_count = _legacy_arguments(args) + options = parse_key_value_args(remaining, kwargs, lowercase_kwargs=True) + typefield = str(options.pop("typefield", typefield or "code")) + latfield = str(options.pop("latfield", latfield or "time")) + durfield = str(options.pop("durfield", durfield or "")) + if "align" not in options: + options["align"] = align + + skipline = int(options.pop("skipline", 0) or 0) + explicit_fields = "fields" in options + records = None + if not explicit_fields: + records = _presentation_records(filename, typefield, latfield, durfield, skipline) + if records is None: + if legacy_count and not explicit_fields: + raise ValueError(f"Could not detect Presentation fields {typefield!r} and {latfield!r}") + event_source: Any = filename + options.setdefault("fields", ["type", "latency"]) + options.setdefault("timeunit", float("nan")) + if skipline: + options["skipline"] = skipline + else: + event_source = records + options.setdefault("timeunit", 1e-4) + + eeg, _command = pop_importevent(EEG, "event", event_source, return_com=True, **options) + history_arguments = [format_history_value(filename)] + if legacy_count: + history_arguments.extend( + [ + format_history_value(typefield), + format_history_value(latfield), + format_history_value(durfield), + format_history_value(align), + ] + ) + command = f"EEG = pop_importpres(EEG, {', '.join(history_arguments)});" eeg["history"] = command if not EEG.get("history") else f"{EEG['history'].rstrip()}\n{command}" return (eeg, command) if return_com else eeg + + +def _legacy_arguments(args: tuple[Any, ...]) -> tuple[Any, Any, Any, Any, tuple[Any, ...], int]: + values = list(args[:4]) + legacy_count = len(values) + values.extend([None] * (4 - len(values))) + typefield, latfield, durfield, align = values + if durfield is not None and not isinstance(durfield, str): + align = durfield + durfield = None + legacy_count = min(legacy_count, 3) + if align is None: + align = 0 + return typefield, latfield, durfield, align, args[legacy_count:], legacy_count + + +def _presentation_records( + filename: str | Path, + typefield: str, + latfield: str, + durfield: str, + skipline: int, +) -> list[dict[str, Any]] | None: + with Path(filename).open(encoding="utf-8-sig", errors="replace", newline="") as stream: + rows = list(csv.reader(stream, delimiter="\t")) + expected = {typefield.casefold(), latfield.casefold()} + start = max(skipline, 0) + header_index = next( + ( + index + for index, row in enumerate(rows[start:], start=start) + if expected.issubset({value.strip().casefold() for value in row}) + ), + None, + ) + if header_index is None: + return None + header = [value.strip() for value in rows[header_index]] + renamed = [_presentation_field_name(value, typefield, latfield, durfield) for value in header] + records = [] + for row in rows[header_index + 1 :]: + if not row or all(not value.strip() for value in row): + continue + if len(row) != len(header): + raise ValueError("Presentation LOG rows must have the same number of columns as the header") + records.append(dict(zip(renamed, (_coerce_presentation_value(value) for value in row)))) + return records + + +def _presentation_field_name(name: str, typefield: str, latfield: str, durfield: str) -> str: + lowered = name.casefold() + if lowered == typefield.casefold(): + return "type" + if lowered == latfield.casefold(): + return "latency" + if durfield and durfield.casefold() != "none" and lowered == durfield.casefold(): + return "duration" + normalized = name.replace(" ", "_") + if normalized.endswith(")") and "(" in normalized and not normalized.startswith("("): + normalized = normalized[: normalized.rfind("(")] + return normalized + + +def _coerce_presentation_value(value: str) -> Any: + stripped = value.strip() + try: + number = float(stripped) + except ValueError: + return stripped + return int(number) if number.is_integer() else number diff --git a/src/eegprep/resources/help/pop_biosig.md b/src/eegprep/resources/help/pop_biosig.md index 4b62056d..90df89b5 100644 --- a/src/eegprep/resources/help/pop_biosig.md +++ b/src/eegprep/resources/help/pop_biosig.md @@ -8,8 +8,13 @@ Usage: ```python EEG = pop_biosig("recording.edf") EEG, com = pop_biosig("recording.bdf", return_com=True) +EEG = pop_biosig("recording.edf", blockrange=[30, 60]) ``` +`blockrange=[start, stop]` loads a half-open time interval in seconds: the +sample at `start` is included and the sample at `stop` is not. The stop time is +clamped to the recording duration. + Use `pop_fileio` for other formats such as BrainVision, EGI MFF, CNT, or EEGLAB `.set` files. diff --git a/src/eegprep/resources/help/pop_chancoresp.md b/src/eegprep/resources/help/pop_chancoresp.md index 64de22d9..1e69e049 100644 --- a/src/eegprep/resources/help/pop_chancoresp.md +++ b/src/eegprep/resources/help/pop_chancoresp.md @@ -5,7 +5,8 @@ Pair corresponding channels between two channel-location sets. `pop_chancoresp(chans1, chans2)` compares labels and returns two 1-based index lists. The default `autoselect="all"` mode pairs labels that match in both inputs. Use `autoselect="fiducials"` to pair only common fiducial aliases such -as nasion, LPA, and RPA. +as nasion, LPA, and RPA. Netstation's `FidT10` and `FidT9` labels correspond to +LPA and RPA, respectively, matching EEGLAB. Example: diff --git a/src/eegprep/resources/help/pop_chanevent.md b/src/eegprep/resources/help/pop_chanevent.md index e1ddc0ff..b74c91d4 100644 --- a/src/eegprep/resources/help/pop_chanevent.md +++ b/src/eegprep/resources/help/pop_chanevent.md @@ -13,6 +13,9 @@ EEG, com = pop_chanevent(EEG, [1, 2], "edge", "leading", return_com=True) Channel indices are EEGLAB-style 1-based values. Continuous 2-D datasets are supported. Options can delete the event channel after import, replace existing events, append to existing events, and compute event durations from leading and -trailing edges. +trailing edges. `edgelen` fuses transitions separated by fewer than the given +number of samples. Binary channels use `typename` (default `chanN`) as their +event type; multivalued channels retain the value at each transition unless +`nbtype` requests a named type. See also: POP_IMPORTEVENT diff --git a/src/eegprep/resources/help/pop_fileio.md b/src/eegprep/resources/help/pop_fileio.md index 61f22c72..7780259b 100644 --- a/src/eegprep/resources/help/pop_fileio.md +++ b/src/eegprep/resources/help/pop_fileio.md @@ -12,8 +12,12 @@ Usage: ```python EEG = pop_fileio("recording.vhdr") EEG, com = pop_fileio("recording.edf", return_com=True) +EEG = pop_fileio("recording.bdf", blockrange=[10, 20]) ``` +For MNE-backed continuous formats, `blockrange=[start, stop]` imports a +half-open interval measured in seconds. + Use the more specific File menu entries when you want format-specific file filters in the GUI. diff --git a/src/eegprep/resources/help/pop_importpres.md b/src/eegprep/resources/help/pop_importpres.md index 8fc4b6d0..75ffd4f1 100644 --- a/src/eegprep/resources/help/pop_importpres.md +++ b/src/eegprep/resources/help/pop_importpres.md @@ -8,9 +8,14 @@ Usage: ```python EEG = pop_importpres(EEG, "experiment.log") EEG, com = pop_importpres(EEG, "experiment.log", return_com=True) +EEG = pop_importpres(EEG, "experiment.log", "Event Type", "Time", "Duration") ``` -The default fields are event type and latency. Pass explicit `fields` or -`timeunit` options when your log has a different structure. +For tab-delimited Presentation logs, EEGPrep finds the header row, uses `code` +and `time` by default, and converts Presentation's 0.1 ms timestamps to EEG +samples. The positional field names select another event-type, latency, or +duration column. Pass `timeunit` to override the timestamp unit. Simple event +tables without a Presentation header continue to use `fields` and `timeunit` +as accepted by `pop_importevent`. See also: POP_IMPORTEVENT diff --git a/tests/test_binary_eeglab_tests.py b/tests/test_binary_eeglab_tests.py new file mode 100644 index 00000000..8ce6cc20 --- /dev/null +++ b/tests/test_binary_eeglab_tests.py @@ -0,0 +1,388 @@ +"""Behavioral ports of applicable tests from EEGLAB's current binary suite.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pyedflib +import pytest +from pyedflib import highlevel + +from eegprep.functions.popfunc._file_io import eeg_from_data +from eegprep.functions.popfunc.pop_biosig import pop_biosig +from eegprep.functions.popfunc.pop_chancoresp import pop_chancoresp +from eegprep.functions.popfunc.pop_chanevent import pop_chanevent +from eegprep.functions.popfunc.pop_importpres import pop_importpres +from eegprep.functions.popfunc.pop_snapread import pop_snapread +from eegprep.functions.sigprocfunc.snapread import snapread +from tests.eeglab_tests import eeglab_test + + +BINARY_SUITE = "unittesting_binary" + + +def _write_edf_family(path: Path, data: np.ndarray, srate: int, *, bdf: bool = False) -> None: + headers = highlevel.make_signal_headers( + [f"E{index}" for index in range(1, data.shape[0] + 1)], + sample_frequency=srate, + physical_min=-200, + physical_max=200, + ) + file_type = pyedflib.FILETYPE_BDFPLUS if bdf else pyedflib.FILETYPE_EDFPLUS + assert highlevel.write_edf(str(path), data, headers, file_type=file_type) + + +@eeglab_test(f"{BINARY_SUITE}/pop_biosig/binary_pop_biosig_wrapperTest.m", "test_test_pop_biosig") +def test_pop_biosig_reads_bdf_blockrange_with_exact_metadata(tmp_path: Path) -> None: + srate = 8 + data = np.vstack( + [ + np.linspace(-100, 100, srate * 12), + np.sin(np.arange(srate * 12) / 4) * 50, + ] + ) + filename = tmp_path / "recording.bdf" + _write_edf_family(filename, data, srate, bdf=True) + + complete = pop_biosig(filename) + cropped, command = pop_biosig(filename, blockrange=[1, 10], return_com=True) + + assert cropped["data"].shape == (2, 9 * srate) + assert cropped["nbchan"] == 2 + assert cropped["pnts"] == 9 * srate + assert cropped["trials"] == 1 + assert cropped["srate"] == srate + assert cropped["xmin"] == 0 + assert cropped["xmax"] == pytest.approx((9 * srate - 1) / srate) + assert [loc["labels"] for loc in cropped["chanlocs"]] == ["E1", "E2"] + np.testing.assert_array_equal(cropped["data"], complete["data"][:, srate : 10 * srate]) + assert "'blockrange', [1 10]" in command + assert cropped["history"] == command + + +@eeglab_test( + f"{BINARY_SUITE}/pop_biosig/binary_pop_biosig_wrapperTest.m", + "test_test_pop_biosig_timerange", +) +def test_pop_biosig_adjacent_edf_timeranges_are_sample_continuous(tmp_path: Path) -> None: + srate = 256 + samples = np.arange(61 * srate, dtype=float) + data = (np.sin(samples / 37) * 100)[np.newaxis, :] + filename = tmp_path / "recording.edf" + _write_edf_family(filename, data, srate) + + first = pop_biosig(filename, blockrange=[0, 30]) + second = pop_biosig(filename, blockrange=[29, 60]) + + assert first["pnts"] == 30 * srate + assert second["pnts"] == 31 * srate + np.testing.assert_array_equal(first["data"][0, -srate:], second["data"][0, :srate]) + + +CHANCORRESP_SOURCE = f"{BINARY_SUITE}/pop_chancoresp/binary_pop_chancoresp_wrapperTest.m" + + +@eeglab_test(CHANCORRESP_SOURCE, "test_pass_autoselect_fiducials") +def test_pop_chancoresp_autoselects_fiducials_case_insensitively() -> None: + left, right = pop_chancoresp( + ["Nz", "lpa", "rpa", "x"], + ["nZ", "rpa", "lpa", "x"], + "gui", + "off", + "autoselect", + "fiducials", + ) + + assert left == [1, 2, 3] + assert right == [1, 3, 2] + + +@eeglab_test(CHANCORRESP_SOURCE, "test_pass_autoselect_none") +def test_pop_chancoresp_autoselect_none_returns_no_pairs() -> None: + assert pop_chancoresp(["a", "b", "c"], ["a", "x", "b"], "gui", "off", "autoselect", "none") == ( + [], + [], + ) + + +@eeglab_test(CHANCORRESP_SOURCE, "test_pass_chanlists_not_empty") +def test_pop_chancoresp_preserves_explicit_pairs() -> None: + result = pop_chancoresp( + ["a", "b"], + ["x", "y"], + "gui", + "off", + "chanlist1", + [1, 2], + "chanlist2", + [2, 1], + ) + + assert result == ([1, 2], [2, 1]) + + +@eeglab_test(CHANCORRESP_SOURCE, "test_pass_clear") +def test_pop_chancoresp_clear_returns_unpaired_display_rows() -> None: + left, right = pop_chancoresp("clear", ["a", "b", "c"], ["a", "b", "x"]) + + assert left == [" 1 - a", " 2 - b", " 3 - c"] + assert right == [" 1 - a", " 2 - b", " 3 - x"] + + +@eeglab_test(CHANCORRESP_SOURCE, "test_pass_invalid_fiducials") +def test_pop_chancoresp_invalid_fiducials_return_no_pairs() -> None: + result = pop_chancoresp(["x"], ["x"], "gui", "off", "autoselect", "fiducials") + + assert result == ([], []) + + +@eeglab_test(CHANCORRESP_SOURCE, "test_pass_labels_only") +def test_pop_chancoresp_pairs_matching_labels_by_default() -> None: + result = pop_chancoresp(["a", "b", "c"], ["a", "x", "b"], "gui", "off") + + assert result == ([1, 2], [1, 3]) + + +@eeglab_test(CHANCORRESP_SOURCE, "test_pass_pair") +def test_pop_chancoresp_pair_updates_text_and_correspondences() -> None: + result = pop_chancoresp("pair", 2, 3, ["a", "b", "c"], ["a", "b", "x"], [], [], "", "") + + assert result == (" 2 - b -> 3 - x", " 3 - x -> 2 - b", [2], [3]) + + +@eeglab_test(CHANCORRESP_SOURCE, "test_pass_unpair") +def test_pop_chancoresp_unpair_removes_correspondence_and_updates_text() -> None: + result = pop_chancoresp( + "unpair", + 2, + 3, + ["a", "b", "c"], + ["a", "b", "x"], + [1, 2, 3], + [1, 3, 2], + "", + "", + ) + + assert result == (" 2 - b", " 3 - x", [1, 3], [1, 2]) + + +@eeglab_test(CHANCORRESP_SOURCE, "test_test_pop_chancoresp") +def test_pop_chancoresp_covers_the_upstream_option_matrix() -> None: + first = ["Nz", "LPA", "RPA", *[f"E{index}" for index in range(4, 33)]] + same = list(first) + different = ["Nz", "LPA", "RPA", *[f"X{index}" for index in range(4, 33)]] + longer = [*first, *[f"E{index}" for index in range(33, 69)]] + explicit_pairs = [ + (list(range(1, 17)), list(range(1, 17))), + (list(range(1, 17)), list(range(17, 33))), + ([1, 2, 3, 4, 5], [17, 6, 1, 30, 5]), + ] + + for second, all_pairs in [ + (same, (list(range(1, 33)), list(range(1, 33)))), + (different, ([1, 2, 3], [1, 2, 3])), + (longer, (list(range(1, 33)), list(range(1, 33)))), + ]: + assert pop_chancoresp(first, second, "gui", "off") == all_pairs + assert pop_chancoresp(first, second, "gui", "off", "autoselect", "none") == ([], []) + assert pop_chancoresp(first, second, "gui", "off", "autoselect", "all") == all_pairs + assert pop_chancoresp(first, second, "gui", "off", "autoselect", "fiducials") == ( + [1, 2, 3], + [1, 2, 3], + ) + for mode in ["none", "all", "fiducials"]: + for left, right in explicit_pairs: + result = pop_chancoresp( + first, + second, + "gui", + "off", + "autoselect", + mode, + "chanlist1", + left, + "chanlist2", + right, + ) + assert result == (left, right) + + +@eeglab_test(f"{BINARY_SUITE}/pop_chanevent/binary_pop_chanevent_wrapperTest.m", "test_test_pop_chanevent") +def test_pop_chanevent_covers_the_upstream_33_case_option_matrix() -> None: + trigger = np.array([0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0], dtype=float) + eeg = eeg_from_data( + np.vstack([np.arange(trigger.size), trigger]), + srate=100, + chanlocs=[{"labels": "Cz"}, {"labels": "TRIG"}], + ) + edge_cases = [ + ("both", 1, False, [2, 5, 6, 9], None), + ("leading", 1, False, [2, 6], None), + ("trailing", 1, False, [5, 9], None), + ("both", 10, False, [2, 9], None), + ("leading", 10, False, [2], None), + ("trailing", 10, False, [9], None), + ("leading", 1, True, [2, 6], [2, 2]), + ("leading", 10, True, [2], [6]), + ] + + for delchan in ["on", "off"]: + for delevent in ["on", "off"]: + for edge, edgelen, duration, latencies, durations in edge_cases: + out = pop_chanevent( + eeg, + 2, + "edge", + edge, + "edgelen", + edgelen, + "oper", + "", + "duration", + "on" if duration else "off", + "delchan", + delchan, + "delevent", + delevent, + "nbtype", + np.nan, + "typename", + "TRIG", + ) + events = [dict(event) for event in out["event"]] + assert [event["latency"] for event in events] == latencies + assert [event["type"] for event in events] == ["TRIG"] * len(latencies) + assert [event["urevent"] for event in events] == list(range(len(events))) + assert out["nbchan"] == (1 if delchan == "on" else 2) + if durations is not None: + assert [event["duration"] for event in events] == durations + + default = pop_chanevent(eeg, 2) + assert [event["latency"] for event in default["event"]] == [2, 5, 6, 9] + assert [event["type"] for event in default["event"]] == ["chan2"] * 4 + assert default["data"].shape == (1, trigger.size) + + +@eeglab_test(f"{BINARY_SUITE}/pop_importpres/binary_pop_importpres_wrapperTest.m", "test_test_pop_importpres") +def test_pop_importpres_covers_the_upstream_five_call_forms(tmp_path: Path) -> None: + filename = tmp_path / "experiment.LOG" + filename.write_text( + "Scenario demo\n" + "Logfile demo\n" + "Subject demo\n" + "Trial demo\n" + "Timestamp demo\n" + "Subject\tEvent Type\tCode\tTime\tDuration\n" + "S01\tPicture\t11\t2\t1\n" + "S01\tResponse\t12\t3\t2\n", + encoding="utf-8", + ) + eeg = eeg_from_data(np.zeros((1, 405)), srate=100) + + default = pop_importpres(eeg, filename) + labels_only = pop_importpres(eeg, filename, "Event Type", "Time", "None") + with_duration = pop_importpres(eeg, filename, "Event Type", "Time", "Duration") + explicit = pop_importpres( + eeg, + filename, + "Event Type", + "Time", + "None", + 0, + "timeunit", + 1, + "append", + "no", + "indices", + [], + "align", + 0, + "optimalign", + "on", + ) + skipped = pop_importpres( + eeg, + filename, + "Event Type", + "Time", + "None", + 0, + "skipline", + 5, + "timeunit", + 1, + "append", + "no", + "indices", + [], + "align", + 0, + "optimalign", + "on", + ) + + assert [event["type"] for event in default["event"]] == [11, 12] + assert [event["latency"] for event in default["event"]] == pytest.approx([1.02, 1.03]) + assert [event["type"] for event in labels_only["event"]] == ["Picture", "Response"] + assert all("duration" not in event for event in labels_only["event"]) + assert [event["duration"] for event in with_duration["event"]] == pytest.approx([0.01, 0.02]) + assert [event["latency"] for event in explicit["event"]] == [201, 301] + assert [(event["type"], event["latency"]) for event in skipped["event"]] == [ + (event["type"], event["latency"]) for event in explicit["event"] + ] + + +def _write_snapmaster(path: Path) -> np.ndarray: + nframes = 405 + values = np.vstack( + [ + np.r_[np.zeros(2), np.ones(2) * 3, np.zeros(396), np.ones(2) * 3, np.zeros(3)], + np.arange(nframes, dtype=float), + -np.arange(nframes, dtype=float), + ] + ).astype(" None: + filename = tmp_path / "TEST.SMA" + expected = _write_snapmaster(filename) + + complete, params, events, header = snapread(filename) + after_400, seeked_params, seeked_events, _ = snapread(filename, 400) + after_one, _, _, _ = snapread(filename, 1) + + np.testing.assert_array_equal(complete, expected) + np.testing.assert_array_equal(after_400, expected[:, 400:]) + np.testing.assert_array_equal(after_one, expected[:, 1:]) + assert params.tolist() == [2, 405, 100] + assert seeked_params.tolist() == [2, 5, 100] + assert np.flatnonzero(events).tolist() == [2, 400] + assert np.flatnonzero(seeked_events).tolist() == [] + assert '"NCHAN%"=3' in header + + +@eeglab_test(f"{BINARY_SUITE}/pop_snapread/binary_pop_snapread_wrapperTest.m", "test_test_pop_snapread") +def test_pop_snapread_applies_each_upstream_gain_and_builds_eeg_metadata(tmp_path: Path) -> None: + filename = tmp_path / "TEST.SMA" + expected = _write_snapmaster(filename) + + default = pop_snapread(filename) + gain_400 = pop_snapread(filename, 400) + gain_one, command = pop_snapread(filename, 1, return_com=True) + + np.testing.assert_array_equal(default["data"], expected) + np.testing.assert_array_equal(gain_400["data"], expected * 400) + np.testing.assert_array_equal(gain_one["data"], expected) + assert gain_one["nbchan"] == 2 + assert gain_one["pnts"] == 405 + assert gain_one["trials"] == 1 + assert gain_one["srate"] == 100 + assert [event["latency"] for event in gain_one["event"]] == [3, 401] + assert [event["type"] for event in gain_one["event"]] == [1, 1] + assert gain_one["history"] == command diff --git a/tests/test_phase2_file_channel_location.py b/tests/test_phase2_file_channel_location.py index 1b136e20..168f24c9 100644 --- a/tests/test_phase2_file_channel_location.py +++ b/tests/test_phase2_file_channel_location.py @@ -555,7 +555,7 @@ def test_pop_chancoresp_autoselects_all_channels_and_fiducials() -> None: assert fid_left == [1, 3, 4] assert fid_right == [4, 3, 2] assert template_left == [1, 3, 4] - assert template_right == [3, 2, 1] + assert template_right == [3, 1, 2] _assert_parseable(command) From 815cf3db9fa1906b469c01dd4695732483dd4581 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:46 -0700 Subject: [PATCH 09/45] Port current rejection workflow tests (#337) --- docs/source/changelog.rst | 4 + src/eegprep/functions/popfunc/pop_rejchan.py | 2 +- src/eegprep/resources/help/pop_rejchan.md | 4 + tests/test_phase7_long_tail_helpers.py | 4 + tests/test_pop_autorej.py | 5 ++ tests/test_rejection_workflows.py | 77 +++++++++++++++++++- 6 files changed, 93 insertions(+), 3 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 5b817441..b4ea2abd 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,10 @@ the `GitHub Releases `_ page. Unreleased ========== +- Current EEGLAB rejection-workflow test ports now cover threshold, statistical, + spectral, trend, channel, epoch, and automatic rejection. Scripted + ``pop_rejchan`` calls use EEGLAB's default threshold of 400; the interactive + dialog continues to default to a normalized threshold of 5. - Topographic interpolation no longer leaks NumPy 2 floating-point warnings for finite biharmonic matrix products. - ``eeg_interp`` now accepts MATLAB-loaded object arrays of channel locations when diff --git a/src/eegprep/functions/popfunc/pop_rejchan.py b/src/eegprep/functions/popfunc/pop_rejchan.py index 9a45926b..ea64fa61 100644 --- a/src/eegprep/functions/popfunc/pop_rejchan.py +++ b/src/eegprep/functions/popfunc/pop_rejchan.py @@ -90,7 +90,7 @@ def _apply_one(EEG: dict[str, Any], options: dict[str, Any]) -> tuple[dict[str, raise ValueError("EEG data must be 2-D or 3-D") elec = one_based_indices(options.get("elec"), limit=int(out.get("nbchan", flat.shape[0])), default_all=True) selected = [item - 1 for item in elec] - threshold = parse_numeric_sequence(options.get("threshold", 5), dtype=float) + threshold = parse_numeric_sequence(options.get("threshold", 400), dtype=float) measure_name = str(options.get("measure", "kurt")).lower() norm = str(options.get("norm", "off")).lower() == "on" measure = _channel_measure(flat[selected], measure_name, threshold, norm, out, options) diff --git a/src/eegprep/resources/help/pop_rejchan.md b/src/eegprep/resources/help/pop_rejchan.md index 4492c724..6003903f 100644 --- a/src/eegprep/resources/help/pop_rejchan.md +++ b/src/eegprep/resources/help/pop_rejchan.md @@ -5,6 +5,10 @@ Usage: EEG, indices, measure = pop_rejchan(EEG, "measure", "kurt", "threshold", 5) EEG, command = pop_rejchan(EEG, return_com=True) +Scripted calls use EEGLAB's default absolute threshold of `400` when +`threshold` is omitted. The interactive dialog supplies its own normalized +default threshold of `5`. + Supported measures are probability, kurtosis, spectrum, and standard deviation. Use `indexonly="on"` to return candidate channel indices without removing them. diff --git a/tests/test_phase7_long_tail_helpers.py b/tests/test_phase7_long_tail_helpers.py index cb98029b..9267a510 100644 --- a/tests/test_phase7_long_tail_helpers.py +++ b/tests/test_phase7_long_tail_helpers.py @@ -121,6 +121,10 @@ def test_pop_icathresh_sets_component_rejection_flags(): assert command == "EEG = pop_icathresh(EEG, [4 4 10], 'current', 25, 0);" +@eeglab_test( + "unittesting_popfunc/pop_rejchanspec/popfunc_pop_rejchanspec_wrapperTest.m", + "test_test_pop_rejchanspec", +) def test_pop_rejchanspec_rejects_spectral_outlier_and_returns_history(): eeg = _eeg(np.zeros((3, 8))) specdata = np.array([[1.0, 2.0, 1.0], [1.0, 50.0, 1.0], [1.0, 2.0, 1.0]]) diff --git a/tests/test_pop_autorej.py b/tests/test_pop_autorej.py index e817de42..00407ecd 100644 --- a/tests/test_pop_autorej.py +++ b/tests/test_pop_autorej.py @@ -16,6 +16,7 @@ from eegprep.functions.popfunc.pop_autorej import pop_autorej from eegprep.functions.popfunc.pop_loadset import pop_loadset +from tests.eeglab_tests import eeglab_test EPOCHED_DATASET_PATH = Path(__file__).resolve().parents[1] / "sample_data" / "eeglab_data_epochs_ica.set" @@ -42,6 +43,10 @@ @pytest.mark.parametrize(("options", "expected"), EEGLAB_REJECTIONS) +@eeglab_test( + "unittesting_popfunc/pop_autorej/popfunc_pop_autorej_wrapperTest.m", + "test_test_pop_autorej", +) def test_pop_autorej_rejects_the_same_epochs_as_eeglab(options, expected): EEG = pop_loadset(EPOCHED_DATASET_PATH) diff --git a/tests/test_rejection_workflows.py b/tests/test_rejection_workflows.py index dc05f568..8e660e24 100644 --- a/tests/test_rejection_workflows.py +++ b/tests/test_rejection_workflows.py @@ -75,19 +75,42 @@ def _reference_trend_marks( return row_marks +@eeglab_test( + "unittesting_popfunc/pop_eegthresh/popfunc_pop_eegthresh_wrapperTest.m", + "test_test_pop_eegthresh", +) def test_pop_eegthresh_marks_epochs_and_emits_replayable_python(): eeg = _epoched_eeg() out, com = pop_eegthresh(eeg, 1, [1], -10, 10, 0, 0.79, 0, 0, return_com=True) + component_out, component_rejected = pop_eegthresh(eeg, 0, [1], -10, 10, 0, 0.79, 1, 0) assert out["reject"]["rejthresh"].tolist() == [False, True, False, False, False] assert out["reject"]["rejthreshE"][0].tolist() == [False, True, False, False, False] + assert component_rejected == [2] + assert component_out["reject"]["icarejthresh"].tolist() == [False, True, False, False, False] assert _console_python_command(com) == ( "EEG = pop_eegthresh(EEG, icacomp=1, elecrange=[1], negthresh=[-10], " "posthresh=[10], starttime=[0], endtime=[0.79], superpose=0, reject=0)" ) +@eeglab_test( + "unittesting_popfunc/pop_jointprob/popfunc_pop_jointprob_wrapperTest.m", + "test_test_pop_jointprob", +) +@eeglab_test( + "unittesting_popfunc/pop_rejkurt/popfunc_pop_rejkurt_wrapperTest.m", + "test_test_pop_rejkurt", +) +@eeglab_test( + "unittesting_popfunc/pop_rejspec/popfunc_pop_rejspec_wrapperTest.m", + "test_test_pop_rejspec", +) +@eeglab_test( + "unittesting_popfunc/pop_rejtrend/popfunc_pop_rejtrend_wrapperTest.m", + "test_test_pop_rejtrend", +) def test_rejection_statistics_store_data_and_component_marks(): eeg = _epoched_eeg() @@ -643,6 +666,10 @@ def test_trend_marks_match_reference_window_loop(): np.testing.assert_array_equal(reject, expected.any(axis=0)) +@eeglab_test( + "unittesting_popfunc/pop_rejepoch/popfunc_pop_rejepoch_wrapperTest.m", + "test_test_pop_rejepoch", +) def test_eeg_rejsuperpose_and_pop_rejepoch_remove_marked_epochs(): eeg = _epoched_eeg() eeg["reject"]["rejmanual"] = np.array([False, True, False, False, True]) @@ -885,6 +912,52 @@ def test_channel_and_continuous_rejection_work_on_sample_data_without_ica(): pop_eegthresh(sample, 0, [1], -10, 10, 0, 1) +@eeglab_test( + "unittesting_popfunc/pop_rejchan/popfunc_pop_rejchan_wrapperTest.m", + "test_test_pop_rejchan", +) +def test_pop_rejchan_current_suite_probability_and_kurtosis_options(): + rng = np.random.default_rng(91) + eeg = create_test_eeg(n_channels=5, n_samples=40, n_trials=3, srate=100) + eeg["data"] = rng.normal(size=(5, 40, 3)) + options = ( + ([2, 4, 5], [5], "kurt", "off"), + ([1, 2, 3, 4, 5], [5], "kurt", "off"), + ([2, 4, 5], [5, 5, 5], "kurt", "off"), + ([2, 4, 5], [5], "prob", "off"), + ([1, 2, 3, 4, 5], [5], "kurt", "on"), + ([1, 2, 3, 4, 5], [5], "prob", "off"), + ([1, 2, 3, 4, 5], [5, 1], "kurt", "off"), + ([1, 2, 3, 4], [5], "kurt", "on"), + ) + + for channels, threshold, measure_name, norm in options: + out, rejected, measure = pop_rejchan( + eeg, + "elec", + channels, + "threshold", + threshold, + "measure", + measure_name, + "norm", + norm, + "indexonly", + "on", + ) + assert measure.shape == (len(channels),) + assert np.isfinite(measure).all() + assert set(rejected).issubset(channels) + assert out["nbchan"] == eeg["nbchan"] + + removal_eeg = create_test_eeg(n_channels=2, n_samples=20, n_trials=1, srate=100) + removal_eeg["data"] = np.zeros((2, 20)) + removal_eeg["data"][0, 10] = 100 + removed, rejected, _measure = pop_rejchan(removal_eeg, "measure", "std", "threshold", 5) + assert rejected == [1] + assert removed["nbchan"] == 1 + + def test_rejection_component_threshold_recomputes_stale_stored_icaact(): eeg = _epoched_eeg() eeg["icaweights"] = 2.0 * np.eye(4) @@ -897,14 +970,14 @@ def test_rejection_component_threshold_recomputes_stale_stored_icaact(): assert out["reject"]["icarejthresh"].tolist() == [False, True, False, False, False] -def test_pop_rejchan_default_threshold_matches_gui_zscore_default(): +def test_pop_rejchan_scripted_default_threshold_matches_eeglab(): eeg = create_test_eeg(n_channels=2, n_samples=20, n_trials=1, srate=100) eeg["data"] = np.zeros((2, 20)) eeg["data"][0, 10] = 100 _out, rejected_channels, _measure = pop_rejchan(eeg, "measure", "std", "indexonly", "on") - assert rejected_channels == [1] + assert rejected_channels == [] def test_pop_rejcont_history_replays_effectful_mode_and_overlap_options(): From 922cbe3550b9eec480bd26734cb7a4ef4952f02a Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:46 -0700 Subject: [PATCH 10/45] Port current clean_rawdata tests (#338) --- docs/source/changelog.rst | 3 + docs/source/user_guide/plugins.rst | 5 + .../plugins/clean_rawdata/clean_channels.py | 3 + .../resources/help/pop_clean_rawdata.md | 3 + tests/test_clean_rawdata_eeglab_tests.py | 180 ++++++++++++++++++ 5 files changed, 194 insertions(+) create mode 100644 tests/test_clean_rawdata_eeglab_tests.py diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index b4ea2abd..f3a9a90c 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,9 @@ the `GitHub Releases `_ page. Unreleased ========== +- ``pop_clean_rawdata`` now routes channel cleaning to the location-free + algorithm when ``EEG["chanlocs"]`` is empty. It previously failed with an + indexing error before the documented fallback could run. - Current EEGLAB rejection-workflow test ports now cover threshold, statistical, spectral, trend, channel, epoch, and automatic rejection. Scripted ``pop_rejchan`` calls use EEGLAB's default threshold of 400; the interactive diff --git a/docs/source/user_guide/plugins.rst b/docs/source/user_guide/plugins.rst index 0d6abe83..a0fec8ec 100644 --- a/docs/source/user_guide/plugins.rst +++ b/docs/source/user_guide/plugins.rst @@ -93,6 +93,11 @@ Use ``clean_artifacts`` when you need lower-level tuple outputs: clean_eeg, highpass_state, burst_state, removed_channels = clean_artifacts(EEG) +Channel correlation cleaning uses the spatial reconstruction method when the +dataset has usable ``X``, ``Y``, and ``Z`` channel coordinates. If channel +locations are empty, ``pop_clean_rawdata`` automatically uses the bundled +location-free correlation method instead. + FIRFilt ======= diff --git a/src/eegprep/plugins/clean_rawdata/clean_channels.py b/src/eegprep/plugins/clean_rawdata/clean_channels.py index 30b05bb7..cec21c5d 100644 --- a/src/eegprep/plugins/clean_rawdata/clean_channels.py +++ b/src/eegprep/plugins/clean_rawdata/clean_channels.py @@ -62,6 +62,9 @@ def clean_channels( EEG['data'] = np.asarray(EEG['data'], dtype=np.float64) C, S = EEG['data'].shape Fs = EEG['srate'] + chanlocs = EEG.get('chanlocs') + if chanlocs is None or len(chanlocs) == 0: + raise ValueError('To use this function most of your channels should have X,Y,Z location measurements.') # handle fractions or absolute values if subset_size >= 1: diff --git a/src/eegprep/resources/help/pop_clean_rawdata.md b/src/eegprep/resources/help/pop_clean_rawdata.md index 7a58a77e..fa79d9a0 100644 --- a/src/eegprep/resources/help/pop_clean_rawdata.md +++ b/src/eegprep/resources/help/pop_clean_rawdata.md @@ -32,6 +32,9 @@ Behavior: - The function calls EEGPrep's `clean_artifacts` backend and returns a cleaned EEG dataset. - GUI choices are converted to the same named options used by the command-line API. +- Channel correlation cleaning uses spatial channel coordinates when they are + available. An empty `EEG['chanlocs']` automatically selects the location-free + correlation method. - `Distance='Riemannian'` uses EEGPrep's calibration-time Riemannian ASR estimate (`clean_asr(..., useriemannian='calib')`). Full Riemannian ASR processing is not ported, and direct `clean_asr(useriemannian=True)` requests diff --git a/tests/test_clean_rawdata_eeglab_tests.py b/tests/test_clean_rawdata_eeglab_tests.py new file mode 100644 index 00000000..9826e6af --- /dev/null +++ b/tests/test_clean_rawdata_eeglab_tests.py @@ -0,0 +1,180 @@ +"""Ports of the current EEGLAB clean_rawdata regression tests.""" + +from copy import deepcopy + +import numpy as np + +from eegprep import pop_clean_rawdata +from tests.eeglab_tests import eeglab_test +from tests.fixtures import create_test_eeg + + +UPSTREAM_SUITE = "unittesting_clean_rawdata/clean_rawdata_wrapperTest.m" +_SAMPLE_RATE = 128.0 +_N_CHANNELS = 8 +_N_SAMPLES = 4096 +_BURST = slice(1800, 2050) +_ALL_OFF = { + "FlatlineCriterion": "off", + "ChannelCriterion": "off", + "LineNoiseCriterion": "off", + "Highpass": "off", + "BurstCriterion": "off", + "WindowCriterion": "off", + "BurstRejection": "off", + "Distance": "Euclidian", +} + + +def _continuous_eeg(*, with_locations: bool = True) -> dict: + time = np.arange(_N_SAMPLES) / _SAMPLE_RATE + rng = np.random.default_rng(20260317) + common_signal = ( + np.sin(2 * np.pi * 10 * time) + + 0.35 * np.sin(2 * np.pi * 6 * time) + + 0.15 * np.sin(2 * np.pi * 3 * time) + + 0.4 * np.sin(2 * np.pi * 0.15 * time) + ) + data = np.vstack( + [(1 + 0.04 * channel) * common_signal + 0.04 * rng.standard_normal(_N_SAMPLES) for channel in range(8)] + ) + eeg = create_test_eeg( + n_channels=_N_CHANNELS, + n_samples=_N_SAMPLES, + srate=_SAMPLE_RATE, + n_trials=1, + ) + eeg["data"] = data + if not with_locations: + eeg["chanlocs"] = [] + return eeg + + +def _with_bad_channel(*, with_locations: bool) -> dict: + eeg = _continuous_eeg(with_locations=with_locations) + eeg["data"][-1] = 3 * np.random.default_rng(42).standard_normal(_N_SAMPLES) + return eeg + + +def _with_burst() -> dict: + eeg = _continuous_eeg() + eeg["data"][:, _BURST] += 80 * np.random.default_rng(4).standard_normal((_N_CHANNELS, 250)) + return eeg + + +def _clean_twice(eeg: dict, **options) -> dict: + original = deepcopy(eeg) + first = pop_clean_rawdata(eeg, gui=False, **options) + np.testing.assert_equal(eeg, original) + second = pop_clean_rawdata(eeg, gui=False, **options) + np.testing.assert_equal(first, second) + return first + + +@eeglab_test(UPSTREAM_SUITE, "test_clean_rawdata_filtering_test") +def test_clean_rawdata_highpass_is_deterministic_and_removes_drift(): + eeg = _continuous_eeg() + cleaned = _clean_twice(eeg, **(_ALL_OFF | {"Highpass": [0.25, 0.75]})) + + time = np.arange(_N_SAMPLES) / _SAMPLE_RATE + drift = np.sin(2 * np.pi * 0.15 * time) + input_amplitude = abs(2 * np.dot(eeg["data"][0], drift) / _N_SAMPLES) + output_amplitude = abs(2 * np.dot(cleaned["data"][0], drift) / _N_SAMPLES) + + assert cleaned["data"].shape == eeg["data"].shape + assert output_amplitude < 0.1 * input_amplitude + + +@eeglab_test(UPSTREAM_SUITE, "test_clean_rawdata_chan_test") +def test_clean_rawdata_without_locations_is_deterministic_and_removes_bad_channel(): + eeg = _with_bad_channel(with_locations=False) + cleaned = _clean_twice( + eeg, + **( + _ALL_OFF + | { + "FlatlineCriterion": 5, + "ChannelCriterion": 0.8, + "LineNoiseCriterion": 4, + } + ), + ) + + assert cleaned["nbchan"] == _N_CHANNELS - 1 + np.testing.assert_array_equal( + cleaned["etc"]["clean_channel_mask"], + [True, True, True, True, True, True, True, False], + ) + + +@eeglab_test(UPSTREAM_SUITE, "test_clean_rawdata_chanloc_test") +def test_clean_rawdata_with_locations_is_deterministic_and_removes_bad_channel(): + eeg = _with_bad_channel(with_locations=True) + cleaned = _clean_twice( + eeg, + **( + _ALL_OFF + | { + "FlatlineCriterion": 5, + "ChannelCriterion": 0.8, + "LineNoiseCriterion": 4, + } + ), + ) + + assert cleaned["nbchan"] == _N_CHANNELS - 1 + assert [channel["labels"] for channel in cleaned["chanlocs"]] == [f"Ch{index}" for index in range(1, 8)] + np.testing.assert_array_equal( + cleaned["etc"]["clean_channel_mask"], + [True, True, True, True, True, True, True, False], + ) + + +@eeglab_test(UPSTREAM_SUITE, "test_clean_rawdata_asr_test") +def test_clean_rawdata_asr_repair_is_deterministic_and_reduces_burst_energy(): + eeg = _with_burst() + cleaned = _clean_twice(eeg, **(_ALL_OFF | {"BurstCriterion": 20})) + + input_energy = np.linalg.norm(eeg["data"][:, _BURST]) + output_energy = np.linalg.norm(cleaned["data"][:, _BURST]) + assert cleaned["data"].shape == eeg["data"].shape + assert np.all(np.isfinite(cleaned["data"])) + assert output_energy < 0.95 * input_energy + + +@eeglab_test(UPSTREAM_SUITE, "test_clean_rawdata_rej_test") +def test_clean_rawdata_asr_rejection_is_deterministic_and_removes_burst_samples(): + eeg = _with_burst() + cleaned = _clean_twice( + eeg, + **(_ALL_OFF | {"BurstCriterion": 20, "BurstRejection": "on"}), + ) + + sample_mask = cleaned["etc"]["clean_sample_mask"] + assert sample_mask.dtype == np.bool_ + assert sample_mask.shape == (_N_SAMPLES,) + assert cleaned["pnts"] == np.count_nonzero(sample_mask) + assert cleaned["pnts"] < eeg["pnts"] + assert np.all(np.isfinite(cleaned["data"])) + + +@eeglab_test(UPSTREAM_SUITE, "test_clean_rawdata_finalrej_test") +def test_clean_rawdata_final_window_rejection_is_deterministic_and_removes_bad_windows(): + eeg = _continuous_eeg() + eeg["data"][:5, 1600:2200] += 35 * np.random.default_rng(1).standard_normal((5, 600)) + cleaned = _clean_twice( + eeg, + **( + _ALL_OFF + | { + "WindowCriterion": 0.25, + "WindowCriterionTolerances": [-np.inf, 7], + "Channels": [], + } + ), + ) + + sample_mask = cleaned["etc"]["clean_sample_mask"] + assert sample_mask.shape == (_N_SAMPLES,) + assert np.any(~sample_mask) + assert cleaned["pnts"] == np.count_nonzero(sample_mask) From a72f2f8b70b84c7752a82af7aff587b2db56cf12 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:47 -0700 Subject: [PATCH 11/45] Port EEGLAB STUDY workflow tests (#339) --- docs/source/api/study_workflows.rst | 14 +- docs/source/changelog.rst | 8 + docs/source/user_guide/study_workflows.rst | 19 +- src/eegprep/__init__.py | 6 + src/eegprep/functions/popfunc/pop_saveset.py | 8 +- .../functions/studyfunc/_study_params.py | 76 ++++ .../functions/studyfunc/_study_utils.py | 2 +- .../functions/studyfunc/pop_dipparams.py | 32 ++ .../functions/studyfunc/pop_erpimparams.py | 44 ++ .../functions/studyfunc/pop_erpparams.py | 37 ++ .../functions/studyfunc/pop_erspparams.py | 54 +++ .../functions/studyfunc/pop_specparams.py | 37 ++ .../functions/studyfunc/pop_statparams.py | 113 +++++ .../functions/studyfunc/std_maketrialinfo.py | 41 +- tests/test_study_params_eeglab_tests.py | 238 ++++++++++ tests/test_study_workflows_eeglab_tests.py | 406 ++++++++++++++++++ tests/test_tutorial_eeglab_tests.py | 198 +++++++++ 17 files changed, 1312 insertions(+), 21 deletions(-) create mode 100644 src/eegprep/functions/studyfunc/_study_params.py create mode 100644 src/eegprep/functions/studyfunc/pop_dipparams.py create mode 100644 src/eegprep/functions/studyfunc/pop_erpimparams.py create mode 100644 src/eegprep/functions/studyfunc/pop_erpparams.py create mode 100644 src/eegprep/functions/studyfunc/pop_erspparams.py create mode 100644 src/eegprep/functions/studyfunc/pop_specparams.py create mode 100644 src/eegprep/functions/studyfunc/pop_statparams.py create mode 100644 tests/test_study_params_eeglab_tests.py create mode 100644 tests/test_study_workflows_eeglab_tests.py create mode 100644 tests/test_tutorial_eeglab_tests.py diff --git a/docs/source/api/study_workflows.rst b/docs/source/api/study_workflows.rst index 7db43826..85b5ad9e 100644 --- a/docs/source/api/study_workflows.rst +++ b/docs/source/api/study_workflows.rst @@ -66,6 +66,19 @@ Measures eegprep.std_readspec eegprep.std_specplot +Plot and Statistics Parameters +------------------------------ + +.. autosummary:: + :toctree: generated/ + + eegprep.pop_dipparams + eegprep.pop_erpimparams + eegprep.pop_erpparams + eegprep.pop_erspparams + eegprep.pop_specparams + eegprep.pop_statparams + Plotting -------- @@ -100,4 +113,3 @@ Other eegprep.std_selectdesign eegprep.std_selsubject eegprep.std_substudy - diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index f3a9a90c..86856208 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,14 @@ the `GitHub Releases `_ page. Unreleased ========== +- Added ``pop_dipparams``, ``pop_erpimparams``, ``pop_erpparams``, + ``pop_erspparams``, ``pop_specparams``, and ``pop_statparams`` for storing + EEGLAB-compatible STUDY plotting and statistics settings. Analysis-defining + range changes now invalidate stale measure caches. ``std_maketrialinfo`` now + derives custom fields such as reaction time from each epoch's time-locking + event and preserves those rows across later STUDY operations. Saving ``.set`` + files now supports event dictionaries with heterogeneous custom fields by + encoding absent struct values as MATLAB empty arrays. - ``pop_clean_rawdata`` now routes channel cleaning to the location-free algorithm when ``EEG["chanlocs"]`` is empty. It previously failed with an indexing error before the documented fallback could run. diff --git a/docs/source/user_guide/study_workflows.rst b/docs/source/user_guide/study_workflows.rst index 7d183f9c..5dfd227d 100644 --- a/docs/source/user_guide/study_workflows.rst +++ b/docs/source/user_guide/study_workflows.rst @@ -111,6 +111,21 @@ current standalone cache rather than EEGLAB sidecar measure files. ``std_readdata``/``std_erpplot``/``std_erspplot`` cache contract used by scripts, so GUI and console plots slice axes and cached channel groups consistently. +Store reusable plot and statistics choices on the STUDY before plotting: + +.. code-block:: python + + from eegprep import pop_erpparams, pop_statparams + + STUDY = pop_erpparams(STUDY, timerange=[-200, 800], plotconditions="together") + STUDY = pop_statparams(STUDY, condstats="on", method="perm", naccu=2000) + +The corresponding ``pop_erpimparams``, ``pop_erspparams``, ``pop_specparams``, +and ``pop_dipparams`` functions store settings under ``STUDY["etc"]`` using +EEGLAB field names. Changing an ERP time range, spectrum frequency range, or +ERSP/ITC time-frequency range invalidates the affected cached measure fields; +run ``pop_precomp(..., recompute="on")`` before plotting them again. + Use ``std_checkfiles``, ``std_checkdatasession``, ``std_uniformfiles``, and ``std_uniformsetinds`` to audit loaded dataset consistency and cached measure shapes before saving or plotting group-level results. ``std_savedat`` writes @@ -135,7 +150,9 @@ Select datasets or trials from STUDY metadata: These helpers return EEGLAB-facing 1-based dataset and trial indices. Trial metadata may be stored as row dictionaries or as EEGLAB-loaded columnar ``{"factor": [values...]}`` dictionaries; STUDY selectors normalize both forms -before matching factor levels and numerical ranges. Use ``std_substudy`` or +before matching factor levels and numerical ranges. ``std_maketrialinfo`` uses +each epoch's time-locking event, including custom fields such as reaction time, +and later STUDY synchronization preserves those derived rows. Use ``std_substudy`` or ``std_rmdat`` when a workflow needs to remove datasets; EEGPrep remaps STUDY references and invalidates cached measure arrays after membership changes. diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 3c2a1e62..e92366f4 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -208,6 +208,7 @@ "pop_dipfit_nonlinear": ("eegprep.plugins.dipfit.pop_dipfit_nonlinear", "pop_dipfit_nonlinear"), "pop_dipfit_settings": ("eegprep.plugins.dipfit.pop_dipfit_settings", "pop_dipfit_settings"), "pop_dipplot": ("eegprep.plugins.dipfit.pop_dipplot", "pop_dipplot"), + "pop_dipparams": ("eegprep.functions.studyfunc.pop_dipparams", "pop_dipparams"), "pop_epoch": ("eegprep.functions.popfunc.pop_epoch", "pop_epoch"), "pop_eegfilt": ("eegprep.functions.popfunc.pop_eegfilt", "pop_eegfilt"), "pop_eegfiltnew": ("eegprep.plugins.firfilt.pop_eegfiltnew", "pop_eegfiltnew"), @@ -225,6 +226,9 @@ "pop_comperp": ("eegprep.functions.popfunc.pop_comperp", "pop_comperp"), "pop_envtopo": ("eegprep.functions.popfunc.pop_envtopo", "pop_envtopo"), "pop_erpimage": ("eegprep.functions.popfunc.pop_erpimage", "pop_erpimage"), + "pop_erpimparams": ("eegprep.functions.studyfunc.pop_erpimparams", "pop_erpimparams"), + "pop_erpparams": ("eegprep.functions.studyfunc.pop_erpparams", "pop_erpparams"), + "pop_erspparams": ("eegprep.functions.studyfunc.pop_erspparams", "pop_erspparams"), "pop_eventstat": ("eegprep.functions.popfunc.pop_eventstat", "pop_eventstat"), "pop_fileio_brainvision_mat": ( "eegprep.functions.popfunc.pop_fileio_brainvision_mat", @@ -297,6 +301,8 @@ "pop_selectcomps": ("eegprep.functions.popfunc.pop_selectcomps", "pop_selectcomps"), "pop_signalstat": ("eegprep.functions.popfunc.pop_signalstat", "pop_signalstat"), "pop_snapread": ("eegprep.functions.popfunc.pop_snapread", "pop_snapread"), + "pop_specparams": ("eegprep.functions.studyfunc.pop_specparams", "pop_specparams"), + "pop_statparams": ("eegprep.functions.studyfunc.pop_statparams", "pop_statparams"), "pop_study": ("eegprep.functions.studyfunc.pop_study", "pop_study"), "pop_studydesign": ("eegprep.functions.studyfunc.pop_studydesign", "pop_studydesign"), "pop_studyerp": ("eegprep.functions.studyfunc.pop_studyerp", "pop_studyerp"), diff --git a/src/eegprep/functions/popfunc/pop_saveset.py b/src/eegprep/functions/popfunc/pop_saveset.py index 16cb7e77..214643a7 100644 --- a/src/eegprep/functions/popfunc/pop_saveset.py +++ b/src/eegprep/functions/popfunc/pop_saveset.py @@ -196,7 +196,7 @@ def flatten_dict(data): """ # Flatten each dictionary and collect the fields and types flat_data = [flatten_dict_sub(item) for item in data] - fields = list(flat_data[0].keys()) + fields = list(dict.fromkeys(field for item in flat_data for field in item)) dtypes = [] has_object = False @@ -204,7 +204,7 @@ def flatten_dict(data): # Check ALL values per field (not just the first) to handle mixed types # e.g., event type can be numeric (2) for regular events and 'boundary' for boundary events for field in fields: - all_values = [item[field] for item in flat_data] + all_values = [item.get(field, default_empty) for item in flat_data] has_seq = any(isinstance(v, (list, np.ndarray)) for v in all_values) if has_seq: dtypes.append((field, 'O')) @@ -218,14 +218,14 @@ def flatten_dict(data): # (scipy.io.savemat handles mixed typed/object recarrays poorly) if has_object: dtype = np.dtype([(f, 'O') for f in fields]) - data_tuples = [tuple(_matlab_double(item[field]) for field in fields) for item in flat_data] + data_tuples = [tuple(_matlab_double(item.get(field, default_empty)) for field in fields) for item in flat_data] else: dtype = np.dtype(dtypes) data_tuples = [] for item in flat_data: row = [] for field, (_, dt) in zip(fields, dtypes): - val = item[field] + val = item.get(field, default_empty) if dt == np.float64: row.append(float(val)) else: diff --git a/src/eegprep/functions/studyfunc/_study_params.py b/src/eegprep/functions/studyfunc/_study_params.py new file mode 100644 index 00000000..9b9b34fd --- /dev/null +++ b/src/eegprep/functions/studyfunc/_study_params.py @@ -0,0 +1,76 @@ +"""Shared storage and cache invalidation for STUDY plotting parameters.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from eegprep.functions.popfunc._pop_utils import parse_key_value_args +from eegprep.functions.popfunc.plot_utils import python_literal +from eegprep.functions.studyfunc._study_utils import ensure_study, equal_value + + +def update_study_params( + STUDY: dict[str, Any], + section: str, + defaults: dict[str, Any], + function_name: str, + args: tuple[Any, ...], + kwargs: dict[str, Any], + *, + invalidate_on: tuple[str, ...] = (), + invalidated_fields: tuple[str, ...] = (), + return_com: bool = False, +) -> Any: + """Fill defaults, apply known options, and invalidate affected caches.""" + study = ensure_study(STUDY) + params = study["etc"].get(section) + if not isinstance(params, dict): + params = {} + for key, value in defaults.items(): + params.setdefault(key, deepcopy(value)) + study["etc"][section] = params + + options = _options(args, kwargs) + unknown = sorted(set(options) - set(defaults)) + if unknown: + raise ValueError(f"Unknown {function_name} option(s): {', '.join(unknown)}") + changed = {key for key, value in options.items() if not equal_value(params.get(key), value)} + for key, value in options.items(): + params[key] = deepcopy(value) + if changed.intersection(invalidate_on): + _clear_measure_fields(study, invalidated_fields) + if changed: + study["saved"] = "no" + + command = _history_command(function_name, options) + return (study, command) if return_com else study + + +def _options(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: + if len(args) == 1 and str(args[0]).lower() == "default": + if kwargs: + raise ValueError("'default' cannot be combined with parameter options") + return {} + return parse_key_value_args(args, kwargs, lowercase_kwargs=True) + + +def _clear_measure_fields(study: dict[str, Any], fields: tuple[str, ...]) -> None: + for collection_name in ("cluster", "changrp"): + collection = study.get(collection_name) or [] + if isinstance(collection, dict): + collection = [collection] + for entry in collection: + if not isinstance(entry, dict): + continue + for field in fields: + entry.pop(field, None) + + +def _history_command(function_name: str, options: dict[str, Any]) -> str: + pieces = ["STUDY"] + pieces.extend(f"{key}={python_literal(value)}" for key, value in options.items()) + return f"STUDY = {function_name}({', '.join(pieces)})" + + +__all__ = ["update_study_params"] diff --git a/src/eegprep/functions/studyfunc/_study_utils.py b/src/eegprep/functions/studyfunc/_study_utils.py index 26f1e7bd..21f6c8ad 100644 --- a/src/eegprep/functions/studyfunc/_study_utils.py +++ b/src/eegprep/functions/studyfunc/_study_utils.py @@ -159,7 +159,7 @@ def sync_datasetinfo(study: dict[str, Any], datasets: list[dict[str, Any]]) -> d for index, eeg in enumerate(datasets, start=1): previous = existing[index - 1] if index <= len(existing) else {} info = dataset_info_from_eeg(index, eeg) - for field in ("subject", "condition", "group", "session", "run", "comps"): + for field in ("subject", "condition", "group", "session", "run", "comps", "trialinfo"): if not _empty_value(previous.get(field)): info[field] = previous[field] for key, value in previous.items(): diff --git a/src/eegprep/functions/studyfunc/pop_dipparams.py b/src/eegprep/functions/studyfunc/pop_dipparams.py new file mode 100644 index 00000000..95cd3a73 --- /dev/null +++ b/src/eegprep/functions/studyfunc/pop_dipparams.py @@ -0,0 +1,32 @@ +"""Configure STUDY dipole plotting parameters.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.studyfunc._study_params import update_study_params + + +DIP_DEFAULTS = { + "axistight": "off", + "projimg": "off", + "projlines": "off", + "density": "off", + "centrline": "on", +} + + +def pop_dipparams(STUDY: dict[str, Any], *args: Any, return_com: bool = False, **kwargs: Any) -> Any: + """Set defaults or named options in ``STUDY.etc.dipparams``.""" + return update_study_params( + STUDY, + "dipparams", + DIP_DEFAULTS, + "pop_dipparams", + args, + kwargs, + return_com=return_com, + ) + + +__all__ = ["pop_dipparams"] diff --git a/src/eegprep/functions/studyfunc/pop_erpimparams.py b/src/eegprep/functions/studyfunc/pop_erpimparams.py new file mode 100644 index 00000000..bc274191 --- /dev/null +++ b/src/eegprep/functions/studyfunc/pop_erpimparams.py @@ -0,0 +1,44 @@ +"""Configure STUDY ERP-image plotting parameters.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.studyfunc._study_params import update_study_params + + +ERPIMAGE_DEFAULTS = { + "erpimageopt": [], + "sorttype": "", + "sortwin": [], + "sortfield": "latency", + "rmcomps": [], + "interp": [], + "timerange": [], + "topotime": [], + "colorlimits": [], + "concatenate": "off", + "nlines": 20, + "smoothing": 10, + "averagemode": "ave", + "averagechan": "off", +} +ERPIMAGE_FIELDS = ("erpimdata", "erpimtimes", "erpimtrials", "erpimevents") + + +def pop_erpimparams(STUDY: dict[str, Any], *args: Any, return_com: bool = False, **kwargs: Any) -> Any: + """Set defaults or named options in ``STUDY.etc.erpimparams``.""" + return update_study_params( + STUDY, + "erpimparams", + ERPIMAGE_DEFAULTS, + "pop_erpimparams", + args, + kwargs, + invalidate_on=("timerange",), + invalidated_fields=ERPIMAGE_FIELDS, + return_com=return_com, + ) + + +__all__ = ["pop_erpimparams"] diff --git a/src/eegprep/functions/studyfunc/pop_erpparams.py b/src/eegprep/functions/studyfunc/pop_erpparams.py new file mode 100644 index 00000000..38c8f9c1 --- /dev/null +++ b/src/eegprep/functions/studyfunc/pop_erpparams.py @@ -0,0 +1,37 @@ +"""Configure STUDY ERP plotting parameters.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.studyfunc._study_params import update_study_params + + +ERP_DEFAULTS = { + "topotime": [], + "filter": [], + "timerange": [], + "ylim": [], + "plotgroups": "apart", + "plotconditions": "apart", + "averagechan": "off", + "detachplots": "on", +} + + +def pop_erpparams(STUDY: dict[str, Any], *args: Any, return_com: bool = False, **kwargs: Any) -> Any: + """Set defaults or named options in ``STUDY.etc.erpparams``.""" + return update_study_params( + STUDY, + "erpparams", + ERP_DEFAULTS, + "pop_erpparams", + args, + kwargs, + invalidate_on=("timerange",), + invalidated_fields=("erpdata", "erptimes"), + return_com=return_com, + ) + + +__all__ = ["pop_erpparams"] diff --git a/src/eegprep/functions/studyfunc/pop_erspparams.py b/src/eegprep/functions/studyfunc/pop_erspparams.py new file mode 100644 index 00000000..f501afaa --- /dev/null +++ b/src/eegprep/functions/studyfunc/pop_erspparams.py @@ -0,0 +1,54 @@ +"""Configure STUDY ERSP and ITC plotting parameters.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.studyfunc._study_params import update_study_params + + +ERSP_DEFAULTS = { + "topotime": [], + "topofreq": [], + "timerange": [], + "freqrange": [], + "ersplim": [], + "itclim": [], + "maskdata": "off", + "averagemode": "rms", + "averagechan": "off", + "subbaseline": "off", +} +ERSP_FIELDS = ( + "erspdata", + "ersptimes", + "erspfreqs", + "erspbase", + "erspdatatrials", + "erspsubjinds", + "ersptrialinfo", + "itcdata", + "itctimes", + "itcfreqs", + "itcdatatrials", + "itcsubjinds", + "itctrialinfo", +) + + +def pop_erspparams(STUDY: dict[str, Any], *args: Any, return_com: bool = False, **kwargs: Any) -> Any: + """Set defaults or named options in ``STUDY.etc.erspparams``.""" + return update_study_params( + STUDY, + "erspparams", + ERSP_DEFAULTS, + "pop_erspparams", + args, + kwargs, + invalidate_on=("timerange", "freqrange", "subbaseline"), + invalidated_fields=ERSP_FIELDS, + return_com=return_com, + ) + + +__all__ = ["pop_erspparams"] diff --git a/src/eegprep/functions/studyfunc/pop_specparams.py b/src/eegprep/functions/studyfunc/pop_specparams.py new file mode 100644 index 00000000..b7c51e48 --- /dev/null +++ b/src/eegprep/functions/studyfunc/pop_specparams.py @@ -0,0 +1,37 @@ +"""Configure STUDY spectrum plotting parameters.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.studyfunc._study_params import update_study_params + + +SPEC_DEFAULTS = { + "topofreq": [], + "freqrange": [], + "ylim": [], + "subtractsubjectmean": "off", + "plotgroups": "apart", + "plotconditions": "apart", + "averagechan": "off", + "detachplots": "on", +} + + +def pop_specparams(STUDY: dict[str, Any], *args: Any, return_com: bool = False, **kwargs: Any) -> Any: + """Set defaults or named options in ``STUDY.etc.specparams``.""" + return update_study_params( + STUDY, + "specparams", + SPEC_DEFAULTS, + "pop_specparams", + args, + kwargs, + invalidate_on=("freqrange", "subtractsubjectmean"), + invalidated_fields=("specdata", "specfreqs"), + return_com=return_com, + ) + + +__all__ = ["pop_specparams"] diff --git a/src/eegprep/functions/studyfunc/pop_statparams.py b/src/eegprep/functions/studyfunc/pop_statparams.py new file mode 100644 index 00000000..874cf745 --- /dev/null +++ b/src/eegprep/functions/studyfunc/pop_statparams.py @@ -0,0 +1,113 @@ +"""Configure statistics used by STUDY measure plots.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._pop_utils import parse_key_value_args +from eegprep.functions.popfunc.plot_utils import python_literal +from eegprep.functions.studyfunc._study_utils import ensure_study + + +COMMON_DEFAULTS = { + "effect": "main", + "groupstats": "off", + "condstats": "off", + "singletrials": "off", + "mode": "eeglab", +} +EEGLAB_DEFAULTS = {"naccu": [], "alpha": np.nan, "method": "param", "mcorrect": "none"} +FIELDTRIP_DEFAULTS = { + "naccu": [], + "alpha": np.nan, + "method": "analytic", + "mcorrect": "none", + "clusterparam": "'clusterstatistic','maxsum'", + "channelneighbor": [], + "channelneighborparam": "'method','triangulation'", +} +COMMON_OPTIONS = {"effect", "groupstats", "condstats", "singletrials", "mode"} +EEGLAB_OPTIONS = {"naccu", "alpha", "method", "mcorrect"} +FIELDTRIP_OPTIONS = { + "fieldtripnaccu", + "fieldtripalpha", + "fieldtripmethod", + "fieldtripmcorrect", + "fieldtripclusterparam", + "fieldtripchannelneighbor", + "fieldtripchannelneighborparam", +} + + +def pop_statparams(STUDY: dict[str, Any], *args: Any, return_com: bool = False, **kwargs: Any) -> Any: + """Set STUDY statistics options, including EEGLAB and FieldTrip sub-options.""" + is_study = isinstance(STUDY.get("etc"), dict) + if is_study: + study = ensure_study(STUDY) + params = study["etc"].get("statistics") + else: + study = deepcopy(STUDY) + params = study + params = _with_defaults(params) + options = _options(args, kwargs) + unknown = sorted(set(options) - COMMON_OPTIONS - EEGLAB_OPTIONS - FIELDTRIP_OPTIONS) + if unknown: + raise ValueError(f"Unknown pop_statparams option(s): {', '.join(unknown)}") + for key, value in options.items(): + key = {"statistics": "method", "threshold": "alpha"}.get(key, key) + if key in COMMON_OPTIONS: + params[key] = deepcopy(value) + elif key in EEGLAB_OPTIONS: + params["eeglab"][key] = deepcopy(value) + else: + nested_key = key.removeprefix("fieldtrip") + params["fieldtrip"][nested_key] = deepcopy(value) + if nested_key == "channelneighborparam": + params["fieldtrip"]["channelneighbor"] = [] + if is_study: + study["etc"]["statistics"] = params + if options: + study["saved"] = "no" + else: + study = params + command = _history_command(options) + return (study, command) if return_com else study + + +def _with_defaults(params: Any) -> dict[str, Any]: + output = deepcopy(params) if isinstance(params, dict) else {} + for key, value in COMMON_DEFAULTS.items(): + output.setdefault(key, deepcopy(value)) + for section, defaults in (("eeglab", EEGLAB_DEFAULTS), ("fieldtrip", FIELDTRIP_DEFAULTS)): + nested = output.get(section) + if not isinstance(nested, dict): + nested = {} + for key, value in defaults.items(): + nested.setdefault(key, deepcopy(value)) + output[section] = nested + return output + + +def _options(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: + if len(args) == 1 and str(args[0]).lower() == "default": + if kwargs: + raise ValueError("'default' cannot be combined with parameter options") + return {} + options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) + if "statistics" in options: + options["method"] = options.pop("statistics") + if "threshold" in options: + options["alpha"] = options.pop("threshold") + return options + + +def _history_command(options: dict[str, Any]) -> str: + pieces = ["STUDY"] + pieces.extend(f"{key}={python_literal(value)}" for key, value in options.items()) + return f"STUDY = pop_statparams({', '.join(pieces)})" + + +__all__ = ["pop_statparams"] diff --git a/src/eegprep/functions/studyfunc/std_maketrialinfo.py b/src/eegprep/functions/studyfunc/std_maketrialinfo.py index 7d0019e9..1af892bc 100644 --- a/src/eegprep/functions/studyfunc/std_maketrialinfo.py +++ b/src/eegprep/functions/studyfunc/std_maketrialinfo.py @@ -17,13 +17,13 @@ def std_maketrialinfo( STUDY: dict[str, Any] | None, ALLEEG: list[dict[str, Any]] | None, ) -> tuple[dict[str, Any], list[list[dict[str, Any]]]]: - """Populate ``STUDY.datasetinfo[*].trialinfo`` from loaded EEG metadata.""" + """Populate trial information from each epoch's time-locking event.""" study, datasets = sync_study_datasets(STUDY, ALLEEG) alltrialinfo: list[list[dict[str, Any]]] = [] for index, eeg in enumerate(datasets): - rows = trialinfo_from_eeg(eeg) + rows = _trialinfo_from_events(eeg) if not rows: - rows = _trialinfo_from_events(eeg) + rows = trialinfo_from_eeg(eeg) alltrialinfo.append(rows) if rows and index < len(study.get("datasetinfo") or []): study["datasetinfo"][index]["trialinfo"] = rows @@ -35,21 +35,34 @@ def _trialinfo_from_events(eeg: dict[str, Any]) -> list[dict[str, Any]]: if trials <= 1: return [] events = _event_rows(eeg.get("event")) - by_epoch: dict[int, dict[str, Any]] = {} + by_epoch: dict[int, list[dict[str, Any]]] = {} for event in events: epoch = _event_epoch(event) - if epoch is None or epoch in by_epoch: + if epoch is None: continue - row = { - key: deepcopy(value) - for key, value in event.items() - if key not in EVENT_TRIALINFO_EXCLUDE and not _empty_value(value) and not isinstance(value, dict) - } - if row: - by_epoch[epoch] = row - if not by_epoch: + by_epoch.setdefault(epoch, []).append(event) + if set(by_epoch) != set(range(1, trials + 1)): return [] - return [by_epoch.get(epoch, {}) for epoch in range(1, trials + 1)] + rows = [] + for epoch in range(1, trials + 1): + event = min(by_epoch[epoch], key=lambda item: _time_lock_distance(item, epoch, eeg)) + rows.append( + { + key: deepcopy(value) + for key, value in event.items() + if key not in EVENT_TRIALINFO_EXCLUDE and not _empty_value(value) and not isinstance(value, dict) + } + ) + return rows if any(rows) else [] + + +def _time_lock_distance(event: dict[str, Any], epoch: int, eeg: dict[str, Any]) -> float: + latency = float(event.get("latency", np.inf)) + pnts = int(eeg.get("pnts", 0) or 0) + srate = float(eeg.get("srate", 1.0) or 1.0) + xmin = float(eeg.get("xmin", 0.0) or 0.0) + zero_latency = (epoch - 1) * pnts - xmin * srate + 1.0 + return abs(latency - zero_latency) def _event_rows(value: Any) -> list[dict[str, Any]]: diff --git a/tests/test_study_params_eeglab_tests.py b/tests/test_study_params_eeglab_tests.py new file mode 100644 index 00000000..8c83cc4e --- /dev/null +++ b/tests/test_study_params_eeglab_tests.py @@ -0,0 +1,238 @@ +"""Ports of current EEGLAB STUDY plotting-parameter tests.""" + +from __future__ import annotations + +import ast + +import eegprep +from eegprep.functions.studyfunc.pop_dipparams import pop_dipparams +from eegprep.functions.studyfunc.pop_erpimparams import pop_erpimparams +from eegprep.functions.studyfunc.pop_erpparams import pop_erpparams +from eegprep.functions.studyfunc.pop_erspparams import pop_erspparams +from eegprep.functions.studyfunc.pop_specparams import pop_specparams +from eegprep.functions.studyfunc.pop_statparams import pop_statparams +from tests.eeglab_tests import eeglab_test + + +STUDYFUNC_ROOT = "unittesting_studyfunc" + + +def _reference(wrapper: str, test: str): + source = f"{STUDYFUNC_ROOT}/{wrapper}/studyfunc_{wrapper}_wrapperTest.m" + return eeglab_test(source, test) + + +def _study_with_cached_measures() -> dict: + fields = { + "erpdata": [[1.0]], + "erptimes": [0.0], + "specdata": [[2.0]], + "specfreqs": [10.0], + "erspdata": [[[3.0]]], + "ersptimes": [0.0], + "erspfreqs": [10.0], + "erspbase": [1.0], + "itcdata": [[[0.5]]], + "itctimes": [0.0], + "itcfreqs": [10.0], + "erpimdata": [[4.0]], + "erpimtimes": [0.0], + "erpimtrials": [1], + "erpimevents": ["stim"], + } + return { + "name": "Generated parameter study", + "etc": {}, + "cluster": [{"name": "ParentCluster", **fields}], + "changrp": [{"name": "Cz", **fields}], + } + + +@_reference("pop_dipparams", "test_test_pop_dipparams") +def test_pop_dipparams_stores_every_current_eeglab_test_option(): + study = _study_with_cached_measures() + cases = ( + ("axistight", "on"), + ("axistight", "off"), + ("projimg", "on"), + ("projimg", "off"), + ("projlines", "on"), + ("projlines", "off"), + ("density", "on"), + ("density", "off"), + ("centrline", "on"), + ("centrline", "off"), + ) + + for key, value in cases: + study = pop_dipparams(study, key, value) + assert study["etc"]["dipparams"][key] == value + + assert study["etc"]["dipparams"] == { + "axistight": "off", + "projimg": "off", + "projlines": "off", + "density": "off", + "centrline": "off", + } + + +@_reference("pop_erpimparams", "test_test_pop_erpimparams") +def test_pop_erpimparams_stores_tested_ranges_and_invalidates_erpimage_cache(): + study = _study_with_cached_measures() + for key, value in ( + ("topotime", 100), + ("topotime", [50, 100]), + ("timerange", [0, 200]), + ("timerange", [-100, 200]), + ("colorlimits", [0, 200]), + ): + study = pop_erpimparams(study, key, value) + assert study["etc"]["erpimparams"][key] == value + + for collection in (study["cluster"], study["changrp"]): + assert not {"erpimdata", "erpimtimes", "erpimtrials", "erpimevents"}.intersection(collection[0]) + + +@_reference("pop_erpparams", "test_test_pop_erpparams") +def test_pop_erpparams_stores_every_current_option_and_invalidates_erp_cache(): + study = _study_with_cached_measures() + cases = ( + ("topotime", 100), + ("topotime", [100, 200]), + ("topotime", []), + ("filter", 20), + ("filter", []), + ("timerange", [-100, 200]), + ("timerange", []), + ("ylim", [0, 20]), + ("plotgroups", "together"), + ("plotgroups", "apart"), + ("plotconditions", "together"), + ("plotconditions", "apart"), + ("averagechan", "on"), + ("averagechan", "off"), + ) + + for key, value in cases: + study = pop_erpparams(study, key, value) + assert study["etc"]["erpparams"][key] == value + + for collection in (study["cluster"], study["changrp"]): + assert "erpdata" not in collection[0] + assert "erptimes" not in collection[0] + + +@_reference("pop_erspparams", "test_test_pop_erspparams") +def test_pop_erspparams_stores_every_current_option_and_invalidates_time_frequency_cache(): + study = _study_with_cached_measures() + cases = ( + ("timerange", [-100, 400]), + ("freqrange", [2, 60]), + ("ersplim", [1, 20]), + ("itclim", [0, 1]), + ("itclim", [0, 2]), + ("topotime", 100), + ("topotime", [100, 200]), + ("topofreq", 10), + ("topofreq", [8, 12]), + ("subbaseline", "on"), + ("subbaseline", "off"), + ("maskdata", "on"), + ("maskdata", "off"), + ) + + for key, value in cases: + study = pop_erspparams(study, key, value) + assert study["etc"]["erspparams"][key] == value + + for collection in (study["cluster"], study["changrp"]): + assert not {"erspdata", "ersptimes", "erspfreqs", "erspbase", "itcdata", "itctimes", "itcfreqs"}.intersection( + collection[0] + ) + + +@_reference("pop_specparams", "test_test_pop_specparams") +def test_pop_specparams_stores_every_current_option_and_invalidates_spectrum_cache(): + study = _study_with_cached_measures() + cases = ( + ("topofreq", 10), + ("topofreq", [8, 12]), + ("freqrange", [2, 60]), + ("ylim", [0, 20]), + ("plotgroups", "together"), + ("plotgroups", "apart"), + ("plotconditions", "together"), + ("plotconditions", "apart"), + ("subtractsubjectmean", "on"), + ("subtractsubjectmean", "off"), + ("averagechan", "on"), + ("averagechan", "off"), + ) + + for key, value in cases: + study = pop_specparams(study, key, value) + assert study["etc"]["specparams"][key] == value + + for collection in (study["cluster"], study["changrp"]): + assert "specdata" not in collection[0] + assert "specfreqs" not in collection[0] + + +@_reference("pop_statparams", "test_test_pop_statparams") +def test_pop_statparams_stores_common_eeglab_and_fieldtrip_namespaces(): + study = _study_with_cached_measures() + cases = ( + ("groupstats", "on", "common", "groupstats"), + ("groupstats", "off", "common", "groupstats"), + ("condstats", "on", "common", "condstats"), + ("condstats", "off", "common", "condstats"), + ("singletrials", "on", "common", "singletrials"), + ("singletrials", "off", "common", "singletrials"), + ("mode", "eeglab", "common", "mode"), + ("mode", "fieldtrip", "common", "mode"), + ("method", "param", "eeglab", "method"), + ("method", "perm", "eeglab", "method"), + ("method", "bootstrap", "eeglab", "method"), + ("naccu", 2000, "eeglab", "naccu"), + ("alpha", 0.5, "eeglab", "alpha"), + ("mcorrect", "fdr", "eeglab", "mcorrect"), + ("mcorrect", "holms", "eeglab", "mcorrect"), + ("mcorrect", "bonferoni", "eeglab", "mcorrect"), + ("mcorrect", "none", "eeglab", "mcorrect"), + ("fieldtripmethod", "analytic", "fieldtrip", "method"), + ("fieldtripmethod", "montecarlo", "fieldtrip", "method"), + ("fieldtripnaccu", 2000, "fieldtrip", "naccu"), + ("fieldtripalpha", 0.5, "fieldtrip", "alpha"), + ("fieldtripmcorrect", "cluster", "fieldtrip", "mcorrect"), + ("fieldtripmcorrect", "max", "fieldtrip", "mcorrect"), + ("fieldtripmcorrect", "fdr", "fieldtrip", "mcorrect"), + ("fieldtripmcorrect", "holms", "fieldtrip", "mcorrect"), + ("fieldtripmcorrect", "bonferoni", "fieldtrip", "mcorrect"), + ("fieldtripmcorrect", "none", "fieldtrip", "mcorrect"), + ("fieldtripclusterparam", [], "fieldtrip", "clusterparam"), + ("fieldtripchannelneighbor", [], "fieldtrip", "channelneighbor"), + ("fieldtripchannelneighborparam", [], "fieldtrip", "channelneighborparam"), + ) + + for option, value, namespace, stored_key in cases: + study = pop_statparams(study, option, value) + statistics = study["etc"]["statistics"] + actual = statistics[stored_key] if namespace == "common" else statistics[namespace][stored_key] + assert actual == value + + study, command = pop_statparams(study, "default", return_com=True) + assert study["etc"]["statistics"]["fieldtrip"]["alpha"] == 0.5 + assert command == "STUDY = pop_statparams(STUDY)" + ast.parse(command) + + +def test_study_parameter_functions_are_public_and_history_replays_empty_values(): + study = _study_with_cached_measures() + + updated, command = eegprep.pop_erpparams(study, timerange=[], return_com=True) + namespace = {"STUDY": study, "pop_erpparams": pop_erpparams} + exec(command, namespace) + + assert namespace["STUDY"]["etc"]["erpparams"] == updated["etc"]["erpparams"] + assert "timerange=[]" in command diff --git a/tests/test_study_workflows_eeglab_tests.py b/tests/test_study_workflows_eeglab_tests.py new file mode 100644 index 00000000..2eb2facf --- /dev/null +++ b/tests/test_study_workflows_eeglab_tests.py @@ -0,0 +1,406 @@ +"""Generated-fixture ports of current EEGLAB STUDY workflow tests.""" + +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") + +from matplotlib import pyplot as plt +import numpy as np + +from eegprep.functions.popfunc.plot_utils import component_activations +from eegprep.functions.popfunc.pop_saveset import pop_saveset +from eegprep.functions.studyfunc.pop_clust import pop_clust +from eegprep.functions.studyfunc.pop_study import pop_study +from eegprep.functions.studyfunc.std_editset import std_editset +from eegprep.functions.studyfunc.std_erpplot import std_erpplot +from eegprep.functions.studyfunc.std_erspplot import std_erspplot +from eegprep.functions.studyfunc.std_makedesign import std_makedesign +from eegprep.functions.studyfunc.std_preclust import std_preclust +from eegprep.functions.studyfunc.std_precomp import std_precomp +from eegprep.functions.studyfunc.std_selectdesign import std_selectdesign +from eegprep.functions.studyfunc.std_specplot import std_specplot +from tests.eeglab_tests import eeglab_test + + +STUDYFUNC_ROOT = "unittesting_studyfunc" + + +def _reference(wrapper: str, test: str): + source = f"{STUDYFUNC_ROOT}/{wrapper}/studyfunc_{wrapper}_wrapperTest.m" + return eeglab_test(source, test) + + +def _deterministic_eeg( + setname: str, + subject: str, + condition: str, + *, + offset: float = 0.0, + n_channels: int = 4, + n_components: int = 3, +) -> dict: + srate = 64.0 + pnts = 128 + trials = 4 + seconds = np.arange(pnts, dtype=float) / srate + data = np.empty((n_channels, pnts, trials), dtype=float) + activations = np.empty((n_components, pnts, trials), dtype=float) + for trial in range(trials): + phase = trial * np.pi / 8 + for channel in range(n_channels): + frequency = 6.0 + 2.0 * channel + data[channel, :, trial] = np.sin(2 * np.pi * frequency * seconds + phase) + offset + for component in range(n_components): + frequency = 8.0 + 2.0 * component + activations[component, :, trial] = np.sin(2 * np.pi * frequency * seconds + phase) + offset / 2 + mixing = np.zeros((n_channels, n_components), dtype=float) + mixing[:n_components, :] = np.eye(n_components) + weights = np.zeros((n_components, n_channels), dtype=float) + weights[:, :n_components] = np.eye(n_components) + chanlocs = [] + for channel in range(n_channels): + angle = 2 * np.pi * channel / n_channels + chanlocs.append( + { + "labels": f"Ch{channel + 1}", + "theta": float(np.degrees(angle)), + "radius": 0.35, + "X": float(np.cos(angle)), + "Y": float(np.sin(angle)), + "Z": 0.0, + } + ) + dipoles = [ + { + "posxyz": [float(component + 1), float((-1) ** component), float(component) / 2], + "momxyz": [1.0, 0.0, 0.5], + "rv": 0.05, + } + for component in range(n_components) + ] + return { + "setname": setname, + "subject": subject, + "condition": condition, + "group": "control", + "session": 1, + "run": 1, + "data": data, + "nbchan": n_channels, + "pnts": pnts, + "trials": trials, + "srate": srate, + "xmin": 0.0, + "xmax": float(seconds[-1]), + "times": seconds * 1000.0, + "chanlocs": chanlocs, + "icaact": activations, + "icawinv": mixing, + "icaweights": weights, + "icasphere": np.eye(n_channels), + "icachansind": list(range(n_channels)), + "event": [ + {"type": "stim", "latency": trial * pnts + 1, "epoch": trial + 1, "urevent": trial + 1} + for trial in range(trials) + ], + "urevent": [{"type": "stim", "latency": trial * pnts + 1, "epoch": trial + 1} for trial in range(trials)], + "epoch": [{"event": [trial], "eventtype": ["stim"]} for trial in range(trials)], + "dipfit": {"model": dipoles}, + "etc": {}, + } + + +def _study_pair(*, n_channels: int = 4, n_components: int = 3) -> tuple[dict, list[dict]]: + datasets = [ + _deterministic_eeg("s01_target", "S01", "target", n_channels=n_channels, n_components=n_components), + _deterministic_eeg( + "s02_standard", + "S02", + "standard", + offset=0.2, + n_channels=n_channels, + n_components=n_components, + ), + ] + return pop_study(None, datasets, name="Generated N400 study") + + +@_reference("std_editset", "test_test_std_editset") +def test_std_editset_loads_generated_sets_assigns_metadata_and_removes_a_dataset(tmp_path: Path): + first = _deterministic_eeg("ignore", "", "") + second = _deterministic_eeg("probe", "", "", offset=0.2) + first_path = tmp_path / "Ignore.set" + second_path = tmp_path / "Probe.set" + pop_saveset(first, first_path) + pop_saveset(second, second_path) + + study, alleeg = std_editset( + None, + None, + "commands", + [ + ["index", 1, "load", first_path, "subject", "S01", "condition", "ignore"], + ["index", 2, "load", second_path, "subject", "S01", "condition", "probe"], + ], + "updatedat", + "off", + ) + study = std_makedesign(study, alleeg, 1, variable1="condition", values1=["ignore", "probe"]) + study, alleeg = std_editset(study, alleeg, commands=[["remove", 2]], updatedat="off") + + assert len(alleeg) == 1 + assert alleeg[0]["setname"] == "ignore" + assert [(row["subject"], row["condition"]) for row in study["datasetinfo"]] == [("S01", "ignore")] + assert study["design"][0]["variable"][0]["value"] == ["ignore", "probe"] + + +@_reference("std_makedesign", "test_test_std_makedesign") +def test_std_makedesign_preserves_subject_selection_and_combined_factor_levels(): + datasets = [] + for subject in ("S02", "S07", "S08", "S10"): + datasets.extend( + [ + _deterministic_eeg(f"{subject}_syn", subject, "synonyms"), + _deterministic_eeg(f"{subject}_nonsyn", subject, "non-synonyms", offset=0.2), + ] + ) + study, alleeg = pop_study(None, datasets, name="Generated design study") + selected_subjects = ["S02", "S07", "S08", "S10"] + + study = std_makedesign( + study, + alleeg, + 1, + variable1="condition", + name="STUDY.design 1", + values1=["non-synonyms", "synonyms"], + subjselect=selected_subjects, + ) + study = std_makedesign( + study, + alleeg, + 2, + variable1="condition", + name="Design 2 test", + values1=["non-synonyms", ["non-synonyms", "synonyms"]], + subjselect=selected_subjects, + ) + + assert len(study["design"]) == 2 + assert study["design"][0]["cases"]["value"] == selected_subjects + assert study["design"][1]["cases"]["value"] == selected_subjects + assert study["design"][1]["variable"][0]["value"][1] == ["non-synonyms", "synonyms"] + + +@_reference("std_precomp", "test_test_std_precomp") +def test_std_precomp_computes_channel_and_component_erp_spectrum_ersp_and_itc(): + study, alleeg = _study_pair() + tf_params = {"cycles": 0, "nfreqs": 8, "timesout": 8} + + study, alleeg = std_precomp( + study, + alleeg, + "components", + recompute="on", + erp="on", + scalp="on", + spec="on", + ersp="on", + itc="on", + erspparams=tf_params, + ) + study, alleeg = std_precomp( + study, + alleeg, + "channels", + recompute="on", + erp="on", + spec="on", + ersp="on", + itc="on", + erspparams=tf_params, + ) + + channel = study["changrp"][0] + component = study["cluster"][0] + np.testing.assert_allclose(np.asarray(channel["erpdata"])[0], np.mean(alleeg[0]["data"][0], axis=1)) + assert np.asarray(component["erpdata"]).shape == (2, 3, 128) + assert np.asarray(component["topo"]).shape == (2, 3, 4) + assert np.asarray(channel["erspdata"]).shape == np.asarray(channel["itcdata"]).shape + assert np.nanmin(channel["itcdata"]) >= 0.0 + assert np.nanmax(channel["itcdata"]) <= 1.0 + 1e-12 + peak = int(np.argmax(np.asarray(channel["specdata"])[0])) + assert channel["specfreqs"][peak] == 6.0 + + +@_reference("std_preclust", "test_test_std_preclust") +def test_std_preclust_combines_all_current_measure_families_and_final_pca(): + study, alleeg = _study_pair() + study, alleeg = std_precomp( + study, + alleeg, + "components", + recompute="on", + erp="on", + scalp="on", + spec="on", + ersp="on", + itc="on", + erspparams={"cycles": 0, "nfreqs": 6, "timesout": 6}, + ) + + study, _alleeg = std_preclust( + study, + alleeg, + 1, + ["spec", "npca", 4, "norm", 1, "weight", 1, "freqrange", [3, 25]], + ["erp", "npca", 4, "norm", 1, "weight", 1, "timewindow", []], + ["scalp", "npca", 4, "norm", 1, "weight", 1, "abso", 1], + ["dipoles", "norm", 1, "weight", 10], + ["ersp", "npca", 4, "freqrange", [], "timewindow", [], "norm", 1, "weight", 1], + ["itc", "npca", 4, "freqrange", [], "timewindow", [], "norm", 1, "weight", 1], + ["finaldim", "npca", 4], + ) + + preclust = study["etc"]["preclust"] + assert np.asarray(preclust["preclustdata"]).shape == (6, 4) + assert [item["measure"] for item in preclust["preclustparams"]] == [ + "spec", + "erp", + "scalp", + "dipoles", + "ersp", + "itc", + "finaldim", + ] + assert np.isfinite(preclust["preclustdata"]).all() + + +@_reference("pop_clust", "test_test_pop_clust") +def test_pop_clust_runs_current_kmeanscluster_scenario_with_ten_clusters(): + study, alleeg = _study_pair(n_channels=6, n_components=6) + study, alleeg = std_preclust(study, alleeg, 1, ["scalp", "npca", 4, "norm", 1, "weight", 1]) + + study = pop_clust(study, alleeg, algorithm="kmeanscluster", clus_num=10, random_state=11) + + children = study["cluster"][1:] + assert len(children) == 10 + assert sum(len(cluster["comps"]) for cluster in children) == 12 + assert study["cluster"][0]["child"] == [cluster["name"] for cluster in children] + + +@_reference("std_selectdesign", "test_test_std_selectdesign") +def test_std_selectdesign_scans_generated_designs_without_corrupting_component_membership(): + study, alleeg = _study_pair() + study = std_makedesign(study, alleeg, 2, variable1="subject", values1=["S01"], name="S01") + study = std_makedesign(study, alleeg, 3, variable1="subject", values1=["S02"], name="S02") + study, alleeg = std_preclust(study, alleeg) + original_pairs = (deepcopy(study["cluster"][0]["sets"]), deepcopy(study["cluster"][0]["comps"])) + + for design_index in range(1, 4): + selected = std_selectdesign(study, alleeg, design_index) + assert selected["currentdesign"] == design_index + assert (selected["cluster"][0]["sets"], selected["cluster"][0]["comps"]) == original_pairs + + +@_reference("std_erpplot", "test_test_stderpplot2") +def test_std_erpplot_channel_output_matches_direct_epoch_average(): + study, alleeg = _study_pair() + study, alleeg = std_precomp(study, alleeg, [1], erp="on", recompute="on") + + _study, erpdata, erptimes, figure = std_erpplot(study, alleeg, channels=[1]) + + expected = np.stack([np.mean(eeg["data"][0], axis=1) for eeg in alleeg]) + np.testing.assert_allclose(erpdata[0], expected, atol=1e-12) + np.testing.assert_allclose(erptimes, alleeg[0]["times"], atol=1e-12) + assert len(figure.axes[0].lines) == 1 + plt.close(figure) + + +@_reference("std_erpplot", "test_test_stderpplot3") +def test_std_erpplot_component_output_matches_direct_scaled_activation_average(): + study, alleeg = _study_pair() + study, alleeg = std_precomp(study, alleeg, "components", erp="on", scalp="on", recompute="on") + + _study, erpdata, erptimes, figure = std_erpplot(study, alleeg, clusters=1, components=[2]) + + expected = [] + for eeg in alleeg: + scale = float(np.sqrt(np.mean(np.asarray(eeg["icawinv"])[:, 1] ** 2))) + expected.append(np.mean(component_activations(eeg)[1], axis=1) * scale) + np.testing.assert_allclose(erpdata[0][:, 0, :], np.stack(expected), atol=1e-12) + np.testing.assert_allclose(erptimes, alleeg[0]["times"], atol=1e-12) + plt.close(figure) + + +@_reference("std_specplot", "test_test_stdspecplot3") +def test_std_specplot_channel_output_preserves_known_oscillation_peak(): + study, alleeg = _study_pair() + study, alleeg = std_precomp(study, alleeg, [2], spec="on", recompute="on") + + _study, specdata, frequencies, figure = std_specplot(study, alleeg, channels=[1]) + + assert frequencies[int(np.argmax(specdata[0][0]))] == 8.0 + assert frequencies[int(np.argmax(specdata[0][1]))] == 8.0 + assert np.isfinite(specdata[0]).all() + plt.close(figure) + + +@_reference("std_specplot", "test_test_stdspecplot4") +def test_std_specplot_component_output_preserves_known_activation_peak(): + study, alleeg = _study_pair() + study, alleeg = std_precomp(study, alleeg, "components", spec="on", recompute="on") + + _study, specdata, frequencies, figure = std_specplot(study, alleeg, clusters=1, components=[3]) + + assert frequencies[int(np.argmax(specdata[0][0, 0]))] == 12.0 + assert frequencies[int(np.argmax(specdata[0][1, 0]))] == 12.0 + assert np.isfinite(specdata[0]).all() + plt.close(figure) + + +@_reference("std_erspplot", "test_test_std_erspplot2") +def test_std_erspplot_channel_output_matches_precomputed_axes_and_cache(): + study, alleeg = _study_pair() + study, alleeg = std_precomp( + study, + alleeg, + [1], + ersp="on", + recompute="on", + erspparams={"cycles": 0, "nfreqs": 8, "timesout": 8}, + ) + + _study, erspdata, times, frequencies, figure = std_erspplot(study, alleeg, channels=[1]) + + np.testing.assert_allclose(erspdata[0], study["changrp"][0]["erspdata"]) + np.testing.assert_allclose(times, study["changrp"][0]["ersptimes"]) + np.testing.assert_allclose(frequencies, study["changrp"][0]["erspfreqs"]) + assert np.isfinite(erspdata[0]).all() + plt.close(figure) + + +@_reference("std_erspplot", "test_test_std_erspplot3") +def test_std_erspplot_component_output_selects_the_requested_component(): + study, alleeg = _study_pair() + study, alleeg = std_precomp( + study, + alleeg, + "components", + ersp="on", + recompute="on", + erspparams={"cycles": 0, "nfreqs": 8, "timesout": 8}, + ) + + _study, erspdata, times, frequencies, figure = std_erspplot(study, alleeg, clusters=1, components=[2]) + + expected = np.asarray(study["cluster"][0]["erspdata"])[:, 1:2] + np.testing.assert_allclose(erspdata[0], expected) + assert erspdata[0].shape == (2, 1, frequencies.size, times.size) + assert np.isfinite(erspdata[0]).all() + plt.close(figure) diff --git a/tests/test_tutorial_eeglab_tests.py b/tests/test_tutorial_eeglab_tests.py new file mode 100644 index 00000000..d51f2d08 --- /dev/null +++ b/tests/test_tutorial_eeglab_tests.py @@ -0,0 +1,198 @@ +"""Generated-data ports of current EEGLAB tutorial workflows.""" + +from __future__ import annotations + +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") + +from matplotlib import pyplot as plt +import numpy as np + +from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset +from eegprep.functions.adminfunc.eeg_store import eeg_store +from eegprep.functions.popfunc.pop_comments import pop_comments +from eegprep.functions.popfunc.pop_eegfilt import pop_eegfilt +from eegprep.functions.popfunc.pop_epoch import pop_epoch +from eegprep.functions.popfunc.pop_newset import pop_newset +from eegprep.functions.popfunc.pop_resample import pop_resample +from eegprep.functions.popfunc.pop_reref import pop_reref +from eegprep.functions.popfunc.pop_rmbase import pop_rmbase +from eegprep.functions.popfunc.pop_saveset import pop_saveset +from eegprep.functions.popfunc.pop_topoplot import pop_topoplot +from eegprep.functions.studyfunc.std_editset import std_editset +from eegprep.functions.studyfunc.std_makedesign import std_makedesign +from eegprep.functions.studyfunc.std_maketrialinfo import std_maketrialinfo +from tests.eeglab_tests import eeglab_test +from tests.fixtures import create_test_eeg + + +TUTORIAL_WRAPPER = "unittesting_tutorial/tutorial_wrapperTest.m" + + +def _continuous_tutorial_eeg() -> dict: + eeg = create_test_eeg(n_channels=4, n_samples=1024, srate=128.0) + seconds = np.arange(eeg["pnts"], dtype=float) / eeg["srate"] + eeg["data"] = np.stack( + [ + np.sin(2 * np.pi * 6 * seconds) + 0.15 * np.sin(2 * np.pi * 0.2 * seconds), + np.sin(2 * np.pi * 8 * seconds + 0.2), + np.sin(2 * np.pi * 10 * seconds + 0.4), + np.sin(2 * np.pi * 12 * seconds + 0.6), + ] + ) + eeg["times"] = seconds * 1000.0 + eeg["event"] = [ + {"type": "square", "latency": 257.0, "duration": 0.0, "urevent": 1}, + {"type": "rt", "latency": 289.0, "duration": 0.0, "urevent": 2}, + {"type": "square", "latency": 641.0, "duration": 0.0, "urevent": 3}, + {"type": "rt", "latency": 681.0, "duration": 0.0, "urevent": 4}, + ] + eeg["urevent"] = [dict(event) for event in eeg["event"]] + eeg["setname"] = "Generated continuous tutorial EEG" + return eeg + + +@eeglab_test(TUTORIAL_WRAPPER, "test_eeglab_history") +def test_eeglab_history_tutorial_runs_as_replayable_generated_data_pipeline(): + eeg = _continuous_tutorial_eeg() + alleeg, eeg, currentset = eeg_store([], eeg) + + eeg, filter_command = pop_eegfilt(eeg, 1, 0, 128, return_com=True) + alleeg, eeg, currentset, newset_command = pop_newset( + alleeg, + eeg, + currentset, + setname="filtered Continuous EEG Data", + ) + eeg, reref_command = pop_reref(eeg, [], return_com=True) + eeg, comment_command = pop_comments( + eeg, + "", + "Dataset was highpass filtered at 1 Hz and rereferenced.", + True, + return_com=True, + ) + eeg, epoch_command = pop_epoch( + eeg, + ["square"], + [-1, 2], + newname="Continuous EEG Data epochs", + return_com=True, + ) + alleeg, eeg, currentset, overwrite_command = pop_newset( + alleeg, + eeg, + currentset, + setname="Continuous EEG Data epochs", + overwrite="on", + ) + eeg, baseline_command = pop_rmbase(eeg, [-1000, 0], return_com=True) + alleeg, eeg, currentset = eeg_store(alleeg, eeg, currentset) + resampled, resample_command = pop_resample(eeg, 64, return_com=True) + figures, topoplot_command = pop_topoplot( + eeg, + 1, + [0, 200, 400], + "Topographic plot", + [1, 3], + 0, + electrodes="on", + plot="off", + return_com=True, + ) + + assert eeg["trials"] == 2 + assert eeg["data"].shape == (4, eeg["pnts"], 2) + baseline = np.asarray(eeg["times"]) <= 0 + np.testing.assert_allclose(np.mean(eeg["data"][:, baseline, :], axis=1), 0.0, atol=1e-10) + np.testing.assert_allclose(np.mean(eeg["data"], axis=0), 0.0, atol=1e-10) + assert resampled["srate"] == 64.0 + assert resampled["pnts"] < eeg["pnts"] + assert currentset == 2 + assert len(alleeg) == 2 + assert len(figures) == 1 + assert "highpass filtered" in eeg["comments"] + for command in ( + filter_command, + newset_command, + reref_command, + comment_command, + epoch_command, + overwrite_command, + baseline_command, + resample_command, + topoplot_command, + ): + assert command + plt.close(figures[0]) + + +@eeglab_test(TUTORIAL_WRAPPER, "test_event_processing_single_dataset") +def test_event_processing_single_dataset_shifts_events_and_adds_time_locked_cues(): + eeg = _continuous_tutorial_eeg() + original_count = len(eeg["event"]) + original_square_latencies = [event["latency"] for event in eeg["event"] if event["type"] == "square"] + + for event in eeg["event"]: + event["latency"] += 10 + for event in list(eeg["event"]): + if event["type"] != "square": + continue + cue = dict(event) + cue["latency"] = event["latency"] - 0.1 * eeg["srate"] + cue["type"] = "cue" + eeg["event"].append(cue) + eeg = eeg_checkset(eeg, "eventconsistency") + + events = list(eeg["event"]) + shifted_squares = [event["latency"] for event in events if event["type"] == "square"] + cue_latencies = [event["latency"] for event in events if event["type"] == "cue"] + assert len(events) == original_count + 2 + np.testing.assert_allclose(shifted_squares, np.asarray(original_square_latencies) + 10) + np.testing.assert_allclose(cue_latencies, np.asarray(shifted_squares) - 0.1 * eeg["srate"]) + assert [event["latency"] for event in events] == sorted(event["latency"] for event in events) + + +@eeglab_test(TUTORIAL_WRAPPER, "test_event_processing_study") +def test_event_processing_study_exposes_derived_reaction_time_as_a_design_variable(tmp_path: Path): + eeg = create_test_eeg(n_channels=4, n_samples=64, n_trials=2, srate=128.0) + eeg["setname"] = "generated_rtevents" + eeg["subject"] = "S01" + eeg["epoch"] = [] + eeg["event"] = [ + {"type": "square", "latency": 17.0, "epoch": 1}, + {"type": "rt", "latency": 37.0, "epoch": 1}, + {"type": "square", "latency": 81.0, "epoch": 2}, + {"type": "rt", "latency": 113.0, "epoch": 2}, + ] + for current, following in zip(eeg["event"], eeg["event"][1:]): + if current["type"] == "square" and following["type"] == "rt" and current["epoch"] == following["epoch"]: + current["rt"] = (following["latency"] - current["latency"]) / eeg["srate"] * 1000.0 + dataset_path = tmp_path / "generated_rtevents.set" + pop_saveset(eeg, dataset_path) + + study, alleeg = std_editset( + None, + None, + commands=[["index", 1, "load", dataset_path, "subject", "S01"]], + updatedat="off", + ) + study, trialinfo = std_maketrialinfo(study, alleeg) + rt_values = [row["rt"] for row in trialinfo[0]] + study = std_makedesign( + study, + alleeg, + 1, + name="Reaction time", + variable1="rt", + values1=rt_values, + vartype1="continuous", + ) + + np.testing.assert_allclose(rt_values, [156.25, 250.0]) + assert study["design"][0]["variable"][0]["label"] == "rt" + assert study["design"][0]["variable"][0]["vartype"] == "continuous" + assert study["datasetinfo"][0]["trialinfo"] == trialinfo[0] From e6ae5c3b8a3f7d19314433d63e42c23a96a4172d Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:47 -0700 Subject: [PATCH 12/45] Port current event and epoch import tests (#340) --- docs/source/api/interactive_pop_workflows.rst | 15 + docs/source/examples/plot_import_data.py | 2 + src/eegprep/functions/guifunc/menu_actions.py | 2 +- src/eegprep/functions/popfunc/_file_io.py | 12 +- .../functions/popfunc/pop_importepoch.py | 190 +++++++++++- .../functions/popfunc/pop_importevent.py | 286 ++++++++++++++---- .../functions/popfunc/pop_importpres.py | 2 + src/eegprep/resources/help/pop_importepoch.md | 25 +- src/eegprep/resources/help/pop_importevent.md | 16 +- tests/test_file_menu_pop_functions.py | 17 +- tests/test_pop_importepoch_current.py | 114 +++++++ tests/test_pop_importevent_current.py | 105 +++++++ tests/test_sample_data_pop_functions.py | 7 +- 13 files changed, 712 insertions(+), 81 deletions(-) create mode 100644 tests/test_pop_importepoch_current.py create mode 100644 tests/test_pop_importevent_current.py diff --git a/docs/source/api/interactive_pop_workflows.rst b/docs/source/api/interactive_pop_workflows.rst index 8e382f3a..304a18fb 100644 --- a/docs/source/api/interactive_pop_workflows.rst +++ b/docs/source/api/interactive_pop_workflows.rst @@ -33,6 +33,21 @@ also accepts EEGLAB's positional event-type, time, and duration field names. eegprep.pop_saveset eegprep.pop_writeeeg +Event and Epoch Tables +---------------------- + +``pop_importevent`` accepts text files or record sequences. Imported latencies +use seconds by default, ``timeunit=1e-3`` selects milliseconds, and +``timeunit=numpy.nan`` selects sample positions. Existing events are appended +unless ``append="no"`` is supplied. Alignment can anchor imported rows to the +existing event stream and optionally compensate for small clock-rate drift; +``indices`` uses 1-based event numbers when updating selected rows. + +``pop_importepoch`` accepts one row per epoch. It preserves the row metadata in +``EEG["epoch"]`` and creates time-locking and latency-field events in +``EEG["event"]``. Event latencies are stored as 1-based absolute samples, and +durations are stored as sample counts. + Preprocessing ------------- diff --git a/docs/source/examples/plot_import_data.py b/docs/source/examples/plot_import_data.py index 64a7031e..4e9a3375 100644 --- a/docs/source/examples/plot_import_data.py +++ b/docs/source/examples/plot_import_data.py @@ -90,6 +90,8 @@ def find_sample_data() -> Path: str(events_file), "timeunit", np.nan, + "append", + "no", ) print("replaced events:", [(e["type"], e["latency"]) for e in with_events["event"]]) diff --git a/src/eegprep/functions/guifunc/menu_actions.py b/src/eegprep/functions/guifunc/menu_actions.py index 31550c96..3685777d 100644 --- a/src/eegprep/functions/guifunc/menu_actions.py +++ b/src/eegprep/functions/guifunc/menu_actions.py @@ -613,7 +613,7 @@ def _import_current_dataset_metadata(self, action: str, parent: Any | None) -> N else: from eegprep.functions.popfunc.pop_importevent import pop_importevent - eeg_out, command = pop_importevent(selection, "event", filename, return_com=True) + eeg_out, command = pop_importevent(selection, "event", filename, "append", "no", return_com=True) self._store_current_from_gui(eeg_out, command=command) self._refresh() diff --git a/src/eegprep/functions/popfunc/_file_io.py b/src/eegprep/functions/popfunc/_file_io.py index 67ac84fc..b5bd36e7 100644 --- a/src/eegprep/functions/popfunc/_file_io.py +++ b/src/eegprep/functions/popfunc/_file_io.py @@ -6,6 +6,7 @@ import json import math from pathlib import Path +import re from typing import Any import mne @@ -183,11 +184,12 @@ def read_table_records( *, fields: list[str] | tuple[str, ...] | None = None, skipline: int = 0, + delimiter: str | int | None = None, ) -> list[dict[str, Any]]: """Read a simple CSV/TSV/whitespace table into record dictionaries.""" path = Path(filename) with path.open(newline="", encoding="utf-8") as stream: - rows = [row for row in _read_rows(stream, path.suffix.lower()) if row] + rows = [row for row in _read_rows(stream, path.suffix.lower(), delimiter) if row] rows = rows[int(skipline) :] if not rows: return [] @@ -308,7 +310,13 @@ def json_safe(value: Any) -> Any: return value -def _read_rows(stream: Any, suffix: str) -> list[list[str]]: +def _read_rows(stream: Any, suffix: str, delimiter: str | int | None) -> list[list[str]]: + if delimiter is not None: + delimiters = chr(delimiter) if isinstance(delimiter, int) else str(delimiter) + if len(delimiters) == 1: + return [row for row in csv.reader(stream, delimiter=delimiters)] + pattern = f"[{re.escape(delimiters)}]+" + return [re.split(pattern, line.strip()) for line in stream if line.strip()] if suffix == ".csv": return [row for row in csv.reader(stream)] if suffix == ".tsv": diff --git a/src/eegprep/functions/popfunc/pop_importepoch.py b/src/eegprep/functions/popfunc/pop_importepoch.py index 3a9b37f3..6fec0e4d 100644 --- a/src/eegprep/functions/popfunc/pop_importepoch.py +++ b/src/eegprep/functions/popfunc/pop_importepoch.py @@ -3,40 +3,97 @@ from __future__ import annotations from copy import deepcopy +from pathlib import Path from typing import Any +import numpy as np + from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset, strict_mode -from eegprep.functions.popfunc._file_io import read_table_records +from eegprep.functions.popfunc._file_io import events_to_records, read_table_records from eegprep.functions.popfunc._pop_utils import format_history_value, parse_key_value_args, parse_text_tokens def pop_importepoch( EEG: dict[str, Any], - filename: str | None = None, + filename: Any = None, fieldlist: list[str] | tuple[str, ...] | str | None = None, *args: Any, return_com: bool = False, **kwargs: Any, ) -> dict[str, Any] | tuple[dict[str, Any], str]: - """Import per-epoch metadata from a text table.""" + """Import per-epoch metadata and construct its corresponding events. + + Each input row describes one epoch. ``latencyfields`` create additional + events at epoch-relative times, while ``typefield`` names the time-locking + event at zero. Latencies and durations are stored in samples. + """ if filename is None: filename = kwargs.pop("filename", None) if filename is None: raise ValueError("pop_importepoch requires an epoch info file") options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) fields = _tokens(fieldlist) - records = read_table_records(filename, fields=fields, skipline=int(options.get("headerlines", 0) or 0)) + records = _load_records(filename, fields, headerlines=_scalar_int(options.get("headerlines", 0))) + if fields is None: + fields = list(records[0]) if records else [] trials = int(EEG.get("trials", 1) or 1) if trials <= 1: raise ValueError("pop_importepoch requires an epoched dataset") if len(records) != trials: raise ValueError("The number of imported epoch rows must match EEG.trials") + + latency_fields = _tokens(options.get("latencyfields")) or [] + duration_fields = _duration_fields(options.get("durationfields"), len(latency_fields)) + type_field = str(options.get("typefield", "") or "") + _validate_fields(fields, latency_fields, duration_fields, type_field) + out = deepcopy(EEG) out["epoch"] = records out["saved"] = "no" - if str(options.get("clearevents", "on")).lower() in {"on", "yes", "true", "1"}: - out["event"] = [] - out["urevent"] = [] + old_events = events_to_records(out.get("event")) + clear_events = _is_on(options.get("clearevents", "on")) or any("epoch" not in event for event in old_events) + events = [] if clear_events else old_events + srate = float(out.get("srate", 1) or 1) + pnts = int(out.get("pnts", 0) or 0) + xmin = float(out.get("xmin", 0) or 0) + timeunit = float(options.get("timeunit", 1 / srate)) + + if xmin <= 0: + for trial, record in enumerate(records, start=1): + events.append( + { + "epoch": trial, + "type": record[type_field] if type_field else "TLE", + "latency": -xmin * srate + 1 + (trial - 1) * pnts, + "duration": 0, + } + ) + + for latency_field, duration_field in zip(latency_fields, duration_fields): + for trial, record in enumerate(records, start=1): + events.append( + { + "epoch": trial, + "type": latency_field, + "latency": (float(record[latency_field]) * timeunit - xmin) * srate + 1 + (trial - 1) * pnts, + "duration": _duration(record, duration_field, timeunit=timeunit, srate=srate), + } + ) + + if not events: + events = [{"epoch": trial} for trial in range(1, trials + 1)] + other_fields = _other_fields(fields, latency_fields, duration_fields, type_field) + for event in events: + epoch_index = int(event["epoch"]) - 1 + for field in other_fields: + event["epoch" + field if field in {"type", "latency"} else field] = records[epoch_index][field] + + out["event"] = events + if _is_empty(out.get("eventdescription")): + out["eventdescription"] = _event_descriptions(events) + with strict_mode(False): + out = eeg_checkset(out, "eventconsistency") + out["event"], out["urevent"] = _rebuild_urevents(events_to_records(out["event"])) with strict_mode(False): out = eeg_checkset(out) command = _history_command(filename, fields, options) @@ -44,15 +101,130 @@ def pop_importepoch( return (out, command) if return_com else out +def _load_records(source: Any, fields: list[str] | None, *, headerlines: int) -> list[dict[str, Any]]: + if isinstance(source, (str, bytes, Path)): + return read_table_records(source, fields=fields, skipline=headerlines) + if isinstance(source, np.ndarray): + source = source.tolist() + if isinstance(source, dict): + return [dict(source)] + rows = list(source) + if not rows: + return [] + if all(isinstance(row, dict) for row in rows): + return [dict(row) for row in rows] + if not fields: + raise ValueError("pop_importepoch requires field names for epoch arrays") + records = [] + for row in rows: + values = np.asarray(row, dtype=object).ravel().tolist() + if len(values) != len(fields): + raise ValueError("There must be as many field names as columns in the epoch array") + records.append(dict(zip(fields, values))) + return records + + +def _duration_fields(value: Any, count: int) -> list[Any]: + if value is None or (isinstance(value, str) and not value): + return [0] * count + if isinstance(value, str): + values = parse_text_tokens(value) + else: + values = np.asarray(value, dtype=object).ravel().tolist() + if len(values) != count: + raise ValueError("There must be one duration field for each latency field") + return values + + +def _validate_fields( + fields: list[str], + latency_fields: list[str], + duration_fields: list[Any], + type_field: str, +) -> None: + missing = [field for field in latency_fields if field not in fields] + missing.extend(str(field) for field in duration_fields if field not in {0, "0"} and str(field) not in fields) + if type_field and type_field not in fields: + missing.append(type_field) + if missing: + raise ValueError(f"Epoch field(s) not found: {', '.join(missing)}") + + +def _duration(record: dict[str, Any], field: Any, *, timeunit: float, srate: float) -> float: + if field in {0, "0", None, ""}: + return 0.0 + return float(record[str(field)]) * timeunit * srate + + +def _other_fields( + fields: list[str], + latency_fields: list[str], + duration_fields: list[Any], + type_field: str, +) -> list[str]: + excluded = set(latency_fields) + excluded.update(str(field) for field in duration_fields if field not in {0, "0"}) + if type_field: + excluded.add(type_field) + return [field for field in fields if field not in excluded] + + +def _event_descriptions(events: list[dict[str, Any]]) -> list[str]: + descriptions = { + "epoch": "Epoch number", + "type": "Event type", + "latency": "Event latency", + "duration": "Event duration", + } + fields = [] + for event in events: + for field in event: + if field not in fields: + fields.append(field) + return [descriptions.get(field, "") for field in fields] + + +def _rebuild_urevents(events: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + normalized_events = [] + urevents = [] + for index, event in enumerate(events): + urevent = dict(event) + urevent.pop("urevent", None) + normalized = dict(urevent) + normalized["urevent"] = index + normalized_events.append(normalized) + urevents.append(urevent) + return normalized_events, urevents + + +def _scalar_int(value: Any) -> int: + values = np.asarray(value).ravel() + if values.size != 1: + raise ValueError("headerlines must be a scalar") + return int(values[0]) + + +def _is_on(value: Any) -> bool: + return str(value).lower() in {"on", "yes", "true", "1"} + + +def _is_empty(value: Any) -> bool: + if value is None: + return True + if isinstance(value, np.ndarray): + return value.size == 0 + return not value + + def _tokens(value: Any) -> list[str] | None: - if value in (None, ""): + if value is None or (isinstance(value, str) and not value): return None if isinstance(value, str): return [str(item) for item in parse_text_tokens(value)] return [str(item) for item in value] -def _history_command(filename: str, fields: list[str] | None, options: dict[str, Any]) -> str: +def _history_command(filename: Any, fields: list[str] | None, options: dict[str, Any]) -> str: pieces = [format_history_value(filename), format_history_value(fields or [])] for key in ["latencyfields", "durationfields", "typefield", "timeunit", "headerlines", "clearevents"]: if key in options: diff --git a/src/eegprep/functions/popfunc/pop_importevent.py b/src/eegprep/functions/popfunc/pop_importevent.py index 48831475..702bd730 100644 --- a/src/eegprep/functions/popfunc/pop_importevent.py +++ b/src/eegprep/functions/popfunc/pop_importevent.py @@ -3,49 +3,72 @@ from __future__ import annotations from copy import deepcopy +import logging +import math from pathlib import Path from typing import Any import numpy as np +from scipy.optimize import fmin from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset, strict_mode -from eegprep.functions.popfunc._file_io import events_to_records, read_table_records, records_to_events +from eegprep.functions.popfunc._file_io import events_to_records, read_table_records from eegprep.functions.popfunc._pop_utils import format_history_value, parse_key_value_args, parse_text_tokens +logger = logging.getLogger(__name__) + + def pop_importevent( EEG: dict[str, Any], *args: Any, return_com: bool = False, **kwargs: Any, ) -> dict[str, Any] | tuple[dict[str, Any], str]: - """Import events from a text table or record array.""" + """Import events from a text table or record array. + + Event latencies are converted to 1-based sample positions. Existing events + are appended by default, matching EEGLAB; pass ``append='no'`` to replace + them. ``indices`` contains EEGLAB-facing 1-based event indices to update. + """ options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) event_source = options.get("event", options.get("filename")) if event_source is None: raise ValueError("pop_importevent requires an event file or event records") fields = _fields(options.get("fields")) - if isinstance(event_source, (str, bytes, Path)): - records = read_table_records(str(event_source), fields=fields, skipline=int(options.get("skipline", 0) or 0)) - else: - records = [dict(record) for record in event_source] - events = records_to_events(records, srate=float(EEG.get("srate", 1) or 1), timeunit=_timeunit(options)) - append = str(options.get("append", "no")).lower() in {"on", "yes", "true", "1"} + records = _load_records(event_source, fields, options) + old_events = events_to_records(EEG.get("event")) + indices = _event_indices(options.get("indices"), len(old_events)) + append = _is_on(options.get("append", "yes")) or bool(indices) + latency_present = any("latency" in record for record in records) + if not latency_present and any("latency" in event for event in old_events): + append = False + + align = _align_value(options) + if not math.isnan(align): + _validate_alignment(align, old_events, len(records)) + if append: + logger.warning("pop_importevent: alignment and append were both requested; applying EEGLAB behavior") + + events, imported_indices = _merge_records(old_events, records, append=append, indices=indices) + _recompute_latencies( + events, + imported_indices, + old_events, + srate=float(EEG.get("srate", 1) or 1), + timeunit=_timeunit(options), + align=align, + optimalign=_is_on(options.get("optimalign", "on")), + optimoffset=_is_on(options.get("optimoffset", "off")), + optimmeas=str(options.get("optimmeas", "mean")).lower(), + ) + out = deepcopy(EEG) - if append: - original_events, original_urevents = _events_with_existing_urevents( - events_to_records(out.get("event")), - events_to_records(out.get("urevent")), - ) - imported_events, imported_urevents = _events_with_new_urevents(events, len(original_urevents)) - events = original_events + imported_events - events.sort(key=lambda item: float(item.get("latency", np.inf))) - out["urevent"] = original_urevents + imported_urevents - else: - events, urevents = _events_with_new_urevents(events, 0) - out["urevent"] = urevents out["event"] = events out["saved"] = "no" + with strict_mode(False): + out = eeg_checkset(out, "eventconsistency") + out["event"], out["urevent"] = _rebuild_urevents(events_to_records(out["event"])) with strict_mode(False): out = eeg_checkset(out) command = _history_command(event_source, options) @@ -53,6 +76,182 @@ def pop_importevent( return (out, command) if return_com else out +def _load_records(event_source: Any, fields: list[str] | None, options: dict[str, Any]) -> list[dict[str, Any]]: + if isinstance(event_source, (str, bytes, Path)): + return read_table_records( + str(event_source), + fields=fields, + skipline=int(options.get("skipline", 0) or 0), + delimiter=options.get("delim"), + ) + if isinstance(event_source, np.ndarray): + event_source = event_source.tolist() + if isinstance(event_source, dict): + return [dict(event_source)] + rows = list(event_source) + if not rows: + return [] + if all(isinstance(record, dict) for record in rows): + return [dict(record) for record in rows] + if not fields: + raise ValueError("pop_importevent requires fields for event arrays") + records = [] + for row in rows: + values = np.asarray(row, dtype=object).ravel().tolist() + if len(values) != len(fields): + raise ValueError("All event rows must have the same number of values as fields") + records.append(dict(zip(fields, values))) + return records + + +def _event_indices(value: Any, count: int) -> list[int]: + if value is None: + return [] + values = np.asarray(value).ravel().tolist() + indices = [] + for item in values: + index = int(item) + if index < 1 or index > count: + raise ValueError("event indices must be 1-based and within EEG.event") + indices.append(index - 1) + return indices + + +def _merge_records( + old_events: list[dict[str, Any]], + records: list[dict[str, Any]], + *, + append: bool, + indices: list[int], +) -> tuple[list[dict[str, Any]], list[int]]: + if indices: + if len(indices) != len(records): + raise ValueError("The number of event rows must match the number of indices") + events = deepcopy(old_events) + for event_index, record in zip(indices, records): + events[event_index].update(deepcopy(record)) + return events, indices + if append: + events = deepcopy(old_events) + deepcopy(records) + return events, list(range(len(old_events), len(events))) + if old_events and len(records) == len(old_events): + events = deepcopy(old_events) + for event, record in zip(events, records): + event.update(deepcopy(record)) + return events, list(range(len(events))) + return deepcopy(records), list(range(len(records))) + + +def _recompute_latencies( + events: list[dict[str, Any]], + indices: list[int], + old_events: list[dict[str, Any]], + *, + srate: float, + timeunit: float, + align: float, + optimalign: bool, + optimoffset: bool, + optimmeas: str, +) -> None: + if not indices or not any("latency" in events[index] for index in indices): + if any("duration" in events[index] for index in indices): + raise ValueError("A duration field cannot be defined without a latency field") + return + if optimmeas not in {"mean", "median"}: + raise ValueError("optimmeas must be 'mean' or 'median'") + + numeric_timeunit = not math.isnan(timeunit) + for initial_index, event_index in enumerate(indices, start=1): + event = events[event_index] + if "latency" not in event: + continue + latency = float(event["latency"]) + if numeric_timeunit: + event["init_index"] = initial_index + event["init_time"] = latency * timeunit + event["latency"] = latency * srate * timeunit + if "duration" in event: + event["duration"] = float(event["duration"]) * srate * timeunit + else: + event["latency"] = latency + + if not math.isnan(align): + event_anchor_index = 0 if align >= 0 else int(-align) + imported_anchor = float(events[event_anchor_index]["latency"]) + old_anchor = float(old_events[int(align) if align >= 0 else 0]["latency"]) + for event_index in indices: + events[event_index]["latency"] = float(events[event_index]["latency"]) - imported_anchor + old_anchor + + scale = 1.0 + offset = 0.0 + if optimalign and not math.isnan(align): + scale, offset = _optimal_alignment(events, old_events, align, optimmeas, optimoffset) + if not 0.99 <= scale <= 1.01: + scale = 1.0 + + if not math.isnan(align) and scale != 1.0: + event_anchor_index = 0 if align >= 0 else int(-align) + anchor = float(events[event_anchor_index]["latency"]) + for event_index in indices: + latency = float(events[event_index]["latency"]) + events[event_index]["latency"] = (latency - anchor) * scale + anchor + offset + elif numeric_timeunit: + for event_index in indices: + latency = float(events[event_index]["latency"]) + events[event_index]["latency"] = round((latency + 1) * 1000 * scale + offset) / 1000 + + +def _optimal_alignment( + events: list[dict[str, Any]], + old_events: list[dict[str, Any]], + align: float, + measure: str, + optimize_offset: bool, +) -> tuple[float, float]: + new_latencies = np.asarray([float(event["latency"]) for event in events], dtype=float) + old_latencies = np.asarray([float(event["latency"]) for event in old_events], dtype=float) + new_anchor = new_latencies[0] + old_anchor = old_latencies[int(align)] if align >= 0 else old_latencies[0] + new_relative = new_latencies - new_anchor + old_relative = old_latencies - old_anchor + + def objective(parameters: np.ndarray) -> float: + scale = float(parameters[0]) + offset = float(parameters[1]) if parameters.size == 2 else 0.0 + differences = np.abs(scale * new_relative[:, np.newaxis] - old_relative[np.newaxis, :] + offset) + nearest = differences.min(axis=0) + return float(np.median(nearest) if measure == "median" else np.mean(nearest)) + + initial = np.asarray([1.0, 0.0] if optimize_offset else [1.0]) + parameters = fmin(objective, initial, disp=False) + parameters = fmin(objective, parameters, disp=False) + return float(parameters[0]), float(parameters[1]) if parameters.size == 2 else 0.0 + + +def _validate_alignment(align: float, old_events: list[dict[str, Any]], imported_count: int) -> None: + if not old_events: + raise ValueError("Cannot align imported events without pre-existing events") + if any("latency" not in event for event in old_events): + raise ValueError("Pre-existing events must have latency values for alignment") + if align >= 0 and int(align) >= len(old_events): + raise ValueError("align refers to a pre-existing event outside EEG.event") + if align < 0 and int(-align) >= imported_count: + raise ValueError("negative align refers to an imported event outside the event table") + + +def _align_value(options: dict[str, Any]) -> float: + value = options.get("align", math.nan) + align = float(value) + if not math.isnan(align) and not align.is_integer(): + raise ValueError("align must be an integer or NaN") + return align + + +def _is_on(value: Any) -> bool: + return str(value).lower() in {"on", "yes", "true", "1"} + + def _fields(value: Any) -> list[str] | None: if value in (None, ""): return None @@ -61,55 +260,29 @@ def _fields(value: Any) -> list[str] | None: return [str(item) for item in value] -def _timeunit(options: dict[str, Any]) -> float | None: +def _timeunit(options: dict[str, Any]) -> float: if "timeunit" not in options: - return None + return 1.0 value = options["timeunit"] if isinstance(value, str) and value.lower() == "nan": return float("nan") return float(value) -def _events_with_existing_urevents( +def _rebuild_urevents( events: list[dict[str, Any]], - urevents: list[dict[str, Any]], -) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - normalized_urevents = [_urevent_record(event) for event in urevents] - normalized_events = [] - for event in events: - normalized = dict(event) - urevent_index = _valid_urevent_index(normalized.get("urevent"), len(normalized_urevents)) - if urevent_index is None: - urevent_index = len(normalized_urevents) - normalized_urevents.append(_urevent_record(normalized)) - normalized["urevent"] = urevent_index - normalized_events.append(normalized) - return normalized_events, normalized_urevents - - -def _events_with_new_urevents( - events: list[dict[str, Any]], - offset: int, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: normalized_events = [] urevents = [] for index, event in enumerate(events): urevent = _urevent_record(event) event_with_ref = dict(urevent) - event_with_ref["urevent"] = offset + index + event_with_ref["urevent"] = index normalized_events.append(event_with_ref) urevents.append(urevent) return normalized_events, urevents -def _valid_urevent_index(value: Any, count: int) -> int | None: - try: - index = int(value) - except (TypeError, ValueError): - return None - return index if 0 <= index < count else None - - def _urevent_record(event: dict[str, Any]) -> dict[str, Any]: record = dict(event) record.pop("urevent", None) @@ -118,7 +291,18 @@ def _urevent_record(event: dict[str, Any]) -> dict[str, Any]: def _history_command(event_source: Any, options: dict[str, Any]) -> str: pieces = [format_history_value("event"), format_history_value(event_source)] - for key in ["fields", "append", "skipline", "timeunit", "align", "optimalign"]: + for key in [ + "fields", + "append", + "skipline", + "timeunit", + "delim", + "indices", + "align", + "optimalign", + "optimoffset", + "optimmeas", + ]: if key in options: pieces.extend([format_history_value(key), format_history_value(options[key])]) return f"EEG = pop_importevent(EEG, {', '.join(pieces)});" diff --git a/src/eegprep/functions/popfunc/pop_importpres.py b/src/eegprep/functions/popfunc/pop_importpres.py index cac54cde..3571f2b3 100644 --- a/src/eegprep/functions/popfunc/pop_importpres.py +++ b/src/eegprep/functions/popfunc/pop_importpres.py @@ -29,6 +29,8 @@ def pop_importpres( durfield = str(options.pop("durfield", durfield or "")) if "align" not in options: options["align"] = align + if len(EEG.get("event", [])) == 0: + options["align"] = float("nan") skipline = int(options.pop("skipline", 0) or 0) explicit_fields = "fields" in options diff --git a/src/eegprep/resources/help/pop_importepoch.md b/src/eegprep/resources/help/pop_importepoch.md index 62c8d7f7..573b0740 100644 --- a/src/eegprep/resources/help/pop_importepoch.md +++ b/src/eegprep/resources/help/pop_importepoch.md @@ -1,16 +1,33 @@ # POP_IMPORTEPOCH - Import epoch metadata -`pop_importepoch` imports one row of metadata per epoch from a text table. +`pop_importepoch` imports one row of metadata per epoch from a text table or a +two-dimensional Python sequence. Usage: ```python EEG = pop_importepoch(EEG, "epochs.tsv", ["condition", "rt"]) -EEG, com = pop_importepoch(EEG, "epochs.tsv", return_com=True) +EEG, com = pop_importepoch( + EEG, + rows, + ["epoch", "response", "rt"], + "latencyfields", ["rt"], + "typefield", "response", + "timeunit", 1e-3, + return_com=True, +) ``` The current dataset must be epoched, and the number of imported rows must match -`EEG["trials"]`. Options can control header rows, latency fields, duration -fields, type fields, and whether existing events are cleared. +`EEG["trials"]`. By default, prior events are cleared and a `TLE` event is +created at time zero in every epoch. `typefield` supplies those event types. +Every name in `latencyfields` creates another event per epoch; corresponding +`durationfields` values are optional and use the same `timeunit`. Event +latencies remain 1-based absolute sample positions, while `EEG["epoch"]` also +contains the imported row metadata and derived epoch-relative event fields. + +Use `headerlines` to skip leading file rows and `clearevents="off"` to retain +existing epoched events. Existing continuous-style events without an `epoch` +field cannot be retained because they cannot be associated with imported rows. See also: POP_IMPORTEVENT, POP_EPOCH diff --git a/src/eegprep/resources/help/pop_importevent.md b/src/eegprep/resources/help/pop_importevent.md index fe820f76..b905b1fe 100644 --- a/src/eegprep/resources/help/pop_importevent.md +++ b/src/eegprep/resources/help/pop_importevent.md @@ -5,12 +5,22 @@ Usage: ```python -EEG = pop_importevent(EEG, "event", "events.tsv") +EEG = pop_importevent(EEG, "event", "events.tsv", "append", "no") EEG, com = pop_importevent(EEG, "event", records, return_com=True) ``` Imported events are normalized into EEGLAB-style event dictionaries with -1-based sample latencies. Use the append option to preserve existing events and -extend `urevent`; otherwise EEGPrep replaces the event table. +1-based sample latencies. Existing events are appended by default; use +`"append", "no"` to replace them. `timeunit` gives the latency unit in seconds, +so millisecond tables use `1e-3`; use `numpy.nan` when values are already sample +positions. Duration values use the same unit and are converted to sample counts. + +`align` aligns an imported event to an existing event: `0` aligns the first +events, a positive value selects that zero-based offset in the existing table, +and a negative value selects a later imported event to align to the first +existing event. `optimalign="on"` additionally estimates a sampling-rate ratio +when alignment is requested. `indices` contains 1-based event indices to update +instead of appending rows. Use `delim` for a delimiter that cannot be inferred +from the filename, for example `"delim", ","` for comma-separated `.txt` data. See also: POP_CHANEVENT, POP_IMPORTPRES, POP_IMPORTERPLAB diff --git a/tests/test_file_menu_pop_functions.py b/tests/test_file_menu_pop_functions.py index 4cecd61d..599e5c4b 100644 --- a/tests/test_file_menu_pop_functions.py +++ b/tests/test_file_menu_pop_functions.py @@ -140,7 +140,9 @@ def test_pop_importevent_replaces_and_appends_events(tmp_path): events_file.write_text("type\tlatency\tduration\nstim\t1\t0\nresp\t4\t1\n", encoding="utf-8") eeg = _eeg() - replaced, command = pop_importevent(eeg, "event", events_file, "timeunit", math.nan, return_com=True) + replaced, command = pop_importevent( + eeg, "event", events_file, "timeunit", math.nan, "append", "no", return_com=True + ) appended = pop_importevent(eeg, "event", events_file, "timeunit", math.nan, "append", "yes") assert [event["type"] for event in replaced["event"]] == ["stim", "resp"] @@ -153,24 +155,21 @@ def test_pop_importevent_replaces_and_appends_events(tmp_path): assert "pop_importevent" in command -def test_pop_importevent_append_keeps_loaded_urevent_pointers(tmp_path): +def test_pop_importevent_append_rebuilds_sorted_urevent_pointers(tmp_path): events_file = tmp_path / "events.tsv" events_file.write_text("type\tlatency\tduration\nnew1\t5\t0\nnew2\t9\t0\n", encoding="utf-8") eeg = pop_loadset(str(SAMPLE_DATASET_PATH)) - original = [(event["type"], event["latency"], event["urevent"]) for event in eeg["event"]] + original = [(event["type"], event["latency"]) for event in eeg["event"]] n_urevents = len(eeg["urevent"]) appended = pop_importevent(eeg, "event", events_file, "timeunit", math.nan, "append", "yes") - kept = [(e["type"], e["latency"], e["urevent"]) for e in appended["event"] if e["type"] not in {"new1", "new2"}] + kept = [(e["type"], e["latency"]) for e in appended["event"] if e["type"] not in {"new1", "new2"}] assert kept == original - assert sorted(e["urevent"] for e in appended["event"] if e["type"] in {"new1", "new2"}) == [ - n_urevents, - n_urevents + 1, - ] assert len(appended["urevent"]) == n_urevents + 2 for event in appended["event"]: assert appended["urevent"][event["urevent"]]["type"] == event["type"] + assert appended["urevent"][event["urevent"]]["latency"] == event["latency"] def test_pop_importepoch_requires_epoch_count_match(tmp_path): @@ -181,7 +180,7 @@ def test_pop_importepoch_requires_epoch_count_match(tmp_path): imported, command = pop_importepoch(eeg, epoch_file, return_com=True) assert [epoch["condition"] for epoch in imported["epoch"]] == ["rare", "frequent"] - assert imported["event"].size == 0 + assert [(event["type"], event["latency"]) for event in imported["event"]] == [("TLE", 1), ("TLE", 7)] assert "pop_importepoch" in command diff --git a/tests/test_pop_importepoch_current.py b/tests/test_pop_importepoch_current.py new file mode 100644 index 00000000..1592676f --- /dev/null +++ b/tests/test_pop_importepoch_current.py @@ -0,0 +1,114 @@ +"""Current eeglab_tests coverage for importing epoch metadata.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from eegprep.functions.popfunc._file_io import eeg_from_data +from eegprep.functions.popfunc.pop_importepoch import pop_importepoch +from tests.eeglab_tests import eeglab_test + + +UPSTREAM = "unittesting_popfunc/pop_importepoch/popfunc_pop_importepoch_wrapperTest.m" + + +def _epoched_eeg() -> dict: + eeg = eeg_from_data(np.zeros((1, 100, 3), dtype=float), srate=100, xmin=-0.2) + eeg["event"] = [{"type": "old", "latency": 10.0, "duration": 0, "epoch": 1, "urevent": 0}] + eeg["urevent"] = [{"type": "old", "latency": 10.0, "duration": 0, "epoch": 1}] + return eeg + + +def _write_epoch_table(path: Path) -> Path: + path.write_text( + "Epoch\tResponse\tResponse_latency\n1\tCorrect\t250\n2\tWrong\t500\n3\tCorrect\t750\n", + encoding="utf-8", + ) + return path + + +_EPOCH_ROWS = [[1, "Correct", 250], [2, "Wrong", 500], [3, "Correct", 750]] +_EPOCH_ROWS_WITH_DURATION = [ + [1, "Correct", 250, 100], + [2, "Wrong", 500, 200], + [3, "Correct", 750, 300], +] + + +@eeglab_test(UPSTREAM, "test_test_pop_importepoch") +@pytest.mark.parametrize("case", [1, 2, 3, 4], ids=["case-1", "case-2", "case-3", "case-4"]) +def test_current_pop_importepoch_option_cases(tmp_path: Path, case: int) -> None: + epoch_file = _write_epoch_table(tmp_path / "epochinfo.txt") + if case == 1: + source = epoch_file + fields = ["epoch", "response", "rt"] + options = ("latencyfields", ["rt"], "timeunit", 1e-3, "headerlines", [1]) + elif case == 2: + source = epoch_file + fields = ["epoch", "response", "rt"] + options = ( + "typefield", + "response", + "timeunit", + 1e-3, + "latencyfields", + ["rt"], + "headerlines", + [1], + "clearevents", + "on", + ) + elif case == 3: + source = _EPOCH_ROWS + fields = ["epoch", "response", "rt"] + options = ( + "typefield", + "response", + "timeunit", + 1e-3, + "latencyfields", + ["rt"], + "headerlines", + [0], + "clearevents", + "on", + ) + else: + source = _EPOCH_ROWS_WITH_DURATION + fields = ["epoch", "response", "rt", "dr"] + options = ( + "typefield", + "response", + "durationfields", + ["dr"], + "timeunit", + 1e-3, + "latencyfields", + ["rt"], + "headerlines", + [0], + "clearevents", + "on", + ) + + output = pop_importepoch(_epoched_eeg(), source, fields, *options) + + events = [dict(event) for event in output["event"]] + assert len(events) == 6 + assert all(event["type"] != "old" for event in events) + assert [event["latency"] for event in events] == pytest.approx([21, 46, 121, 171, 221, 296]) + expected_locking_types = ["TLE", "TLE", "TLE"] if case == 1 else ["Correct", "Wrong", "Correct"] + assert [events[index]["type"] for index in (0, 2, 4)] == expected_locking_types + assert [events[index]["type"] for index in (1, 3, 5)] == ["rt", "rt", "rt"] + assert [output["epoch"][index]["response"] for index in range(3)] == ["Correct", "Wrong", "Correct"] + assert [output["epoch"][index]["rt"] for index in range(3)] == [250, 500, 750] + if case == 1: + assert [event["response"] for event in events] == ["Correct", "Correct", "Wrong", "Wrong", "Correct", "Correct"] + else: + assert all("response" not in event for event in events) + expected_durations = [0, 10, 0, 20, 0, 30] if case == 4 else [0, 0, 0, 0, 0, 0] + assert [event["duration"] for event in events] == pytest.approx(expected_durations) + assert all(output["urevent"][event["urevent"]]["latency"] == event["latency"] for event in events) diff --git a/tests/test_pop_importevent_current.py b/tests/test_pop_importevent_current.py new file mode 100644 index 00000000..128e704c --- /dev/null +++ b/tests/test_pop_importevent_current.py @@ -0,0 +1,105 @@ +"""Current eeglab_tests coverage for importing event tables.""" + +from __future__ import annotations + +import math +from pathlib import Path + +import numpy as np +import pytest + +from eegprep.functions.popfunc._file_io import eeg_from_data +from eegprep.functions.popfunc.pop_importevent import pop_importevent +from tests.eeglab_tests import eeglab_test + + +UPSTREAM = "unittesting_popfunc/pop_importevent/popfunc_pop_importevent_wrapperTest.m" + + +def _continuous_eeg() -> dict: + eeg = eeg_from_data(np.zeros((1, 25_000), dtype=float), srate=100) + old_events = [ + {"type": f"old-{index + 1}", "latency": 101.0 + index * 50.25, "urevent": index} for index in range(200) + ] + eeg["event"] = old_events + eeg["urevent"] = [{key: value for key, value in event.items() if key != "urevent"} for event in old_events] + return eeg + + +def _write_event_table(path: Path, delimiter: str) -> Path: + rows = [delimiter.join(("Latency", "Type", "Position"))] + rows.extend( + delimiter.join((str(index), "target" if index % 2 else "response", str(index % 2 + 1))) + for index in range(1, 101) + ) + path.write_text("\n".join(rows) + "\n", encoding="utf-8") + return path + + +_CASES = [ + (1, (), 300, 100, 101.0, 10_001.0), + (2, ("append", "no", "optimalign", "on"), 100, 100, 101.0, 10_001.0), + (3, ("append", "no", "optimalign", "off"), 100, 100, 101.0, 10_001.0), + (4, ("append", "no", "align", 0, "optimalign", "on"), 100, 100, 101.0, 10_062.875), + (5, ("append", "no", "align", 0, "optimalign", "off"), 100, 100, 102.0, 10_002.0), + (6, ("append", "no", "align", 199, "optimalign", "on"), 100, 100, 10_101.75, 20_001.75), + (7, ("append", "no", "align", 199, "optimalign", "off"), 100, 100, 10_101.75, 20_001.75), + (8, ("append", "no", "align", -99, "optimalign", "on"), 1, 1, 101.0, 101.0), + (9, ("append", "no", "align", -99, "optimalign", "off"), 2, 2, 2.0, 102.0), + (10, ("append", "yes", "optimalign", "on"), 300, 100, 101.0, 10_001.0), + (11, ("append", "yes", "optimalign", "off"), 300, 100, 101.0, 10_001.0), + (12, ("append", "yes", "align", 0, "optimalign", "on"), 300, 100, 101.0, 10_001.0), + (13, ("append", "yes", "align", 0, "optimalign", "off"), 300, 100, 101.0, 10_001.0), + (14, ("append", "yes", "align", 199, "optimalign", "on"), 300, 100, 10_100.75, 20_000.75), + (15, ("append", "yes", "align", 199, "optimalign", "off"), 300, 100, 10_100.75, 20_000.75), + (16, ("append", "yes", "align", -99, "optimalign", "on"), 251, 51, 26.25, 5_026.25), + (17, ("append", "yes", "align", -99, "optimalign", "off"), 251, 51, 26.25, 5_026.25), + (18, ("append", "no", "indices", list(range(1, 101)), "optimalign", "on"), 200, 100, 101.0, 10_001.0), + (19, ("append", "no", "indices", [], "optimalign", "on"), 100, 100, 101.0, 10_001.0), + (20, ("append", "no", "timeunit", 1e-3, "optimalign", "on"), 100, 100, 1.1, 11.0), + (21, ("delim", ","), 300, 100, 101.0, 10_001.0), +] + + +@eeglab_test(UPSTREAM, "test_test_pop_importevent") +@pytest.mark.parametrize( + ("case", "extra_options", "event_count", "imported_count", "first_latency", "last_latency"), + _CASES, + ids=[f"case-{case}" for case, *_rest in _CASES], +) +def test_current_pop_importevent_option_cases( + tmp_path: Path, + case: int, + extra_options: tuple, + event_count: int, + imported_count: int, + first_latency: float, + last_latency: float, +) -> None: + delimiter = "," if case == 21 else "\t" + event_file = _write_event_table(tmp_path / f"events-{case}.txt", delimiter) + options = [ + "event", + event_file, + "fields", + ["latency", "type", "position"], + "skipline", + 1, + "timeunit", + 1, + "align", + math.nan, + ] + options.extend(extra_options) + + output = pop_importevent(_continuous_eeg(), *options) + + events = [dict(event) for event in output["event"]] + imported = [event for event in events if event.get("type") in {"target", "response"}] + assert len(events) == event_count + assert len(imported) == imported_count + assert imported[0]["latency"] == pytest.approx(first_latency, abs=1e-3) + assert imported[-1]["latency"] == pytest.approx(last_latency, abs=1e-3) + assert all(event["position"] in {1, 2} for event in imported) + assert all(event["init_index"] in range(1, 101) for event in imported) + assert all(output["urevent"][event["urevent"]]["type"] == event["type"] for event in events) diff --git a/tests/test_sample_data_pop_functions.py b/tests/test_sample_data_pop_functions.py index 1ee7ae5f..056b8b30 100644 --- a/tests/test_sample_data_pop_functions.py +++ b/tests/test_sample_data_pop_functions.py @@ -520,7 +520,9 @@ def test_pop_importevent_replaces_sample_events_from_table(tmp_path, sample_eeg) events_file = tmp_path / "events.tsv" events_file.write_text("type\tlatency\tduration\nnewstim\t10\t0\n", encoding="utf-8") - imported, command = pop_importevent(sample_eeg, "event", events_file, "timeunit", np.nan, return_com=True) + imported, command = pop_importevent( + sample_eeg, "event", events_file, "timeunit", np.nan, "append", "no", return_com=True + ) assert len(imported["event"]) == 1 assert imported["event"][0]["type"] == "newstim" @@ -561,7 +563,8 @@ def test_pop_importepoch_updates_sample_epoch_metadata(tmp_path, sample_eeg): assert len(imported["epoch"]) == epoched["trials"] assert imported["epoch"][0]["condition"] == "square_1" - assert imported["event"].size == 0 + assert imported["event"].size == epoched["trials"] + assert all(event["type"] == "TLE" for event in imported["event"]) assert "pop_importepoch" in command From 8efb9b6d7742e5201603494590e9d1ba88d0dc7b Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:47 -0700 Subject: [PATCH 13/45] Port current file workflow tests (#341) --- docs/source/api/interactive_pop_workflows.rst | 7 +- docs/source/changelog.rst | 6 + docs/source/user_guide/storage.rst | 20 ++- src/eegprep/functions/popfunc/pop_fileio.py | 43 ++++- src/eegprep/functions/popfunc/pop_loadset.py | 51 +++++- src/eegprep/functions/popfunc/pop_select.py | 5 +- src/eegprep/functions/popfunc/pop_writeeeg.py | 69 +++++++- src/eegprep/resources/help/pop_fileio.md | 7 + src/eegprep/resources/help/pop_loadset.md | 7 + src/eegprep/resources/help/pop_writeeeg.md | 11 +- tests/test_file_menu_pop_functions.py | 13 +- tests/test_phase2_file_channel_location.py | 2 + tests/test_sample_data_pop_functions.py | 155 ++++++++++++++---- 13 files changed, 321 insertions(+), 75 deletions(-) diff --git a/docs/source/api/interactive_pop_workflows.rst b/docs/source/api/interactive_pop_workflows.rst index 304a18fb..232b8e0f 100644 --- a/docs/source/api/interactive_pop_workflows.rst +++ b/docs/source/api/interactive_pop_workflows.rst @@ -10,8 +10,11 @@ Loading and Saving ``pop_biosig`` and ``pop_fileio`` accept ``blockrange=[start, stop]`` for half-open, seconds-based reads of continuous EDF, BDF, and other MNE-backed -formats. ``pop_importpres`` recognizes tab-delimited Presentation headers and -also accepts EEGLAB's positional event-type, time, and duration field names. +formats. ``pop_fileio`` also accepts EEGLAB's 1-based ``channels`` selection +and inclusive ``samples`` and ``trials`` ranges. ``pop_loadset`` supports +metadata-only and 1-based channel loading through ``loadmode``. +``pop_importpres`` recognizes tab-delimited Presentation headers and also +accepts EEGLAB's positional event-type, time, and duration field names. .. autosummary:: :toctree: generated/ diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 86856208..44b6a960 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,12 @@ the `GitHub Releases `_ page. Unreleased ========== +- ``pop_loadset`` now supports EEGLAB's metadata-only and 1-based channel + loading modes, while ``pop_fileio`` applies 1-based channel and inclusive + sample/trial selections with consistent dataset bookkeeping. ``pop_writeeeg`` + now writes real EDF and BDF files through the installed writer instead of + routing unsupported BDF/GDF format names into MNE's EDF-only exporter; GDF + output fails explicitly until a compatible writer is available. - Added ``pop_dipparams``, ``pop_erpimparams``, ``pop_erpparams``, ``pop_erspparams``, ``pop_specparams``, and ``pop_statparams`` for storing EEGLAB-compatible STUDY plotting and statistics settings. Analysis-defining diff --git a/docs/source/user_guide/storage.rst b/docs/source/user_guide/storage.rst index ebf84512..185a0e88 100644 --- a/docs/source/user_guide/storage.rst +++ b/docs/source/user_guide/storage.rst @@ -79,11 +79,15 @@ The GUI, ``EEGPrepSession``, and ``eegprep-console`` use the same history, and dataset menus stay synchronized. Unsaved resident datasets cannot be offloaded; save them first or keep ``option_storedisk`` disabled. -Current Limitations -=================== - -``pop_loadset`` supports full dataset loading for Phase 5. EEGLAB channel-only -and ``loadmode="info"`` paths fail clearly instead of pretending data is -available. Derived caches such as ``icaact`` are not managed by a separate -lazy-storage layer, and EEGPrep does not provide multi-process write -coordination for shared ``.fdt`` files. +Selective Loading +================= + +``pop_loadset(path, loadmode="info")`` loads metadata without loading sample +data. ``EEG["data"]`` contains the saved sidecar filename or ``"in set file"`` +so callers can tell where the samples live. Passing an integer or sequence as +``loadmode`` loads those 1-based channels and clears ICA fields that no longer +describe the selected channel matrix. + +Derived caches such as ``icaact`` are not managed by a separate lazy-storage +layer, and EEGPrep does not provide multi-process write coordination for shared +``.fdt`` files. diff --git a/src/eegprep/functions/popfunc/pop_fileio.py b/src/eegprep/functions/popfunc/pop_fileio.py index 1d28c9d7..d088c8b9 100644 --- a/src/eegprep/functions/popfunc/pop_fileio.py +++ b/src/eegprep/functions/popfunc/pop_fileio.py @@ -11,9 +11,10 @@ import scipy.io from eegprep.functions.popfunc._file_io import mne_raw_to_eeg -from eegprep.functions.popfunc._pop_utils import format_history_value +from eegprep.functions.popfunc._pop_utils import format_history_value, parse_numeric_sequence from eegprep.functions.popfunc.pop_importdata import pop_importdata from eegprep.functions.popfunc.pop_loadset import _is_hdf5_file, pop_loadset +from eegprep.functions.popfunc.pop_select import pop_select logger = logging.getLogger(__name__) @@ -24,10 +25,14 @@ def pop_fileio( filename: str | Path, *, return_com: bool = False, **kwargs: Any ) -> dict[str, Any] | tuple[dict[str, Any], str]: - """Import a supported EEG data file with MNE/File-IO-style readers.""" + """Import an EEG file, optionally selecting 1-based channels, samples, or trials.""" path = Path(filename) suffix = path.suffix.lower() + history_options = dict(kwargs) blockrange = kwargs.pop("blockrange", None) + channels = kwargs.pop("channels", None) + samples = kwargs.pop("samples", None) + trials = kwargs.pop("trials", None) if suffix == ".set": eeg = pop_loadset(str(path)) elif suffix == ".mat" and kwargs.get("dataformat") != "matlab-array": @@ -45,7 +50,8 @@ def pop_fileio( if blockrange is not None: _crop_raw_to_blockrange(raw, blockrange) eeg = mne_raw_to_eeg(raw, setname=path.stem, filename=str(path)) - command = f"EEG = pop_fileio({format_history_value(path)});" + eeg = _select_imported_data(eeg, channels=channels, samples=samples, trials=trials) + command = _history_command(path, history_options) eeg["history"] = command return (eeg, command) if return_com else eeg @@ -91,3 +97,34 @@ def _crop_raw_to_blockrange(raw: mne.io.BaseRaw, blockrange: Any) -> None: if start >= recording_stop: raise ValueError("blockrange starts after the end of the recording") raw.crop(tmin=start, tmax=min(stop, recording_stop), include_tmax=False) + + +def _select_imported_data(EEG: dict[str, Any], *, channels: Any, samples: Any, trials: Any) -> dict[str, Any]: + options: dict[str, Any] = {} + if channels is not None: + selected = parse_numeric_sequence(channels, dtype=int) + if not selected or any(index < 1 for index in selected): + raise ValueError("channels must contain positive 1-based indices") + options["channel"] = [index - 1 for index in selected] + if samples is not None: + options["point"] = _inclusive_bounds(samples, "samples") + if trials is not None: + start, stop = _inclusive_bounds(trials, "trials") + options["trial"] = list(range(start, stop + 1)) + if not options: + return EEG + return pop_select(EEG, gui=False, **options) + + +def _inclusive_bounds(value: Any, name: str) -> list[int]: + bounds = parse_numeric_sequence(value, dtype=int) + if len(bounds) != 2 or bounds[0] < 1 or bounds[1] < bounds[0]: + raise ValueError(f"{name} must be a positive 1-based [start, stop] range") + return bounds + + +def _history_command(path: Path, options: dict[str, Any]) -> str: + pieces = [format_history_value(path)] + for key, value in options.items(): + pieces.extend([format_history_value(key), format_history_value(value, cell_for_sequence=None)]) + return f"EEG = pop_fileio({', '.join(pieces)});" diff --git a/src/eegprep/functions/popfunc/pop_loadset.py b/src/eegprep/functions/popfunc/pop_loadset.py index 7b5316dd..e434ef0b 100644 --- a/src/eegprep/functions/popfunc/pop_loadset.py +++ b/src/eegprep/functions/popfunc/pop_loadset.py @@ -9,7 +9,7 @@ from eegprep.functions.adminfunc.storage import memmap_enabled, memmap_fdt, read_fdt from eegprep.functions.popfunc._file_io import normalize_icachansind, ur_indices_to_zero_based -from eegprep.functions.popfunc._pop_utils import is_on, parse_key_value_args +from eegprep.functions.popfunc._pop_utils import is_on, parse_key_value_args, parse_numeric_sequence from eegprep.functions.popfunc.pop_loadset_h5 import pop_loadset_h5 # Allows access using . notation # class EEG: @@ -30,12 +30,15 @@ def loadset(file_path): def pop_loadset(file_path=None, *args, loadmode="all", memmap=None, **kwargs): - """Load EEGLAB dataset from .set or .mat file. + """Load an EEGLAB dataset, its metadata, or selected channels. Parameters ---------- file_path : str Path to the EEGLAB .set file. + loadmode : {"all", "info"} or int or sequence of int, optional + Load all data, metadata without sample data, or selected channels. + Channel numbers are 1-based, as in EEGLAB. Returns ------- @@ -45,8 +48,8 @@ def pop_loadset(file_path=None, *args, loadmode="all", memmap=None, **kwargs): from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset file_path, loadmode, use_memmap = _load_options(file_path, args, kwargs, loadmode, memmap) - if loadmode != "all": - raise NotImplementedError("pop_loadset currently supports loadmode='all' only; storedisk uses eeg_retrieve().") + info_only = isinstance(loadmode, str) and loadmode == "info" + channel_selection = [] if isinstance(loadmode, str) else loadmode def new_check(obj): # check if obj is a dictionary and apply recursively the function to each object not changing the struture of the dictionary @@ -108,9 +111,13 @@ def new_check(obj): if 'icachansind' in EEG: EEG['icachansind'] = normalize_icachansind(EEG['icachansind'], matlab_one_based=not loaded_with_h5) - if not loaded_with_h5: + if not loaded_with_h5 and not info_only: _load_sidecar_data(EEG, Path(file_path), use_memmap=use_memmap) + info_data = None + if info_only: + info_data = _string_value(EEG.get("datfile")) or "in set file" + # Convert 1-based MATLAB urchan/urevent to 0-based before eeg_checkset, which # copies event.urevent into epoch.eventurevent. scipy's squeeze_me returns a # bare dict for a 1x1 struct array, so wrap it in a list first. @@ -123,6 +130,10 @@ def new_check(obj): ur_indices_to_zero_based(EEG) EEG = eeg_checkset(EEG) + if info_data is not None: + EEG["data"] = info_data + if channel_selection: + _select_loaded_channels(EEG, channel_selection) EEG.pop("changes_not_saved", None) EEG["saved"] = "justloaded" @@ -147,7 +158,17 @@ def _load_options(file_path, args, kwargs, loadmode, memmap): options = parse_key_value_args(args, kwargs, lowercase_keys=True, lowercase_kwargs=True) filename = file_path filepath = options.pop("filepath", None) - loadmode = str(options.pop("loadmode", loadmode) or "all").lower() + loadmode = options.pop("loadmode", loadmode) + if loadmode is None or (isinstance(loadmode, str) and loadmode == ""): + loadmode = "all" + if isinstance(loadmode, str): + loadmode = loadmode.lower() + if loadmode not in {"all", "info"}: + raise ValueError("loadmode must be 'all', 'info', or 1-based channel indices") + else: + loadmode = parse_numeric_sequence(loadmode, dtype=int) + if not loadmode or any(index < 1 for index in loadmode): + raise ValueError("loadmode channel indices must be positive and 1-based") memmap = options.pop("memmap", memmap) options.pop("check", None) options.pop("verbose", None) @@ -165,6 +186,24 @@ def _load_options(file_path, args, kwargs, loadmode, memmap): return str(path), loadmode, use_memmap +def _select_loaded_channels(EEG, channel_selection) -> None: + indices = np.asarray(channel_selection, dtype=int) - 1 + nbchan = int(EEG.get("nbchan", np.asarray(EEG.get("data", [])).shape[0]) or 0) + if np.any(indices < 0) or np.any(indices >= nbchan): + raise ValueError("loadmode channel indices exceed the dataset channel count") + data = np.asarray(EEG.get("data")) + if data.ndim not in {2, 3}: + raise ValueError("channel loadmode requires numeric EEG data") + EEG["datachannel"] = channel_selection[0] if len(channel_selection) == 1 else list(channel_selection) + EEG["data"] = data[indices] + EEG["nbchan"] = len(indices) + chanlocs = EEG.get("chanlocs") + if chanlocs is not None and len(chanlocs): + EEG["chanlocs"] = [chanlocs[index] for index in indices] + for field in ("icachansind", "icaact", "icaweights", "icasphere", "icawinv"): + EEG[field] = np.array([]) + + def _load_sidecar_data(EEG, file_path: Path, *, use_memmap: bool) -> None: data_value = EEG.get("data") datfile = _string_value(EEG.get("datfile")) diff --git a/src/eegprep/functions/popfunc/pop_select.py b/src/eegprep/functions/popfunc/pop_select.py index 78aa8a78..7e6ed59e 100644 --- a/src/eegprep/functions/popfunc/pop_select.py +++ b/src/eegprep/functions/popfunc/pop_select.py @@ -6,6 +6,7 @@ import numpy as np from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset +from eegprep.functions.miscfunc.misc import finite_pinv 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 @@ -500,7 +501,7 @@ def _clip_time_matrix(mat): for ch in icachansind: if ch in chan_idx_list: newinds.append(chan_idx_list.index(ch)) - EEG['icachansind'] = newinds + EEG['icachansind'] = np.asarray(newinds, dtype=int) else: icasphere = EEG.get('icasphere') if _has_content(icasphere): @@ -517,7 +518,7 @@ def _clip_time_matrix(mat): EEG['icawinv'] = icawinv[np.array(icachans, dtype=int), :] # recompute weights/sphere as in MATLAB iw = EEG['icawinv'] - EEG['icaweights'] = np.linalg.pinv(iw) + EEG['icaweights'] = finite_pinv(iw) EEG['icasphere'] = np.eye(EEG['icaweights'].shape[1]) if _has_content(EEG.get('specicaact')): diff --git a/src/eegprep/functions/popfunc/pop_writeeeg.py b/src/eegprep/functions/popfunc/pop_writeeeg.py index 36134251..63b47d39 100644 --- a/src/eegprep/functions/popfunc/pop_writeeeg.py +++ b/src/eegprep/functions/popfunc/pop_writeeeg.py @@ -1,22 +1,75 @@ -"""Export EEG data to EDF/BDF/GDF through MNE.""" +"""Export EEG data to EDF or BDF.""" from __future__ import annotations from pathlib import Path from typing import Any -from mne.export import export_raw +import numpy as np +import pyedflib +from pyedflib import highlevel -from eegprep.functions.popfunc._file_io import eeg_to_mne_raw -from eegprep.functions.popfunc._pop_utils import format_history_value +from eegprep.functions.popfunc._file_io import channel_labels, events_to_records +from eegprep.functions.popfunc._pop_utils import format_history_value, parse_key_value_args def pop_writeeeg(EEG: dict[str, Any], filename: str | Path, *args: Any, **kwargs: Any) -> str: - """Write continuous EEG data to an EDF/BDF/GDF-compatible file.""" + """Write continuous EEG data to EDF or BDF.""" path = Path(filename) + options = parse_key_value_args(args, kwargs, lowercase_keys=True, lowercase_kwargs=True) + output_type = str(options.pop("type", path.suffix.lstrip("."))).lower() + if options: + raise ValueError(f"Unsupported pop_writeeeg option(s): {', '.join(sorted(options))}") if path.suffix.lower() not in {".edf", ".bdf", ".gdf"}: raise ValueError("pop_writeeeg output must end in .edf, .bdf, or .gdf") + if output_type != path.suffix.lstrip(".").lower(): + raise ValueError("TYPE must match the output filename extension") + if output_type == "gdf": + raise NotImplementedError("GDF writing is not supported by EEGPrep's installed Python writer") path.parent.mkdir(parents=True, exist_ok=True) - raw = eeg_to_mne_raw(EEG) - export_raw(str(path), raw, fmt=path.suffix.lower()[1:], overwrite=True) - return f"LASTCOM = pop_writeeeg(EEG, {format_history_value(path)});" + _write_edf_family(EEG, path, output_type) + pieces = [format_history_value(path)] + if "type" in {str(key).lower() for key in kwargs} or args: + pieces.extend(["'TYPE'", format_history_value(output_type.upper())]) + return f"LASTCOM = pop_writeeeg(EEG, {', '.join(pieces)});" + + +def _write_edf_family(EEG: dict[str, Any], path: Path, output_type: str) -> None: + data = np.ascontiguousarray(EEG["data"]) + if data.ndim != 2: + raise ValueError("pop_writeeeg requires continuous 2-D channel-by-sample data") + if not np.isfinite(data).all(): + raise ValueError("pop_writeeeg requires finite EEG data") + file_type = pyedflib.FILETYPE_BDFPLUS if output_type == "bdf" else pyedflib.FILETYPE_EDFPLUS + digital_min, digital_max = (-8_388_608, 8_388_607) if output_type == "bdf" else (-32_768, 32_767) + headers = [ + _signal_header(label, signal, float(EEG["srate"]), digital_min, digital_max) + for label, signal in zip(channel_labels(EEG), data) + ] + annotations = [ + [ + (float(event.get("latency", 1)) - 1) / float(EEG["srate"]), + float(event.get("duration", 0) or 0) / float(EEG["srate"]), + str(event.get("type", "event")), + ] + for event in events_to_records(EEG.get("event")) + ] + if not highlevel.write_edf(str(path), data, headers, header={"annotations": annotations}, file_type=file_type): + raise OSError(f"Could not write {output_type.upper()} file: {path}") + + +def _signal_header(label: str, signal: np.ndarray, srate: float, digital_min: int, digital_max: int) -> dict[str, Any]: + physical_min = float(np.floor(np.min(signal))) + physical_max = float(np.ceil(np.max(signal))) + if physical_min == physical_max: + physical_min -= 1 + physical_max += 1 + return highlevel.make_signal_header( + label, + dimension="uV", + sample_frequency=srate, + physical_min=physical_min, + physical_max=physical_max, + digital_min=digital_min, + digital_max=digital_max, + ) diff --git a/src/eegprep/resources/help/pop_fileio.md b/src/eegprep/resources/help/pop_fileio.md index 7780259b..227e0cb4 100644 --- a/src/eegprep/resources/help/pop_fileio.md +++ b/src/eegprep/resources/help/pop_fileio.md @@ -13,11 +13,18 @@ Usage: EEG = pop_fileio("recording.vhdr") EEG, com = pop_fileio("recording.edf", return_com=True) EEG = pop_fileio("recording.bdf", blockrange=[10, 20]) +EEG = pop_fileio("epochs.set", channels="1:16", trials=[2, 50]) ``` For MNE-backed continuous formats, `blockrange=[start, stop]` imports a half-open interval measured in seconds. +`channels` accepts 1-based channel numbers. `samples=[start, stop]` and +`trials=[start, stop]` are inclusive, 1-based ranges matching File-IO and +EEGLAB history commands. The resulting channel, point, and trial selection is +performed with `pop_select`, so dataset dimensions, events, and ICA metadata +are updated consistently. + Use the more specific File menu entries when you want format-specific file filters in the GUI. diff --git a/src/eegprep/resources/help/pop_loadset.md b/src/eegprep/resources/help/pop_loadset.md index 10520bd9..2c0807db 100644 --- a/src/eegprep/resources/help/pop_loadset.md +++ b/src/eegprep/resources/help/pop_loadset.md @@ -6,8 +6,15 @@ Usage: ```python EEG = pop_loadset("sample.set") +metadata = pop_loadset("sample.set", loadmode="info") +channel_10 = pop_loadset("sample.set", loadmode=10) ``` +`loadmode="info"` loads the dataset metadata while leaving `EEG["data"]` as +the stored data filename (or `"in set file"`). An integer or sequence loads +only those channels using EEGLAB's 1-based channel numbers and clears the ICA +fields, which no longer describe the selected channel matrix. + When `EEG_OPTIONS["option_memmapdata"] = 1`, two-file datasets saved with an `.fdt` sidecar load through a NumPy-compatible memory map. Single-file `.set` datasets still load into memory. diff --git a/src/eegprep/resources/help/pop_writeeeg.md b/src/eegprep/resources/help/pop_writeeeg.md index 2d6a2dae..d03ce5dd 100644 --- a/src/eegprep/resources/help/pop_writeeeg.md +++ b/src/eegprep/resources/help/pop_writeeeg.md @@ -1,4 +1,4 @@ -# POP_WRITEEG - Export EEG data to EDF/BDF/GDF +# POP_WRITEEG - Export EEG data to EDF/BDF `pop_writeeeg` writes the current dataset to an external EEG file format. @@ -6,10 +6,13 @@ Usage: ```python com = pop_writeeeg(EEG, "recording.edf") +com = pop_writeeeg(EEG, "recording.bdf", "TYPE", "BDF") ``` -The File > Export menu prompts for an EDF, BDF, or GDF output path and records -the command in session history. Use `pop_saveset` for EEGPrep/EEGLAB `.set` -files and `pop_exportbids` for BIDS folder output. +The File > Export menu prompts for an output path and records the command in +session history. EDF and BDF are written with their standard 16-bit and 24-bit +sample ranges. GDF writing is not yet available and raises a clear error. Use +`pop_saveset` for EEGPrep/EEGLAB `.set` files and `pop_exportbids` for BIDS +folder output. See also: POP_SAVESET, POP_EXPORTBIDS diff --git a/tests/test_file_menu_pop_functions.py b/tests/test_file_menu_pop_functions.py index 599e5c4b..b25b1305 100644 --- a/tests/test_file_menu_pop_functions.py +++ b/tests/test_file_menu_pop_functions.py @@ -360,18 +360,15 @@ def test_pop_writeeeg_escapes_history_path(monkeypatch, tmp_path): filename = tmp_path / "output's.edf" captured = {} - monkeypatch.setattr("eegprep.functions.popfunc.pop_writeeeg.eeg_to_mne_raw", lambda _eeg: object()) + def fake_write_edf_family(eeg, path, output_type): + captured.update({"eeg": eeg, "path": path, "output_type": output_type}) - def fake_export_raw(path, raw, *, fmt, overwrite): - captured.update({"path": path, "raw": raw, "fmt": fmt, "overwrite": overwrite}) - - monkeypatch.setattr("eegprep.functions.popfunc.pop_writeeeg.export_raw", fake_export_raw) + monkeypatch.setattr("eegprep.functions.popfunc.pop_writeeeg._write_edf_family", fake_write_edf_family) command = pop_writeeeg(_eeg(), filename) - assert captured["path"] == str(filename) - assert captured["fmt"] == "edf" - assert captured["overwrite"] is True + assert captured["path"] == filename + assert captured["output_type"] == "edf" assert _matlab_string(filename) in command diff --git a/tests/test_phase2_file_channel_location.py b/tests/test_phase2_file_channel_location.py index 168f24c9..aa4361ba 100644 --- a/tests/test_phase2_file_channel_location.py +++ b/tests/test_phase2_file_channel_location.py @@ -77,6 +77,8 @@ def test_readlocs_reads_packaged_mat_backed_montage() -> None: assert {"X", "Y", "Z", "theta", "radius", "sph_theta", "sph_phi"} <= set(locs[3]) +@eeglab_test("unittesting_popfunc/pop_readlocs/popfunc_pop_readlocs_wrapperTest.m", "test_test_pop_readlocs") +@eeglab_test("unittesting_popfunc/pop_writelocs/popfunc_pop_writelocs_wrapperTest.m", "test_test_pop_writelocs") def test_readlocs_and_writelocs_round_trip_locs_and_ced(tmp_path: Path) -> None: locs = [ {"labels": "Fz", "theta": 0.0, "radius": 0.25}, diff --git a/tests/test_sample_data_pop_functions.py b/tests/test_sample_data_pop_functions.py index 056b8b30..43dada41 100644 --- a/tests/test_sample_data_pop_functions.py +++ b/tests/test_sample_data_pop_functions.py @@ -14,6 +14,7 @@ from eegprep.functions.adminfunc.eeg_store import eeg_store from eegprep.functions.adminfunc.pop_delset import pop_delset from eegprep.functions.adminfunc.pop_editoptions import pop_editoptions +from eegprep.functions.miscfunc.misc import finite_matmul from eegprep.functions.popfunc.eeg_emptyset import eeg_emptyset from eegprep.functions.popfunc.eeg_lat2point import eeg_lat2point from eegprep.functions.popfunc.eeg_runica import eeg_runica @@ -104,6 +105,28 @@ def test_pop_loadset_loads_eeglab_sample_data_with_core_fields(sample_eeg_base): assert np.issubdtype(np.asarray(sample_eeg_base["icachansind"]).dtype, np.integer) +@eeglab_test("unittesting_popfunc/pop_loadset/popfunc_pop_loadset_wrapperTest.m", "test_test_pop_loadset") +def test_pop_loadset_current_suite_info_channel_and_eeg_modes(): + path = Path("sample_data/eeglab_data_epochs_ica.set") + full = pop_loadset("filename", path.name, "filepath", path.parent) + info = pop_loadset("filename", path.name, "filepath", path.parent, "loadmode", "info") + channel = pop_loadset("filename", path.name, "filepath", path.parent, "loadmode", 10) + from_eeg = pop_loadset("filename", "", "filepath", "", "eeg", full) + + assert full["data"].shape == (32, 384, 80) + assert info["data"] == "eeglab_data_epochs_ica.fdt" + assert info["nbchan"] == full["nbchan"] + assert info["pnts"] == full["pnts"] + assert info["trials"] == full["trials"] + assert channel["data"].shape == (1, 384, 80) + np.testing.assert_array_equal(channel["data"], full["data"][9:10]) + assert channel["datachannel"] == 10 + assert channel["chanlocs"][0]["labels"] == full["chanlocs"][9]["labels"] + for field in ("icachansind", "icaact", "icaweights", "icasphere", "icawinv"): + assert np.asarray(channel[field]).size == 0 + np.testing.assert_array_equal(from_eeg["data"], full["data"]) + + def test_pop_fileio_loads_sample_set_and_records_replayable_history(sample_eeg_base): eeg, command = pop_fileio(SAMPLE_SET, return_com=True) @@ -113,6 +136,23 @@ def test_pop_fileio_loads_sample_set_and_records_replayable_history(sample_eeg_b assert command == "EEG = pop_fileio('sample_data/eeglab_data.set');" +@eeglab_test("unittesting_popfunc/pop_fileio/popfunc_pop_fileio_wrapperTest.m", "test_test_pop_fileio") +def test_pop_fileio_current_suite_channel_sample_and_trial_ranges(): + path = Path("sample_data/eeglab_data_epochs_ica.set") + full = pop_fileio(path) + all_selected = pop_fileio(path, channels="1:32", samples=[1, 384]) + selected, command = pop_fileio(path, channels="1:16", trials=[2, 50], return_com=True) + + np.testing.assert_array_equal(all_selected["data"], full["data"]) + np.testing.assert_array_equal(selected["data"], full["data"][:16, :, 1:50]) + assert selected["data"].shape == (16, 384, 49) + assert selected["nbchan"] == 16 + assert selected["trials"] == 49 + assert selected["history"] == command + assert "'channels', '1:16'" in command + assert "'trials', [2 50]" in command + + def test_pop_biosig_rejects_sample_set_because_it_is_not_a_biosig_file(): with pytest.raises(ValueError, match="BIOSIG|EDF|BDF|GDF|Unsupported"): pop_biosig(SAMPLE_SET, return_com=True) @@ -430,19 +470,29 @@ def test_pop_icflag_flags_sample_components_with_iclabel_probabilities(sample_ee assert "pop_icflag" in command -def test_pop_expica_exports_sample_ica_weights(sample_eeg_with_ica, tmp_path): +@eeglab_test("unittesting_popfunc/pop_expica/popfunc_pop_expica_wrapperTest.m", "test_pass_inv_file") +@eeglab_test("unittesting_popfunc/pop_expica/popfunc_pop_expica_wrapperTest.m", "test_pass_weights_file") +def test_pop_expica_exports_sample_ica_matrices(sample_eeg_with_ica, tmp_path): weights_file = tmp_path / "sample_weights.tsv" + inverse_file = tmp_path / "sample_inverse.tsv" with warnings.catch_warnings(): warnings.simplefilter("error", RuntimeWarning) - command = pop_expica(sample_eeg_with_ica, weights_file) - - exported = np.loadtxt(weights_file, delimiter="\t") - assert exported.shape == (32, 32) - assert np.isfinite(exported).all() - assert "pop_expica" in command + weights_command = pop_expica(sample_eeg_with_ica, weights_file) + inverse_command = pop_expica(sample_eeg_with_ica, inverse_file, "inv") + + exported_weights = np.loadtxt(weights_file, delimiter="\t") + exported_inverse = np.loadtxt(inverse_file, delimiter="\t") + np.testing.assert_allclose( + exported_weights, + finite_matmul(sample_eeg_with_ica["icaweights"], sample_eeg_with_ica["icasphere"]), + ) + np.testing.assert_allclose(exported_inverse, sample_eeg_with_ica["icawinv"]) + assert "pop_expica" in weights_command + assert "'inv'" in inverse_command +@eeglab_test("unittesting_popfunc/pop_export/popfunc_pop_export_wrapperTest.m", "test_test_pop_export") def test_pop_export_writes_sample_data_table(tmp_path, sample_eeg): output_file = tmp_path / "sample_export.tsv" @@ -504,6 +554,7 @@ def test_pop_expevents_writes_all_sample_events(tmp_path, sample_eeg): assert "pop_expevents" in command +@eeglab_test("unittesting_popfunc/pop_importdata/popfunc_pop_importdata_wrapperTest.m", "test_test_pop_importdata") def test_pop_importdata_loads_exported_sample_slice(tmp_path, sample_eeg): data_file = tmp_path / "sample_slice.tsv" np.savetxt(data_file, sample_eeg["data"][:2, :12], delimiter="\t") @@ -568,16 +619,41 @@ def test_pop_importepoch_updates_sample_epoch_metadata(tmp_path, sample_eeg): assert "pop_importepoch" in command -def test_pop_saveset_roundtrips_sample_dataset(tmp_path, sample_eeg): - output_file = tmp_path / "roundtrip.set" - - pop_saveset(sample_eeg, str(output_file)) - loaded = pop_loadset(str(output_file)) - - assert loaded["data"].shape == sample_eeg["data"].shape - assert loaded["setname"] == sample_eeg["setname"] - assert len(loaded["event"]) == len(sample_eeg["event"]) - assert np.issubdtype(np.asarray(loaded["icachansind"]).dtype, np.integer) +@eeglab_test("unittesting_popfunc/pop_saveset/popfunc_pop_saveset_wrapperTest.m", "test_test_pop_saveset") +def test_pop_saveset_current_suite_onefile_and_twofiles_roundtrip(tmp_path): + source = pop_loadset("sample_data/eeglab_data_epochs_ica.set") + + for check in ("off", "on"): + saved = pop_saveset( + copy.deepcopy(source), + "filename", + f"onefile_{check}.set", + "filepath", + tmp_path, + "check", + check, + "savemode", + "onefile", + ) + loaded = pop_loadset(saved["filename"], "filepath", saved["filepath"]) + np.testing.assert_array_equal(loaded["data"], source["data"]) + assert np.asarray(loaded["datfile"]).size == 0 + + saved = pop_saveset( + copy.deepcopy(source), + "filename", + "twofiles.set", + "filepath", + tmp_path, + "check", + "off", + "savemode", + "twofiles", + ) + loaded = pop_loadset(saved["filename"], "filepath", saved["filepath"]) + np.testing.assert_array_equal(loaded["data"], source["data"]) + assert loaded["datfile"] == "twofiles.fdt" + assert (tmp_path / "twofiles.fdt").exists() def test_pop_study_records_sample_dataset_info(sample_eeg): @@ -637,6 +713,7 @@ def test_pop_studywizard_builds_study_from_saved_sample_set(tmp_path, sample_eeg assert command.startswith("STUDY, ALLEEG = pop_studywizard(") +@eeglab_test("unittesting_popfunc/pop_saveh/popfunc_pop_saveh_wrapperTest.m", "test_test_pop_saveh") def test_pop_saveh_writes_sample_history_commands(tmp_path): command = pop_saveh( ["EEG = pop_fileio('sample_data/eeglab_data.set');", "EEG = pop_reref( EEG, []);"], @@ -651,6 +728,7 @@ def test_pop_saveh_writes_sample_history_commands(tmp_path): assert "pop_saveh" in command +@eeglab_test("unittesting_popfunc/pop_runscript/popfunc_pop_runscript_wrapperTest.m", "test_test_pop_runscript") def test_pop_runscript_can_modify_sample_workspace_namespace(sample_eeg, tmp_path): script_file = tmp_path / "rename_sample.py" namespace = {"EEG": sample_eeg} @@ -662,23 +740,32 @@ def test_pop_runscript_can_modify_sample_workspace_namespace(sample_eeg, tmp_pat assert "pop_runscript" in command -def test_pop_writeeeg_exports_sample_through_mne_raw(monkeypatch, tmp_path, sample_eeg): - captured = {} - - def fake_export_raw(path, raw, *, fmt, overwrite): - captured.update({"path": path, "nchan": raw.info["nchan"], "fmt": fmt, "overwrite": overwrite}) - - monkeypatch.setattr("eegprep.functions.popfunc.pop_writeeeg.export_raw", fake_export_raw) - - command = pop_writeeeg(sample_eeg, tmp_path / "sample.edf") - - assert captured == { - "path": str(tmp_path / "sample.edf"), - "nchan": 32, - "fmt": "edf", - "overwrite": True, - } - assert "pop_writeeeg" in command +@eeglab_test("unittesting_popfunc/pop_writeeeg/popfunc_pop_writeeeg_wrapperTest.m", "test_test_pop_writeeeg") +def test_pop_writeeeg_current_suite_edf_and_bdf_roundtrip(tmp_path, sample_eeg): + eeg = copy.deepcopy(sample_eeg) + eeg["data"] = eeg["data"][:2, :256] + eeg["nbchan"] = 2 + eeg["pnts"] = 256 + eeg["xmax"] = (eeg["pnts"] - 1) / eeg["srate"] + eeg["times"] = np.arange(eeg["pnts"]) / eeg["srate"] * 1000 + eeg["chanlocs"] = eeg["chanlocs"][:2] + eeg["event"] = [] + + for suffix in ("edf", "bdf"): + output = tmp_path / f"sample.{suffix}" + command = pop_writeeeg(eeg, output, "TYPE", suffix.upper()) + imported = pop_fileio(output) + stored_ranges = np.ceil(np.max(eeg["data"], axis=1)) - np.floor(np.min(eeg["data"], axis=1)) + quantization = np.max(stored_ranges) / (65_535 if suffix == "edf" else 16_777_215) + + assert output.exists() + assert imported["data"].shape == eeg["data"].shape + assert imported["srate"] == eeg["srate"] + np.testing.assert_allclose(imported["data"], eeg["data"], rtol=0, atol=quantization * 1.1) + assert f"'TYPE', '{suffix.upper()}'" in command + + with pytest.raises(NotImplementedError, match="GDF writing"): + pop_writeeeg(eeg, tmp_path / "sample.gdf", "TYPE", "GDF") def test_pop_exportbids_writes_valid_bids_dataset_from_sample(tmp_path, sample_eeg): From bcc537a093a76364406434c0b133d15e4c665255 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:48 -0700 Subject: [PATCH 14/45] Port current core event helper tests (#342) --- docs/source/api/core_and_session.rst | 6 + docs/source/api/data_loading_and_saving.rst | 5 + docs/source/api/epoching_and_selection.rst | 5 +- .../api/preprocessing_and_artifacts.rst | 2 + docs/source/changelog.rst | 6 + src/eegprep/__init__.py | 14 + .../functions/popfunc/eeg_addnewevents.py | 89 +++ .../functions/popfunc/eeg_amplitudearea.py | 75 +++ src/eegprep/functions/popfunc/eeg_chaninds.py | 20 + src/eegprep/functions/popfunc/eeg_context.py | 229 ++++++++ .../functions/popfunc/eeg_eventhist.py | 64 +++ .../functions/popfunc/eeg_eventtypes.py | 33 ++ .../functions/popfunc/eeg_getepochevent.py | 195 +++++++ .../functions/popfunc/eeg_insertbound.py | 93 ++++ .../functions/popfunc/eeg_matchchans.py | 64 +++ .../functions/popfunc/eeg_mergechan.py | 39 ++ .../functions/popfunc/eeg_mergelocs.py | 39 ++ .../functions/popfunc/eeg_timeinterp.py | 91 +++ .../functions/popfunc/eeg_urlatency.py | 33 ++ src/eegprep/functions/popfunc/getchanlist.py | 24 + tests/test_core_event_eeglab_tests.py | 517 ++++++++++++++++++ tests/test_sample_data_pop_functions.py | 2 + 22 files changed, 1644 insertions(+), 1 deletion(-) create mode 100644 src/eegprep/functions/popfunc/eeg_addnewevents.py create mode 100644 src/eegprep/functions/popfunc/eeg_amplitudearea.py create mode 100644 src/eegprep/functions/popfunc/eeg_chaninds.py create mode 100644 src/eegprep/functions/popfunc/eeg_context.py create mode 100644 src/eegprep/functions/popfunc/eeg_eventhist.py create mode 100644 src/eegprep/functions/popfunc/eeg_eventtypes.py create mode 100644 src/eegprep/functions/popfunc/eeg_getepochevent.py create mode 100644 src/eegprep/functions/popfunc/eeg_insertbound.py create mode 100644 src/eegprep/functions/popfunc/eeg_matchchans.py create mode 100644 src/eegprep/functions/popfunc/eeg_mergechan.py create mode 100644 src/eegprep/functions/popfunc/eeg_mergelocs.py create mode 100644 src/eegprep/functions/popfunc/eeg_timeinterp.py create mode 100644 src/eegprep/functions/popfunc/eeg_urlatency.py create mode 100644 src/eegprep/functions/popfunc/getchanlist.py create mode 100644 tests/test_core_event_eeglab_tests.py diff --git a/docs/source/api/core_and_session.rst b/docs/source/api/core_and_session.rst index 2618935f..4ad317fd 100644 --- a/docs/source/api/core_and_session.rst +++ b/docs/source/api/core_and_session.rst @@ -20,7 +20,13 @@ Dataset containers, validation, and the shared session that the GUI and ``eegpre eegprep.eeg_checkset eegprep.eeg_checkset_strict_mode eegprep.eeg_compare + eegprep.eeg_context eegprep.eeg_emptyset + eegprep.eeg_eventhist + eegprep.eeg_eventtypes + eegprep.eeg_getepochevent + eegprep.eeg_insertbound + eegprep.eeg_urlatency eegprep.eeg_retrieve eegprep.eeg_store eegprep.eegh diff --git a/docs/source/api/data_loading_and_saving.rst b/docs/source/api/data_loading_and_saving.rst index 29c291cd..61b9b065 100644 --- a/docs/source/api/data_loading_and_saving.rst +++ b/docs/source/api/data_loading_and_saving.rst @@ -14,13 +14,18 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.coregister eegprep.decodechan eegprep.eeg2mne + eegprep.eeg_chaninds eegprep.eeg_decodechan eegprep.eeg_eeg2mne eegprep.eeg_mne2eeg eegprep.eeg_mne2eeg_epochs + eegprep.eeg_matchchans + eegprep.eeg_mergechan + eegprep.eeg_mergelocs eegprep.floatread eegprep.floatwrite eegprep.loadset + eegprep.getchanlist eegprep.mne2eeg eegprep.mne2eeg_epochs eegprep.readeetraklocs diff --git a/docs/source/api/epoching_and_selection.rst b/docs/source/api/epoching_and_selection.rst index 7a8bcec6..56363b79 100644 --- a/docs/source/api/epoching_and_selection.rst +++ b/docs/source/api/epoching_and_selection.rst @@ -8,12 +8,15 @@ Extracting epochs, selecting data, and converting between latencies and sample p .. autosummary:: :toctree: generated/ + eegprep.eeg_addnewevents eegprep.eeg_findboundaries + eegprep.eeg_getepochevent + eegprep.eeg_insertbound eegprep.eeg_lat2point eegprep.eeg_point2lat + eegprep.eeg_urlatency eegprep.epoch eegprep.findboundaries eegprep.lat2point eegprep.point2lat eegprep.select - diff --git a/docs/source/api/preprocessing_and_artifacts.rst b/docs/source/api/preprocessing_and_artifacts.rst index 603435db..387282cb 100644 --- a/docs/source/api/preprocessing_and_artifacts.rst +++ b/docs/source/api/preprocessing_and_artifacts.rst @@ -19,9 +19,11 @@ Artifact detection and repair, referencing, resampling, interpolation, and rejec eegprep.clean_windows eegprep.correct_mc eegprep.correctfit + eegprep.eeg_amplitudearea eegprep.eeg_eegrej eegprep.eeg_interp eegprep.eeg_rejsuperpose + eegprep.eeg_timeinterp eegprep.eeglab_clean_artifacts eegprep.eegrej eegprep.eegthresh diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 44b6a960..13add2f2 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,12 @@ the `GitHub Releases `_ page. Unreleased ========== +- Core event and channel helpers now cover EEGLAB-compatible event insertion, + event histograms and type counts, per-epoch field extraction, boundary-aware + original latencies and context queries, channel-type lookup and montage + matching/merging, ERP-window amplitude, and bad-time interpolation. These + helpers are available directly from ``eegprep`` and run without an EEGLAB + checkout. - ``pop_loadset`` now supports EEGLAB's metadata-only and 1-based channel loading modes, while ``pop_fileio`` applies 1-based channel and inclusive sample/trial selections with consistent dataset bookkeeping. ``pop_writeeeg`` diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index e92366f4..396d5426 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -78,24 +78,35 @@ "decodechan": ("eegprep.functions.redefine_functions", "decodechan"), "discover_extensions": ("eegprep.extensions", "discover_extensions"), "eeg2mne": ("eegprep.functions.redefine_functions", "eeg2mne"), + "eeg_addnewevents": ("eegprep.functions.popfunc.eeg_addnewevents", "eeg_addnewevents"), "eeg_amica": ("eegprep.functions.popfunc.eeg_amica", "eeg_amica"), + "eeg_amplitudearea": ("eegprep.functions.popfunc.eeg_amplitudearea", "eeg_amplitudearea"), "eeg_autocorr": ("eegprep.plugins.ICLabel.eeg_autocorr", "eeg_autocorr"), "eeg_autocorr_fftw": ("eegprep.plugins.ICLabel.eeg_autocorr_fftw", "eeg_autocorr_fftw"), "eeg_autocorr_welch": ("eegprep.plugins.ICLabel.eeg_autocorr_welch", "eeg_autocorr_welch"), "eeg_checkset": ("eegprep.functions.adminfunc.eeg_checkset", "eeg_checkset"), "eeg_checkset_strict_mode": ("eegprep.functions.adminfunc.eeg_checkset", "strict_mode"), + "eeg_chaninds": ("eegprep.functions.popfunc.eeg_chaninds", "eeg_chaninds"), "eeg_compare": ("eegprep.functions.popfunc.eeg_compare", "eeg_compare"), + "eeg_context": ("eegprep.functions.popfunc.eeg_context", "eeg_context"), "eeg_decodechan": ("eegprep.functions.popfunc.eeg_decodechan", "eeg_decodechan"), "eeg_eeg2mne": ("eegprep.functions.miscfunc.eeg_eeg2mne", "eeg_eeg2mne"), "eeg_eegrej": ("eegprep.functions.popfunc.eeg_eegrej", "eeg_eegrej"), "eeg_icalabelstat": ("eegprep.plugins.ICLabel.eeg_icalabelstat", "eeg_icalabelstat"), "eeg_emptyset": ("eegprep.functions.popfunc.eeg_emptyset", "eeg_emptyset"), + "eeg_eventhist": ("eegprep.functions.popfunc.eeg_eventhist", "eeg_eventhist"), + "eeg_eventtypes": ("eegprep.functions.popfunc.eeg_eventtypes", "eeg_eventtypes"), "eeg_multieegplot": ("eegprep.functions.popfunc.eeg_multieegplot", "eeg_multieegplot"), "eegplot": ("eegprep.functions.sigprocfunc.eegplot", "eegplot"), "eeg_findboundaries": ("eegprep.functions.popfunc.eeg_findboundaries", "eeg_findboundaries"), + "eeg_getepochevent": ("eegprep.functions.popfunc.eeg_getepochevent", "eeg_getepochevent"), "eeg_getica": ("eegprep.functions.sigprocfunc.ica_helpers", "eeg_getica"), "eeg_interp": ("eegprep.functions.popfunc.eeg_interp", "eeg_interp"), + "eeg_insertbound": ("eegprep.functions.popfunc.eeg_insertbound", "eeg_insertbound"), "eeg_lat2point": ("eegprep.functions.popfunc.eeg_lat2point", "eeg_lat2point"), + "eeg_matchchans": ("eegprep.functions.popfunc.eeg_matchchans", "eeg_matchchans"), + "eeg_mergechan": ("eegprep.functions.popfunc.eeg_mergechan", "eeg_mergechan"), + "eeg_mergelocs": ("eegprep.functions.popfunc.eeg_mergelocs", "eeg_mergelocs"), "eeg_mne2eeg": ("eegprep.functions.miscfunc.eeg_mne2eeg", "eeg_mne2eeg"), "eeg_mne2eeg_epochs": ("eegprep.functions.miscfunc.eeg_mne2eeg_epochs", "eeg_mne2eeg_epochs"), "eeg_picard": ("eegprep.functions.popfunc.eeg_picard", "eeg_picard"), @@ -108,6 +119,8 @@ "eeg_rpsd": ("eegprep.plugins.ICLabel.eeg_rpsd", "eeg_rpsd"), "eeg_runica": ("eegprep.functions.popfunc.eeg_runica", "eeg_runica"), "eeg_store": ("eegprep.functions.adminfunc.eeg_store", "eeg_store"), + "eeg_timeinterp": ("eegprep.functions.popfunc.eeg_timeinterp", "eeg_timeinterp"), + "eeg_urlatency": ("eegprep.functions.popfunc.eeg_urlatency", "eeg_urlatency"), "eegh": ("eegprep.functions.adminfunc.eegh", "eegh"), "eeglab": ("eegprep.functions.adminfunc.eeglab", "eeglab"), "errordlg2": ("eegprep.functions.guifunc.errordlg2", "errordlg2"), @@ -131,6 +144,7 @@ "finputcheck": ("eegprep.functions.guifunc.finputcheck", "finputcheck"), "format_plugin_menu": ("eegprep.functions.adminfunc.plugin_menu", "format_plugin_menu"), "gui": ("eegprep.functions.adminfunc.eeglab", "gui"), + "getchanlist": ("eegprep.functions.popfunc.getchanlist", "getchanlist"), "gethelpvar": ("eegprep.functions.adminfunc.gethelpvar", "gethelpvar"), "getkeyval": ("eegprep.functions.adminfunc.getkeyval", "getkeyval"), "icaact": ("eegprep.functions.sigprocfunc.ica_helpers", "icaact"), diff --git a/src/eegprep/functions/popfunc/eeg_addnewevents.py b/src/eegprep/functions/popfunc/eeg_addnewevents.py new file mode 100644 index 00000000..647524c1 --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_addnewevents.py @@ -0,0 +1,89 @@ +"""Add events while preserving event/urevent consistency.""" + +from __future__ import annotations + +import copy +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._event_utils import events_as_list +from eegprep.functions.popfunc.eeg_urlatency import eeg_urlatency + + +def eeg_addnewevents( + EEG: dict[str, Any], + event_latency_arrays: Any, + types: Any, + field_names: Any | None = None, + field_values: Any | None = None, +) -> dict[str, Any]: + """Add typed events and rebuild sorted, consistent event structures. + + Each latency array corresponds to one event type. Optional field-value + arrays span all newly added events in the same concatenated order. + """ + output = copy.deepcopy(EEG) + latency_groups = [np.asarray(group, dtype=float).ravel().tolist() for group in event_latency_arrays] + type_values = [types] if isinstance(types, str) else list(types) + if len(latency_groups) != len(type_values): + raise ValueError("one event type is required for each latency array") + fields = [] if field_names is None else ([field_names] if isinstance(field_names, str) else list(field_names)) + values = [] if field_values is None else list(field_values) + total = sum(len(group) for group in latency_groups) + if len(fields) != len(values): + raise ValueError("field_names and field_values must have the same length") + flat_values = [np.asarray(value, dtype=object).ravel().tolist() for value in values] + if any(len(value) != total for value in flat_values): + raise ValueError("each field-values array must contain one value per new event") + + original_events = events_as_list(output.get("event", [])) + all_fields = set(fields) + for event in original_events: + all_fields.update(event) + new_events: list[dict[str, Any]] = [] + value_index = 0 + for event_type, latencies in zip(type_values, latency_groups): + for latency in latencies: + event = {field: np.nan for field in all_fields} + event.update({"latency": float(latency), "type": event_type, "duration": 0.0, "urevent": np.nan}) + for field_index, field in enumerate(fields): + event[field] = flat_values[field_index][value_index] + new_events.append(event) + value_index += 1 + for event in original_events: + event.setdefault("duration", 0.0) + for field in fields: + event.setdefault(field, np.nan) + combined = sorted([*original_events, *new_events], key=lambda event: float(event["latency"])) + old_urevents = events_as_list(output.get("urevent", [])) + event_urevents: list[tuple[float, dict[str, Any], dict[str, Any]]] = [] + for event in combined: + pointer = event.get("urevent") + if _valid_pointer(pointer, len(old_urevents)): + original_latency = float(old_urevents[int(pointer)]["latency"]) + else: + original_latency = float(eeg_urlatency(combined, event["latency"])) + urevent = {key: copy.deepcopy(value) for key, value in event.items() if key != "urevent"} + urevent["latency"] = original_latency + if urevent.get("duration") is None or np.asarray(urevent.get("duration")).size == 0: + urevent["duration"] = 0.0 + event_urevents.append((original_latency, event, urevent)) + urevents: list[dict[str, Any]] = [] + for pointer, (_latency, event, urevent) in enumerate(sorted(event_urevents, key=lambda item: item[0])): + event["urevent"] = pointer + urevents.append(urevent) + output["event"] = combined + output["urevent"] = urevents + return output + + +def _valid_pointer(value: Any, length: int) -> bool: + try: + pointer = float(value) + except (TypeError, ValueError): + return False + return np.isfinite(pointer) and pointer.is_integer() and 0 <= pointer < length + + +__all__ = ["eeg_addnewevents"] diff --git a/src/eegprep/functions/popfunc/eeg_amplitudearea.py b/src/eegprep/functions/popfunc/eeg_amplitudearea.py new file mode 100644 index 00000000..d9f27ab4 --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_amplitudearea.py @@ -0,0 +1,75 @@ +"""Mean ERP amplitude over a time window.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +from scipy.interpolate import CubicSpline + + +def eeg_amplitudearea( + EEG: dict[str, Any], + channels: Any, + resrate: float, + wstart: float, + wend: float, +) -> tuple[np.ndarray, np.ndarray]: + """Return signed mean ERP amplitude within a millisecond window. + + Channel identifiers are 0-based so the result of :func:`eeg_chaninds` can + be passed directly. ``resrate`` retains the original helper's + samples-per-millisecond interpretation. + """ + if wstart > wend: + raise ValueError("wstart must not be greater than wend") + if wstart == wend: + raise ValueError("the integration window must have nonzero duration") + if resrate <= 0: + raise ValueError("resrate must be positive") + data = np.asarray(EEG["data"], dtype=float) + if data.ndim != 3: + raise ValueError("EEG.data must be channel x sample x epoch") + times = np.asarray(EEG.get("times", []), dtype=float) + if times.size != data.shape[1]: + times = np.linspace(float(EEG["xmin"]) * 1000, float(EEG["xmax"]) * 1000, data.shape[1]) + if wstart < times[0] or wend > times[-1]: + raise ValueError("integration window must lie within EEG.times") + channel_array = np.asarray(channels, dtype=int).ravel() + if np.any(channel_array < 0) or np.any(channel_array >= data.shape[0]): + raise ValueError("channels must be 0-based and within EEG.nbchan") + + start_index = int(np.argmin(np.abs(times - wstart))) + end_index = int(np.argmin(np.abs(times - wend))) + if times[start_index] > wstart: + start_index -= 1 + if times[end_index] < wend: + end_index += 1 + start_index = max(start_index, 0) + end_index = min(end_index, len(times) - 1) + support_times = times[start_index : end_index + 1] + if support_times.size < 2: + raise ValueError("the integration window needs at least two supporting samples") + + step = 1.0 / float(resrate) + resampled_times = [float(wstart)] + while resampled_times[-1] < wend: + resampled_times.append(resampled_times[-1] + step) + grid = np.asarray(resampled_times) + erp = np.mean(data, axis=2) + amplitudes = np.empty(len(channel_array), dtype=float) + for output_index, channel in enumerate(channel_array): + samples = CubicSpline(support_times, erp[channel, start_index : end_index + 1])(grid) + area = 0.0 + for index in range(len(grid) - 1): + width = min(grid[index + 1], wend) - grid[index] + if width <= 0: + break + fraction = width / (grid[index + 1] - grid[index]) + endpoint = samples[index] + fraction * (samples[index + 1] - samples[index]) + area += width * (samples[index] + endpoint) / 2 + amplitudes[output_index] = area / (wend - wstart) + return channel_array, amplitudes + + +__all__ = ["eeg_amplitudearea"] diff --git a/src/eegprep/functions/popfunc/eeg_chaninds.py b/src/eegprep/functions/popfunc/eeg_chaninds.py new file mode 100644 index 00000000..5cbe49ee --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_chaninds.py @@ -0,0 +1,20 @@ +"""Legacy channel-name lookup compatibility.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.popfunc.eeg_decodechan import eeg_decodechan + + +def eeg_chaninds(EEG: Any, channel_names: Any, errorifnotfound: bool = True) -> list[int]: + """Return 0-based indices for channel labels. + + This is EEGPrep's compatibility alias for :func:`eeg_decodechan`; Python + indices remain 0-based throughout the channel-selection API. + """ + indices, _ = eeg_decodechan(EEG, channel_names, ignoremissing=not errorifnotfound) + return indices + + +__all__ = ["eeg_chaninds"] diff --git a/src/eegprep/functions/popfunc/eeg_context.py b/src/eegprep/functions/popfunc/eeg_context.py new file mode 100644 index 00000000..80374811 --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_context.py @@ -0,0 +1,229 @@ +"""Query neighboring urevents around selected target events.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._event_utils import events_as_list, is_boundary_event + + +def eeg_context( + EEG: dict[str, Any], + targets: Any | None = None, + neighbors: Any | None = None, + positions: Any | None = None, + field: str | list[str] | None = None, + alltargs: str | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, Any, Any]: + """Return target-event context from the original urevent sequence. + + Event identifiers in the returned matrices are 1-based. Neighbor searches + stop at boundary events and delays are returned in milliseconds. + """ + events = events_as_list(EEG.get("event", [])) + urevents = events_as_list(EEG.get("urevent", [])) + if not events or not urevents: + raise ValueError("EEG.event and EEG.urevent are required") + if any("urevent" not in event for event in events if not is_boundary_event(event)): + raise ValueError("every EEG.event must point to an urevent") + all_targets = alltargs.lower() if isinstance(alltargs, str) else alltargs + if all_targets not in (None, "", "all"): + raise ValueError("alltargs must be 'all' or empty") + + target_types = _type_list(targets) + neighbor_types = _type_list(neighbors) + requested_positions = np.sort(np.asarray([1] if _empty(positions) else positions, dtype=int).ravel()) + field_names = [] if field is None else ([field] if isinstance(field, str) else list(field)) + for name in field_names: + if not any(name in event for event in urevents): + raise ValueError(f"specified field {name!r} not found in urevent") + continuous = int(EEG.get("trials", 1)) == 1 or not any("epoch" in event for event in events) + return_all = continuous or all_targets == "all" + + target_rows: list[list[float]] = [] + neighbor_rows: list[list[float]] = [] + neighbor_type_rows: list[list[float]] = [] + delay_rows: list[list[float]] = [] + target_fields: list[list[Any]] = [] + neighbor_fields: list[list[list[Any]]] = [] + + for event_index, event in enumerate(events): + if is_boundary_event(event): + continue + urevent_index = int(event["urevent"]) + if urevent_index < 0 or urevent_index >= len(urevents): + raise ValueError("event urevent pointer is outside EEG.urevent") + target_type = _type_text(urevents[urevent_index].get("type")) + target_type_index = _matching_type_index(target_type, target_types) + if target_type_index is None: + continue + centered_epoch = _centered_epoch(EEG, event_index, event) + if not return_all and not np.isfinite(centered_epoch): + continue + target_rows.append([event_index + 1, urevent_index + 1, centered_epoch, target_type_index]) + row_neighbors: list[float] = [] + row_neighbor_types: list[float] = [] + row_delays: list[float] = [] + row_fields: list[list[Any]] = [] + for position in requested_positions: + match = _neighbor_at(urevents, urevent_index, int(position), neighbor_types) + if match is None: + row_neighbors.append(np.nan) + row_neighbor_types.append(np.nan) + row_delays.append(np.nan) + row_fields.append([[] for _ in field_names]) + continue + neighbor_index, neighbor_type_index = match + row_neighbors.append(float(neighbor_index + 1)) + row_neighbor_types.append(float(neighbor_type_index)) + row_delays.append( + 1000 + / float(EEG["srate"]) + * (float(urevents[neighbor_index]["latency"]) - float(urevents[urevent_index]["latency"])) + ) + row_fields.append([urevents[neighbor_index].get(name) for name in field_names]) + neighbor_rows.append(row_neighbors) + neighbor_type_rows.append(row_neighbor_types) + delay_rows.append(row_delays) + target_fields.append([urevents[urevent_index].get(name) for name in field_names]) + neighbor_fields.append(row_fields) + + target_array = np.asarray(target_rows, dtype=float).reshape(-1, 4) + output_shape = (len(target_rows), len(requested_positions)) + neighbor_array = np.asarray(neighbor_rows, dtype=float).reshape(output_shape) + neighbor_type_array = np.asarray(neighbor_type_rows, dtype=float).reshape(output_shape) + delay_array = np.asarray(delay_rows, dtype=float).reshape(output_shape) + if not field_names: + return target_array, neighbor_array, neighbor_type_array, delay_array, [], [] + if _fields_are_numeric(urevents, field_names): + target_field_array = np.asarray( + [[_numeric_or_nan(value) for value in values] for values in target_fields], + dtype=float, + ).reshape(len(target_fields), len(field_names)) + neighbor_field_array = np.asarray( + [[[_numeric_or_nan(value) for value in values] for values in positions] for positions in neighbor_fields], + dtype=float, + ).reshape(len(neighbor_fields), len(requested_positions), len(field_names)) + else: + target_field_array = np.empty((len(target_fields), len(field_names)), dtype=object) + neighbor_field_array = np.empty( + (len(neighbor_fields), len(requested_positions), len(field_names)), + dtype=object, + ) + for target_index, values in enumerate(target_fields): + target_field_array[target_index, :] = values + for position_index, neighbor_values in enumerate(neighbor_fields[target_index]): + neighbor_field_array[target_index, position_index, :] = neighbor_values + if len(field_names) == 1: + target_field_array = target_field_array[:, 0] + neighbor_field_array = neighbor_field_array[:, :, 0] + return ( + target_array, + neighbor_array, + neighbor_type_array, + delay_array, + target_field_array, + neighbor_field_array, + ) + + +def _type_list(value: Any | None) -> list[str]: + if _empty(value): + return ["_ALL"] + values = [value] if isinstance(value, (str, int, float)) else list(value) + return [_type_text(item) for item in values] + + +def _matching_type_index(value: str, types: list[str]) -> int | None: + if "_ALL" in types: + return 1 + for index, candidate in enumerate(types, start=1): + if value.lower() == candidate.lower(): + return index + return None + + +def _centered_epoch(EEG: dict[str, Any], event_index: int, event: dict[str, Any]) -> float: + epochs = event.get("epoch", []) + if not isinstance(epochs, (list, tuple, np.ndarray)): + epochs = [epochs] + epoch_records = EEG.get("epoch", []) + for epoch_number in np.asarray(epochs).ravel(): + index = int(epoch_number) - 1 + if index < 0 or index >= len(epoch_records): + continue + record = epoch_records[index] + event_numbers = np.asarray(record.get("event", [])).ravel() + latencies = record.get("eventlatency", []) + if not isinstance(latencies, (list, tuple, np.ndarray)): + latencies = [latencies] + for position, number in enumerate(event_numbers): + if int(number) == event_index and position < len(latencies): + latency = np.asarray(latencies[position]).ravel() + if latency.size and float(latency[0]) == 0: + return float(epoch_number) + return np.nan + + +def _neighbor_at( + urevents: list[dict[str, Any]], + target_index: int, + position: int, + types: list[str], +) -> tuple[int, int] | None: + if position == 0: + type_index = _matching_type_index(_type_text(urevents[target_index].get("type")), types) + return (target_index, type_index) if type_index is not None else None + direction = 1 if position > 0 else -1 + remaining = abs(position) + index = target_index + direction + while 0 <= index < len(urevents): + event = urevents[index] + if is_boundary_event(event): + return None + type_index = _matching_type_index(_type_text(event.get("type")), types) + if type_index is not None: + remaining -= 1 + if remaining == 0: + return index, type_index + index += direction + return None + + +def _empty(value: Any | None) -> bool: + if value is None or (isinstance(value, str) and value == ""): + return True + try: + return np.asarray(value).size == 0 + except ValueError: + return False + + +def _type_text(value: Any) -> str: + if isinstance(value, (int, float, np.integer, np.floating)) and float(value).is_integer(): + return str(int(value)) + return str(value) + + +def _fields_are_numeric(events: list[dict[str, Any]], field_names: list[str]) -> bool: + for name in field_names: + values = [event.get(name) for event in events if not _empty(event.get(name))] + if not values or any(not _is_numeric_scalar(value) for value in values): + return False + return True + + +def _is_numeric_scalar(value: Any) -> bool: + array = np.asarray(value) + return array.size == 1 and np.issubdtype(array.dtype, np.number) + + +def _numeric_or_nan(value: Any) -> float: + if _empty(value): + return np.nan + return float(np.asarray(value).reshape(-1)[0]) + + +__all__ = ["eeg_context"] diff --git a/src/eegprep/functions/popfunc/eeg_eventhist.py b/src/eegprep/functions/popfunc/eeg_eventhist.py new file mode 100644 index 00000000..81f5e282 --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_eventhist.py @@ -0,0 +1,64 @@ +"""Event-field value and histogram summaries.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._event_utils import events_as_list + + +def eeg_eventhist(events: Any, field: str, bins: int | Any = 10) -> tuple[list[Any] | np.ndarray, np.ndarray, Any]: + """Return event-field values, histogram counts, and bin labels or edges.""" + event_list = events_as_list(events) + if not event_list: + raise ValueError("event structure is empty") + if not any(field in event for event in event_list): + raise ValueError(f"named field {field!r} is not an event field") + first = next((event.get(field) for event in event_list if _has_value(event.get(field))), None) + if first is None: + raise ValueError(f"all event fields named {field!r} are empty") + + if isinstance(first, str): + values = [str(event.get(field) or " ") for event in event_list] + labels = sorted(set(values)) + counts = np.asarray([values.count(label) for label in labels], dtype=int) + return values, counts, labels + if isinstance(first, dict): + return [event.get(field) for event in event_list], np.array([], dtype=int), [] + + values = np.asarray( + [float(event[field]) if _has_value(event.get(field)) else np.nan for event in event_list], + dtype=float, + ) + if np.isscalar(bins): + number = int(bins) + if number < 3: + raise ValueError("number of bins must be greater than 2") + mean = float(np.nanmean(values)) + standard_deviation = float(np.nanstd(values, ddof=1)) if np.count_nonzero(np.isfinite(values)) > 1 else 0.0 + offsets = np.arange(-(number // 2), int(np.ceil(number / 2)) + 1) + edges = mean + offsets * standard_deviation + edges[0], edges[-1] = -np.inf, np.inf + else: + edges = np.asarray(bins, dtype=float) + finite_values = values[np.isfinite(values)] + counts = np.zeros(len(edges) - 1, dtype=int) + for index in range(len(counts)): + mask = (finite_values >= edges[index]) & (finite_values < edges[index + 1]) + counts[index] = int(np.count_nonzero(mask)) + return values, counts, edges + + +def _has_value(value: Any) -> bool: + if value is None: + return False + if isinstance(value, np.ndarray): + return value.size > 0 + if isinstance(value, (list, tuple)): + return bool(value) + return True + + +__all__ = ["eeg_eventhist"] diff --git a/src/eegprep/functions/popfunc/eeg_eventtypes.py b/src/eegprep/functions/popfunc/eeg_eventtypes.py new file mode 100644 index 00000000..eaa6ceb6 --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_eventtypes.py @@ -0,0 +1,33 @@ +"""Summarize event types in an EEG dataset.""" + +from __future__ import annotations + +from collections import Counter +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._event_utils import events_as_list + + +def eeg_eventtypes(EEG: dict[str, Any]) -> tuple[list[str], list[int]]: + """Return event type names and counts, sorted by decreasing count. + + Ties follow EEGLAB's reverse-alphabetical ordering. + """ + if not isinstance(EEG, dict): + raise TypeError("EEG must be a dataset dictionary") + if "event" not in EEG: + raise ValueError("EEG.event field not found") + counts = Counter(_type_text(event.get("type", "")) for event in events_as_list(EEG["event"])) + ordered = sorted(counts, key=lambda value: (counts[value], value), reverse=True) + return ordered, [counts[value] for value in ordered] + + +def _type_text(value: Any) -> str: + if isinstance(value, (int, float, np.integer, np.floating)) and float(value).is_integer(): + return str(int(value)) + return str(value) + + +__all__ = ["eeg_eventtypes"] diff --git a/src/eegprep/functions/popfunc/eeg_getepochevent.py b/src/eegprep/functions/popfunc/eeg_getepochevent.py new file mode 100644 index 00000000..b0355024 --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_getepochevent.py @@ -0,0 +1,195 @@ +"""Extract event values on a per-epoch basis.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._event_utils import events_as_list +from eegprep.functions.popfunc.eeg_point2lat import eeg_point2lat + + +_OPTION_NAMES = {"type", "timewin", "fieldname", "trials"} + + +def eeg_getepochevent( + EEG: dict[str, Any] | list[dict[str, Any]], *args: Any, **kwargs: Any +) -> tuple[Any, list[list[Any]]]: + """Return a selected event-field value for each epoch. + + Both EEGLAB's positional form ``(type, timewin, fieldname)`` and its + key/value form are accepted. Trial selections are 1-based; stored event + fields such as ``urevent`` are returned in EEGPrep's 0-based form. + """ + options = _parse_options(args, kwargs) + if isinstance(EEG, list): + return _multiple_datasets(EEG, options) + event_types = options["type"] + time_window = np.asarray(options["timewin"], dtype=float) + field_name = str(options["fieldname"]) + events = events_as_list(EEG.get("event", [])) + trial_count = int(EEG.get("trials", 1)) + epoch_values: list[Any] = [np.nan] * trial_count + all_epoch_values: list[list[Any]] = [[] for _ in range(trial_count)] + + for event in events: + if not _matches_type(event.get("type"), event_types) or field_name not in event: + continue + epoch = _event_epoch(event) + latency_ms = float( + eeg_point2lat( + [event["latency"]], + [epoch], + EEG["srate"], + [EEG["xmin"] * 1000, EEG["xmax"] * 1000], + 1e-3, + )[0] + ) + if latency_ms < time_window[0] or latency_ms > time_window[1]: + continue + value = _field_value(event[field_name], field_name, float(EEG["srate"]), latency_ms) + if value is None: + continue + index = epoch - 1 + if index < 0 or index >= trial_count: + raise ValueError("event epoch is outside EEG.trials") + if field_name != "latency" or not all_epoch_values[index]: + epoch_values[index] = _numeric_value(value) + all_epoch_values[index].append(value) + + selected_trials = options["trials"] + if selected_trials is not None and np.asarray(selected_trials).size: + indices = (np.asarray(selected_trials, dtype=int).ravel() - 1).tolist() + if any(index < 0 or index >= trial_count for index in indices): + raise ValueError("trials must be 1-based and within EEG.trials") + epoch_values = [epoch_values[index] for index in indices] + all_epoch_values = [all_epoch_values[index] for index in indices] + try: + output_values: Any = np.asarray(epoch_values, dtype=float) + except (TypeError, ValueError): + output_values = epoch_values + return output_values, all_epoch_values + + +def _multiple_datasets(datasets: list[dict[str, Any]], options: dict[str, Any]) -> tuple[Any, list[list[Any]]]: + trial_groups = _dataset_trial_groups(options["trials"], len(datasets)) + combined_values: list[Any] = [] + combined_all_values: list[list[Any]] = [] + for dataset, trials in zip(datasets, trial_groups): + dataset_options = {**options, "trials": trials} + values, all_values = eeg_getepochevent(dataset, **dataset_options) + combined_values.extend(np.asarray(values).ravel().tolist()) + combined_all_values.extend(all_values) + try: + output_values: Any = np.asarray(combined_values, dtype=float) + except (TypeError, ValueError): + output_values = combined_values + return output_values, combined_all_values + + +def _dataset_trial_groups(trials: Any, dataset_count: int) -> list[Any]: + if trials is None or np.asarray(trials, dtype=object).size == 0: + return [[] for _ in range(dataset_count)] + if dataset_count == 1: + return [trials] + if isinstance(trials, (str, bytes)) or np.isscalar(trials): + raise ValueError("trials must provide one selection per dataset") + groups = list(trials) + if len(groups) != dataset_count: + raise ValueError("trials must provide one selection per dataset") + return groups + + +def _parse_options(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: + options: dict[str, Any] = {"type": [], "timewin": [-np.inf, np.inf], "fieldname": "latency", "trials": []} + legacy_time_window = len(args) > 1 and _is_numeric_pair(args[1]) + if ( + not legacy_time_window + and args + and isinstance(args[0], str) + and args[0].lower() in _OPTION_NAMES + and len(args) % 2 == 0 + ): + for key, value in zip(args[::2], args[1::2]): + options[str(key).lower()] = value + elif args: + options["type"] = args[0] + if len(args) > 1 and np.asarray(args[1]).size: + options["timewin"] = args[1] + if len(args) > 2: + options["fieldname"] = args[2] + if len(args) > 3: + raise TypeError("old eeg_getepochevent form accepts at most three options") + options.update({str(key).lower(): value for key, value in kwargs.items()}) + unknown = set(options) - _OPTION_NAMES + if unknown: + raise TypeError(f"unknown eeg_getepochevent options: {sorted(unknown)}") + if not np.asarray(options["timewin"]).size: + options["timewin"] = [-np.inf, np.inf] + return options + + +def _is_numeric_pair(value: Any) -> bool: + array = np.asarray(value) + return array.size == 2 and np.issubdtype(array.dtype, np.number) + + +def _matches_type(value: Any, requested: Any) -> bool: + if requested is None or (isinstance(requested, (list, tuple, np.ndarray)) and len(requested) == 0): + return True + requested_values = [requested] if isinstance(requested, (str, int, float)) else list(requested) + return any(_type_text(value) == _type_text(candidate) for candidate in requested_values) + + +def _event_epoch(event: dict[str, Any]) -> int: + value = event.get("epoch", 1) + if isinstance(value, np.ndarray): + value = value.ravel()[0] + elif isinstance(value, (list, tuple)): + value = value[0] + return int(value) + + +def _field_value(value: Any, field: str, srate: float, latency_ms: float) -> Any: + if isinstance(value, np.ndarray): + if value.size == 0: + return None + value = value.ravel()[0] + elif isinstance(value, (list, tuple)): + if not value: + return None + value = value[0] + if field == "latency": + return latency_ms + if field == "duration": + return float(value) / srate * 1000 + return value + + +def _numeric_value(value: Any) -> Any: + if not isinstance(value, str): + return value + output = 0.0 + for position, character in enumerate(value, start=1): + code = ord(character) + adjusted = ( + code - 47 + if 48 <= code <= 57 + else code - 64 + if 65 <= code <= 90 + else code - 96 + if 97 <= code <= 122 + else code + ) + output += adjusted / 36**position + return output + + +def _type_text(value: Any) -> str: + if isinstance(value, (int, float, np.integer, np.floating)) and float(value).is_integer(): + return str(int(value)) + return str(value) + + +__all__ = ["eeg_getepochevent"] diff --git a/src/eegprep/functions/popfunc/eeg_insertbound.py b/src/eegprep/functions/popfunc/eeg_insertbound.py new file mode 100644 index 00000000..c1b1b6cd --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_insertbound.py @@ -0,0 +1,93 @@ +"""Insert boundary events after removing continuous sample regions.""" + +from __future__ import annotations + +import copy +from typing import Any + +import numpy as np + +from eegprep.functions.adminfunc.eeg_options import EEG_OPTIONS +from eegprep.functions.miscfunc.misc import round_mat +from eegprep.functions.popfunc._event_utils import events_as_list, is_boundary_event + + +def eeg_insertbound( + events: Any, + pnts: int, + regions: Any, + lengths: Any | None = None, +) -> tuple[list[dict[str, Any]], list[int]]: + """Insert boundaries and adjust event latencies for removed regions. + + Regions use EEGLAB's 1-based inclusive ``[begin, end]`` convention. The + optional ``lengths`` argument is accepted for API compatibility; current + EEGLAB derives lengths from the regions themselves. Returned event indices + are 0-based for direct use with the Python event list. + """ + del lengths + output = [copy.deepcopy(event) for event in events_as_list(events)] + region_array = np.asarray(regions, dtype=float) + if region_array.size == 0: + return output, [] + region_array = np.atleast_2d(round_mat(region_array).astype(int)) + if region_array.shape[1] != 2: + raise ValueError("regions must have shape (n, 2)") + region_array = np.clip(region_array, 1, int(pnts)) + region_array = region_array[np.argsort(region_array[:, 0])] + if np.any(region_array[:, 0] > region_array[:, 1]): + raise ValueError("each region must satisfy begin <= end") + for index in range(1, len(region_array)): + if region_array[index - 1, 1] >= region_array[index, 0]: + region_array[index, 0] = region_array[index - 1, 1] + 1 + region_array = region_array[region_array[:, 0] <= region_array[:, 1]] + durations = region_array[:, 1] - region_array[:, 0] + 1 + original_count = len(output) + extra_fields = ( + set().union(*(event.keys() for event in output)) - {"type", "latency", "duration"} if output else set() + ) + original_latencies = np.asarray([float(event["latency"]) for event in output], dtype=float) + adjusted_latencies = original_latencies.copy() + remove: set[int] = set() + + for region_index, (begin, end) in enumerate(region_array): + adjusted_latencies[original_latencies > begin] -= durations[region_index] + interior = np.flatnonzero((original_latencies > begin) & (original_latencies < end)) + extra_duration = sum( + float(output[index].get("duration", 0) or 0) for index in interior if is_boundary_event(output[index]) + ) + remove.update(interior.tolist()) + boundary = {field: np.array([]) for field in extra_fields} + boundary.update( + { + "type": _boundary_type(output[:original_count]), + "latency": float(begin - np.sum(durations[:region_index]) - 0.5), + "duration": float(durations[region_index] + extra_duration), + "_eegprep_new_boundary": True, + } + ) + output.append(boundary) + + retained = [] + for index, event in enumerate(output[:original_count]): + if index not in remove: + event["latency"] = float(adjusted_latencies[index]) + if event["latency"] >= 0: + retained.append(event) + retained.extend(output[original_count:]) + retained.sort(key=lambda event: float(event["latency"])) + new_indices: list[int] = [] + for index, event in enumerate(retained): + if event.pop("_eegprep_new_boundary", False): + new_indices.append(index) + return retained, new_indices + + +def _boundary_type(events: list[dict[str, Any]]) -> str | int: + numeric = not events or isinstance(events[0].get("type"), (int, float, np.integer, np.floating)) + if numeric and EEG_OPTIONS["option_boundary99"] and events: + return -99 + return "boundary" + + +__all__ = ["eeg_insertbound"] diff --git a/src/eegprep/functions/popfunc/eeg_matchchans.py b/src/eegprep/functions/popfunc/eeg_matchchans.py new file mode 100644 index 00000000..c0de054a --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_matchchans.py @@ -0,0 +1,64 @@ +"""Match a small channel montage to a larger montage.""" + +from __future__ import annotations + +import copy +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._chanutils import chanlocs_as_list + + +def eeg_matchchans( + big_locations: Any, + small_locations: Any, + noplot: str | None = None, +) -> tuple[list[int], np.ndarray, list[dict[str, Any]]]: + """Return a unique nearest large-montage channel for each small channel. + + Returned channel indices are 0-based for direct use with Python channel + arrays. The legacy plotting switch is accepted but matching itself is + noninteractive. + """ + if noplot is not None and str(noplot).lower() != "noplot": + raise ValueError("the third argument must be 'noplot' when provided") + big = [copy.deepcopy(location) for location in chanlocs_as_list(big_locations)] + small = chanlocs_as_list(small_locations) + if len(small) > len(big): + raise ValueError("big_locations must contain at least as many channels as small_locations") + for location in big: + location["bigchan"] = [] + location["bigdist"] = [] + used: set[int] = set() + selected: list[int] = [] + distances: list[float] = [] + for small_location in small: + small_xyz = _unit_xyz(small_location) + candidates = [ + (float(np.linalg.norm(small_xyz - _unit_xyz(location))), index) + for index, location in enumerate(big) + if index not in used + ] + distance, index = min(candidates) + used.add(index) + selected.append(index) + distances.append(distance) + big[index]["bigchan"] = index + big[index]["bigdist"] = distance + return selected, np.asarray(distances, dtype=float), [big[index] for index in selected] + + +def _unit_xyz(location: dict[str, Any]) -> np.ndarray: + radius_value = location.get("sph_radius", 1) + radius = 1.0 if radius_value is None or np.asarray(radius_value).size == 0 else float(radius_value) + coordinates = [location.get(axis) for axis in ("X", "Y", "Z")] + if any(value is None or np.asarray(value).size == 0 for value in coordinates): + raise ValueError("channel locations must define X, Y, and Z") + coordinates_array = np.asarray(coordinates, dtype=float) + if not np.isfinite(radius) or radius <= 0 or not np.all(np.isfinite(coordinates_array)): + raise ValueError("channel locations must contain finite coordinates and a positive spherical radius") + return coordinates_array / radius + + +__all__ = ["eeg_matchchans"] diff --git a/src/eegprep/functions/popfunc/eeg_mergechan.py b/src/eegprep/functions/popfunc/eeg_mergechan.py new file mode 100644 index 00000000..4550f5bd --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_mergechan.py @@ -0,0 +1,39 @@ +"""Merge two ordered channel-location sequences.""" + +from __future__ import annotations + +import copy +from typing import Any + +from eegprep.functions.popfunc._chanutils import chanlocs_as_list + + +def eeg_mergechan(locations1: Any, locations2: Any) -> list[dict[str, Any]]: + """Merge channel locations by label while preserving montage order.""" + first = [copy.deepcopy(location) for location in chanlocs_as_list(locations1)] + second = [copy.deepcopy(location) for location in chanlocs_as_list(locations2)] + labels1 = [str(location.get("labels", "")).lower() for location in first] + labels2 = [str(location.get("labels", "")).lower() for location in second] + merged: list[dict[str, Any]] = [] + index1 = index2 = 0 + while index1 < len(first) or index2 < len(second): + if index1 >= len(first): + merged.append(second[index2]) + index2 += 1 + elif index2 >= len(second): + merged.append(first[index1]) + index1 += 1 + elif labels1[index1] == labels2[index2]: + merged.append(first[index1]) + index1 += 1 + index2 += 1 + elif labels1[index1] not in labels2: + merged.append(first[index1]) + index1 += 1 + else: + merged.append(second[index2]) + index2 += 1 + return merged + + +__all__ = ["eeg_mergechan"] diff --git a/src/eegprep/functions/popfunc/eeg_mergelocs.py b/src/eegprep/functions/popfunc/eeg_mergelocs.py new file mode 100644 index 00000000..26d40aec --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_mergelocs.py @@ -0,0 +1,39 @@ +"""Merge any number of channel-location montages.""" + +from __future__ import annotations + +import warnings +from typing import Any + +from eegprep.functions.popfunc._chanutils import chanlocs_as_list +from eegprep.functions.popfunc.eeg_mergechan import eeg_mergechan + + +def eeg_mergelocs(*location_sets: Any) -> tuple[list[dict[str, Any]], bool]: + """Merge ordered montages and report incompatible electrode ordering.""" + if not location_sets: + return [], False + ordered = sorted((chanlocs_as_list(locations) for locations in location_sets), key=len, reverse=True) + merged = ordered[0] + incompatible = False + for locations in ordered[1:]: + candidate = eeg_mergechan(merged, locations) + unique_labels = {str(location.get("labels", "")).lower() for location in (*merged, *locations)} + if len(candidate) > len(unique_labels): + incompatible = True + present = {str(location.get("labels", "")).lower() for location in merged} + candidate = [ + *merged, + *(location for location in locations if str(location.get("labels", "")).lower() not in present), + ] + merged = candidate + if incompatible: + warnings.warn( + "different channel montage or electrode order for the datasets", + RuntimeWarning, + stacklevel=2, + ) + return merged, incompatible + + +__all__ = ["eeg_mergelocs"] diff --git a/src/eegprep/functions/popfunc/eeg_timeinterp.py b/src/eegprep/functions/popfunc/eeg_timeinterp.py new file mode 100644 index 00000000..f4d5c0d1 --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_timeinterp.py @@ -0,0 +1,91 @@ +"""Interpolate a contiguous bad-sample interval.""" + +from __future__ import annotations + +import copy +from typing import Any + +import numpy as np +from scipy.interpolate import CubicSpline + + +def eeg_timeinterp( + EEG: dict[str, Any], + samples: Any, + *, + epochinds: Any | None = None, + interpwin: int = 5, + elecinds: Any | None = None, + epochcont: str = "off", +) -> dict[str, Any]: + """Spline-interpolate bad samples using neighboring activity. + + Samples and epochs use 1-based EEGLAB identifiers. Electrodes are 0-based + so the result of :func:`eeg_chaninds` can be passed directly. ``samples`` + may be a ``[begin, end]`` range or a contiguous vector of bad samples. + """ + output = copy.deepcopy(EEG) + data = np.asarray(output["data"]) + was_continuous = data.ndim == 2 + if was_continuous: + data = data[:, :, np.newaxis] + if data.ndim != 3: + raise ValueError("EEG.data must be two- or three-dimensional") + sample_values = np.asarray(samples, dtype=int).ravel() + if sample_values.size < 2: + raise ValueError("samples must contain a begin and end sample") + begin = int(sample_values.min()) + end = int(sample_values.max()) + pnts = data.shape[1] + if begin < 1 or end > pnts: + raise ValueError("samples must be 1-based and within EEG.pnts") + if interpwin < 1: + raise ValueError("interpwin must be positive") + if epochcont not in {"on", "off"}: + raise ValueError("epochcont must be 'on' or 'off'") + electrodes = _zero_based_indices(elecinds, data.shape[0], "elecinds") + epochs = _one_based_indices(epochinds, data.shape[2], "epochinds") + width = end - begin + margin = max(width * int(interpwin), 1) + output_samples = np.arange(begin - 1, end) + + for epoch in epochs: + for electrode in electrodes: + signal = data[electrode, :, epoch] + support_indices = np.concatenate( + ( + np.arange(max(begin - 1 - margin, 0), begin - 1), + np.arange(end, min(end + margin, pnts)), + ) + ) + support_values = signal[support_indices] + if epochcont == "on" and end + margin > pnts and epoch + 1 < data.shape[2]: + extra_count = min(end + margin - pnts, pnts) + support_indices = np.concatenate((support_indices, np.arange(pnts, pnts + extra_count))) + support_values = np.concatenate((support_values, data[electrode, :extra_count, epoch + 1])) + if support_indices.size < 2: + raise ValueError("not enough neighboring samples for interpolation") + data[electrode, output_samples, epoch] = CubicSpline(support_indices, support_values)(output_samples) + output["data"] = data[:, :, 0] if was_continuous else data + return output + + +def _one_based_indices(value: Any | None, length: int, name: str) -> list[int]: + if value is None or np.asarray(value).size == 0: + return list(range(length)) + values = np.asarray(value, dtype=int).ravel() + if np.any(values < 1) or np.any(values > length): + raise ValueError(f"{name} must be 1-based and within range") + return (values - 1).tolist() + + +def _zero_based_indices(value: Any | None, length: int, name: str) -> list[int]: + if value is None or np.asarray(value).size == 0: + return list(range(length)) + values = np.asarray(value, dtype=int).ravel() + if np.any(values < 0) or np.any(values >= length): + raise ValueError(f"{name} must be 0-based and within range") + return values.tolist() + + +__all__ = ["eeg_timeinterp"] diff --git a/src/eegprep/functions/popfunc/eeg_urlatency.py b/src/eegprep/functions/popfunc/eeg_urlatency.py new file mode 100644 index 00000000..ada0d2ae --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_urlatency.py @@ -0,0 +1,33 @@ +"""Recover original sample latencies across boundary events.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._event_utils import events_as_list, is_boundary_event + + +def eeg_urlatency(events: Any, latencies: Any) -> float | np.ndarray: + """Map current-data latencies to their original continuous positions. + + Boundary durations are added when the boundary precedes an input latency, + matching EEGLAB's 1-based, fractional-sample latency convention. + """ + input_array = np.asarray(latencies, dtype=float) + output = input_array.copy() + boundary_events = [event for event in events_as_list(events) if is_boundary_event(event)] + if boundary_events and any("duration" not in event for event in boundary_events): + output[...] = np.nan + else: + for event in boundary_events: + duration = event.get("duration") + if duration is None or np.asarray(duration).size == 0: + output[...] = np.nan + break + output = np.where(float(event["latency"]) < input_array, output + float(duration), output) + return float(output) if output.ndim == 0 else output + + +__all__ = ["eeg_urlatency"] diff --git a/src/eegprep/functions/popfunc/getchanlist.py b/src/eegprep/functions/popfunc/getchanlist.py new file mode 100644 index 00000000..81d872cd --- /dev/null +++ b/src/eegprep/functions/popfunc/getchanlist.py @@ -0,0 +1,24 @@ +"""Select channels by their channel-location type.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.popfunc._chanutils import chanlocs_as_list + + +def getchanlist(chanlocs: Any, channel_type: str | list[str] | tuple[str, ...] | None = None) -> list[int]: + """Return 0-based channel indices matching one or more types. + + Matching is case-insensitive. With no type, every channel is returned. + Missing requested types do not prevent other requested types from matching. + """ + locations = chanlocs_as_list(chanlocs) + if channel_type is None or not locations or "type" not in locations[0]: + return list(range(len(locations))) + requested = [channel_type] if isinstance(channel_type, str) else list(channel_type) + requested_lower = {str(value).lower() for value in requested} + return [index for index, loc in enumerate(locations) if str(loc.get("type", "")).lower() in requested_lower] + + +__all__ = ["getchanlist"] diff --git a/tests/test_core_event_eeglab_tests.py b/tests/test_core_event_eeglab_tests.py new file mode 100644 index 00000000..6e08ec76 --- /dev/null +++ b/tests/test_core_event_eeglab_tests.py @@ -0,0 +1,517 @@ +"""Ports of current EEGLAB tests for core event and channel helpers.""" + +from __future__ import annotations + +import copy +import warnings +from pathlib import Path + +import numpy as np + +from eegprep import ( + eeg_addnewevents, + eeg_amplitudearea, + eeg_chaninds, + eeg_context, + eeg_eegrej, + eeg_eventhist, + eeg_eventtypes, + eeg_getepochevent, + eeg_insertbound, + eeg_matchchans, + eeg_mergechan, + eeg_mergelocs, + eeg_timeinterp, + eeg_urlatency, + getchanlist, +) +from eegprep.functions.popfunc.pop_loadset import pop_loadset +from tests.eeglab_tests import eeglab_test + + +def _epoched_event_eeg(*, durations: bool = False, include_event_epochs: bool = True) -> dict: + event_types = ["square", "square", "rt", "square", "rt"] + latencies = [2, 5, 5.3, 8, 8.4] + epochs = [1, 2, 2, 3, 3] + events = [] + for index, (event_type, latency, epoch) in enumerate(zip(event_types, latencies, epochs), start=1): + event = { + "type": event_type, + "position": 2 if event_type == "square" else [], + "latency": latency, + "urevent": index - 1, + } + if include_event_epochs: + event["epoch"] = epoch + if durations: + event["duration"] = index / 10 + events.append(event) + return { + "data": np.zeros((2, 3, 3)), + "nbchan": 2, + "pnts": 3, + "trials": 3, + "srate": 1, + "xmin": 0, + "xmax": 2, + "event": events, + "epoch": [ + {"event": [0], "eventlatency": [[1000]], "eventtype": ["square"], "eventurevent": [[0]]}, + { + "event": [1, 2], + "eventlatency": [[1000], [1300]], + "eventtype": ["square", "rt"], + "eventurevent": [[1], [2]], + }, + { + "event": [3, 4], + "eventlatency": [[1000], [1400]], + "eventtype": ["square", "rt"], + "eventurevent": [[3], [4]], + }, + ], + } + + +def _context_eeg(*, epoched: bool = False) -> dict: + event_types = ["square", "square", "rt", "square", "rt"] + latencies = [0, 0, 0.3, 0, 0.4] if epoched else [5, 8, 8.5, 12, 12.4] + epochs = [1, 2, 2, 3, 3] + events = [] + urevents = [] + for index, (event_type, latency) in enumerate(zip(event_types, latencies), start=1): + record = {"type": event_type, "position": 2 if event_type == "square" else [], "latency": latency} + urevent = dict(record) + event = {**record, "urevent": index - 1} + if epoched: + event["epoch"] = epochs[index - 1] + urevent["epoch"] = epochs[index - 1] + events.append(event) + urevents.append(urevent) + eeg = {"event": events, "urevent": urevents, "srate": 1, "trials": 3 if epoched else 1} + if epoched: + eeg["epoch"] = [ + {"event": [0], "eventlatency": [[0]]}, + {"event": [1, 2], "eventlatency": [[0], [0.3]]}, + {"event": [3, 4], "eventlatency": [[0], [0.4]]}, + ] + return eeg + + +def _matching_locations() -> tuple[list[dict], list[dict]]: + small = [ + {"labels": "1_1", "X": 0, "Y": 1, "Z": 0, "sph_radius": 1}, + {"labels": "1_2", "X": 0, "Y": -1, "Z": 0, "sph_radius": 1}, + ] + big = [ + {"labels": "2_1", "X": -np.sqrt(2) / 2, "Y": np.sqrt(2) / 2, "Z": 0, "sph_radius": 1}, + {"labels": "2_2", "X": 1, "Y": 0, "Z": 0, "sph_radius": 1}, + {"labels": "2_3", "X": 0, "Y": -1, "Z": 0, "sph_radius": 1}, + ] + return big, small + + +def _typed_locations() -> list[dict]: + return [{"type": value, "X": index, "Y": index + 1, "Z": index + 2} for index, value in enumerate("hello", start=1)] + + +@eeglab_test( + "unittesting_popfunc/eeg_addnewevents/popfunc_eeg_addnewevents_wrapperTest.m", + "test_test_eeg_addnewevents", +) +def test_eeg_addnewevents_current_suite_documented_calls_are_functional(): + eeg = {"event": [], "urevent": []} + output = eeg_addnewevents( + eeg, + [[100, 200], [300, 400, 500]], + ["type1", "type2"], + ["field1", "field2"], + [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]], + ) + assert [event["latency"] for event in output["event"]] == [100, 200, 300, 400, 500] + assert [event["type"] for event in output["event"]] == ["type1", "type1", "type2", "type2", "type2"] + assert [event["field1"] for event in output["event"]] == [1, 2, 3, 4, 5] + assert [event["urevent"] for event in output["event"]] == [0, 1, 2, 3, 4] + assert all("urevent" not in event for event in output["urevent"]) + + existing = { + "event": [ + {"type": "late-original", "latency": 4, "urevent": 1}, + {"type": "early-original", "latency": 6, "urevent": 0}, + ], + "urevent": [ + {"type": "early-original", "latency": 2}, + {"type": "late-original", "latency": 10}, + ], + } + merged = eeg_addnewevents(existing, [[5]], ["new"]) + assert [event["type"] for event in merged["event"]] == ["late-original", "new", "early-original"] + assert [event["urevent"] for event in merged["event"]] == [2, 1, 0] + assert [event["latency"] for event in merged["urevent"]] == [2, 5, 10] + for event in merged["event"]: + assert merged["urevent"][event["urevent"]]["type"] == event["type"] + + +@eeglab_test( + "unittesting_popfunc/eeg_amplitudearea/popfunc_eeg_amplitudearea_wrapperTest.m", + "test_test_eeg_amplitudearea", +) +def test_eeg_amplitudearea_current_suite_epoched_cases(): + data = np.zeros((2, 3, 4)) + data[0] = [[1, 1, 2, 2], [1, 1, 2, 2], [1, 1, 2, 2]] + data[1] = [[2, 2, 2, 2], [1, 1, 1, 1], [1, 1, 1, 1]] + eeg = {"data": data, "times": np.array([0, 1000, 2000]), "xmin": 0, "xmax": 2} + channels, amplitude = eeg_amplitudearea(eeg, [0, 1], 1, 0, 3) + np.testing.assert_array_equal(channels, [0, 1]) + np.testing.assert_allclose(amplitude, [1.5, 1.9985], atol=1e-12) + + data[0] = [[1, 4, 13, 16], [2, 5, 14, 17], [3, 6, 15, 18]] + data[1] = [[7, 10, 19, 22], [8, 11, 20, 23], [9, 12, 21, 24]] + _, second_amplitude = eeg_amplitudearea(eeg, [0, 1], 1, 0, 3) + assert np.all(np.isfinite(second_amplitude)) + assert second_amplitude[1] > second_amplitude[0] + + linear = { + "data": np.array([[[0.0], [1.0], [2.0]]]), + "times": np.array([0.0, 1.0, 2.0]), + "xmin": 0, + "xmax": 0.002, + } + _, truncated_interval = eeg_amplitudearea(linear, [0], 1, 0, 1.5) + np.testing.assert_allclose(truncated_interval, [0.75], atol=1e-12) + + +@eeglab_test("unittesting_popfunc/eeg_chaninds/popfunc_eeg_chaninds_wrapperTest.m", "test_test_eeg_chaninds") +def test_eeg_chaninds_current_suite_label_forms(): + labels = "FPz EOG1 F3 Fz F4 EOG2 FC5 FC1 FC2 FC6 T7 C3 C4 Cz T8 CP5 CP1 CP2 CP6 P7 P3 Pz P4 P8 PO7 PO3 POz PO4 PO8 O1 Oz O2".split() + eeg = {"chanlocs": [{"labels": label} for label in labels]} + assert eeg_chaninds(eeg, ["P7"]) == [19] + assert eeg_chaninds(eeg, labels) == list(range(32)) + mixed = "T8 CP5 CP1 P3 Pz P4 P8 O1 Oz O2 FPz EOG1 F3 Fz F4 EOG2 FC5 FC1 FC6 Cz".split() + indices = eeg_chaninds(eeg, mixed) + assert len(indices) == len(mixed) + assert {labels[index] for index in indices} == set(mixed) + + +@eeglab_test("unittesting_popfunc/eeg_context/popfunc_eeg_context_wrapperTest.m", "test_test_eeg_context") +def test_eeg_context_current_suite_six_context_cases(): + eeg = _context_eeg() + expected_targets = np.array([[1, 1, np.nan, 1], [2, 2, np.nan, 1], [4, 4, np.nan, 1]]) + result = eeg_context(eeg, "square", ["square", "rt"], [1], "type", "all") + np.testing.assert_allclose(result[0], expected_targets, equal_nan=True) + np.testing.assert_allclose(result[1], [[2], [3], [5]], equal_nan=True) + np.testing.assert_allclose(result[2], [[1], [2], [2]], equal_nan=True) + np.testing.assert_allclose(result[3], [[3000], [500], [400]], equal_nan=True) + assert result[4].tolist() == ["square", "square", "square"] + assert result[5].tolist() == [["square"], ["rt"], ["rt"]] + repeated = eeg_context(eeg, "square", ["square", "rt"], [1], "type") + np.testing.assert_allclose(repeated[0], expected_targets, equal_nan=True) + uppercase_all = eeg_context(eeg, "square", ["square", "rt"], [1], "type", "ALL") + np.testing.assert_allclose(uppercase_all[0], expected_targets, equal_nan=True) + + epoched = eeg_context(_context_eeg(epoched=True), "square", ["square", "rt"], [1], "type") + np.testing.assert_allclose(epoched[0][:, 2], [1, 2, 3]) + np.testing.assert_allclose(epoched[3], [[0], [300], [400]]) + two_after = eeg_context(eeg, "square", ["square", "rt"], [1, 2], "type") + np.testing.assert_allclose(two_after[1], [[2, 3], [3, 4], [5, np.nan]], equal_nan=True) + around = eeg_context(eeg, "square", ["square", "rt"], [-2, 1], "type") + np.testing.assert_allclose(around[1], [[np.nan, 2], [np.nan, 3], [2, 5]], equal_nan=True) + assert around[5].tolist() == [[[], "square"], [[], "rt"], ["square", "rt"]] + defaults = eeg_context(eeg) + np.testing.assert_allclose(defaults[1], [[2], [3], [4], [5], [np.nan]], equal_nan=True) + np.testing.assert_allclose(defaults[3], [[3000], [500], [3500], [400], [np.nan]], equal_nan=True) + assert defaults[4] == [] and defaults[5] == [] + + numeric = eeg_context(eeg, "square", ["square", "rt"], [2], "latency") + np.testing.assert_allclose(numeric[4], [5, 8, 12]) + np.testing.assert_allclose(numeric[5], [[8.5], [12], [np.nan]], equal_nan=True) + assert numeric[4].dtype == float and numeric[5].dtype == float + + no_targets = eeg_context(eeg, "missing", ["square"], [1], "latency") + assert no_targets[4].shape == (0,) and no_targets[5].shape == (0, 1) + + with_boundary = _context_eeg() + with_boundary["event"].insert(2, {"type": "boundary", "latency": 8.25, "duration": 1}) + with_boundary["urevent"].insert(2, {"type": "boundary", "latency": 8.25, "duration": 1}) + for event in with_boundary["event"]: + if "urevent" in event and event["urevent"] >= 2: + event["urevent"] += 1 + boundary_result = eeg_context(with_boundary, "square", ["square", "rt"], [1], "type") + np.testing.assert_allclose(boundary_result[1], [[2], [np.nan], [6]], equal_nan=True) + + +@eeglab_test("unittesting_popfunc/eeg_eegrej/popfunc_eeg_eegrej_wrapperTest.m", "test_test_eeg_eegrej") +def test_eeg_eegrej_current_suite_empty_and_middle_regions(): + data = np.arange(1, 16, dtype=float).reshape(3, 5) + eeg = {"data": data, "nbchan": 3, "pnts": 5, "trials": 1, "srate": 1, "xmin": 0, "xmax": 4, "event": []} + unchanged = eeg_eegrej(eeg, []) + np.testing.assert_array_equal(unchanged["data"], data) + output = eeg_eegrej(eeg, [[2, 3]]) + np.testing.assert_array_equal(output["data"], data[:, [0, 3, 4]]) + assert output["event"] == [{"type": "boundary", "latency": 1.5, "duration": 2.0}] + + +@eeglab_test("unittesting_popfunc/eeg_eegrej/popfunc_eeg_eegrej_wrapperTest.m", "test_testcase_eegrej") +def test_eeg_eegrej_current_suite_endpoint_event_regression(): + eeg = {"data": np.zeros((1, 10000)), "pnts": 10000, "srate": 500, "trials": 1, "xmin": 0, "xmax": 19.998} + cases = [ + ( + [ + {"type": "mrk1", "latency": 999}, + {"type": "mrk2", "latency": 1000}, + {"type": "mrk3", "latency": 2000}, + {"type": "mrk4", "latency": 2001}, + ], + [1000, 2000], + ["mrk1", "boundary", "mrk4"], + [999, 999.5, 1000], + ), + ( + [{"type": "mrk1", "latency": 1}, {"type": "mrk2", "latency": 1000}, {"type": "mrk3", "latency": 1001}], + [1, 1000], + ["boundary", "mrk3"], + [0.5, 1], + ), + ( + [{"type": "mrk1", "latency": 8999}, {"type": "mrk2", "latency": 9000}, {"type": "mrk3", "latency": 10000}], + [9000, 10000], + ["mrk1", "boundary"], + [8999, 8999.5], + ), + ] + for events, region, expected_types, expected_latencies in cases: + case = {**eeg, "event": events} + output = eeg_eegrej(case, [region]) + assert [event["type"] for event in output["event"]] == expected_types + np.testing.assert_allclose([event["latency"] for event in output["event"]], expected_latencies) + + +@eeglab_test("unittesting_popfunc/eeg_eventhist/popfunc_eeg_eventhist_wrapperTest.m", "test_test_eeg_eventhist") +def test_eeg_eventhist_current_suite_string_and_numeric_fields(): + string_events = [{"type": value} for value in ["square", "square", "rt", "square", "rt"]] + values, counts, labels = eeg_eventhist(string_events, "type") + assert values == ["square", "square", "rt", "square", "rt"] + np.testing.assert_array_equal(counts, [2, 3]) + assert labels == ["rt", "square"] + _, plot_counts, plot_labels = eeg_eventhist(string_events, "type", 2) + np.testing.assert_array_equal(plot_counts, counts) + assert plot_labels == labels + + numeric_events = [{"latency": value} for value in [0, 0, 0.3, 0, 0.4]] + numeric_values, numeric_counts, edges = eeg_eventhist(numeric_events, "latency", 3) + np.testing.assert_allclose(numeric_values, [0, 0, 0.3, 0, 0.4]) + np.testing.assert_array_equal(numeric_counts, [3, 1, 1]) + np.testing.assert_allclose(edges, [-np.inf, 0.14, 0.3349358869, np.inf]) + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + _, singleton_counts, singleton_edges = eeg_eventhist([{"latency": 0.0}], "latency", 3) + assert singleton_counts.sum() == 1 + assert not np.isnan(singleton_edges).any() + + _, explicit_counts, _ = eeg_eventhist([{"latency": value} for value in [0, 1, 2]], "latency", [0, 1, 2]) + np.testing.assert_array_equal(explicit_counts, [1, 1]) + + +@eeglab_test("unittesting_popfunc/eeg_eventtypes/popfunc_eeg_eventtypes_wrapperTest.m", "test_test_eeg_eventtypes") +def test_eeg_eventtypes_current_suite_counts_and_order(): + values = ["square", "triangle", "circle", "square", "not really a triangle", "triangle", "square", "point"] + types, counts = eeg_eventtypes({"event": [{"type": value} for value in values]}) + assert types == ["square", "triangle", "point", "not really a triangle", "circle"] + assert counts == [3, 2, 1, 1, 1] + assert eeg_eventtypes({"event": [{"type": 1.0}, {"type": 1}, {"type": 2.5}]}) == (["1", "2.5"], [2, 1]) + + +@eeglab_test("unittesting_popfunc/eeg_getepochevent/popfunc_eeg_getepochevent_wrapperTest.m", "test_pass_duration") +def test_eeg_getepochevent_current_suite_duration_new_and_old_forms(): + eeg = _epoched_event_eeg(durations=True) + new_values, new_all = eeg_getepochevent(eeg, "type", "rt", "fieldname", "duration") + old_values, old_all = eeg_getepochevent(eeg, "rt", [], "duration") + np.testing.assert_allclose(new_values, [np.nan, 300, 500], equal_nan=True) + np.testing.assert_allclose(old_values, new_values, equal_nan=True) + assert new_all == [[], [300], [500]] and old_all == new_all + + +@eeglab_test("unittesting_popfunc/eeg_getepochevent/popfunc_eeg_getepochevent_wrapperTest.m", "test_pass_empty_timewin") +def test_eeg_getepochevent_current_suite_empty_time_window(): + values, all_values = eeg_getepochevent(_epoched_event_eeg(), "type", "rt", "fieldname", "urevent") + np.testing.assert_allclose(values, [np.nan, 2, 4], equal_nan=True) + assert all_values == [[], [2], [4]] + + selected, selected_all = eeg_getepochevent(_epoched_event_eeg(), "type", "rt", "fieldname", "urevent", "trials", 2) + np.testing.assert_array_equal(selected, [2]) + assert selected_all == [[2]] + + combined, combined_all = eeg_getepochevent( + [_epoched_event_eeg(), _epoched_event_eeg()], + "type", + "rt", + "trials", + [[2], [3]], + ) + np.testing.assert_allclose(combined, [1300, 1400]) + assert combined_all == [[1300], [1400]] + + +@eeglab_test("unittesting_popfunc/eeg_getepochevent/popfunc_eeg_getepochevent_wrapperTest.m", "test_pass_four_args") +def test_eeg_getepochevent_current_suite_old_four_argument_form(): + values, all_values = eeg_getepochevent(_epoched_event_eeg(), "rt", [500, 1300], "urevent") + np.testing.assert_allclose(values, [np.nan, 2, np.nan], equal_nan=True) + assert all_values == [[], [2], []] + + literal_type = _epoched_event_eeg() + literal_type["event"][0]["type"] = "type" + type_values, _ = eeg_getepochevent(literal_type, "type", [-1000, 1000]) + np.testing.assert_allclose(type_values, [1000, np.nan, np.nan], equal_nan=True) + + +@eeglab_test("unittesting_popfunc/eeg_getepochevent/popfunc_eeg_getepochevent_wrapperTest.m", "test_pass_general") +def test_eeg_getepochevent_current_suite_default_latency(): + values, all_values = eeg_getepochevent(_epoched_event_eeg(), "rt") + np.testing.assert_allclose(values, [np.nan, 1300, 1400], equal_nan=True) + assert all_values == [[], [1300], [1400]] + + +@eeglab_test("unittesting_popfunc/eeg_getepochevent/popfunc_eeg_getepochevent_wrapperTest.m", "test_pass_no_epoch") +def test_eeg_getepochevent_current_suite_continuous_fallback(): + eeg = _epoched_event_eeg(include_event_epochs=False) + values, all_values = eeg_getepochevent(eeg, "rt") + np.testing.assert_allclose(values, [4300, np.nan, np.nan], equal_nan=True) + assert all_values == [[4300, 7400], [], []] + + +@eeglab_test("unittesting_popfunc/eeg_insertbound/popfunc_eeg_insertbound_wrapperTest.m", "test_pass_general") +def test_eeg_insertbound_current_suite_general_case(): + eeg = _epoched_event_eeg() + events = copy.deepcopy(eeg["event"]) + for event, latency in zip(events, [1, 3, 3.3, 5, 5.4]): + event["latency"] = latency + output, new_indices = eeg_insertbound(events, eeg["pnts"] * (eeg["trials"] + 1), [[2, 3]]) + assert new_indices == [3] + assert output[3]["type"] == "boundary" + assert output[3]["latency"] == 1.5 + assert output[3]["duration"] == 2 + np.testing.assert_allclose([event["latency"] for event in output], [1, 1, 1.3, 1.5, 3, 3.4]) + + unordered, unordered_indices = eeg_insertbound([{"type": "stim", "latency": 15}], 20, [[10, 12], [2, 4]]) + assert unordered_indices == [0, 1] + np.testing.assert_allclose([event["latency"] for event in unordered], [1.5, 6.5, 9]) + np.testing.assert_allclose([event["duration"] for event in unordered[:2]], [3, 3]) + + rounded, _ = eeg_insertbound([{"type": "stim", "latency": 5}], 20, [[2.5, 3.5]]) + np.testing.assert_allclose([event["latency"] for event in rounded], [2.5, 3]) + + +def _assert_matchchans(option: str | None) -> None: + big, small = _matching_locations() + selected, distances, locations = eeg_matchchans(big, small, option) + assert selected == [0, 2] + np.testing.assert_allclose(distances, [np.sqrt(2 - np.sqrt(2)), 0]) + assert locations[0]["bigchan"] == 0 + assert locations[0]["bigdist"] == distances[0] + assert locations[1]["bigchan"] == 2 and locations[1]["bigdist"] == 0 + + +@eeglab_test("unittesting_popfunc/eeg_matchchans/popfunc_eeg_matchchans_wrapperTest.m", "test_pass_general") +def test_eeg_matchchans_current_suite_general_case(): + _assert_matchchans(None) + + +@eeglab_test("unittesting_popfunc/eeg_matchchans/popfunc_eeg_matchchans_wrapperTest.m", "test_pass_noplot") +def test_eeg_matchchans_current_suite_noplot_case(): + _assert_matchchans("noplot") + + big, small = _matching_locations() + big[0]["X"] = np.nan + with np.testing.assert_raises_regex(ValueError, "finite coordinates"): + eeg_matchchans(big, small, "noplot") + big, small = _matching_locations() + big[0]["sph_radius"] = 0 + with np.testing.assert_raises_regex(ValueError, "positive spherical radius"): + eeg_matchchans(big, small, "noplot") + + +@eeglab_test("unittesting_popfunc/eeg_mergechan/popfunc_eeg_mergechan_wrapperTest.m", "test_test_eeg_mergechan") +def test_eeg_mergechan_current_suite_three_overlap_shapes(): + first = [{"labels": label} for label in "ABCDEFGHIJ"] + second = [{"labels": label} for label in "EFGHIJKLMNO"] + assert [loc["labels"] for loc in eeg_mergechan(first[:1], first[1:2])] == ["A", "B"] + assert [loc["labels"] for loc in eeg_mergechan(first, second)] == list("ABCDEFGHIJKLMNO") + subset = [{"labels": label} for label in "ACEGI"] + assert [loc["labels"] for loc in eeg_mergechan(first, subset)] == list("ABCDEFGHIJ") + + +@eeglab_test("unittesting_popfunc/eeg_mergelocs/popfunc_eeg_mergelocs_wrapperTest.m", "test_test_eeg_mergelocs") +def test_eeg_mergelocs_current_suite_three_overlap_shapes(): + first = [{"labels": label} for label in "ABCDEFGHIJ"] + second = [{"labels": label} for label in "EFGHIJKLMNO"] + one, warning = eeg_mergelocs(first[:1], first[1:2]) + assert [loc["labels"] for loc in one] == ["A", "B"] and not warning + overlap, warning = eeg_mergelocs(first, second) + assert [loc["labels"] for loc in overlap] == list("ABCDEFGHIJKLMNO") and not warning + subset, warning = eeg_mergelocs(first, [{"labels": label} for label in "ACEGI"]) + assert [loc["labels"] for loc in subset] == list("ABCDEFGHIJ") and not warning + + +@eeglab_test("unittesting_popfunc/eeg_timeinterp/popfunc_eeg_timeinterp_wrapperTest.m", "test_test_eeg_timeinterp") +def test_eeg_timeinterp_current_suite_continuous_sample_workflow(): + sample = Path(__file__).resolve().parents[1] / "sample_data" / "eeglab_data.set" + eeg = pop_loadset(sample) + original = np.asarray(eeg["data"]).copy() + eeg["data"][0, 99:1000] = 0 + output = eeg_timeinterp(eeg, np.arange(100, 1001)) + assert output["data"].shape == original.shape + assert np.any(output["data"][0, 99:1000] != 0) + assert np.all(np.isfinite(output["data"][:, 99:1000])) + np.testing.assert_array_equal(output["data"][:, :99], original[:, :99]) + np.testing.assert_array_equal(output["data"][:, 1000:], original[:, 1000:]) + + sample_numbers = np.arange(1, 21, dtype=float) + polynomial = sample_numbers**3 - 2 * sample_numbers**2 + sample_numbers + polynomial_eeg = {"data": polynomial[np.newaxis, :].copy(), "pnts": 20} + polynomial_eeg["data"][0, 7:12] = 0 + reconstructed = eeg_timeinterp(polynomial_eeg, [8, 9, 10, 11, 12], interpwin=1) + np.testing.assert_allclose(reconstructed["data"][0, 7:12], polynomial[7:12], rtol=1e-12) + + selected_electrode = {"data": np.vstack([polynomial, polynomial]), "pnts": 20} + selected_electrode["data"][:, 7:12] = 0 + selected_electrode = eeg_timeinterp(selected_electrode, [8, 12], elecinds=[0], interpwin=1) + np.testing.assert_allclose(selected_electrode["data"][0, 7:12], polynomial[7:12], rtol=1e-12) + np.testing.assert_array_equal(selected_electrode["data"][1, 7:12], np.zeros(5)) + + +@eeglab_test("unittesting_popfunc/eeg_urlatency/popfunc_eeg_urlatency_wrapperTest.m", "test_pass_general") +def test_eeg_urlatency_current_suite_boundary_durations(): + events = [ + {"type": "boundary", "duration": 2, "latency": 1.5}, + {"type": "boundary", "duration": 3, "latency": 5.5}, + {"type": "boundary", "duration": 1, "latency": 9.5}, + ] + assert eeg_urlatency(events, 9) == 14 + np.testing.assert_allclose(eeg_urlatency(events, [1, 6, 10]), [1, 11, 16]) + + +@eeglab_test("unittesting_popfunc/eeg_urlatency/popfunc_eeg_urlatency_wrapperTest.m", "test_pass_no_duration") +def test_eeg_urlatency_current_suite_missing_duration(): + events = [{"type": "boundary", "latency": latency} for latency in [1.5, 5.5, 9.5]] + assert np.isnan(eeg_urlatency(events, 9)) + + +@eeglab_test("unittesting_popfunc/getchanlist/popfunc_getchanlist_wrapperTest.m", "test_pass_cell") +def test_getchanlist_current_suite_multiple_types_and_missing_type(): + assert getchanlist(_typed_locations(), ["e", "f"]) == [1] + + +@eeglab_test("unittesting_popfunc/getchanlist/popfunc_getchanlist_wrapperTest.m", "test_pass_general") +def test_getchanlist_current_suite_case_insensitive_type(): + assert getchanlist(_typed_locations(), "L") == [2, 3] + + +@eeglab_test("unittesting_popfunc/getchanlist/popfunc_getchanlist_wrapperTest.m", "test_pass_no_match") +def test_getchanlist_current_suite_no_match(): + assert getchanlist(_typed_locations(), "a") == [] + + +@eeglab_test("unittesting_popfunc/getchanlist/popfunc_getchanlist_wrapperTest.m", "test_pass_one_arg") +def test_getchanlist_current_suite_default_all_channels(): + assert getchanlist(_typed_locations()) == [0, 1, 2, 3, 4] diff --git a/tests/test_sample_data_pop_functions.py b/tests/test_sample_data_pop_functions.py index 43dada41..d749f986 100644 --- a/tests/test_sample_data_pop_functions.py +++ b/tests/test_sample_data_pop_functions.py @@ -93,6 +93,7 @@ def sample_eeg_with_ica(sample_eeg_with_ica_base): return copy.deepcopy(sample_eeg_with_ica_base) +@eeglab_test("eeglab_tests_wrapperTest.m", "test_readcontsamplefile") def test_pop_loadset_loads_eeglab_sample_data_with_core_fields(sample_eeg_base): assert sample_eeg_base["data"].shape == (32, 30504) assert sample_eeg_base["nbchan"] == 32 @@ -105,6 +106,7 @@ def test_pop_loadset_loads_eeglab_sample_data_with_core_fields(sample_eeg_base): assert np.issubdtype(np.asarray(sample_eeg_base["icachansind"]).dtype, np.integer) +@eeglab_test("eeglab_tests_wrapperTest.m", "test_readepochsamplefile") @eeglab_test("unittesting_popfunc/pop_loadset/popfunc_pop_loadset_wrapperTest.m", "test_test_pop_loadset") def test_pop_loadset_current_suite_info_channel_and_eeg_modes(): path = Path("sample_data/eeglab_data_epochs_ica.set") From 7cbec7e6546cc39bb3aa959d72f638b23bb53b0b Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:48 -0700 Subject: [PATCH 15/45] Port current dataset workflow tests (#343) * Port current dataset workflow tests * Port current pop_eegfilt test --- docs/source/api/interactive_pop_workflows.rst | 6 +- src/eegprep/__init__.py | 1 + src/eegprep/functions/popfunc/importevent.py | 28 + src/eegprep/functions/popfunc/pop_chanedit.py | 9 +- .../functions/popfunc/pop_importevent.py | 41 +- src/eegprep/functions/popfunc/pop_newset.py | 18 +- .../functions/popfunc/pop_selectevent.py | 12 +- src/eegprep/resources/help/pop_chanedit.md | 3 +- src/eegprep/resources/help/pop_newset.md | 4 + src/eegprep/resources/help/pop_selectevent.md | 3 + tests/test_popfunc_dataset_eeglab_tests.py | 502 ++++++++++++++++++ 11 files changed, 602 insertions(+), 25 deletions(-) create mode 100644 src/eegprep/functions/popfunc/importevent.py create mode 100644 tests/test_popfunc_dataset_eeglab_tests.py diff --git a/docs/source/api/interactive_pop_workflows.rst b/docs/source/api/interactive_pop_workflows.rst index 232b8e0f..8ae6a635 100644 --- a/docs/source/api/interactive_pop_workflows.rst +++ b/docs/source/api/interactive_pop_workflows.rst @@ -25,6 +25,7 @@ accepts EEGLAB's positional event-type, time, and duration field names. eegprep.pop_expevents eegprep.pop_export eegprep.pop_exportbids + eegprep.importevent eegprep.pop_importdata eegprep.pop_importepoch eegprep.pop_importevent @@ -39,7 +40,10 @@ accepts EEGLAB's positional event-type, time, and duration field names. Event and Epoch Tables ---------------------- -``pop_importevent`` accepts text files or record sequences. Imported latencies +``importevent`` converts standalone event tables into event dictionaries using +the same field, delimiter, time-unit, alignment, and append rules as +``pop_importevent``. ``pop_importevent`` accepts text files or record sequences +and stores the result on an EEG dataset. Imported latencies use seconds by default, ``timeunit=1e-3`` selects milliseconds, and ``timeunit=numpy.nan`` selects sample positions. Existing events are appended unless ``append="no"`` is supplied. Alignment can anchor imported rows to the diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 396d5426..9941b916 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -153,6 +153,7 @@ "iclabel": ("eegprep.plugins.ICLabel.iclabel", "iclabel"), "eeg_icflag": ("eegprep.plugins.ICLabel.eeg_icflag", "eeg_icflag"), "inputdlg2": ("eegprep.functions.guifunc.inputdlg2", "inputdlg2"), + "importevent": ("eegprep.functions.popfunc.importevent", "importevent"), "inputgui": ("eegprep.functions.guifunc.inputgui", "inputgui"), "interp": ("eegprep.functions.redefine_functions", "interp"), "jointprob": ("eegprep.functions.sigprocfunc.jointprob", "jointprob"), diff --git a/src/eegprep/functions/popfunc/importevent.py b/src/eegprep/functions/popfunc/importevent.py new file mode 100644 index 00000000..9f101e9c --- /dev/null +++ b/src/eegprep/functions/popfunc/importevent.py @@ -0,0 +1,28 @@ +"""Import EEGLAB-compatible event tables without an EEG dataset wrapper.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.popfunc._pop_utils import parse_key_value_args +from eegprep.functions.popfunc.pop_importevent import _import_event_records + + +def importevent( + event: Any, + oldevent: Any = None, + srate: float = 1.0, + *args: Any, + **kwargs: Any, +) -> list[dict[str, Any]]: + """Import event records from a text table or in-memory rows. + + Latencies use seconds by default and are converted to EEGLAB's 1-based + sample positions. Pass ``timeunit=float("nan")`` when input latencies are + already sample positions. Explicit event ``indices`` are 1-based. + """ + options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) + return _import_event_records(options.get("event", event), oldevent, float(srate), options) + + +__all__ = ["importevent"] diff --git a/src/eegprep/functions/popfunc/pop_chanedit.py b/src/eegprep/functions/popfunc/pop_chanedit.py index 68bcbf22..de7e5cd5 100644 --- a/src/eegprep/functions/popfunc/pop_chanedit.py +++ b/src/eegprep/functions/popfunc/pop_chanedit.py @@ -271,6 +271,9 @@ def _apply_chanedit( elif key == "headrad": for chan in chanlocs: chan["sph_radius"] = float(value) + elif key == "shrink": + if chanlocs: + chanlocs[0]["shrink"] = float(value) elif key == "settype": indices, chan_type = _index_value_args(value, len(chanlocs)) for index in indices: @@ -358,8 +361,10 @@ def _cart_to_all(chan: dict[str, Any]) -> None: def _read_chanloc_file(value: Any) -> list[dict[str, Any]]: - path = Path(value[0] if isinstance(value, (list, tuple)) else value) - return readlocs(path) + if isinstance(value, (list, tuple)): + path = Path(value[0]) + return readlocs(path, *value[1:]) + return readlocs(Path(value)) def _write_chanloc_file(value: Any, chanlocs: list[dict[str, Any]]) -> None: diff --git a/src/eegprep/functions/popfunc/pop_importevent.py b/src/eegprep/functions/popfunc/pop_importevent.py index 702bd730..fd643dfa 100644 --- a/src/eegprep/functions/popfunc/pop_importevent.py +++ b/src/eegprep/functions/popfunc/pop_importevent.py @@ -35,9 +35,31 @@ def pop_importevent( event_source = options.get("event", options.get("filename")) if event_source is None: raise ValueError("pop_importevent requires an event file or event records") + old_events = events_to_records(EEG.get("event")) + events = _import_event_records(event_source, old_events, float(EEG.get("srate", 1) or 1), options) + + out = deepcopy(EEG) + out["event"] = events + out["saved"] = "no" + with strict_mode(False): + out = eeg_checkset(out, "eventconsistency") + out["event"], out["urevent"] = _rebuild_urevents(events_to_records(out["event"])) + with strict_mode(False): + out = eeg_checkset(out) + command = _history_command(event_source, options) + out["history"] = _append_history(out.get("history", ""), command) + return (out, command) if return_com else out + + +def _import_event_records( + event_source: Any, + oldevent: Any, + srate: float, + options: dict[str, Any], +) -> list[dict[str, Any]]: fields = _fields(options.get("fields")) records = _load_records(event_source, fields, options) - old_events = events_to_records(EEG.get("event")) + old_events = events_to_records(oldevent) indices = _event_indices(options.get("indices"), len(old_events)) append = _is_on(options.get("append", "yes")) or bool(indices) latency_present = any("latency" in record for record in records) @@ -48,32 +70,21 @@ def pop_importevent( if not math.isnan(align): _validate_alignment(align, old_events, len(records)) if append: - logger.warning("pop_importevent: alignment and append were both requested; applying EEGLAB behavior") + logger.warning("importevent: alignment and append were both requested; applying EEGLAB behavior") events, imported_indices = _merge_records(old_events, records, append=append, indices=indices) _recompute_latencies( events, imported_indices, old_events, - srate=float(EEG.get("srate", 1) or 1), + srate=float(srate), timeunit=_timeunit(options), align=align, optimalign=_is_on(options.get("optimalign", "on")), optimoffset=_is_on(options.get("optimoffset", "off")), optimmeas=str(options.get("optimmeas", "mean")).lower(), ) - - out = deepcopy(EEG) - out["event"] = events - out["saved"] = "no" - with strict_mode(False): - out = eeg_checkset(out, "eventconsistency") - out["event"], out["urevent"] = _rebuild_urevents(events_to_records(out["event"])) - with strict_mode(False): - out = eeg_checkset(out) - command = _history_command(event_source, options) - out["history"] = _append_history(out.get("history", ""), command) - return (out, command) if return_com else out + return events def _load_records(event_source: Any, fields: list[str] | None, options: dict[str, Any]) -> list[dict[str, Any]]: diff --git a/src/eegprep/functions/popfunc/pop_newset.py b/src/eegprep/functions/popfunc/pop_newset.py index 47837f19..30afe8c4 100644 --- a/src/eegprep/functions/popfunc/pop_newset.py +++ b/src/eegprep/functions/popfunc/pop_newset.py @@ -55,7 +55,7 @@ def pop_newset( eeg_to_store = _apply_dataset_metadata(EEG, options) if _should_save(options.get("saveold", False)) and CURRENTSET: - _save_existing_datasets(alleeg, CURRENTSET) + _save_existing_datasets(alleeg, CURRENTSET, options.get("saveold")) saved_new = _should_save(options.get("savenew", False)) if saved_new: _save_new_dataset(eeg_to_store, options.get("savenew")) @@ -196,13 +196,21 @@ def _first_currentset(CURRENTSET: int | list[int] | tuple[int, ...] | None) -> i return int(CURRENTSET) if CURRENTSET else None -def _save_existing_datasets(ALLEEG: list[dict[str, Any]], CURRENTSET: Any) -> None: +def _save_existing_datasets(ALLEEG: list[dict[str, Any]], CURRENTSET: Any, target: Any) -> None: indices = list(CURRENTSET) if isinstance(CURRENTSET, (list, tuple)) else [CURRENTSET] + explicit_target = isinstance(target, (str, Path)) and str(target).strip().lower() not in { + "", + "on", + "yes", + "true", + } + if explicit_target and len(indices) != 1: + raise ValueError("saveold with multiple datasets requires each dataset to have filename metadata") for index in indices: if not index: continue dataset = ALLEEG[int(index) - 1] - filename = _dataset_filename(dataset) + filename = str(target) if explicit_target else _dataset_filename(dataset) if filename is None: raise ValueError("saveold requires the existing dataset to have filename and filepath") pop_saveset(dataset, filename) @@ -211,8 +219,8 @@ def _save_existing_datasets(ALLEEG: list[dict[str, Any]], CURRENTSET: Any) -> No def _save_new_dataset(EEG: dict[str, Any] | list[dict[str, Any]], target: Any) -> None: if isinstance(EEG, list): raise ValueError("savenew for multiple datasets requires explicit per-dataset paths") - if isinstance(target, str) and target.lower() not in {"on", "yes", "true"}: - filename = target + if isinstance(target, (str, Path)) and str(target).lower() not in {"on", "yes", "true"}: + filename = str(target) else: filename = _dataset_filename(EEG) if filename is None: diff --git a/src/eegprep/functions/popfunc/pop_selectevent.py b/src/eegprep/functions/popfunc/pop_selectevent.py index a284f39a..31ee269d 100644 --- a/src/eegprep/functions/popfunc/pop_selectevent.py +++ b/src/eegprep/functions/popfunc/pop_selectevent.py @@ -306,10 +306,20 @@ def _matching_indices( def _comparison_value(EEG: dict[str, Any], event: dict[str, Any], field: str) -> float: value = float(event.get(field, np.nan)) if field == "latency": + if int(EEG.get("trials", 1) or 1) > 1: + return float( + eeg_point2lat( + value, + event.get("epoch", 1), + float(EEG.get("srate", 1)), + [float(EEG.get("xmin", 0)) * 1000, float(EEG.get("xmax", 0)) * 1000], + 1e-3, + )[0] + ) return float( eeg_point2lat( value, event.get("epoch", 1), float(EEG.get("srate", 1)), [EEG.get("xmin", 0), EEG.get("xmax", 0)] - ) + )[0] ) if field == "duration": scale = float(EEG.get("srate", 1)) / (1000 if int(EEG.get("trials", 1) or 1) > 1 else 1) diff --git a/src/eegprep/resources/help/pop_chanedit.md b/src/eegprep/resources/help/pop_chanedit.md index f6e044f0..654d5387 100644 --- a/src/eegprep/resources/help/pop_chanedit.md +++ b/src/eegprep/resources/help/pop_chanedit.md @@ -6,7 +6,8 @@ Use `pop_chanedit(EEG, "changefield", [index, field, value])` to edit a single channel using EEGLAB-facing 1-based channel indices. The EEGPrep port also supports basic channel insertion, deletion, channel-location file load/save, and coordinate conversion between Cartesian, spherical, and -topographic fields. +topographic fields. The `shrink` option records EEGLAB's display-only +topographic shrink factor on the first channel-location record. The GUI presents the first channel in an EEGLAB-style channel editor. Rich channel-table navigation is intentionally limited in this phase; command-line diff --git a/src/eegprep/resources/help/pop_newset.md b/src/eegprep/resources/help/pop_newset.md index a339cf42..45419d04 100644 --- a/src/eegprep/resources/help/pop_newset.md +++ b/src/eegprep/resources/help/pop_newset.md @@ -19,5 +19,9 @@ ALLEEG, EEG, CURRENTSET, LASTCOM = pop_newset(ALLEEG, EEG, CURRENTSET, "setname" ALLEEG, EEG, CURRENTSET, LASTCOM = pop_newset(ALLEEG, EEG, CURRENTSET, "retrieve", 2) ``` +`saveold` and `savenew` accept explicit `.set` file paths. With +`saveold="on"`, the current dataset's `filename` and `filepath` metadata +select the destination. + EEGPrep uses EEGLAB-facing 1-based dataset indices for `CURRENTSET`. Python array indices inside EEG data remain 0-based. diff --git a/src/eegprep/resources/help/pop_selectevent.md b/src/eegprep/resources/help/pop_selectevent.md index b659552b..99ddf828 100644 --- a/src/eegprep/resources/help/pop_selectevent.md +++ b/src/eegprep/resources/help/pop_selectevent.md @@ -7,6 +7,9 @@ custom fields. Event indices are 1-based. Continuous data keeps boundary events when deleting non-selected events, matching EEGLAB's expectation that boundaries preserve discontinuity information. +Latency ranges are expressed in seconds for continuous data and milliseconds +relative to epoch onset for epoched data. + Epoch selection raises when every epoch would be removed. Pass `erroronempty="off"` to allow an empty result instead; the returned EEG then has zero trials, empty data, and no events. diff --git a/tests/test_popfunc_dataset_eeglab_tests.py b/tests/test_popfunc_dataset_eeglab_tests.py new file mode 100644 index 00000000..f4080dcf --- /dev/null +++ b/tests/test_popfunc_dataset_eeglab_tests.py @@ -0,0 +1,502 @@ +"""Behavioral ports of current EEGLAB dataset-workflow wrapper tests.""" + +from __future__ import annotations + +from copy import deepcopy + +import matplotlib +import numpy as np +import pytest + +matplotlib.use("Agg") +from matplotlib import pyplot as plt + +from eegprep.functions.adminfunc.pop_delset import pop_delset +from eegprep.functions.popfunc.eeg_emptyset import eeg_emptyset +from eegprep.functions.popfunc.importevent import importevent +from eegprep.functions.popfunc.pop_chanedit import pop_chanedit +from eegprep.functions.popfunc.pop_editset import pop_editset +from eegprep.functions.popfunc.pop_eegfilt import pop_eegfilt +from eegprep.functions.popfunc.pop_epoch import pop_epoch +from eegprep.functions.popfunc.pop_eventstat import event_values, pop_eventstat +from eegprep.functions.popfunc.pop_mergeset import pop_mergeset +from eegprep.functions.popfunc.pop_newset import pop_newset +from eegprep.functions.popfunc.pop_rmdat import pop_rmdat +from eegprep.functions.popfunc.pop_runica import pop_runica +from eegprep.functions.popfunc.pop_selectevent import pop_selectevent +from eegprep.functions.popfunc.pop_signalstat import pop_signalstat +from eegprep.functions.popfunc.pop_subcomp import pop_subcomp +from tests.eeglab_tests import eeglab_test + + +def _continuous_eeg(name: str = "continuous", *, nbchan: int = 4, pnts: int = 240) -> dict: + srate = 100.0 + samples = np.arange(pnts, dtype=float) + data = np.vstack( + [np.sin(2 * np.pi * (channel + 1) * samples / 53) + channel + samples / pnts for channel in range(nbchan)] + ) + latencies = [31.0, 81.0, 151.0, 211.0] + types = ["square", "rt", "square", "rt"] + events = [ + {"type": event_type, "latency": latency, "position": index % 2 + 1, "urevent": index} + for index, (event_type, latency) in enumerate(zip(types, latencies)) + ] + chanlocs = [ + { + "labels": f"Ch{index + 1}", + "theta": float(index * 90), + "radius": 0.3, + "X": float(np.cos(index * np.pi / 2)), + "Y": float(np.sin(index * np.pi / 2)), + "Z": 0.0, + "type": "EEG", + } + for index in range(nbchan) + ] + return { + "setname": name, + "filename": "", + "filepath": "", + "subject": "S01", + "condition": "rest", + "group": "control", + "session": 1, + "run": 1, + "comments": "", + "data": data, + "nbchan": nbchan, + "pnts": pnts, + "trials": 1, + "srate": srate, + "xmin": 0.0, + "xmax": (pnts - 1) / srate, + "times": samples / srate * 1000, + "ref": "common", + "chanlocs": chanlocs, + "urchanlocs": deepcopy(chanlocs), + "chaninfo": {}, + "event": events, + "urevent": [{key: value for key, value in event.items() if key != "urevent"} for event in events], + "epoch": [], + "eventdescription": {}, + "epochdescription": {}, + "reject": {}, + "stats": {}, + "specdata": {}, + "specicaact": {}, + "icaweights": np.eye(nbchan), + "icasphere": np.eye(nbchan), + "icawinv": np.eye(nbchan), + "icaact": data.copy(), + "icachansind": np.arange(nbchan), + "history": "", + "saved": "no", + "etc": {}, + } + + +def _epoched_eeg(name: str = "epoched") -> dict: + eeg = _continuous_eeg(name, pnts=200) + eeg["data"] = np.asarray(eeg["data"]).reshape(4, 50, 4, order="F") + eeg["pnts"] = 50 + eeg["trials"] = 4 + eeg["xmin"] = -0.1 + eeg["xmax"] = 0.39 + eeg["times"] = np.linspace(-100, 390, 50) + eeg["event"] = [] + eeg["urevent"] = [] + eeg["epoch"] = [] + for trial in range(1, 5): + event = { + "type": "square" if trial % 2 else "rt", + "latency": (trial - 1) * 50 + 11.0, + "epoch": trial, + "position": 1 if trial in {1, 3} else 2, + "urevent": trial - 1, + } + eeg["event"].append(event) + eeg["urevent"].append({key: value for key, value in event.items() if key not in {"epoch", "urevent"}}) + eeg["epoch"].append({"condition": "odd" if trial % 2 else "even"}) + eeg["icaact"] = eeg["data"].copy() + return eeg + + +@eeglab_test("unittesting_popfunc/importevent/popfunc_importevent_wrapperTest.m", "test_pass_general") +def test_current_importevent_reads_named_fields_and_skips_header(tmp_path): + event_file = tmp_path / "testevent.txt" + event_file.write_text("type latency code\ntest 400 3\n", encoding="utf-8") + + events = importevent( + event_file, + [], + 250, + "fields", + ["type", "latency", "code"], + "skipline", + 1, + ) + + assert events == [{"type": "test", "latency": 100001.0, "code": 3, "init_index": 1, "init_time": 400.0}] + + +@eeglab_test("unittesting_popfunc/importevent/popfunc_importevent_wrapperTest.m", "test_test_latency") +def test_current_importevent_seconds_and_sample_positions_have_identical_boundaries(): + seconds = importevent( + [[0, "Experiment begins"], [49, "Experiment ends"]], + [], + 1, + "fields", + ["latency", "type"], + "timeunit", + 1, + ) + samples = importevent( + [[1, "Experiment begins"], [50, "Experiment ends"]], + [], + 1, + "fields", + ["latency", "type"], + "timeunit", + np.nan, + ) + + assert [event["latency"] for event in seconds] == [1.0, 50.0] + assert [event["latency"] for event in samples] == [1.0, 50.0] + + +@eeglab_test("unittesting_popfunc/pop_chanedit/popfunc_pop_chanedit_wrapperTest.m", "test_test_pop_chanedit") +def test_current_pop_chanedit_loads_locations_and_records_display_shrink(tmp_path): + location_file = tmp_path / "eeglab_chan32.locs" + location_file.write_text("1 0 0.25 Fz\n2 180 0.3 Pz\n", encoding="utf-8") + + chanlocs = pop_chanedit([], "load", [location_file, "filetype", ""], "shrink", -0.1) + + assert [channel["labels"] for channel in chanlocs] == ["Fz", "Pz"] + assert chanlocs[0]["shrink"] == pytest.approx(-0.1) + assert chanlocs[0]["radius"] == pytest.approx(0.25) + + +@eeglab_test("unittesting_popfunc/pop_delset/popfunc_pop_delset_wrapperTest.m", "test_test_pop_delset") +def test_current_pop_delset_blanks_selected_dataset_without_renumbering_following_sets(): + first = _continuous_eeg("first") + second = _continuous_eeg("second") + + alleeg, command = pop_delset([first, second], [1]) + + assert alleeg[0] == {} + assert alleeg[1]["setname"] == "second" + assert command == "ALLEEG = pop_delset( ALLEEG, [1] );" + + +@eeglab_test("unittesting_popfunc/pop_editset/popfunc_pop_editset_wrapperTest.m", "test_test_pop_editset") +def test_current_pop_editset_builds_a_consistent_epoched_dataset_from_arrays(): + data = np.arange(72, dtype=float).reshape(3, 24) + chanlocs = [{"labels": "Fz"}, {"labels": "Cz"}, {"labels": "Pz"}] + + output = pop_editset( + eeg_emptyset(), + "setname", + "UnitTesting", + "data", + data, + "dataformat", + "array", + "subject", + "S01", + "condition", + "testing", + "group", + "control", + "session", + 1, + "chanlocs", + chanlocs, + "pnts", + 8, + "srate", + 200, + "xmin", + -0.1, + "ref", + "common", + "icaweights", + np.eye(3), + "icasphere", + np.eye(3), + "comments", + "fully exercised replacement for the commented MATLAB test", + ) + + assert output["data"].shape == (3, 8, 3) + assert (output["nbchan"], output["pnts"], output["trials"]) == (3, 8, 3) + assert output["times"][[0, -1]].tolist() == pytest.approx([-100.0, -65.0]) + assert output["icaact"].shape == (3, 8, 3) + assert [channel["labels"] for channel in output["chanlocs"]] == ["Fz", "Cz", "Pz"] + + +@eeglab_test("unittesting_popfunc/pop_eegfilt/popfunc_pop_eegfilt_wrapperTest.m", "test_test_pop_eegfilt") +def test_current_pop_eegfilt_default_highpass_attenuates_sub_cutoff_signal(): + srate = 100.0 + pnts = 4000 + time = np.arange(pnts) / srate + below_cutoff = 2 * np.sin(2 * np.pi * 0.2 * time) + passband = np.sin(2 * np.pi * 10 * time) + eeg = _continuous_eeg(pnts=pnts) + eeg["data"] = np.vstack([below_cutoff + passband, 0.5 * below_cutoff + 2 * passband, below_cutoff, passband]) + eeg["icaact"] = np.ones_like(eeg["data"]) + input_data = eeg["data"].copy() + input_events = deepcopy(eeg["event"]) + + output, command = pop_eegfilt(eeg, 1, 0, [], [0], return_com=True) + + frequencies = np.fft.rfftfreq(pnts, 1 / srate) + low_bin = int(np.argmin(np.abs(frequencies - 0.2))) + pass_bin = int(np.argmin(np.abs(frequencies - 10))) + input_spectrum = np.abs(np.fft.rfft(input_data[0])) + output_spectrum = np.abs(np.fft.rfft(output["data"][0])) + assert output_spectrum[low_bin] < input_spectrum[low_bin] * 0.01 + assert output_spectrum[pass_bin] > input_spectrum[pass_bin] * 0.95 + assert output["data"].shape == input_data.shape + assert output["event"] == input_events + assert output["icaact"].size == 0 + assert output["saved"] == "no" + np.testing.assert_array_equal(eeg["data"], input_data) + assert command == "EEG = pop_eegfilt( EEG, 1, 0, [], [0], 0, 0, 'firls', 0);" + + +@eeglab_test("unittesting_popfunc/pop_eventstat/popfunc_pop_eventstat_wrapperTest.m", "test_test_pop_eventstat") +def test_current_pop_eventstat_filters_type_and_epoch_relative_latency_numerically(): + continuous = _continuous_eeg() + epoched = _epoched_eeg() + + all_result = pop_eventstat(continuous, "latency", "", [], 5, plot="off") + typed_result = pop_eventstat(continuous, "latency", "rt", [500, 2200], 80, plot="off") + epoch_result = pop_eventstat(epoched, "latency", "square", [-1, 1], 5, plot="off") + + assert all_result.mean == pytest.approx(np.mean([31, 81, 151, 211])) + assert typed_result.mean == pytest.approx(np.mean([81, 211])) + np.testing.assert_array_equal(event_values(epoched, "latency", type="square", latrange=[-1, 1]), [11, 111]) + assert epoch_result.mean == pytest.approx(61.0) + assert typed_result.trimmed_indices.size == 0 + assert np.isnan(typed_result.trimmed_mean) + plt.close(all_result.figure) + plt.close(typed_result.figure) + plt.close(epoch_result.figure) + + +@eeglab_test("unittesting_popfunc/pop_mergeset/popfunc_pop_mergeset_wrapperTest.m", "test_test_pop_mergeset") +def test_current_pop_mergeset_handles_continuous_epoched_list_and_pair_forms(): + continuous = [_continuous_eeg(f"continuous-{index}") for index in range(3)] + merged = pop_mergeset(continuous, [1, 2], 0) + kept_ica = pop_mergeset(continuous, [1, 2], 1) + direct = pop_mergeset(continuous[0], continuous[1], 0) + merged_three = pop_mergeset(continuous, [1, 2, 3], 0) + + assert merged["data"].shape == direct["data"].shape == (4, 480) + assert merged_three["data"].shape == (4, 720) + assert sum(event["type"] == "boundary" for event in merged_three["event"]) == 2 + assert merged["icaweights"].size == 0 + assert kept_ica["icaweights"].shape == (4, 4) + np.testing.assert_allclose( + kept_ica["icaweights"] @ kept_ica["icasphere"] @ kept_ica["icawinv"], + np.eye(4), + ) + + epoched = [_epoched_eeg(f"epoched-{index}") for index in range(3)] + merged_epochs = pop_mergeset(epoched, [1, 2], 0) + kept_epoch_ica = pop_mergeset(epoched, [1, 2], 1) + direct_epochs = pop_mergeset(epoched[0], epoched[1], 0) + merged_three_epochs = pop_mergeset(epoched, [1, 2, 3], 0) + + assert merged_epochs["data"].shape == direct_epochs["data"].shape == (4, 50, 8) + assert merged_three_epochs["data"].shape == (4, 50, 12) + assert merged_epochs["trials"] == 8 + assert [event["epoch"] for event in merged_epochs["event"]] == list(range(1, 9)) + assert kept_epoch_ica["icaweights"].shape == (4, 4) + np.testing.assert_allclose( + kept_epoch_ica["icaweights"] @ kept_epoch_ica["icasphere"] @ kept_epoch_ica["icawinv"], + np.eye(4), + ) + + +@eeglab_test("unittesting_popfunc/pop_newset/popfunc_pop_newset_wrapperTest.m", "test_test_pop_newset") +def test_current_pop_newset_overwrites_appends_saves_and_retrieves(tmp_path): + original = _epoched_eeg("base") + alleeg = [deepcopy(original) for _index in range(8)] + + alleeg, current, current_set, _ = pop_newset( + alleeg, deepcopy(original), 1, "setname", "origin", "comments", "no change", "overwrite", "on" + ) + assert current_set == 1 + assert alleeg[0]["setname"] == current["setname"] == "origin" + + alleeg, current, current_set, _ = pop_newset( + alleeg, deepcopy(alleeg[7]), 8, "setname", "new", "comments", "change", "overwrite", "off" + ) + assert current_set == 9 + assert len(alleeg) == 9 + + alleeg, current, current_set, _ = pop_newset( + alleeg, deepcopy(alleeg[7]), 8, "setname", "replacement", "overwrite", "on" + ) + assert current_set == 8 + assert alleeg[7]["setname"] == "replacement" + + old_file = tmp_path / "old.set" + new_file = tmp_path / "new.set" + alleeg, current, current_set, _ = pop_newset( + alleeg, + deepcopy(alleeg[1]), + 2, + "setname", + "saved replacement", + "overwrite", + "on", + "saveold", + old_file, + "savenew", + new_file, + ) + assert old_file.exists() and new_file.exists() + assert current["saved"] == "yes" + + _alleeg, retrieved, retrieved_set, _ = pop_newset(alleeg, current, current_set, "retrieve", 1) + assert retrieved_set == 1 + assert retrieved["setname"] == "origin" + + +@eeglab_test("unittesting_popfunc/pop_rmdat/popfunc_pop_rmdat_wrapperTest.m", "test_test_pop_rmdat") +def test_current_pop_rmdat_keeps_and_removes_each_requested_event_window(): + eeg = _continuous_eeg(pnts=240) + + keep_rt = pop_rmdat(eeg, ["rt"], [-0.1, 0.2], 0) + remove_rt = pop_rmdat(eeg, ["rt"], [-0.1, 0.2], 1) + keep_square = pop_rmdat(eeg, ["square"], [-0.1, 0.2], 0) + keep_clipped = pop_rmdat(eeg, ["rt"], [-10, 200], 0) + keep_both = pop_rmdat(eeg, ["rt", "square"], [-0.1, 0.2], 0) + + assert 0 < keep_rt["pnts"] < eeg["pnts"] + assert remove_rt["pnts"] == eeg["pnts"] - keep_rt["pnts"] + assert keep_square["pnts"] == keep_rt["pnts"] + assert keep_clipped["pnts"] == eeg["pnts"] + assert keep_both["pnts"] > keep_rt["pnts"] + + +@eeglab_test("unittesting_popfunc/pop_runica/popfunc_pop_runica_wrapperTest.m", "test_test_pop_runica") +def test_current_pop_runica_extended_pca_produces_a_valid_reduced_decomposition(): + rng = np.random.default_rng(17) + sources = np.vstack( + [ + rng.laplace(size=600), + rng.uniform(-1, 1, size=600), + np.sin(np.linspace(0, 35, 600)), + np.sign(np.sin(np.linspace(0, 21, 600))), + ] + ) + mixing = rng.normal(size=(6, 4)) + eeg = _continuous_eeg(nbchan=6, pnts=600) + with np.errstate(all="ignore"): + eeg["data"] = mixing @ sources + eeg["icaweights"] = np.array([]) + eeg["icasphere"] = np.array([]) + eeg["icawinv"] = np.array([]) + eeg["icaact"] = np.array([]) + eeg["icachansind"] = np.array([], dtype=int) + + output = pop_runica(eeg, "icatype", "runica", "extended", 1, "pca", 4, "maxsteps", 96, "seed", 7) + + assert output["icaweights"].shape == (4, 6) + assert output["icasphere"].shape == (6, 6) + assert output["icawinv"].shape == (6, 4) + assert output["icaact"].shape == (4, 600, 1) + with np.errstate(all="ignore"): + expected_activations = output["icaweights"] @ output["icasphere"] @ output["data"] + np.testing.assert_allclose(expected_activations, output["icaact"].reshape(4, 600), rtol=1e-8, atol=1e-8) + assert output["icachansind"].tolist() == list(range(6)) + + +def _overlapping_epoch_eeg() -> dict: + eeg = eeg_emptyset() + eeg.update( + { + "srate": 500.0, + "nbchan": 1, + "data": np.zeros((1, 2000)), + "pnts": 2000, + "trials": 1, + "xmin": 0.0, + "xmax": 3.998, + "times": np.arange(2000) / 500 * 1000, + "event": [ + {"type": "1", "latency": 201.0, "urevent": 0}, + {"type": "2", "latency": 501.0, "urevent": 1}, + ], + "urevent": [{"type": "1", "latency": 201.0}, {"type": "2", "latency": 501.0}], + } + ) + epoched, _indices = pop_epoch(eeg, ["1", "2"], [-0.2, 1]) + return epoched + + +@eeglab_test( + "unittesting_popfunc/pop_selectevent/popfunc_pop_selectevent_wrapperTest.m", + "test_demo_selectevent_glitch", +) +def test_current_pop_selectevent_handles_overlapping_epochs_and_millisecond_ranges(): + eeg = _overlapping_epoch_eeg() + + latency_selected, _ = pop_selectevent(eeg, "type", "2", "deleteepochs", "on", "latency", "-10<=10") + all_type_two, _ = pop_selectevent(eeg, "type", "2", "deleteepochs", "on") + only_type_two, _ = pop_selectevent(eeg, "type", "2", "deleteepochs", "on", "deleteevents", "on") + only_type_one, _ = pop_selectevent(eeg, "type", "1", "deleteepochs", "on", "deleteevents", "on") + + assert len(latency_selected["event"]) == 1 + assert len(all_type_two["event"]) == 3 + assert len(only_type_two["event"]) == 2 + assert len(only_type_one["event"]) == 1 + + +@eeglab_test("unittesting_popfunc/pop_selectevent/popfunc_pop_selectevent_wrapperTest.m", "test_test_pop_selectevent") +def test_current_pop_selectevent_selects_epochs_using_a_custom_event_field(): + selected, event_indices = pop_selectevent( + _epoched_eeg(), "position", 1, "deleteevents", "off", "deleteepochs", "on" + ) + + assert selected["trials"] == 2 + assert event_indices == [1, 3] + assert [event["position"] for event in selected["event"]] == [1, 1] + np.testing.assert_array_equal(selected["data"], _epoched_eeg()["data"][:, :, [0, 2]]) + + +@eeglab_test("unittesting_popfunc/pop_signalstat/popfunc_pop_signalstat_wrapperTest.m", "test_test_pop_signalstat") +def test_current_pop_signalstat_reports_raw_and_component_statistics_at_multiple_trim_levels(): + eeg = _epoched_eeg() + data = np.asarray(eeg["data"]) + + raw = pop_signalstat(eeg, 1, 2, 5, plot="off") + component = pop_signalstat(eeg, 0, 2, 5, plot="off") + heavily_trimmed = pop_signalstat(eeg, 1, 2, 50, plot="off") + lightly_trimmed = pop_signalstat(eeg, 0, 2, 0.5, plot="off") + + assert raw.mean == pytest.approx(float(np.mean(data[1]))) + assert component.mean == pytest.approx(raw.mean) + assert heavily_trimmed.trimmed_indices.size < raw.trimmed_indices.size + assert lightly_trimmed.trimmed_indices.size >= component.trimmed_indices.size + for result in (raw, component, heavily_trimmed, lightly_trimmed): + assert np.isfinite(result.matlab_tuple()[:9]).all() + plt.close(result.figure) + + +@eeglab_test("unittesting_popfunc/pop_subcomp/popfunc_pop_subcomp_wrapperTest.m", "test_test_pop_subcomp") +def test_current_pop_subcomp_removes_component_three_from_data_and_ica_fields(): + eeg = _epoched_eeg() + original = np.asarray(eeg["data"]).copy() + + output = pop_subcomp(eeg, [3], 0) + + np.testing.assert_allclose(output["data"][[0, 1, 3]], original[[0, 1, 3]]) + np.testing.assert_allclose(output["data"][2], 0) + assert output["icaweights"].shape == (3, 4) + assert output["icawinv"].shape == (4, 3) + assert output["icaact"].size == 0 + assert output["setname"].endswith("pruned with ICA") From e88cb9bb7581dafcbd8857d1743ce827518df155 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:48 -0700 Subject: [PATCH 16/45] Port current EEGLAB GUI helper tests (#344) --- docs/source/api/gui_and_console.rst | 8 +- docs/source/changelog.rst | 5 + src/eegprep/__init__.py | 1 + src/eegprep/functions/guifunc/__init__.py | 1 + src/eegprep/functions/guifunc/inputgui.py | 51 +++++- src/eegprep/functions/guifunc/listdlg2.py | 38 +++-- src/eegprep/functions/guifunc/qt.py | 15 +- src/eegprep/functions/guifunc/supergui.py | 31 ++++ tests/test_finputcheck.py | 9 + tests/test_guifunc_pophelp_chansel.py | 1 + tests/test_guifunc_primitives.py | 190 +++++++++++++++++++++- tests/test_package_exports.py | 1 + 12 files changed, 322 insertions(+), 29 deletions(-) create mode 100644 src/eegprep/functions/guifunc/supergui.py diff --git a/docs/source/api/gui_and_console.rst b/docs/source/api/gui_and_console.rst index 566ab7b4..979c991c 100644 --- a/docs/source/api/gui_and_console.rst +++ b/docs/source/api/gui_and_console.rst @@ -6,8 +6,11 @@ GUI and Console Qt dialogs, console workspace objects, and the entry points that open the graphical interface. ``listdlg2`` accepts either a sequence of labels or one scalar string. A scalar -string is displayed as one choice, and returned selections use EEGLAB-facing -1-based positions. +string is displayed as one choice, returned selections use EEGLAB-facing +1-based positions, and ``okstring`` and ``cancelstring`` customize the action +labels. ``inputgui(..., mode="plot")`` opens a nonmodal preview and returns the +application, dialog, and tagged widget mapping. ``supergui`` exposes the same +renderer-level construction without showing or executing the dialog. .. autosummary:: :toctree: generated/ @@ -32,3 +35,4 @@ string is displayed as one choice, and returned selections use EEGLAB-facing eegprep.plugin_status eegprep.pophelp eegprep.select_multiple_datasets + eegprep.supergui diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 13add2f2..b26f1fc0 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,11 @@ the `GitHub Releases `_ page. Unreleased ========== +- The current EEGLAB GUI-helper tests now have deterministic Python ports. + ``supergui`` builds declarative dialogs without entering a modal loop, + ``inputgui`` supports a non-blocking ``mode="plot"`` workflow and renders + radio buttons, and ``listdlg2`` accepts multi-line prompts and custom OK and + Cancel labels. - Core event and channel helpers now cover EEGLAB-compatible event insertion, event histograms and type counts, per-epoch field extraction, boundary-aware original latencies and context queries, channel-type lookup and montage diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 9941b916..9e10152b 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -333,6 +333,7 @@ "pop_writelocs": ("eegprep.functions.popfunc.pop_writelocs", "pop_writelocs"), "pop_xfirws": ("eegprep.plugins.firfilt.pop_xfirws", "pop_xfirws"), "pophelp": ("eegprep.functions.guifunc.pophelp", "pophelp"), + "supergui": ("eegprep.functions.guifunc.supergui", "supergui"), "readegilocs": ("eegprep.functions.sigprocfunc.readegilocs", "readegilocs"), "readelp": ("eegprep.functions.sigprocfunc.readlocs", "readelp"), "readeetraklocs": ("eegprep.functions.sigprocfunc.readlocs", "readeetraklocs"), diff --git a/src/eegprep/functions/guifunc/__init__.py b/src/eegprep/functions/guifunc/__init__.py index 5c6f2e13..99c44cc3 100644 --- a/src/eegprep/functions/guifunc/__init__.py +++ b/src/eegprep/functions/guifunc/__init__.py @@ -18,6 +18,7 @@ "inputgui": ("eegprep.functions.guifunc.inputgui", "inputgui"), "listdlg2": ("eegprep.functions.guifunc.listdlg2", "listdlg2"), "pophelp": ("eegprep.functions.guifunc.pophelp", "pophelp"), + "supergui": ("eegprep.functions.guifunc.supergui", "supergui"), } __all__ = list(_LAZY_EXPORTS) diff --git a/src/eegprep/functions/guifunc/inputgui.py b/src/eegprep/functions/guifunc/inputgui.py index e81b0bfd..66e4d8e0 100644 --- a/src/eegprep/functions/guifunc/inputgui.py +++ b/src/eegprep/functions/guifunc/inputgui.py @@ -3,19 +3,62 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, Literal, overload from .spec import DialogSpec +from .supergui import supergui +@overload def inputgui( spec: DialogSpec, initial_values: Mapping[str, Any] | None = None, renderer: Any | None = None, -) -> dict[str, Any] | None: - """Render a dialog spec and return tagged values, or ``None`` on cancel.""" + *, + mode: Literal["normal"] = "normal", +) -> dict[str, Any] | None: ... + + +@overload +def inputgui( + spec: DialogSpec, + initial_values: Mapping[str, Any] | None = None, + renderer: Any | None = None, + *, + mode: Literal["plot"], +) -> tuple[Any, Any, dict[str, Any]]: ... + + +def inputgui( + spec: DialogSpec, + initial_values: Mapping[str, Any] | None = None, + renderer: Any | None = None, + *, + mode: Literal["normal", "plot"] = "normal", +) -> dict[str, Any] | tuple[Any, Any, dict[str, Any]] | None: + """Render an EEGLAB-like dialog specification. + + ``mode="normal"`` runs the modal dialog and returns tagged values, or + ``None`` when the user cancels. ``mode="plot"`` displays the dialog + without blocking and returns ``(app, dialog, tagged_widgets)`` so callers + can manage the window. The non-blocking mode is useful for previews and + custom workflows that need to retain the dialog handle. + """ + if not isinstance(spec, DialogSpec): + raise TypeError("inputgui requires a DialogSpec") + if spec.help_text is not None and not isinstance(spec.help_text, str): + raise TypeError("inputgui help_text must be a string or None") if renderer is None: from .qt import QtDialogRenderer renderer = QtDialogRenderer() - return renderer.run(spec, initial_values=initial_values) + mode = mode.lower() + if mode == "normal": + return renderer.run(spec, initial_values=initial_values) + if mode != "plot": + raise ValueError("inputgui mode must be 'normal' or 'plot'") + + app, dialog, widgets = supergui(spec, initial_values=initial_values, renderer=renderer) + dialog.show() + app.processEvents() + return app, dialog, widgets diff --git a/src/eegprep/functions/guifunc/listdlg2.py b/src/eegprep/functions/guifunc/listdlg2.py index f3f32ea7..6aae7cc5 100644 --- a/src/eegprep/functions/guifunc/listdlg2.py +++ b/src/eegprep/functions/guifunc/listdlg2.py @@ -31,12 +31,14 @@ def _require_qt() -> tuple[Any, Any]: def listdlg2( *, - promptstring: str = "", + promptstring: Sequence[str] | str = "", liststring: Sequence[str] | str, selectionmode: str = "multiple", initialvalue: Sequence[int] | None = None, listsize: tuple[int, int] | None = None, name: str = "", + okstring: str = "Ok", + cancelstring: str = "Cancel", parent: Any | None = None, ) -> tuple[list[int], int, str]: """Open an EEGLAB-like list selector. @@ -56,6 +58,8 @@ def listdlg2( initialvalue=initialvalue, listsize=listsize, name=name, + okstring=okstring, + cancelstring=cancelstring, parent=parent, ) @@ -68,12 +72,14 @@ def listdlg2( def build_listdlg2_dialog( *, - promptstring: str = "", + promptstring: Sequence[str] | str = "", liststring: Sequence[str] | str, selectionmode: str = "multiple", initialvalue: Sequence[int] | None = None, listsize: tuple[int, int] | None = None, name: str = "", + okstring: str = "Ok", + cancelstring: str = "Cancel", parent: Any | None = None, ) -> tuple[Any, Any]: """Build a listdlg2 dialog without executing it, for visual capture tests.""" @@ -89,6 +95,8 @@ def build_listdlg2_dialog( initialvalue=initialvalue, listsize=listsize, name=name, + okstring=okstring, + cancelstring=cancelstring, parent=parent, ) return app, dialog @@ -98,30 +106,39 @@ def _create_dialog( QtCore: Any, QtWidgets: Any, *, - promptstring: str, + promptstring: Sequence[str] | str, liststring: Sequence[str] | str, selectionmode: str, initialvalue: Sequence[int] | None, listsize: tuple[int, int] | None, name: str, + okstring: str, + cancelstring: str, parent: Any | None, ) -> tuple[Any, Any, list[str]]: list_items = [liststring] if isinstance(liststring, str) else [str(item) for item in liststring] + prompt_text = promptstring if isinstance(promptstring, str) else "\n".join(str(line) for line in promptstring) initial = _normalise_initial(initialvalue, len(list_items), selectionmode) dialog = QtWidgets.QDialog(parent) dialog.setObjectName("listdlg2") dialog.setWindowTitle(name) _apply_listdlg_style(dialog) + cancel = QtWidgets.QPushButton(cancelstring, dialog) + ok = QtWidgets.QPushButton(okstring, dialog) + cancel_width = max(62, cancel.sizeHint().width()) + ok_width = max(62, ok.sizeHint().width()) visible_rows = min(max(len(list_items), 1), 10) if listsize is not None: width, height = listsize else: - width = max(176, min(420, 12 * max((len(item) for item in list_items), default=0) + 64)) + list_width = min(420, 12 * max((len(item) for item in list_items), default=0) + 64) + button_width = 54 + cancel_width + ok_width + width = max(176, list_width, button_width) height = 115 + visible_rows * 19 dialog.resize(width, height) - if promptstring: - label = QtWidgets.QLabel(promptstring, dialog) + if prompt_text: + label = QtWidgets.QLabel(prompt_text, dialog) label.setObjectName("prompt") label.setAlignment(QtCore.Qt.AlignLeft) label.setGeometry(18, 15, width - 36, 40) @@ -138,17 +155,15 @@ def _create_dialog( list_widget.addItem(item) if index in initial: item.setSelected(True) - list_widget.setGeometry(18, 64 if promptstring else 15, width - 36, visible_rows * 20 + 8) + list_widget.setGeometry(18, 64 if prompt_text else 15, width - 36, visible_rows * 20 + 8) - cancel = QtWidgets.QPushButton("Cancel", dialog) - ok = QtWidgets.QPushButton("Ok", dialog) cancel.setObjectName("cancel") ok.setObjectName("ok") cancel.clicked.connect(dialog.reject) ok.clicked.connect(dialog.accept) button_y = height - 33 - cancel.setGeometry(18, button_y, 62, 18) - ok.setGeometry(width - 80, button_y, 62, 18) + cancel.setGeometry(18, button_y, cancel_width, 18) + ok.setGeometry(width - 18 - ok_width, button_y, ok_width, 18) return dialog, list_widget, list_items @@ -189,7 +204,6 @@ def _apply_listdlg_style(dialog: Any) -> None: background: {EEGLAB_BUTTON_BACKGROUND}; border: 1px solid {EEGLAB_CONTROL_BORDER}; min-width: 62px; - max-width: 62px; min-height: 18px; max-height: 18px; padding: 0; diff --git a/src/eegprep/functions/guifunc/qt.py b/src/eegprep/functions/guifunc/qt.py index a935028a..e5afbc39 100644 --- a/src/eegprep/functions/guifunc/qt.py +++ b/src/eegprep/functions/guifunc/qt.py @@ -215,6 +215,9 @@ def _build_widget(self, QtWidgets: Any, control: ControlSpec, initial_values: Ma elif style == "checkbox": widget = QtWidgets.QCheckBox(control.string) widget.setChecked(bool(value)) + elif style in {"radio", "radiobutton"}: + widget = QtWidgets.QRadioButton(control.string) + widget.setChecked(bool(value)) elif style == "popupmenu": widget = QtWidgets.QComboBox() widget.addItems([item.strip() for item in control.string.split("|")]) @@ -433,14 +436,14 @@ def _apply_eeglab_style(dialog: Any, spec: DialogSpec) -> None: color: {EEGLAB_TEXT}; font-size: 16px; }} - QLabel, QCheckBox, QPushButton, QLineEdit, QTextEdit, QComboBox, QListWidget {{ + QLabel, QCheckBox, QRadioButton, QPushButton, QLineEdit, QTextEdit, QComboBox, QListWidget {{ font-size: 16px; }} - QLabel, QCheckBox {{ + QLabel, QCheckBox, QRadioButton {{ color: {EEGLAB_TEXT}; background: transparent; }} - QLabel:disabled, QCheckBox:disabled {{ + QLabel:disabled, QCheckBox:disabled, QRadioButton:disabled {{ color: {EEGLAB_DISABLED_TEXT}; }} QLineEdit {{ @@ -532,10 +535,10 @@ def _apply_eeglab_style(dialog: Any, spec: DialogSpec) -> None: min-height: 102px; max-height: 102px; }} - QCheckBox {{ + QCheckBox, QRadioButton {{ spacing: 4px; }} - QCheckBox::indicator {{ + QCheckBox::indicator, QRadioButton::indicator {{ width: 13px; height: 13px; }} @@ -634,7 +637,7 @@ def _apply_widget_size_policy(QtWidgets: Any, widget: Any, style: str) -> None: if style == "textarea": widget.setSizePolicy(policy.Expanding, policy.Expanding) return - if style in {"text", "checkbox"}: + if style in {"text", "checkbox", "radio", "radiobutton"}: widget.setMinimumWidth(0) widget.setSizePolicy(policy.Expanding, policy.Fixed) diff --git a/src/eegprep/functions/guifunc/supergui.py b/src/eegprep/functions/guifunc/supergui.py new file mode 100644 index 00000000..d853e68f --- /dev/null +++ b/src/eegprep/functions/guifunc/supergui.py @@ -0,0 +1,31 @@ +"""Renderer-level construction for EEGLAB-like dialog specifications.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from .spec import DialogSpec + + +def supergui( + spec: DialogSpec, + initial_values: Mapping[str, Any] | None = None, + renderer: Any | None = None, +) -> tuple[Any, Any, dict[str, Any]]: + """Build a dialog without entering its modal event loop. + + This is EEGPrep's declarative counterpart to EEGLAB's low-level + ``supergui`` builder. The returned tuple contains the application, dialog, + and tagged widgets. Use :func:`inputgui` for the usual modal workflow. + """ + if not isinstance(spec, DialogSpec): + raise TypeError("supergui requires a DialogSpec") + if renderer is None: + from .qt import QtDialogRenderer + + renderer = QtDialogRenderer() + build_dialog = getattr(renderer, "build_dialog", None) + if build_dialog is None: + raise TypeError("supergui renderer must provide build_dialog()") + return build_dialog(spec, initial_values=initial_values) diff --git a/tests/test_finputcheck.py b/tests/test_finputcheck.py index b9781ed1..fcd71a90 100644 --- a/tests/test_finputcheck.py +++ b/tests/test_finputcheck.py @@ -14,14 +14,17 @@ ("p3rcent", "real", [0, 1], 1 / math.sqrt(2)), ("forth", "cell", [], []), ) +FINPUTCHECK_WRAPPER = "unittesting_guifunc/finputcheck/guifunc_finputcheck_wrapperTest.m" +@eeglab_test(FINPUTCHECK_WRAPPER, "test_fail_no_arg") @eeglab_test("unittesting_guifunc/finputcheck/fail_no_arg.m", "test_fail_no_arg") def test_finputcheck_requires_arguments_and_field_rules(): with pytest.raises(TypeError): finputcheck() +@eeglab_test(FINPUTCHECK_WRAPPER, "test_fail_no_key_val") @eeglab_test("unittesting_guifunc/finputcheck/fail_no_key_val.m", "test_fail_no_key_val") def test_finputcheck_reports_an_incomplete_key_value_sequence(): result = finputcheck(["key1", 3, "the2test"], RULES) @@ -29,6 +32,7 @@ def test_finputcheck_reports_an_incomplete_key_value_sequence(): assert result == "error: bad 'key', 'val' sequence" +@eeglab_test(FINPUTCHECK_WRAPPER, "test_pass_empty") @eeglab_test("regression_tests/t_finputcheck.m", "test_1") @eeglab_test("unittesting_guifunc/finputcheck/pass_empty.m", "test_pass_empty") def test_finputcheck_uses_defaults_for_an_empty_argument_list(): @@ -44,6 +48,7 @@ def test_finputcheck_uses_defaults_for_an_empty_argument_list(): } +@eeglab_test(FINPUTCHECK_WRAPPER, "test_pass_general") @eeglab_test("regression_tests/t_finputcheck.m", "test_2") @eeglab_test("unittesting_guifunc/finputcheck/pass_general.m", "test_pass_general") def test_finputcheck_validates_all_supported_rule_values(): @@ -54,6 +59,7 @@ def test_finputcheck_validates_all_supported_rule_values(): assert result == {"key1": 3, "the2test": "foo", "p3rcent": 0.4937, "forth": ["a", 2, "11b", "D"]} +@eeglab_test(FINPUTCHECK_WRAPPER, "test_pass_multiple_types") @eeglab_test("regression_tests/t_finputcheck.m", "test_3") @eeglab_test("unittesting_guifunc/finputcheck/pass_multiple_types.m", "test_pass_multiple_types") def test_finputcheck_accepts_a_value_matching_any_declared_type(): @@ -69,6 +75,7 @@ def test_finputcheck_accepts_a_value_matching_any_declared_type(): assert result == {"key1": "test", "the2test": "foo", "p3rcent": 0.4937, "forth": ["a", 2, "11b", "D"]} +@eeglab_test(FINPUTCHECK_WRAPPER, "test_pass_standard") @eeglab_test("regression_tests/t_finputcheck.m", "test_4") @eeglab_test("unittesting_guifunc/finputcheck/pass_standard.m", "test_pass_standard") def test_finputcheck_fills_only_omitted_values_from_defaults(): @@ -84,6 +91,7 @@ def test_finputcheck_fills_only_omitted_values_from_defaults(): } +@eeglab_test(FINPUTCHECK_WRAPPER, "test_pass_strings") @eeglab_test("regression_tests/t_finputcheck.m", "test_5") @eeglab_test("unittesting_guifunc/finputcheck/pass_strings.m", "test_pass_strings") def test_finputcheck_accepts_declared_string_choices_case_insensitively(): @@ -95,6 +103,7 @@ def test_finputcheck_accepts_declared_string_choices_case_insensitively(): assert result == {"key1": 3, "the2test": "foo", "p3rcent": 0.4937, "forth": ["a", 2, "11b", "D"]} +@eeglab_test(FINPUTCHECK_WRAPPER, "test_pass_unknown") @eeglab_test("regression_tests/t_finputcheck.m", "test_6") @eeglab_test("unittesting_guifunc/finputcheck/pass_unknown.m", "test_pass_unknown") def test_finputcheck_can_return_unrecognized_arguments_in_ignore_mode(): diff --git a/tests/test_guifunc_pophelp_chansel.py b/tests/test_guifunc_pophelp_chansel.py index 6455b97a..358020eb 100644 --- a/tests/test_guifunc_pophelp_chansel.py +++ b/tests/test_guifunc_pophelp_chansel.py @@ -39,6 +39,7 @@ def test_pophelp_accepts_pophelp_expression(self): self.assertIn("resources/help", Path(source_path).as_posix()) self.assertTrue(source_path.endswith("pop_reref.md")) + @eeglab_test("unittesting_guifunc/pophelp/guifunc_pophelp_wrapperTest.m", "test_test_pophelp") @eeglab_test("unittesting_guifunc/pophelp/test_pophelp.m", "test_test_pophelp") def test_pophelp_accepts_function_name_with_or_without_matlab_suffix(self): plain_text, plain_source = pophelp_text("pop_editoptions") diff --git a/tests/test_guifunc_primitives.py b/tests/test_guifunc_primitives.py index 061e13b3..4068adc7 100644 --- a/tests/test_guifunc_primitives.py +++ b/tests/test_guifunc_primitives.py @@ -8,10 +8,18 @@ from eegprep.functions.guifunc.inputdlg2 import inputdlg2, inputdlg2_dialog_spec from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.listdlg2 import build_listdlg2_dialog, listdlg2 -from eegprep.functions.guifunc.spec import ControlSpec, DialogSpec +from eegprep.functions.guifunc.spec import CallbackSpec, ControlSpec, DialogSpec +from eegprep.functions.guifunc.supergui import supergui from tests.eeglab_tests import eeglab_test +ERRORDLG2_WRAPPER = "unittesting_guifunc/errordlg2/guifunc_errordlg2_wrapperTest.m" +INPUTDLG2_WRAPPER = "unittesting_guifunc/inputdlg2/guifunc_inputdlg2_wrapperTest.m" +INPUTGUI_WRAPPER = "unittesting_guifunc/inputgui/guifunc_inputgui_wrapperTest.m" +LISTDLG2_WRAPPER = "unittesting_guifunc/listdlg2/guifunc_listdlg2_wrapperTest.m" +SUPERGUI_WRAPPER = "unittesting_guifunc/supergui/guifunc_supergui_wrapperTest.m" + + def _spec(*, geomvert: tuple[float, ...] | None = None, help_text: str | None = None) -> DialogSpec: return DialogSpec( title="MyGUI", @@ -38,12 +46,42 @@ def run(self, spec, initial_values=None): return self.result +class RecordingPlotRenderer: + def __init__(self): + self.app = RecordingApplication() + self.dialog = RecordingDialog() + self.widgets = {"entry": object()} + self.calls = [] + + def build_dialog(self, spec, initial_values=None): + self.calls.append((spec, initial_values)) + return self.app, self.dialog, self.widgets + + +class RecordingApplication: + def __init__(self): + self.processed = 0 + + def processEvents(self): + self.processed += 1 + + +class RecordingDialog: + def __init__(self): + self.shown = 0 + + def show(self): + self.shown += 1 + + +@eeglab_test(INPUTGUI_WRAPPER, "test_fail_no_arg") @eeglab_test("unittesting_guifunc/inputgui/fail_no_arg.m", "test_fail_no_arg") def test_inputgui_requires_a_dialog_spec(): with pytest.raises(TypeError): inputgui() +@eeglab_test(INPUTGUI_WRAPPER, "test_i_pass_general") @eeglab_test("unittesting_guifunc/inputgui/i_pass_general.m", "test_i_pass_general") def test_inputgui_returns_tagged_values_from_renderer(): renderer = RecordingRenderer({"choice": True, "entry": "accepted"}) @@ -55,6 +93,7 @@ def test_inputgui_returns_tagged_values_from_renderer(): assert renderer.calls == [(spec, {"entry": "initial"})] +@eeglab_test(INPUTGUI_WRAPPER, "test_i_pass_geomvert") @eeglab_test("unittesting_guifunc/inputgui/i_pass_geomvert.m", "test_i_pass_geomvert") def test_inputgui_preserves_explicit_vertical_geometry(): renderer = RecordingRenderer({}) @@ -65,6 +104,7 @@ def test_inputgui_preserves_explicit_vertical_geometry(): assert renderer.calls[0][0].geomvert == (4, 1) +@eeglab_test(INPUTGUI_WRAPPER, "test_i_pass_help") @eeglab_test("unittesting_guifunc/inputgui/i_pass_help.m", "test_i_pass_help") def test_inputgui_preserves_help_target_for_the_renderer(): renderer = RecordingRenderer({}) @@ -76,6 +116,94 @@ def test_inputgui_preserves_help_target_for_the_renderer(): assert renderer.calls[0][0].show_help_button +@eeglab_test(INPUTGUI_WRAPPER, "test_i_pass_help_cell") +@eeglab_test("unittesting_guifunc/inputgui/i_pass_help_cell.m", "test_i_pass_help_cell") +def test_inputgui_represents_multiple_help_actions_as_explicit_controls(qt_widgets): + spec = DialogSpec( + title="MyGUI", + controls=( + ControlSpec( + "pushbutton", + "Help gui", + tag="help_gui", + callback=CallbackSpec( + "show_message", + {"button": "help_gui", "title": "Help gui", "message": "First help action"}, + ), + ), + ControlSpec( + "pushbutton", + "More help", + tag="more_help", + callback=CallbackSpec( + "show_message", + {"button": "more_help", "title": "More help", "message": "Second help action"}, + ), + ), + ), + geometry=((1, 1),), + function_name="test_inputgui_help", + eeglab_source="functions/guifunc/inputgui.m", + show_help_button=False, + ) + + _app, dialog, widgets = inputgui(spec, mode="plot") + + assert isinstance(widgets["help_gui"], qt_widgets.QPushButton) + assert isinstance(widgets["more_help"], qt_widgets.QPushButton) + assert widgets["help_gui"].text() == "Help gui" + assert widgets["more_help"].text() == "More help" + assert dialog.isVisible() + dialog.close() + + +@eeglab_test(INPUTGUI_WRAPPER, "test_i_pass_help_numeric") +@eeglab_test("unittesting_guifunc/inputgui/i_pass_help_numeric.m", "test_i_pass_help_numeric") +def test_inputgui_rejects_a_numeric_help_target(): + spec = _spec(help_text=100) # type: ignore[arg-type] + + with pytest.raises(TypeError, match="help_text must be a string"): + inputgui(spec, renderer=RecordingRenderer({})) + + +@eeglab_test(INPUTGUI_WRAPPER, "test_i_pass_old") +@eeglab_test("unittesting_guifunc/inputgui/i_pass_old.m", "test_i_pass_old") +def test_inputgui_accepts_its_three_python_arguments_positionally(): + renderer = RecordingRenderer({"entry": "accepted"}) + spec = _spec() + + result = inputgui(spec, {"entry": "initial"}, renderer) + + assert result == {"entry": "accepted"} + assert renderer.calls == [(spec, {"entry": "initial"})] + + +@eeglab_test(INPUTGUI_WRAPPER, "test_i_pass_plotmode") +@eeglab_test("unittesting_guifunc/inputgui/i_pass_plotmode.m", "test_i_pass_plotmode") +def test_inputgui_plot_mode_returns_a_visible_nonmodal_dialog(): + renderer = RecordingPlotRenderer() + spec = _spec() + + result = inputgui(spec, {"entry": "preview"}, renderer, mode="plot") + + assert result == (renderer.app, renderer.dialog, renderer.widgets) + assert renderer.calls == [(spec, {"entry": "preview"})] + assert renderer.dialog.shown == 1 + assert renderer.app.processed == 1 + + +@eeglab_test(INPUTGUI_WRAPPER, "test_i_pass_return_four") +@eeglab_test("unittesting_guifunc/inputgui/i_pass_return_four.m", "test_i_pass_return_four") +def test_inputgui_tagged_mapping_is_the_python_rich_output(): + renderer = RecordingRenderer({"choice": True, "entry": "accepted"}) + + result = inputgui(_spec(), initial_values={"entry": "initial"}, renderer=renderer) + + assert result == {"choice": True, "entry": "accepted"} + assert "pushme" not in result + + +@eeglab_test(INPUTGUI_WRAPPER, "test_i_pass_reuse") @eeglab_test("unittesting_guifunc/inputgui/i_pass_reuse.m", "test_i_pass_reuse") def test_inputgui_renderer_can_be_reused_without_retaining_initial_values(): renderer = RecordingRenderer({"entry": "accepted"}) @@ -87,11 +215,15 @@ def test_inputgui_renderer_can_be_reused_without_retaining_initial_values(): assert renderer.calls == [(spec, {"entry": "first"}), (spec, {"entry": "second"})] +@eeglab_test(SUPERGUI_WRAPPER, "test_test_supergui") @eeglab_test("unittesting_guifunc/supergui/test_supergui.m", "test_test_supergui") -def test_dialog_spec_represents_supergui_geometry_controls_and_layout_options(): +def test_supergui_builds_geometry_controls_and_layout_options(qt_widgets): spec = DialogSpec( title="MyGUI", - controls=(ControlSpec("radiobutton", "radio"), ControlSpec("pushbutton", "push")), + controls=( + ControlSpec("radiobutton", "radio", tag="radio"), + ControlSpec("pushbutton", "push", tag="push"), + ), geometry=((1, 1),), geomvert=(3, 2), function_name="test_supergui", @@ -100,6 +232,8 @@ def test_dialog_spec_represents_supergui_geometry_controls_and_layout_options(): row_spacing=2, ) + _app, dialog, widgets = supergui(spec) + assert spec.title == "MyGUI" assert spec.geometry == ((1, 1),) assert spec.geomvert == (3, 2) @@ -109,20 +243,29 @@ def test_dialog_spec_represents_supergui_geometry_controls_and_layout_options(): ] assert spec.content_margins == (10, 8, 14, 12) assert spec.row_spacing == 2 + assert dialog.windowTitle() == "MyGUI" + assert isinstance(widgets["radio"], qt_widgets.QRadioButton) + assert isinstance(widgets["push"], qt_widgets.QPushButton) + assert widgets["radio"].text() == "radio" + assert widgets["push"].text() == "push" + dialog.close() +@eeglab_test(INPUTDLG2_WRAPPER, "test_fail_no_arg") @eeglab_test("unittesting_guifunc/inputdlg2/fail_no_arg.m", "test_fail_no_arg") def test_inputdlg2_requires_a_prompt_and_title(): with pytest.raises(TypeError): inputdlg2() +@eeglab_test(INPUTDLG2_WRAPPER, "test_fail_invalid_length") @eeglab_test("unittesting_guifunc/inputdlg2/fail_invalid_length.m", "test_fail_invalid_length") def test_inputdlg2_rejects_mismatched_prompts_and_defaults(): with pytest.raises(ValueError, match="same length"): inputdlg2_dialog_spec(["testcase", "another test"], "inputdlg2 testcase", 1, ["this"]) +@eeglab_test(INPUTDLG2_WRAPPER, "test_i_pass_general") @eeglab_test("unittesting_guifunc/inputdlg2/i_pass_general.m", "test_i_pass_general") def test_inputdlg2_returns_answers_in_prompt_order(): renderer = RecordingRenderer({"answer0": "this", "answer1": "that"}) @@ -144,6 +287,7 @@ def test_inputdlg2_returns_answers_in_prompt_order(): assert [control.value for control in spec.controls if control.style == "edit"] == ["this", "that"] +@eeglab_test(INPUTDLG2_WRAPPER, "test_i_pass_horizontal") @eeglab_test("unittesting_guifunc/inputdlg2/i_pass_horizontal.m", "test_i_pass_horizontal") def test_inputdlg2_uses_vertical_rows_for_a_multiline_prompt(): spec = inputdlg2_dialog_spec([["test", "case"], "another test"], "inputdlg2 testcase", 1, ["this", "that"]) @@ -153,6 +297,7 @@ def test_inputdlg2_uses_vertical_rows_for_a_multiline_prompt(): assert spec.geomvert == (2, 1) +@eeglab_test(INPUTDLG2_WRAPPER, "test_i_pass_no_function") @eeglab_test("unittesting_guifunc/inputdlg2/i_pass_no_function.m", "test_i_pass_no_function") def test_inputdlg2_omits_help_when_no_function_name_is_given(): spec = inputdlg2_dialog_spec(["testcase", "another test"], "inputdlg2 testcase", 1, ["this", "that"]) @@ -171,6 +316,7 @@ def qt_widgets(): app.processEvents() +@eeglab_test(ERRORDLG2_WRAPPER, "test_i_pass_general") @eeglab_test("unittesting_guifunc/errordlg2/i_pass_general.m", "test_i_pass_general") def test_errordlg2_builds_a_critical_message_with_requested_text(qt_widgets): _app, dialog = build_errordlg2("Explanation of error", "testcase for errordlg2") @@ -181,12 +327,14 @@ def test_errordlg2_builds_a_critical_message_with_requested_text(qt_widgets): dialog.close() +@eeglab_test(LISTDLG2_WRAPPER, "test_fail_no_arg") @eeglab_test("unittesting_guifunc/listdlg2/fail_no_arg.m", "test_fail_no_arg") def test_listdlg2_requires_list_items(): with pytest.raises(TypeError): listdlg2() +@eeglab_test(LISTDLG2_WRAPPER, "test_i_pass_general") @eeglab_test("unittesting_guifunc/listdlg2/i_pass_general.m", "test_i_pass_general") def test_listdlg2_defaults_to_multiple_selection(qt_widgets): _app, dialog = build_listdlg2_dialog(liststring=["This", "is", "a", "testcase"]) @@ -197,6 +345,7 @@ def test_listdlg2_defaults_to_multiple_selection(qt_widgets): dialog.close() +@eeglab_test(LISTDLG2_WRAPPER, "test_i_pass_InitialValue") @eeglab_test("unittesting_guifunc/listdlg2/i_pass_InitialValue.m", "test_i_pass_InitialValue") def test_listdlg2_selects_one_based_initial_values(qt_widgets): _app, dialog = build_listdlg2_dialog( @@ -209,15 +358,17 @@ def test_listdlg2_selects_one_based_initial_values(qt_widgets): dialog.close() +@eeglab_test(LISTDLG2_WRAPPER, "test_i_pass_PromptString") @eeglab_test("unittesting_guifunc/listdlg2/i_pass_PromptString.m", "test_i_pass_PromptString") def test_listdlg2_displays_prompt_text(qt_widgets): - _app, dialog = build_listdlg2_dialog(liststring=["one", "two"], promptstring="Choose values") + _app, dialog = build_listdlg2_dialog(liststring=["one", "two"], promptstring=["Choose", "values"]) prompt = dialog.findChild(qt_widgets.QLabel, "prompt") - assert prompt.text() == "Choose values" + assert prompt.text() == "Choose\nvalues" dialog.close() +@eeglab_test(LISTDLG2_WRAPPER, "test_i_pass_listsize") @eeglab_test("unittesting_guifunc/listdlg2/i_pass_listsize.m", "test_i_pass_listsize") def test_listdlg2_uses_requested_window_size(qt_widgets): _app, dialog = build_listdlg2_dialog(liststring=["one", "two"], listsize=(420, 260)) @@ -226,6 +377,7 @@ def test_listdlg2_uses_requested_window_size(qt_widgets): dialog.close() +@eeglab_test(LISTDLG2_WRAPPER, "test_i_pass_name") @eeglab_test("unittesting_guifunc/listdlg2/i_pass_name.m", "test_i_pass_name") def test_listdlg2_uses_requested_window_title(qt_widgets): _app, dialog = build_listdlg2_dialog(liststring=["one", "two"], name="My list") @@ -234,6 +386,7 @@ def test_listdlg2_uses_requested_window_title(qt_widgets): dialog.close() +@eeglab_test(LISTDLG2_WRAPPER, "test_i_pass_single") @eeglab_test("unittesting_guifunc/listdlg2/i_pass_single.m", "test_i_pass_single") def test_listdlg2_single_mode_selects_only_one_item(qt_widgets): _app, dialog = build_listdlg2_dialog( @@ -247,6 +400,7 @@ def test_listdlg2_single_mode_selects_only_one_item(qt_widgets): dialog.close() +@eeglab_test(LISTDLG2_WRAPPER, "test_i_pass_string") @eeglab_test("unittesting_guifunc/listdlg2/i_pass_string.m", "test_i_pass_string") def test_listdlg2_treats_a_string_as_one_list_item(qt_widgets): _app, dialog = build_listdlg2_dialog(liststring="This is a testcase") @@ -256,3 +410,29 @@ def test_listdlg2_treats_a_string_as_one_list_item(qt_widgets): assert list_widget.item(0).text() == "This is a testcase" assert list_widget.selectionMode() == qt_widgets.QAbstractItemView.SingleSelection dialog.close() + + +@eeglab_test(LISTDLG2_WRAPPER, "test_i_pass_OKString") +@eeglab_test("unittesting_guifunc/listdlg2/i_pass_OKString.m", "test_i_pass_OKString") +def test_listdlg2_uses_custom_ok_label_and_accepts(qt_widgets): + _app, dialog = build_listdlg2_dialog(liststring=["one", "two"], okstring="TEST_OK") + ok = dialog.findChild(qt_widgets.QPushButton, "ok") + + assert ok.text() == "TEST_OK" + assert ok.width() >= ok.sizeHint().width() + ok.click() + assert dialog.result() == qt_widgets.QDialog.Accepted + dialog.close() + + +@eeglab_test(LISTDLG2_WRAPPER, "test_i_pass_CancelString") +@eeglab_test("unittesting_guifunc/listdlg2/i_pass_CancelString.m", "test_i_pass_CancelString") +def test_listdlg2_uses_custom_cancel_label_and_rejects(qt_widgets): + _app, dialog = build_listdlg2_dialog(liststring=["one", "two"], cancelstring="TEST_CANCEL") + cancel = dialog.findChild(qt_widgets.QPushButton, "cancel") + + assert cancel.text() == "TEST_CANCEL" + assert cancel.width() >= cancel.sizeHint().width() + cancel.click() + assert dialog.result() == qt_widgets.QDialog.Rejected + dialog.close() diff --git a/tests/test_package_exports.py b/tests/test_package_exports.py index ea324927..96bd36a1 100644 --- a/tests/test_package_exports.py +++ b/tests/test_package_exports.py @@ -106,6 +106,7 @@ def test_phase_6b_public_exports_are_intentional() -> None: "pop_editoptions", "pop_newset", "pophelp", + "supergui", "validate_catalog_entries", "validate_catalog_file", "validate_extension_spec", From bb375de3d681a0339304beefd88275892052f251 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:49 -0700 Subject: [PATCH 17/45] test: port current visual popfunc suite (#345) --- docs/source/changelog.rst | 8 + docs/source/examples/plot_data_plotting.py | 1 + src/eegprep/functions/guifunc/menu_actions.py | 2 +- src/eegprep/functions/popfunc/pop_plotdata.py | 117 ++-- src/eegprep/functions/popfunc/pop_plottopo.py | 48 +- src/eegprep/functions/popfunc/pop_prop.py | 4 +- src/eegprep/functions/popfunc/pop_spectopo.py | 18 +- src/eegprep/functions/popfunc/pop_topoplot.py | 104 +++- src/eegprep/functions/sigprocfunc/envtopo.py | 11 + src/eegprep/functions/sigprocfunc/plottopo.py | 19 +- src/eegprep/functions/sigprocfunc/spectopo.py | 20 +- src/eegprep/resources/help/pop_envtopo.md | 4 + src/eegprep/resources/help/pop_plotdata.md | 12 +- src/eegprep/resources/help/pop_plottopo.md | 3 + src/eegprep/resources/help/pop_spectopo.md | 4 + src/eegprep/resources/help/pop_topoplot.md | 6 + tests/test_phase4_plot_wrappers.py | 2 +- tests/test_popfunc_visual_eeglab_tests.py | 498 ++++++++++++++++++ tools/visual_parity/capture.py | 2 +- tools/visual_parity/cases.json | 2 +- 20 files changed, 804 insertions(+), 81 deletions(-) create mode 100644 tests/test_popfunc_visual_eeglab_tests.py diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index b26f1fc0..9e039397 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,14 @@ the `GitHub Releases `_ page. Unreleased ========== +- Current EEGLAB visual-wrapper test ports now exercise plots through + deterministic headless numerical and figure assertions. ``pop_plotdata`` + supports channel/component mode, 1-based trial selection, trial averaging, + and single-trial overlays; ``pop_plottopo`` honors its positional title and + single-trial arguments. ``pop_spectopo`` supports Blackman-Harris windows and + ``blckhn``, and ``pop_envtopo`` accepts the legacy ``compnums=-N`` top-N + shorthand. ``pop_topoplot`` renders signed ``EEG.chanmatrix`` grids even when + channel locations are unavailable. - The current EEGLAB GUI-helper tests now have deterministic Python ports. ``supergui`` builds declarative dialogs without entering a modal loop, ``inputgui`` supports a non-blocking ``mode="plot"`` workflow and renders diff --git a/docs/source/examples/plot_data_plotting.py b/docs/source/examples/plot_data_plotting.py index c132bf48..b5953490 100644 --- a/docs/source/examples/plot_data_plotting.py +++ b/docs/source/examples/plot_data_plotting.py @@ -105,6 +105,7 @@ comp_spectra = pop_spectopo(EEG, 0, EPOCH_MS, freqs=[10], icacomps=[1, 2, 3, 4, 5], plot="off") fig_comp_erps = pop_plotdata(EEG, components=[1, 2, 3, 4, 5], plot="off") +fig_selected_channels = pop_plotdata(EEG, 1, [1, 2, 3], [2, 5, 7], "Selected trials", plot="off") fig_envtopo, com_envtopo = pop_envtopo(EEG, [-100, 600], compnums=list(range(1, 11)), plot="off", return_com=True) comp_image = pop_erpimage(EEG, 0, 1, smooth=10, decimate=2, plot="off") comp_tf = pop_newtimef(EEG, 0, 1, EPOCH_MS, [3, 0.8], plot="off") diff --git a/src/eegprep/functions/guifunc/menu_actions.py b/src/eegprep/functions/guifunc/menu_actions.py index 3685777d..86650549 100644 --- a/src/eegprep/functions/guifunc/menu_actions.py +++ b/src/eegprep/functions/guifunc/menu_actions.py @@ -1572,7 +1572,7 @@ def _run_plot_function(self, name: str, variant: str, parent: Any | None) -> Non elif name == "pop_plotdata": from eegprep.functions.popfunc.pop_plotdata import pop_plotdata - _result, command = pop_plotdata(selection, return_com=True) + _result, command = pop_plotdata(selection, typeplot=0 if variant == "components" else 1, return_com=True) elif name == "pop_erpimage": from eegprep.functions.popfunc.pop_erpimage import pop_erpimage diff --git a/src/eegprep/functions/popfunc/pop_plotdata.py b/src/eegprep/functions/popfunc/pop_plotdata.py index 5259b4d8..a113b7c1 100644 --- a/src/eegprep/functions/popfunc/pop_plotdata.py +++ b/src/eegprep/functions/popfunc/pop_plotdata.py @@ -10,65 +10,113 @@ from eegprep.functions.guifunc.spec import ControlSpec, DialogSpec from eegprep.functions.popfunc.plot_utils import ( component_activations, + eeg_epoch_data, eeg_times_ms, history_command, numeric_vector, + selected_indices, show_figures, ) +from eegprep.functions.popfunc._chanutils import chanlocs_as_list from eegprep.functions.sigprocfunc.plottopo import plottopo def pop_plotdata( EEG: dict[str, Any] | None = None, + typeplot: int = 1, + indices: Any = None, + trials: Any = None, + plottitle: str = "", + singletrials: int = 0, + ydir: int = 1, + ylimits: Any = None, + *, components: Any = None, - *args: Any, gui: bool | None = None, renderer: Any | None = None, plot: str | bool = "on", return_com: bool = False, - **kwargs: Any, + title: str | None = None, ): - """Plot component ERP activations in a rectangular array. + """Plot channel or component activity in a rectangular/scalp array. - Pass ``plot='off'`` to build and return the figure without opening a window. + ``typeplot=1`` selects channels and ``typeplot=0`` selects ICA components. + Trial selections and channel/component indices are EEGLAB-facing and + therefore 1-based. When ``singletrials`` is false, selected trials are + averaged before plotting; otherwise every selected trial is overlaid. + + ``components=...`` is the EEGPrep convenience spelling for + ``typeplot=0, indices=...``. Pass ``plot='off'`` to build and return the + figure without opening a window. """ if EEG is None: return (None, "") if return_com else None + if components is not None: + if indices is not None: + raise TypeError("indices and components cannot both be supplied") + typeplot = 0 + indices = components + typeplot = int(typeplot) + if typeplot not in {0, 1}: + raise ValueError("typeplot must be 1 for channels or 0 for components") if gui is None: - gui = components is None and not kwargs + gui = indices is None if gui: - result = _run_gui(EEG, renderer=renderer) + result = _run_gui(EEG, typeplot=typeplot, renderer=renderer) if result is None: return (None, "") if return_com else None - components = result["components"] - kwargs.update(result["options"]) - acts = component_activations(EEG) - erp = np.nanmean(acts, axis=2) - command_kwargs = dict(kwargs) - ylimits = kwargs.pop("ylimits", None) + indices = result["indices"] + plottitle = result["plottitle"] + ylimits = result["ylimits"] + singletrials = 0 + ydir = -1 + if title is not None: + plottitle = title + + source = eeg_epoch_data(EEG) if typeplot else component_activations(EEG) + row_indices = selected_indices(indices, source.shape[0]) + trial_indices = selected_indices(trials, source.shape[2]) + selected = source[row_indices, :, :][:, :, trial_indices] + plot_data = selected if int(bool(singletrials)) else np.nanmean(selected, axis=2) + plot_chanlocs = _selected_chanlocs(EEG, row_indices) if typeplot else _component_labels(row_indices) + default_title = "Channel ERPs" if typeplot else "Component ERPs" + title_value = str(plottitle or EEG.get("setname") or default_title) figure = plottopo( - erp, + plot_data, times=eeg_times_ms(EEG), - channels=components, - title=str(kwargs.pop("title", EEG.get("setname") or "Component ERPs")), - ydir=int(kwargs.pop("ydir", -1)), + chanlocs=plot_chanlocs, + title=title_value, + ydir=int(ydir), ylimits=ylimits, + rect=True, + singletrials=bool(singletrials), + ) + command = history_command( + "pop_plotdata", + typeplot, + (row_indices + 1).tolist(), + (trial_indices + 1).tolist(), + plottitle, + int(bool(singletrials)), + int(ydir), + numeric_vector(ylimits).tolist() or [0, 0], ) - command = history_command("pop_plotdata", components, **command_kwargs) show_figures(figure, plot=plot) return (figure, command) if return_com else figure -def pop_plotdata_dialog_spec(EEG: dict[str, Any]) -> DialogSpec: +def pop_plotdata_dialog_spec(EEG: dict[str, Any], *, typeplot: int = 1) -> DialogSpec: """Return the EEGLAB-like dialog spec for ``pop_plotdata``.""" - n_components = np.asarray(EEG.get("icaweights", [])).shape[0] + is_channel = bool(int(typeplot)) + count = int(EEG.get("nbchan", 0) or 0) if is_channel else np.asarray(EEG.get("icaweights", [])).shape[0] + label = "Channel" if is_channel else "Component" return DialogSpec( - title="Component ERPs in rect. array -- pop_plotdata()", + title=f"{label} ERPs in rect. array -- pop_plotdata()", controls=( - ControlSpec("text", "Component number(s):"), - ControlSpec("edit", tag="components", value=f"1:{n_components}" if n_components else ""), + ControlSpec("text", f"{label} number(s):"), + ControlSpec("edit", tag="indices", value=f"1:{count}" if count else ""), ControlSpec("text", "Plot title:"), - ControlSpec("edit", tag="title", value=f"{str(EEG.get('setname') or '').strip()} ERP".strip()), + ControlSpec("edit", tag="plottitle", value=f"{str(EEG.get('setname') or '').strip()} ERP".strip()), ControlSpec("text", "Vertical limits ([0 0]-> data range):"), ControlSpec("edit", tag="ylimits", value="0 0"), ), @@ -80,17 +128,26 @@ def pop_plotdata_dialog_spec(EEG: dict[str, Any]) -> DialogSpec: ) -def _run_gui(EEG: dict[str, Any], *, renderer: Any | None = None) -> dict[str, Any] | None: - result = inputgui(pop_plotdata_dialog_spec(EEG), renderer=renderer) +def _run_gui(EEG: dict[str, Any], *, typeplot: int, renderer: Any | None = None) -> dict[str, Any] | None: + result = inputgui(pop_plotdata_dialog_spec(EEG, typeplot=typeplot), renderer=renderer) if result is None: return None return { - "components": numeric_vector(result.get("components", []), dtype=int).tolist(), - "options": { - "title": str(result.get("title", "") or ""), - "ylimits": numeric_vector(result.get("ylimits", [])).tolist(), - }, + "indices": numeric_vector(result.get("indices", []), dtype=int).tolist(), + "plottitle": str(result.get("plottitle", "") or ""), + "ylimits": numeric_vector(result.get("ylimits", [])).tolist(), } +def _selected_chanlocs(EEG: dict[str, Any], indices: np.ndarray) -> list[dict[str, Any]]: + chanlocs = chanlocs_as_list(EEG.get("chanlocs", [])) + if len(chanlocs) < int(EEG.get("nbchan", 0) or 0): + return [{"labels": str(index + 1)} for index in indices] + return [chanlocs[int(index)] for index in indices] + + +def _component_labels(indices: np.ndarray) -> list[dict[str, str]]: + return [{"labels": str(int(index) + 1)} for index in indices] + + __all__ = ["pop_plotdata", "pop_plotdata_dialog_spec"] diff --git a/src/eegprep/functions/popfunc/pop_plottopo.py b/src/eegprep/functions/popfunc/pop_plottopo.py index fa2b21bd..a4db0600 100644 --- a/src/eegprep/functions/popfunc/pop_plottopo.py +++ b/src/eegprep/functions/popfunc/pop_plottopo.py @@ -13,15 +13,17 @@ history_command, numeric_vector, parse_plot_options_text, - selected_indices, show_figures, ) +from eegprep.functions.popfunc._pop_utils import parse_key_value_args from eegprep.functions.sigprocfunc.plottopo import plottopo def pop_plottopo( EEG: dict[str, Any] | None = None, chans: Any = None, + plottitle: str = "", + singletrials: int = 0, *args: Any, gui: bool | None = None, renderer: Any | None = None, @@ -35,39 +37,35 @@ def pop_plottopo( """ if EEG is None: return (None, "") if return_com else None + options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) if gui is None: - gui = chans is None and not kwargs + gui = chans is None and not options if gui: result = _run_gui(EEG, renderer=renderer) if result is None: return (None, "") if return_com else None chans = result["chans"] - kwargs.update(result["options"]) - command_kwargs = dict(kwargs) - data, times = data_time_slice(EEG, kwargs.pop("timerange", None)) - singletrials = bool(kwargs.pop("singletrials", False)) - rect = bool(kwargs.pop("rect", False)) - plot_options = parse_plot_options_text(kwargs.pop("options", "")) - ydir = int(plot_options.pop("ydir", kwargs.pop("ydir", -1))) - if singletrials: - selected = selected_indices(chans, data.shape[0]) - plot_data = data[selected, :, :].transpose(0, 2, 1).reshape(selected.size * data.shape[2], data.shape[1]) - plot_channels = None - chanlocs = [] - else: - plot_data = np.nanmean(data, axis=2) - plot_channels = chans - chanlocs = EEG.get("chanlocs", []) + plottitle = result["plottitle"] + singletrials = int(result["singletrials"]) + options.update(result["options"]) + command_options = dict(options) + data, times = data_time_slice(EEG, options.pop("timerange", None)) + rect = bool(options.pop("rect", False)) + plot_options = parse_plot_options_text(options.pop("options", "")) + ydir = int(plot_options.pop("ydir", options.pop("ydir", -1))) + title = str(options.pop("title", plottitle or EEG.get("setname") or "Channel ERPs")) + plot_data = data if bool(singletrials) else np.nanmean(data, axis=2) figure = plottopo( plot_data, times=times, - chanlocs=chanlocs, - channels=plot_channels, - title=str(kwargs.pop("title", EEG.get("setname") or "Channel ERPs")), + chanlocs=EEG.get("chanlocs", []), + channels=chans, + title=title, ydir=ydir, rect=rect, + singletrials=bool(singletrials), ) - command = history_command("pop_plottopo", chans, **command_kwargs) + command = history_command("pop_plottopo", chans, plottitle, int(bool(singletrials)), **command_options) show_figures(figure, plot=plot) return (figure, command) if return_com else figure @@ -86,7 +84,7 @@ def pop_plottopo_dialog_spec(EEG: dict[str, Any]) -> DialogSpec: ControlSpec("text", "Plot in rect. array"), ControlSpec("checkbox", "(set=yes)", tag="rect", value=False), ControlSpec("text", "Other plot options (see help)"), - ControlSpec("edit", tag="options", value="'ydir', -1"), + ControlSpec("edit", tag="options", value="'ydir', 1"), ), geometry=((1, 1), (1, 1), (1, 1), (1, 1), (1, 1)), function_name="pop_plottopo", @@ -102,9 +100,9 @@ def _run_gui(EEG: dict[str, Any], *, renderer: Any | None = None) -> dict[str, A return None return { "chans": numeric_vector(result.get("chans", []), dtype=int).tolist(), + "plottitle": str(result.get("title", "") or ""), + "singletrials": bool(result.get("singletrials", False)), "options": { - "title": str(result.get("title", "") or ""), - "singletrials": bool(result.get("singletrials", False)), "rect": bool(result.get("rect", False)), "options": str(result.get("options", "") or ""), }, diff --git a/src/eegprep/functions/popfunc/pop_prop.py b/src/eegprep/functions/popfunc/pop_prop.py index a0088829..49b0f507 100644 --- a/src/eegprep/functions/popfunc/pop_prop.py +++ b/src/eegprep/functions/popfunc/pop_prop.py @@ -262,7 +262,7 @@ def _draw_continuous_erp_image(container: Any, samples: np.ndarray, srate: float def _draw_spectrum(ax: Any, EEG: dict[str, Any], spectrum_input: np.ndarray, spec_opt: Any, mapnorm: Any) -> None: """Draw the activity power spectrum from raw per-epoch data (spectopo).""" - spec_options = parse_plot_options_text(spec_opt) + spec_options = dict(spec_opt) if isinstance(spec_opt, dict) else parse_plot_options_text(spec_opt) spectra, freqs, _std = compute_spectra( spectrum_input, int(EEG.get("pnts", spectrum_input.shape[1]) or spectrum_input.shape[1]), @@ -271,6 +271,8 @@ def _draw_spectrum(ax: Any, EEG: dict[str, Any], spectrum_input: np.ndarray, spe overlap=_first_int(spec_options.get("overlap")) or 0, nfft=_first_int(spec_options.get("nfft")), mapnorm=mapnorm, + wintype=str(spec_options.get("wintype", "hamming")), + blckhn=_first_int(spec_options.get("blckhn")) or 2, ) ax.plot(freqs, spectra[0], color=EEGLAB_RED) # Match spectopo: x-limits from the requested band, y-limits hugging the data in that diff --git a/src/eegprep/functions/popfunc/pop_spectopo.py b/src/eegprep/functions/popfunc/pop_spectopo.py index 1c1e333f..1a63e353 100644 --- a/src/eegprep/functions/popfunc/pop_spectopo.py +++ b/src/eegprep/functions/popfunc/pop_spectopo.py @@ -274,14 +274,20 @@ def _raise_for_unsupported_component_options(options: dict[str, Any]) -> None: "pop_spectopo only supports whole-scalp component spectra (plotchan=0); " "per-electrode or max-power projection is not available in EEGPrep" ) - if "icamode" in options and not bool(options["icamode"]): - raise ValueError( - "pop_spectopo only supports component spectra (icamode on); (data-comp) spectra is not available in EEGPrep" + if "icamode" in options: + mode = options["icamode"] + supported = ( + str(mode).strip().lower() in {"normal", "on", "yes", "true", "1"} if isinstance(mode, str) else bool(mode) ) + if not supported: + raise ValueError( + "pop_spectopo only supports component spectra (icamode normal); " + "(data-comp) spectra is not available in EEGPrep" + ) def _split_spectopo_options(options: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: - spectral_keys = {"winsize", "overlap", "nfft"} + spectral_keys = {"winsize", "overlap", "nfft", "wintype", "blckhn"} spectral = {key: options[key] for key in spectral_keys if key in options} if "winsize" in spectral: spectral["winsize"] = int(numeric_vector(spectral["winsize"])[0]) @@ -289,6 +295,10 @@ def _split_spectopo_options(options: dict[str, Any]) -> tuple[dict[str, Any], di spectral["overlap"] = int(numeric_vector(spectral["overlap"])[0]) if "nfft" in spectral: spectral["nfft"] = int(numeric_vector(spectral["nfft"])[0]) + if "wintype" in spectral: + spectral["wintype"] = str(spectral["wintype"]) + if "blckhn" in spectral: + spectral["blckhn"] = int(numeric_vector(spectral["blckhn"])[0]) topoplot = {key: value for key, value in options.items() if key not in spectral_keys} for unused in {"icacomps", "icamaps", "nicamaps", "plotchan", "icamode"}: topoplot.pop(unused, None) diff --git a/src/eegprep/functions/popfunc/pop_topoplot.py b/src/eegprep/functions/popfunc/pop_topoplot.py index 52ec04ba..38bbf1b3 100644 --- a/src/eegprep/functions/popfunc/pop_topoplot.py +++ b/src/eegprep/functions/popfunc/pop_topoplot.py @@ -15,7 +15,7 @@ from eegprep.functions.guifunc.spec import ControlSpec, DialogSpec from eegprep.functions.miscfunc.misc import round_mat from eegprep.functions.popfunc._chanutils import chanlocs_as_list -from eegprep.functions.popfunc.plot_utils import component_map_data, show_figures +from eegprep.functions.popfunc.plot_utils import component_map_data, component_maps as ica_component_maps, show_figures from eegprep.functions.popfunc.plot_utils import history_command as plot_history_command from eegprep.functions.popfunc._pop_utils import is_on as _is_on from eegprep.functions.popfunc._pop_utils import parse_key_value_args, parse_numeric_sequence, parse_text_tokens @@ -83,6 +83,7 @@ def pop_topoplot( plotdip = 0 options = parse_key_value_args(option_args, kwargs, lowercase_kwargs=True) options = _normalise_topoplot_options(EEG, options) + channel_matrix = _channel_matrix(EEG) items_array = _parse_numeric_sequence(items) if items_array.size == 0: raise ValueError("Nothing to plot; provide at least one latency or component index") @@ -93,7 +94,11 @@ def pop_topoplot( maps, labels = _erp_maps(EEG, items_array) plot_chanlocs = chanlocs_as_list(EEG.get("chanlocs", [])) elif typeplot == 0: - maps, labels, plot_chanlocs = _component_maps(EEG, items_array) + if channel_matrix is None: + maps, labels, plot_chanlocs = _component_maps(EEG, items_array) + else: + maps, labels = _component_maps_without_locations(EEG, items_array) + plot_chanlocs = [] else: raise ValueError("typeplot must be 1 for ERP maps or 0 for component maps") @@ -108,6 +113,7 @@ def pop_topoplot( rowcols=rowcols_array, options=plot_options, component=typeplot == 0, + channel_matrix=channel_matrix, ) command = _history_command(typeplot, items_array, topotitle, rowcols_array, int(bool(plotdip)), options) @@ -236,6 +242,7 @@ def _plot_map_pages( rowcols: tuple[int, int], options: dict[str, Any], component: bool = False, + channel_matrix: np.ndarray | None = None, ) -> list[Any]: rows, cols = rowcols per_page = rows * cols @@ -253,7 +260,10 @@ def _plot_map_pages( ax.axis("off") continue # pop_topoplot owns the colorbar so it can label component maps by polarity. - topoplot(values, chanlocs, axes=ax, colorbar=False, maplimits=maplimits, **options) + if channel_matrix is None: + topoplot(values, chanlocs, axes=ax, colorbar=False, maplimits=maplimits, **options) + else: + _plot_channel_grid(ax, values, channel_matrix, maplimits=maplimits, options=options) if ax.images: colorbar_image = ax.images[-1] plotted_axes.append(ax) @@ -307,7 +317,6 @@ def _numeric_maplimits(maplimits: Any) -> tuple[float, float] | None: def _erp_maps(EEG: dict[str, Any], latencies_ms: np.ndarray) -> tuple[list[np.ndarray | None], list[str]]: - _require_chanlocs(EEG) data = np.asarray(EEG.get("data")) if data.ndim == 2: data = data[:, :, np.newaxis] @@ -349,6 +358,74 @@ def _component_maps( return maps, labels, chanlocs +def _component_maps_without_locations( + EEG: dict[str, Any], components: np.ndarray +) -> tuple[list[np.ndarray | None], list[str]]: + icawinv = ica_component_maps(EEG) + maps = [] + labels = [] + for component in components: + if np.isnan(component): + maps.append(None) + labels.append("") + continue + index = int(abs(component)) + if index < 1 or index > icawinv.shape[1]: + raise ValueError(f"component index {index} is outside available ICA components") + values = icawinv[:, index - 1] + maps.append(-values if component < 0 else values) + labels.append(f"IC {int(component)}") + return maps, labels + + +def _plot_channel_grid( + ax: Any, + values: np.ndarray, + channel_matrix: np.ndarray, + *, + maplimits: Any, + options: dict[str, Any], +) -> None: + flat_values = np.asarray(values, dtype=float).reshape(-1) + grid_values = np.full(channel_matrix.shape, np.nan, dtype=float) + nonzero = channel_matrix != 0 + indices = np.abs(channel_matrix[nonzero]) - 1 + if indices.size and np.max(indices) >= flat_values.size: + raise ValueError("EEG.chanmatrix references channels unavailable in the plotted map") + grid_values[nonzero] = flat_values[indices] * np.sign(channel_matrix[nonzero]) + low, high = _grid_maplimits(grid_values, maplimits) + colormap = plt.get_cmap(options.get("colormap") or "turbo").copy() + colormap.set_bad("white" if _is_on(options.get("whitebk", "off")) else ax.get_facecolor()) + ax.imshow( + np.ma.masked_invalid(grid_values), + cmap=colormap, + vmin=low, + vmax=high, + origin="upper", + interpolation="nearest", + aspect="equal", + ) + ax.set_xticks([]) + ax.set_yticks([]) + + +def _grid_maplimits(values: np.ndarray, maplimits: Any) -> tuple[float, float]: + finite = values[np.isfinite(values)] + if finite.size == 0: + return -1.0, 1.0 + numeric = _numeric_maplimits(maplimits) + if numeric is not None: + return numeric + setting = str(maplimits or "absmax").lower() + if setting == "absmax": + limit = float(np.max(np.abs(finite))) + return (-limit, limit) if limit else (-1.0, 1.0) + if setting in {"maxmin", "minmax"}: + low, high = float(np.min(finite)), float(np.max(finite)) + return (low, high) if high > low else (low - 1.0, high + 1.0) + raise ValueError("topoplot maplimits must be 'absmax', 'maxmin', or [min max]") + + def _latency_positions(EEG: dict[str, Any], latencies_ms: np.ndarray) -> np.ndarray: pnts = int(EEG.get("pnts", 0)) xmin = float(EEG.get("xmin", 0)) @@ -453,11 +530,28 @@ def _require_ica(EEG: dict[str, Any]) -> None: def _validate_topoplot_inputs(EEG: dict[str, Any], typeplot: int) -> None: - _require_chanlocs(EEG) + channel_matrix = _channel_matrix(EEG) + if channel_matrix is None: + _require_chanlocs(EEG) if typeplot == 0: _require_ica(EEG) +def _channel_matrix(EEG: dict[str, Any]) -> np.ndarray | None: + if "chanmatrix" not in EEG: + return None + matrix = np.asarray(EEG.get("chanmatrix"), dtype=float) + if matrix.size == 0: + raise ValueError("EEG.chanmatrix is empty") + if matrix.ndim != 2 or not np.all(np.isfinite(matrix)) or not np.all(matrix == np.trunc(matrix)): + raise ValueError("EEG.chanmatrix must be a finite 2-D matrix of channel indices") + matrix = matrix.astype(int) + channel_count = int(EEG.get("nbchan", np.asarray(EEG.get("data")).shape[0]) or 0) + if np.max(np.abs(matrix), initial=0) > channel_count: + raise ValueError(f"EEG.chanmatrix indices must be within -{channel_count}..{channel_count}") + return matrix + + def _is_plotdip_value(value: Any) -> bool: return value is None or isinstance(value, (bool, int, float, np.integer, np.floating)) diff --git a/src/eegprep/functions/sigprocfunc/envtopo.py b/src/eegprep/functions/sigprocfunc/envtopo.py index 700699e6..945ae866 100644 --- a/src/eegprep/functions/sigprocfunc/envtopo.py +++ b/src/eegprep/functions/sigprocfunc/envtopo.py @@ -130,6 +130,7 @@ def envtopo( 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) + compnums, compsplot = _legacy_component_selection(compnums, compsplot) candidates = _resolve_components(compnums, n_components) removed = _resolve_subcomps(subcomps, n_components, candidates) @@ -179,6 +180,16 @@ def envtopo( return EnvtopoResult(compvarorder, compvars, compframes, comptimes, compsplotted, metric, figure) +def _legacy_component_selection(compnums: Any, compsplot: int) -> tuple[Any, int]: + """Translate EEGLAB's legacy ``compnums=-N`` spelling to top-N ranking.""" + if _is_empty(compnums): + return compnums, abs(int(compsplot)) + values = np.asarray(compnums, dtype=int).ravel() + if values.size == 1 and values[0] < 0: + return None, abs(int(values[0])) + return compnums, abs(int(compsplot)) + + 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) diff --git a/src/eegprep/functions/sigprocfunc/plottopo.py b/src/eegprep/functions/sigprocfunc/plottopo.py index dfb2047b..08eca461 100644 --- a/src/eegprep/functions/sigprocfunc/plottopo.py +++ b/src/eegprep/functions/sigprocfunc/plottopo.py @@ -21,14 +21,19 @@ def plottopo( ydir: int = -1, ylimits: Any = None, rect: bool = False, + singletrials: bool = False, ): - """Plot channel/component traces in an EEGLAB-like array.""" + """Plot channel/component traces in an EEGLAB-like array. + + Three-dimensional input is averaged over trials by default. Set + ``singletrials=True`` to overlay every trial in each channel panel. + """ values = np.asarray(data, dtype=float) - if values.ndim == 3: + if values.ndim == 3 and not singletrials: values = np.nanmean(values, axis=2) - if values.ndim != 2: - raise ValueError("plottopo data must be channels x points") - count, points = values.shape + if values.ndim not in {2, 3}: + raise ValueError("plottopo data must be channels x points or channels x points x trials") + count, points = values.shape[:2] x_values = ( np.asarray(times, dtype=float).ravel() if times is not None and len(np.asarray(times).ravel()) @@ -135,10 +140,10 @@ def _plot_trace( ax.plot(x_values, values, color="black", linewidth=0.8) ax.axhline(0, color="0.75", linewidth=0.6) ax.set_title(label, fontsize=9) - if ydir < 0: - ax.invert_yaxis() if limits is not None: ax.set_ylim(limits) + if ydir < 0: + ax.invert_yaxis() ax.tick_params(labelsize=7) diff --git a/src/eegprep/functions/sigprocfunc/spectopo.py b/src/eegprep/functions/sigprocfunc/spectopo.py index d6e00564..8ceb1f7f 100644 --- a/src/eegprep/functions/sigprocfunc/spectopo.py +++ b/src/eegprep/functions/sigprocfunc/spectopo.py @@ -44,6 +44,8 @@ def spectopo( winsize: int | None = None, overlap: int = 0, nfft: int | None = None, + wintype: str = "hamming", + blckhn: int = 2, ): """Compute and optionally plot channel/component log power spectra. @@ -60,6 +62,8 @@ def spectopo( winsize=winsize, overlap=overlap, nfft=nfft, + wintype=wintype, + blckhn=blckhn, ) figure = None if str(plot).lower() != "off": @@ -85,6 +89,8 @@ def compute_spectra( overlap: int = 0, nfft: int | None = None, mapnorm: Any = None, + wintype: str = "hamming", + blckhn: int = 2, ) -> tuple[np.ndarray, np.ndarray, None]: """Return Welch spectra in dB as ``channels x frequencies``. @@ -126,11 +132,21 @@ def compute_spectra( epochs = epochs[:, :keep, :] pnts = keep + window_type = str(wintype).strip().lower() + if window_type not in {"hamming", "blackmanharris"}: + raise ValueError("wintype must be 'hamming' or 'blackmanharris'") nperseg = int(winsize or min(round(srate), pnts)) + if window_type == "blackmanharris": + divisor = int(blckhn) + if divisor <= 0: + raise ValueError("blckhn must be a positive integer") + nperseg = int(np.floor(nperseg / divisor + 0.5)) nperseg = max(1, min(nperseg, pnts)) noverlap = max(0, min(int(overlap), nperseg - 1)) - # symmetric Hamming + no detrend to match MATLAB pwelch - window = get_window("hamming", nperseg, fftbins=False) + # EEGLAB uses symmetric windows and disables detrending in pwelch. + window = get_window(window_type, nperseg, fftbins=False) + if window_type == "blackmanharris": + nfft = 1 << int(np.ceil(np.log2(nperseg))) freqs = None psd_sum: np.ndarray | None = None for index in range(trials): diff --git a/src/eegprep/resources/help/pop_envtopo.md b/src/eegprep/resources/help/pop_envtopo.md index dbbd285b..ff2e5510 100644 --- a/src/eegprep/resources/help/pop_envtopo.md +++ b/src/eegprep/resources/help/pop_envtopo.md @@ -8,6 +8,10 @@ fig, com = pop_envtopo(EEG, timerange=[-100, 300], return_com=True) This requires epoched data, channel locations, and ICA weights/maps. +`compnums=-N` retains EEGLAB's legacy shorthand for ranking all components and +plotting the `N` largest contributors. Positive component indices restrict the +candidate set instead. + Leaving the "Component numbers to remove from data before plotting" field blank removes no components. diff --git a/src/eegprep/resources/help/pop_plotdata.md b/src/eegprep/resources/help/pop_plotdata.md index 91eb3e0c..17206210 100644 --- a/src/eegprep/resources/help/pop_plotdata.md +++ b/src/eegprep/resources/help/pop_plotdata.md @@ -1,11 +1,17 @@ # pop_plotdata -Plots ICA component ERP activations in a rectangular array. +Plots selected channel or ICA-component activity. Selected trials are averaged +by default, or overlaid per channel/component with `singletrials=1`. ```python -fig, com = pop_plotdata(EEG, components=[1, 2, 3], return_com=True) +channel_fig, com = pop_plotdata( + EEG, 1, [1, 2, 3], [2, 5, 7], "Selected channel ERPs", return_com=True +) +component_fig = pop_plotdata(EEG, components=[1, 2, 3], plot="off") ``` -This requires ICA activations or ICA weights. +`typeplot=1` selects channels and `typeplot=0` selects components. The +`components=` spelling is a convenience for component mode. All indices are +1-based. Component mode requires ICA activations or ICA weights. When scripting, pass `plot='off'` to build the figure without opening a window; the default `plot='on'` displays it. diff --git a/src/eegprep/resources/help/pop_plottopo.md b/src/eegprep/resources/help/pop_plottopo.md index 3bcfd13f..1546eb61 100644 --- a/src/eegprep/resources/help/pop_plottopo.md +++ b/src/eegprep/resources/help/pop_plottopo.md @@ -8,5 +8,8 @@ fig, com = pop_plottopo(EEG, chans=[1, 2, 3], rect=False, return_com=True) ``` Channel indices are EEGLAB-facing and 1-based. +The positional form `pop_plottopo(EEG, channels, title, singletrials)` is also +supported. Set `singletrials=1` to overlay the selected dataset trials in each +channel panel instead of plotting the trial average. When scripting, pass `plot='off'` to build the figure without opening a window; the default `plot='on'` displays it. diff --git a/src/eegprep/resources/help/pop_spectopo.md b/src/eegprep/resources/help/pop_spectopo.md index e66597ba..40c79ee1 100644 --- a/src/eegprep/resources/help/pop_spectopo.md +++ b/src/eegprep/resources/help/pop_spectopo.md @@ -10,6 +10,10 @@ result, com = pop_spectopo(EEG, dataflag=1, freqs=[6, 10, 22], return_com=True) `dataflag=1` plots channel spectra. `dataflag=0` plots component spectra and requires ICA activations or ICA weights. +Use `wintype="hamming"` (the default) or `wintype="blackmanharris"`. For a +Blackman-Harris analysis, `blckhn` divides the requested/default window length +before the next-power-of-two FFT length is chosen, matching EEGLAB. + For `dataflag=0`, the spectra panel overlays the bold black RMS-power curve of the channel data, marks the analysis frequency with a vertical line, and draws the scalp maps of the `nicamaps` components with the most power at that diff --git a/src/eegprep/resources/help/pop_topoplot.md b/src/eegprep/resources/help/pop_topoplot.md index b519b7a5..69afde1b 100644 --- a/src/eegprep/resources/help/pop_topoplot.md +++ b/src/eegprep/resources/help/pop_topoplot.md @@ -23,6 +23,12 @@ Component colorbars use `-`, `0`, and `+` labels only when their limits span both negative and positive values. One-sided scales retain numeric labels and the exact requested endpoints. +When `EEG["chanmatrix"]` is present, maps use its rectangular signed channel +grid instead of interpolated channel locations. Positive entries select the +corresponding 1-based channel, negative entries invert its value, and zero +entries leave a blank cell. This mode also works when `EEG["chanlocs"]` is +empty. + DIPFIT dipole overlays and 3-D head plots are handled by later Phase 4 work. When scripting, pass `plot='off'` to build the figure without opening a window; the default `plot='on'` displays it. diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index 91e17cfc..8d1abe62 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1183,7 +1183,7 @@ def test_phase4_dialog_specs_match_eeglab_selector_layouts(sample_eeg, ica_epoch plottopo_controls = controls_by_tag(pop_plottopo_dialog_spec(sample_eeg)) assert plottopo_controls["rect"].value is False - assert plottopo_controls["options"].value == "'ydir', -1" + assert plottopo_controls["options"].value == "'ydir', 1" chanplot_controls = controls_by_tag(pop_chanplot_dialog_spec({"name": "demo study"}, [sample_eeg])) assert chanplot_controls["chan_list"].string.startswith("All channels|") diff --git a/tests/test_popfunc_visual_eeglab_tests.py b/tests/test_popfunc_visual_eeglab_tests.py new file mode 100644 index 00000000..b6b3e7f0 --- /dev/null +++ b/tests/test_popfunc_visual_eeglab_tests.py @@ -0,0 +1,498 @@ +"""Meaningful headless ports of current EEGLAB visual pop-function tests.""" + +from __future__ import annotations + +from copy import deepcopy + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import pytest +from scipy.signal import get_window, welch + +from eegprep.functions.popfunc.eeg_multieegplot import eeg_multieegplot +from eegprep.functions.popfunc.pop_chansel import pop_chansel, pop_chansel_display_values +from eegprep.functions.popfunc.pop_compareerps import pop_compareerps +from eegprep.functions.popfunc.pop_comperp import pop_comperp +from eegprep.functions.popfunc.pop_crossf import pop_crossf +from eegprep.functions.popfunc.pop_eegplot import pop_eegplot +from eegprep.functions.popfunc.pop_envtopo import pop_envtopo +from eegprep.functions.popfunc.pop_erpimage import pop_erpimage +from eegprep.functions.popfunc.pop_headplot import pop_headplot +from eegprep.functions.popfunc.pop_loadset import pop_loadset +from eegprep.functions.popfunc.pop_newcrossf import pop_newcrossf +from eegprep.functions.popfunc.pop_plotdata import pop_plotdata, pop_plotdata_dialog_spec +from eegprep.functions.popfunc.pop_plottopo import pop_plottopo +from eegprep.functions.popfunc.pop_prop import pop_prop +from eegprep.functions.popfunc.pop_selectcomps import pop_selectcomps +from eegprep.functions.popfunc.pop_spectopo import pop_spectopo +from eegprep.functions.popfunc.pop_timef import pop_timef +from eegprep.functions.popfunc.pop_timtopo import pop_timtopo +from eegprep.functions.popfunc.pop_topoplot import pop_topoplot +from eegprep.functions.sigprocfunc.eegplot import winrej_to_array +from tests.eeglab_tests import eeglab_test +from tests.fixtures import SAMPLE_DATASET_PATH + + +def _source(name: str) -> str: + return f"unittesting_popfunc/{name}/popfunc_{name}_wrapperTest.m" + + +@pytest.fixture(autouse=True) +def _close_figures(): + yield + plt.close("all") + + +@pytest.fixture +def epoched_eeg() -> dict: + rng = np.random.default_rng(9173) + channels, points, trials, components = 6, 64, 8, 4 + srate = 64.0 + xmin = -0.25 + seconds = xmin + np.arange(points) / srate + data = np.empty((channels, points, trials), dtype=float) + for channel in range(channels): + for trial in range(trials): + data[channel, :, trial] = ( + (channel + 1) * 0.2 + + np.sin(2 * np.pi * (5 + channel) * seconds + trial * 0.11) + + rng.normal(scale=0.01, size=points) + ) + + raw_weights = rng.normal(size=(components, channels)) + weights = np.linalg.qr(raw_weights.T)[0].T[:components] + maps = np.linalg.pinv(weights) + activations = np.einsum("kc,cpt->kpt", weights, data) + chanlocs = [] + for index in range(channels): + theta = 360.0 * index / channels + radius = 0.32 + chanlocs.append( + { + "labels": f"Ch{index + 1}", + "theta": theta, + "radius": radius, + "X": radius * np.cos(np.deg2rad(theta)), + "Y": radius * np.sin(np.deg2rad(theta)), + "Z": 0.15 + 0.02 * (index % 2), + } + ) + events = [ + { + "type": "rt", + "latency": trial * points + 17, + "epoch": trial + 1, + "rt": float(trials - trial), + } + for trial in range(trials) + ] + return { + "data": data, + "nbchan": channels, + "pnts": points, + "trials": trials, + "srate": srate, + "xmin": xmin, + "xmax": float(seconds[-1]), + "times": seconds * 1000.0, + "setname": "synthetic epochs", + "chanlocs": chanlocs, + "chaninfo": {}, + "event": events, + "epoch": [{"event": [index]} for index in range(trials)], + "icaweights": weights, + "icasphere": np.eye(channels), + "icawinv": maps, + "icaact": activations, + "icachansind": np.arange(channels), + "reject": {}, + "splinefile": "", + "icasplinefile": "", + } + + +@pytest.fixture +def continuous_eeg(epoched_eeg: dict) -> dict: + eeg = deepcopy(epoched_eeg) + eeg["data"] = epoched_eeg["data"].transpose(0, 2, 1).reshape(epoched_eeg["nbchan"], -1) + eeg["icaact"] = epoched_eeg["icaact"].transpose(0, 2, 1).reshape(epoched_eeg["icaact"].shape[0], -1) + eeg["pnts"] = eeg["data"].shape[1] + eeg["trials"] = 1 + eeg["xmin"] = 0.0 + eeg["xmax"] = (eeg["pnts"] - 1) / eeg["srate"] + eeg["times"] = np.arange(eeg["pnts"]) * 1000.0 / eeg["srate"] + eeg["event"] = [] + eeg["epoch"] = [] + eeg["setname"] = "synthetic continuous" + return eeg + + +@eeglab_test(_source("eeg_multieegplot"), "test_pass_continuous") +def test_eeg_multieegplot_continuous_preserves_channel_major_samples() -> None: + data = np.asarray([[1, 1, 1, 2, 2], [2, 1, 2, 1, 1]], dtype=float) + + model = eeg_multieegplot(data, np.zeros((0, 4)), np.zeros(2), show=False) + + np.testing.assert_array_equal(model.data.data, data[:, :, np.newaxis]) + assert model.data.mode == "continuous" + assert model.state.winlength == 5 + assert model.state.xgrid is False + + +@eeglab_test(_source("eeg_multieegplot"), "test_pass_continuous_reject") +def test_eeg_multieegplot_continuous_translates_new_rejection_regions() -> None: + model = eeg_multieegplot(np.zeros((2, 20)), [[0, 0, 3, 7]], np.ones((2, 20)), show=False) + + rows = winrej_to_array(model.state.winrej, 2) + np.testing.assert_array_equal(rows[:, :2], [[3, 7]]) + np.testing.assert_allclose(rows[:, 2:5], [[0.8, 0.8, 1.0]]) + np.testing.assert_array_equal(rows[:, 5:], [[0, 0]]) + + +@eeglab_test(_source("eeg_multieegplot"), "test_pass_epochs") +def test_eeg_multieegplot_epochs_translate_trial_and_electrode_marks() -> None: + data = np.zeros((2, 3, 3), dtype=float) + trial_rejection = np.asarray([0, 1, 0]) + electrode_rejection = np.asarray([[0, 1, 0], [0, 0, 0]]) + + model = eeg_multieegplot(data, trial_rejection, electrode_rejection, show=False) + + rows = winrej_to_array(model.state.winrej, 2) + np.testing.assert_array_equal(rows[:, :2], [[3, 5]]) + np.testing.assert_array_equal(rows[:, 5:], [[1, 0]]) + assert model.data.mode == "epoched" + + +@eeglab_test(_source("pop_chansel"), "test_test_pop_chansel") +def test_pop_chansel_returns_selected_indices_labels_and_text(monkeypatch: pytest.MonkeyPatch) -> None: + seen = {} + + def choose(**kwargs): + seen.update(kwargs) + return [1, 3], True, "" + + monkeypatch.setattr("eegprep.functions.popfunc.pop_chansel.listdlg2", choose) + + indices, text, labels = pop_chansel(["Fz", "C z", "Pz"], withindex="on", select=[1, 3], selectionmode="multiple") + + assert indices == [1, 3] + assert labels == ["Fz", "Pz"] + assert text == "Fz Pz" + assert seen["liststring"] == ["1 - Fz", "2 - C z", "3 - Pz"] + assert seen["initialvalue"] == [1, 3] + assert pop_chansel_display_values(["Fz", "Cz"], withindex="off") == ["Fz", "Cz"] + + +@eeglab_test(_source("pop_compareerps"), "test_i_pass_general") +def test_pop_compareerps_gui_path_averages_selected_datasets(epoched_eeg: dict) -> None: + datasets = [deepcopy(epoched_eeg), deepcopy(epoched_eeg)] + datasets[1]["data"] = datasets[1]["data"] + 2.0 + + class Renderer: + def run(self, _spec, initial_values=None): + return {"datadd": "1 2", "datsub": "", "chans": "1", "addavg": True} + + result, command = pop_compareerps(datasets, gui=True, renderer=Renderer(), return_com=True) + + expected = np.mean([dataset["data"][0].mean(axis=1) for dataset in datasets], axis=0) + np.testing.assert_allclose(result["erp1"][0], expected) + assert command == "pop_compareerps(ALLEEG);" + + +@eeglab_test(_source("pop_compareerps"), "test_i_pass_sets_chans_title") +def test_pop_compareerps_honors_dataset_channel_subset_and_title(epoched_eeg: dict) -> None: + datasets = [deepcopy(epoched_eeg) for _ in range(3)] + for index, dataset in enumerate(datasets, start=1): + dataset["data"] = dataset["data"] * index + + result, command = pop_compareerps( + datasets, setlist=[1, 3], chansubset=[2, 4], plottitle="Comparing datasets", gui=False, return_com=True + ) + + expected = np.mean([datasets[index]["data"][[1, 3]].mean(axis=2) for index in (0, 2)], axis=0) + np.testing.assert_allclose(result["erp1"], expected) + assert result["figure"].axes[0].get_title() == "Comparing datasets" + assert "[1 3], [2 4], 'Comparing datasets'" in command + + +@eeglab_test(_source("pop_comperp"), "test_test_pop_comperp") +def test_pop_comperp_computes_channel_and_component_differences(epoched_eeg: dict) -> None: + first = deepcopy(epoched_eeg) + second = deepcopy(epoched_eeg) + second["data"] = second["data"] + 0.75 + second["icaact"] = second["icaact"] - 0.5 + + channels = pop_comperp([first, second], 1, [1], [2], chans=[1, 2], std="on", allerps="on") + components = pop_comperp([first, second], 0, [1], [2], chans=[1, 3], std="on", allerps="on") + + np.testing.assert_allclose(channels["erpsub"], -0.75) + np.testing.assert_allclose(components["erpsub"], 0.5) + assert channels["figure"].axes[0].lines + assert components["figure"].axes[0].lines + + +@eeglab_test(_source("pop_crossf"), "test_pass_tlimits_empty") +def test_pop_crossf_derives_time_limits_when_empty(epoched_eeg: dict) -> None: + result, command = pop_crossf(epoched_eeg, 1, 1, 2, None, [0], timesout=8, return_com=True) + + assert result.coherence.shape == result.phase.shape + assert result.times.size <= 8 + assert np.isfinite(result.coherence).all() + assert command.startswith("pop_crossf(EEG, 1, 1, 2") + + +@eeglab_test(_source("pop_crossf"), "test_test_pop_crossf") +def test_pop_crossf_channel_and_component_paths_use_requested_signals(epoched_eeg: dict) -> None: + limits = [epoched_eeg["times"][0], epoched_eeg["times"][-1]] + channels = pop_crossf(epoched_eeg, 1, 2, 4, limits, [0], timesout=8, type="phasecoher") + components = pop_crossf(epoched_eeg, 0, 1, 3, limits, [0], timesout=8, type="phasecoher") + + assert channels.alltf_x.shape[-1] == epoched_eeg["trials"] + assert components.alltf_x.shape[-1] == epoched_eeg["trials"] + assert not np.allclose(channels.coherence, components.coherence) + + +@eeglab_test(_source("pop_eegplot"), "test_test_pop_eegplot") +def test_pop_eegplot_builds_continuous_channel_and_epoched_component_models( + continuous_eeg: dict, epoched_eeg: dict +) -> None: + continuous = pop_eegplot(continuous_eeg, 1, 0, 0, show=False) + components = pop_eegplot(epoched_eeg, 0, 0, 0, show=False) + + assert continuous.data.mode == "continuous" + assert continuous.data.data.shape == (*continuous_eeg["data"].shape, 1) + assert components.data.mode == "component" + np.testing.assert_array_equal(components.data.data, epoched_eeg["icaact"]) + + +@eeglab_test(_source("pop_envtopo"), "test_test_pop_envtopo") +def test_pop_envtopo_supports_legacy_negative_component_count_and_contribution_window(epoched_eeg: dict) -> None: + limits = [epoched_eeg["times"][0], epoched_eeg["times"][-1]] + + figure, command = pop_envtopo( + epoched_eeg, + limits, + limcontrib=[0, 300], + compnums=-3, + electrodes="off", + return_com=True, + ) + + map_axes = [axis for axis in figure.axes if axis.images] + assert len(map_axes) == 3 + assert all(axis.get_title().startswith("IC ") for axis in map_axes) + assert "compnums=-3" in command + assert "limcontrib=[0, 300]" in command + + +@eeglab_test(_source("pop_erpimage"), "test_test_pop_erpimage") +def test_pop_erpimage_sorts_channel_trials_and_projects_components(epoched_eeg: dict) -> None: + channels = pop_erpimage( + epoched_eeg, + 1, + 2, + sortingeventfield="rt", + sortingtype=["rt"], + renorm="yes", + smooth=1, + cbar=False, + ) + components = pop_erpimage(epoched_eeg, 0, 2, projchan=[1, 3], smooth=1, cbar=False) + + channel_values = epoched_eeg["data"][1] + np.testing.assert_allclose(channels["image"], channel_values.T[::-1]) + projection = epoched_eeg["icawinv"][[0, 2], 1].mean() * epoched_eeg["icaact"][1] + np.testing.assert_allclose(components["image"], projection.T) + with pytest.raises(ValueError, match="phase2"): + pop_erpimage(epoched_eeg, 1, 2, phase2=0.1) + + +@eeglab_test(_source("pop_headplot"), "test_test_pop_headplot") +def test_pop_headplot_creates_reusable_spline_and_finite_3d_maps(tmp_path) -> None: + eeg = pop_loadset(SAMPLE_DATASET_PATH) + spline = tmp_path / "current_suite.spl" + setup = {"splinefile": str(spline), "transform": [0, -10, 0, -0.1, 0, -1.6, 1100, 1100, 1100]} + + figures, command = pop_headplot(eeg, 1, [0, 100], "ERP scalp maps", [1, 2], setup=setup, return_com=True) + + assert spline.exists() + assert len(figures) == 1 + assert all(axis.name == "3d" for axis in figures[0].axes[:2]) + facecolors = np.concatenate( + [collection.get_facecolors() for axis in figures[0].axes[:2] for collection in axis.collections] + ) + assert np.isfinite(facecolors).all() + assert "setup={" in command + + +@eeglab_test(_source("pop_newcrossf"), "test_test_pop_newcrossf") +def test_pop_newcrossf_channel_and_component_coherence_are_bounded(epoched_eeg: dict) -> None: + limits = [epoched_eeg["times"][0], epoched_eeg["times"][-1]] + channels = pop_newcrossf(epoched_eeg, 1, 1, 2, limits, [0], timesout=8, type="phasecoher") + components = pop_newcrossf(epoched_eeg, 0, 1, 2, limits, [0], timesout=8, type="phasecoher") + + for result in (channels, components): + assert result.coherence.shape == result.phase.shape + assert np.all(result.coherence >= 0) + assert np.all(result.coherence <= 1 + 1e-12) + assert result.alltf_x.shape[-1] == epoched_eeg["trials"] + + +@eeglab_test(_source("pop_plotdata"), "test_test_pop_plotdata") +def test_pop_plotdata_selects_modes_trials_averages_and_single_trial_overlays(epoched_eeg: dict) -> None: + channel_figure, command = pop_plotdata( + epoched_eeg, + 1, + [2, 4], + [2, 5, 7], + "selected channels", + 0, + -1, + [-2, 2], + return_com=True, + ) + component_figure = pop_plotdata(epoched_eeg, 0, [1, 3], [1, 4], "components", 1, 1, [0, 0]) + + channel_axes = {axis.get_title(): axis for axis in channel_figure.axes} + expected = epoched_eeg["data"][1, :, [1, 4, 6]].mean(axis=0) + np.testing.assert_allclose(channel_axes["Ch2"].lines[0].get_ydata(), expected) + assert channel_axes["Ch2"].yaxis_inverted() + component_axes = {axis.get_title(): axis for axis in component_figure.axes} + assert len(component_axes["1"].lines) == 3 # two trials plus the zero reference + assert not component_axes["1"].yaxis_inverted() + assert "pop_plotdata(EEG, 1, [2, 4], [2, 5, 7]" in command + assert pop_plotdata_dialog_spec(epoched_eeg, typeplot=1).title.startswith("Channel ERPs") + assert pop_plotdata_dialog_spec(epoched_eeg, typeplot=0).title.startswith("Component ERPs") + + +@eeglab_test(_source("pop_plottopo"), "test_test_pop_plottopo") +def test_pop_plottopo_draws_selected_channel_trial_averages(epoched_eeg: dict) -> None: + figure, command = pop_plottopo(epoched_eeg, [1, 3, 5], "selected channels", 0, return_com=True) + single_trials = pop_plottopo(epoched_eeg, [1, 3], "single trials", 1, rect=True) + + axes = {axis.get_title(): axis for axis in figure.axes} + np.testing.assert_allclose(axes["Ch3"].lines[0].get_ydata(), epoched_eeg["data"][2].mean(axis=1)) + assert len(axes) == 3 + assert axes["Ch3"].yaxis_inverted() + assert command.startswith("pop_plottopo(EEG, [1, 3, 5], 'selected channels', 0)") + assert len(single_trials.axes[0].lines) == epoched_eeg["trials"] + 1 + + +@eeglab_test(_source("pop_prop"), "test_test_pop_prop") +def test_pop_prop_builds_channel_and_component_property_panels(epoched_eeg: dict, continuous_eeg: dict) -> None: + channel = pop_prop(epoched_eeg, 1, 2, 0, {"freqrange": [2, 25]}, plot="off") + components = pop_prop(epoched_eeg, 0, [1, 3], 0, {"freqrange": [2, 25]}, plot="off") + continuous = pop_prop(continuous_eeg, 1, 1, 0, {"freqrange": [2, 25]}, plot="off") + + assert any(axis.get_title() == "Channel 2" for axis in channel.axes) + assert [next(axis for axis in figure.axes if axis.get_xlabel() == "Frequency (Hz)") for figure in components] + assert any("continu" in axis.get_title().lower() for axis in continuous.axes) + + +@eeglab_test(_source("pop_selectcomps"), "test_test_pop_selectcomps") +def test_pop_selectcomps_marks_only_requested_components_without_mutating_input(epoched_eeg: dict) -> None: + before = set(plt.get_fignums()) + selected, command = pop_selectcomps(epoched_eeg, [1, 2, 3, 4], reject=[2, 4], plot=True, return_com=True) + + assert "gcompreject" not in epoched_eeg["reject"] + np.testing.assert_array_equal(selected["reject"]["gcompreject"], [0, 1, 0, 1]) + assert command == "EEG = pop_selectcomps(EEG, [1 2 3 4], reject=[2 4]);" + created = set(plt.get_fignums()) - before + assert len(created) == 1 + assert [axis.get_title() for axis in plt.figure(created.pop()).axes[:4]] == ["IC 1", "IC 2", "IC 3", "IC 4"] + + +@eeglab_test(_source("pop_spectopo"), "test_test_pop_spectopo") +def test_pop_spectopo_blackman_harris_matches_welch_and_component_mode(epoched_eeg: dict) -> None: + channel = pop_spectopo( + epoched_eeg, + 1, + [epoched_eeg["times"][0], epoched_eeg["times"][-1]], + "EEG", + percent=100, + freq=[8, 10], + freqrange=[2, 25], + wintype="blackmanharris", + blckhn=2, + ) + component = pop_spectopo(epoched_eeg, 0, None, "EEG", freq=[10], plotchan=0, icacomps=[1, 2], nicamaps=2) + + window = get_window("blackmanharris", 32, fftbins=False) + powers = [] + for trial in range(epoched_eeg["trials"]): + frequencies, power = welch( + epoched_eeg["data"][:, :, trial], + fs=64.0, + window=window, + nperseg=32, + noverlap=0, + nfft=32, + detrend=False, + axis=1, + scaling="density", + ) + powers.append(power) + expected = 10 * np.log10(np.mean(powers, axis=0)) + np.testing.assert_allclose(channel["freqs"], frequencies) + np.testing.assert_allclose(channel["spectra"], expected, rtol=1e-12, atol=1e-12) + assert component["spectra"].shape[0] == 2 + assert np.isfinite(component["spectra"]).all() + with pytest.raises(ValueError, match="whole-scalp component spectra"): + pop_spectopo(epoched_eeg, 0, None, "EEG", freq=[10], plotchan=3, icacomps=[1, 2]) + with pytest.raises(ValueError, match="data-comp"): + pop_spectopo(epoched_eeg, 0, None, "EEG", freq=[10], plotchan=0, icamode="sub", icacomps=[1, 2]) + + +@eeglab_test(_source("pop_timef"), "test_test_pop_timef") +def test_pop_timef_channel_and_component_results_have_consistent_tf_arrays(epoched_eeg: dict) -> None: + limits = [epoched_eeg["times"][0], epoched_eeg["times"][-1]] + channel, channel_command = pop_timef( + epoched_eeg, 1, 2, limits, [0], freqs=[4, 20], timesout=8, plotphase="off", return_com=True + ) + component = pop_timef(epoched_eeg, 0, 1, limits, [0], freqs=[4, 20], timesout=8, plotphase="off") + + for result in (channel, component): + assert result.ersp.shape == result.itc.shape + assert result.tfdata.shape[:2] == result.ersp.shape + assert result.tfdata.shape[-1] == epoched_eeg["trials"] + assert np.isfinite(result.ersp).all() + assert channel_command.startswith("pop_timef(EEG, 1, 2") + + +@eeglab_test(_source("pop_timtopo"), "test_test_pop_timtopo") +def test_pop_timtopo_nan_latency_selects_global_power_peak(epoched_eeg: dict) -> None: + erp = epoched_eeg["data"].mean(axis=2) + expected_index = int(np.argmax(np.sum(erp**2, axis=0))) + expected_latency = float(epoched_eeg["times"][expected_index]) + + figure, command = pop_timtopo(epoched_eeg, [np.nan], title="ERP maps", return_com=True) + + map_titles = [axis.get_title() for axis in figure.axes if axis.images] + assert map_titles == [f"{expected_latency:.0f}"] + assert figure.texts[0].get_text() == "ERP maps" + assert "float('nan')" in command + + +@eeglab_test(_source("pop_topoplot"), "test_test_pop_topoplot") +def test_pop_topoplot_channel_and_component_maps_use_requested_layout_and_polarity(epoched_eeg: dict) -> None: + channels = pop_topoplot(epoched_eeg, 1, [0, 100], "ERP maps", [1, 2], 0, electrodes="off", colorbar="off") + components = pop_topoplot(epoched_eeg, 0, [1, -2], "Component maps", [1, 2], 0, electrodes="off", colorbar="off") + grid_eeg = deepcopy(epoched_eeg) + grid_eeg["chanlocs"] = [] + grid_eeg["chanmatrix"] = np.asarray([[1, 2, 0], [-3, 4, 5]]) + grid = pop_topoplot(grid_eeg, 1, [0], "Grid map", [1, 1], 0, colorbar="off") + + assert [axis.get_title() for axis in channels[0].axes] == ["0 ms", "100 ms"] + assert [axis.get_title() for axis in components[0].axes] == ["IC 1", "IC -2"] + positive = components[0].axes[0].images[0].get_clim() + negative = components[0].axes[1].images[0].get_clim() + assert positive[0] == pytest.approx(-positive[1]) + assert negative[0] == pytest.approx(-negative[1]) + frame = int(np.argmin(np.abs(epoched_eeg["times"]))) + values = epoched_eeg["data"][:, frame].mean(axis=1) + expected_grid = np.asarray([[values[0], values[1], np.nan], [-values[2], values[3], values[4]]]) + np.testing.assert_allclose(grid[0].axes[0].images[0].get_array(), expected_grid) diff --git a/tools/visual_parity/capture.py b/tools/visual_parity/capture.py index 7687a14f..30a480a1 100644 --- a/tools/visual_parity/capture.py +++ b/tools/visual_parity/capture.py @@ -742,7 +742,7 @@ def _write_matlab_simple_pop_dialog_script( "pop_plottopo": "Topographic ERP plot - pop_plottopo()", "pop_headplot": "ERP head plot(s) -- pop_headplot()", "coregister": "coregister()", - "pop_plotdata": "Component ERPs in rect. array -- pop_plotdata()", + "pop_plotdata": "Channel ERPs in rect. array -- pop_plotdata()", "pop_erpimage": "Channel ERP image -- pop_erpimage()", "pop_envtopo": "Plot component and ERP envelopes -- pop_envtopo()", "pop_comperp": "ERP grand average/RMS - pop_comperp()", diff --git a/tools/visual_parity/cases.json b/tools/visual_parity/cases.json index 43364461..80bf5e6a 100644 --- a/tools/visual_parity/cases.json +++ b/tools/visual_parity/cases.json @@ -1668,7 +1668,7 @@ }, { "id": "pop_plotdata_dialog", - "description": "Component ERP array dialog opened from pop_plotdata.", + "description": "Channel ERP array dialog opened from pop_plotdata.", "window_size": [490, 231], "timeout_seconds": 120, "targets": { From 66c8c2e65ff47a8a1f83b40361d5e0edf6808a0b Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:49 -0700 Subject: [PATCH 18/45] Port administrative data access tests (#346) --- docs/source/api/core_and_session.rst | 2 + docs/source/user_guide/contracts.rst | 13 + src/eegprep/__init__.py | 2 + .../functions/adminfunc/eeg_checkchanlocs.py | 279 ++++++++++++ .../functions/adminfunc/eeg_getdatact.py | 401 ++++++++++++++++++ tests/test_admin_data_access_eeglab_tests.py | 280 ++++++++++++ 6 files changed, 977 insertions(+) create mode 100644 src/eegprep/functions/adminfunc/eeg_checkchanlocs.py create mode 100644 src/eegprep/functions/adminfunc/eeg_getdatact.py create mode 100644 tests/test_admin_data_access_eeglab_tests.py diff --git a/docs/source/api/core_and_session.rst b/docs/source/api/core_and_session.rst index 4ad317fd..e30f157b 100644 --- a/docs/source/api/core_and_session.rst +++ b/docs/source/api/core_and_session.rst @@ -18,6 +18,7 @@ Dataset containers, validation, and the shared session that the GUI and ``eegpre eegprep.checkset eegprep.compare eegprep.eeg_checkset + eegprep.eeg_checkchanlocs eegprep.eeg_checkset_strict_mode eegprep.eeg_compare eegprep.eeg_context @@ -25,6 +26,7 @@ Dataset containers, validation, and the shared session that the GUI and ``eegpre eegprep.eeg_eventhist eegprep.eeg_eventtypes eegprep.eeg_getepochevent + eegprep.eeg_getdatact eegprep.eeg_insertbound eegprep.eeg_urlatency eegprep.eeg_retrieve diff --git a/docs/source/user_guide/contracts.rst b/docs/source/user_guide/contracts.rst index c413e3e9..f9e34fa8 100644 --- a/docs/source/user_guide/contracts.rst +++ b/docs/source/user_guide/contracts.rst @@ -18,6 +18,19 @@ Stored EEG dictionaries are normalized through ``eeg_checkset`` or an invariants. If you build a dataset dictionary by hand, pass it through ``eeg_checkset`` before handing it to other functions. +Channel locations can be normalized independently with +``eeg_checkchanlocs``. It fills the standard location fields, converts +compatible coordinate systems, moves fiducials and ``IGNORE`` entries to +``EEG["chaninfo"]["nodatchans"]``, and normalizes the recorded nose direction +to ``+X`` when the coordinates allow it. + +Use ``eeg_getdatact`` to extract channels, ICA activations, component-removed +data, or component backprojections without mutating the dataset. Its selector +arguments are 1-based at the public API boundary, while stored +``icachansind`` values remain 0-based. The default result is always +``(signals, samples, trials)``; ``reshape="2d"`` concatenates trials in +column-major order for EEGLAB-compatible numerical workflows. + ``event`` entries keep EEGLAB-facing ``latency`` values and, when available, ``urevent`` pointers back to ``urevent`` entries. ``urevent`` is the original-event table; functions that create, delete, or reorder events state diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 9e10152b..b3d247dc 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -85,6 +85,7 @@ "eeg_autocorr_fftw": ("eegprep.plugins.ICLabel.eeg_autocorr_fftw", "eeg_autocorr_fftw"), "eeg_autocorr_welch": ("eegprep.plugins.ICLabel.eeg_autocorr_welch", "eeg_autocorr_welch"), "eeg_checkset": ("eegprep.functions.adminfunc.eeg_checkset", "eeg_checkset"), + "eeg_checkchanlocs": ("eegprep.functions.adminfunc.eeg_checkchanlocs", "eeg_checkchanlocs"), "eeg_checkset_strict_mode": ("eegprep.functions.adminfunc.eeg_checkset", "strict_mode"), "eeg_chaninds": ("eegprep.functions.popfunc.eeg_chaninds", "eeg_chaninds"), "eeg_compare": ("eegprep.functions.popfunc.eeg_compare", "eeg_compare"), @@ -100,6 +101,7 @@ "eegplot": ("eegprep.functions.sigprocfunc.eegplot", "eegplot"), "eeg_findboundaries": ("eegprep.functions.popfunc.eeg_findboundaries", "eeg_findboundaries"), "eeg_getepochevent": ("eegprep.functions.popfunc.eeg_getepochevent", "eeg_getepochevent"), + "eeg_getdatact": ("eegprep.functions.adminfunc.eeg_getdatact", "eeg_getdatact"), "eeg_getica": ("eegprep.functions.sigprocfunc.ica_helpers", "eeg_getica"), "eeg_interp": ("eegprep.functions.popfunc.eeg_interp", "eeg_interp"), "eeg_insertbound": ("eegprep.functions.popfunc.eeg_insertbound", "eeg_insertbound"), diff --git a/src/eegprep/functions/adminfunc/eeg_checkchanlocs.py b/src/eegprep/functions/adminfunc/eeg_checkchanlocs.py new file mode 100644 index 00000000..75876da7 --- /dev/null +++ b/src/eegprep/functions/adminfunc/eeg_checkchanlocs.py @@ -0,0 +1,279 @@ +"""Normalize EEGLAB channel-location structures.""" + +from __future__ import annotations + +from copy import deepcopy +import logging +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._chanutils import chanlocs_as_list +from eegprep.functions.sigprocfunc.convertlocs import convertlocs + + +logger = logging.getLogger(__name__) + +_CHANNEL_FIELDS = ( + "labels", + "theta", + "radius", + "X", + "Y", + "Z", + "sph_theta", + "sph_phi", + "sph_radius", + "type", + "ref", + "urchan", +) +_NUMERIC_FIELDS = frozenset({"theta", "radius", "X", "Y", "Z", "sph_theta", "sph_phi", "sph_radius", "urchan"}) +_NO_DATA_TYPES = frozenset({"fid", "ignore"}) +_NOSE_ROTATION_DEGREES = {"+y": 270.0, "-x": 180.0, "-y": 90.0} + + +def eeg_checkchanlocs(chans: Any, chaninfo: dict[str, Any] | None = None) -> Any: + """Normalize channel locations and their shared metadata. + + EEG dictionaries are copied and returned with normalized ``chanlocs`` and + ``chaninfo``. Passing a channel-location container directly returns + ``(chanlocs, chaninfo, all_locations)``; the third value retains non-data + fiducial and ignored locations. Missing numeric fields use ``None`` and + missing text fields use an empty string. + + Args: + chans: An EEG dictionary or a channel-location container. + chaninfo: Shared location metadata for the lower-level container form. + + Returns: + A normalized EEG dictionary, or the three-value lower-level result. + """ + if chans is None: + raise TypeError("chans must be an EEG dictionary or channel-location container") + + is_eeg = isinstance(chans, dict) and "data" in chans + output = deepcopy(chans) if is_eeg else None + raw_info = output.get("chaninfo", {}) if output is not None else chaninfo + if raw_info is None or (isinstance(raw_info, np.ndarray) and raw_info.size == 0): + raw_info = {} + if not isinstance(raw_info, dict): + raise TypeError("chaninfo must be a dictionary") + info = deepcopy(raw_info) + locations = chanlocs_as_list(output.get("chanlocs", [])) if output is not None else chanlocs_as_list(chans) + locations = [deepcopy(location) for location in locations] + + existing_no_data = chanlocs_as_list(info.pop("nodatchans", [])) + tagged_locations = [ + ( + location, + _datachan_value(location.get("datachan"), default=True) if output is None else True, + output is None and "datachan" in location, + ) + for location in locations + ] + tagged_locations.extend((deepcopy(location), False, True) for location in existing_no_data) + normalized = [ + _normalize_location(location, index) for index, (location, _datachan, _explicit) in enumerate(tagged_locations) + ] + + _move_deprecated_shared_fields(normalized, info) + _clean_labels(normalized) + normalized = _normalize_coordinate_systems(normalized) + normalized, info = _normalize_nose_direction(normalized, info, output) + + strip_urchan = output is not None and not chanlocs_as_list(output.get("urchanlocs", [])) + data_locations: list[dict[str, Any]] = [] + no_data_locations: list[dict[str, Any]] = [] + all_locations: list[dict[str, Any]] = [] + for location, (_original, originally_data, explicit_datachan) in zip(normalized, tagged_locations): + is_data = originally_data + if not explicit_datachan: + is_data = is_data and str(location.get("type", "")).strip().lower() not in _NO_DATA_TYPES + location = deepcopy(location) + if strip_urchan: + location.pop("urchan", None) + location.pop("datachan", None) + all_location = deepcopy(location) + all_location["datachan"] = int(is_data) + all_locations.append(all_location) + target = data_locations if is_data else no_data_locations + if not is_data: + location["datachan"] = 0 + target.append(location) + + info.setdefault("plotrad", None) + info.setdefault("shrink", None) + info.setdefault("nosedir", "+X") + info["nodatchans"] = no_data_locations + if "topoplot" not in info and normalized and _looks_like_meg(normalized[0]): + info["topoplot"] = ["conv", "on", "headrad", 0.3] + + if output is None: + return data_locations, info, all_locations + + output["chanlocs"] = data_locations + output["chaninfo"] = info + return output + + +def _datachan_value(value: Any, *, default: bool) -> bool: + if value is None or (isinstance(value, (list, tuple, np.ndarray)) and np.asarray(value).size == 0): + return default + if isinstance(value, str): + return value.strip().casefold() not in {"", "0", "false", "no", "off"} + return bool(value) + + +def _looks_like_meg(location: dict[str, Any]) -> bool: + return "MLC11" in str(location.get("labels", "")) or "meg" in str(location.get("type", "")).casefold() + + +def _normalize_location(location: Any, index: int) -> dict[str, Any]: + if not isinstance(location, dict): + raise TypeError("Channel-location entries must be dictionaries") + normalized = { + key: deepcopy(value) for key, value in location.items() if key not in {"sph_phi_besa", "sph_theta_besa"} + } + for field in _CHANNEL_FIELDS: + if field in _NUMERIC_FIELDS: + normalized[field] = _numeric_value(normalized.get(field)) + else: + normalized[field] = _text_value(normalized.get(field), label=field == "labels") + if not normalized["labels"]: + normalized["labels"] = f"E{index + 1}" + return normalized + + +def _numeric_value(value: Any) -> float | int | None: + if value is None or (isinstance(value, (list, tuple, np.ndarray)) and np.asarray(value).size == 0): + return None + if isinstance(value, bool): + return None + try: + scalar = float(value) + except (TypeError, ValueError): + return None + if np.isnan(scalar): + return None + if isinstance(value, (int, np.integer)): + return int(value) + return scalar + + +def _text_value(value: Any, *, label: bool) -> str: + if value is None or (isinstance(value, (list, tuple, np.ndarray)) and np.asarray(value).size == 0): + return "" + if isinstance(value, str): + return value + prefix = "E" if label and isinstance(value, (int, float, np.integer, np.floating)) else "" + return f"{prefix}{value}" + + +def _move_deprecated_shared_fields(locations: list[dict[str, Any]], info: dict[str, Any]) -> None: + if not locations: + return + plotrad = locations[0].get("plotrad") + if plotrad not in (None, ""): + try: + info["plotrad"] = float(plotrad) + except (TypeError, ValueError): + info["plotrad"] = plotrad + shrink = locations[0].get("shrink") + if shrink not in (None, ""): + try: + shrink_value = float(shrink) + info["plotrad"] = 0.5 / (1.0 - shrink_value) + except (TypeError, ValueError, ZeroDivisionError): + pass + for location in locations: + location.pop("plotrad", None) + location.pop("shrink", None) + + +def _clean_labels(locations: list[dict[str, Any]]) -> None: + labels = [str(location["labels"]) for location in locations] + numeric_eeg_labels = sum(_is_numeric(label.replace("EEG", "")) for label in labels) + if any("EEG" in label for label in labels) and numeric_eeg_labels < 30: + for location in locations: + label = str(location["labels"]) + for prefix in ("EEG-", "EEG ", "EEG"): + label = label.replace(prefix, "") + location["labels"] = label + for location in locations: + label = str(location["labels"]) + label = label.replace("BrainVision RDA_", "").replace("RDA_", "") + if len(label) >= 2 and label[0] == label[-1] and label[0] in {"'", '"'}: + label = label[1:-1] + location["labels"] = label + lowered = [str(location["labels"]).casefold() for location in locations] + if len(lowered) != len(set(lowered)): + logger.warning("Some channels have the same label") + + +def _is_numeric(value: str) -> bool: + try: + float(value) + except ValueError: + return False + return True + + +def _normalize_coordinate_systems(locations: list[dict[str, Any]]) -> list[dict[str, Any]]: + requires_conversion = any( + (location["X"] is not None and location["theta"] is None) + or (location["sph_theta"] is not None and location["theta"] is None) + or (location["X"] is not None and location["sph_theta"] is None) + for location in locations + ) + if not requires_conversion: + return locations + converted = [] + for location in locations: + try: + converted.append(convertlocs(location, "auto")) + except (TypeError, ValueError): + logger.warning("Unable to convert electrode locations between coordinate systems") + converted.append(location) + return [_normalize_location(location, index) for index, location in enumerate(converted)] + + +def _normalize_nose_direction( + locations: list[dict[str, Any]], + info: dict[str, Any], + eeg: dict[str, Any] | None, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + direction = str(info.get("nosedir", "+X")) + degrees = _NOSE_ROTATION_DEGREES.get(direction.lower()) + if degrees is None or not locations: + return locations, info + if not all(location["X"] is not None and location["Y"] is not None for location in locations): + return locations, info + + radians = np.deg2rad(degrees) + for location in locations: + coordinate = complex(float(location["Y"]), float(location["X"])) * np.exp(-1j * radians) + location["Y"] = float(coordinate.real) + location["X"] = float(coordinate.imag) + if location["theta"] is not None: + location["theta"] = _wrap_degrees(float(location["theta"]) - degrees) + if location["sph_theta"] is not None: + location["sph_theta"] = _wrap_degrees(float(location["sph_theta"]) + degrees) + + info["originalnosedir"] = direction + info["nosedir"] = "+X" + if eeg is not None: + transform = eeg.get("dipfit", {}).get("coord_transform") if isinstance(eeg.get("dipfit"), dict) else None + if transform is not None: + updated = np.asarray(transform, dtype=float).reshape(-1).copy() + if updated.size == 0: + updated = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0]) + if updated.size < 6: + raise ValueError("dipfit.coord_transform must be empty or contain at least six values") + updated.flat[5] += radians + eeg["dipfit"]["coord_transform"] = updated + return locations, info + + +def _wrap_degrees(value: float) -> float: + return (value + 180.0) % 360.0 - 180.0 diff --git a/src/eegprep/functions/adminfunc/eeg_getdatact.py b/src/eegprep/functions/adminfunc/eeg_getdatact.py new file mode 100644 index 00000000..1360a98c --- /dev/null +++ b/src/eegprep/functions/adminfunc/eeg_getdatact.py @@ -0,0 +1,401 @@ +"""Extract channel or component data from EEG datasets.""" + +from __future__ import annotations + +from collections.abc import Sequence +from copy import deepcopy +from pathlib import Path +from typing import Any +import warnings + +import numpy as np + +from eegprep.functions.miscfunc.value_parsing import is_empty_value, parse_key_value_args, parse_numeric_sequence +from eegprep.functions.popfunc._chanutils import chanlocs_as_list +from eegprep.functions.popfunc.eeg_interp import eeg_interp +from eegprep.functions.popfunc.pop_loadset import pop_loadset + + +def eeg_getdatact(EEG: Any, *args: Any, return_boundaries: bool = False, **kwargs: Any) -> Any: + """Return selected channel data, component activity, or a backprojection. + + Channel, component, trial, and sample selectors are EEGLAB-facing 1-based + indices. ``icachansind`` remains an internal 0-based EEGPrep field. By + default the result has shape ``(signals, samples, trials)``; pass + ``reshape="2d"`` to concatenate trials along the sample axis. + + Args: + EEG: One EEG dictionary, a ``.set`` path, or a sequence of datasets. + *args: Optional EEGLAB-style key/value pairs. + return_boundaries: Also return continuous boundary offsets. + **kwargs: ``channel``, ``component``, ``rmcomps``, ``projchan``, + ``trialindices``, ``samples``, ``interp``, ``reshape``, and + ``verbose``. + + Returns: + The selected data, optionally paired with continuous boundary offsets. + """ + options = parse_key_value_args(args, kwargs, lowercase_keys=True, lowercase_kwargs=True) + allowed = { + "channel", + "component", + "rmcomps", + "projchan", + "trialindices", + "samples", + "interp", + "reshape", + "verbose", + } + unknown = set(options) - allowed + if unknown: + raise ValueError(f"Unsupported eeg_getdatact option: {sorted(unknown)[0]}") + reshape = str(options.get("reshape", "3d")).lower() + if reshape not in {"2d", "3d"}: + raise ValueError("reshape must be '2d' or '3d'") + verbose = str(options.get("verbose", "on")).lower() + if verbose not in {"on", "off"}: + raise ValueError("verbose must be 'on' or 'off'") + if _indices(options.get("channel"), name="channel") and _indices(options.get("component"), name="component"): + raise ValueError("channel and component cannot be used together") + if options.get("component") is not None and options.get("rmcomps") is not None: + if _indices(options["component"], name="component") and _indices(options["rmcomps"], name="rmcomps"): + raise ValueError("component and rmcomps cannot be used together") + + if _is_dataset_sequence(EEG): + data, boundaries = _concatenate_datasets(list(EEG), options) + else: + dataset = pop_loadset(EEG) if isinstance(EEG, (str, Path)) else EEG + if not isinstance(dataset, dict): + raise TypeError("EEG must be a dataset dictionary, .set path, or sequence of datasets") + data, boundaries = _extract_one(dataset, options) + return (data, boundaries) if return_boundaries else data + + +def _extract_one(EEG: dict[str, Any], options: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]: + pnts, trials = _metadata_dimensions(EEG) + channels = _indices(options.get("channel"), name="channel") + components = _indices(options.get("component"), name="component") + remove_components = _indices(options.get("rmcomps"), name="rmcomps") + projection_channels = _projection_channel_indices(EEG, options.get("projchan")) + raw_interpolation_locations = options.get("interp") + interpolation_locations = None if is_empty_value(raw_interpolation_locations) else raw_interpolation_locations + + if components: + if interpolation_locations is not None: + raise ValueError("interp cannot be used with component extraction") + selected = _component_data(EEG, None, components, pnts, trials) + else: + data3d = _as_3d(_dataset_data(EEG), pnts, trials) + select_after_interpolation = interpolation_locations is not None + channel_indices = ( + list(range(data3d.shape[0])) + if select_after_interpolation + else _zero_based_selection(channels, data3d.shape[0], "channel") + ) + selected = np.asarray(data3d[channel_indices], dtype=float).copy() + if remove_components: + selected -= _removed_component_projection(EEG, data3d, channel_indices, remove_components) + if select_after_interpolation: + selected = _interpolate_channels(EEG, selected, interpolation_locations) + old_all_channels = channels == list(range(1, data3d.shape[0] + 1)) + requested = [] if not channels or old_all_channels else channels + selected = selected[_zero_based_selection(requested, selected.shape[0], "channel")] + + if projection_channels: + if not components: + raise ValueError("projchan requires a component selection") + selected = _project_components(EEG, selected, components, projection_channels) + + trial_indices = _zero_based_selection( + _indices(options.get("trialindices"), name="trialindices"), trials, "trialindices" + ) + sample_indices = _zero_based_selection(_indices(options.get("samples"), name="samples"), pnts, "samples") + selected = selected[:, sample_indices, :][:, :, trial_indices] + + reshape = str(options.get("reshape", "3d")).lower() + if reshape == "2d": + selected = selected.reshape(selected.shape[0], -1, order="F") + return selected, _continuous_boundaries(EEG, options.get("samples")) + + +def _dataset_data(EEG: dict[str, Any]) -> np.ndarray: + data = EEG.get("data") + if not isinstance(data, str): + array = np.asarray(data) + if array.size == 0: + raise ValueError("EEG.data is empty") + return array + + filepath = Path(str(EEG.get("filepath", ""))) + if data.casefold() == "in set file": + set_file = filepath / str(EEG.get("filename", "")) + if not set_file.is_file(): + raise FileNotFoundError(f"EEG dataset file not found: {set_file}") + loaded = pop_loadset(set_file) + loaded_data = np.asarray(loaded.get("data")) + if loaded_data.size == 0 or isinstance(loaded.get("data"), str): + raise ValueError(f"EEG dataset does not contain readable sample data: {set_file}") + return loaded_data + filename = filepath / data + if not filename.exists() and str(EEG.get("filename", "")): + filename = filepath / Path(str(EEG["filename"])).with_suffix(".fdt") + if not filename.exists(): + raise FileNotFoundError(f"EEG data file not found: {filename}") + nbchan = int(EEG["nbchan"]) + pnts = int(EEG["pnts"]) + trials = int(EEG.get("trials", 1)) + raw = np.fromfile(filename, dtype=" tuple[int, int]: + pnts = int(EEG.get("pnts", 0)) + trials = int(EEG.get("trials", 1)) + if pnts <= 0 or trials <= 0: + raise ValueError("EEG pnts and trials must be positive") + return pnts, trials + + +def _as_3d(data: np.ndarray, pnts: int, trials: int) -> np.ndarray: + array = np.asarray(data) + if array.ndim == 3: + if array.shape[1:] != (pnts, trials): + raise ValueError("EEG.data shape does not match pnts and trials") + return array + if array.ndim != 2 or array.shape[1] != pnts * trials: + raise ValueError("EEG.data shape does not match pnts and trials") + return array.reshape(array.shape[0], pnts, trials, order="F") + + +def _component_data( + EEG: dict[str, Any], + data: np.ndarray | None, + components: list[int], + pnts: int | None = None, + trials: int | None = None, +) -> np.ndarray: + weights = np.asarray(EEG.get("icaweights", []), dtype=float) + if weights.ndim != 2 or not weights.size: + raise ValueError("No ICA weights in dataset") + component_indices = _zero_based_selection(components, weights.shape[0], "component") + cached = np.asarray(EEG.get("icaact", [])) + if pnts is None or trials is None: + if data is None: + pnts, trials = _metadata_dimensions(EEG) + else: + pnts, trials = data.shape[1:] + if not cached.size: + cached = _external_component_activity(EEG, weights.shape[0], pnts, trials) + if cached.size: + cached3d = _as_3d(cached, pnts, trials) + return np.asarray(cached3d[component_indices], dtype=float).copy() + if data is None: + data = _as_3d(_dataset_data(EEG), pnts, trials) + weights, sphere, _inverse, ica_channels = _ica_matrices(EEG, require_inverse=False) + flattened = data[ica_channels].reshape(len(ica_channels), -1, order="F") + activations = (weights[component_indices] @ sphere) @ flattened + return activations.reshape(len(component_indices), pnts, trials, order="F") + + +def _external_component_activity(EEG: dict[str, Any], component_count: int, pnts: int, trials: int) -> np.ndarray: + set_name = str(EEG.get("filename", "")) + if not set_name: + return np.array([]) + filename = Path(str(EEG.get("filepath", ""))) / f"{Path(set_name).stem}.icaact" + if not filename.is_file(): + return np.array([]) + values = np.fromfile(filename, dtype=" np.ndarray: + weights, _sphere, inverse, ica_channels = _ica_matrices(EEG) + component_indices = _zero_based_selection(components, weights.shape[0], "rmcomps") + activations = _component_data(EEG, data, components, data.shape[1], data.shape[2]) + output = np.zeros((len(selected_channels), data.shape[1], data.shape[2]), dtype=float) + channel_lookup = {channel: index for index, channel in enumerate(ica_channels)} + for output_index, channel in enumerate(selected_channels): + if channel not in channel_lookup: + continue + mixing_row = inverse[channel_lookup[channel], component_indices] + output[output_index] = np.tensordot(mixing_row, activations, axes=(0, 0)) + return output + + +def _project_components( + EEG: dict[str, Any], + activity: np.ndarray, + components: list[int], + projection_channels: list[int], +) -> np.ndarray: + weights, _sphere, inverse, ica_channels = _ica_matrices(EEG) + component_indices = _zero_based_selection(components, weights.shape[0], "component") + requested_channels = _zero_based_selection(projection_channels, int(EEG["nbchan"]), "projchan") + channel_lookup = {channel: index for index, channel in enumerate(ica_channels)} + missing = [channel + 1 for channel in requested_channels if channel not in channel_lookup] + if missing: + raise ValueError(f"Cannot backproject components onto channel {missing[0]} because it was not used for ICA") + rows = [channel_lookup[channel] for channel in requested_channels] + flattened = activity.reshape(activity.shape[0], -1, order="F") + projected = inverse[np.ix_(rows, component_indices)] @ flattened + return projected.reshape(len(rows), activity.shape[1], activity.shape[2], order="F") + + +def _interpolate_channels(EEG: dict[str, Any], data: np.ndarray, locations: Any) -> np.ndarray: + requested_locations = chanlocs_as_list(locations) + if not requested_locations or not all(isinstance(location, dict) for location in requested_locations): + raise TypeError("interp must be a channel-location dictionary or sequence of dictionaries") + temporary = deepcopy(EEG) + temporary["data"] = data[:, :, 0] if data.shape[2] == 1 else data + temporary["event"] = [] + temporary["epoch"] = [] + interpolated = eeg_interp(temporary, requested_locations, "spherical", dtype="float64") + return _as_3d(np.asarray(interpolated["data"]), int(EEG["pnts"]), int(EEG.get("trials", 1))) + + +def _projection_channel_indices(EEG: dict[str, Any], value: Any) -> list[int]: + if value is None: + return [] + raw = value.tolist() if isinstance(value, np.ndarray) else value + values = [raw] if isinstance(raw, str) else list(raw) if isinstance(raw, Sequence) else [raw] + if values and all(isinstance(item, str) for item in values): + labels = [str(location.get("labels", "")) for location in chanlocs_as_list(EEG.get("chanlocs", []))] + lookup: dict[str, list[int]] = {} + for index, label in enumerate(labels, start=1): + lookup.setdefault(label.casefold(), []).append(index) + missing = [str(item) for item in values if str(item).casefold() not in lookup] + if missing: + raise ValueError(f"Unknown projection channel label: {missing[0]}") + duplicates = [str(item) for item in values if len(lookup[str(item).casefold()]) > 1] + if duplicates: + raise ValueError(f"Projection channel label is not unique: {duplicates[0]}") + return [lookup[str(item).casefold()][0] for item in values] + return _indices(value, name="projchan") + + +def _ica_matrices( + EEG: dict[str, Any], *, require_inverse: bool = True +) -> tuple[np.ndarray, np.ndarray, np.ndarray, list[int]]: + weights = np.asarray(EEG.get("icaweights", []), dtype=float) + sphere = np.asarray(EEG.get("icasphere", []), dtype=float) + inverse = np.asarray(EEG.get("icawinv", []), dtype=float) + if not weights.size or not sphere.size or (require_inverse and not inverse.size): + raise ValueError("No ICA weights in dataset") + if weights.ndim != 2 or sphere.ndim != 2 or (require_inverse and inverse.ndim != 2): + raise ValueError("ICA matrices must be two-dimensional") + raw_channels = np.asarray(EEG.get("icachansind", []), dtype=int).reshape(-1) + ica_channels = raw_channels.tolist() if raw_channels.size else list(range(sphere.shape[1])) + if len(ica_channels) != sphere.shape[1] or any(index < 0 or index >= int(EEG["nbchan"]) for index in ica_channels): + raise ValueError("icachansind does not match ICA matrix dimensions") + if weights.shape[1] != sphere.shape[0]: + raise ValueError("ICA matrix dimensions are inconsistent") + if require_inverse and inverse.shape != (sphere.shape[1], weights.shape[0]): + raise ValueError("ICA matrix dimensions are inconsistent") + return weights, sphere, inverse, ica_channels + + +def _continuous_boundaries(EEG: dict[str, Any], samples: Any) -> np.ndarray: + if int(EEG.get("trials", 1)) != 1: + return np.array([], dtype=float) + if _indices(samples, name="samples"): + warnings.warn("Boundary offsets are not adjusted when samples are selected", RuntimeWarning, stacklevel=3) + boundaries = [] + for event in _records(EEG.get("event", [])): + if str(event.get("type", "")).casefold() == "boundary": + boundaries.append(float(event["latency"]) - 0.5) + return np.asarray(boundaries, dtype=float) + + +def _concatenate_datasets(datasets: list[dict[str, Any]], options: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]: + if not datasets: + raise ValueError("At least one EEG dataset is required") + continuous_flags = [int(dataset.get("trials", 1)) == 1 for dataset in datasets] + if any(continuous_flags) and not all(continuous_flags): + raise ValueError("Continuous and epoched datasets cannot be concatenated") + trial_options = _per_dataset_option(options.get("trialindices"), len(datasets)) + remove_options = _per_dataset_option(options.get("rmcomps"), len(datasets)) + parts: list[np.ndarray] = [] + boundaries: list[float] = [] + continuous = all(continuous_flags) + offset = 0 + for index, dataset in enumerate(datasets): + current_options = dict(options) + current_options["trialindices"] = trial_options[index] + current_options["rmcomps"] = remove_options[index] + current_options["reshape"] = "3d" + part, inner_boundaries = _extract_one(dataset, current_options) + if parts and part.shape[0] != parts[0].shape[0]: + raise ValueError("Datasets to be concatenated do not have the same number of signals") + if continuous: + if parts: + boundaries.append(float(offset)) + boundaries.extend((inner_boundaries + offset).tolist()) + offset += part.shape[1] + else: + if parts and part.shape[1] != parts[0].shape[1]: + raise ValueError("Epoched datasets must have the same number of samples") + parts.append(part) + axis = 1 if continuous else 2 + result = np.concatenate(parts, axis=axis) + if str(options.get("reshape", "3d")).lower() == "2d": + result = result.reshape(result.shape[0], -1, order="F") + return result, np.asarray(boundaries, dtype=float) + + +def _indices(value: Any, *, name: str) -> list[int]: + if value is None: + return [] + values = parse_numeric_sequence(value, dtype=float) + output = [] + for value in values: + if not np.isfinite(value) or not float(value).is_integer() or value < 1: + raise ValueError(f"{name} must contain positive 1-based integers") + output.append(int(value)) + return output + + +def _zero_based_selection(indices: list[int], size: int, name: str) -> list[int]: + if not indices: + return list(range(size)) + output = [index - 1 for index in indices] + if any(index >= size for index in output): + raise IndexError(f"{name} contains an index outside 1..{size}") + return output + + +def _is_dataset_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance(value, (str, bytes, Path, dict, np.ndarray)) + + +def _per_dataset_option(value: Any, count: int) -> list[Any]: + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, np.ndarray)): + values = list(value) + if len(values) == count and any(isinstance(item, (list, tuple, np.ndarray)) or item is None for item in values): + return values + return [value] * count + + +def _records(value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + if isinstance(value, np.ndarray): + value = value.tolist() + if isinstance(value, dict): + return [value] + return [record for record in value if isinstance(record, dict)] diff --git a/tests/test_admin_data_access_eeglab_tests.py b/tests/test_admin_data_access_eeglab_tests.py new file mode 100644 index 00000000..6fe2a2c1 --- /dev/null +++ b/tests/test_admin_data_access_eeglab_tests.py @@ -0,0 +1,280 @@ +"""Behavioral ports of current EEGLAB administrative data-access tests.""" + +from __future__ import annotations + +from copy import deepcopy + +import numpy as np +import pytest + +from eegprep import eeg_checkchanlocs, eeg_getdatact +from eegprep.functions.popfunc.pop_loadset import pop_loadset +from eegprep.functions.popfunc.pop_saveset import pop_saveset +from tests.eeglab_tests import eeglab_test + + +def _eeg(*, epoched: bool = False) -> dict: + data = np.arange(3 * 20, dtype=float).reshape(3, 20) + pnts = 5 if epoched else 20 + trials = 4 if epoched else 1 + data = data.reshape(3, pnts, trials, order="F") if epoched else data + weights = np.array([[1.0, 0.5, 0.0], [0.0, 1.0, -0.25], [0.25, 0.0, 1.0]]) + inverse = np.linalg.inv(weights) + flattened = data.reshape(3, -1, order="F") + activity = (weights @ flattened).reshape(3, pnts, trials, order="F") + if not epoched: + activity = activity[:, :, 0] + return { + "data": data, + "nbchan": 3, + "pnts": pnts, + "trials": trials, + "srate": 100.0, + "xmin": 0.0, + "xmax": (pnts - 1) / 100.0, + "chanlocs": np.asarray( + [ + {"labels": "EEG Fz", "X": 1.0, "Y": 0.0, "Z": 0.0}, + {"labels": "RDA_Cz", "theta": 0.0, "radius": 0.0}, + {"labels": "'Pz'", "theta": 180.0, "radius": 0.5}, + ], + dtype=object, + ), + "urchanlocs": np.array([], dtype=object), + "chaninfo": {}, + "event": [], + "icaweights": weights, + "icasphere": np.eye(3), + "icawinv": inverse, + "icachansind": np.arange(3), + "icaact": activity, + } + + +@eeglab_test( + "unittesting_adminfunc/eeg_checkchanlocs/adminfunc_eeg_checkchanlocs_wrapperTest.m", + "test_test_eeg_checkchanlocs", +) +def test_current_eeg_checkchanlocs_normalizes_continuous_epoched_and_extended_locations(caplog): + continuous = eeg_checkchanlocs(_eeg()) + assert [location["labels"] for location in continuous["chanlocs"]] == ["Fz", "Cz", "Pz"] + assert all(all(location[field] is not None for field in ("X", "Y", "Z")) for location in continuous["chanlocs"]) + np.testing.assert_allclose( + [[location[axis] for axis in ("X", "Y", "Z")] for location in continuous["chanlocs"]], + [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [-1.0, 0.0, 0.0]], + atol=1e-12, + ) + assert continuous["chaninfo"] == {"plotrad": None, "shrink": None, "nosedir": "+X", "nodatchans": []} + assert "urchan" not in continuous["chanlocs"][0] + + extended = _eeg() + extended["chanlocs"] = list(extended["chanlocs"]) + [{"labels": "test"}, {"labels": "test"}] + with caplog.at_level("WARNING"): + checked = eeg_checkchanlocs(extended) + assert len(checked["chanlocs"]) == 5 + assert "same label" in caplog.text + + epoched = eeg_checkchanlocs(_eeg(epoched=True)) + assert epoched["data"].shape == (3, 5, 4) + assert [location["labels"] for location in epoched["chanlocs"]] == ["Fz", "Cz", "Pz"] + + +@eeglab_test( + "unittesting_adminfunc/eeg_getdatact/adminfunc_eeg_getdatact_wrapperTest.m", + "test_test_eeg_getdatact", +) +def test_current_eeg_getdatact_channel_component_trial_and_removal_cases(): + continuous = _eeg() + np.testing.assert_array_equal(eeg_getdatact(continuous), continuous["data"][:, :, None]) + np.testing.assert_array_equal( + eeg_getdatact(continuous, "channel", [1, 3], "trialindices", 1, "verbose", "on"), + continuous["data"][[0, 2], :, None], + ) + np.testing.assert_array_equal( + eeg_getdatact(continuous, "channel", [1, 3], "trialindices", 1, "verbose", "off"), + continuous["data"][[0, 2], :, None], + ) + + epoched = _eeg(epoched=True) + np.testing.assert_array_equal(eeg_getdatact(epoched), epoched["data"]) + np.testing.assert_array_equal( + eeg_getdatact(epoched, channel=[1, 3], trialindices=1), + epoched["data"][[0, 2], :, :1], + ) + np.testing.assert_array_equal( + eeg_getdatact(epoched, channel=[1, 3], trialindices="1:4"), + epoched["data"][[0, 2]], + ) + np.testing.assert_array_equal( + eeg_getdatact(epoched, component=[1, 3], trialindices=1), epoched["icaact"][[0, 2], :, :1] + ) + + expected = epoched["data"] - np.einsum( + "c,st->cst", + epoched["icawinv"][:, 0], + epoched["icaact"][0], + ) + np.testing.assert_allclose(eeg_getdatact(epoched, rmcomps=[1]), expected, atol=1e-12) + + +def test_eeg_getdatact_computes_uncached_activity_backprojection_and_two_dimensional_shape(): + eeg = _eeg(epoched=True) + eeg["icaact"] = np.array([]) + components = eeg_getdatact(eeg, component=[1, 2], trialindices=[2, 4]) + expected = (eeg["icaweights"] @ eeg["data"].reshape(3, -1, order="F")).reshape(3, 5, 4, order="F") + np.testing.assert_allclose(components, expected[[0, 1]][:, :, [1, 3]]) + + projection = eeg_getdatact(eeg, component=[1, 2], projchan=[1, 3]) + np.testing.assert_allclose( + projection, + np.einsum("ci,ist->cst", eeg["icawinv"][[0, 2], :2], expected[:2]), + atol=1e-12, + ) + labeled_projection = eeg_getdatact(eeg, component=[1, 2], projchan=["EEG Fz", "'Pz'"]) + np.testing.assert_allclose(labeled_projection, projection) + flattened = eeg_getdatact(eeg, channel=[1, 3], reshape="2d") + np.testing.assert_array_equal(flattened, eeg["data"][[0, 2]].reshape(2, -1, order="F")) + + +def test_eeg_getdatact_concatenates_datasets_and_reports_continuous_boundaries(): + first = _eeg() + first["data"] = first["data"][:, :5] + first["icaact"] = first["icaact"][:, :5] + first["pnts"] = 5 + first["event"] = [{"type": "boundary", "latency": 3.5}] + second = deepcopy(first) + second["data"] = second["data"] + 100 + second["icaact"] = second["icaweights"] @ second["data"] + + combined, boundaries = eeg_getdatact([first, second], return_boundaries=True) + np.testing.assert_array_equal(combined, np.concatenate([first["data"], second["data"]], axis=1)[:, :, None]) + np.testing.assert_array_equal(boundaries, [3.0, 5.0, 8.0]) + + +def test_eeg_getdatact_rejects_ambiguous_or_out_of_range_selections(): + eeg = _eeg(epoched=True) + with pytest.raises(ValueError, match="cannot be used together"): + eeg_getdatact(eeg, component=[1], rmcomps=[2]) + with pytest.raises(ValueError, match="cannot be used together"): + eeg_getdatact(eeg, component=[1], channel=[2]) + with pytest.raises(IndexError, match="outside"): + eeg_getdatact(eeg, channel=[4]) + with pytest.raises(ValueError, match="projchan requires"): + eeg_getdatact(eeg, projchan=[1]) + with pytest.raises(ValueError, match="Continuous and epoched"): + eeg_getdatact([_eeg(), eeg]) + + +def test_eeg_checkchanlocs_separates_fiducials_and_rotates_nose_direction(): + eeg = _eeg() + eeg["chanlocs"] = [ + {"labels": "Cz", "type": "EEG", "X": 0.0, "Y": 1.0, "Z": 0.0, "theta": 0.0, "sph_theta": 90.0}, + {"labels": "Nz", "type": "FID", "X": 1.0, "Y": 0.0, "Z": 0.0, "theta": 90.0, "sph_theta": 0.0}, + ] + eeg["chaninfo"] = {"nosedir": "+Y"} + + checked = eeg_checkchanlocs(eeg) + + assert [location["labels"] for location in checked["chanlocs"]] == ["Cz"] + assert [location["labels"] for location in checked["chaninfo"]["nodatchans"]] == ["Nz"] + assert checked["chaninfo"]["nosedir"] == "+X" + assert checked["chaninfo"]["originalnosedir"] == "+Y" + np.testing.assert_allclose( + [checked["chanlocs"][0]["X"], checked["chanlocs"][0]["Y"]], + [1.0, 0.0], + atol=1e-12, + ) + + +def test_eeg_checkchanlocs_lower_form_preserves_datachan_flags_and_meg_defaults(): + locations = [ + {"labels": "MLC11", "type": "MEG", "datachan": 1}, + {"labels": "Explicit", "type": "FID", "datachan": 1}, + {"labels": "Fid", "type": "", "datachan": 0, "urchan": 4}, + ] + + data_locations, info, all_locations = eeg_checkchanlocs(locations) + + assert [location["labels"] for location in data_locations] == ["MLC11", "Explicit"] + assert [location["labels"] for location in info["nodatchans"]] == ["Fid"] + assert [location["datachan"] for location in all_locations] == [1, 1, 0] + assert all("datachan" not in location for location in data_locations) + assert info["nodatchans"][0]["datachan"] == 0 + assert info["topoplot"] == ["conv", "on", "headrad", 0.3] + + +def test_eeg_getdatact_reads_fdt_dat_and_info_only_set_storage(tmp_path): + eeg = _eeg() + expected = np.asarray(eeg["data"][:, :5], dtype=np.float32) + metadata = {**eeg, "pnts": 5, "data": "samples.fdt", "filepath": str(tmp_path), "filename": "source.set"} + expected.reshape(-1, order="F").tofile(tmp_path / "samples.fdt") + np.testing.assert_array_equal(eeg_getdatact(metadata)[:, :, 0], expected) + + frames = expected.T + frames.reshape(-1, order="F").tofile(tmp_path / "samples.dat") + metadata["data"] = "samples.dat" + np.testing.assert_array_equal(eeg_getdatact(metadata)[:, :, 0], expected) + + saved = { + **eeg, + "data": expected, + "pnts": 5, + "trials": 1, + "xmax": 0.04, + "times": np.arange(5, dtype=float) * 10.0, + "icaact": np.array([]), + } + set_path = tmp_path / "embedded.set" + pop_saveset(saved, set_path, savemode="onefile") + info_only = pop_loadset(set_path, loadmode="info") + np.testing.assert_array_equal(eeg_getdatact(info_only)[:, :, 0], expected) + + +def test_eeg_getdatact_accepts_cached_components_without_inverse_and_interpolates_target_locations(): + eeg = _eeg(epoched=True) + expected = np.asarray(eeg["icaact"][[0], :, :1]) + eeg["icasphere"] = np.array([]) + eeg["icawinv"] = np.array([]) + np.testing.assert_array_equal(eeg_getdatact(eeg, component=[1], trialindices=1), expected) + + interpolation_eeg = { + "data": np.arange(4 * 8, dtype=float).reshape(4, 8), + "nbchan": 4, + "pnts": 8, + "trials": 1, + "xmin": 0.0, + "xmax": 0.07, + "chanlocs": [ + {"labels": "X+", "X": 1.0, "Y": 0.0, "Z": 0.0}, + {"labels": "X-", "X": -1.0, "Y": 0.0, "Z": 0.0}, + {"labels": "Y+", "X": 0.0, "Y": 1.0, "Z": 0.0}, + {"labels": "Y-", "X": 0.0, "Y": -1.0, "Z": 0.0}, + ], + } + target = deepcopy(interpolation_eeg["chanlocs"]) + target.append({"labels": "Z+", "X": 0.0, "Y": 0.0, "Z": 1.0}) + interpolated = eeg_getdatact(interpolation_eeg, interp=target) + assert interpolated.shape == (5, 8, 1) + np.testing.assert_array_equal(interpolated[:4, :, 0], interpolation_eeg["data"]) + assert np.all(np.isfinite(interpolated[4])) + + +def test_eeg_getdatact_reads_external_component_activity(tmp_path): + eeg = _eeg(epoched=True) + activity = np.asarray(eeg["icaact"], dtype=np.float32) + rows = np.stack([component.reshape(-1, order="F") for component in activity]) + rows.tofile(tmp_path / "external.icaact") + eeg["data"] = np.array([]) + eeg["icaact"] = np.array([]) + eeg["filepath"] = str(tmp_path) + eeg["filename"] = "external.set" + + np.testing.assert_array_equal(eeg_getdatact(eeg, component=[2]), activity[[1]]) + + +def test_eeg_getdatact_rejects_ambiguous_projection_labels(): + eeg = _eeg() + eeg["chanlocs"][1]["labels"] = "EEG Fz" + + with pytest.raises(ValueError, match="not unique"): + eeg_getdatact(eeg, component=[1], projchan=["eeg fz"]) From 439e661fc7ed3723701a83885531d71b2cd5ad88 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:49 -0700 Subject: [PATCH 19/45] Port current EEGLAB numerical utility tests (#347) * Port EEGLAB numerical utility tests * fix: harden numerical utility contracts --- docs/source/api/index.rst | 1 + docs/source/api/numerical_utilities.rst | 98 ++++ docs/source/changelog.rst | 11 + src/eegprep/__init__.py | 30 + src/eegprep/functions/miscfunc/__init__.py | 50 +- src/eegprep/functions/miscfunc/_validation.py | 39 ++ src/eegprep/functions/miscfunc/abspeak.py | 63 +++ src/eegprep/functions/miscfunc/averef.py | 44 ++ src/eegprep/functions/miscfunc/covary.py | 34 ++ src/eegprep/functions/miscfunc/datlim.py | 20 + src/eegprep/functions/miscfunc/eucl.py | 43 ++ src/eegprep/functions/miscfunc/gabor2d.py | 49 ++ src/eegprep/functions/miscfunc/gauss.py | 21 + src/eegprep/functions/miscfunc/gauss2d.py | 58 ++ src/eegprep/functions/miscfunc/gauss3d.py | 58 ++ src/eegprep/functions/miscfunc/hungarian.py | 29 + src/eegprep/functions/miscfunc/laplac2d.py | 36 ++ src/eegprep/functions/miscfunc/mapcorr.py | 54 ++ src/eegprep/functions/miscfunc/matcorr.py | 115 ++++ src/eegprep/functions/miscfunc/matperm.py | 37 ++ src/eegprep/functions/miscfunc/means.py | 52 ++ src/eegprep/functions/miscfunc/misc.py | 8 +- src/eegprep/functions/miscfunc/nan_std.py | 38 ++ src/eegprep/functions/miscfunc/pcexpand.py | 26 + src/eegprep/functions/miscfunc/pcsquash.py | 38 ++ src/eegprep/functions/miscfunc/perminv.py | 24 + src/eegprep/functions/miscfunc/scanfold.py | 40 ++ src/eegprep/functions/miscfunc/uniquef.py | 38 ++ src/eegprep/functions/miscfunc/vectdata.py | 95 ++++ src/eegprep/functions/sigprocfunc/__init__.py | 20 + .../functions/sigprocfunc/celltomat.py | 28 + src/eegprep/functions/sigprocfunc/eyelike.py | 32 ++ src/eegprep/functions/sigprocfunc/fastif.py | 15 + src/eegprep/functions/sigprocfunc/matsel.py | 51 ++ .../functions/sigprocfunc/mattocell.py | 15 + src/eegprep/functions/sigprocfunc/nan_mean.py | 28 + src/eegprep/functions/sigprocfunc/quantile.py | 43 ++ src/eegprep/functions/sigprocfunc/shuffle.py | 37 ++ tests/test_miscfunc_numerical_eeglab_tests.py | 528 ++++++++++++++++++ ...test_sigprocfunc_numerical_eeglab_tests.py | 144 +++++ 40 files changed, 2186 insertions(+), 4 deletions(-) create mode 100644 docs/source/api/numerical_utilities.rst create mode 100644 src/eegprep/functions/miscfunc/_validation.py create mode 100644 src/eegprep/functions/miscfunc/abspeak.py create mode 100644 src/eegprep/functions/miscfunc/averef.py create mode 100644 src/eegprep/functions/miscfunc/covary.py create mode 100644 src/eegprep/functions/miscfunc/datlim.py create mode 100644 src/eegprep/functions/miscfunc/eucl.py create mode 100644 src/eegprep/functions/miscfunc/gabor2d.py create mode 100644 src/eegprep/functions/miscfunc/gauss.py create mode 100644 src/eegprep/functions/miscfunc/gauss2d.py create mode 100644 src/eegprep/functions/miscfunc/gauss3d.py create mode 100644 src/eegprep/functions/miscfunc/hungarian.py create mode 100644 src/eegprep/functions/miscfunc/laplac2d.py create mode 100644 src/eegprep/functions/miscfunc/mapcorr.py create mode 100644 src/eegprep/functions/miscfunc/matcorr.py create mode 100644 src/eegprep/functions/miscfunc/matperm.py create mode 100644 src/eegprep/functions/miscfunc/means.py create mode 100644 src/eegprep/functions/miscfunc/nan_std.py create mode 100644 src/eegprep/functions/miscfunc/pcexpand.py create mode 100644 src/eegprep/functions/miscfunc/pcsquash.py create mode 100644 src/eegprep/functions/miscfunc/perminv.py create mode 100644 src/eegprep/functions/miscfunc/scanfold.py create mode 100644 src/eegprep/functions/miscfunc/uniquef.py create mode 100644 src/eegprep/functions/miscfunc/vectdata.py create mode 100644 src/eegprep/functions/sigprocfunc/celltomat.py create mode 100644 src/eegprep/functions/sigprocfunc/eyelike.py create mode 100644 src/eegprep/functions/sigprocfunc/fastif.py create mode 100644 src/eegprep/functions/sigprocfunc/matsel.py create mode 100644 src/eegprep/functions/sigprocfunc/mattocell.py create mode 100644 src/eegprep/functions/sigprocfunc/nan_mean.py create mode 100644 src/eegprep/functions/sigprocfunc/quantile.py create mode 100644 src/eegprep/functions/sigprocfunc/shuffle.py create mode 100644 tests/test_miscfunc_numerical_eeglab_tests.py create mode 100644 tests/test_sigprocfunc_numerical_eeglab_tests.py diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 97507900..e16631ac 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -19,6 +19,7 @@ see its functions. filtering_and_signal_processing time_frequency plotting_and_topography + numerical_utilities statistics study_workflows interactive_pop_workflows diff --git a/docs/source/api/numerical_utilities.rst b/docs/source/api/numerical_utilities.rst new file mode 100644 index 00000000..a4902428 --- /dev/null +++ b/docs/source/api/numerical_utilities.rst @@ -0,0 +1,98 @@ +.. _api_numerical_utilities: + +=================== +Numerical Utilities +=================== + +EEGPrep includes the small numerical building blocks used by its signal and +component workflows. They are standalone NumPy/SciPy functions and do not need +MATLAB or an EEGLAB checkout. + +Indexing and compatibility +========================== + +Array indices are zero-based. This applies to peak frames returned by +``abspeak``, assignments returned by ``hungarian`` and ``matcorr``, permutation +vectors, ``matsel`` selections, and ``shuffle`` axes. Kernel peak coordinates +remain one-based because they describe the sampled coordinate system in the +EEGLAB formulas rather than Python array indices. + +``covary`` deliberately retains EEGLAB's grand-mean centering before computing +column second moments; use NumPy's variance functions when ordinary per-column +centering is intended. ``means`` uses the number of finite observations when +computing standard errors and leaves single-observation sample variances +undefined. This fixes the historical EEGLAB helper's underestimated standard +errors when a group contains missing values. ``nan_std`` uses a centered +calculation so large constant offsets do not erase small physiological +variation through catastrophic cancellation. + +``quantile`` retains the midpoint empirical-probability rule used by the +current EEGLAB tests. Integer counts, axes, permutations, and selections must +be exact integers; fractional values are rejected instead of silently selecting +the wrong samples. Functions that have a meaningful complex-valued contract, +including PCA, interpolation, peak phase, and NaN-aware means, preserve complex +data. Real-only functions reject complex input instead of discarding its +imaginary component. + +``vectdata`` supports linear, cubic, and nearest-neighbor interpolation. +MATLAB's legacy biharmonic ``griddata`` +``v4`` mode has no well-defined one-dimensional SciPy equivalent and raises +``NotImplementedError`` rather than substituting a different interpolator. + +Peaks, summaries, and transforms +================================ + +.. autosummary:: + :toctree: generated/ + + eegprep.abspeak + eegprep.averef + eegprep.covary + eegprep.datlim + eegprep.eucl + eegprep.means + eegprep.nan_mean + eegprep.nan_std + eegprep.quantile + eegprep.vectdata + +Kernels +------- + +.. autosummary:: + :toctree: generated/ + + eegprep.gauss + eegprep.gauss2d + eegprep.gauss3d + eegprep.gabor2d + eegprep.laplac2d + +Matching and component projections +================================== + +.. autosummary:: + :toctree: generated/ + + eegprep.hungarian + eegprep.mapcorr + eegprep.matcorr + eegprep.matperm + eegprep.pcsquash + eegprep.pcexpand + eegprep.perminv + eegprep.uniquef + +Low-level compatibility helpers +=============================== + +.. autosummary:: + :toctree: generated/ + + eegprep.celltomat + eegprep.eyelike + eegprep.fastif + eegprep.matsel + eegprep.mattocell + eegprep.scanfold + eegprep.shuffle diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 9e039397..8a2069c7 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,17 @@ the `GitHub Releases `_ page. Unreleased ========== +- Added standalone numerical utilities for peak extraction, average reference, + Gaussian/Gabor/Laplacian kernels, distance and assignment problems, + correlation-based map matching, grouped summaries, PCA compression and + reconstruction, interpolation, quantiles, matrix selection, and permutation + handling. Python-facing indices are zero-based, and each function is covered + by ports of the corresponding current EEGLAB test wrapper. Numerical edge + contracts reject fractional indices and lossy complex casts, support MATLAB + cell arrays loaded through SciPy, preserve complex PCA and interpolation, and + avoid catastrophic cancellation in NaN-aware standard deviations. Grouped + standard errors also use the finite sample count, correcting an EEGLAB helper + defect when groups contain missing observations. - Current EEGLAB visual-wrapper test ports now exercise plots through deterministic headless numerical and figure assertions. ``pop_plotdata`` supports channel/component mode, 1-based trial selection, trial averaging, diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index b3d247dc..7ef8dc54 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -13,6 +13,8 @@ setup_logging(logging.WARNING) _LAZY_EXPORTS = { + "abspeak": ("eegprep.functions.miscfunc.abspeak", "abspeak"), + "averef": ("eegprep.functions.miscfunc.averef", "averef"), "CallbackSpec": ("eegprep.functions.guifunc.spec", "CallbackSpec"), "ConsoleDatasetResult": ("eegprep.functions.adminfunc.console", "ConsoleDatasetResult"), "ConsolePopResult": ("eegprep.functions.adminfunc.console", "ConsolePopResult"), @@ -56,6 +58,7 @@ "build_safe_update_commands": ("eegprep.extension_catalog", "build_safe_update_commands"), "bundled_plugins": ("eegprep.functions.adminfunc.plugin_menu", "bundled_plugins"), "cart2topo": ("eegprep.functions.sigprocfunc.cart2topo", "cart2topo"), + "celltomat": ("eegprep.functions.sigprocfunc.celltomat", "celltomat"), "chancenter": ("eegprep.functions.sigprocfunc.chancenter", "chancenter"), "check_extension_compatibility": ("eegprep.extensions", "check_extension_compatibility"), "checkset": ("eegprep.functions.redefine_functions", "checkset"), @@ -74,10 +77,37 @@ "compare": ("eegprep.functions.redefine_functions", "compare"), "compvar": ("eegprep.functions.sigprocfunc.ica_helpers", "compvar"), "convertlocs": ("eegprep.functions.sigprocfunc.convertlocs", "convertlocs"), + "covary": ("eegprep.functions.miscfunc.covary", "covary"), "coregister": ("eegprep.functions.sigprocfunc.coregister", "coregister"), "decodechan": ("eegprep.functions.redefine_functions", "decodechan"), + "datlim": ("eegprep.functions.miscfunc.datlim", "datlim"), "discover_extensions": ("eegprep.extensions", "discover_extensions"), "eeg2mne": ("eegprep.functions.redefine_functions", "eeg2mne"), + "eucl": ("eegprep.functions.miscfunc.eucl", "eucl"), + "eyelike": ("eegprep.functions.sigprocfunc.eyelike", "eyelike"), + "fastif": ("eegprep.functions.sigprocfunc.fastif", "fastif"), + "gabor2d": ("eegprep.functions.miscfunc.gabor2d", "gabor2d"), + "gauss": ("eegprep.functions.miscfunc.gauss", "gauss"), + "gauss2d": ("eegprep.functions.miscfunc.gauss2d", "gauss2d"), + "gauss3d": ("eegprep.functions.miscfunc.gauss3d", "gauss3d"), + "hungarian": ("eegprep.functions.miscfunc.hungarian", "hungarian"), + "laplac2d": ("eegprep.functions.miscfunc.laplac2d", "laplac2d"), + "mapcorr": ("eegprep.functions.miscfunc.mapcorr", "mapcorr"), + "matcorr": ("eegprep.functions.miscfunc.matcorr", "matcorr"), + "matperm": ("eegprep.functions.miscfunc.matperm", "matperm"), + "matsel": ("eegprep.functions.sigprocfunc.matsel", "matsel"), + "mattocell": ("eegprep.functions.sigprocfunc.mattocell", "mattocell"), + "means": ("eegprep.functions.miscfunc.means", "means"), + "nan_mean": ("eegprep.functions.sigprocfunc.nan_mean", "nan_mean"), + "nan_std": ("eegprep.functions.miscfunc.nan_std", "nan_std"), + "pcexpand": ("eegprep.functions.miscfunc.pcexpand", "pcexpand"), + "pcsquash": ("eegprep.functions.miscfunc.pcsquash", "pcsquash"), + "perminv": ("eegprep.functions.miscfunc.perminv", "perminv"), + "quantile": ("eegprep.functions.sigprocfunc.quantile", "quantile"), + "scanfold": ("eegprep.functions.miscfunc.scanfold", "scanfold"), + "shuffle": ("eegprep.functions.sigprocfunc.shuffle", "shuffle"), + "uniquef": ("eegprep.functions.miscfunc.uniquef", "uniquef"), + "vectdata": ("eegprep.functions.miscfunc.vectdata", "vectdata"), "eeg_addnewevents": ("eegprep.functions.popfunc.eeg_addnewevents", "eeg_addnewevents"), "eeg_amica": ("eegprep.functions.popfunc.eeg_amica", "eeg_amica"), "eeg_amplitudearea": ("eegprep.functions.popfunc.eeg_amplitudearea", "eeg_amplitudearea"), diff --git a/src/eegprep/functions/miscfunc/__init__.py b/src/eegprep/functions/miscfunc/__init__.py index 6574f4ef..8d6946ca 100644 --- a/src/eegprep/functions/miscfunc/__init__.py +++ b/src/eegprep/functions/miscfunc/__init__.py @@ -1 +1,49 @@ -"""EEGLAB-style miscellaneous function modules.""" +"""EEGLAB-style miscellaneous numerical functions.""" + +from .abspeak import abspeak +from .averef import averef +from .covary import covary +from .datlim import datlim +from .eucl import eucl +from .gabor2d import gabor2d +from .gauss import gauss +from .gauss2d import gauss2d +from .gauss3d import gauss3d +from .hungarian import hungarian +from .laplac2d import laplac2d +from .mapcorr import mapcorr +from .matcorr import matcorr +from .matperm import matperm +from .means import means +from .nan_std import nan_std +from .pcexpand import pcexpand +from .pcsquash import pcsquash +from .perminv import perminv +from .scanfold import scanfold +from .uniquef import uniquef +from .vectdata import vectdata + +__all__ = [ + "abspeak", + "averef", + "covary", + "datlim", + "eucl", + "gabor2d", + "gauss", + "gauss2d", + "gauss3d", + "hungarian", + "laplac2d", + "mapcorr", + "matcorr", + "matperm", + "means", + "nan_std", + "pcexpand", + "pcsquash", + "perminv", + "scanfold", + "uniquef", + "vectdata", +] diff --git a/src/eegprep/functions/miscfunc/_validation.py b/src/eegprep/functions/miscfunc/_validation.py new file mode 100644 index 00000000..4486e595 --- /dev/null +++ b/src/eegprep/functions/miscfunc/_validation.py @@ -0,0 +1,39 @@ +"""Validation shared by numerical helper ports.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def integer_scalar(value: Any, name: str) -> int: + """Return an exact integer scalar without silently truncating input.""" + array = np.asarray(value) + if array.ndim != 0 or not np.issubdtype(array.dtype, np.number) or np.iscomplexobj(array): + raise ValueError(f"{name} must be an integer") + numeric = float(array) + if not np.isfinite(numeric) or not numeric.is_integer(): + raise ValueError(f"{name} must be an integer") + return int(numeric) + + +def integer_array(value: Any, name: str) -> np.ndarray: + """Return exact integer values without silently truncating input.""" + array = np.asarray(value) + if not np.issubdtype(array.dtype, np.number) or np.iscomplexobj(array): + raise ValueError(f"{name} must contain integers") + numeric = array.astype(float) + if np.any(~np.isfinite(numeric)) or np.any(numeric != np.trunc(numeric)): + raise ValueError(f"{name} must contain integers") + return numeric.astype(np.intp) + + +def real_array(value: Any, name: str) -> np.ndarray: + """Return real floating-point data without discarding imaginary parts.""" + array = np.asarray(value) + if not np.issubdtype(array.dtype, np.number): + raise TypeError(f"{name} must be numeric") + if np.iscomplexobj(array): + raise ValueError(f"{name} must be real") + return array.astype(float, copy=False) diff --git a/src/eegprep/functions/miscfunc/abspeak.py b/src/eegprep/functions/miscfunc/abspeak.py new file mode 100644 index 00000000..6eaa9906 --- /dev/null +++ b/src/eegprep/functions/miscfunc/abspeak.py @@ -0,0 +1,63 @@ +"""Absolute peak extraction for channel-major data.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from ._validation import integer_scalar + + +def abspeak(data: Any, frames_per_epoch: int | None = None) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return absolute peaks, zero-based frames, and signs for each epoch. + + NaNs are ignored when finite or infinite observations exist. An all-NaN + channel/epoch returns ``NaN`` amplitude, frame ``-1``, and sign ``NaN``. + Tied peaks use the last frame, matching EEGLAB's stable-sort behavior. + """ + values = np.asarray(data) + if values.ndim != 2: + raise ValueError("data must be a 2-D channel-by-frame array") + total_frames = values.shape[1] + epoch_frames = total_frames if frames_per_epoch is None else integer_scalar(frames_per_epoch, "frames_per_epoch") + if epoch_frames == 0: + epoch_frames = total_frames + if epoch_frames <= 0 or total_frames % epoch_frames: + raise ValueError("frames_per_epoch must be positive and divide the data length") + + epochs = total_frames // epoch_frames + reshaped = values.reshape(values.shape[0], epochs, epoch_frames) + amplitudes = np.empty((values.shape[0], epochs), dtype=float) + frames = np.full((values.shape[0], epochs), -1, dtype=int) + signs = np.empty((values.shape[0], epochs), dtype=np.result_type(values.dtype, float)) + for channel in range(values.shape[0]): + for epoch in range(epochs): + row = reshaped[channel, epoch] + valid = ~np.isnan(row) + if not valid.any(): + amplitudes[channel, epoch] = np.nan + signs[channel, epoch] = np.nan + continue + peak = np.nanmax(np.abs(row)) + frame = int(np.flatnonzero(valid & (np.abs(row) == peak))[-1]) + amplitudes[channel, epoch] = peak + frames[channel, epoch] = frame + signs[channel, epoch] = _phase(row[frame], peak) + return amplitudes, frames, signs + + +def _phase(value: Any, magnitude: float) -> Any: + if magnitude == 0: + return 0 + if not np.isinf(magnitude): + return value / magnitude + real = np.sign(np.real(value)) if np.isinf(np.real(value)) else 0 + if not np.iscomplexobj(value): + return real + imaginary = np.sign(np.imag(value)) if np.isinf(np.imag(value)) else 0 + direction = complex(real, imaginary) + return direction / abs(direction) + + +__all__ = ["abspeak"] diff --git a/src/eegprep/functions/miscfunc/averef.py b/src/eegprep/functions/miscfunc/averef.py new file mode 100644 index 00000000..a712714d --- /dev/null +++ b/src/eegprep/functions/miscfunc/averef.py @@ -0,0 +1,44 @@ +"""Average-reference numerical helper.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def averef( + data: Any, + weights: Any | None = None, + sphere: Any | None = None, + *, + return_parameters: bool = False, +) -> np.ndarray | tuple[np.ndarray, np.ndarray | None, np.ndarray | None, np.ndarray]: + """Average-reference channel-major data and optionally transform ICA weights. + + Set ``return_parameters=True`` to also receive transformed weights, sphere, + and the removed channel mean. This explicit switch replaces MATLAB's + output-count-dependent return behavior. + """ + values = np.asarray(data) + if values.ndim != 2 or values.shape[0] < 2: + raise ValueError("data must contain at least two channels") + mean_data = np.mean(values, axis=0) + referenced = values - mean_data + + output_weights: np.ndarray | None = None + output_sphere: np.ndarray | None = None + if weights is not None: + weight_array = np.asarray(weights) + unmixing = weight_array if sphere is None else weight_array @ np.asarray(sphere) + inverse = np.linalg.pinv(unmixing) + average_matrix = np.eye(inverse.shape[0]) - np.ones((inverse.shape[0], inverse.shape[0])) / inverse.shape[0] + output_weights = np.linalg.pinv(average_matrix @ inverse) + output_sphere = None if sphere is None else np.eye(values.shape[0]) + + if return_parameters: + return referenced, output_weights, output_sphere, mean_data + return referenced + + +__all__ = ["averef"] diff --git a/src/eegprep/functions/miscfunc/covary.py b/src/eegprep/functions/miscfunc/covary.py new file mode 100644 index 00000000..dfb71eb7 --- /dev/null +++ b/src/eegprep/functions/miscfunc/covary.py @@ -0,0 +1,34 @@ +"""Legacy EEGLAB covariance estimator.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from ._validation import real_array + + +def covary(data: Any) -> np.ndarray | np.floating[Any]: + """Return EEGLAB's globally centered, unbiased column second moment. + + Unlike :func:`numpy.var`, the historical EEGLAB helper subtracts one + grand mean from the entire input before computing each column. This + behavior is retained because existing analyses can depend on it. + """ + values = real_array(data, "data") + if values.ndim == 0: + return np.float64(np.nan) + if values.ndim == 1 or (values.ndim == 2 and values.shape[0] == 1): + vector = values.reshape(-1) + if vector.size < 2: + return np.float64(np.nan) + centered = vector - np.mean(vector) + return np.sum(centered * centered) / (vector.size - 1) + if values.ndim != 2 or values.shape[0] < 2: + raise ValueError("data must be a vector or 2-D matrix with at least two rows") + centered = values - np.mean(values) + return np.sum(centered * centered, axis=0) / (values.shape[0] - 1) + + +__all__ = ["covary"] diff --git a/src/eegprep/functions/miscfunc/datlim.py b/src/eegprep/functions/miscfunc/datlim.py new file mode 100644 index 00000000..121bbe30 --- /dev/null +++ b/src/eegprep/functions/miscfunc/datlim.py @@ -0,0 +1,20 @@ +"""Numeric data limits.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from ._validation import real_array + + +def datlim(data: Any) -> np.ndarray: + """Return the minimum and maximum of a nonempty numeric array.""" + values = real_array(data, "data") + if values.size == 0: + raise ValueError("data must not be empty") + return np.asarray([np.min(values), np.max(values)]) + + +__all__ = ["datlim"] diff --git a/src/eegprep/functions/miscfunc/eucl.py b/src/eegprep/functions/miscfunc/eucl.py new file mode 100644 index 00000000..07599709 --- /dev/null +++ b/src/eegprep/functions/miscfunc/eucl.py @@ -0,0 +1,43 @@ +"""Euclidean distances between point sets.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from ._validation import real_array +from scipy.spatial.distance import cdist, pdist, squareform + + +def eucl(coordinates: Any, other: Any | None = None) -> np.ndarray | float: + """Compute pairwise Euclidean distances between rows of point arrays.""" + first = _points(coordinates) + if other is None: + if first.shape[0] < 2: + raise ValueError("at least two points are required") + distances = squareform(pdist(first, metric="euclidean")) + return float(distances[0, 1]) if first.shape[0] == 2 else distances + second = _points(other) + if first.shape[1] != second.shape[1]: + raise ValueError("coordinate sets must have the same dimension") + distances = cdist(first, second, metric="euclidean") + if distances.shape == (1, 1): + return float(distances[0, 0]) + if first.shape[0] == 1: + return distances[0] + if second.shape[0] == 1: + return distances[:, 0] + return distances + + +def _points(value: Any) -> np.ndarray: + points = real_array(value, "coordinates") + if points.ndim == 1: + points = points.reshape(1, -1) + if points.ndim != 2: + raise ValueError("coordinates must be a point vector or 2-D point array") + return points + + +__all__ = ["eucl"] diff --git a/src/eegprep/functions/miscfunc/gabor2d.py b/src/eegprep/functions/miscfunc/gabor2d.py new file mode 100644 index 00000000..600d90a9 --- /dev/null +++ b/src/eegprep/functions/miscfunc/gabor2d.py @@ -0,0 +1,49 @@ +"""Two-dimensional Gabor kernels.""" + +from __future__ import annotations + +import numpy as np + +from .gauss2d import _positive_shape, _threshold_magnitude + + +def gabor2d( + rows: int, + columns: int, + frequency: float | None = None, + angle: float = 0.0, + sigma_rows: float | None = None, + sigma_columns: float | None = None, + mean_row: float | None = None, + mean_column: float | None = None, + phase: float = 0.0, + cut: float = 0.0, +) -> np.ndarray: + """Return a sinusoidal carrier under an anisotropic Gaussian envelope. + + ``frequency``, ``angle``, and ``phase`` are in degrees, preserving the + EEGLAB contract. Magnitude thresholding retains both positive and negative + lobes; the MATLAB implementation discards every negative value when + ``cut`` is nonzero. + """ + row_count, column_count = _positive_shape(rows, columns) + sigma_r = row_count / 5 if sigma_rows is None else float(sigma_rows) + sigma_c = column_count / 5 if sigma_columns is None else float(sigma_columns) + if sigma_r <= 0 or sigma_c <= 0: + raise ValueError("standard deviations must be positive") + center_r = (row_count + 1) / 2 if mean_row is None else float(mean_row) + center_c = (column_count + 1) / 2 if mean_column is None else float(mean_column) + freq = 360 / row_count if frequency is None else float(frequency) + x, y = np.meshgrid( + np.arange(1, row_count + 1, dtype=float), + np.arange(1, column_count + 1, dtype=float), + indexing="ij", + ) + rotated = ((x - center_r) + 1j * (y - center_c)) * np.exp(1j * np.deg2rad(angle)) + envelope = np.exp(-0.5 * (((x - center_r) / sigma_r) ** 2 + ((y - center_c) / sigma_c) ** 2)) + envelope /= np.sqrt(sigma_r * sigma_c) * np.pi + kernel = np.sin(np.real(rotated) * np.deg2rad(freq) + np.deg2rad(phase)) * envelope + return _threshold_magnitude(kernel, cut) + + +__all__ = ["gabor2d"] diff --git a/src/eegprep/functions/miscfunc/gauss.py b/src/eegprep/functions/miscfunc/gauss.py new file mode 100644 index 00000000..a285b6a7 --- /dev/null +++ b/src/eegprep/functions/miscfunc/gauss.py @@ -0,0 +1,21 @@ +"""One-dimensional Gaussian windows.""" + +from __future__ import annotations + +import numpy as np + +from ._validation import integer_scalar + + +def gauss(frames: int, standard_deviations: float) -> np.ndarray: + """Return an EEGLAB-compatible Gaussian window with a unit peak.""" + frame_count = integer_scalar(frames, "frames") + if frame_count < 1 or standard_deviations <= 0: + raise ValueError("frames and standard_deviations must be positive") + if frame_count == 1: + return np.ones(1) + locations = np.linspace(-standard_deviations, standard_deviations, frame_count) + return np.exp(-(locations**2)) + + +__all__ = ["gauss"] diff --git a/src/eegprep/functions/miscfunc/gauss2d.py b/src/eegprep/functions/miscfunc/gauss2d.py new file mode 100644 index 00000000..5f63cb62 --- /dev/null +++ b/src/eegprep/functions/miscfunc/gauss2d.py @@ -0,0 +1,58 @@ +"""Two-dimensional Gaussian kernels.""" + +from __future__ import annotations + +import numpy as np + +from ._validation import integer_scalar + + +def gauss2d( + rows: int, + columns: int, + sigma_rows: float | None = None, + sigma_columns: float | None = None, + mean_row: float | None = None, + mean_column: float | None = None, + cut: float = 0.0, +) -> np.ndarray: + """Return an EEGLAB-compatible two-dimensional Gaussian kernel. + + Coordinates intentionally remain one-based so explicit EEGLAB peak + coordinates produce the same samples. + """ + row_count, column_count = _positive_shape(rows, columns) + sigma_r = row_count / 5 if sigma_rows is None else float(sigma_rows) + sigma_c = column_count / 5 if sigma_columns is None else float(sigma_columns) + if sigma_r <= 0 or sigma_c <= 0: + raise ValueError("standard deviations must be positive") + center_r = (row_count + 1) / 2 if mean_row is None else float(mean_row) + center_c = (column_count + 1) / 2 if mean_column is None else float(mean_column) + x, y = np.meshgrid( + np.arange(1, row_count + 1, dtype=float), + np.arange(1, column_count + 1, dtype=float), + indexing="ij", + ) + kernel = np.exp(-0.5 * (((x - center_r) / sigma_r) ** 2 + ((y - center_c) / sigma_c) ** 2)) + kernel /= np.sqrt(sigma_r * sigma_c) * np.pi + return _threshold_magnitude(kernel, cut) + + +def _positive_shape(rows: int, columns: int) -> tuple[int, int]: + shape = integer_scalar(rows, "rows"), integer_scalar(columns, "columns") + if shape[0] < 1 or shape[1] < 1: + raise ValueError("kernel dimensions must be positive") + return shape + + +def _threshold_magnitude(values: np.ndarray, cut: float) -> np.ndarray: + if not 0 <= cut <= 1: + raise ValueError("cut must lie between zero and one") + if cut == 0 or values.size == 0: + return values + output = values.copy() + output[np.abs(output) < np.max(np.abs(output)) * cut] = 0 + return output + + +__all__ = ["gauss2d"] diff --git a/src/eegprep/functions/miscfunc/gauss3d.py b/src/eegprep/functions/miscfunc/gauss3d.py new file mode 100644 index 00000000..40d1d5e9 --- /dev/null +++ b/src/eegprep/functions/miscfunc/gauss3d.py @@ -0,0 +1,58 @@ +"""Three-dimensional Gaussian kernels.""" + +from __future__ import annotations + +import numpy as np + +from .gauss2d import _threshold_magnitude +from ._validation import integer_scalar + + +def gauss3d( + rows: int, + columns: int, + depth: int, + sigma_rows: float | None = None, + sigma_columns: float | None = None, + sigma_depth: float | None = None, + mean_row: float | None = None, + mean_column: float | None = None, + mean_depth: float | None = None, + cut: float = 0.0, +) -> np.ndarray: + """Return an EEGLAB-compatible three-dimensional Gaussian kernel.""" + shape = ( + integer_scalar(rows, "rows"), + integer_scalar(columns, "columns"), + integer_scalar(depth, "depth"), + ) + if min(shape) < 1: + raise ValueError("kernel dimensions must be positive") + sigmas = np.asarray( + [ + shape[0] / 5 if sigma_rows is None else sigma_rows, + shape[1] / 5 if sigma_columns is None else sigma_columns, + shape[2] / 5 if sigma_depth is None else sigma_depth, + ], + dtype=float, + ) + if np.any(sigmas <= 0): + raise ValueError("standard deviations must be positive") + means = np.asarray( + [ + (shape[0] + 1) / 2 if mean_row is None else mean_row, + (shape[1] + 1) / 2 if mean_column is None else mean_column, + (shape[2] + 1) / 2 if mean_depth is None else mean_depth, + ], + dtype=float, + ) + grids = np.meshgrid( + *(np.arange(1, length + 1, dtype=float) for length in shape), + indexing="ij", + ) + exponent = sum(((grid - mean) / sigma) ** 2 for grid, mean, sigma in zip(grids, means, sigmas)) + kernel = np.exp(-0.5 * exponent) / (np.sqrt(np.prod(sigmas)) * np.pi) + return _threshold_magnitude(kernel, cut) + + +__all__ = ["gauss3d"] diff --git a/src/eegprep/functions/miscfunc/hungarian.py b/src/eegprep/functions/miscfunc/hungarian.py new file mode 100644 index 00000000..33764dc1 --- /dev/null +++ b/src/eegprep/functions/miscfunc/hungarian.py @@ -0,0 +1,29 @@ +"""Minimum-cost bipartite assignment.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +from scipy.optimize import linear_sum_assignment + +from ._validation import real_array + + +def hungarian(cost_matrix: Any) -> tuple[np.ndarray, float]: + """Return a zero-based column-to-row assignment and its total cost.""" + costs = real_array(cost_matrix, "cost_matrix") + if costs.ndim != 2 or costs.shape[0] != costs.shape[1]: + raise ValueError("cost matrix must be square") + if np.any(np.isnan(costs)) or np.any(np.isneginf(costs)): + raise ValueError("cost matrix must not contain NaN or negative infinity") + try: + rows, columns = linear_sum_assignment(costs) + except ValueError as error: + raise ValueError("cost matrix has no finite complete assignment") from error + assignment = np.empty(costs.shape[1], dtype=int) + assignment[columns] = rows + return assignment, float(costs[rows, columns].sum()) + + +__all__ = ["hungarian"] diff --git a/src/eegprep/functions/miscfunc/laplac2d.py b/src/eegprep/functions/miscfunc/laplac2d.py new file mode 100644 index 00000000..8c90d07f --- /dev/null +++ b/src/eegprep/functions/miscfunc/laplac2d.py @@ -0,0 +1,36 @@ +"""Two-dimensional Laplacian-of-Gaussian kernels.""" + +from __future__ import annotations + +import numpy as np + +from .gauss2d import _positive_shape, _threshold_magnitude + + +def laplac2d( + rows: int, + columns: int, + sigma: float | None = None, + mean_row: float | None = None, + mean_column: float | None = None, + cut: float = 0.0, +) -> np.ndarray: + """Return EEGLAB's sampled two-dimensional Laplacian kernel.""" + row_count, column_count = _positive_shape(rows, columns) + width = row_count / 5 if sigma is None else float(sigma) + if width <= 0: + raise ValueError("sigma must be positive") + center_r = (row_count + 1) / 2 if mean_row is None else float(mean_row) + center_c = (column_count + 1) / 2 if mean_column is None else float(mean_column) + x, y = np.meshgrid( + np.arange(1, row_count + 1, dtype=float), + np.arange(1, column_count + 1, dtype=float), + indexing="ij", + ) + radius_squared = (x - center_r) ** 2 + (y - center_c) ** 2 + variance = width**2 + kernel = -np.exp(-0.5 * radius_squared / variance) * (radius_squared - variance) / variance**2 + return _threshold_magnitude(kernel, cut) + + +__all__ = ["laplac2d"] diff --git a/src/eegprep/functions/miscfunc/mapcorr.py b/src/eegprep/functions/miscfunc/mapcorr.py new file mode 100644 index 00000000..a682fd79 --- /dev/null +++ b/src/eegprep/functions/miscfunc/mapcorr.py @@ -0,0 +1,54 @@ +"""Channel-label-aware map matching.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +import numpy as np + +from ._validation import real_array +from .matcorr import _apply_weighting, _cosine_rows, _match_correlations + + +def mapcorr( + first: Any, + second: Any, + first_channels: Sequence[Mapping[str, Any]], + second_channels: Sequence[Mapping[str, Any]], + remove_mean: bool = False, + method: int = 2, + weighting: Any | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Match rows after aligning map columns by common channel labels.""" + left = real_array(first, "first") + right = real_array(second, "second") + if left.ndim != 2 or right.ndim != 2: + raise ValueError("map matrices must be 2-D") + if left.shape[1] != len(first_channels) or right.shape[1] != len(second_channels): + raise ValueError("channel-location counts must match map columns") + left_labels = [str(channel.get("labels", "")).strip() for channel in first_channels] + right_labels = [str(channel.get("labels", "")).strip() for channel in second_channels] + if not all(left_labels) or not all(right_labels): + raise ValueError("channel labels must be nonempty") + if len(set(left_labels)) != len(left_labels) or len(set(right_labels)) != len(right_labels): + raise ValueError("channel labels must be unique within each map") + right_by_label = {label: index for index, label in enumerate(right_labels)} + left_indices: list[int] = [] + right_indices: list[int] = [] + for left_index, channel in enumerate(first_channels): + label = left_labels[left_index] + if label in right_by_label: + left_indices.append(left_index) + right_indices.append(right_by_label[label]) + if not left_indices: + raise ValueError("the channel sets have no labels in common") + if remove_mean: + left = left - np.mean(left, axis=1, keepdims=True) + right = right - np.mean(right, axis=1, keepdims=True) + correlations = _cosine_rows(left[:, left_indices], right[:, right_indices]) + correlations = _apply_weighting(correlations, weighting) + return _match_correlations(correlations, method) + + +__all__ = ["mapcorr"] diff --git a/src/eegprep/functions/miscfunc/matcorr.py b/src/eegprep/functions/miscfunc/matcorr.py new file mode 100644 index 00000000..d322dafe --- /dev/null +++ b/src/eegprep/functions/miscfunc/matcorr.py @@ -0,0 +1,115 @@ +"""Correlation-based row matching.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +from scipy.optimize import linear_sum_assignment + +from ._validation import real_array + + +def matcorr( + first: Any, + second: Any, + remove_mean: bool = False, + method: int = 2, + weighting: Any | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Match rows of two matrices by absolute cosine correlation. + + Methods are ``0`` (globally optimal Hungarian assignment), ``1`` (Vogel + approximation), and ``2`` (successive maximum correlation). Returned row + indices are zero-based. Rectangular inputs return ``min(rows)`` unique pairs. + """ + left = real_array(first, "first") + right = real_array(second, "second") + if left.ndim != 2 or right.ndim != 2 or left.shape[1] != right.shape[1]: + raise ValueError("input matrices must be 2-D with the same number of columns") + if remove_mean: + left = left - np.mean(left, axis=1, keepdims=True) + right = right - np.mean(right, axis=1, keepdims=True) + correlations = _cosine_rows(left, right) + correlations = _apply_weighting(correlations, weighting) + return _match_correlations(correlations, method) + + +def _cosine_rows(left: np.ndarray, right: np.ndarray) -> np.ndarray: + left_norm = np.linalg.norm(left, axis=1) + right_norm = np.linalg.norm(right, axis=1) + denominator = np.outer(left_norm, right_norm) + correlations = np.zeros((left.shape[0], right.shape[0]), dtype=float) + np.divide(left @ right.T, denominator, out=correlations, where=denominator != 0) + return correlations + + +def _apply_weighting(correlations: np.ndarray, weighting: Any | None) -> np.ndarray: + if weighting is None: + return correlations + weights = real_array(weighting, "weighting") + if weights.size == 0 or np.linalg.norm(weights) == 0: + return correlations + if weights.shape != correlations.shape: + raise ValueError("weighting must have the same shape as the correlation matrix") + return correlations * weights + + +def _match_correlations(correlations: np.ndarray, method: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + if method == 0: + rows, columns = linear_sum_assignment(-np.abs(correlations)) + elif method == 1: + rows, columns = _vogel_pairs(np.abs(correlations)) + elif method == 2: + rows, columns = _greedy_pairs(np.abs(correlations)) + else: + raise ValueError("method must be 0, 1, or 2") + values = correlations[rows, columns] + order = np.argsort(-np.abs(values), kind="stable") + return values[order], rows[order], columns[order], correlations + + +def _greedy_pairs(scores: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + active = scores.copy() + row_indices: list[int] = [] + column_indices: list[int] = [] + for _ in range(min(active.shape)): + flat_index = int(np.argmax(active)) + row, column = np.unravel_index(flat_index, active.shape) + row_indices.append(int(row)) + column_indices.append(int(column)) + active[row, :] = -np.inf + active[:, column] = -np.inf + return np.asarray(row_indices), np.asarray(column_indices) + + +def _vogel_pairs(scores: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + active_rows = list(range(scores.shape[0])) + active_columns = list(range(scores.shape[1])) + row_indices: list[int] = [] + column_indices: list[int] = [] + while active_rows and active_columns: + submatrix = scores[np.ix_(active_rows, active_columns)] + row_penalties = _penalties(submatrix, axis=1) + column_penalties = _penalties(submatrix, axis=0) + row_choice = int(np.argmax(row_penalties)) + column_choice = int(np.argmax(column_penalties)) + if row_penalties[row_choice] > column_penalties[column_choice]: + chosen_row = row_choice + chosen_column = int(np.argmax(submatrix[chosen_row])) + else: + chosen_column = column_choice + chosen_row = int(np.argmax(submatrix[:, chosen_column])) + row_indices.append(active_rows.pop(chosen_row)) + column_indices.append(active_columns.pop(chosen_column)) + return np.asarray(row_indices), np.asarray(column_indices) + + +def _penalties(scores: np.ndarray, axis: int) -> np.ndarray: + sorted_scores = np.sort(scores, axis=axis) + if scores.shape[axis] == 1: + return np.take(sorted_scores, -1, axis=axis).reshape(-1) + return (np.take(sorted_scores, -1, axis=axis) - np.take(sorted_scores, -2, axis=axis)).reshape(-1) + + +__all__ = ["matcorr"] diff --git a/src/eegprep/functions/miscfunc/matperm.py b/src/eegprep/functions/miscfunc/matperm.py new file mode 100644 index 00000000..3145c49d --- /dev/null +++ b/src/eegprep/functions/miscfunc/matperm.py @@ -0,0 +1,37 @@ +"""Apply row matches and polarity corrections.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from ._validation import integer_array, real_array + + +def matperm( + first: Any, second: Any, first_indices: Any, second_indices: Any, correlations: Any +) -> tuple[np.ndarray, np.ndarray]: + """Reorder and sign-correct rows of ``first`` into ``second`` row order.""" + left = np.asarray(first) + right = np.asarray(second) + rows = integer_array(first_indices, "first_indices").reshape(-1) + destinations = integer_array(second_indices, "second_indices").reshape(-1) + values = real_array(correlations, "correlations").reshape(-1) + if left.ndim != 2 or right.ndim != 2 or left.shape[1] != right.shape[1]: + raise ValueError("input matrices must be 2-D with the same number of columns") + if not (rows.size == destinations.size == values.size): + raise ValueError("indices and correlations must have equal lengths") + if np.unique(rows).size != rows.size or np.unique(destinations).size != destinations.size: + raise ValueError("row matches must be unique") + if np.any(rows < 0) or np.any(rows >= left.shape[0]): + raise IndexError("first_indices are out of range") + if np.any(destinations < 0) or np.any(destinations >= right.shape[0]): + raise IndexError("second_indices are out of range") + order = np.argsort(destinations, kind="stable") + permutation = rows[order] + signs = np.where(values[order] < 0, -1, 1) + return left[permutation] * signs[:, None], permutation + + +__all__ = ["matperm"] diff --git a/src/eegprep/functions/miscfunc/means.py b/src/eegprep/functions/miscfunc/means.py new file mode 100644 index 00000000..4f4071ec --- /dev/null +++ b/src/eegprep/functions/miscfunc/means.py @@ -0,0 +1,52 @@ +"""Grouped means and uncertainty estimates.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from .uniquef import uniquef + + +def means(data: Any, groups: Any | None = None) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return per-group means, standard errors, variances, and group IDs. + + Statistics use only finite observations. This intentionally fixes EEGLAB's + ``means`` helper, which divides standard errors by the full group size even + when nonfinite observations were excluded. + """ + values = np.asarray(data) + if not np.issubdtype(values.dtype, np.number): + raise TypeError("data must be numeric") + if values.ndim == 1: + values = values.reshape(-1, 1) + if values.ndim != 2: + raise ValueError("data must be a vector or 2-D observations-by-variables matrix") + if groups is None: + group_vector = np.ones(values.shape[0], dtype=float) + group_ids = np.ones(1, dtype=float) + else: + group_vector = np.asarray(groups).reshape(-1) + if group_vector.size != values.shape[0]: + raise ValueError("groups must contain one label per observation") + group_ids, _, _ = uniquef(group_vector, sort=True) + + mean_dtype = np.result_type(values.dtype, float) + group_means = np.full((group_ids.size, values.shape[1]), np.nan, dtype=mean_dtype) + standard_errors = np.full((group_ids.size, values.shape[1]), np.nan) + variances = np.full_like(standard_errors, np.nan) + for row, group_id in enumerate(group_ids): + subset = values[group_vector == group_id] + for column in range(values.shape[1]): + finite = subset[np.isfinite(subset[:, column]), column] + if finite.size == 0: + continue + group_means[row, column] = np.mean(finite) + if finite.size > 1: + variances[row, column] = np.var(finite, ddof=1) + standard_errors[row, column] = np.sqrt(variances[row, column] / finite.size) + return group_means, standard_errors, variances, group_ids + + +__all__ = ["means"] diff --git a/src/eegprep/functions/miscfunc/misc.py b/src/eegprep/functions/miscfunc/misc.py index 3cd5b070..2bda712c 100644 --- a/src/eegprep/functions/miscfunc/misc.py +++ b/src/eegprep/functions/miscfunc/misc.py @@ -258,9 +258,11 @@ def canonicalize_signs(V): """Canonicalize signs of column matrix V so that the largest absolute value is positive.""" # V: columns are eigenvectors idx = np.argmax(np.abs(V), axis=0) - sgn = np.sign(V[idx, range(V.shape[1])]) - sgn[sgn == 0] = 1 - return V * sgn + pivot = V[idx, range(V.shape[1])] + phase = np.ones_like(pivot, dtype=np.result_type(V.dtype, float)) + nonzero = pivot != 0 + phase[nonzero] = np.conj(pivot[nonzero]) / np.abs(pivot[nonzero]) + return V * phase def round_mat(x, decimals=0): diff --git a/src/eegprep/functions/miscfunc/nan_std.py b/src/eegprep/functions/miscfunc/nan_std.py new file mode 100644 index 00000000..82d3d0a9 --- /dev/null +++ b/src/eegprep/functions/miscfunc/nan_std.py @@ -0,0 +1,38 @@ +"""NaN-aware sample standard deviation.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from ._validation import integer_scalar, real_array + + +def nan_std(data: Any, axis: int | None = None) -> np.ndarray | np.floating[Any]: + """Return sample standard deviations while ignoring NaNs. + + When ``axis`` is omitted, the first non-singleton dimension is used, as in + MATLAB. A single non-NaN observation yields ``NaN``. + """ + values = real_array(data, "data") + if values.ndim == 0: + return np.float64(np.nan) + selected_axis = _first_nonsingleton_axis(values) if axis is None else integer_scalar(axis, "axis") + count = np.sum(~np.isnan(values), axis=selected_axis) + with np.errstate(divide="ignore", invalid="ignore"): + mean = np.nansum(values, axis=selected_axis) / count + centered = values - np.expand_dims(mean, axis=selected_axis) + centered = np.where(np.isnan(values), 0.0, centered) + variance = np.sum(centered**2, axis=selected_axis) / (count - 1) + return np.where(count > 1, np.sqrt(variance), np.nan) + + +def _first_nonsingleton_axis(values: np.ndarray) -> int: + for axis, length in enumerate(values.shape): + if length != 1: + return axis + return 0 + + +__all__ = ["nan_std"] diff --git a/src/eegprep/functions/miscfunc/pcexpand.py b/src/eegprep/functions/miscfunc/pcexpand.py new file mode 100644 index 00000000..6d00b5dc --- /dev/null +++ b/src/eegprep/functions/miscfunc/pcexpand.py @@ -0,0 +1,26 @@ +"""Principal-component reconstruction.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def pcexpand(projections: Any, eigenvectors: Any, data_means: Any) -> np.ndarray: + """Expand component projections back into channel space.""" + projected = np.asarray(projections) + vectors = np.asarray(eigenvectors) + means = np.asarray(data_means).reshape(-1) + if not all(np.issubdtype(array.dtype, np.number) for array in (projected, vectors, means)): + raise TypeError("PCA inputs must be numeric") + if projected.ndim != 2 or vectors.ndim != 2 or vectors.shape[0] != vectors.shape[1]: + raise ValueError("projections must be 2-D and eigenvectors must be square") + if projected.shape[0] > vectors.shape[1]: + raise ValueError("eigenvectors must span every projected component") + if means.size != vectors.shape[0]: + raise ValueError("data_means must contain one value per output channel") + return vectors[:, : projected.shape[0]] @ projected + means[:, None] + + +__all__ = ["pcexpand"] diff --git a/src/eegprep/functions/miscfunc/pcsquash.py b/src/eegprep/functions/miscfunc/pcsquash.py new file mode 100644 index 00000000..e2c95eaf --- /dev/null +++ b/src/eegprep/functions/miscfunc/pcsquash.py @@ -0,0 +1,38 @@ +"""Principal-component compression.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from ._validation import integer_scalar +from .misc import canonicalize_signs + + +def pcsquash(data: Any, components: int | None = None) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Compress channel-major data into its leading principal components.""" + values = np.asarray(data) + if not np.issubdtype(values.dtype, np.number): + raise TypeError("data must be numeric") + if values.ndim == 1: + values = values.reshape(1, -1) + if values.ndim != 2 or values.shape[1] == 0: + raise ValueError("data must be a nonempty channel-by-frame matrix") + count = values.shape[0] if components is None else integer_scalar(components, "components") + if count == 0: + count = values.shape[0] + if count < 1 or count > values.shape[0]: + raise ValueError("components must lie between one and the channel count") + data_mean = np.mean(values, axis=1) + centered = values - data_mean[:, None] + covariance = centered @ centered.conj().T / values.shape[1] + eigenvalues, eigenvectors = np.linalg.eigh(covariance) + order = np.argsort(eigenvalues)[::-1] + eigenvalues = eigenvalues[order] + eigenvectors = canonicalize_signs(eigenvectors[:, order]) + compressed = eigenvectors[:, :count].conj().T @ centered + return eigenvectors, eigenvalues, compressed, data_mean + + +__all__ = ["pcsquash"] diff --git a/src/eegprep/functions/miscfunc/perminv.py b/src/eegprep/functions/miscfunc/perminv.py new file mode 100644 index 00000000..4da34c9f --- /dev/null +++ b/src/eegprep/functions/miscfunc/perminv.py @@ -0,0 +1,24 @@ +"""Inverse permutations.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from ._validation import integer_array + + +def perminv(permutation: Any) -> np.ndarray: + """Return the inverse of a zero-based permutation vector.""" + values = integer_array(permutation, "permutation") + if values.ndim != 1: + raise ValueError("permutation must be one-dimensional") + if np.unique(values).size != values.size or np.any(np.sort(values) != np.arange(values.size)): + raise ValueError("input must contain each integer from zero to n - 1 exactly once") + inverse = np.empty_like(values) + inverse[values] = np.arange(values.size) + return inverse + + +__all__ = ["perminv"] diff --git a/src/eegprep/functions/miscfunc/scanfold.py b/src/eegprep/functions/miscfunc/scanfold.py new file mode 100644 index 00000000..b5881c73 --- /dev/null +++ b/src/eegprep/functions/miscfunc/scanfold.py @@ -0,0 +1,40 @@ +"""Recursive MATLAB-source folder scanning.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path + +from ._validation import integer_scalar + + +def scanfold( + folder: str | Path, ignore: Iterable[str] | str | Path = (), max_depth: int = 100 +) -> tuple[list[str], str]: + """Return MATLAB filenames below ``folder`` and EEGLAB's ``-a`` text.""" + root = Path(folder) + if not root.is_dir(): + raise NotADirectoryError(root) + depth = integer_scalar(max_depth, "max_depth") + if depth < 0: + raise ValueError("max_depth must be nonnegative") + ignore_names = [ignore] if isinstance(ignore, (str, Path)) else ignore + ignored = {str(name).casefold() for name in ignore_names} + filenames = _scan(root, ignored, depth) + return filenames, "".join(f" -a {name}" for name in filenames) + + +def _scan(folder: Path, ignored: set[str], depth: int) -> list[str]: + if depth == 0: + return [] + filenames: list[str] = [] + for entry in sorted(folder.iterdir(), key=lambda path: path.name.casefold()): + if entry.is_dir(): + if entry.name.casefold() not in ignored: + filenames.extend(_scan(entry, ignored, depth - 1)) + elif entry.suffix.casefold() == ".m": + filenames.append(entry.name) + return filenames + + +__all__ = ["scanfold"] diff --git a/src/eegprep/functions/miscfunc/uniquef.py b/src/eegprep/functions/miscfunc/uniquef.py new file mode 100644 index 00000000..09b55a5c --- /dev/null +++ b/src/eegprep/functions/miscfunc/uniquef.py @@ -0,0 +1,38 @@ +"""Stable unique numeric values with frequencies.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from ._validation import real_array + + +def uniquef(groups: Any, sort: bool = False) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return finite unique values, counts, and zero-based first indices.""" + values = real_array(groups, "groups").reshape(-1) + tolerance = np.finfo(float).eps * 10**4 + unique_values: list[float] = [] + counts: list[int] = [] + first_indices: list[int] = [] + for index, value in enumerate(values): + if not np.isfinite(value): + continue + matches = np.flatnonzero(np.abs(np.asarray(unique_values) - value) < tolerance) + if matches.size: + counts[int(matches[0])] += 1 + else: + unique_values.append(float(value)) + counts.append(1) + first_indices.append(index) + result_values = np.asarray(unique_values) + result_counts = np.asarray(counts, dtype=int) + result_indices = np.asarray(first_indices, dtype=int) + if sort: + order = np.argsort(result_values, kind="stable") + return result_values[order], result_counts[order], result_indices[order] + return result_values, result_counts, result_indices + + +__all__ = ["uniquef"] diff --git a/src/eegprep/functions/miscfunc/vectdata.py b/src/eegprep/functions/miscfunc/vectdata.py new file mode 100644 index 00000000..2c42cc1a --- /dev/null +++ b/src/eegprep/functions/miscfunc/vectdata.py @@ -0,0 +1,95 @@ +"""Vector interpolation with optional moving averaging.""" + +from __future__ import annotations + +from typing import Any, Literal + +import numpy as np +from scipy.interpolate import interp1d +from scipy.signal import convolve + +from ._validation import real_array +from .misc import round_mat + + +def vectdata( + data: Any, + times: Any, + *, + timesout: Any, + method: Literal["linear", "cubic", "nearest", "v4"] = "linear", + average: float | None = None, + avgtype: Literal["const", "gauss"] = "const", + border: Literal["on", "off"] = "off", +) -> tuple[np.ndarray, np.ndarray]: + """Interpolate along the final data axis and optionally smooth first. + + ``average`` is a window width in the units of ``times``. MATLAB's legacy + ``v4`` biharmonic ``griddata`` method has no well-defined one-dimensional + equivalent and is rejected explicitly. + """ + values = np.asarray(data) + if not np.issubdtype(values.dtype, np.number): + raise TypeError("data must be numeric") + input_times = real_array(times, "times").reshape(-1) + output_times = real_array(timesout, "timesout").reshape(-1) + was_vector = values.ndim == 1 + if was_vector: + values = values.reshape(1, -1) + if values.ndim != 2 or values.shape[1] != input_times.size: + raise ValueError("data must be a vector or 2-D array whose final axis matches times") + if input_times.size < 2 or np.any(np.diff(input_times) <= 0): + raise ValueError("times must be strictly increasing and contain at least two points") + if method == "v4": + raise NotImplementedError("MATLAB's legacy griddata v4 method has no 1-D SciPy equivalent") + if method not in {"linear", "cubic", "nearest"}: + raise ValueError("method must be linear, cubic, nearest, or v4") + if border not in {"on", "off"}: + raise ValueError("border must be on or off") + if avgtype not in {"const", "gauss"}: + raise ValueError("avgtype must be const or gauss") + + working_times = input_times + working_values = values + if average is not None: + if average <= 0: + raise ValueError("average must be positive") + spacings = np.diff(working_times) + if not np.allclose(spacings, spacings[0], rtol=0, atol=1e-8): + point_count = int(np.ceil((working_times[-1] - working_times[0]) / np.mean(spacings))) + 1 + uniform_times = np.linspace(working_times[0], working_times[-1], point_count) + working_values = _interpolate(working_values, working_times, uniform_times, method) + working_times = uniform_times + window_points = max(1, int(round_mat(average / np.diff(working_times).mean()))) + kernel = _smoothing_kernel(window_points, avgtype) + working_values = _smooth(working_values, kernel, correct_border=border == "on") + result = _interpolate(working_values, working_times, output_times, method) + return (result[0] if was_vector else result), output_times + + +def _interpolate(values: np.ndarray, times: np.ndarray, output: np.ndarray, method: str) -> np.ndarray: + interpolator = interp1d(times, values, kind=method, axis=1, bounds_error=True, assume_sorted=True) + return np.asarray(interpolator(output)) + + +def _smoothing_kernel(points: int, avgtype: str) -> np.ndarray: + if avgtype == "const": + return np.ones(points, dtype=float) / points + if avgtype == "gauss": + locations = np.arange(points, dtype=float) - (points - 1) / 2 + width = max(0.15 * points, np.finfo(float).eps) + kernel = np.exp(-0.5 * (locations / width) ** 2) + return kernel / kernel.sum() + raise ValueError("avgtype must be const or gauss") + + +def _smooth(values: np.ndarray, kernel: np.ndarray, *, correct_border: bool) -> np.ndarray: + kernel_2d = kernel.reshape(1, -1) + smoothed = convolve(values, kernel_2d, mode="same") + if not correct_border: + return smoothed + normalization = convolve(np.ones_like(values), kernel_2d, mode="same") + return smoothed / normalization + + +__all__ = ["vectdata"] diff --git a/src/eegprep/functions/sigprocfunc/__init__.py b/src/eegprep/functions/sigprocfunc/__init__.py index 128e6bd1..35bed18b 100644 --- a/src/eegprep/functions/sigprocfunc/__init__.py +++ b/src/eegprep/functions/sigprocfunc/__init__.py @@ -1 +1,21 @@ """EEGLAB-style signal processing function modules.""" + +from .celltomat import celltomat +from .eyelike import eyelike +from .fastif import fastif +from .matsel import matsel +from .mattocell import mattocell +from .nan_mean import nan_mean +from .quantile import quantile +from .shuffle import shuffle + +__all__ = [ + "celltomat", + "eyelike", + "fastif", + "matsel", + "mattocell", + "nan_mean", + "quantile", + "shuffle", +] diff --git a/src/eegprep/functions/sigprocfunc/celltomat.py b/src/eegprep/functions/sigprocfunc/celltomat.py new file mode 100644 index 00000000..91ce8baf --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/celltomat.py @@ -0,0 +1,28 @@ +"""Numeric cell-like data conversion.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def celltomat(cells: Any) -> np.ndarray: + """Convert a rectangular nested sequence of numeric scalars to an array.""" + result = np.asarray(cells) + if result.dtype == object and result.size: + values = [_numeric_scalar(cell) for cell in result.flat] + result = np.asarray(values).reshape(result.shape) + if not np.issubdtype(result.dtype, np.number) and result.size: + raise TypeError("cells must contain numeric values") + return result + + +def _numeric_scalar(cell: Any) -> Any: + value = np.asarray(cell) + if value.size != 1 or not np.issubdtype(value.dtype, np.number): + raise TypeError("cells must form a rectangular array of numeric scalars") + return value.reshape(-1)[0] + + +__all__ = ["celltomat"] diff --git a/src/eegprep/functions/sigprocfunc/eyelike.py b/src/eegprep/functions/sigprocfunc/eyelike.py new file mode 100644 index 00000000..f553e10e --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/eyelike.py @@ -0,0 +1,32 @@ +"""Permutation and scale normalization of square matrices.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +from scipy.optimize import linear_sum_assignment + + +def eyelike(matrix: Any) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Permute and scale rows so the output diagonal is one.""" + values = np.asarray(matrix) + if not np.issubdtype(values.dtype, np.number): + raise TypeError("matrix must be numeric") + if values.ndim != 2 or values.shape[0] != values.shape[1]: + raise ValueError("matrix must be square") + row_norms = np.sum(np.abs(values), axis=1) + if np.any(row_norms == 0): + raise ValueError("matrix rows must contain at least one nonzero value") + normalized = values / row_norms[:, None] + rows, columns = linear_sum_assignment(-np.abs(normalized)) + permutation = np.zeros_like(values) + permutation[columns, rows] = 1 + diagonal = np.diag(permutation @ values) + if np.any(diagonal == 0): + raise ValueError("matrix cannot be permuted to a nonzero diagonal") + scale = np.diag(1 / diagonal) + return scale @ permutation @ values, scale, permutation + + +__all__ = ["eyelike"] diff --git a/src/eegprep/functions/sigprocfunc/fastif.py b/src/eegprep/functions/sigprocfunc/fastif.py new file mode 100644 index 00000000..19398b6c --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/fastif.py @@ -0,0 +1,15 @@ +"""Expression-level conditional compatibility helper.""" + +from __future__ import annotations + +from typing import TypeVar + +T = TypeVar("T") + + +def fastif(condition: object, if_true: T, if_false: T) -> T: + """Return ``if_true`` when ``condition`` is truthy, otherwise ``if_false``.""" + return if_true if bool(condition) else if_false + + +__all__ = ["fastif"] diff --git a/src/eegprep/functions/sigprocfunc/matsel.py b/src/eegprep/functions/sigprocfunc/matsel.py new file mode 100644 index 00000000..d034d0ab --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/matsel.py @@ -0,0 +1,51 @@ +"""Selection from flattened channel-by-frame epoch matrices.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc._validation import integer_array, integer_scalar + + +def matsel( + data: Any, + frames_per_epoch: int | None, + frames: Any | None, + channels: Any | None = None, + epochs: Any | None = None, +) -> np.ndarray: + """Select zero-based channels, within-epoch frames, and epochs. + + Input data remain two-dimensional with epochs concatenated along columns; + output epochs are concatenated in the requested order. + """ + values = np.asarray(data) + if values.ndim != 2 or values.size == 0: + raise ValueError("data must be a nonempty 2-D channel-by-frame matrix") + epoch_frames = values.shape[1] if frames_per_epoch is None else integer_scalar(frames_per_epoch, "frames_per_epoch") + if epoch_frames == 0: + epoch_frames = values.shape[1] + if epoch_frames <= 0 or values.shape[1] % epoch_frames: + raise ValueError("frames_per_epoch must divide the data length") + epoch_count = values.shape[1] // epoch_frames + frame_indices = _indices(frames, epoch_frames, "frames") + channel_indices = _indices(channels, values.shape[0], "channels") + epoch_indices = _indices(epochs, epoch_count, "epochs") + if frame_indices.size == 0 or epoch_indices.size == 0: + return values[np.ix_(channel_indices, np.asarray([], dtype=int))] + sample_indices = np.concatenate([frame_indices + epoch * epoch_frames for epoch in epoch_indices]) + return values[np.ix_(channel_indices, sample_indices)] + + +def _indices(selection: Any | None, length: int, name: str) -> np.ndarray: + indices = np.arange(length) if selection is None else integer_array(selection, name).reshape(-1) + if indices.size == 0: + return indices + if np.any(indices < 0) or np.any(indices >= length): + raise IndexError(f"{name} indices are out of range") + return indices + + +__all__ = ["matsel"] diff --git a/src/eegprep/functions/sigprocfunc/mattocell.py b/src/eegprep/functions/sigprocfunc/mattocell.py new file mode 100644 index 00000000..90003173 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/mattocell.py @@ -0,0 +1,15 @@ +"""Matrix to cell-like data conversion.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def mattocell(matrix: Any) -> list[Any]: + """Convert a NumPy-compatible matrix to nested Python scalar lists.""" + return np.asarray(matrix).tolist() + + +__all__ = ["mattocell"] diff --git a/src/eegprep/functions/sigprocfunc/nan_mean.py b/src/eegprep/functions/sigprocfunc/nan_mean.py new file mode 100644 index 00000000..edb6fc45 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/nan_mean.py @@ -0,0 +1,28 @@ +"""NaN-aware arithmetic means.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc._validation import integer_scalar +from eegprep.functions.miscfunc.nan_std import _first_nonsingleton_axis + + +def nan_mean(data: Any, axis: int | None = None) -> Any: + """Return means while ignoring NaNs along the selected dimension.""" + values = np.asarray(data) + if not np.issubdtype(values.dtype, np.number): + raise TypeError("data must be numeric") + if values.ndim == 0: + return values[()] + selected_axis = _first_nonsingleton_axis(values) if axis is None else integer_scalar(axis, "axis") + count = np.sum(~np.isnan(values), axis=selected_axis) + total = np.nansum(values, axis=selected_axis) + with np.errstate(divide="ignore", invalid="ignore"): + result = total / count + return np.where(count > 0, result, np.nan) + + +__all__ = ["nan_mean"] diff --git a/src/eegprep/functions/sigprocfunc/quantile.py b/src/eegprep/functions/sigprocfunc/quantile.py new file mode 100644 index 00000000..c23febdf --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/quantile.py @@ -0,0 +1,43 @@ +"""Legacy MATLAB-compatible sample quantiles.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc._validation import integer_scalar, real_array +from eegprep.functions.miscfunc.nan_std import _first_nonsingleton_axis + + +def quantile(data: Any, probabilities: Any, axis: int | None = None) -> np.ndarray: + """Return quantiles using MATLAB's midpoint empirical-probability rule.""" + values = real_array(data, "data") + requested = real_array(probabilities, "probabilities").reshape(-1) + if np.any((requested < 0) | (requested > 1)): + raise ValueError("probabilities must lie between zero and one") + if values.ndim == 0: + return np.full(requested.shape, float(values)) + selected_axis = _first_nonsingleton_axis(values) if axis is None else integer_scalar(axis, "axis") + moved = np.moveaxis(values, selected_axis, 0) + if moved.shape[0] == 0: + raise ValueError("data must not be empty") + columns = moved.reshape(moved.shape[0], -1) + output = np.empty((requested.size, columns.shape[1]), dtype=float) + for column in range(columns.shape[1]): + samples = np.sort(columns[~np.isnan(columns[:, column]), column]) + if samples.size == 0: + output[:, column] = np.nan + continue + sample_probabilities = (np.arange(samples.size) + 0.5) / samples.size + output[:, column] = np.interp( + requested, + sample_probabilities, + samples, + left=samples[0], + right=samples[-1], + ) + return output.reshape((requested.size, *moved.shape[1:])) + + +__all__ = ["quantile"] diff --git a/src/eegprep/functions/sigprocfunc/shuffle.py b/src/eegprep/functions/sigprocfunc/shuffle.py new file mode 100644 index 00000000..c7079756 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/shuffle.py @@ -0,0 +1,37 @@ +"""Axis-wise random shuffling.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc._validation import integer_scalar + + +def shuffle( + data: Any, axis: int | None = None, *, rng: np.random.Generator | None = None +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Shuffle one zero-based axis and return permutation and inverse indices.""" + values = np.asarray(data) + if values.ndim == 0: + raise ValueError("data must have at least one dimension") + selected_axis = _first_nonsingleton_axis(values) if axis is None else integer_scalar(axis, "axis") + if selected_axis < 0: + selected_axis += values.ndim + if selected_axis < 0 or selected_axis >= values.ndim: + raise ValueError(f"axis {selected_axis} is out of range for {values.ndim}-D data") + generator = np.random.default_rng() if rng is None else rng + permutation = generator.permutation(values.shape[selected_axis]) + inverse = np.argsort(permutation) + return np.take(values, permutation, axis=selected_axis), permutation, inverse + + +def _first_nonsingleton_axis(values: np.ndarray) -> int: + for axis, length in enumerate(values.shape): + if length != 1: + return axis + return values.ndim - 1 + + +__all__ = ["shuffle"] diff --git a/tests/test_miscfunc_numerical_eeglab_tests.py b/tests/test_miscfunc_numerical_eeglab_tests.py new file mode 100644 index 00000000..e05babae --- /dev/null +++ b/tests/test_miscfunc_numerical_eeglab_tests.py @@ -0,0 +1,528 @@ +"""Behavioral ports of current EEGLAB miscellaneous numerical tests.""" + +from __future__ import annotations + +import itertools + +import numpy as np +import pytest + +from eegprep import ( + abspeak, + averef, + covary, + datlim, + eucl, + gabor2d, + gauss, + gauss2d, + gauss3d, + hungarian, + laplac2d, + mapcorr, + matcorr, + matperm, + means, + nan_std, + pcexpand, + pcsquash, + perminv, + scanfold, + uniquef, + vectdata, +) +from tests.eeglab_tests import eeglab_test + + +def _source(name: str) -> str: + return f"unittesting_miscfunc/{name}/miscfunc_{name}_wrapperTest.m" + + +@eeglab_test(_source("abspeak"), "test_fail_invalid_frames") +def test_current_abspeak_rejects_an_epoch_length_that_does_not_divide_data(): + with pytest.raises(ValueError, match="divide"): + abspeak(np.ones((2, 5)), 6) + with pytest.raises(ValueError, match="integer"): + abspeak(np.ones((2, 6)), 2.9) + + +@eeglab_test(_source("abspeak"), "test_fail_no_arg") +def test_current_abspeak_requires_data(): + with pytest.raises(TypeError): + abspeak() # type: ignore[call-arg] + + +@eeglab_test(_source("abspeak"), "test_pass_all_identical") +@eeglab_test(_source("abspeak"), "test_pass_all_negative") +@eeglab_test(_source("abspeak"), "test_pass_all_positive") +@eeglab_test(_source("abspeak"), "test_pass_inf") +@eeglab_test(_source("abspeak"), "test_pass_mixed_sign") +@eeglab_test(_source("abspeak"), "test_pass_small_diff") +def test_current_abspeak_returns_last_tied_peak_and_its_sign(): + cases = [ + ( + [[-2, -2, -2, -2], [5, 5, 5, 5]], + [2, 5], + [3, 3], + [-1, 1], + ), + ( + [[-2, -1, -3, -5, -4], [-8, -2, -6, -7, -1]], + [5, 8], + [3, 0], + [-1, -1], + ), + ( + [[2, 1, 3, 5, 4], [8, 2, 6, 7, 1]], + [5, 8], + [3, 0], + [1, 1], + ), + ( + [[5, -1, 7, -3, 9], [-2, 4, -7, 3, -5]], + [9, 7], + [4, 2], + [1, -1], + ), + ( + [[-2, -2.0000000000001, -1.9999999999999, -2], [5.0000000000002, -5.0000000000001, 5, 5]], + [2.0000000000001, 5.0000000000002], + [1, 0], + [-1, 1], + ), + ] + for data, expected_amplitudes, expected_frames, expected_signs in cases: + amplitudes, frames, signs = abspeak(data) + np.testing.assert_allclose(amplitudes[:, 0], expected_amplitudes) + np.testing.assert_array_equal(frames[:, 0], expected_frames) + np.testing.assert_array_equal(signs[:, 0], expected_signs) + + amplitudes, frames, signs = abspeak([[1, np.inf, 3], [-np.inf, 2, np.inf]]) + np.testing.assert_array_equal(amplitudes[:, 0], [np.inf, np.inf]) + np.testing.assert_array_equal(frames[:, 0], [1, 2]) + np.testing.assert_array_equal(signs[:, 0], [1, 1]) + amplitudes, frames, signs = abspeak([[1, -4, -7, 2]], 2) + np.testing.assert_array_equal(amplitudes, [[4, 7]]) + np.testing.assert_array_equal(frames, [[1, 0]]) + np.testing.assert_array_equal(signs, [[-1, -1]]) + amplitudes, frames, signs = abspeak([[1 + 1j, 3 + 4j]]) + np.testing.assert_allclose(amplitudes, [[5]]) + np.testing.assert_array_equal(frames, [[1]]) + np.testing.assert_allclose(signs, [[0.6 + 0.8j]]) + + +@eeglab_test(_source("abspeak"), "test_pass_nan_inf") +def test_current_abspeak_ignores_nan_without_masking_infinite_peaks(): + amplitudes, frames, signs = abspeak([[np.nan, -np.inf, np.nan], [np.nan, np.nan, np.nan]]) + assert amplitudes[0, 0] == np.inf + assert frames[0, 0] == 1 + assert signs[0, 0] == -1 + assert np.isnan(amplitudes[1, 0]) + assert frames[1, 0] == -1 + assert np.isnan(signs[1, 0]) + + +@eeglab_test(_source("averef"), "test_fail_few_data") +def test_current_averef_rejects_single_channel_data(): + with pytest.raises(ValueError, match="two channels"): + averef([[2, 8]]) + + +@eeglab_test(_source("averef"), "test_fail_no_arg") +def test_current_averef_requires_data(): + with pytest.raises(TypeError): + averef() # type: ignore[call-arg] + + +@eeglab_test(_source("averef"), "test_pass_one_arg_mixed") +@eeglab_test(_source("averef"), "test_pass_one_arg_positive") +def test_current_averef_removes_each_frames_channel_mean(): + positive = np.asarray([[2, 1, 3], [8, 2, 6]], dtype=float) + np.testing.assert_allclose(averef(positive), [[-3, -0.5, -1.5], [3, 0.5, 1.5]]) + mixed = np.asarray([[2, 1, 3, 2.4, -np.pi], [0, -3, -3, -1.8, np.pi]]) + np.testing.assert_allclose(averef(mixed), [[1, 2, 3, 2.1, -np.pi], [-1, -2, -3, -2.1, np.pi]]) + complex_data = np.asarray([[1 + 2j, 3 - 1j], [5 - 2j, 1 + 3j]]) + np.testing.assert_allclose(averef(complex_data).mean(axis=0), 0, atol=1e-15) + + referenced, weights, sphere, mean_data = averef(positive, np.eye(2), np.eye(2), return_parameters=True) + np.testing.assert_allclose(referenced.mean(axis=0), 0, atol=1e-15) + np.testing.assert_allclose(sphere, np.eye(2)) + np.testing.assert_allclose(mean_data, positive.mean(axis=0)) + np.testing.assert_allclose(weights, [[0.5, -0.5], [-0.5, 0.5]]) + + input_weights = np.asarray([[2.0, 1.0], [0.5, 1.5]]) + input_sphere = np.asarray([[1.0, 0.2], [0.0, 0.8]]) + _, transformed, transformed_sphere, _ = averef(positive, input_weights, input_sphere, return_parameters=True) + mixing = np.linalg.pinv(input_weights @ input_sphere) + average_matrix = np.eye(2) - np.ones((2, 2)) / 2 + np.testing.assert_allclose(transformed, np.linalg.pinv(average_matrix @ mixing)) + np.testing.assert_allclose(transformed_sphere, np.eye(2)) + + +@eeglab_test(_source("covary"), "test_pass_mixed_matrix") +@eeglab_test(_source("covary"), "test_pass_mixed_vector") +@eeglab_test(_source("covary"), "test_pass_positive_matrix") +@eeglab_test(_source("covary"), "test_pass_positive_vector") +@eeglab_test(_source("covary"), "test_test_covary") +def test_current_covary_preserves_grand_mean_centering_and_unbiased_scaling(): + np.testing.assert_allclose(covary([2, 1, 3, 5, 4]), 2.5) + np.testing.assert_allclose(covary([-1, 2, 3, -4, 5]), 12.5) + positive = np.asarray([[2, 1, 3, 5, 4], [5, 4, 3, 2, 1], [3, 3, 3, 3, 3], [1, 0, 1, 0, 1]]).T + mixed = np.asarray([[2, -1, -4, 5, 3], [5, -4, 3, 2, -1], [-3, 3, -3, -3, 3], [1, 0, 1, 0, 1]]).T + np.testing.assert_allclose(covary(positive), [2.95, 2.95, 0.45, 4.35]) + np.testing.assert_allclose(covary(mixed), [12.8125, 12.8125, 12.3125, 0.3125]) + assert covary([1, 4, 1]) == pytest.approx(3) + + +@eeglab_test(_source("datlim"), "test_fail_not_numeric") +def test_current_datlim_rejects_non_numeric_input(): + with pytest.raises(TypeError, match="numeric"): + datlim(["one", "two"]) + + +@eeglab_test(_source("datlim"), "test_pass_all_positive") +@eeglab_test(_source("datlim"), "test_pass_mixed") +def test_current_datlim_flattens_all_dimensions_and_retains_infinities(): + np.testing.assert_array_equal(datlim([[2, 1, 3], [8, np.inf, 0]]), [0, np.inf]) + np.testing.assert_array_equal(datlim([[-1e100, -np.inf, 12], [23, -100, 90]]), [-np.inf, 90]) + + +@eeglab_test(_source("eucl"), "test_test_eucl") +def test_current_eucl_supports_within_between_and_reference_distances(): + first = np.asarray([[0, 0], [3, 4], [0, 8]], dtype=float) + second = np.asarray([[0, 4], [6, 8]], dtype=float) + np.testing.assert_allclose(eucl(first), [[0, 5, 8], [5, 0, 5], [8, 5, 0]]) + np.testing.assert_allclose(eucl(first, second), [[4, 10], [3, 5], [4, 6]]) + np.testing.assert_allclose(eucl(first, [[0, 0]]), [0, 5, 8]) + np.testing.assert_allclose(eucl([[0, 0]], second), [4, 10]) + assert eucl([[0, 0], [3, 4]]) == pytest.approx(5) + np.testing.assert_allclose(eucl(first, second), np.asarray(eucl(second, first)).T) + + +@eeglab_test(_source("gabor2d"), "test_pass_cut") +@eeglab_test(_source("gabor2d"), "test_pass_general") +@eeglab_test(_source("gabor2d"), "test_pass_rotated") +@eeglab_test(_source("gabor2d"), "test_pass_ten_args") +def test_current_gabor2d_controls_orientation_frequency_phase_and_magnitude_cut(): + default = gabor2d(5, 4) + assert default.shape == (5, 4) + assert np.max(np.abs(default)) < 1 + rotated = gabor2d(5, 4, 72, 270) + assert np.all(rotated[:, :2] <= 1e-15) + assert np.all(rotated[:, 2:] >= -1e-15) + high_frequency = gabor2d(50, 40, 20, 0, 10, 8, 25.5, 20.5, 30) + assert np.count_nonzero(np.diff(np.signbit(high_frequency[:, 20]))) >= 4 + cut = gabor2d(50, 40, 20, 0, 10, 8, 25.5, 20.5, 0, 0.5) + uncut = gabor2d(50, 40, 20, 0, 10, 8, 25.5, 20.5, 0, 0) + assert np.count_nonzero(cut) < np.count_nonzero(uncut) + assert np.any(cut < 0) and np.any(cut > 0) + + +@eeglab_test(_source("gauss"), "test_pass_general") +def test_current_gauss_is_symmetric_positive_and_unit_peaked(): + window = gauss(5, 1) + np.testing.assert_allclose(window, window[::-1]) + assert window[2] == pytest.approx(1) + assert np.all((window > 0) & (window <= 1)) + np.testing.assert_array_equal(gauss(1, 3), [1]) + with pytest.raises(ValueError, match="integer"): + gauss(5.5, 1) + + +@eeglab_test(_source("gauss2d"), "test_pass_general") +@eeglab_test(_source("gauss2d"), "test_pass_seven_args") +def test_current_gauss2d_has_requested_peak_symmetry_and_cut(): + kernel = gauss2d(5, 5) + np.testing.assert_allclose(kernel, kernel[::-1]) + np.testing.assert_allclose(kernel, kernel[:, ::-1]) + assert np.argmax(kernel) == np.ravel_multi_index((2, 2), kernel.shape) + cut = gauss2d(5, 4, 1, 1, 3, 2.5, 0.5) + uncut = gauss2d(5, 4, 1, 1, 3, 2.5, 0) + assert np.count_nonzero(cut) < np.count_nonzero(uncut) + with pytest.raises(ValueError, match="integer"): + gauss2d(5.5, 4) + + +@eeglab_test(_source("gauss3d"), "test_test_gauss3d") +def test_current_gauss3d_honors_anisotropic_shape_peak_and_cut(): + assert gauss3d(2, 5, 7).shape == (2, 5, 7) + kernel = gauss3d(3, 3, 3, 0.5, 0.1, 0.04, 1, 2, 3) + assert np.argmax(kernel) == np.ravel_multi_index((0, 1, 2), kernel.shape) + cut = gauss3d(3, 3, 3, 0.6, 0.6, 0.6, 2, 2, 2, 0.5) + assert 0 < np.count_nonzero(cut) < cut.size + with pytest.raises(ValueError, match="integer"): + gauss3d(3, 3, 3.5) + + +@eeglab_test(_source("hungarian"), "test_pass_equal") +@eeglab_test(_source("hungarian"), "test_pass_general") +@eeglab_test(_source("hungarian"), "test_pass_hard_one") +@eeglab_test(_source("hungarian"), "test_pass_hard_three") +@eeglab_test(_source("hungarian"), "test_pass_hard_two") +@eeglab_test(_source("hungarian"), "test_pass_ideal") +@eeglab_test(_source("hungarian"), "test_pass_negative") +@eeglab_test(_source("hungarian"), "test_pass_zeros") +def test_current_hungarian_finds_known_global_minima_with_unique_assignments(): + cases = [ + ([[1, 1, 1, 1], [2, 2, 2, 2], [3, 3, 3, 3], [4, 4, 4, 4]], 10), + ([[1, 1, 1, 2], [3, 2, 4, 1], [4, 4, 2, 4], [2, 3, 3, 3]], 6), + ([[4, 2, 4, 1], [2, 4, 1, 3], [1, 1, 3, 4], [3, 3, 2, 2]], 6), + ([[3, 1, 1, 4], [1, 3, 2, 1], [2, 2, 3, 2], [4, 4, 4, 3]], 7), + ([[3, 2, 3, 2], [2, 1, 2, 1], [1, 4, 1, 3], [4, 3, 4, 4]], 8), + ([[1, 3, 2, 2], [3, 2, 4, 1], [4, 4, 1, 4], [2, 1, 3, 3]], 4), + ([[-1, 1, 1, 2], [3, 2, 4, -1], [4, 4, -2, 4], [-2, 3, 3, 3]], -4), + (np.zeros((4, 4)), 0), + ] + for matrix, expected_cost in cases: + costs = np.asarray(matrix) + assignment, total = hungarian(costs) + np.testing.assert_array_equal(np.sort(assignment), np.arange(4)) + assert total == pytest.approx(expected_cost) + brute_force = min( + sum(costs[row, column] for column, row in enumerate(order)) for order in itertools.permutations(range(4)) + ) + assert total == pytest.approx(brute_force) + assignment, total = hungarian([[1, np.inf], [np.inf, 2]]) + np.testing.assert_array_equal(assignment, [0, 1]) + assert total == 3 + with pytest.raises(ValueError, match="no finite complete assignment"): + hungarian([[1, np.inf], [2, np.inf]]) + + +@eeglab_test(_source("laplac2d"), "test_pass_cut") +@eeglab_test(_source("laplac2d"), "test_pass_general") +@eeglab_test(_source("laplac2d"), "test_pass_mean") +@eeglab_test(_source("laplac2d"), "test_pass_sigma") +def test_current_laplac2d_honors_peak_location_scale_and_cut(): + kernel = laplac2d(5, 5) + assert kernel[2, 2] == pytest.approx(1) + assert kernel.min() == pytest.approx(-0.4060058497) + shifted = laplac2d(25, 25, 5, 25, 4) + assert np.argmax(shifted) == np.ravel_multi_index((24, 3), shifted.shape) + assert laplac2d(25, 25, 1).max() == pytest.approx(1) + upstream_cut = laplac2d(25, 25, 5, 12.5, 12.5, 1e-6) + assert 0.038 < upstream_cut.max() < 0.039 + material_cut = laplac2d(25, 25, 5, 12.5, 12.5, 0.1) + assert 0 < np.count_nonzero(material_cut) < material_cut.size + + +@eeglab_test(_source("mapcorr"), "test_test_mapcorr") +def test_current_mapcorr_aligns_values_by_channel_label_before_matching(): + first_channels = [{"labels": name} for name in ["Fz", "Cz", "Pz"]] + second_channels = [{"labels": name} for name in ["Pz", "Fz", "Cz", "Oz"]] + first = np.asarray([[1, 2, 3], [4, -2, 0]], dtype=float) + second = np.asarray([[3, 1, 2, 99], [0, 4, -2, -12]], dtype=float) + correlations, first_indices, second_indices, all_correlations = mapcorr( + first, second, first_channels, second_channels, method=0 + ) + np.testing.assert_allclose(correlations, [1, 1]) + np.testing.assert_array_equal(first[first_indices], first[[0, 1]]) + np.testing.assert_array_equal(second_indices, [0, 1]) + np.testing.assert_allclose(np.diag(all_correlations), 1) + zero_weight = mapcorr(first, second, first_channels, second_channels, method=0, weighting=0) + np.testing.assert_allclose(zero_weight[3], all_correlations) + with pytest.raises(ValueError, match="unique"): + mapcorr(first, second, first_channels, [{"labels": "Pz"}] * 4) + + +@eeglab_test(_source("matcorr"), "test_pass_general") +@eeglab_test(_source("matcorr"), "test_pass_mean") +@eeglab_test(_source("matcorr"), "test_pass_method_hungarian") +@eeglab_test(_source("matcorr"), "test_pass_method_vam") +@eeglab_test(_source("matcorr"), "test_pass_not_square") +@eeglab_test(_source("matcorr"), "test_pass_not_square_hungarian") +@eeglab_test(_source("matcorr"), "test_pass_num_rows") +@eeglab_test(_source("matcorr"), "test_pass_weights") +def test_current_matcorr_matches_permuted_rows_across_methods_and_rectangular_inputs(): + first = np.asarray([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=float) + second = first[[1, 0, 2]] + for method in (0, 1, 2): + correlations, first_indices, second_indices, matrix = matcorr(first, second, method=method) + np.testing.assert_allclose(first[first_indices], second[second_indices]) + np.testing.assert_allclose(correlations, 1) + np.testing.assert_allclose(matrix[first_indices, second_indices], 1) + centered = matcorr(first, second, remove_mean=True) + np.testing.assert_allclose(centered[0], 1) + rectangular = np.vstack([first, [1, 5, 9]]) + result = matcorr(rectangular, rectangular[[2, 3, 0, 1]], method=0) + assert result[0].size == 4 + unequal = matcorr(rectangular, second) + assert unequal[0].size == 3 + weights = np.asarray([[1, 0.4, 0.6], [0.5, 0.8, 1], [0.2, 1, 0.9]]) + weighted = matcorr(first, second, weighting=weights) + np.testing.assert_allclose(weighted[3], matcorr(first, second)[3] * weights) + + +@eeglab_test(_source("matperm"), "test_pass_general") +def test_current_matperm_reorders_rows_and_corrects_component_polarity(): + first = np.asarray([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) + second = np.asarray([[4, 5, 6], [-1, -2, -3], [7, 8, 9]]) + output, permutation = matperm(first, second, [1, 0, 2], [0, 1, 2], [1, -1, 1]) + np.testing.assert_array_equal(output, second) + np.testing.assert_array_equal(permutation, [1, 0, 2]) + with pytest.raises(ValueError, match="integers"): + matperm(first, second, [0.9], [0], [1]) + + +@eeglab_test(_source("means"), "test_test_means") +def test_current_means_computes_groupwise_statistics_along_observations(): + values = np.asarray([[1, 2], [3, np.nan], [5, 6], [7, 10]], dtype=float) + group_means, standard_errors, variances, group_ids = means(values, [2, 1, 2, 1]) + np.testing.assert_array_equal(group_ids, [1, 2]) + np.testing.assert_allclose(group_means, [[5, 10], [3, 4]]) + np.testing.assert_allclose(variances, [[8, np.nan], [8, 8]], equal_nan=True) + np.testing.assert_allclose(standard_errors, [[2, np.nan], [2, 2]], equal_nan=True) + _, missing_stderr, missing_variance, _ = means([[1], [3], [np.nan]]) + np.testing.assert_allclose(missing_variance, [[2]]) + np.testing.assert_allclose(missing_stderr, [[1]]) + complex_means, _, _, _ = means([[1 + 2j], [3 + 4j]]) + np.testing.assert_allclose(complex_means, [[2 + 3j]]) + assert means(np.ones((32, 100)))[0].shape == (1, 100) + assert means(np.ones((32, 1)))[0].shape == (1, 1) + assert means(np.ones((1, 100)))[0].shape == (1, 100) + + +@eeglab_test(_source("nan_std"), "test_pass_general") +@eeglab_test(_source("nan_std"), "test_pass_nan") +@eeglab_test(_source("nan_std"), "test_pass_number") +@eeglab_test(_source("nan_std"), "test_pass_row") +def test_current_nan_std_uses_sample_scaling_and_first_nonsingleton_axis(): + np.testing.assert_allclose(nan_std([[1, -5], [2, 0], [4, 6]]), [np.sqrt(7 / 3), np.sqrt(91 / 3)]) + values = [[np.nan, np.nan, 3, 0, -4], [np.nan, 1, 3, 4, 4], [np.nan, 11, 0, -7, np.nan]] + np.testing.assert_allclose( + nan_std(values), [np.nan, np.sqrt(50), np.sqrt(3), np.sqrt(31), np.sqrt(32)], equal_nan=True + ) + offset = 1e10 + np.testing.assert_allclose(nan_std(offset + np.arange(4)), np.sqrt(5 / 3)) + assert np.isnan(nan_std(1)) + assert nan_std([1, 2]) == pytest.approx(np.sqrt(0.5)) + + +@eeglab_test(_source("pcexpand"), "test_pass_general") +@eeglab_test(_source("pcexpand"), "test_pass_means_row_vector") +def test_current_pcexpand_accepts_row_or_column_mean_vectors(): + projections = np.asarray([[1, -2, 3], [-2, 0, 1]]) + vectors = np.asarray([[0.6, -0.8], [0.8, 0.6]]) + expected = np.asarray([[4.2, 0.8, 3], [0.6, -0.6, 4]]) + np.testing.assert_allclose(pcexpand(projections, vectors, [2, 1]), expected) + np.testing.assert_allclose(pcexpand(projections, vectors, [[2], [1]]), expected) + + +@eeglab_test(_source("pcsquash"), "test_pass_column_vector") +@eeglab_test(_source("pcsquash"), "test_pass_general") +@eeglab_test(_source("pcsquash"), "test_pass_row_vector") +def test_current_pcsquash_orders_components_and_roundtrips_through_pcexpand(): + for data in (np.asarray([[1, 3]]).T, np.asarray([[1, 2, 3, 4, 5], [-2, 0, 2, -1, 6]]), np.asarray([1, 2, 3])): + vectors, eigenvalues, compressed, data_mean = pcsquash(data) + expected = np.asarray(data, dtype=float) + if expected.ndim == 1: + expected = expected.reshape(1, -1) + np.testing.assert_allclose(pcexpand(compressed, vectors, data_mean), expected, atol=1e-12) + assert np.all(np.diff(eigenvalues) <= 1e-15) + np.testing.assert_allclose(vectors.T @ vectors, np.eye(vectors.shape[1]), atol=1e-12) + general = np.asarray([[1, 2, 3, 4, 5], [-2, 0, 2, -1, 6]]) + vectors, eigenvalues, compressed, data_mean = pcsquash(general) + v1 = 0.5 * np.sqrt(2 - np.sqrt(2)) + v2 = 0.5 * np.sqrt(2 + np.sqrt(2)) + np.testing.assert_allclose(eigenvalues, [5 + 3 * np.sqrt(2), 5 - 3 * np.sqrt(2)]) + np.testing.assert_allclose(np.abs(vectors), np.abs([[v1, -v2], [v2, v1]])) + np.testing.assert_allclose(data_mean, [3, 1]) + np.testing.assert_allclose(vectors @ compressed + data_mean[:, None], general) + np.testing.assert_allclose(pcsquash([1, 2, 3])[1], [2 / 3]) + complex_data = np.asarray([[1 + 2j, 3 + 1j, 2 - 1j], [2 - 1j, 0 + 2j, 4 + 3j]]) + vectors, _, compressed, data_mean = pcsquash(complex_data) + np.testing.assert_allclose(pcexpand(compressed, vectors, data_mean), complex_data, atol=1e-12) + with pytest.raises(ValueError, match="integer"): + pcsquash([[1, 2, 3], [3, 2, 1]], 1.9) + + +@eeglab_test(_source("perminv"), "test_pass_general") +def test_current_perminv_returns_zero_based_inverse_permutation(): + permutation = np.asarray([1, 3, 0, 4, 2]) + inverse = perminv(permutation) + np.testing.assert_array_equal(inverse, [2, 0, 4, 1, 3]) + np.testing.assert_array_equal(permutation[inverse], np.arange(permutation.size)) + with pytest.raises(ValueError, match="integers"): + perminv([0.9, 1.1]) + + +@eeglab_test(_source("scanfold"), "test_test_scanfold") +def test_current_scanfold_respects_ignored_directories_and_depth(tmp_path): + (tmp_path / "root.m").write_text("function root", encoding="utf-8") + (tmp_path / "readme.txt").write_text("not MATLAB", encoding="utf-8") + nested = tmp_path / "nested" + nested.mkdir() + (nested / "nested.m").write_text("function nested", encoding="utf-8") + ignored = tmp_path / "plugins" + ignored.mkdir() + (ignored / "plugin.m").write_text("function plugin", encoding="utf-8") + + files, command = scanfold(tmp_path) + assert files == ["nested.m", "plugin.m", "root.m"] + assert command == " -a nested.m -a plugin.m -a root.m" + assert scanfold(tmp_path, {"plugins"})[0] == ["nested.m", "root.m"] + assert scanfold(tmp_path, max_depth=1)[0] == ["root.m"] + assert scanfold(tmp_path, ignore="plugins")[0] == ["nested.m", "root.m"] + with pytest.raises(ValueError, match="integer"): + scanfold(tmp_path, max_depth=1.5) + + +@eeglab_test(_source("uniquef"), "test_test_uniquef") +def test_current_uniquef_returns_stable_counts_and_zero_based_first_indices(): + groups = [3, 2, 1, 2, 1, 2, 2, 2, 1, np.nan, np.inf] + values, counts, indices = uniquef(groups) + np.testing.assert_array_equal(values, [3, 2, 1]) + np.testing.assert_array_equal(counts, [1, 5, 3]) + np.testing.assert_array_equal(indices, [0, 1, 2]) + values, counts, indices = uniquef(groups, sort=True) + np.testing.assert_array_equal(values, [1, 2, 3]) + np.testing.assert_array_equal(counts, [3, 5, 1]) + np.testing.assert_array_equal(indices, [2, 1, 0]) + + +@eeglab_test(_source("vectdata"), "test_test_vectdata") +def test_current_vectdata_interpolates_and_smooths_with_explicit_v4_exclusion(): + times = np.arange(-20, 20.5, 0.5) + values = np.sin(times) + dense_times = np.linspace(-20, 20, 4001) + linear, returned_times = vectdata(values, times, timesout=dense_times, method="linear") + nearest, _ = vectdata(values, times, timesout=dense_times, method="nearest") + cubic, _ = vectdata(values, times, timesout=dense_times, method="cubic") + np.testing.assert_array_equal(returned_times, dense_times) + np.testing.assert_allclose(linear[::50], values) + np.testing.assert_allclose(nearest[::50], values) + assert np.max(np.abs(cubic - np.sin(dense_times))) < 0.003 + + impulse = np.zeros(9) + impulse[4] = 1 + smoothed, _ = vectdata(impulse, np.arange(9), timesout=np.arange(9), average=3, avgtype="gauss") + assert 0 < smoothed[4] < 1 + constant, _ = vectdata(np.ones(9), np.arange(9), timesout=np.arange(9), average=5, border="on") + np.testing.assert_allclose(constant, 1) + complex_values = np.exp(1j * np.arange(5)) + complex_result, _ = vectdata(complex_values, np.arange(5), timesout=np.arange(5)) + np.testing.assert_allclose(complex_result, complex_values) + with pytest.raises(NotImplementedError, match="v4"): + vectdata(values, times, timesout=dense_times, method="v4") + + +def test_real_only_numerical_helpers_reject_complex_inputs_instead_of_discarding_them(): + complex_values = [1 + 2j, 3 + 4j] + for call in ( + lambda: covary(complex_values), + lambda: datlim(complex_values), + lambda: eucl(complex_values), + lambda: hungarian([[1 + 1j]]), + lambda: mapcorr( + [complex_values], + [complex_values], + [{"labels": "A"}, {"labels": "B"}], + [{"labels": "A"}, {"labels": "B"}], + ), + lambda: matcorr([complex_values], [complex_values]), + lambda: matperm([complex_values], [complex_values], [0], [0], [1 + 1j]), + lambda: nan_std(complex_values), + lambda: uniquef(complex_values), + ): + with pytest.raises(ValueError, match="real"): + call() diff --git a/tests/test_sigprocfunc_numerical_eeglab_tests.py b/tests/test_sigprocfunc_numerical_eeglab_tests.py new file mode 100644 index 00000000..79bf0178 --- /dev/null +++ b/tests/test_sigprocfunc_numerical_eeglab_tests.py @@ -0,0 +1,144 @@ +"""Behavioral ports of current EEGLAB low-level numerical tests.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from eegprep import celltomat, eyelike, fastif, matsel, mattocell, nan_mean, quantile, shuffle +from tests.eeglab_tests import eeglab_test + + +def _source(name: str) -> str: + return f"unittesting_sigprocfunc/{name}/sigprocfunc_{name}_wrapperTest.m" + + +@eeglab_test(_source("celltomat"), "test_pass_1d_num") +@eeglab_test(_source("celltomat"), "test_pass_empty") +def test_current_celltomat_converts_rectangular_numeric_cells_and_empty_input(): + np.testing.assert_array_equal(celltomat([1, 2, 3]), [1, 2, 3]) + np.testing.assert_array_equal(celltomat([[1, 2, 3], [4, 5, 6]]), [[1, 2, 3], [4, 5, 6]]) + object_cells = np.asarray([[1, 2], [3, 4]], dtype=object) + np.testing.assert_array_equal(celltomat(object_cells), [[1, 2], [3, 4]]) + matlab_cells = np.empty((2, 2), dtype=object) + for index, value in enumerate([1, 2, 3, 4]): + matlab_cells.flat[index] = np.asarray([[value]]) + np.testing.assert_array_equal(celltomat(matlab_cells), [[1, 2], [3, 4]]) + assert celltomat([]).size == 0 + with pytest.raises((TypeError, ValueError)): + celltomat([[1, 2], [3]]) + invalid_cells = np.empty(1, dtype=object) + invalid_cells[0] = np.asarray([1, 2]) + with pytest.raises(TypeError): + celltomat(invalid_cells) + + +@eeglab_test(_source("eyelike"), "test_pass_diag_zero_1") +@eeglab_test(_source("eyelike"), "test_pass_diag_zero_2") +@eeglab_test(_source("eyelike"), "test_pass_eye") +@eeglab_test(_source("eyelike"), "test_pass_eye_scale") +@eeglab_test(_source("eyelike"), "test_pass_general") +def test_current_eyelike_produces_unit_diagonal_and_invertible_transform(): + matrices = [ + np.eye(3), + np.diag([3, 2, 9]), + np.asarray([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=float), + np.asarray([[0, 1, 1], [1, 0, 1], [1, 1, 0]], dtype=float), + np.asarray([[0, -4, 1], [2, 0, 1], [1, 3, 0]], dtype=float), + np.asarray([[1 + 2j, 3], [4, 2 - 1j]]), + ] + for matrix in matrices: + normalized, scale, permutation = eyelike(matrix) + np.testing.assert_allclose(np.diag(normalized), 1) + np.testing.assert_allclose(np.linalg.inv(permutation) @ np.linalg.inv(scale) @ normalized, matrix) + + +@eeglab_test(_source("fastif"), "test_pass_complex") +@eeglab_test(_source("fastif"), "test_pass_false") +@eeglab_test(_source("fastif"), "test_pass_not_bool") +@eeglab_test(_source("fastif"), "test_pass_true") +def test_current_fastif_uses_python_truthiness_without_evaluating_strings(): + assert fastif(True, "yes", "no") == "yes" + assert fastif(False, "yes", "no") == "no" + assert fastif("not boolean", "yes", "no") == "yes" + matrix = np.arange(1, 10).reshape(3, 3) + np.testing.assert_array_equal(fastif(3 > 1, np.diag(matrix), matrix[:, 2]), [1, 5, 9]) + + +@eeglab_test(_source("matsel"), "test_pass_general") +@eeglab_test(_source("matsel"), "test_pass_valid_frames") +def test_current_matsel_selects_zero_based_frames_from_each_flattened_epoch(): + data = np.asarray([[1, 2, 3], [4, 5, 6]]) + np.testing.assert_array_equal(matsel(data, None, None), data) + epoched = np.asarray([[1, 2, 3, 4], [5, 6, 7, 8]]) + np.testing.assert_array_equal(matsel(epoched, 2, [0]), [[1, 3], [5, 7]]) + np.testing.assert_array_equal(matsel(epoched, 2, [1], [1], [1, 0]), [[8, 6]]) + assert matsel(epoched, 2, [], [0], [0]).shape == (1, 0) + with pytest.raises(IndexError): + matsel(epoched, 2, [2]) + with pytest.raises(ValueError, match="integer"): + matsel(epoched, 2.9, [0]) + with pytest.raises(ValueError, match="integers"): + matsel(epoched, 2, [0.9]) + + +@eeglab_test(_source("mattocell"), "test_pass_empty_data") +@eeglab_test(_source("mattocell"), "test_pass_general") +def test_current_mattocell_returns_nested_python_scalars_and_empty_list(): + assert mattocell([[1, 2, 3], [4, 5, 6]]) == [[1, 2, 3], [4, 5, 6]] + assert mattocell([]) == [] + + +@eeglab_test(_source("nan_mean"), "test_pass_general") +@eeglab_test(_source("nan_mean"), "test_pass_nan") +@eeglab_test(_source("nan_mean"), "test_pass_number") +@eeglab_test(_source("nan_mean"), "test_pass_row_vector") +def test_current_nan_mean_uses_first_nonsingleton_axis_and_preserves_all_nan_columns(): + values = np.asarray([[1, 2, 3, 0, -5], [1, 7, 3, 4, 5], [-2, 12, 0, -6, 3]], dtype=float) + np.testing.assert_allclose(nan_mean(values), [0, 7, 2, -2 / 3, 1]) + values[[0, 1, 2], 0] = np.nan + values[0, 1] = np.nan + values[2, 4] = np.nan + np.testing.assert_allclose(nan_mean(values), [np.nan, 9.5, 2, -2 / 3, 0], equal_nan=True) + assert nan_mean(1) == 1 + assert nan_mean([1, 2, 3, 0, -5]) == pytest.approx(0.2) + np.testing.assert_allclose(nan_mean([1 + 2j, 3 + 4j]), 2 + 3j) + + +@eeglab_test(_source("quantile"), "test_pass_column") +@eeglab_test(_source("quantile"), "test_pass_general") +@eeglab_test(_source("quantile"), "test_pass_matrix") +@eeglab_test(_source("quantile"), "test_pass_number") +@eeglab_test(_source("quantile"), "test_pass_unsorted") +def test_current_quantile_matches_midpoint_empirical_interpolation(): + probabilities = [0, 0.125, 0.25, 0.5, 0.75, 0.875, 1] + expected = [1, 1, 1.5, 3.5, 7.5, 10, 10] + np.testing.assert_allclose(quantile([1, 2, 5, 10], probabilities), expected) + np.testing.assert_allclose(quantile([10, 2, 5, 1], probabilities), expected) + np.testing.assert_allclose(quantile([[1], [2], [5], [10]], probabilities).reshape(-1), expected) + matrix = np.asarray([[-1, 0, -4, -2], [1, 2, 5, 8], [3, 7, 6, 10]]) + matrix_expected = np.asarray( + [[-1, 0, -4, -2], [-0.5, 0.5, -1.75, 0.5], [1, 2, 5, 8], [2.5, 5.75, 5.75, 9.5], [3, 7, 6, 10]] + ) + np.testing.assert_allclose(quantile(matrix, [0, 0.25, 0.5, 0.75, 1]), matrix_expected) + np.testing.assert_array_equal(quantile(17, [0.25, 0.5, 0.75]), [17, 17, 17]) + np.testing.assert_allclose(quantile([1, np.nan, 5], [0, 0.5, 1]), [1, 3, 5]) + with pytest.raises(ValueError, match="integer"): + quantile(matrix, [0.5], axis=0.5) + with pytest.raises(ValueError, match="real"): + quantile([1 + 2j, 3 + 4j], [0.5]) + with pytest.raises(ValueError, match="real"): + quantile([1, 3], [0.5 + 0.1j]) + + +@eeglab_test(_source("shuffle"), "test_test_shuffle") +def test_current_shuffle_returns_zero_based_permutation_and_exact_inverse_for_any_axis(): + generator = np.random.default_rng(17) + for shape, axis in [((32, 100), 0), ((32, 100), 1), ((4, 8, 10), 1), ((4, 8, 10), 2)]: + data = np.arange(np.prod(shape)).reshape(shape) + shuffled, permutation, inverse = shuffle(data, axis, rng=generator) + np.testing.assert_array_equal(shuffled, np.take(data, permutation, axis=axis)) + np.testing.assert_array_equal(np.take(shuffled, inverse, axis=axis), data) + np.testing.assert_array_equal(permutation[inverse], np.arange(shape[axis])) + with pytest.raises(ValueError, match="integer"): + shuffle(np.ones((2, 2)), axis=0.5) From c32ddcaf2110be353f32da62a741663456af1b87 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:50 -0700 Subject: [PATCH 20/45] feat: add safe eeglab_execmenu dispatch (#348) --- docs/parity/eeglab_core_parity_matrix.json | 13 +- docs/source/api/core_and_session.rst | 1 + .../source/user_guide/gui_console_session.rst | 32 +++ src/eegprep/__init__.py | 1 + .../functions/adminfunc/eeglab_execmenu.py | 233 ++++++++++++++++++ src/eegprep/functions/popfunc/pop_chanedit.py | 38 +++ src/eegprep/resources/help/pop_chanedit.md | 6 + tests/test_eeglab_execmenu.py | 192 +++++++++++++++ 8 files changed, 511 insertions(+), 5 deletions(-) create mode 100644 src/eegprep/functions/adminfunc/eeglab_execmenu.py create mode 100644 tests/test_eeglab_execmenu.py diff --git a/docs/parity/eeglab_core_parity_matrix.json b/docs/parity/eeglab_core_parity_matrix.json index 76ac63d5..665dc65f 100644 --- a/docs/parity/eeglab_core_parity_matrix.json +++ b/docs/parity/eeglab_core_parity_matrix.json @@ -415,13 +415,16 @@ { "eeglab_path": "functions/adminfunc/eeglab_execmenu.m", "eeglab_name": "eeglab_execmenu", - "eegprep_equivalent": null, + "eegprep_equivalent": "src/eegprep/functions/adminfunc/eeglab_execmenu.py", "gap_category": "1_long_tail_helper_coverage", - "status": "matlab_runtime_skip", - "rationale": "MATLAB-specific path, help-generation, GUI shim, compatibility, or deployment behavior that should not be recreated in standalone EEGPrep runtime code.", + "status": "implemented", + "rationale": "EEGPrep exposes a safe session-bound dispatcher for registered menu labels and explicit noninteractive parameters without reproducing EEGLAB's global-workspace callback evaluation.", "responsible_phase": "none", - "user_facing_surface": [], - "test_notes": "No runtime parity tests required; validation should continue proving package code does not depend on the vendored EEGLAB tree." + "user_facing_surface": [ + "api", + "console" + ], + "test_notes": "Current eeglab_tests workflow coverage verifies menu/function resolution, state synchronization, file and metadata operations, numerical resampling, and deterministic structured replay." }, { "eeglab_path": "functions/adminfunc/eeglab_new.m", diff --git a/docs/source/api/core_and_session.rst b/docs/source/api/core_and_session.rst index e30f157b..68220438 100644 --- a/docs/source/api/core_and_session.rst +++ b/docs/source/api/core_and_session.rst @@ -33,4 +33,5 @@ Dataset containers, validation, and the shared session that the GUI and ``eegpre eegprep.eeg_store eegprep.eegh eegprep.eeglab + eegprep.eeglab_execmenu eegprep.options diff --git a/docs/source/user_guide/gui_console_session.rst b/docs/source/user_guide/gui_console_session.rst index 16f33382..dc85070a 100644 --- a/docs/source/user_guide/gui_console_session.rst +++ b/docs/source/user_guide/gui_console_session.rst @@ -123,6 +123,38 @@ Normal Python scripts keep normal Python semantics: Use explicit assignment in scripts, notebooks, tests, and batch jobs. +Run Registered Menus Without Dialogs +==================================== + +Automation and parity checks can run a real registered menu workflow against +the same session without opening Qt dialogs. Pass the exact menu label, its +registered ``pop_*`` function, and explicit parameters: + +.. code-block:: python + + from eegprep import EEGPrepSession, eeglab_execmenu + + session = EEGPrepSession() + eeglab_execmenu( + "From ASCII/float file or MATLAB array", + "pop_importdata", + {"data": data, "dataformat": "array", "srate": 100}, + session=session, + ) + eeglab_execmenu( + "Change sampling rate", + "pop_resample", + [64], + session=session, + ) + +``eeglab_execmenu`` verifies that the label and function identify the same +declarative menu item, calls only an explicit set of noninteractive workflows, +and updates ``EEG``, ``ALLEEG``, ``CURRENTSET``, ``LASTCOM``, and ``ALLCOM`` as +one session operation. It never evaluates callback or history strings. For a +deterministic replay, retain the label/function/parameter triples and dispatch +them in the same order into a fresh ``EEGPrepSession``. + History You Can Reuse ===================== diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 7ef8dc54..f0c8b579 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -155,6 +155,7 @@ "eeg_urlatency": ("eegprep.functions.popfunc.eeg_urlatency", "eeg_urlatency"), "eegh": ("eegprep.functions.adminfunc.eegh", "eegh"), "eeglab": ("eegprep.functions.adminfunc.eeglab", "eeglab"), + "eeglab_execmenu": ("eegprep.functions.adminfunc.eeglab_execmenu", "eeglab_execmenu"), "errordlg2": ("eegprep.functions.guifunc.errordlg2", "errordlg2"), "eeglab_clean_artifacts": ("eegprep.functions.adminfunc.eeglabcompat", "clean_artifacts"), "eegrej": ("eegprep.functions.sigprocfunc.eegrej", "eegrej"), diff --git a/src/eegprep/functions/adminfunc/eeglab_execmenu.py b/src/eegprep/functions/adminfunc/eeglab_execmenu.py new file mode 100644 index 00000000..23f8df61 --- /dev/null +++ b/src/eegprep/functions/adminfunc/eeglab_execmenu.py @@ -0,0 +1,233 @@ +"""Execute a registered EEGPrep menu workflow without opening its dialog.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from pathlib import PurePath +from typing import Any + +from eegprep.extension_runtime import ExtensionRuntime +from eegprep.functions.guifunc.eeglab_menu import eeglab_menus +from eegprep.functions.guifunc.menu_spec import MenuItemSpec +from eegprep.functions.guifunc.session import EEGPrepSession, has_eeg_data +from eegprep.functions.popfunc._pop_utils import format_history_value +from eegprep.functions.popfunc.pop_chanedit import pop_chanedit +from eegprep.functions.popfunc.pop_comments import pop_comments +from eegprep.functions.popfunc.pop_editeventvals import pop_editeventvals +from eegprep.functions.popfunc.pop_editset import pop_editset +from eegprep.functions.popfunc.pop_importdata import pop_importdata +from eegprep.functions.popfunc.pop_loadset import pop_loadset +from eegprep.functions.popfunc.pop_resample import pop_resample +from eegprep.functions.popfunc.pop_saveset import pop_saveset + + +_Parameters = Sequence[Any] | Mapping[str, Any] | None +_CURRENT_DATASET_FUNCTIONS: dict[str, Callable[..., Any]] = { + "pop_chanedit": pop_chanedit, + "pop_editeventvals": pop_editeventvals, + "pop_editset": pop_editset, + "pop_resample": pop_resample, +} +_SUPPORTED_FUNCTIONS = {*_CURRENT_DATASET_FUNCTIONS, "pop_comments", "pop_importdata", "pop_loadset", "pop_saveset"} +_CONTROLLED_KEYWORDS = {"gui", "renderer", "return_com"} + + +def eeglab_execmenu( + label: str, + function: str, + parameters: _Parameters, + *, + session: EEGPrepSession, + extension_runtime: ExtensionRuntime | None = None, +) -> str: + """Run a menu-bound ``pop_*`` workflow with explicit parameters. + + The menu label and function name must identify the same registered leaf in + EEGPrep's declarative menu tree. Only explicitly supported, noninteractive + workflows are callable: this function never evaluates callback text or + arbitrary history strings. + + Args: + label: Exact visible menu label, such as ``"Change sampling rate"``. + function: Public function name present in that menu action. + parameters: Positional arguments, EEGLAB-style key/value pairs, or a + mapping of keyword arguments to append to the menu workflow. + session: Shared session to read and update. + extension_runtime: Optional extension registry used while resolving the + current menu tree. + + Returns: + The replayable command appended to ``session.ALLCOM``. + + Raises: + ValueError: If the label and function do not identify one menu action. + NotImplementedError: If the resolved action has no safe noninteractive + dispatcher. + """ + action = _resolve_menu_action(label, function, extension_runtime=extension_runtime) + if function not in _SUPPORTED_FUNCTIONS: + raise NotImplementedError( + f"Menu action {action!r} does not support parameterized execution through eeglab_execmenu" + ) + args, kwargs = _normalize_parameters(parameters) + if function == "pop_importdata": + _validate_import_target(session) + eeg_out, command = pop_importdata(*args, **kwargs, return_com=True) + _store_imported_dataset(session, eeg_out, command) + return command + if function == "pop_loadset": + _validate_import_target(session) + eeg_out = pop_loadset(*args, **kwargs) + command = _history_command(function, args, kwargs) + _store_imported_dataset(session, eeg_out, command) + return command + + eeg = _require_current_dataset(session) + if function == "pop_saveset": + args, kwargs = _apply_action_defaults(action, args, kwargs) + eeg_out = pop_saveset(eeg, *args, **kwargs) + command = _history_command(function, args, kwargs, include_eeg=True) + _store_current_dataset(session, eeg_out, command, mark_saved=True) + return command + if function == "pop_comments": + eeg_out, command = pop_comments( + eeg, + "About this dataset", + *args, + **kwargs, + gui=False, + return_com=True, + ) + else: + target = _CURRENT_DATASET_FUNCTIONS[function] + eeg_out, command = target(eeg, *args, **kwargs, gui=False, return_com=True) + _store_current_dataset(session, eeg_out, command) + return command + + +def _resolve_menu_action( + label: str, + function: str, + *, + extension_runtime: ExtensionRuntime | None, +) -> str: + if not isinstance(label, str) or not label: + raise ValueError("label must be a non-empty menu label") + if not isinstance(function, str) or not function: + raise ValueError("function must be a non-empty function name") + items = eeglab_menus( + all_menus=True, + include_plugins=extension_runtime is not None, + extension_runtime=extension_runtime, + ) + labelled = [item for item in _walk_menu_items(items) if item.label == label] + matches = [item for item in labelled if item.action and item.action.partition(":")[0] == function] + if len(matches) == 1: + return str(matches[0].action) + if len(matches) > 1: + raise ValueError(f"Menu label {label!r} and function {function!r} are ambiguous") + if not labelled: + raise ValueError(f"Could not find menu with label {label!r}") + actions = sorted({item.action for item in labelled if item.action}) + if not actions: + raise ValueError(f"Menu label {label!r} is not an executable menu item") + raise ValueError(f"Menu label {label!r} is registered for {', '.join(actions)}, not {function!r}") + + +def _walk_menu_items(items: tuple[MenuItemSpec, ...]) -> list[MenuItemSpec]: + walked: list[MenuItemSpec] = [] + for item in items: + walked.append(item) + walked.extend(_walk_menu_items(item.children)) + return walked + + +def _normalize_parameters(parameters: _Parameters) -> tuple[tuple[Any, ...], dict[str, Any]]: + if parameters is None: + return (), {} + if isinstance(parameters, Mapping): + kwargs = {str(key): value for key, value in parameters.items()} + controlled = sorted(_CONTROLLED_KEYWORDS & set(kwargs)) + if controlled: + raise ValueError(f"eeglab_execmenu controls parameter(s): {', '.join(controlled)}") + return (), kwargs + if isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes, bytearray)): + return tuple(parameters), {} + raise TypeError("parameters must be a sequence, mapping, or None") + + +def _apply_action_defaults( + action: str, + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> tuple[tuple[Any, ...], dict[str, Any]]: + _base, _separator, variant = action.partition(":") + if variant != "resave" or _has_option(args, kwargs, "savemode"): + return args, kwargs + return (*args, "savemode", "resave"), kwargs + + +def _has_option(args: tuple[Any, ...], kwargs: Mapping[str, Any], option: str) -> bool: + if any(str(key).lower() == option for key in kwargs): + return True + return any(isinstance(value, str) and value.lower() == option for value in args[::2]) + + +def _require_current_dataset(session: EEGPrepSession) -> dict[str, Any]: + if len(session.CURRENTSET) != 1: + raise ValueError("eeglab_execmenu requires exactly one current dataset") + eeg = session.current_eeg() + if isinstance(eeg, list): + if len(eeg) != 1: + raise ValueError("eeglab_execmenu requires exactly one current dataset") + eeg = eeg[0] + if not has_eeg_data(eeg): + raise ValueError("No current dataset") + return eeg + + +def _validate_import_target(session: EEGPrepSession) -> None: + if len(session.CURRENTSET) > 1: + raise ValueError("eeglab_execmenu requires at most one current dataset for an import") + + +def _store_imported_dataset(session: EEGPrepSession, eeg: dict[str, Any], command: str) -> None: + session.echo_command(command) + if session.CURRENTSET: + session.store_current(eeg, index=session.CURRENTSET[0], command=command) + return + session.store_current(eeg, new=True, command=command) + + +def _store_current_dataset( + session: EEGPrepSession, + eeg: dict[str, Any], + command: str, + *, + mark_saved: bool = False, +) -> None: + session.echo_command(command) + session.store_current(eeg, index=session.CURRENTSET[0], command=command, mark_saved=mark_saved) + + +def _history_command( + function: str, + args: tuple[Any, ...], + kwargs: Mapping[str, Any], + *, + include_eeg: bool = False, +) -> str: + values = ["EEG"] if include_eeg else [] + values.extend(_history_value(value) for value in args) + for key, value in kwargs.items(): + values.extend((format_history_value(str(key)), _history_value(value))) + return f"EEG = {function}({', '.join(values)});" + + +def _history_value(value: Any) -> str: + if isinstance(value, PurePath): + value = value.as_posix() + return format_history_value(value, cell_for_sequence="any_strings") + + +__all__ = ["eeglab_execmenu"] diff --git a/src/eegprep/functions/popfunc/pop_chanedit.py b/src/eegprep/functions/popfunc/pop_chanedit.py index de7e5cd5..cf77f708 100644 --- a/src/eegprep/functions/popfunc/pop_chanedit.py +++ b/src/eegprep/functions/popfunc/pop_chanedit.py @@ -19,6 +19,7 @@ _CHANNEL_FIELDS = ("labels", "theta", "radius", "X", "Y", "Z", "sph_theta", "sph_phi", "sph_radius", "type", "ref") +_LOCATION_FIELDS = ("theta", "radius", "X", "Y", "Z", "sph_theta", "sph_phi", "sph_radius") def pop_chanedit( @@ -266,6 +267,11 @@ def _apply_chanedit( _convert_locations(chanlocs, value) elif key == "load": chanlocs = _read_chanloc_file(value) + elif key == "lookup": + chanlocs = _lookup_chanlocs(chanlocs, value) + chaninfo["filename"] = str(_channel_location_path(value)) + lookup_name = chaninfo["filename"].lower() + chaninfo["nosedir"] = "+Y" if "standard_10" in lookup_name and lookup_name.endswith(".elc") else "+X" elif key == "save": _write_chanloc_file(value, chanlocs) elif key == "headrad": @@ -367,6 +373,38 @@ def _read_chanloc_file(value: Any) -> list[dict[str, Any]]: return readlocs(Path(value)) +def _lookup_chanlocs(chanlocs: list[dict[str, Any]], value: Any) -> list[dict[str, Any]]: + template = _read_chanloc_file(value) + by_label: dict[str, dict[str, Any]] = {} + for channel in template: + label = str(channel.get("labels") or "") + if label.strip(): + by_label.setdefault(label.casefold(), channel) + looked_up: list[dict[str, Any]] = [] + for index, channel in enumerate(chanlocs): + label = str(channel.get("labels") or "") + output = { + "labels": label, + "datachan": channel.get("datachan", True), + "type": channel.get("type", ""), + "urchan": index, + "ref": "", + } + match = by_label.get(label.casefold()) + if match is not None: + output.update({field: match[field] for field in _LOCATION_FIELDS if field in match}) + if _is_blank(output["type"]) and not _is_blank(match.get("type")): + output["type"] = match["type"] + looked_up.append(output) + return looked_up + + +def _channel_location_path(value: Any) -> Path: + if isinstance(value, (list, tuple)) and value: + value = value[0] + return Path(value) + + def _write_chanloc_file(value: Any, chanlocs: list[dict[str, Any]]) -> None: writelocs(chanlocs, value, "filetype", "chanedit", "header", "on") diff --git a/src/eegprep/resources/help/pop_chanedit.md b/src/eegprep/resources/help/pop_chanedit.md index 654d5387..fdb4931f 100644 --- a/src/eegprep/resources/help/pop_chanedit.md +++ b/src/eegprep/resources/help/pop_chanedit.md @@ -9,6 +9,12 @@ load/save, and coordinate conversion between Cartesian, spherical, and topographic fields. The `shrink` option records EEGLAB's display-only topographic shrink factor on the first channel-location record. +Use `pop_chanedit(EEG, "lookup", filename)` to preserve the dataset's channel +order while filling coordinates from matching, case-insensitive labels in a +channel-location template. EEGPrep records the template filename and nose +direction in `EEG["chaninfo"]`; labels absent from the template remain in the +dataset without invented coordinates. + The GUI presents the first channel in an EEGLAB-style channel editor. Rich channel-table navigation is intentionally limited in this phase; command-line calls cover the implemented edit operations. diff --git a/tests/test_eeglab_execmenu.py b/tests/test_eeglab_execmenu.py new file mode 100644 index 00000000..d39dbb8c --- /dev/null +++ b/tests/test_eeglab_execmenu.py @@ -0,0 +1,192 @@ +"""Ports of the current EEGLAB ``eeglab_execmenu`` wrapper test.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import eegprep +from eegprep.functions.adminfunc.console import EEGPrepConsoleWorkspace +from eegprep.functions.adminfunc.eeglab_execmenu import eeglab_execmenu +from eegprep.functions.guifunc.session import EEGPrepSession +from tests.eeglab_tests import eeglab_test +from tests.fixtures import SAMPLE_DATASET_PATH + + +EEGLAB_EXECMENU_WRAPPER = "unittesting_adminfunc/eeglab_execmenu/adminfunc_eeglab_execmenu_wrapperTest.m" + + +def _workflow_calls(save_file: Path) -> list[tuple[str, str, list[object]]]: + data = np.random.default_rng(37).random((10, 1000)) + montage = Path(eegprep.__file__).resolve().parent / "resources/headplot/Standard-10-5-Cap385.sfp" + return [ + ( + "From ASCII/float file or MATLAB array", + "pop_importdata", + ["dataformat", "array", "nbchan", 0, "data", data, "srate", 100], + ), + ("Save current dataset as", "pop_saveset", [save_file]), + ("Dataset info", "pop_editset", ["subject", "test2"]), + ("Resave current dataset(s)", "pop_saveset", ["savemode", "resave"]), + ("Load existing dataset", "pop_loadset", [SAMPLE_DATASET_PATH]), + ("Event values", "pop_editeventvals", ["changefield", [1, "position", 3]]), + ("About this dataset", "pop_comments", [["EEGLAB Tutorial Dataset", "test"]]), + ("Channel locations", "pop_chanedit", ["lookup", montage]), + ("Change sampling rate", "pop_resample", [64]), + ] + + +def _run_workflow(calls: list[tuple[str, str, list[object]]]) -> EEGPrepSession: + session = EEGPrepSession() + for label, function, parameters in calls: + command = eeglab_execmenu(label, function, parameters, session=session) + assert command == session.LASTCOM + assert session.ALLCOM[-1] == command + assert session.CURRENTSET == [1] + assert session.EEG is session.ALLEEG[0] + return session + + +@eeglab_test(EEGLAB_EXECMENU_WRAPPER, "test_i_pass_general") +def test_current_eeglab_execmenu_workflow_updates_session_and_replays_deterministically(tmp_path: Path): + calls = _workflow_calls(tmp_path / "test.set") + session = EEGPrepSession() + workspace = EEGPrepConsoleWorkspace(session, exports={}) + + try: + import_command = eeglab_execmenu(*calls[0], session=session) + assert np.asarray(session.EEG["data"]).shape == (10, 1000) + assert session.EEG["srate"] == 100 + assert import_command.startswith("EEG = pop_importdata(") + + eeglab_execmenu(*calls[1], session=session) + assert (tmp_path / "test.set").is_file() + assert session.EEG["filename"] == "test.set" + + eeglab_execmenu(*calls[2], session=session) + assert session.EEG["subject"] == "test2" + + eeglab_execmenu(*calls[3], session=session) + assert session.LASTCOM == "EEG = pop_saveset(EEG, 'savemode', 'resave');" + + eeglab_execmenu(*calls[4], session=session) + assert np.asarray(session.EEG["data"]).shape == (32, 30504) + assert len(session.ALLEEG) == 1 + + eeglab_execmenu(*calls[5], session=session) + assert session.EEG["event"][0]["position"] == 3 + + eeglab_execmenu(*calls[6], session=session) + assert session.EEG["comments"] == "EEGLAB Tutorial Dataset\ntest" + + eeglab_execmenu(*calls[7], session=session) + assert session.EEG["chanlocs"][0]["labels"] == "FPz" + assert session.EEG["chanlocs"][0]["urchan"] == 0 + assert np.isfinite(session.EEG["chanlocs"][0]["X"]) + assert "theta" not in session.EEG["chanlocs"][1] + + eeglab_execmenu(*calls[8], session=session) + assert session.EEG["srate"] == 64 + assert np.asarray(session.EEG["data"]).shape == (32, 15252) + assert session.EEG["event"][0]["position"] == 3 + assert session.EEG["comments"] == "EEGLAB Tutorial Dataset\ntest" + assert len(session.ALLCOM) == len(calls) + assert workspace.namespace["EEG"] is session.EEG + assert workspace.namespace["ALLEEG"] is session.ALLEEG + assert workspace.namespace["CURRENTSET"] == 1 + assert workspace.namespace["LASTCOM"] == session.LASTCOM + assert workspace.namespace["ALLCOM"] is session.ALLCOM + finally: + workspace.close() + + replayed = _run_workflow(calls) + np.testing.assert_array_equal(replayed.EEG["data"], session.EEG["data"]) + np.testing.assert_array_equal( + [event["latency"] for event in replayed.EEG["event"]], + [event["latency"] for event in session.EEG["event"]], + ) + assert [event["type"] for event in replayed.EEG["event"]] == [event["type"] for event in session.EEG["event"]] + assert replayed.EEG["event"][0]["position"] == session.EEG["event"][0]["position"] == 3 + assert replayed.EEG["comments"] == session.EEG["comments"] + assert [chan["labels"] for chan in replayed.EEG["chanlocs"]] == [chan["labels"] for chan in session.EEG["chanlocs"]] + np.testing.assert_allclose( + [replayed.EEG["chanlocs"][0][field] for field in ("X", "Y", "Z")], + [session.EEG["chanlocs"][0][field] for field in ("X", "Y", "Z")], + ) + assert replayed.ALLCOM == session.ALLCOM + + +def test_execmenu_resave_uses_the_registered_action_default(tmp_path: Path): + calls = _workflow_calls(tmp_path / "resave-default.set") + session = EEGPrepSession() + eeglab_execmenu(*calls[0], session=session) + eeglab_execmenu(*calls[1], session=session) + + command = eeglab_execmenu("Resave current dataset(s)", "pop_saveset", None, session=session) + + assert command == "EEG = pop_saveset(EEG, 'savemode', 'resave');" + assert session.EEG["saved"] == "yes" + + +def test_execmenu_accepts_explicit_keyword_parameters_without_cross_session_state(): + first = EEGPrepSession() + second = EEGPrepSession() + + command = eeglab_execmenu( + "From ASCII/float file or MATLAB array", + "pop_importdata", + {"data": np.ones((2, 12)), "dataformat": "array", "srate": 50}, + session=first, + ) + + assert first.CURRENTSET == [1] + assert np.asarray(first.EEG["data"]).shape == (2, 12) + assert "'srate', 50" in command + assert second.CURRENTSET == [] + assert second.ALLCOM == [] + + +@pytest.mark.parametrize( + ("label", "function", "message"), + [ + ("Not a menu", "pop_loadset", "Could not find menu"), + ("Dataset info", "pop_resample", "registered for pop_editset"), + ("File", "pop_loadset", "not an executable menu item"), + ], +) +def test_execmenu_rejects_unregistered_label_function_pairs(label: str, function: str, message: str): + session = EEGPrepSession() + + with pytest.raises(ValueError, match=message): + eeglab_execmenu(label, function, [], session=session) + + assert session.CURRENTSET == [] + assert session.ALLCOM == [] + + +def test_execmenu_rejects_registered_workflows_without_a_safe_parameterized_dispatcher(): + session = EEGPrepSession() + + with pytest.raises(NotImplementedError, match="pop_select"): + eeglab_execmenu("Select data", "pop_select", ["point", [1, 10]], session=session) + + assert session.CURRENTSET == [] + assert session.ALLCOM == [] + + +def test_execmenu_rejects_parameters_that_could_enable_a_dialog(): + session = EEGPrepSession() + + with pytest.raises(ValueError, match="controls parameter.*gui"): + eeglab_execmenu( + "From ASCII/float file or MATLAB array", + "pop_importdata", + {"data": np.ones((1, 4)), "gui": True}, + session=session, + ) + + +def test_eeglab_execmenu_is_public(): + assert eegprep.eeglab_execmenu is eeglab_execmenu From 0bd5af2a6ba48b4c0187f4563ddabc870ebd075d Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:50 -0700 Subject: [PATCH 21/45] test: port misc data and event utilities (#349) --- docs/source/api/core_and_session.rst | 2 + docs/source/api/data_loading_and_saving.rst | 2 + docs/source/api/epoching_and_selection.rst | 3 + .../api/filtering_and_signal_processing.rst | 4 +- docs/source/api/plotting_and_topography.rst | 2 +- docs/source/changelog.rst | 6 + src/eegprep/__init__.py | 17 + .../functions/miscfunc/eeg_time2prev.py | 108 ++++++ src/eegprep/functions/miscfunc/getipsph.py | 55 +++ src/eegprep/functions/miscfunc/hist2.py | 79 ++++ src/eegprep/functions/miscfunc/rmart.py | 115 ++++++ src/eegprep/functions/miscfunc/rmsave.py | 37 ++ src/eegprep/functions/miscfunc/shortread.py | 68 ++++ .../functions/miscfunc/unique_cell_string.py | 21 ++ .../functions/popfunc/eeg_regepochs.py | 159 ++++++++ .../sigprocfunc/biosig2eeglabevent.py | 112 ++++++ .../functions/sigprocfunc/eventalign.py | 35 ++ .../functions/sigprocfunc/eventlock.py | 122 ++++++ tests/test_misc_data_event_eeglab_tests.py | 346 ++++++++++++++++++ 19 files changed, 1291 insertions(+), 2 deletions(-) create mode 100644 src/eegprep/functions/miscfunc/eeg_time2prev.py create mode 100644 src/eegprep/functions/miscfunc/getipsph.py create mode 100644 src/eegprep/functions/miscfunc/hist2.py create mode 100644 src/eegprep/functions/miscfunc/rmart.py create mode 100644 src/eegprep/functions/miscfunc/rmsave.py create mode 100644 src/eegprep/functions/miscfunc/shortread.py create mode 100644 src/eegprep/functions/miscfunc/unique_cell_string.py create mode 100644 src/eegprep/functions/popfunc/eeg_regepochs.py create mode 100644 src/eegprep/functions/sigprocfunc/biosig2eeglabevent.py create mode 100644 src/eegprep/functions/sigprocfunc/eventalign.py create mode 100644 src/eegprep/functions/sigprocfunc/eventlock.py create mode 100644 tests/test_misc_data_event_eeglab_tests.py diff --git a/docs/source/api/core_and_session.rst b/docs/source/api/core_and_session.rst index 68220438..57a9df71 100644 --- a/docs/source/api/core_and_session.rst +++ b/docs/source/api/core_and_session.rst @@ -31,7 +31,9 @@ Dataset containers, validation, and the shared session that the GUI and ``eegpre eegprep.eeg_urlatency eegprep.eeg_retrieve eegprep.eeg_store + eegprep.eeg_time2prev eegprep.eegh eegprep.eeglab eegprep.eeglab_execmenu eegprep.options + eegprep.unique_cell_string diff --git a/docs/source/api/data_loading_and_saving.rst b/docs/source/api/data_loading_and_saving.rst index 61b9b065..a09a5577 100644 --- a/docs/source/api/data_loading_and_saving.rst +++ b/docs/source/api/data_loading_and_saving.rst @@ -8,6 +8,7 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. .. autosummary:: :toctree: generated/ + eegprep.biosig2eeglabevent eegprep.cart2topo eegprep.chancenter eegprep.convertlocs @@ -34,6 +35,7 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.readlocs eegprep.saveset eegprep.snapread + eegprep.shortread eegprep.writelocs ``readegilocs`` includes packaged EGI montages for 32/33, 64/65, 128/129, diff --git a/docs/source/api/epoching_and_selection.rst b/docs/source/api/epoching_and_selection.rst index 56363b79..ac7080a1 100644 --- a/docs/source/api/epoching_and_selection.rst +++ b/docs/source/api/epoching_and_selection.rst @@ -14,8 +14,11 @@ Extracting epochs, selecting data, and converting between latencies and sample p eegprep.eeg_insertbound eegprep.eeg_lat2point eegprep.eeg_point2lat + eegprep.eeg_regepochs eegprep.eeg_urlatency eegprep.epoch + eegprep.eventalign + eegprep.eventlock eegprep.findboundaries eegprep.lat2point eegprep.point2lat diff --git a/docs/source/api/filtering_and_signal_processing.rst b/docs/source/api/filtering_and_signal_processing.rst index 029492cb..1691657e 100644 --- a/docs/source/api/filtering_and_signal_processing.rst +++ b/docs/source/api/filtering_and_signal_processing.rst @@ -17,10 +17,12 @@ FIR filter design, filtering helpers, and window functions. eegprep.firgauss eegprep.firws eegprep.firwsord + eegprep.getipsph eegprep.invfirwsord eegprep.invkaiserbeta eegprep.kaiserbeta eegprep.minphaserceps eegprep.plotfresp + eegprep.rmart + eegprep.rmsave eegprep.windows - diff --git a/docs/source/api/plotting_and_topography.rst b/docs/source/api/plotting_and_topography.rst index 5c9ff041..a65b4871 100644 --- a/docs/source/api/plotting_and_topography.rst +++ b/docs/source/api/plotting_and_topography.rst @@ -8,7 +8,7 @@ Scalp maps and artifact review plots. .. autosummary:: :toctree: generated/ + eegprep.hist2 eegprep.topoplot eegprep.vis_artifacts eegprep.vis_artifacts_diagnostics - diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 8a2069c7..c997e8dd 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,12 @@ the `GitHub Releases `_ page. Unreleased ========== +- Regular epoch generation, preceding-event timing, event alignment, BioSig + event conversion, sphering, RMS summaries, int16 matrix reads, legacy + lagged-regression artifact removal, ordered string selection, and + superimposed histograms are now public standalone utilities. Their current + EEGLAB tests now have deterministic ports, including substantive assertions + where the MATLAB tests are commented out or ignore their own result. - Added standalone numerical utilities for peak extraction, average reference, Gaussian/Gabor/Laplacian kernels, distance and assignment problems, correlation-based map matching, grouped summaries, PCA compression and diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index f0c8b579..2b24a386 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -56,6 +56,10 @@ "bids_preproc": ("eegprep.plugins.EEG_BIDS.bids_preproc", "bids_preproc"), "build_safe_install_commands": ("eegprep.extension_catalog", "build_safe_install_commands"), "build_safe_update_commands": ("eegprep.extension_catalog", "build_safe_update_commands"), + "biosig2eeglabevent": ( + "eegprep.functions.sigprocfunc.biosig2eeglabevent", + "biosig2eeglabevent", + ), "bundled_plugins": ("eegprep.functions.adminfunc.plugin_menu", "bundled_plugins"), "cart2topo": ("eegprep.functions.sigprocfunc.cart2topo", "cart2topo"), "celltomat": ("eegprep.functions.sigprocfunc.celltomat", "celltomat"), @@ -147,10 +151,12 @@ "eeg_pvaf": ("eegprep.functions.sigprocfunc.ica_helpers", "eeg_pvaf"), "eeg_rejsuperpose": ("eegprep.functions.popfunc.eeg_rejsuperpose", "eeg_rejsuperpose"), "eeg_retrieve": ("eegprep.functions.adminfunc.eeg_retrieve", "eeg_retrieve"), + "eeg_regepochs": ("eegprep.functions.popfunc.eeg_regepochs", "eeg_regepochs"), "eeg_readoptions": ("eegprep.functions.adminfunc.eeg_readoptions", "eeg_readoptions"), "eeg_rpsd": ("eegprep.plugins.ICLabel.eeg_rpsd", "eeg_rpsd"), "eeg_runica": ("eegprep.functions.popfunc.eeg_runica", "eeg_runica"), "eeg_store": ("eegprep.functions.adminfunc.eeg_store", "eeg_store"), + "eeg_time2prev": ("eegprep.functions.miscfunc.eeg_time2prev", "eeg_time2prev"), "eeg_timeinterp": ("eegprep.functions.popfunc.eeg_timeinterp", "eeg_timeinterp"), "eeg_urlatency": ("eegprep.functions.popfunc.eeg_urlatency", "eeg_urlatency"), "eegh": ("eegprep.functions.adminfunc.eegh", "eegh"), @@ -162,6 +168,8 @@ "eegthresh": ("eegprep.functions.sigprocfunc.eegthresh", "eegthresh"), "entropy_rej": ("eegprep.functions.sigprocfunc.entropy_rej", "entropy_rej"), "epoch": ("eegprep.functions.redefine_functions", "epoch"), + "eventalign": ("eegprep.functions.sigprocfunc.eventalign", "eventalign"), + "eventlock": ("eegprep.functions.sigprocfunc.eventlock", "eventlock"), "extension_version_satisfies": ("eegprep.extensions", "extension_version_satisfies"), "findboundaries": ("eegprep.functions.redefine_functions", "findboundaries"), "fir_filterdcpadded": ("eegprep.plugins.firfilt.fir_filterdcpadded", "fir_filterdcpadded"), @@ -178,8 +186,10 @@ "format_plugin_menu": ("eegprep.functions.adminfunc.plugin_menu", "format_plugin_menu"), "gui": ("eegprep.functions.adminfunc.eeglab", "gui"), "getchanlist": ("eegprep.functions.popfunc.getchanlist", "getchanlist"), + "getipsph": ("eegprep.functions.miscfunc.getipsph", "getipsph"), "gethelpvar": ("eegprep.functions.adminfunc.gethelpvar", "gethelpvar"), "getkeyval": ("eegprep.functions.adminfunc.getkeyval", "getkeyval"), + "hist2": ("eegprep.functions.miscfunc.hist2", "hist2"), "icaact": ("eegprep.functions.sigprocfunc.ica_helpers", "icaact"), "icaproj": ("eegprep.functions.sigprocfunc.ica_helpers", "icaproj"), "icavar": ("eegprep.functions.sigprocfunc.ica_helpers", "icavar"), @@ -376,7 +386,9 @@ "rejtrend": ("eegprep.functions.sigprocfunc.rejtrend", "rejtrend"), "reref": ("eegprep.functions.redefine_functions", "reref"), "resample": ("eegprep.functions.redefine_functions", "resample"), + "rmart": ("eegprep.functions.miscfunc.rmart", "rmart"), "rmbase": ("eegprep.functions.sigprocfunc.rmbase", "rmbase"), + "rmsave": ("eegprep.functions.miscfunc.rmsave", "rmsave"), "saveset": ("eegprep.functions.redefine_functions", "saveset"), "select": ("eegprep.functions.redefine_functions", "select"), "select_multiple_datasets": ( @@ -384,6 +396,7 @@ "select_multiple_datasets", ), "signalstat": ("eegprep.functions.sigprocfunc.signalstat", "signalstat"), + "shortread": ("eegprep.functions.miscfunc.shortread", "shortread"), "windows": ("eegprep.plugins.firfilt.windows", "windows"), "snapread": ("eegprep.functions.sigprocfunc.snapread", "snapread"), "optimal_kmeans": ("eegprep.functions.studyfunc.optimal_kmeans", "optimal_kmeans"), @@ -453,6 +466,10 @@ "timefreq": ("eegprep.functions.timefreqfunc.timefreq", "timefreq"), "timewarp": ("eegprep.functions.timefreqfunc.timewarp", "timewarp"), "topoplot": ("eegprep.functions.sigprocfunc.topoplot", "topoplot"), + "unique_cell_string": ( + "eegprep.functions.miscfunc.unique_cell_string", + "unique_cell_string", + ), "validate_extension_spec": ("eegprep.extensions", "validate_extension_spec"), "validate_catalog_entries": ("eegprep.extension_catalog_validation", "validate_catalog_entries"), "validate_catalog_file": ("eegprep.extension_catalog_validation", "validate_catalog_file"), diff --git a/src/eegprep/functions/miscfunc/eeg_time2prev.py b/src/eegprep/functions/miscfunc/eeg_time2prev.py new file mode 100644 index 00000000..1754f63a --- /dev/null +++ b/src/eegprep/functions/miscfunc/eeg_time2prev.py @@ -0,0 +1,108 @@ +"""Compute target-event delays from preceding original events.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import numpy as np + + +def eeg_time2prev( + EEG: dict[str, Any], + target: Sequence[Any], + previous: Sequence[Any], +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return delays from target events to preceding original events. + + Event and urevent pointers in EEGPrep dictionaries are zero-based. Returned + ``targets``, ``urtargets``, and ``urprevs`` therefore use zero-based indices; + ``urprevs`` uses ``-1`` when no matching preceding event exists. Event + latencies remain EEGLAB-compatible one-based sample positions. + + Args: + EEG: Dataset containing ``event``, ``urevent``, and ``srate``. + target: Event types whose delays should be reported. + previous: Event types eligible as the preceding event. + + Returns: + ``(delays_ms, targets, urtargets, urprevs)``. + """ + target_types = _type_names(target, "target") + previous_types = _type_names(previous, "previous") + events = _records(EEG.get("event"), "event") + urevents = _records(EEG.get("urevent"), "urevent") + sampling_rate = float(EEG.get("srate", 0)) + if sampling_rate <= 0: + raise ValueError("eeg_time2prev: EEG.srate must be positive") + + delays: list[float] = [] + targets: list[int] = [] + urtargets: list[int] = [] + urprevs: list[int] = [] + for event_index, event in enumerate(events): + urevent_index = _urevent_index(event, len(urevents)) + if _event_type(urevents[urevent_index].get("type")) not in target_types: + continue + + previous_index = next( + ( + index + for index in range(urevent_index - 1, -1, -1) + if _event_type(urevents[index].get("type")) in previous_types + ), + -1, + ) + delay = 0.0 + if previous_index >= 0: + target_latency = float(urevents[urevent_index]["latency"]) + previous_latency = float(urevents[previous_index]["latency"]) + delay = (target_latency - previous_latency) * 1000 / sampling_rate + + delays.append(delay) + targets.append(event_index) + urtargets.append(urevent_index) + urprevs.append(previous_index) + + return ( + np.asarray(delays, dtype=float), + np.asarray(targets, dtype=int), + np.asarray(urtargets, dtype=int), + np.asarray(urprevs, dtype=int), + ) + + +def _type_names(values: Sequence[Any], name: str) -> set[str]: + if isinstance(values, (str, bytes)) or not isinstance(values, Sequence): + raise ValueError(f'eeg_time2prev: {name} must be a sequence of event types') + return {_event_type(value) for value in values} + + +def _event_type(value: Any) -> str: + if isinstance(value, bytes): + value = value.decode("utf-8") + return str(value).casefold() + + +def _records(value: Any, name: str) -> list[dict[str, Any]]: + if value is None: + raise ValueError(f"eeg_time2prev: EEG.{name} is required") + if isinstance(value, np.ndarray): + value = value.reshape(-1).tolist() + if isinstance(value, dict): + value = [value] + if not isinstance(value, list) or not all(isinstance(record, dict) for record in value): + raise ValueError(f"eeg_time2prev: EEG.{name} must contain event dictionaries") + return value + + +def _urevent_index(event: dict[str, Any], count: int) -> int: + if "urevent" not in event: + raise ValueError("eeg_time2prev: every EEG.event needs a zero-based urevent pointer") + index = int(event["urevent"]) + if index != event["urevent"] or index < 0 or index >= count: + raise ValueError("eeg_time2prev: EEG.event contains an invalid zero-based urevent pointer") + return index + + +__all__ = ["eeg_time2prev"] diff --git a/src/eegprep/functions/miscfunc/getipsph.py b/src/eegprep/functions/miscfunc/getipsph.py new file mode 100644 index 00000000..9bd894ab --- /dev/null +++ b/src/eegprep/functions/miscfunc/getipsph.py @@ -0,0 +1,55 @@ +"""In-place sphering and dimension-reducing quasi-sphering.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc.misc import finite_matmul, finite_pinv + + +def getipsph(x: Any, m: int | None = None) -> np.ndarray: + """Return an EEGLAB-compatible sphering or quasi-sphering matrix. + + Args: + x: Channel-major data with shape ``(channels, samples)``. + m: Output dimensionality. The default preserves all channels. + + Returns: + A matrix with shape ``(m, channels)`` whose product with centered + ``x`` has identity covariance when the retained covariance is full rank. + """ + data = np.asarray(x, dtype=float) + if data.ndim != 2 or data.shape[1] == 0: + raise ValueError("getipsph: x must be a non-empty channels-by-samples matrix") + if not np.isfinite(data).all(): + raise ValueError("getipsph: x must contain only finite values") + + channels, samples = data.shape + dimensions = channels if m is None else _dimension(m, channels) + centered = data - np.mean(data, axis=1, keepdims=True) + covariance = finite_matmul(centered, centered.T) / samples + covariance = (covariance + covariance.T) / 2 + left, singular_values, _right = np.linalg.svd(covariance) + + if dimensions == channels: + root_inverse = finite_pinv(np.diag(np.sqrt(singular_values))) + return finite_matmul(finite_matmul(left, root_inverse), left.T) + + variance_order = np.argsort(np.diag(covariance))[::-1] + orientation_left, _values, orientation_right = np.linalg.svd(left[variance_order[:dimensions], :dimensions]) + root_inverse = finite_pinv(np.diag(np.sqrt(singular_values[:dimensions]))) + oriented = finite_matmul(orientation_left, orientation_right) + whitened = finite_matmul(oriented, root_inverse) + return finite_matmul(whitened, left[:, :dimensions].T) + + +def _dimension(value: Any, channels: int) -> int: + dimensions = int(value) + if dimensions != value or dimensions < 1 or dimensions > channels: + raise ValueError("getipsph: m must be an integer from 1 through the number of channels") + return dimensions + + +__all__ = ["getipsph"] diff --git a/src/eegprep/functions/miscfunc/hist2.py b/src/eegprep/functions/miscfunc/hist2.py new file mode 100644 index 00000000..0e9c9dd3 --- /dev/null +++ b/src/eegprep/functions/miscfunc/hist2.py @@ -0,0 +1,79 @@ +"""Superimposed histogram plotting.""" + +from __future__ import annotations + +from typing import Any + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.axes import Axes + + +def hist2(data1: Any, data2: Any, bins: Any = None, *, ax: Axes | None = None) -> Axes: + """Plot two semi-transparent histograms on one Matplotlib axes. + + Vector ``bins`` values are bin centers, matching MATLAB ``hist`` rather + than NumPy's bin-edge convention. Values beyond the first and last center + are accumulated in the end bins. + """ + first = _finite_vector(data1, "data1") + second = _finite_vector(data2, "data2") + centers = _bin_centers(first, second, bins) + boundaries = np.concatenate(([-np.inf], (centers[:-1] + centers[1:]) / 2, [np.inf])) + first_counts = np.histogram(first, boundaries)[0] + second_counts = np.histogram(second, boundaries)[0] + widths = _bar_widths(centers) + + if ax is None: + _figure, ax = plt.subplots() + ax.bar(centers, first_counts, width=widths, color="blue", alpha=0.5, edgecolor="none", align="center") + ax.bar(centers, second_counts, width=widths, color="red", alpha=0.5, edgecolor="none", align="center") + ax.set_ylabel("Number of values") + if centers.size > 1: + ax.set_xlim(float(centers[0]), float(centers[-1])) + return ax + + +def _finite_vector(data: Any, name: str) -> np.ndarray: + values = np.asarray(data, dtype=float).reshape(-1) + if values.size == 0 or not np.isfinite(values).all(): + raise ValueError(f"hist2: {name} must contain finite values") + return values + + +def _bin_centers(first: np.ndarray, second: np.ndarray, bins: Any) -> np.ndarray: + minimum = min(float(np.min(first)), float(np.min(second))) + maximum = max(float(np.max(first)), float(np.max(second))) + if bins is None: + count = 100 + return _even_centers(minimum, maximum, count) + + values = np.asarray(bins, dtype=float).reshape(-1) + if values.size == 1: + count = int(values[0]) + if count != values[0] or count < 1: + raise ValueError("hist2: a scalar bins value must be a positive integer") + return _even_centers(minimum, maximum, count) + if not np.isfinite(values).all() or np.any(np.diff(values) <= 0): + raise ValueError("hist2: bin centers must be finite and strictly increasing") + return values + + +def _even_centers(minimum: float, maximum: float, count: int) -> np.ndarray: + if minimum == maximum: + if count == 1: + return np.asarray([minimum]) + span = max(abs(minimum), 1.0) * 0.5 + minimum -= span + maximum += span + return np.linspace(minimum, maximum, count) + + +def _bar_widths(centers: np.ndarray) -> np.ndarray: + if centers.size == 1: + return np.asarray([1.0]) + differences = np.diff(centers) + return np.concatenate(([differences[0]], np.minimum(differences[:-1], differences[1:]), [differences[-1]])) + + +__all__ = ["hist2"] diff --git a/src/eegprep/functions/miscfunc/rmart.py b/src/eegprep/functions/miscfunc/rmart.py new file mode 100644 index 00000000..53137071 --- /dev/null +++ b/src/eegprep/functions/miscfunc/rmart.py @@ -0,0 +1,115 @@ +"""Legacy lagged-regression ocular artifact removal.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc.misc import finite_matmul +from eegprep.functions.sigprocfunc.floatread import floatread +from eegprep.functions.sigprocfunc.floatwrite import floatwrite + + +_DEFAULT_THRESHOLD = 80.0 +_EPOCH_FRAMES = 80 +_LAG_COUNT = 40 + + +def rmart( + datafile: str | Path, + outfile: str | Path, + nchans: int, + chanlist: Any, + eogchan: Any, + threshold: float = _DEFAULT_THRESHOLD, + *, + format: str | None = None, +) -> np.ndarray: + """Remove threshold-triggered EOG artifacts from a float32 data file. + + ``chanlist`` and ``eogchan`` use EEGLAB-facing one-based channel numbers. + The input and output are channel-major float32 matrices stored in MATLAB + column order. The corrected selected channels are returned as well as + written to ``outfile``. + + This implements the intended 40-lag local regression described by EEGLAB's + legacy ``rmart`` rather than its currently unreachable processing branch. + ICA or ASR is generally preferable for new analyses. + """ + channel_count = _positive_integer(nchans, "nchans") + selected = _channel_indices(chanlist, channel_count, "chanlist") + eog_indices = _channel_indices(eogchan, channel_count, "eogchan") + trigger = _DEFAULT_THRESHOLD if threshold == 0 else float(threshold) + if not np.isfinite(trigger) or trigger < 0: + raise ValueError("rmart: threshold must be a finite non-negative value") + + data = floatread(datafile, [channel_count, np.inf], format) + eog = np.asarray(data[eog_indices], dtype=float) + corrected = np.asarray(data[selected], dtype=float).copy() + for output_index, channel_index in enumerate(selected): + signal = np.asarray(data[channel_index], dtype=float) + signal = signal - np.mean(signal) + for center in _artifact_centers(signal, eog, trigger): + signal = _regress_window(signal, eog, center) + corrected[output_index] = signal + + floatwrite(corrected, outfile, format) + return corrected + + +def _artifact_centers(signal: np.ndarray, eog: np.ndarray, threshold: float) -> list[int]: + margin = _EPOCH_FRAMES // 2 + _LAG_COUNT // 2 + if signal.size <= 2 * margin: + return [] + triggered = np.abs(signal) >= threshold + triggered |= np.any(np.abs(eog) >= threshold, axis=0) + candidates = np.flatnonzero(triggered) + centers: list[int] = [] + for sample in candidates: + center = int(np.clip(sample, margin, signal.size - margin)) + if not centers or center - centers[-1] >= _EPOCH_FRAMES: + centers.append(center) + return centers + + +def _regress_window(signal: np.ndarray, eog: np.ndarray, center: int) -> np.ndarray: + half_epoch = _EPOCH_FRAMES // 2 + half_lags = _LAG_COUNT // 2 + signal_start = center - half_epoch + signal_stop = center + half_epoch + eog_start = signal_start - half_lags + eog_stop = signal_stop + half_lags + extended = eog[:, eog_start:eog_stop] + columns = [np.ones(_EPOCH_FRAMES)] + columns.extend(row[lag : lag + _EPOCH_FRAMES] for row in extended for lag in range(_LAG_COUNT)) + design = np.column_stack(columns) + coefficients, _residuals, _rank, _singular_values = np.linalg.lstsq( + design, + signal[signal_start:signal_stop], + rcond=None, + ) + output = signal.copy() + output[signal_start:signal_stop] -= finite_matmul(design, coefficients) + return output + + +def _channel_indices(values: Any, count: int, name: str) -> list[int]: + numbers = np.asarray(values).reshape(-1) + if numbers.size == 0: + raise ValueError(f"rmart: {name} must not be empty") + indices = [int(value) - 1 for value in numbers] + if any(index + 1 != value or index < 0 or index >= count for index, value in zip(indices, numbers)): + raise ValueError(f"rmart: {name} must contain one-based channel numbers") + return indices + + +def _positive_integer(value: Any, name: str) -> int: + result = int(value) + if result != value or result < 1: + raise ValueError(f"rmart: {name} must be a positive integer") + return result + + +__all__ = ["rmart"] diff --git a/src/eegprep/functions/miscfunc/rmsave.py b/src/eegprep/functions/miscfunc/rmsave.py new file mode 100644 index 00000000..cb42915c --- /dev/null +++ b/src/eegprep/functions/miscfunc/rmsave.py @@ -0,0 +1,37 @@ +"""Root-mean-square summaries for channel-major EEG data.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def rmsave(data: Any, frames: int | None = None) -> np.ndarray: + """Return the RMS of each channel in consecutive equal-length blocks. + + A three-dimensional ``(channels, points, trials)`` input is flattened in + MATLAB column order. With no explicit ``frames``, each trial is one block. + """ + array = np.asarray(data) + if array.ndim not in {2, 3} or array.shape[0] == 0: + raise ValueError("rmsave: data must be a non-empty 2D or 3D channel-major array") + + default_frames = array.shape[1] + flattened = array.reshape(array.shape[0], -1, order="F").astype(np.result_type(array.dtype, float), copy=False) + block_length = default_frames if frames is None else _positive_integer(frames) + if flattened.shape[1] % block_length: + raise ValueError("rmsave: frames must divide the data length exactly") + + blocks = flattened.reshape(array.shape[0], block_length, -1, order="F") + return np.sqrt(np.mean(np.abs(blocks) ** 2, axis=1)) + + +def _positive_integer(value: Any) -> int: + result = int(value) + if result != value or result < 1: + raise ValueError("rmsave: frames must be a positive integer") + return result + + +__all__ = ["rmsave"] diff --git a/src/eegprep/functions/miscfunc/shortread.py b/src/eegprep/functions/miscfunc/shortread.py new file mode 100644 index 00000000..616217dc --- /dev/null +++ b/src/eegprep/functions/miscfunc/shortread.py @@ -0,0 +1,68 @@ +"""Read MATLAB-ordered int16 matrices.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np + + +def shortread( + filename: str | Path, + size: Any, + format: str | None = None, + offset: int = 0, +) -> np.ndarray: + """Read an int16 matrix using MATLAB column ordering. + + ``offset`` counts int16 values, not bytes. An infinite final dimension is + inferred from the number of values remaining after the offset. + """ + dtype = _dtype(format) + start = _nonnegative_integer(offset) + values = np.fromfile(Path(filename), dtype=dtype, offset=start * dtype.itemsize) + shape = _shape(size, values.size) + count = int(np.prod(shape)) + if count > values.size: + raise ValueError("shortread: requested matrix is larger than the remaining file") + return values[:count].reshape(shape, order="F") + + +def _dtype(format: str | None) -> np.dtype: + text = str(format or "native").lower() + if text in {"", "n", "native"}: + return np.dtype("i2") + if text in {"ieee-le", "l", "little", "int16le"}: + return np.dtype("i2") + raise ValueError(f"shortread: unsupported format {format!r}") + + +def _nonnegative_integer(value: Any) -> int: + result = int(value) + if result != value or result < 0: + raise ValueError("shortread: offset must be a non-negative integer") + return result + + +def _shape(size: Any, available: int) -> tuple[int, ...]: + dimensions = np.asarray(size, dtype=float).reshape(-1) + if dimensions.size == 0 or np.isnan(dimensions).any() or np.isinf(dimensions[:-1]).any(): + raise ValueError("shortread: only the final size dimension may be infinite") + if np.any(dimensions[:-1] < 1) or np.any(dimensions[:-1] != np.floor(dimensions[:-1])): + raise ValueError("shortread: finite size dimensions must be positive integers") + if np.isneginf(dimensions[-1]): + raise ValueError("shortread: the inferred final size dimension must be positive infinity") + if np.isposinf(dimensions[-1]): + known = int(np.prod(dimensions[:-1])) if dimensions.size > 1 else 1 + if available % known: + raise ValueError("shortread: remaining file length is not divisible by the requested dimensions") + dimensions[-1] = available // known + if dimensions[-1] < 1 or dimensions[-1] != np.floor(dimensions[-1]): + raise ValueError("shortread: finite size dimensions must be positive integers") + return tuple(int(value) for value in dimensions) + + +__all__ = ["shortread"] diff --git a/src/eegprep/functions/miscfunc/unique_cell_string.py b/src/eegprep/functions/miscfunc/unique_cell_string.py new file mode 100644 index 00000000..f210d803 --- /dev/null +++ b/src/eegprep/functions/miscfunc/unique_cell_string.py @@ -0,0 +1,21 @@ +"""String selection helper for heterogeneous cell-like sequences.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + + +def unique_cell_string(values: Iterable[Any]) -> list[str]: + """Return unique string elements in first-seen order, ignoring non-strings.""" + result: list[str] = [] + seen: set[str] = set() + for value in values: + if not isinstance(value, str) or value in seen: + continue + seen.add(value) + result.append(value) + return result + + +__all__ = ["unique_cell_string"] diff --git a/src/eegprep/functions/popfunc/eeg_regepochs.py b/src/eegprep/functions/popfunc/eeg_regepochs.py new file mode 100644 index 00000000..c32d9209 --- /dev/null +++ b/src/eegprep/functions/popfunc/eeg_regepochs.py @@ -0,0 +1,159 @@ +"""Split continuous EEG into regularly recurring epochs.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc.value_parsing import parse_key_value_args +from eegprep.functions.popfunc.pop_epoch import pop_epoch +from eegprep.functions.popfunc.pop_mergeset import pop_mergeset +from eegprep.functions.popfunc.pop_rmbase import pop_rmbase + + +_DEFAULTS = { + "recurrence": 1.0, + "limits": None, + "rmbase": 0.0, + "eventtype": "X", + "eventdata": None, + "extractepochs": "on", +} + + +def eeg_regepochs(EEG: dict[str, Any] | list[dict[str, Any]], *args: Any, **kwargs: Any) -> dict[str, Any]: + """Insert regular events and optionally extract consecutive epochs. + + Event latencies remain EEGLAB-compatible one-based sample positions; + ``urevent`` pointers stored in EEGPrep dictionaries are zero-based. Legacy + positional ``recurrence, limits, rmbase`` arguments and EEGLAB-style + key/value pairs are both supported. + """ + dataset = _dataset(EEG) + options = _options(args, kwargs) + recurrence = float(options["recurrence"]) + sampling_rate = float(dataset.get("srate", 0)) + data = np.asarray(dataset.get("data")) + inferred_points = data.shape[1] if data.ndim in {2, 3} else 0 + points = int(dataset.get("pnts", inferred_points)) + if sampling_rate <= 0 or points < 1: + raise ValueError("eeg_regepochs: EEG.srate and EEG.pnts must be positive") + if int(dataset.get("trials", 1)) != 1: + raise ValueError("eeg_regepochs: input dataset must be continuous") + if recurrence <= 0: + raise ValueError("eeg_regepochs: recurrence must be positive") + + duration = float(dataset.get("xmax", (points - 1) / sampling_rate)) + 1 / sampling_rate + event_count = int(np.floor(duration / recurrence + np.finfo(float).eps * 8)) + if event_count < 1: + raise ValueError("eeg_regepochs: recurrence is longer than the recording") + limits = _limits(options["limits"], recurrence) + event_type = str(options["eventtype"]) + extra = _event_data(options["eventdata"]) + + events = _records(dataset.get("event")) + urevents = _records(dataset.get("urevent")) + for event in events: + if "type" in event: + event["type"] = str(event["type"]) + if events and not urevents: + urevents = [ + {key: deepcopy(value) for key, value in event.items() if key not in {"epoch", "urevent"}} + for event in events + ] + for index, event in enumerate(events): + event["urevent"] = index + for index in range(event_count): + latency = recurrence * index * sampling_rate + 1 + urevent = {"type": event_type, "latency": latency, **deepcopy(extra)} + event = {**deepcopy(urevent), "urevent": len(urevents)} + urevents.append(urevent) + events.append(event) + events.sort(key=lambda event: float(event.get("latency", np.inf))) + dataset["event"] = events + dataset["urevent"] = urevents + + extract = str(options["extractepochs"]).casefold() + if extract == "off": + dataset["saved"] = "no" + return dataset + if extract != "on": + raise ValueError("eeg_regepochs: extractepochs must be 'on' or 'off'") + + setname = str(dataset.get("setname", "")) + newname = f"{setname} - {recurrence:g}-s epochs" if setname else f"{recurrence:g}-s epochs" + epoched, _indices = pop_epoch( + dataset, + [event_type], + limits, + "newname", + newname, + "epochinfo", + "yes", + ) + baseline_end = float(options["rmbase"]) + if not np.isnan(baseline_end) and limits[0] < baseline_end: + epoched = pop_rmbase(epoched, [limits[0] * 1000, baseline_end * 1000], gui=False) + return epoched + + +def _dataset(EEG: dict[str, Any] | list[dict[str, Any]]) -> dict[str, Any]: + if isinstance(EEG, list): + if not EEG: + raise ValueError("eeg_regepochs: EEG must not be empty") + if len(EEG) == 1: + return deepcopy(EEG[0]) + return pop_mergeset(EEG, list(range(1, len(EEG) + 1)), gui=False) + if not isinstance(EEG, dict) or "event" not in EEG: + raise ValueError("eeg_regepochs: EEG must be a dataset dictionary with an event table") + return deepcopy(EEG) + + +def _options(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: + if args and not isinstance(args[0], (str, bytes)): + if len(args) > 3: + raise ValueError("eeg_regepochs: positional form accepts recurrence, limits, and rmbase") + names = ("recurrence", "limits", "rmbase") + supplied = dict(zip(names, args)) + supplied.update({str(key).lower(): value for key, value in kwargs.items()}) + else: + supplied = parse_key_value_args(args, kwargs, lowercase_kwargs=True) + unknown = sorted(set(supplied) - set(_DEFAULTS)) + if unknown: + raise ValueError(f"eeg_regepochs: unsupported option(s): {', '.join(unknown)}") + return {**_DEFAULTS, **supplied} + + +def _limits(value: Any, recurrence: float) -> list[float]: + limits = np.asarray([0, recurrence] if value is None else value, dtype=float).reshape(-1) + if limits.size != 2 or not np.isfinite(limits).all() or limits[0] >= limits[1]: + raise ValueError("eeg_regepochs: limits must be an increasing two-value vector") + return limits.tolist() + + +def _event_data(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return deepcopy(value) + values = list(value) + if len(values) % 2 or not all(isinstance(values[index], str) for index in range(0, len(values), 2)): + raise ValueError("eeg_regepochs: eventdata must be a mapping or key/value sequence") + return dict(zip(values[::2], values[1::2])) + + +def _records(value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + if isinstance(value, np.ndarray): + value = value.reshape(-1).tolist() + if isinstance(value, dict): + value = [value] + if not isinstance(value, list) or not all(isinstance(record, dict) for record in value): + raise ValueError("eeg_regepochs: event tables must contain dictionaries") + return [deepcopy(record) for record in value] + + +__all__ = ["eeg_regepochs"] diff --git a/src/eegprep/functions/sigprocfunc/biosig2eeglabevent.py b/src/eegprep/functions/sigprocfunc/biosig2eeglabevent.py new file mode 100644 index 00000000..b1aaa091 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/biosig2eeglabevent.py @@ -0,0 +1,112 @@ +"""Convert BioSig event tables to EEGPrep event dictionaries.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +import numpy as np + + +def biosig2eeglabevent( + EVENT: dict[str, Any], + interval: Any = None, + importEDFplus: bool = False, +) -> list[dict[str, Any]]: + """Convert a BioSig ``EVENT`` mapping into EEG event records. + + BioSig ``POS`` values and returned EEG ``latency`` values are one-based + sample positions. When ``interval=[first, last]`` is supplied, both bounds + are one-based and inclusive, and retained latencies are rebased so + ``first`` becomes sample 1. + """ + if not isinstance(EVENT, dict): + raise ValueError("biosig2eeglabevent: EVENT must be a mapping") + fields = {name: _field_values(EVENT[name]) for name in ("TYP", "POS", "DUR", "CHN") if name in EVENT} + events = _initial_events(EVENT.get("Teeg")) + lengths = [len(values) for values in fields.values()] + count = len(events) if events else (lengths[0] if lengths else 0) + if any(length != count for length in lengths): + raise ValueError("biosig2eeglabevent: TYP, POS, DUR, CHN, and Teeg lengths must agree") + if not events: + events = [{} for _ in range(count)] + + selected = list(range(count)) + bounds = _interval(interval) + if bounds is not None: + if "POS" not in fields: + raise ValueError("biosig2eeglabevent: interval selection requires EVENT.POS") + selected = [index for index, value in enumerate(fields["POS"]) if bounds[0] <= float(value) <= bounds[1]] + + include_duration = "DUR" in fields and _contains_nonzero(fields["DUR"]) + include_channel = "CHN" in fields and _contains_nonzero(fields["CHN"]) + result: list[dict[str, Any]] = [] + for index in selected: + event = deepcopy(events[index]) + if "TYP" in fields: + event["type"] = _edf_type(fields["TYP"][index], EVENT, importEDFplus) + if "POS" in fields: + position = float(fields["POS"][index]) + event["latency"] = position if bounds is None else position - bounds[0] + 1 + if include_duration: + duration = fields["DUR"][index] + if bounds is not None: + duration = min(float(duration), bounds[1] - float(fields["POS"][index])) + event["duration"] = duration + if include_channel: + event["chanindex"] = fields["CHN"][index] + result.append(event) + return result + + +def _field_values(value: Any) -> list[Any]: + if isinstance(value, bytes): + return [bytes([item]) for item in value] + if isinstance(value, str): + return list(value) + return np.asarray(value).reshape(-1).tolist() + + +def _initial_events(value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + if isinstance(value, dict): + return [deepcopy(value)] + if isinstance(value, np.ndarray): + value = value.reshape(-1).tolist() + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): + raise ValueError("biosig2eeglabevent: Teeg must contain event dictionaries") + return deepcopy(value) + + +def _interval(value: Any) -> tuple[float, float] | None: + if value is None or np.asarray(value).size == 0: + return None + bounds = np.asarray(value, dtype=float).reshape(-1) + if bounds.size != 2 or not np.isfinite(bounds).all() or bounds[0] > bounds[1]: + raise ValueError("biosig2eeglabevent: interval must be [first, last] with inclusive one-based bounds") + return float(bounds[0]), float(bounds[1]) + + +def _contains_nonzero(values: list[Any]) -> bool: + for value in values: + if isinstance(value, (str, bytes)): + if value: + return True + elif float(value) != 0: + return True + return False + + +def _edf_type(value: Any, event: dict[str, Any], enabled: bool) -> Any: + if not enabled or not isinstance(value, (int, float, np.integer, np.floating)) or value <= 255: + return value + indices = _field_values(event.get("CodeIndex", [])) + descriptions = _field_values(event.get("CodeDesc", [])) + for index, description in zip(indices, descriptions): + if index == value: + return description + return value + + +__all__ = ["biosig2eeglabevent"] diff --git a/src/eegprep/functions/sigprocfunc/eventalign.py b/src/eegprep/functions/sigprocfunc/eventalign.py new file mode 100644 index 00000000..f9bd1002 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/eventalign.py @@ -0,0 +1,35 @@ +"""Score event-array alignment factors.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def eventalign(factor: Any, a: Any, b: Any, measure: str = "median") -> float: + """Return the mean or median nearest alignment error. + + A scalar factor scales ``a``. A two-value factor supplies ``(scale, + offset)``. For matrices, the minimum absolute error is found in each row + before the row minima are summarized. + """ + factors = np.asarray(factor, dtype=float).reshape(-1) + if factors.size not in {1, 2} or not np.isfinite(factors).all(): + raise ValueError("eventalign: factor must contain a finite scale and optional offset") + first = np.asarray(a, dtype=float) + second = np.asarray(b, dtype=float) + if first.size == 0 or first.shape != second.shape: + raise ValueError("eventalign: a and b must be non-empty arrays with matching shapes") + + difference = np.abs(factors[0] * first - second + (factors[1] if factors.size == 2 else 0)) + minima = np.asarray(np.min(difference, axis=-1) if difference.ndim > 1 else np.min(difference)) + statistic = str(measure).casefold() + if statistic == "mean": + return float(np.mean(minima)) + if statistic == "median": + return float(np.median(minima)) + raise ValueError("eventalign: measure must be 'mean' or 'median'") + + +__all__ = ["eventalign"] diff --git a/src/eegprep/functions/sigprocfunc/eventlock.py b/src/eegprep/functions/sigprocfunc/eventlock.py new file mode 100644 index 00000000..262c4d91 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/eventlock.py @@ -0,0 +1,122 @@ +"""Align existing epochs to per-trial event values.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def eventlock( + data: Any, + frames_or_xvals: Any, + eventvals: Any, + medval: float | None = None, +) -> tuple[np.ndarray, float, np.ndarray]: + """Shift epochs so their event frames or time values align. + + A scalar second argument selects multi-channel mode, where ``data`` has + shape ``(channels, frames * trials)``. Zero infers frames per trial from + ``eventvals``. A vector selects single-channel mode, where data has shape + ``(frames, trials)``; ``[start_ms, frames, srate_hz]`` is accepted as a + compact time-axis specification. Positive returned shifts move data right. + """ + array = np.asarray(data) + if array.ndim != 2 or array.size == 0: + raise ValueError("eventlock: data must be a non-empty 2D array") + event_values = np.asarray(eventvals, dtype=float).reshape(-1) + if event_values.size == 0 or not np.isfinite(event_values).all(): + raise ValueError("eventlock: eventvals must contain one finite value per trial") + + argument = np.asarray(frames_or_xvals, dtype=float).reshape(-1) + if argument.size == 1: + return _eventlock_multichannel(array, argument[0], event_values, medval) + return _eventlock_single_channel(array, argument, event_values, medval) + + +def _eventlock_multichannel( + data: np.ndarray, + frames_value: float, + event_values: np.ndarray, + medval: float | None, +) -> tuple[np.ndarray, float, np.ndarray]: + if frames_value == 0: + if data.shape[1] % event_values.size: + raise ValueError("eventlock: data length must be divisible by the number of event values") + frames = data.shape[1] // event_values.size + else: + frames = _positive_integer(frames_value, "frames") + if data.shape[1] % frames: + raise ValueError("eventlock: data length must be a multiple of frames") + trials = data.shape[1] // frames + if event_values.size != trials: + raise ValueError("eventlock: eventvals must contain one value per trial") + + xvals = np.arange(1, frames + 1, dtype=float) + aligned_value, shifts = _alignment(xvals, event_values, medval) + output = np.full(data.shape, np.nan, dtype=np.result_type(data.dtype, float)) + for trial, shift in enumerate(shifts): + start = trial * frames + stop = start + frames + output[:, start:stop] = _shift(data[:, start:stop], int(shift)) + return output, aligned_value, shifts + + +def _eventlock_single_channel( + data: np.ndarray, + argument: np.ndarray, + event_values: np.ndarray, + medval: float | None, +) -> tuple[np.ndarray, float, np.ndarray]: + if argument.size == 3: + frames = _positive_integer(argument[1], "compact x-axis frame count") + sampling_rate = float(argument[2]) + if sampling_rate <= 0: + raise ValueError("eventlock: compact x-axis sampling rate must be positive") + xvals = float(argument[0]) + np.arange(frames) * 1000 / sampling_rate + else: + xvals = argument + frames = xvals.size + if frames != data.shape[0]: + raise ValueError("eventlock: data rows must equal the number of x-axis values") + if event_values.size != data.shape[1]: + raise ValueError("eventlock: eventvals must contain one value per trial") + if not np.isfinite(xvals).all(): + raise ValueError("eventlock: x-axis values must be finite") + + aligned_value, shifts = _alignment(xvals, event_values, medval) + output = np.full(data.shape, np.nan, dtype=np.result_type(data.dtype, float)) + for trial, shift in enumerate(shifts): + output[:, trial] = _shift(data[:, trial], int(shift)) + return output, aligned_value, shifts + + +def _alignment(xvals: np.ndarray, event_values: np.ndarray, medval: float | None) -> tuple[float, np.ndarray]: + requested = float(np.median(event_values) if medval is None else medval) + median_index = int(np.argmin(np.abs(xvals - requested))) + event_indices = np.asarray([np.argmin(np.abs(xvals - value)) for value in event_values], dtype=int) + return float(xvals[median_index]), median_index - event_indices + + +def _shift(values: np.ndarray, shift: int) -> np.ndarray: + output = np.full(values.shape, np.nan, dtype=np.result_type(values.dtype, float)) + length = values.shape[-1] if values.ndim == 2 else values.shape[0] + if abs(shift) >= length: + return output + if shift > 0: + output[..., shift:] = values[..., :-shift] + elif shift < 0: + output[..., :shift] = values[..., -shift:] + else: + output[...] = values + return output + + +def _positive_integer(value: Any, name: str) -> int: + result = int(value) + if result != value or result < 1: + raise ValueError(f"eventlock: {name} must be a positive integer") + return result + + +__all__ = ["eventlock"] diff --git a/tests/test_misc_data_event_eeglab_tests.py b/tests/test_misc_data_event_eeglab_tests.py new file mode 100644 index 00000000..6cadcbb2 --- /dev/null +++ b/tests/test_misc_data_event_eeglab_tests.py @@ -0,0 +1,346 @@ +"""Current EEGLAB test ports for miscellaneous data and event utilities.""" + +from __future__ import annotations + +import copy + +import matplotlib.pyplot as plt +import numpy as np +import pytest + +from eegprep import ( + biosig2eeglabevent, + eeg_regepochs, + eeg_time2prev, + eventalign, + eventlock, + getipsph, + hist2, + rmart, + rmsave, + shortread, + unique_cell_string, +) +from eegprep.functions.sigprocfunc.floatread import floatread +from eegprep.functions.sigprocfunc.floatwrite import floatwrite +from tests.eeglab_tests import eeglab_test + + +def _continuous_eeg(channels: int, points: int, sampling_rate: float) -> dict: + data = np.arange(channels * points, dtype=float).reshape(channels, points) + return { + "data": data, + "nbchan": channels, + "pnts": points, + "trials": 1, + "srate": sampling_rate, + "xmin": 0.0, + "xmax": (points - 1) / sampling_rate, + "times": np.arange(points) * 1000 / sampling_rate, + "setname": "continuous", + "event": [], + "urevent": [], + "epoch": [], + "chanlocs": [], + } + + +@eeglab_test( + "unittesting_miscfunc/eeg_regepochs/miscfunc_eeg_regepochs_wrapperTest.m", + "test_check_number_trials", +) +def test_eeg_regepochs_current_suite_preserves_all_regular_trials(): + eeg = _continuous_eeg(1, 40 * 500, 500) + + one_second = eeg_regepochs( + eeg, + "eventtype", + "temp", + "extractepochs", + "on", + "recurrence", + 1, + "limits", + [0, 1], + ) + offset = eeg_regepochs( + eeg, + "eventtype", + "temp", + "extractepochs", + "on", + "recurrence", + 1, + "limits", + [0.5, 0.8], + ) + + assert one_second["trials"] == offset["trials"] == 40 + assert one_second["data"].shape == (1, 500, 40) + assert offset["data"].shape == (1, 150, 40) + np.testing.assert_array_equal(one_second["data"][0, :, 0], eeg["data"][0, :500]) + np.testing.assert_array_equal(offset["data"][0, :, 0], eeg["data"][0, 250:400]) + + +@eeglab_test( + "unittesting_miscfunc/eeg_regepochs/miscfunc_eeg_regepochs_wrapperTest.m", + "test_pass_general", +) +def test_eeg_regepochs_current_suite_default_and_event_only_workflows(): + eeg = _continuous_eeg(2, 10, 3) + eeg["xmax"] = 3 + eeg["data"] = np.array([[1, 1, 1, 1, 1, 1, 2, 2, 2, 3], [2, 1, 1, 2, 1, 1, 2, 1, 1, 2]]) + + epoched = eeg_regepochs(eeg) + event_only = eeg_regepochs( + eeg, + 1, + [0, 1], + np.nan, + extractepochs="off", + eventdata={"condition": "rest"}, + ) + + assert epoched["data"].shape == (2, 3, 3) + np.testing.assert_array_equal(epoched["data"][:, :, 0], eeg["data"][:, :3]) + assert [event["latency"] for event in event_only["event"]] == [1, 4, 7] + assert [event["urevent"] for event in event_only["event"]] == [0, 1, 2] + assert {event["condition"] for event in event_only["urevent"]} == {"rest"} + + +@eeglab_test( + "unittesting_miscfunc/eeg_time2prev/miscfunc_eeg_time2prev_wrapperTest.m", + "test_pass_general", +) +def test_eeg_time2prev_current_suite_returns_positive_delays_and_zero_based_indices(): + urevents = [ + {"type": "square", "latency": 5}, + {"type": "square", "latency": 8}, + {"type": "rt", "latency": 8.5}, + {"type": "square", "latency": 12}, + {"type": "rt", "latency": 12.4}, + ] + eeg = { + "srate": 1000, + "urevent": urevents, + "event": [{**copy.deepcopy(event), "urevent": index} for index, event in enumerate(urevents)], + } + + delays, targets, urtargets, urprevs = eeg_time2prev(eeg, ["rt"], ["square"]) + + np.testing.assert_allclose(delays, [0.5, 0.4]) + np.testing.assert_array_equal(targets, [2, 4]) + np.testing.assert_array_equal(urtargets, [2, 4]) + np.testing.assert_array_equal(urprevs, [1, 3]) + + no_previous = eeg_time2prev(eeg, ["square"], ["rt"]) + assert no_previous[0][0] == 0 + assert no_previous[3][0] == -1 + + +@eeglab_test( + "unittesting_miscfunc/getipsph/miscfunc_getipsph_wrapperTest.m", + "test_test_getipsph", +) +def test_getipsph_current_suite_full_and_reduced_sphering(): + data = np.random.default_rng(812).standard_normal((10, 1000)) + centered = data - np.mean(data, axis=1, keepdims=True) + + for dimensions in (8, 10, 1): + sphere = getipsph(data, dimensions) + transformed = np.einsum("ij,jk->ik", sphere, centered) + covariance = np.einsum("ij,kj->ik", transformed, transformed) / transformed.shape[1] + assert sphere.shape == (dimensions, 10) + np.testing.assert_allclose(covariance, np.eye(dimensions), atol=1e-11) + + +@eeglab_test( + "unittesting_miscfunc/hist2/miscfunc_hist2_wrapperTest.m", + "test_test_hist2", +) +def test_hist2_current_suite_uses_shared_bin_centers_and_complete_counts(): + figure, axes = plt.subplots() + try: + result = hist2([-2, -0.25, 0.25, 2], [-1, -0.75, 0.75, 1], [-1, 0, 1], ax=axes) + first_counts = [patch.get_height() for patch in axes.containers[0]] + second_counts = [patch.get_height() for patch in axes.containers[1]] + assert result is axes + assert first_counts == [1, 2, 1] + assert second_counts == [2, 0, 2] + assert axes.get_ylabel() == "Number of values" + assert axes.get_xlim() == pytest.approx((-1, 1)) + finally: + plt.close(figure) + + +@eeglab_test( + "unittesting_miscfunc/rmart/miscfunc_rmart_wrapperTest.m", + "test_test_rmart", +) +def test_rmart_current_suite_writes_selected_channels_and_reduces_triggered_eog(tmp_path): + rng = np.random.default_rng(193) + data = rng.standard_normal((3, 320)).astype(np.float32) + data[2] = 0 + data[2, 140:180] = 100 * np.sin(np.linspace(0, np.pi, 40)) + data[0] += 2 * data[2] + source = tmp_path / "test_data.floats" + destination = tmp_path / "test_result.floats" + floatwrite(data, source) + + corrected = rmart(source, destination, 3, [1, 2], 3, 80) + stored = floatread(destination, [2, np.inf]) + + assert corrected.shape == stored.shape == (2, 320) + np.testing.assert_allclose(stored, corrected, rtol=1e-6, atol=1e-6) + centered = data[0] - np.mean(data[0]) + assert np.linalg.norm(corrected[0, 100:180]) < np.linalg.norm(centered[100:180]) * 0.25 + np.testing.assert_allclose(corrected[0, :80], centered[:80], rtol=1e-6, atol=2e-6) + + +@eeglab_test( + "unittesting_miscfunc/rmsave/miscfunc_rmsave_wrapperTest.m", + "test_test_rmsave", +) +def test_rmsave_current_suite_block_shapes_and_values(): + data = np.array( + [ + [2, 5, 3, 6, 7, 2, 6, 8, 1, 2], + [6, 1, 10, 234, 3, 5, 464, 3, 2, 5], + [1, 1, 1, 1, 3, 5, 1, 1, 4, 5], + [4, 23456, 2, 3, 1, 1, 34, 2, 3, 5], + [20, 30, 10, 10, 34, 10, 30, 20, 30, 10], + ] + ) + assert rmsave(data, 10).shape == (5, 1) + assert rmsave(data, 5).shape == (5, 2) + random_data = np.random.default_rng(441).random((32, 100)) + assert rmsave(random_data, 25).shape == (32, 4) + np.testing.assert_allclose( + rmsave(data, 5)[0], + [np.sqrt(np.mean(data[0, :5] ** 2)), np.sqrt(np.mean(data[0, 5:] ** 2))], + ) + + +@eeglab_test( + "unittesting_miscfunc/shortread/miscfunc_shortread_wrapperTest.m", + "test_test_shortread", +) +def test_shortread_current_suite_finite_inferred_and_offset_shapes(tmp_path): + data = np.arange(2000, dtype=np.int16).reshape(20, 100, order="F") + path = tmp_path / "test_short" + np.ravel(data, order="F").tofile(path) + + np.testing.assert_array_equal(shortread(path, [20, 100]), data) + np.testing.assert_array_equal(shortread(path, [20, np.inf]), data) + np.testing.assert_array_equal(shortread(path, [10, 10]), np.arange(100).reshape(10, 10, order="F")) + np.testing.assert_array_equal(shortread(path, [10, 1], offset=1990), np.arange(1990, 2000).reshape(10, 1)) + assert shortread(path, [5, np.inf], offset=20).shape == (5, 396) + + +@eeglab_test( + "unittesting_miscfunc/uniqe_cell_string/miscfunc_uniqe_cell_string_wrapperTest.m", + "test_test_uniqe_cell_string", +) +def test_unique_cell_string_current_suite_ignores_non_strings_and_preserves_order(): + values = [{"name": "haha", "func": "lazy"}, "We ", np.arange(6).reshape(3, 2), 235.3443] + values.extend(["are ", "strings.", [" Not", "including me!"], "are "]) + assert unique_cell_string(values) == ["We ", "are ", "strings."] + + +@eeglab_test( + "unittesting_sigprocfunc/biosig2eeglabevent/sigprocfunc_biosig2eeglabevent_wrapperTest.m", + "test_pass_all_set", +) +def test_biosig2eeglabevent_current_suite_all_fields(): + source = { + "TYP": ["a", "b", "a", "a", "b"], + "POS": [1.0, 2.1, 6.294, 10.2, 42.943], + "DUR": [0.03, 1.02, 3, 0.45, 1.9], + "CHN": ["5", "2", "9", "5", "1"], + } + events = biosig2eeglabevent(source) + assert [event["type"] for event in events] == source["TYP"] + assert [event["latency"] for event in events] == source["POS"] + assert [event["duration"] for event in events] == source["DUR"] + assert [event["chanindex"] for event in events] == source["CHN"] + + +@eeglab_test( + "unittesting_sigprocfunc/biosig2eeglabevent/sigprocfunc_biosig2eeglabevent_wrapperTest.m", + "test_pass_some_set", +) +def test_biosig2eeglabevent_current_suite_partial_fields_and_one_based_interval(): + source = {"POS": [1, 5, 10, 15], "DUR": [0, 8, 4, 2]} + all_events = biosig2eeglabevent(source) + interval_events = biosig2eeglabevent(source, [5, 12]) + + assert [event["latency"] for event in all_events] == source["POS"] + assert [event["duration"] for event in all_events] == source["DUR"] + assert interval_events == [{"latency": 1.0, "duration": 7.0}, {"latency": 6.0, "duration": 2.0}] + + +@eeglab_test( + "unittesting_sigprocfunc/eventalign/sigprocfunc_eventalign_wrapperTest.m", + "test_fail_no_arg", +) +def test_eventalign_current_suite_requires_alignment_inputs(): + with pytest.raises(TypeError): + eventalign() + + +@eeglab_test( + "unittesting_sigprocfunc/eventalign/sigprocfunc_eventalign_wrapperTest.m", + "test_pass_general", +) +def test_eventalign_current_suite_scalar_factor_defaults_to_median(): + assert eventalign(2, [1, 2, 3, 4, 5], [6, 7, 8, 9, 10]) == 0 + assert eventalign([2, 4], [1, 2, 3], [6, 8, 10]) == 0 + + +@eeglab_test( + "unittesting_sigprocfunc/eventalign/sigprocfunc_eventalign_wrapperTest.m", + "test_pass_matrix", +) +def test_eventalign_current_suite_matrix_row_minima(): + first = np.array([[1, 2, 3], [4, 5, 6]]) + second = np.array([[6, 7, 8], [9, 10, 11]]) + assert eventalign(2, first, second) == 1 + assert eventalign(2, first, second, "mean") == 1 + + +@eeglab_test( + "unittesting_sigprocfunc/eventlock/sigprocfunc_eventlock_wrapperTest.m", + "test_pass_general", +) +def test_eventlock_current_suite_multichannel_infers_frames_and_shifts_trials(): + data = np.arange(1, 25).reshape(2, 12) + output, median_value, shifts = eventlock(data, 0, [1, 2, 3, 4], 2) + expected = np.array( + [ + [np.nan, 1, 2, 4, 5, 6, 8, 9, np.nan, 11, 12, np.nan], + [np.nan, 13, 14, 16, 17, 18, 20, 21, np.nan, 23, 24, np.nan], + ] + ) + np.testing.assert_allclose(output, expected, equal_nan=True) + assert median_value == 2 + np.testing.assert_array_equal(shifts, [1, 0, -1, -1]) + + +@eeglab_test( + "unittesting_sigprocfunc/eventlock/sigprocfunc_eventlock_wrapperTest.m", + "test_pass_single", +) +def test_eventlock_current_suite_compact_time_axis_and_endpoint_clamping(): + data = np.arange(1, 25).reshape(4, 6) + output, median_value, shifts = eventlock(data, [-1000, 4, 1], [-2000, -1000, 0, 1000, 2000, 3000]) + expected = np.array( + [ + [np.nan, np.nan, 3, 10, 17, 18], + [1, 2, 9, 16, 23, 24], + [7, 8, 15, 22, np.nan, np.nan], + [13, 14, 21, np.nan, np.nan, np.nan], + ] + ) + np.testing.assert_allclose(output, expected, equal_nan=True) + assert median_value == 0 + np.testing.assert_array_equal(shifts, [1, 1, 0, -1, -2, -2]) From 7ed4d133b37c3029b1c0a594e67d79628da19550 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:50 -0700 Subject: [PATCH 22/45] feat: port legacy signal and text helpers (#350) --- docs/source/api/data_loading_and_saving.rst | 5 + .../api/filtering_and_signal_processing.rst | 4 + docs/source/changelog.rst | 6 + src/eegprep/__init__.py | 9 + src/eegprep/functions/sigprocfunc/blockave.py | 72 ++++ src/eegprep/functions/sigprocfunc/eegfilt.py | 66 +++ src/eegprep/functions/sigprocfunc/env.py | 66 +++ src/eegprep/functions/sigprocfunc/loadeeg.py | 265 ++++++++++++ src/eegprep/functions/sigprocfunc/loadtxt.py | 188 +++++++++ src/eegprep/functions/sigprocfunc/movav.py | 126 ++++++ src/eegprep/functions/sigprocfunc/parsetxt.py | 36 ++ .../functions/sigprocfunc/readneurodat.py | 44 ++ .../functions/sigprocfunc/readtxtfile.py | 14 + src/eegprep/plugins/firfilt/_filtering.py | 39 +- tests/test_legacy_signal_io_eeglab_tests.py | 396 ++++++++++++++++++ 15 files changed, 1334 insertions(+), 2 deletions(-) create mode 100644 src/eegprep/functions/sigprocfunc/blockave.py create mode 100644 src/eegprep/functions/sigprocfunc/eegfilt.py create mode 100644 src/eegprep/functions/sigprocfunc/env.py create mode 100644 src/eegprep/functions/sigprocfunc/loadeeg.py create mode 100644 src/eegprep/functions/sigprocfunc/loadtxt.py create mode 100644 src/eegprep/functions/sigprocfunc/movav.py create mode 100644 src/eegprep/functions/sigprocfunc/parsetxt.py create mode 100644 src/eegprep/functions/sigprocfunc/readneurodat.py create mode 100644 src/eegprep/functions/sigprocfunc/readtxtfile.py create mode 100644 tests/test_legacy_signal_io_eeglab_tests.py diff --git a/docs/source/api/data_loading_and_saving.rst b/docs/source/api/data_loading_and_saving.rst index a09a5577..b485ac42 100644 --- a/docs/source/api/data_loading_and_saving.rst +++ b/docs/source/api/data_loading_and_saving.rst @@ -25,14 +25,19 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.eeg_mergelocs eegprep.floatread eegprep.floatwrite + eegprep.loadeeg + eegprep.loadtxt eegprep.loadset eegprep.getchanlist eegprep.mne2eeg eegprep.mne2eeg_epochs + eegprep.parsetxt eegprep.readeetraklocs eegprep.readegilocs eegprep.readelp eegprep.readlocs + eegprep.readneurodat + eegprep.readtxtfile eegprep.saveset eegprep.snapread eegprep.shortread diff --git a/docs/source/api/filtering_and_signal_processing.rst b/docs/source/api/filtering_and_signal_processing.rst index 1691657e..cf7d2c2c 100644 --- a/docs/source/api/filtering_and_signal_processing.rst +++ b/docs/source/api/filtering_and_signal_processing.rst @@ -8,9 +8,12 @@ FIR filter design, filtering helpers, and window functions. .. autosummary:: :toctree: generated/ + eegprep.blockave eegprep.dftfilt eegprep.dftfilt2 eegprep.dftfilt3 + eegprep.eegfilt + eegprep.env eegprep.fir_filterdcpadded eegprep.firfiltreport eegprep.firfiltsplit @@ -22,6 +25,7 @@ FIR filter design, filtering helpers, and window functions. eegprep.invkaiserbeta eegprep.kaiserbeta eegprep.minphaserceps + eegprep.movav eegprep.plotfresp eegprep.rmart eegprep.rmsave diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index c997e8dd..e5f53f0d 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,12 @@ the `GitHub Releases `_ page. Unreleased ========== +- Added standalone low-level signal and text helpers ``blockave``, ``eegfilt``, + ``env``, ``movav``, ``loadeeg``, ``loadtxt``, ``parsetxt``, + ``readneurodat``, and ``readtxtfile``. Legacy ``eegfilt`` now supports the + even-tap least-squares filters and shortest valid epochs accepted by EEGLAB, + and truncated Neuroscan files retain complete sweeps without returning an + incomplete zero-filled tail. - Regular epoch generation, preceding-event timing, event alignment, BioSig event conversion, sphering, RMS summaries, int16 matrix reads, legacy lagged-regression artifact removal, ordered string selection, and diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 2b24a386..882892b9 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -54,6 +54,7 @@ "assert_extension_entry_point_loads": ("eegprep.extension_testing", "assert_extension_entry_point_loads"), "bids_list_eeg_files": ("eegprep.plugins.EEG_BIDS.bids_list_eeg_files", "bids_list_eeg_files"), "bids_preproc": ("eegprep.plugins.EEG_BIDS.bids_preproc", "bids_preproc"), + "blockave": ("eegprep.functions.sigprocfunc.blockave", "blockave"), "build_safe_install_commands": ("eegprep.extension_catalog", "build_safe_install_commands"), "build_safe_update_commands": ("eegprep.extension_catalog", "build_safe_update_commands"), "biosig2eeglabevent": ( @@ -164,9 +165,11 @@ "eeglab_execmenu": ("eegprep.functions.adminfunc.eeglab_execmenu", "eeglab_execmenu"), "errordlg2": ("eegprep.functions.guifunc.errordlg2", "errordlg2"), "eeglab_clean_artifacts": ("eegprep.functions.adminfunc.eeglabcompat", "clean_artifacts"), + "eegfilt": ("eegprep.functions.sigprocfunc.eegfilt", "eegfilt"), "eegrej": ("eegprep.functions.sigprocfunc.eegrej", "eegrej"), "eegthresh": ("eegprep.functions.sigprocfunc.eegthresh", "eegthresh"), "entropy_rej": ("eegprep.functions.sigprocfunc.entropy_rej", "entropy_rej"), + "env": ("eegprep.functions.sigprocfunc.env", "env"), "epoch": ("eegprep.functions.redefine_functions", "epoch"), "eventalign": ("eegprep.functions.sigprocfunc.eventalign", "eventalign"), "eventlock": ("eegprep.functions.sigprocfunc.eventlock", "eventlock"), @@ -207,9 +210,12 @@ "minphaserceps": ("eegprep.plugins.firfilt.minphaserceps", "minphaserceps"), "load_catalog_entries": ("eegprep.extension_catalog_validation", "load_catalog_entries"), "load_extension_catalog": ("eegprep.extension_catalog", "load_extension_catalog"), + "loadeeg": ("eegprep.functions.sigprocfunc.loadeeg", "loadeeg"), "loadset": ("eegprep.functions.popfunc.pop_loadset", "loadset"), + "loadtxt": ("eegprep.functions.sigprocfunc.loadtxt", "loadtxt"), "mne2eeg": ("eegprep.functions.redefine_functions", "mne2eeg"), "mne2eeg_epochs": ("eegprep.functions.redefine_functions", "mne2eeg_epochs"), + "movav": ("eegprep.functions.sigprocfunc.movav", "movav"), "bootstat": ("eegprep.functions.timefreqfunc.bootstat", "bootstat"), "correct_mc": ("eegprep.functions.timefreqfunc.correct_mc", "correct_mc"), "correctfit": ("eegprep.functions.timefreqfunc.correctfit", "correctfit"), @@ -236,6 +242,7 @@ "vis_artifacts_diagnostics": ("eegprep.plugins.clean_rawdata.vis_artifacts", "vis_artifacts_diagnostics"), "vararg2str": ("eegprep.functions.adminfunc.vararg2str", "vararg2str"), "options": ("eegprep.functions.redefine_functions", "options"), + "parsetxt": ("eegprep.functions.sigprocfunc.parsetxt", "parsetxt"), "picard": ("eegprep.functions.redefine_functions", "picard"), "plugin_menu": ("eegprep.functions.adminfunc.plugin_menu", "plugin_menu"), "plugin_status": ("eegprep.functions.adminfunc.plugin_menu", "plugin_status"), @@ -381,6 +388,8 @@ "readelp": ("eegprep.functions.sigprocfunc.readlocs", "readelp"), "readeetraklocs": ("eegprep.functions.sigprocfunc.readlocs", "readeetraklocs"), "readlocs": ("eegprep.functions.sigprocfunc.readlocs", "readlocs"), + "readneurodat": ("eegprep.functions.sigprocfunc.readneurodat", "readneurodat"), + "readtxtfile": ("eegprep.functions.sigprocfunc.readtxtfile", "readtxtfile"), "realproba": ("eegprep.functions.sigprocfunc.realproba", "realproba"), "rejkurt": ("eegprep.functions.sigprocfunc.rejkurt", "rejkurt"), "rejtrend": ("eegprep.functions.sigprocfunc.rejtrend", "rejtrend"), diff --git a/src/eegprep/functions/sigprocfunc/blockave.py b/src/eegprep/functions/sigprocfunc/blockave.py new file mode 100644 index 00000000..9cef5880 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/blockave.py @@ -0,0 +1,72 @@ +"""Average concatenated, equal-length data epochs.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def blockave(data: Any, frames: int, epochs: Any = None, weights: Any = None) -> np.ndarray: + """Return an epoch average from channel-major concatenated data. + + Args: + data: Array shaped ``(channels, frames * epochs)``. A one-dimensional + input is treated as one channel. + frames: Number of samples in each epoch. + epochs: Optional EEGLAB-facing 1-based epoch indices. Empty or zero + selects every epoch. + weights: Optional weight for every epoch. The selected weights are + normalized to sum to one. + + Returns: + The average shaped ``(channels, frames)``. + """ + array = np.asarray(data) + if array.ndim == 1: + array = array.reshape(1, -1) + if array.ndim != 2: + raise ValueError("data must have shape (channels, frames * epochs)") + if not isinstance(frames, (int, np.integer)) or int(frames) <= 0: + raise ValueError("frames must be a positive integer") + frames = int(frames) + if array.shape[1] % frames: + raise ValueError("frames must divide the data length") + + epoch_count = array.shape[1] // frames + selected = _selected_epochs(epochs, epoch_count) + blocks = array.reshape(array.shape[0], epoch_count, frames)[:, selected, :] + selected_weights = _selected_weights(weights, selected, epoch_count) + return np.average(blocks, axis=1, weights=selected_weights) + + +def _selected_epochs(epochs: Any, epoch_count: int) -> np.ndarray: + if epochs is None: + return np.arange(epoch_count, dtype=int) + values = np.asarray(epochs) + if values.size == 0 or (values.size == 1 and float(values.reshape(-1)[0]) == 0): + return np.arange(epoch_count, dtype=int) + numeric = np.asarray(values, dtype=float).reshape(-1) + if np.any(numeric != np.floor(numeric)): + raise ValueError("epoch indices must be integers") + selected = numeric.astype(int) - 1 + if np.any(selected < 0) or np.any(selected >= epoch_count): + raise ValueError("epoch indices must be 1-based and within the data") + return selected + + +def _selected_weights(weights: Any, selected: np.ndarray, epoch_count: int) -> np.ndarray | None: + if weights is None: + return None + values = np.asarray(weights, dtype=float).reshape(-1) + if values.size == 0 or (values.size == 1 and (values[0] == 0 or np.isnan(values[0]))): + return None + if values.size != epoch_count: + raise ValueError("weights must contain one value per input epoch") + chosen = values[selected] + if not np.all(np.isfinite(chosen)) or np.isclose(np.sum(chosen), 0): + raise ValueError("selected weights must be finite and sum to a non-zero value") + return chosen + + +__all__ = ["blockave"] diff --git a/src/eegprep/functions/sigprocfunc/eegfilt.py b/src/eegprep/functions/sigprocfunc/eegfilt.py new file mode 100644 index 00000000..3cf9a136 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/eegfilt.py @@ -0,0 +1,66 @@ +"""Legacy EEGLAB FIR filtering for channel-major arrays.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.plugins.firfilt._filtering import apply_eegfilt_legacy, design_eegfilt_legacy + + +def eegfilt( + data: Any, + srate: float, + locutoff: float, + hicutoff: float, + epochframes: int | None = None, + filtorder: int | None = None, + revfilt: bool = False, + firtype: str = "firls", + causal: bool = False, +) -> tuple[np.ndarray, np.ndarray]: + """Filter an array using the legacy EEGLAB ``eegfilt`` design. + + Data are shaped ``(channels, frames * epochs)``. Each epoch is filtered + independently when ``epochframes`` is supplied, preventing information + from leaking across trial boundaries. + + Returns: + ``(filtered_data, filter_coefficients)``. + """ + array = np.asarray(data) + if array.ndim == 1: + array = array.reshape(1, -1) + if array.ndim != 2: + raise ValueError("data must have shape (channels, frames * epochs)") + if array.shape[0] > 1 and array.shape[1] == 1: + raise ValueError("input data should be a row vector") + total_frames = array.shape[1] + samples_per_epoch = total_frames if epochframes in (None, 0) else int(epochframes) + if samples_per_epoch <= 0 or total_frames % samples_per_epoch: + raise ValueError("epochframes must be positive and divide the data length") + + coefficients, order = design_eegfilt_legacy( + float(srate), + locutoff=float(locutoff), + hicutoff=float(hicutoff), + filtorder=filtorder, + revfilt=bool(revfilt), + firtype=firtype, + ) + trial_count = total_frames // samples_per_epoch + shaped = array.reshape(array.shape[0], trial_count, samples_per_epoch).transpose(0, 2, 1) + dataset = { + "data": shaped, + "nbchan": array.shape[0], + "pnts": samples_per_epoch, + "trials": trial_count, + "event": [], + } + filtered = apply_eegfilt_legacy(dataset, coefficients, causal=bool(causal), filtorder=order)["data"] + flattened = np.asarray(filtered).transpose(0, 2, 1).reshape(array.shape[0], total_frames) + return flattened, coefficients + + +__all__ = ["eegfilt"] diff --git a/src/eegprep/functions/sigprocfunc/env.py b/src/eegprep/functions/sigprocfunc/env.py new file mode 100644 index 00000000..53ce89bc --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/env.py @@ -0,0 +1,66 @@ +"""Envelope extraction for multichannel signals.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def env(data: Any, timelimits: Any = None, timearray: Any = None) -> np.ndarray: + """Return the sample-wise maximum and minimum across channels. + + When ``timearray`` is supplied, both envelope edges are interpolated from + evenly spaced samples spanning ``timelimits``. + """ + array = np.asarray(data) + if array.ndim == 1: + array = array.reshape(1, -1) + if array.ndim != 2: + raise ValueError("data must have shape (channels, timepoints)") + if array.shape[1] == 0: + return np.empty((2, 0), dtype=np.result_type(array.dtype, float)) + upper = np.max(array, axis=0) + lower = np.min(array, axis=0) + if timearray is None: + return np.vstack([upper, lower]) + limits = np.asarray(timelimits, dtype=float).reshape(-1) + if limits.size != 2: + raise ValueError("timelimits must contain start and end times") + if upper.size > 1 and limits[0] == limits[1]: + raise ValueError("timelimits must span a non-zero interval") + times = np.asarray(timearray, dtype=float) + if times.ndim > 2 or (times.ndim == 2 and min(times.shape) > 1): + raise ValueError("timearray must be a vector") + target = times.reshape(-1) + source = np.linspace(limits[0], limits[1], upper.size) + if upper.size == 1: + interpolated_upper = np.full(target.shape, upper[0], dtype=float) + interpolated_lower = np.full(target.shape, lower[0], dtype=float) + else: + interpolated_upper = _v4_interpolate(source, upper, target) + interpolated_lower = _v4_interpolate(source, lower, target) + envelope_upper = np.maximum(interpolated_upper, interpolated_lower) + envelope_lower = np.minimum(interpolated_upper, interpolated_lower) + return np.vstack([envelope_upper, envelope_lower]) + + +def _v4_interpolate(source: np.ndarray, values: np.ndarray, target: np.ndarray) -> np.ndarray: + """Apply the biharmonic Green's function used by MATLAB griddata v4.""" + source_distance = np.abs(source[:, None] - source[None, :]) + system = _green_function(source_distance) + try: + weights = np.linalg.solve(system, values) + except np.linalg.LinAlgError: + weights = np.linalg.lstsq(system, values, rcond=None)[0] + target_distance = np.abs(target[:, None] - source[None, :]) + return _green_function(target_distance) @ weights + + +def _green_function(distance: np.ndarray) -> np.ndarray: + with np.errstate(divide="ignore", invalid="ignore"): + values = distance**2 * (np.log(distance) - 1.0) + return np.where(np.isfinite(values), values, 0.0) + + +__all__ = ["env"] diff --git a/src/eegprep/functions/sigprocfunc/loadeeg.py b/src/eegprep/functions/sigprocfunc/loadeeg.py new file mode 100644 index 00000000..27104cbc --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/loadeeg.py @@ -0,0 +1,265 @@ +"""Read legacy Neuroscan epoch-oriented ``.eeg`` files.""" + +from __future__ import annotations + +import logging +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np + + +logger = logging.getLogger(__name__) + + +def loadeeg( + filename: str | Path, + chanlist: Any = "all", + triallist: Any = "all", + typerange: Any = "all", + accepttype: Any = "all", + rtrange: Any = "all", + responsetype: Any = "all", + format: str = "auto", +) -> tuple[ + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + list[str], + int, + int, + float, + float, + float, +]: + """Load a Neuroscan epoch file and its per-sweep metadata. + + Channel and trial selections use EEGLAB-facing 1-based indices. Truncated + files return every complete selected sweep and discard the incomplete tail. + + Returns: + ``(signal, accept, types, reaction_times, responses, channel_names, + points, sweeps, sampling_rate, xmin, xmax)``. Selected sweeps are + concatenated along the signal's sample axis. + """ + data_format = str(format).lower() + if data_format not in {"short", "int32", "auto"}: + raise ValueError("format must be 'short', 'int32', or 'auto'") + path = Path(filename) + with path.open("rb") as stream: + if data_format == "auto": + data_format = _detect_format(stream) + header = _read_header(stream) + names, baselines, factors = _read_electrodes(stream, header.channels) + channels = _one_based_indices(chanlist, header.channels, "channel") + selected_trials = _selection_set(triallist, header.sweeps, "trial") + dtype = np.dtype(" str: + original = stream.tell() + try: + stream.seek(12) + raw = stream.read(4) + if len(raw) != 4: + return "short" + next_file = struct.unpack(" _Header: + try: + stream.seek(0) + _read_exact(stream, 20) + _read_exact(stream, 342) + sweeps = _unpack(stream, "H") + _read_exact(stream, 4) + points = _unpack(stream, "H") + channels = _unpack(stream, "H") + _read_exact(stream, 4) + sampling_rate = float(_unpack(stream, "H")) + _read_exact(stream, 127) + xmin = float(_unpack(stream, "f")) + xmax = float(_unpack(stream, "f")) + _read_exact(stream, 387) + except EOFError as exc: + raise ValueError("Neuroscan file ended inside the general header") from exc + if points <= 0 or channels <= 0 or sampling_rate <= 0: + raise ValueError("Neuroscan header contains invalid dimensions or sampling rate") + return _Header(sweeps, points, channels, sampling_rate, xmin, xmax) + + +def _read_electrodes(stream: Any, channel_count: int) -> tuple[list[str], np.ndarray, np.ndarray]: + names: list[str] = [] + baselines = np.empty(channel_count, dtype=float) + factors = np.empty(channel_count, dtype=float) + try: + for channel in range(channel_count): + label = _read_exact(stream, 10).split(b"\x00", 1)[0].decode("latin-1").strip() + _read_exact(stream, 37) + baselines[channel] = _unpack(stream, "H") + _read_exact(stream, 10) + sensitivity = float(_unpack(stream, "f")) + _read_exact(stream, 8) + calibration = float(_unpack(stream, "f")) + names.append(label) + factors[channel] = calibration * sensitivity / 204.8 + except EOFError as exc: + raise ValueError("Neuroscan file ended inside an electrode header") from exc + return names, baselines, factors + + +def _read_sweep_header(stream: Any) -> _SweepHeader | None: + raw = stream.read(13) + if not raw: + return None + if len(raw) != 13: + return None + accept, event_type, _correct, reaction_time, response, _reserved = struct.unpack(" bytes: + value = stream.read(size) + if len(value) != size: + raise EOFError + return value + + +def _unpack(stream: Any, code: str) -> Any: + size = struct.calcsize(code) + return struct.unpack(f"<{code}", _read_exact(stream, size))[0] + + +def _one_based_indices(value: Any, size: int, name: str) -> np.ndarray: + if _is_all(value): + return np.arange(size, dtype=int) + numeric = np.asarray(value, dtype=float).reshape(-1) + if np.any(numeric != np.floor(numeric)): + raise ValueError(f"{name} indices must be integers") + indices = numeric.astype(int) - 1 + if np.any(indices < 0) or np.any(indices >= size): + raise ValueError(f"{name} indices must be 1-based and within the file") + return indices + + +def _selection_set(value: Any, size: int, name: str) -> set[int] | None: + if _is_all(value): + return None + return set((_one_based_indices(value, size, name) + 1).tolist()) + + +def _is_all(value: Any) -> bool: + return isinstance(value, str) and value.lower() == "all" + + +def _selected_sweep( + sweep: int, + metadata: _SweepHeader, + *, + selected_trials: set[int] | None, + typerange: Any, + accepttype: Any, + rtrange: Any, + responsetype: Any, +) -> bool: + return ( + (selected_trials is None or sweep in selected_trials) + and _member(metadata.type, typerange) + and _member(metadata.accept, accepttype) + and _reaction_time_matches(metadata.reaction_time, rtrange) + and _member(metadata.response, responsetype) + ) + + +def _member(value: float, selection: Any) -> bool: + if _is_all(selection): + return True + return bool(np.any(np.asarray(selection, dtype=float).reshape(-1) == value)) + + +def _reaction_time_matches(value: float, selection: Any) -> bool: + if _is_all(selection): + return True + limits = np.asarray(selection, dtype=float).reshape(-1) + if limits.size == 2: + return bool(limits[0] <= value <= limits[1]) + return bool(np.any(limits == value)) + + +__all__ = ["loadeeg"] diff --git a/src/eegprep/functions/sigprocfunc/loadtxt.py b/src/eegprep/functions/sigprocfunc/loadtxt.py new file mode 100644 index 00000000..5eaef3b6 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/loadtxt.py @@ -0,0 +1,188 @@ +"""Load rectangular delimited text while preserving mixed values.""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Any + +import numpy as np + + +logger = logging.getLogger(__name__) + + +def loadtxt( + filename: str | Path, + *args: Any, + convert: str = "on", + skipline: int = 0, + verbose: str = "on", + uniformdelim: str = "off", + blankcell: str = "on", + convertmethod: str = "str2double", + delim: Any = (9, 32), + nlines: int | float | None = None, + **kwargs: Any, +) -> np.ndarray: + """Read an EEGLAB-style numeric or mixed-value text table. + + Positive ``skipline`` counts physical lines. Negative values count only + non-empty lines, which is useful for files whose line endings were expanded + during transfer. ``convert='force'`` returns a numeric, MATLAB-column-major + flattened vector; the other conversion modes return a rectangular table. + """ + options = _options( + args, + kwargs, + convert=convert, + skipline=skipline, + verbose=verbose, + uniformdelim=uniformdelim, + blankcell=blankcell, + convertmethod=convertmethod, + delim=delim, + nlines=nlines, + ) + path = Path(filename) + lines = path.read_text(encoding="utf-8-sig").splitlines() + lines = _skip_lines(lines, int(options["skipline"])) + limit = options["nlines"] + row_limit = None if limit is None or np.isinf(float(limit)) else int(limit) + delimiters = _delimiters(options["delim"]) + keep_blanks = str(options["blankcell"]).lower() == "on" + uniform = str(options["uniformdelim"]).lower() == "on" or not keep_blanks + + rows: list[list[Any]] = [] + for line in lines: + if not line: + continue + tokens = _split_line(line, delimiters, keep_blanks=keep_blanks, uniform=uniform) + if not tokens: + continue + rows.append([_convert_token(token, str(options["convert"]), str(options["convertmethod"])) for token in tokens]) + if row_limit is not None and len(rows) >= row_limit: + break + if str(options["verbose"]).lower() == "on": + logger.info("Read %d non-empty line(s) from %s", len(rows), path) + return _as_array(rows, str(options["convert"])) + + +def _options(args: tuple[Any, ...], kwargs: dict[str, Any], **defaults: Any) -> dict[str, Any]: + if len(args) % 2: + raise ValueError("loadtxt optional arguments must be key/value pairs") + parsed = dict(defaults) + for index in range(0, len(args), 2): + parsed[str(args[index]).lower()] = args[index + 1] + for key, value in kwargs.items(): + parsed[str(key).lower()] = value + allowed = set(defaults) + unknown = set(parsed) - allowed + if unknown: + raise ValueError(f"Unsupported loadtxt option: {sorted(unknown)[0]}") + if str(parsed["convert"]).lower() not in {"on", "off", "force"}: + raise ValueError("convert must be 'on', 'off', or 'force'") + if str(parsed["verbose"]).lower() not in {"on", "off"}: + raise ValueError("verbose must be 'on' or 'off'") + if str(parsed["uniformdelim"]).lower() not in {"on", "off"}: + raise ValueError("uniformdelim must be 'on' or 'off'") + if str(parsed["blankcell"]).lower() not in {"on", "off"}: + raise ValueError("blankcell must be 'on' or 'off'") + if str(parsed["convertmethod"]).lower() not in {"str2double", "str2num"}: + raise ValueError("convertmethod must be 'str2double' or 'str2num'") + skip = float(parsed["skipline"]) + if not skip.is_integer(): + raise ValueError("skipline must be an integer") + if parsed["nlines"] is not None and not np.isinf(float(parsed["nlines"])): + line_count = float(parsed["nlines"]) + if not line_count.is_integer() or line_count < 0: + raise ValueError("nlines must be a non-negative integer") + return parsed + + +def _skip_lines(lines: list[str], count: int) -> list[str]: + if count >= 0: + return lines[count:] + remaining = abs(count) + index = 0 + while index < len(lines) and remaining: + if lines[index] != "": + remaining -= 1 + index += 1 + return lines[index:] + + +def _delimiters(value: Any) -> str: + if isinstance(value, str): + return value + if isinstance(value, (int, float)): + return chr(int(value)) + return "".join(chr(int(item)) if isinstance(item, (int, float)) else str(item) for item in value) + + +def _split_line(line: str, delimiters: str, *, keep_blanks: bool, uniform: bool) -> list[str]: + if not delimiters: + return [line] + if uniform: + return [token for token in re.split(f"[{re.escape(delimiters)}]+", line.strip(delimiters)) if token] + + delimiter_set = set(delimiters) + hard_delimiters = delimiter_set.intersection({"\t", ","}) + soft_delimiters = delimiter_set - hard_delimiters + tokens: list[str] = [] + current: list[str] = [] + previous_hard = False + at_start = True + for character in line: + if character not in delimiter_set: + current.append(character) + previous_hard = False + at_start = False + continue + if character in hard_delimiters: + if current: + tokens.append("".join(current).strip("".join(soft_delimiters))) + current = [] + elif keep_blanks and (previous_hard or at_start): + tokens.append("") + previous_hard = True + at_start = False + elif current: + tokens.append("".join(current)) + current = [] + previous_hard = False + at_start = False + if current: + tokens.append("".join(current)) + elif keep_blanks and previous_hard: + tokens.append("") + return tokens + + +def _convert_token(token: str, convert: str, convertmethod: str) -> Any: + del convertmethod + mode = convert.lower() + if mode == "off": + return token + try: + value = float(token) + except ValueError: + return np.nan if mode == "force" else token + if np.isnan(value) and mode == "on": + return token + return value + + +def _as_array(rows: list[list[Any]], convert: str) -> np.ndarray: + if not rows: + return np.asarray([], dtype=float if convert.lower() == "force" else object) + width = max(len(row) for row in rows) + fill = np.nan if convert.lower() == "force" else "" + padded = [row + [fill] * (width - len(row)) for row in rows] + if convert.lower() == "force": + return np.asarray(padded, dtype=float).reshape(-1, order="F") + return np.asarray(padded, dtype=object) + + +__all__ = ["loadtxt"] diff --git a/src/eegprep/functions/sigprocfunc/movav.py b/src/eegprep/functions/sigprocfunc/movav.py new file mode 100644 index 00000000..51e17358 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/movav.py @@ -0,0 +1,126 @@ +"""Moving averages on regularly or irregularly sampled data.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +NEAR_ZERO = 1e-22 + + +def movav( + data: Any, + xvals: Any = None, + xwidth: float | None = None, + xadv: float | None = None, + firstx: float | None = None, + lastx: float | None = None, + xwin: Any = None, + nonorm: bool = False, +) -> tuple[np.ndarray, np.ndarray]: + """Return moving-window averages and their x-coordinate midpoints. + + ``xvals`` may be irregular. Empty windows repeat the preceding output (or + emit zero for the first window), matching EEGLAB's useful regularization + behavior. A non-scalar ``xwin`` applies windowed rather than rectangular + averaging. + """ + array = np.asarray(data) + if array.ndim == 1: + array = array.reshape(1, -1) + if array.ndim != 2: + raise ValueError("data must have shape (channels, frames)") + if array.shape[1] == 1 and array.shape[0] > 1: + array = array.T + channels, frames = array.shape + if frames < 4: + raise ValueError("data are too short") + + coordinates = _coordinates(xvals, frames) + first = float(np.min(coordinates) if firstx is None else firstx) + last = float(np.max(coordinates) if lastx is None else lastx) + advance = 1.0 if xadv is None or float(xadv) == 0 else float(xadv) + width = (last - first) / 4.0 if xwidth is None or float(xwidth) == 0 else float(xwidth) + if advance <= 0 or width < 0 or last < first: + raise ValueError("xwidth, xadv, firstx, and lastx do not define increasing windows") + window = _window(xwin) + + output_frames = int(np.floor(((last - first + advance + 1.0) - width) / advance)) + output_frames = max(output_frames, 1) + output = np.zeros((channels, output_frames), dtype=np.result_type(array.dtype, float)) + output_x = first + width / 2.0 + np.arange(output_frames, dtype=float) * advance + + low = first + for frame in range(output_frames): + high = low + width + indices = np.flatnonzero((coordinates >= low) & (coordinates < high)) + if indices.size == 0: + if frame: + output[:, frame] = output[:, frame - 1] + elif window is None: + output[:, frame] = _nanmean(array[:, indices], axis=1) + if bool(nonorm): + output[:, frame] *= indices.size + else: + window_advance = (high - low) / window.size + if window_advance <= 0: + raise ValueError("a non-rectangular xwin requires a positive xwidth") + buckets = np.floor((coordinates[indices] - low) / window_advance).astype(int) + buckets = np.clip(buckets, 0, window.size - 1) + selected_weights = window[buckets] + weighted = _nansum(array[:, indices] * selected_weights, axis=1) + weight_sum = float(np.sum(selected_weights)) + output[:, frame] = weighted / weight_sum if abs(weight_sum) > NEAR_ZERO and not nonorm else weighted + low += advance + return output, output_x + + +def _coordinates(xvals: Any, frames: int) -> np.ndarray: + if xvals is None: + return np.arange(1, frames + 1, dtype=float) + values = np.asarray(xvals) + if values.size == 0 or (values.size == 1 and float(values.reshape(-1)[0]) == 0): + return np.arange(1, frames + 1, dtype=float) + if values.ndim > 2 or (values.ndim == 2 and min(values.shape) > 1): + raise ValueError("xvals must be a vector") + coordinates = np.asarray(values, dtype=float).reshape(-1) + if coordinates.size != frames: + raise ValueError("lengths of xvals and data must be equal") + if np.any(np.diff(coordinates) < 0): + raise ValueError("xvals must be increasing") + return coordinates + + +def _window(xwin: Any) -> np.ndarray | None: + if xwin is None: + return None + values = np.asarray(xwin) + if values.size == 0: + return None + if values.ndim > 2 or (values.ndim == 2 and min(values.shape) > 1): + raise ValueError("xwin cannot be a matrix") + window = np.asarray(values, dtype=float).reshape(-1) + if window.size == 1: + if window[0] in {0, 1}: + return None + raise ValueError("xwin must be a vector, zero, or one") + return window + + +def _nanmean(values: np.ndarray, axis: int) -> np.ndarray: + finite = ~np.isnan(values) + count = np.sum(finite, axis=axis) + total = np.sum(np.where(finite, values, 0), axis=axis) + with np.errstate(invalid="ignore", divide="ignore"): + return np.where(count > 0, total / count, np.nan) + + +def _nansum(values: np.ndarray, axis: int) -> np.ndarray: + finite = ~np.isnan(values) + total = np.sum(np.where(finite, values, 0), axis=axis) + return np.where(np.any(finite, axis=axis), total, np.nan) + + +__all__ = ["movav"] diff --git a/src/eegprep/functions/sigprocfunc/parsetxt.py b/src/eegprep/functions/sigprocfunc/parsetxt.py new file mode 100644 index 00000000..678228c9 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/parsetxt.py @@ -0,0 +1,36 @@ +"""Split text using EEGLAB's character-delimiter semantics.""" + +from __future__ import annotations + +from typing import Any + + +DEFAULT_DELIMITERS = " ,\t\"'" + + +def parsetxt(txt: Any, delims: Any = None) -> list[str]: + """Split text at any delimiter character and discard empty tokens.""" + text = str(txt) + delimiters = DEFAULT_DELIMITERS if delims is None else _delimiter_text(delims) + delimiter_set = set(delimiters) + tokens: list[str] = [] + current: list[str] = [] + for character in text: + if character in delimiter_set: + if current: + tokens.append("".join(current)) + current = [] + else: + current.append(character) + if current: + tokens.append("".join(current)) + return tokens + + +def _delimiter_text(delims: Any) -> str: + if isinstance(delims, str): + return delims + return "".join(chr(int(value)) if isinstance(value, (int, float)) else str(value) for value in delims) + + +__all__ = ["parsetxt"] diff --git a/src/eegprep/functions/sigprocfunc/readneurodat.py b/src/eegprep/functions/sigprocfunc/readneurodat.py new file mode 100644 index 00000000..e403ace1 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/readneurodat.py @@ -0,0 +1,44 @@ +"""Read Neuroscan two-dimensional channel-location files.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np + +from eegprep.functions.sigprocfunc.convertlocs import convertlocs +from eegprep.functions.sigprocfunc.loadtxt import loadtxt + + +NEUROSCAN_RADIUS = 513.1617 +NEUROSCAN_EDGE_DEGREES = 44.0 + + +def readneurodat(filename: str | Path) -> tuple[list[dict[str, Any]], list[str], np.ndarray, np.ndarray]: + """Read a Neuroscan ``.dat`` electrode file. + + Returns ``(chanlocs, labels, theta, phi)``. The angular arrays are degrees; + channel locations also include EEGPrep's Cartesian, spherical, and + topographic coordinate fields. + """ + table = loadtxt(filename, verbose="off") + if table.ndim != 2 or table.shape[1] < 4: + raise ValueError("Neuroscan .dat rows must contain index, label, x, and y") + try: + order = np.argsort(np.asarray(table[:, 0], dtype=float), kind="stable") + x = np.asarray(table[order, -2], dtype=float) + y = np.asarray(table[order, -1], dtype=float) + except (TypeError, ValueError) as exc: + raise ValueError("Neuroscan .dat index and coordinates must be numeric") from exc + labels = [str(value) for value in table[order, -3].tolist()] + theta = np.hypot(x, y) / NEUROSCAN_RADIUS * NEUROSCAN_EDGE_DEGREES + phi = np.degrees(np.arctan2(y, x)) + locs = [ + {"labels": label, "sph_theta_besa": float(theta_value), "sph_phi_besa": float(phi_value)} + for label, theta_value, phi_value in zip(labels, theta, phi) + ] + return convertlocs(locs, "sphbesa2all"), labels, theta, phi + + +__all__ = ["readneurodat"] diff --git a/src/eegprep/functions/sigprocfunc/readtxtfile.py b/src/eegprep/functions/sigprocfunc/readtxtfile.py new file mode 100644 index 00000000..453b8179 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/readtxtfile.py @@ -0,0 +1,14 @@ +"""Read a text file using EEGLAB's line-concatenation convention.""" + +from __future__ import annotations + +from pathlib import Path + + +def readtxtfile(filename: str | Path) -> str: + """Return file contents with one leading newline and normalized lines.""" + lines = Path(filename).read_text(encoding="utf-8-sig").splitlines() + return "".join(f"\n{line}" for line in lines) + + +__all__ = ["readtxtfile"] diff --git a/src/eegprep/plugins/firfilt/_filtering.py b/src/eegprep/plugins/firfilt/_filtering.py index 20300af0..4a97630f 100644 --- a/src/eegprep/plugins/firfilt/_filtering.py +++ b/src/eegprep/plugins/firfilt/_filtering.py @@ -133,7 +133,7 @@ def design_eegfilt_legacy( bands, desired = _legacy_firls_shape(nyquist, locutoff, hicutoff, trans) if revfilt: desired = [1.0 - value for value in desired] - return np.asarray(firls(order + 1, bands, desired, fs=2.0), dtype=float), order + return _legacy_firls(order + 1, bands, desired), order def design_firws( @@ -327,7 +327,11 @@ def _filter_segment(b: np.ndarray, segment: np.ndarray, *, causal: bool, usefftf def _legacy_filter_segment(b: np.ndarray, segment: np.ndarray, *, causal: bool) -> np.ndarray: if causal: return lfilter(b, [1.0], segment, axis=1) - return filtfilt(b, [1.0], segment, axis=1) + # MATLAB filtfilt and eegfilt require three times the filter *order*; + # SciPy's default uses three times the coefficient count instead. Pass the + # MATLAB-compatible edge length explicitly so the shortest valid EEGLAB + # epochs are accepted. + return filtfilt(b, [1.0], segment, axis=1, padlen=3 * (b.size - 1)) def _continuous_bounds(EEG: dict[str, Any], pnts: int) -> np.ndarray: @@ -414,6 +418,37 @@ def _legacy_firls_shape( return bands, desired +def _legacy_firls(numtaps: int, bands: list[float], desired: list[float]) -> np.ndarray: + """Design MATLAB-compatible type-I or type-II least-squares coefficients.""" + if numtaps % 2: + return np.asarray(firls(numtaps, bands, desired, fs=2.0), dtype=float) + + band_pairs = np.asarray(bands, dtype=float).reshape(-1, 2) + desired_pairs = np.asarray(desired, dtype=float).reshape(-1, 2) + half = numtaps // 2 + + # A symmetric even-length FIR has the zero-phase response + # sum(a[k] * cos((k + 1/2) * omega)). Integrate that basis exactly + # over each piecewise-linear desired band, as MATLAB firls does. + integer_orders = np.arange(2 * half, dtype=float)[:, None, None] + integrals = np.diff(np.sinc(band_pairs * integer_orders) * band_pairs, axis=2)[:, :, 0] + q = np.sum(integrals, axis=1) + indices = np.arange(half) + gram = 0.5 * (q[np.abs(indices[:, None] - indices[None, :])] + q[indices[:, None] + indices[None, :] + 1]) + + cosine_orders = (np.arange(half, dtype=float) + 0.5)[:, None, None] + slopes = np.diff(desired_pairs, axis=1) / np.diff(band_pairs, axis=1) + intercepts = desired_pairs[:, [0]] - band_pairs[:, [0]] * slopes + projection = band_pairs * (slopes * band_pairs + intercepts) * np.sinc(band_pairs * cosine_orders) + projection += slopes * np.cos(cosine_orders * np.pi * band_pairs) / (np.pi * cosine_orders) ** 2 + target = np.sum(np.diff(projection, axis=2)[:, :, 0], axis=1) + try: + amplitudes = np.linalg.solve(gram, target) + except np.linalg.LinAlgError: + amplitudes = np.linalg.lstsq(gram, target, rcond=None)[0] + return np.concatenate([amplitudes[::-1], amplitudes]) / 2.0 + + def _legacy_fir1(order: int, srate: float, locutoff: float, hicutoff: float) -> np.ndarray: if locutoff > 0 and hicutoff > 0: return np.asarray(firwin(order + 1, [locutoff, hicutoff], pass_zero=False, fs=srate), dtype=float) diff --git a/tests/test_legacy_signal_io_eeglab_tests.py b/tests/test_legacy_signal_io_eeglab_tests.py new file mode 100644 index 00000000..7a7db5aa --- /dev/null +++ b/tests/test_legacy_signal_io_eeglab_tests.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +import logging +import struct +from pathlib import Path + +import numpy as np +import pytest +from scipy.signal import freqz + +from eegprep import blockave, eegfilt, env, loadeeg, loadtxt, movav, parsetxt, readneurodat, readtxtfile +from tests.eeglab_tests import eeglab_test + + +SIGPROC = "unittesting_sigprocfunc" + + +@eeglab_test(f"{SIGPROC}/blockave/sigprocfunc_blockave_wrapperTest.m", "test_pass_equal_weights") +def test_blockave_equal_weights_matches_current_suite() -> None: + data = np.asarray([[-3, 0.5, 1, -3.9, 0.5, -1.1], [2, 2, 3, -1.5, 4, 4]]) + + result = blockave(data, 2) + + np.testing.assert_allclose(result, [[-0.5, -1.5], [3, 1.5]]) + np.testing.assert_allclose(blockave(data, 2, epochs=[1, 3], weights=[1, 100, 3]), [[-0.375, -0.7], [3.5, 3.5]]) + + +@eeglab_test(f"{SIGPROC}/eegfilt/sigprocfunc_eegfilt_wrapperTest.m", "test_pass_general") +def test_eegfilt_general_case_designs_and_applies_legacy_bandpass() -> None: + data = np.asarray( + [ + [1, *([0] * 47)], + [value for number in range(1, 25) for value in (number, 0)], + ], + dtype=float, + ) + + filtered, coefficients = eegfilt(data, 1, 0.17, 0.4) + + assert filtered.shape == data.shape + expected_coefficients = np.asarray( + [ + -0.013087039375293805, + -0.04027022359539272, + 0.07703267043672658, + 0.033350774093545726, + 0.03718929923999598, + -0.03170244161332772, + -0.3677328893223719, + 0.31398418218061297, + ] + ) + expected_coefficients = np.r_[expected_coefficients, expected_coefficients[::-1]] + np.testing.assert_allclose(coefficients, expected_coefficients, rtol=1e-12, atol=1e-12) + frequencies, response = freqz(coefficients, worN=8192, fs=1) + pass_gain = abs(response[np.argmin(abs(frequencies - 0.25))]) + stop_gain = abs(response[np.argmin(abs(frequencies - 0.05))]) + assert pass_gain > 5 * stop_gain + assert np.all(np.isfinite(filtered)) + assert not np.allclose(filtered, data) + + +@eeglab_test(f"{SIGPROC}/env/sigprocfunc_env_wrapperTest.m", "test_pass_general") +def test_env_general_case_matches_current_suite() -> None: + data = np.asarray([[2, 1, 3], [8, 2, 6], [4, 7, 3], [0, 1, -3], [-5, 3, 1]]) + + np.testing.assert_allclose(env(data), [[8, 7, 6], [-5, 1, -3]]) + + +@eeglab_test(f"{SIGPROC}/env/sigprocfunc_env_wrapperTest.m", "test_pass_interpolate") +def test_env_interpolation_preserves_current_suite_anchors_and_envelope_order() -> None: + data = np.asarray([[2, 1, 3], [8, 2, 6], [4, 7, 3], [0, 1, -3], [-5, 3, 1]]) + + result = env(data, [1, 3], np.arange(1, 3.1, 0.5)) + + expected = np.asarray([[8, 8.05305535, 7, 6.56306299, 6], [-5, -1.8174217, 1, -0.32742934, -3]]) + np.testing.assert_allclose(result, expected, atol=5e-9) + assert np.all(result[0] >= result[1]) + + +def _assert_movav(data: np.ndarray, expected_data: np.ndarray, expected_x: np.ndarray, **kwargs) -> None: + result, result_x = movav(data, **kwargs) + expected = np.asarray(expected_data) + if result.shape[1] == 1 and expected.ndim == 1: + expected = expected[:, None] + else: + expected = np.atleast_2d(expected) + np.testing.assert_allclose(result, expected) + np.testing.assert_allclose(result_x, np.atleast_1d(expected_x)) + + +@eeglab_test(f"{SIGPROC}/movav/sigprocfunc_movav_wrapperTest.m", "test_pass_data_column_vector") +def test_movav_column_vector_matches_current_suite() -> None: + _assert_movav(np.asarray([[1], [2], [3], [0], [5], [-6]]), [1.5, 2.5, 1.5, 2.5, -0.5], 0.625 + np.arange(1, 6)) + + +@eeglab_test(f"{SIGPROC}/movav/sigprocfunc_movav_wrapperTest.m", "test_pass_five_frames") +def test_movav_five_frames_matches_current_suite() -> None: + data = np.asarray([[1, 2, 5, 3, 2], np.arange(1, 6), [-2, 0, 4, -6, 3]]) + _assert_movav(data, data, 0.5 + np.arange(1, 6)) + + +@eeglab_test(f"{SIGPROC}/movav/sigprocfunc_movav_wrapperTest.m", "test_pass_four_frames") +def test_movav_four_frames_matches_current_suite() -> None: + data = np.asarray([[1, 2, 5, 3], [1, 2, 3, 4], [-2, 0, 4, -6]]) + _assert_movav(data, data, 0.375 + np.arange(1, 5)) + + +@eeglab_test(f"{SIGPROC}/movav/sigprocfunc_movav_wrapperTest.m", "test_pass_general") +def test_movav_general_case_matches_current_suite() -> None: + data = np.asarray([[1, 2, 5, 3, 2, -1], np.arange(1, 7), [-2, 0, 4, -6, 3, 1]]) + expected = np.asarray([[1.5, 3.5, 4, 2.5, 0.5], [1.5, 2.5, 3.5, 4.5, 5.5], [-1, 2, -1, -1.5, 2]]) + _assert_movav(data, expected, 0.625 + np.arange(1, 6)) + + +@eeglab_test(f"{SIGPROC}/movav/sigprocfunc_movav_wrapperTest.m", "test_pass_i_empty") +def test_movav_empty_windows_match_current_suite_replication() -> None: + data = np.asarray([[1, 2, 5, 3, 2, -1], np.arange(1, 7), [-2, 0, 4, -6, 3, 1]]) + expected = np.asarray([[0, 0, 1, 1.5, 2.2], [0, 0, 1, 1.5, 4], [0, 0, -2, -1, 0.4]]) + _assert_movav(data, expected, 0.625 + np.arange(1, 6), xvals=[4, 5, 6, 6, 6, 6], firstx=1, lastx=6) + + +@eeglab_test(f"{SIGPROC}/movav/sigprocfunc_movav_wrapperTest.m", "test_pass_nonorm_one") +def test_movav_non_normalized_sums_match_current_suite() -> None: + data = np.asarray([[1, 2, 5, 3, 2, -1], np.arange(1, 7), [-2, 0, 4, -6, 3, 1]]) + expected = np.asarray([[3, 7, 8, 5, 1], [3, 5, 7, 9, 11], [-2, 4, -2, -3, 4]]) + _assert_movav(data, expected, 0.625 + np.arange(1, 6), nonorm=True) + + +@eeglab_test(f"{SIGPROC}/movav/sigprocfunc_movav_wrapperTest.m", "test_pass_xwidth_high") +def test_movav_oversize_width_matches_current_suite() -> None: + data = np.asarray([[1, 2, 5, 3, 2, -1], np.arange(1, 7), [-2, 0, 4, -6, 3, 1]]) + _assert_movav(data, [2, 3.5, 0], [4.5], xvals=0, xwidth=7) + + +@eeglab_test(f"{SIGPROC}/movav/sigprocfunc_movav_wrapperTest.m", "test_pass_xwidth_low") +def test_movav_two_sample_width_matches_current_suite() -> None: + data = np.asarray([[1, 2, 5, 3, 2, -1, 0, 1, 2, -20], np.arange(1, 11), [-2, 0, 4, -6, 3, 1, -32, 5, 7, 18]]) + expected = np.asarray( + [ + [1.5, 3.5, 4, 2.5, 0.5, -0.5, 0.5, 1.5, -9], + [1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5], + [-1, 2, -1, -1.5, 2, -15.5, -13.5, 6, 12.5], + ] + ) + _assert_movav(data, expected, np.arange(2, 11), xvals=0, xwidth=2) + + +@eeglab_test(f"{SIGPROC}/movav/sigprocfunc_movav_wrapperTest.m", "test_pass_xwin_column") +def test_movav_column_window_matches_current_suite() -> None: + _assert_movav( + np.arange(1, 7), np.asarray([5, 9, 13, 17, 21]) / 4, 0.625 + np.arange(1, 6), xwin=np.asarray([[3], [1]]) + ) + + +@eeglab_test(f"{SIGPROC}/movav/sigprocfunc_movav_wrapperTest.m", "test_pass_xwin_near_zero") +def test_movav_zero_sum_window_matches_current_suite() -> None: + _assert_movav(np.arange(1, 7), np.full(5, -3), 0.625 + np.arange(1, 6), xwin=np.asarray([[3], [-3]])) + + +def _numeric_text(rows: int = 5, columns: int = 6) -> str: + values = np.arange(1, rows * columns + 1).reshape(rows, columns) + return "\n".join(" ".join(str(value) for value in row) for row in values) + "\n" + + +@eeglab_test(f"{SIGPROC}/loadtxt/sigprocfunc_loadtxt_wrapperTest.m", "test_pass_convert_force") +def test_loadtxt_force_conversion_matches_current_suite_column_order(tmp_path: Path) -> None: + source = tmp_path / "convert_force.txt" + source.write_text(_numeric_text(), encoding="utf-8") + result = loadtxt(source, "convert", "force", verbose="off") + np.testing.assert_allclose(result, np.arange(1, 31).reshape(5, 6).reshape(-1, order="F")) + + +@eeglab_test(f"{SIGPROC}/loadtxt/sigprocfunc_loadtxt_wrapperTest.m", "test_pass_convert_off") +def test_loadtxt_conversion_off_matches_current_suite_strings(tmp_path: Path) -> None: + source = tmp_path / "convert_off.txt" + source.write_text(_numeric_text(), encoding="utf-8") + result = loadtxt(source, "convert", "off", verbose="off") + np.testing.assert_array_equal(result, np.arange(1, 31).astype(str).reshape(5, 6)) + + +@eeglab_test(f"{SIGPROC}/loadtxt/sigprocfunc_loadtxt_wrapperTest.m", "test_pass_general") +def test_loadtxt_general_numeric_cells_match_current_suite(tmp_path: Path) -> None: + source = tmp_path / "general.txt" + source.write_text(_numeric_text(), encoding="utf-8") + result = loadtxt(source, verbose="off") + np.testing.assert_allclose(np.asarray(result, dtype=float), np.arange(1, 31).reshape(5, 6)) + + +@eeglab_test(f"{SIGPROC}/loadtxt/sigprocfunc_loadtxt_wrapperTest.m", "test_pass_negative_skipline") +def test_loadtxt_negative_skipline_counts_only_nonempty_lines(tmp_path: Path) -> None: + source = tmp_path / "negative_skipline.txt" + source.write_text("this\n\nlines\n\nare\n\nempty\n\n" + _numeric_text(), encoding="utf-8") + result = loadtxt(source, "skipline", -4, verbose="off") + np.testing.assert_allclose(np.asarray(result, dtype=float), np.arange(1, 31).reshape(5, 6)) + + +@eeglab_test(f"{SIGPROC}/loadtxt/sigprocfunc_loadtxt_wrapperTest.m", "test_pass_nlines") +def test_loadtxt_nlines_limits_nonempty_rows(tmp_path: Path) -> None: + source = tmp_path / "nlines.txt" + source.write_text(_numeric_text(), encoding="utf-8") + result = loadtxt(source, "nlines", 3, verbose="off") + np.testing.assert_allclose(np.asarray(result, dtype=float), np.arange(1, 19).reshape(3, 6)) + + +@eeglab_test(f"{SIGPROC}/loadtxt/sigprocfunc_loadtxt_wrapperTest.m", "test_pass_skipline") +def test_loadtxt_positive_skipline_counts_physical_lines(tmp_path: Path) -> None: + source = tmp_path / "skipline.txt" + source.write_text("\n\n\n\n" + _numeric_text(), encoding="utf-8") + result = loadtxt(source, "skipline", 4, verbose="off") + np.testing.assert_allclose(np.asarray(result, dtype=float), np.arange(1, 31).reshape(5, 6)) + + +@eeglab_test(f"{SIGPROC}/loadtxt/sigprocfunc_loadtxt_wrapperTest.m", "test_pass_text") +def test_loadtxt_mixed_text_and_numbers_match_current_suite(tmp_path: Path) -> None: + source = tmp_path / "text.txt" + source.write_text(_numeric_text().replace("3", "three", 1), encoding="utf-8") + result = loadtxt(source, verbose="off") + assert result.shape == (5, 6) + assert result[0, 2] == "three" + assert result[4, 5] == 30.0 + + +@eeglab_test(f"{SIGPROC}/loadtxt/sigprocfunc_loadtxt_wrapperTest.m", "test_pass_verbose") +def test_loadtxt_verbose_mode_reports_and_returns_current_suite_table( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + source = tmp_path / "verbose.txt" + source.write_text(_numeric_text(rows=11, columns=3), encoding="utf-8") + with caplog.at_level(logging.INFO, logger="eegprep.functions.sigprocfunc.loadtxt"): + result = loadtxt(source, verbose="on") + np.testing.assert_allclose(np.asarray(result, dtype=float), np.arange(1, 34).reshape(11, 3)) + assert "Read 11 non-empty line(s)" in caplog.text + + +@eeglab_test(f"{SIGPROC}/parsetxt/sigprocfunc_parsetxt_wrapperTest.m", "test_pass_custom_delim") +def test_parsetxt_custom_delimiters_match_current_suite() -> None: + assert parsetxt('1:3.c : "a"..}::{ ', ".:") == ["1", "3", "c ", ' "a"', "}", "{ "] + + +@eeglab_test(f"{SIGPROC}/parsetxt/sigprocfunc_parsetxt_wrapperTest.m", "test_pass_general") +def test_parsetxt_general_case_matches_current_suite() -> None: + assert parsetxt(' Hello, , my dear "friend".It\'s great! ') == [ + "Hello", + "my", + "dear", + "friend", + ".It", + "s", + "great!", + ] + + +@eeglab_test(f"{SIGPROC}/parsetxt/sigprocfunc_parsetxt_wrapperTest.m", "test_pass_only_delim") +def test_parsetxt_delimiter_only_input_matches_current_suite() -> None: + assert parsetxt(" , \" ' \t,' , ") == [] + + +@eeglab_test(f"{SIGPROC}/readneurodat/sigprocfunc_readneurodat_wrapperTest.m", "test_pass_general") +def test_readneurodat_labels_and_coordinates_match_current_suite(tmp_path: Path) -> None: + source = tmp_path / "test.dat" + source.write_text( + "1 FP1 -296.703 826.087\n5 FP2 -10.989 826.087\n4 FP3 296.703 826.087\n" + "2 F7A -450.549 704.348\n3 F3A -252.747 652.174\n", + encoding="utf-8", + ) + locs, labels, theta, phi = readneurodat(source) + assert labels == ["FP1", "F7A", "F3A", "FP3", "FP2"] + assert [loc["labels"] for loc in locs] == labels + np.testing.assert_allclose(np.abs([loc["sph_theta_besa"] for loc in locs]), theta) + np.testing.assert_allclose([loc["sph_phi_besa"] for loc in locs], (phi + 90) % 180 - 90) + assert all({"X", "Y", "Z", "theta", "radius"} <= set(loc) for loc in locs) + + +def _write_neuroscan_eeg( + path: Path, *, truncate_second: bool = False, dtype: str = "short" +) -> tuple[np.ndarray, np.ndarray]: + header = bytearray(900) + header[:8] = b"VERSION3" + struct.pack_into(" None: + source = tmp_path / "bugzilla_456.eeg" + first, _second = _write_neuroscan_eeg(source, truncate_second=True) + with caplog.at_level(logging.WARNING, logger="eegprep.functions.sigprocfunc.loadeeg"): + result = loadeeg(source, format="short") + signal, accept, types, reaction_times, responses, names, points, sweeps, srate, xmin, xmax = result + factors = np.asarray([2.0 / 204.8, 8.0 / 204.8]) + expected = (first - np.asarray([100, 200])[:, None]) * factors[:, None] + np.testing.assert_allclose(signal, expected) + np.testing.assert_array_equal(accept, [1]) + np.testing.assert_array_equal(types, [11]) + np.testing.assert_allclose(reaction_times, [350]) + np.testing.assert_array_equal(responses, [21]) + assert names == ["Fz", "Cz"] + assert (points, sweeps, srate) == (3, 1, 250) + assert xmin == pytest.approx(-0.1) + assert xmax == pytest.approx(0.1) + assert "incomplete data were discarded" in caplog.text + + +@eeglab_test(f"{SIGPROC}/readtxtfile/sigprocfunc_readtxtfile_wrapperTest.m", "test_test_readtxtfile") +def test_readtxtfile_reads_current_suite_location_file_variants(tmp_path: Path) -> None: + paths = [tmp_path / "cap33.ced", tmp_path / "cap25.locs", tmp_path / "chan32.locs"] + contents = ["Number\tlabels\n1\tFz\n", "1 0.0 0.5 Fz\r\n2 0.0 0.0 Cz\r\n", "1 -18 0.35 Fp1"] + for path, content in zip(paths, contents): + path.write_bytes(content.encode("utf-8")) + assert [readtxtfile(path) for path in paths] == [ + "\nNumber\tlabels\n1\tFz", + "\n1 0.0 0.5 Fz\n2 0.0 0.0 Cz", + "\n1 -18 0.35 Fp1", + ] + + +def test_loadeeg_selects_one_based_channels_and_sweep_metadata(tmp_path: Path) -> None: + source = tmp_path / "complete.eeg" + _first, second = _write_neuroscan_eeg(source) + signal, accept, types, reaction_times, responses, names, _points, sweeps, *_ = loadeeg( + source, + chanlist=[2], + triallist=[2], + typerange=[12], + accepttype=[0], + rtrange=[400, 500], + responsetype=[22], + format="short", + ) + np.testing.assert_allclose(signal, (second[[1]] - 200) * (8.0 / 204.8)) + np.testing.assert_array_equal(accept, [0]) + np.testing.assert_array_equal(types, [12]) + np.testing.assert_allclose(reaction_times, [450]) + np.testing.assert_array_equal(responses, [22]) + assert names == ["Cz"] + assert sweeps == 1 + + +def test_loadeeg_auto_detects_32_bit_neuroscan_samples(tmp_path: Path) -> None: + source = tmp_path / "int32.eeg" + first, second = _write_neuroscan_eeg(source, dtype="int32") + signal, _accept, _types, _reaction_times, _responses, _names, _points, sweeps, *_ = loadeeg(source) + raw = np.concatenate([first, second], axis=1) + expected = (raw - np.asarray([100, 200])[:, None]) * np.asarray([2.0 / 204.8, 8.0 / 204.8])[:, None] + np.testing.assert_allclose(signal, expected) + assert sweeps == 2 + + +def test_loadtxt_preserves_tab_and_comma_blank_cells(tmp_path: Path) -> None: + source = tmp_path / "blanks.csv" + source.write_text("a,,c\n1,2,\n", encoding="utf-8") + table = loadtxt(source, delim=",", convert="off", verbose="off") + np.testing.assert_array_equal(table, [["a", "", "c"], ["1", "2", ""]]) + + +def test_eegfilt_filters_epochs_independently() -> None: + first = np.zeros(80) + second = np.zeros(80) + first[-1] = 100 + filtered, _coefficients = eegfilt(np.r_[first, second], 100, 0, 20, epochframes=80, filtorder=20) + assert filtered.shape == (1, 160) + np.testing.assert_allclose(filtered[0, 80:], 0, atol=1e-12) + + +def test_movav_ignores_nans_but_preserves_all_nan_windows() -> None: + data = np.asarray([[1, np.nan, 3, 4], [np.nan, np.nan, 2, 2]]) + result, _x = movav(data, xwidth=2) + assert result[0, 0] == pytest.approx(1) + assert np.isnan(result[1, 0]) From bd98f53609701db777ce76c6dc1c4a2d431a9d80 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:51 -0700 Subject: [PATCH 23/45] feat: complete mmo memmap parity (#351) --- docs/source/api/data_loading_and_saving.rst | 2 + docs/source/changelog.rst | 5 + docs/source/user_guide/storage.rst | 25 + src/eegprep/__init__.py | 2 + src/eegprep/functions/adminfunc/storage.py | 352 ++++++++++++- src/eegprep/functions/popfunc/eeg_eegrej.py | 4 +- src/eegprep/functions/popfunc/pop_epoch.py | 3 + src/eegprep/functions/popfunc/pop_reref.py | 3 + src/eegprep/functions/popfunc/pop_resample.py | 7 +- src/eegprep/functions/popfunc/pop_rmbase.py | 6 +- src/eegprep/functions/popfunc/pop_select.py | 3 + src/eegprep/plugins/firfilt/_filtering.py | 9 +- tests/test_mmo_eeglab_tests.py | 469 ++++++++++++++++++ 13 files changed, 871 insertions(+), 19 deletions(-) create mode 100644 tests/test_mmo_eeglab_tests.py diff --git a/docs/source/api/data_loading_and_saving.rst b/docs/source/api/data_loading_and_saving.rst index b485ac42..2cbadc6b 100644 --- a/docs/source/api/data_loading_and_saving.rst +++ b/docs/source/api/data_loading_and_saving.rst @@ -28,6 +28,8 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.loadeeg eegprep.loadtxt eegprep.loadset + eegprep.MemmapData + eegprep.mmo eegprep.getchanlist eegprep.mne2eeg eegprep.mne2eeg_epochs diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index e5f53f0d..20057196 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,11 @@ the `GitHub Releases `_ page. Unreleased ========== +- ``MemmapData`` now covers current EEGLAB ``mmo`` construction, logical + indexing over normal and transposed files, copy-on-write mutation, resizing, + and deletion. Disk-backed EEG data remains disk-backed through rejection, + epoching, baseline removal, FIR filtering, rereferencing, selection, and + resampling without modifying the source mapping. - Added standalone low-level signal and text helpers ``blockave``, ``eegfilt``, ``env``, ``movav``, ``loadeeg``, ``loadtxt``, ``parsetxt``, ``readneurodat``, and ``readtxtfile``. Legacy ``eegfilt`` now supports the diff --git a/docs/source/user_guide/storage.rst b/docs/source/user_guide/storage.rst index 185a0e88..bf230178 100644 --- a/docs/source/user_guide/storage.rst +++ b/docs/source/user_guide/storage.rst @@ -55,6 +55,31 @@ separate data file exists to map. Mutating a ``MemmapData`` value writes to the ``.fdt`` sidecar; use normal EEGPrep save/history workflows when the dataset metadata should be marked clean. +``MemmapData`` uses zero-based NumPy indices against the logical +``(channels, samples[, trials])`` shape. Copies made with ``copy.copy`` or +``copy.deepcopy`` share the sidecar until one copy is written, then use a +private copy-on-write sidecar so processing a copied EEG does not mutate its +source. ``resize`` retains the overlapping region and fills new samples with +zeros by default; ``delete`` removes indices along a selected logical axis. + +EEGPrep preprocessing keeps disk-backed input disk-backed through continuous +rejection, epoch extraction, baseline removal, FIR filtering, rereferencing, +selection, and resampling. Each result gets its own writable temporary mapping, +while the original dataset and sidecar remain unchanged. + +For direct construction, ``mmo`` is the compact EEGLAB-compatible entry point: + +.. code-block:: python + + from eegprep import mmo + + data = mmo("subject01.fdt", (64, 30000), writable=True) + blank = mmo(None, (64, 30000)) + +Pass ``transposed=True`` for files physically stored as +``(samples, trials, channels)``. Indexing still uses the logical channels-first +shape; storage orientation never changes the public shape or index order. + Storedisk Sessions ================== diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 882892b9..49fd43d9 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -51,6 +51,7 @@ "ExtensionValidationResult": ("eegprep.extensions", "ExtensionValidationResult"), "ICL_feature_extractor": ("eegprep.plugins.ICLabel.ICL_feature_extractor", "ICL_feature_extractor"), "LazyImport": ("eegprep.extensions", "LazyImport"), + "MemmapData": ("eegprep.functions.adminfunc.storage", "MemmapData"), "assert_extension_entry_point_loads": ("eegprep.extension_testing", "assert_extension_entry_point_loads"), "bids_list_eeg_files": ("eegprep.plugins.EEG_BIDS.bids_list_eeg_files", "bids_list_eeg_files"), "bids_preproc": ("eegprep.plugins.EEG_BIDS.bids_preproc", "bids_preproc"), @@ -215,6 +216,7 @@ "loadtxt": ("eegprep.functions.sigprocfunc.loadtxt", "loadtxt"), "mne2eeg": ("eegprep.functions.redefine_functions", "mne2eeg"), "mne2eeg_epochs": ("eegprep.functions.redefine_functions", "mne2eeg_epochs"), + "mmo": ("eegprep.functions.adminfunc.storage", "mmo"), "movav": ("eegprep.functions.sigprocfunc.movav", "movav"), "bootstat": ("eegprep.functions.timefreqfunc.bootstat", "bootstat"), "correct_mc": ("eegprep.functions.timefreqfunc.correct_mc", "correct_mc"), diff --git a/src/eegprep/functions/adminfunc/storage.py b/src/eegprep/functions/adminfunc/storage.py index 9933d31e..08c6af02 100644 --- a/src/eegprep/functions/adminfunc/storage.py +++ b/src/eegprep/functions/adminfunc/storage.py @@ -4,6 +4,8 @@ from copy import deepcopy from pathlib import Path +import shutil +import tempfile from typing import Any import numpy as np @@ -13,8 +15,32 @@ FDT_DTYPE = np.dtype(" None: + self.path = path + self.temporary = temporary + self.references = 0 + + def acquire(self) -> None: + self.references += 1 + + def release(self) -> None: + self.references -= 1 + if self.references == 0 and self.temporary: + self.path.unlink(missing_ok=True) + + class MemmapData: - """NumPy-compatible handle for EEGLAB ``.fdt`` data stored on disk.""" + """NumPy-compatible handle for channel-major EEG data stored on disk. + + Indexing always uses the logical EEG shape and normal zero-based NumPy + indices. ``transposed=True`` maps EEGLAB ``.dat``-style storage whose + physical axes are ``(samples, trials, channels)`` without exposing that + layout to callers. Explicit and deep copies share the backing file until a + copy is mutated, at which point that handle gets a private temporary file. + """ __array_priority__ = 1000 @@ -26,19 +52,123 @@ def __init__( dtype: np.dtype | str = FDT_DTYPE, mode: str = "r+", order: str = "F", + transposed: bool = False, + temporary: bool = False, + debug: bool = False, + _backing: _BackingFile | None = None, ) -> None: - self.path = Path(filename) self._shape = tuple(int(item) for item in shape) + if not self._shape or any(item <= 0 for item in self._shape): + raise ValueError("Memory-mapped data dimensions must be positive") self._dtype = np.dtype(dtype) + if mode not in {"r", "r+", "c"}: + raise ValueError("MemmapData mode must be 'r', 'r+', or 'c'") + if order not in {"C", "F"}: + raise ValueError("MemmapData order must be 'C' or 'F'") self.mode = mode self.order = order + self.transposed = bool(transposed) + if self.transposed and len(self._shape) not in {2, 3}: + raise ValueError("Transposed storage requires a two- or three-dimensional shape") + self.debug = bool(debug) + self.type = "mmo" + self._backing = _backing or _BackingFile(Path(filename), temporary=temporary) + self._backing.acquire() + self._released = False self._array: np.memmap | None = None + self._validate_backing_file() + + @classmethod + def empty( + cls, + shape: tuple[int, ...], + *, + filename: str | Path | None = None, + dtype: np.dtype | str = FDT_DTYPE, + order: str = "F", + transposed: bool = False, + temporary: bool | None = None, + fill_value: float = 0.0, + ) -> "MemmapData": + """Create a writable disk-backed array initialized to ``fill_value``.""" + normalized_shape = tuple(int(item) for item in shape) + if not normalized_shape or any(item <= 0 for item in normalized_shape): + raise ValueError("Memory-mapped data dimensions must be positive") + if transposed and len(normalized_shape) not in {2, 3}: + raise ValueError("Transposed storage requires a two- or three-dimensional shape") + path, owns_file = _output_mapping_path(filename) + if temporary is None: + temporary = owns_file + physical_shape = _physical_shape(normalized_shape, transposed) + mapped = np.memmap(path, dtype=np.dtype(dtype), mode="w+", shape=physical_shape, order=order) + mapped[...] = fill_value + mapped.flush() + mmap_handle = getattr(mapped, "_mmap", None) + del mapped + if mmap_handle is not None: + mmap_handle.close() + return cls( + path, + normalized_shape, + dtype=dtype, + mode="r+", + order=order, + transposed=transposed, + temporary=bool(temporary), + ) + + @classmethod + def from_array( + cls, + data: Any, + *, + filename: str | Path | None = None, + dtype: np.dtype | str = FDT_DTYPE, + order: str = "F", + transposed: bool = False, + temporary: bool | None = None, + ) -> "MemmapData": + """Write an array to disk and return a logical memory-mapped view.""" + array = np.asarray(data, dtype=np.dtype(dtype)) + if array.ndim == 0 or any(item <= 0 for item in array.shape): + raise ValueError("Memory-mapped data must have positive dimensions") + output = cls.empty( + tuple(array.shape), + filename=filename, + dtype=dtype, + order=order, + transposed=transposed, + temporary=temporary, + ) + output._memmap()[...] = array + output.flush() + return output @property def filename(self) -> str: """Return the backing file path as a string.""" return str(self.path) + @property + def path(self) -> Path: + """Return the backing file path.""" + return self._backing.path + + @property + def dataFile(self) -> str: + """Return the backing path using EEGLAB's ``mmo`` field name.""" + return self.filename + + @property + def dimensions(self) -> tuple[int, ...]: + """Return the logical dimensions using EEGLAB's ``mmo`` field name.""" + return self.shape + + @property + def writable(self) -> bool: + """Return whether writes are permitted for this handle.""" + return self.mode != "r" + @property def shape(self) -> tuple[int, ...]: """Return the EEG-shaped array dimensions.""" @@ -66,7 +196,9 @@ def T(self) -> np.ndarray: def flush(self) -> None: """Flush pending writes to the backing file.""" - self._memmap().flush() + self._memmap() + if self._array is not None: + self._array.flush() def close(self) -> None: """Flush and release the backing memory map handle.""" @@ -75,15 +207,87 @@ def close(self) -> None: return self._array = None array.flush() - mmap_handle = getattr(array, "_mmap", None) - del array - if mmap_handle is not None: - mmap_handle.close() + # Do not close ``array._mmap`` directly: NumPy slices can retain views + # of this map after the handle object is replaced inside an EEG dict. + # Releasing our reference lets NumPy close the OS map once every view + # is gone and avoids invalidating those still-live arrays. def copy(self, order: str = "C") -> np.ndarray: """Return an in-memory copy of the mapped data.""" return np.array(self._memmap(), copy=True, order=order) + def mapped_copy(self) -> "MemmapData": + """Return an independent writable disk-backed copy.""" + copied = self.__copy__() + copied._detach_for_write() + return copied + + def resize(self, shape: tuple[int, ...], *, fill_value: float = 0.0) -> None: + """Resize this mapping, retaining the overlapping logical data region. + + Resizing writes a private replacement sidecar. The original sidecar is + never resized underneath another handle. + """ + new_shape = tuple(int(item) for item in shape) + if not new_shape or any(item <= 0 for item in new_shape): + raise ValueError("Memory-mapped data dimensions must be positive") + if len(new_shape) != self.ndim: + raise ValueError("Resizing cannot change the number of dimensions") + if self.transposed and len(new_shape) not in {2, 3}: + raise ValueError("Transposed storage requires a two- or three-dimensional shape") + replacement = MemmapData.empty( + new_shape, + dtype=self.dtype, + order=self.order, + transposed=self.transposed, + fill_value=fill_value, + ) + common_region = tuple(slice(0, min(self.shape[axis], new_shape[axis])) for axis in range(self.ndim)) + replacement[common_region] = self[common_region] + self._adopt(replacement) + + def delete(self, indices: Any, *, axis: int | None = None) -> None: + """Delete logical indices and replace the sidecar with the smaller data. + + ``axis=None`` follows MATLAB's column-major linear deletion. Matrix + results become row vectors, while column-vector inputs remain columns. + """ + if axis is None: + flat = np.asarray(self).reshape(-1, order="F") + result = np.delete(flat, indices) + if self.ndim == 2 and self.shape[1] == 1: + result = result.reshape((-1, 1), order="F") + else: + result = result.reshape((1, -1), order="F") + else: + normalized_axis = int(axis) + if normalized_axis < -self.ndim or normalized_axis >= self.ndim: + raise ValueError(f"axis {normalized_axis} is out of bounds for array of dimension {self.ndim}") + result = np.delete(np.asarray(self), indices, axis=normalized_axis) + if result.size == 0: + raise ValueError("MemmapData cannot represent an empty mapping") + replacement = MemmapData.from_array( + result, + dtype=self.dtype, + order=self.order, + transposed=self.transposed and result.ndim in {2, 3}, + ) + self._adopt(replacement) + + def change_file(self, filename: str | Path, *, writable: bool = False) -> None: + """Move a private mapping, or copy a shared mapping, to ``filename``.""" + target = Path(filename) + target.parent.mkdir(parents=True, exist_ok=True) + self.flush() + source = self.path + shared = self._backing.references > 1 + self.close() + if shared: + shutil.copyfile(source, target) + else: + shutil.move(source, target) + self._replace_backing(_BackingFile(target), mode="r+" if writable else "r") + def reshape(self, *shape: Any, **kwargs: Any) -> np.ndarray: """Return a reshaped view using NumPy's reshape semantics.""" return self._memmap().reshape(*shape, **kwargs) @@ -112,6 +316,9 @@ def __getitem__(self, key: Any) -> Any: return self._memmap()[key] def __setitem__(self, key: Any, value: Any) -> None: + if not self.writable: + raise ValueError("assignment destination is read-only") + self._detach_for_write() self._memmap()[key] = value def __len__(self) -> int: @@ -124,7 +331,16 @@ def __getattr__(self, name: str) -> Any: return getattr(self._memmap(), name) def __copy__(self) -> "MemmapData": - return MemmapData(self.path, self._shape, dtype=self._dtype, mode=self.mode, order=self.order) + return MemmapData( + self.path, + self._shape, + dtype=self._dtype, + mode=self.mode, + order=self.order, + transposed=self.transposed, + debug=self.debug, + _backing=self._backing, + ) def __deepcopy__(self, memo: dict[int, Any]) -> "MemmapData": copied = self.__copy__() @@ -132,18 +348,130 @@ def __deepcopy__(self, memo: dict[int, Any]) -> "MemmapData": return copied def __repr__(self) -> str: - return f"MemmapData(path={str(self.path)!r}, shape={self._shape!r}, dtype={self._dtype})" + return ( + f"MemmapData(path={str(self.path)!r}, shape={self._shape!r}, " + f"dtype={self._dtype}, transposed={self.transposed})" + ) + + def __del__(self) -> None: + if getattr(self, "_released", True): + return + try: + self.close() + finally: + self._backing.release() + self._released = True - def _memmap(self) -> np.memmap: + def _memmap(self) -> np.ndarray: if self._array is None: self._array = np.memmap( self.path, dtype=self._dtype, mode=self.mode, - shape=self._shape, + shape=_physical_shape(self._shape, self.transposed), order=self.order, ) - return self._array + if not self.transposed: + return self._array + if self.ndim == 2: + return self._array.transpose(1, 0) + return self._array.transpose(2, 0, 1) + + def _validate_backing_file(self) -> None: + if not self.path.exists(): + raise FileNotFoundError(f"Memory-mapped data file not found: {self.path}") + expected = self.size * self.dtype.itemsize + actual = self.path.stat().st_size + if actual == 0: + raise ValueError(f"Memory-mapped data file is empty: {self.path}") + if actual != expected: + raise ValueError(f"Memory-mapped data file has {actual} bytes, expected {expected}") + + def _detach_for_write(self) -> None: + if self._backing.references <= 1: + return + self.flush() + new_path, _owns_file = _output_mapping_path(None) + shutil.copyfile(self.path, new_path) + self.close() + self._replace_backing(_BackingFile(new_path, temporary=True), mode="r+") + + def _adopt(self, replacement: "MemmapData") -> None: + replacement.flush() + self.close() + self._shape = replacement.shape + self._dtype = replacement.dtype + self.order = replacement.order + self.transposed = replacement.transposed + self._replace_backing(replacement._backing, mode="r+") + + def _replace_backing(self, backing: _BackingFile, *, mode: str) -> None: + old_backing = self._backing + backing.acquire() + self._backing = backing + self.mode = mode + self._array = None + old_backing.release() + + +def _physical_shape(shape: tuple[int, ...], transposed: bool) -> tuple[int, ...]: + if not transposed: + return shape + if len(shape) == 2: + return (shape[1], shape[0]) + if len(shape) == 3: + return (shape[1], shape[2], shape[0]) + raise ValueError("Transposed storage requires a two- or three-dimensional shape") + + +def _output_mapping_path(filename: str | Path | None) -> tuple[Path, bool]: + if filename is not None: + path = Path(filename) + path.parent.mkdir(parents=True, exist_ok=True) + return path, False + temporary = tempfile.NamedTemporaryFile(prefix="eegprep-mmo-", suffix=".fdt", delete=False) + path = Path(temporary.name) + temporary.close() + return path, True + + +def mmo( + data_file: str | Path | None, + dimensions: tuple[int, ...], + writable: bool = True, + transposed: bool = False, + debug: bool = False, +) -> MemmapData: + """Construct an EEGLAB-compatible memory-mapped EEG data handle. + + ``dimensions`` are always logical channel-major dimensions. Passing + ``data_file=None`` creates a temporary zero-filled sidecar. + """ + shape = tuple(int(item) for item in dimensions) + if data_file is None or str(data_file) == "": + result = MemmapData.empty(shape, transposed=transposed) + result.debug = bool(debug) + return result + return MemmapData( + data_file, + shape, + mode="r+" if writable else "r", + order="F", + transposed=transposed, + debug=debug, + ) + + +def mapped_output_like(source: Any, data: Any) -> Any: + """Keep a derived EEG data array disk-backed when its source was mapped.""" + if not isinstance(source, MemmapData) or isinstance(data, MemmapData): + return data + return MemmapData.from_array( + data, + dtype=source.dtype, + order=source.order, + transposed=source.transposed, + ) class OffloadedData: diff --git a/src/eegprep/functions/popfunc/eeg_eegrej.py b/src/eegprep/functions/popfunc/eeg_eegrej.py index 1ea0c289..d7a1ef3d 100644 --- a/src/eegprep/functions/popfunc/eeg_eegrej.py +++ b/src/eegprep/functions/popfunc/eeg_eegrej.py @@ -4,6 +4,7 @@ from typing import List, Dict, Optional, Tuple import numpy as np from copy import deepcopy +from eegprep.functions.adminfunc.storage import mapped_output_like from eegprep.functions.miscfunc.event_utils import boundary_event_indices from eegprep.functions.miscfunc.event_utils import is_boundary_event as _is_boundary_event from ..miscfunc.misc import round_mat @@ -278,6 +279,7 @@ def eeg_eegrej(EEG, regions): EEG : dict Updated EEG data structure with rejected segments removed """ + source_data = EEG.get("data") EEG = deepcopy(EEG) if regions is None or len(regions) == 0: return EEG @@ -306,7 +308,7 @@ def eeg_eegrej(EEG, regions): data_out, xmax_rel, event2, boundevents = _eegrej(EEG["data"], regions, xdur, events) # finalize core fields - EEG["data"] = data_out + EEG["data"] = mapped_output_like(source_data, data_out) EEG["pnts"] = int(data_out.shape[1]) EEG["xmax"] = float(EEG["xmin"] + xmax_rel) EEG['times'] = np.linspace(EEG['xmin'] * 1000, EEG['xmax'] * 1000, EEG['pnts'], dtype=float) diff --git a/src/eegprep/functions/popfunc/pop_epoch.py b/src/eegprep/functions/popfunc/pop_epoch.py index 0e91ad4f..ad7fc5a7 100644 --- a/src/eegprep/functions/popfunc/pop_epoch.py +++ b/src/eegprep/functions/popfunc/pop_epoch.py @@ -12,6 +12,7 @@ import numpy as np from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset +from eegprep.functions.adminfunc.storage import mapped_output_like from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import CallbackSpec, ControlSpec, DialogSpec from eegprep.functions.popfunc._file_io import infer_dataformat, load_data_array @@ -171,6 +172,7 @@ def _pop_epoch_one( ) -> tuple[dict[str, Any], list[int]]: if not isinstance(EEG, dict): raise ValueError("pop_epoch: EEG must be a dataset dictionary") + source_data = EEG.get("data") events = _event_list(EEG.get("event")) if not events: @@ -258,6 +260,7 @@ def _pop_epoch_one( output = eeg_checkset(output, "eventconsistency") output, accepted_positions = _remove_boundary_epochs(output, accepted_positions) + output["data"] = mapped_output_like(source_data, output["data"]) return output, accepted_positions diff --git a/src/eegprep/functions/popfunc/pop_reref.py b/src/eegprep/functions/popfunc/pop_reref.py index 79f5b9b2..4e84035f 100644 --- a/src/eegprep/functions/popfunc/pop_reref.py +++ b/src/eegprep/functions/popfunc/pop_reref.py @@ -9,6 +9,7 @@ import numpy as np from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset +from eegprep.functions.adminfunc.storage import mapped_output_like from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import CallbackSpec, ControlSpec, DialogSpec from eegprep.functions.popfunc._chanutils import ( @@ -108,6 +109,7 @@ def pop_reref( ref = [] _validate_eeg(EEG) + source_data = EEG["data"] EEG_out: dict[str, Any] = copy.deepcopy(EEG) resolved = _resolve_options(EEG_out, ref, options) @@ -143,6 +145,7 @@ def pop_reref( _normalise_checkset_types(EEG_out) EEG_out = eeg_checkset(EEG_out) + EEG_out["data"] = mapped_output_like(source_data, EEG_out["data"]) com = _history_command(ref, resolved["history_options"]) return (EEG_out, com) if return_com else EEG_out diff --git a/src/eegprep/functions/popfunc/pop_resample.py b/src/eegprep/functions/popfunc/pop_resample.py index 75a64df1..73de7bd9 100644 --- a/src/eegprep/functions/popfunc/pop_resample.py +++ b/src/eegprep/functions/popfunc/pop_resample.py @@ -12,6 +12,7 @@ from scipy.signal.windows import kaiser from eegprep.functions.adminfunc.eeglabcompat import get_eeglab +from eegprep.functions.adminfunc.storage import mapped_output_like from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import CallbackSpec, ControlSpec, DialogSpec from eegprep.functions.miscfunc.event_utils import is_boundary_event as _shared_is_boundary_event @@ -167,7 +168,8 @@ def resample_eeg(EEG, freq, method='poly', fc=0.9, df=0.2): logger.info("resampling data %g Hz", float(freq)) p, q = _resample_ratio(freq, EEG["srate"]) ratio = p / q - data = np.asarray(EEG["data"]) + source_data = EEG["data"] + data = np.asarray(source_data) if data.ndim not in {2, 3}: raise ValueError("pop_resample supports continuous or epoched EEG data") old_pnts = int(EEG.get("pnts", data.shape[1])) @@ -186,7 +188,8 @@ def resample_eeg(EEG, freq, method='poly', fc=0.9, df=0.2): resampled_data = np.concatenate(segments, axis=1) if segments else data_3d[:, :0, :] output = deepcopy(EEG) - output["data"] = resampled_data[:, :, 0] if data.ndim == 2 else resampled_data + output_data = resampled_data[:, :, 0] if data.ndim == 2 else resampled_data + output["data"] = mapped_output_like(source_data, output_data) output["pnts"] = int(resampled_data.shape[1]) output["trials"] = int(resampled_data.shape[2]) output["srate"] = float(freq) diff --git a/src/eegprep/functions/popfunc/pop_rmbase.py b/src/eegprep/functions/popfunc/pop_rmbase.py index 8808658f..1a6c791e 100644 --- a/src/eegprep/functions/popfunc/pop_rmbase.py +++ b/src/eegprep/functions/popfunc/pop_rmbase.py @@ -8,6 +8,7 @@ import numpy as np +from eegprep.functions.adminfunc.storage import mapped_output_like from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import CallbackSpec, ControlSpec, DialogSpec from eegprep.functions.miscfunc.misc import round_mat @@ -175,8 +176,9 @@ def _apply_pop_rmbase_one( chanlist: Any, ) -> tuple[dict[str, Any], list[int], list[int] | None]: _validate_eeg(EEG) + source_data = EEG["data"] output = deepcopy(EEG) - data = np.asarray(output["data"]) + data = np.array(output["data"], copy=True) nbchan = int(output.get("nbchan", data.shape[0])) pnts = int(output.get("pnts", data.shape[1])) trials = int(output.get("trials", data.shape[2] if data.ndim == 3 else 1)) @@ -195,7 +197,7 @@ def _apply_pop_rmbase_one( else: data[channel_indices, :, :] = rmbase(data[channel_indices, :, :], pnts, baseline_indices + 1) - output["data"] = data + output["data"] = mapped_output_like(source_data, data) output["nbchan"] = nbchan output["pnts"] = pnts output["trials"] = trials diff --git a/src/eegprep/functions/popfunc/pop_select.py b/src/eegprep/functions/popfunc/pop_select.py index 7e6ed59e..e04283b0 100644 --- a/src/eegprep/functions/popfunc/pop_select.py +++ b/src/eegprep/functions/popfunc/pop_select.py @@ -6,6 +6,7 @@ import numpy as np from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset +from eegprep.functions.adminfunc.storage import mapped_output_like from eegprep.functions.miscfunc.misc import finite_pinv from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import CallbackSpec, ControlSpec, DialogSpec @@ -61,6 +62,7 @@ def _pop_select_apply(EEG, **kwargs): ------- EEG_out, com """ + source_data = EEG.get("data") EEG = copy.deepcopy(EEG) # shallow options with MATLAB-compatible aliases g = { @@ -567,6 +569,7 @@ def _clip_time_matrix(mat): # Call eeg_checkset to ensure consistency after modifications EEG = eeg_checkset(EEG) + EEG["data"] = mapped_output_like(source_data, EEG["data"]) return EEG diff --git a/src/eegprep/plugins/firfilt/_filtering.py b/src/eegprep/plugins/firfilt/_filtering.py index 4a97630f..11fa7036 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.adminfunc.storage import mapped_output_like 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 @@ -214,6 +215,7 @@ def apply_fir_filter( b = np.asarray(coefficients, dtype=float).ravel() if b.size % 2 != 1: raise ValueError("Filter order is not even.") + source_data = EEG["data"] output = deepcopy(EEG) data = np.asarray(output["data"]) if data.ndim not in {2, 3}: @@ -232,7 +234,8 @@ def apply_fir_filter( window = np.ix_(channel_indices, np.arange(start, stop)) filtered[window] = _filter_segment(b, filtered[window], causal=causal, usefftfilt=usefftfilt) - output["data"] = filtered.reshape(nbchan, trials, pnts).transpose(0, 2, 1) if data.ndim == 3 else filtered + filtered_data = filtered.reshape(nbchan, trials, pnts).transpose(0, 2, 1) if data.ndim == 3 else filtered + output["data"] = mapped_output_like(source_data, filtered_data) output["icaact"] = np.array([]) output["saved"] = "no" return output @@ -249,6 +252,7 @@ def apply_eegfilt_legacy( b = np.asarray(coefficients, dtype=float).ravel() if b.size < 2: raise ValueError("Filter coefficients are required") + source_data = EEG["data"] output = deepcopy(EEG) data = np.asarray(output["data"]) if data.ndim not in {2, 3}: @@ -267,7 +271,8 @@ def apply_eegfilt_legacy( if segment.shape[1] <= order * 3 and not causal: raise ValueError("epochframes must be at least 3 times the filtorder.") filtered[:, start:stop] = _legacy_filter_segment(b, segment, causal=causal) - output["data"] = filtered.reshape(nbchan, trials, pnts).transpose(0, 2, 1) if data.ndim == 3 else filtered + filtered_data = filtered.reshape(nbchan, trials, pnts).transpose(0, 2, 1) if data.ndim == 3 else filtered + output["data"] = mapped_output_like(source_data, filtered_data) output["icaact"] = np.array([]) output["saved"] = "no" return output diff --git a/tests/test_mmo_eeglab_tests.py b/tests/test_mmo_eeglab_tests.py new file mode 100644 index 00000000..9a79fe41 --- /dev/null +++ b/tests/test_mmo_eeglab_tests.py @@ -0,0 +1,469 @@ +"""Current ``eeglab_tests`` ports for EEGLAB's disk-backed ``mmo`` class.""" + +from __future__ import annotations + +from copy import copy, deepcopy +from pathlib import Path + +import numpy as np +import pytest + +from eegprep import MemmapData, mmo +from eegprep.functions.popfunc.eeg_eegrej import eeg_eegrej +from eegprep.functions.popfunc.eeg_emptyset import eeg_emptyset +from eegprep.functions.popfunc.pop_epoch import pop_epoch +from eegprep.functions.popfunc.pop_resample import pop_resample +from eegprep.functions.popfunc.pop_reref import pop_reref +from eegprep.functions.popfunc.pop_rmbase import pop_rmbase +from eegprep.functions.popfunc.pop_select import pop_select +from eegprep.plugins.firfilt.pop_firws import pop_firws +from tests.eeglab_tests import eeglab_test + + +UPSTREAM = "unittesting_adminfunc/mmo/adminfunc_mmo_wrapperTest.m" + + +def _data(shape: tuple[int, ...]) -> np.ndarray: + values = np.arange(1, int(np.prod(shape)) + 1, dtype=np.float32) + return values.reshape(shape, order="F") + + +def _mapping( + tmp_path: Path, + name: str, + data: np.ndarray, + *, + transposed: bool = False, +) -> MemmapData: + suffix = ".dat" if transposed else ".fdt" + return MemmapData.from_array(data, filename=tmp_path / f"{name}{suffix}", transposed=transposed) + + +def _eeg(data: np.ndarray | MemmapData, *, epoched: bool = False) -> dict: + array = np.asarray(data) + srate = 100.0 + output = eeg_emptyset() + trials = int(array.shape[2]) if array.ndim == 3 else 1 + pnts = int(array.shape[1]) + output.update( + { + "setname": "mapped epochs" if epoched else "mapped continuous", + "data": data, + "nbchan": int(array.shape[0]), + "pnts": pnts, + "trials": trials, + "srate": srate, + "xmin": -0.2 if epoched else 0.0, + "xmax": (-0.2 if epoched else 0.0) + (pnts - 1) / srate, + "times": ((-0.2 if epoched else 0.0) + np.arange(pnts) / srate) * 1000, + "chanlocs": [ + { + "labels": f"Ch{index + 1}", + "type": "EEG", + "X": float(np.cos(index * np.pi / 2)), + "Y": float(np.sin(index * np.pi / 2)), + "Z": 0.0, + } + for index in range(array.shape[0]) + ], + "event": [], + "urevent": [], + "epoch": [], + "icaweights": np.array([]), + "icasphere": np.array([]), + "icawinv": np.array([]), + "icaact": np.array([]), + "icachansind": np.array([], dtype=int), + "saved": "no", + } + ) + if epoched: + for trial in range(trials): + event = { + "type": "square", + "latency": float(trial * pnts + 21), + "epoch": trial + 1, + "urevent": trial, + } + output["event"].append(event) + output["urevent"].append({"type": "square", "latency": float(trial * pnts + 21)}) + output["epoch"].append({}) + else: + output["event"] = [ + {"type": "square", "latency": latency, "urevent": index} + for index, latency in enumerate((151.0, 401.0, 651.0)) + ] + output["urevent"] = [{"type": event["type"], "latency": event["latency"]} for event in output["event"]] + return output + + +def _continuous_data() -> np.ndarray: + samples = np.arange(800, dtype=np.float64) / 100.0 + return np.vstack( + [ + np.sin(2 * np.pi * (4 + channel) * samples) + 0.2 * np.cos(2 * np.pi * (channel + 1) * samples) + channel + for channel in range(4) + ] + ).astype(np.float32) + + +def _epoched_data() -> np.ndarray: + return _continuous_data()[:, :360].reshape((4, 120, 3), order="F") + + +def _run_workflow(name: str, eeg: dict) -> dict: + if name.endswith("_after_rejection"): + eeg = eeg_eegrej(eeg, [[260, 300]]) + name = name.removesuffix("_after_rejection") + if name == "continuous_rejection": + return eeg_eegrej(eeg, [[260, 300]]) + if name == "continuous_epoch": + output, _indices = pop_epoch(eeg, ["square"], [-0.1, 0.2], gui=False) + return output + if name in {"continuous_baseline", "epoched_baseline"}: + return pop_rmbase(eeg, [], np.arange(1, 11), gui=False) + if name in {"continuous_filter", "epoched_filter"}: + return pop_firws( + eeg, + ftype="highpass", + fcutoff=3, + wtype="blackman", + forder=20, + gui=False, + ) + if name in {"continuous_rereference", "epoched_rereference"}: + return pop_reref(eeg, [], gui=False) + if name == "continuous_select": + return pop_select(eeg, channel=[0, 2], point=[20, 200], gui=False) + if name == "epoched_select": + return pop_select(eeg, channel=[0, 2], point=[10, 90], trial=[1, 3], gui=False) + if name in {"continuous_resample", "epoched_resample"}: + return pop_resample(eeg, 50, engine="poly", gui=False) + raise AssertionError(f"unknown workflow: {name}") + + +@eeglab_test(UPSTREAM, "test_check_eeglab_mmo") +def test_check_eeglab_mmo_preprocessing_preserves_mapping_and_values(tmp_path: Path): + workflows = ( + "continuous_rejection", + "continuous_epoch", + "continuous_epoch_after_rejection", + "continuous_baseline", + "continuous_baseline_after_rejection", + "continuous_filter", + "continuous_filter_after_rejection", + "continuous_rereference", + "continuous_select", + "continuous_resample", + "continuous_resample_after_rejection", + "epoched_baseline", + "epoched_filter", + "epoched_rereference", + "epoched_select", + "epoched_resample", + ) + for workflow in workflows: + epoched = workflow.startswith("epoched") + source = _epoched_data() if epoched else _continuous_data() + mapped = _mapping(tmp_path, workflow, source) + mapped_result = _run_workflow(workflow, _eeg(mapped, epoched=epoched)) + in_memory_result = _run_workflow(workflow, _eeg(source.copy(), epoched=epoched)) + + assert isinstance(mapped_result["data"], MemmapData), workflow + assert mapped_result["data"].path != mapped.path, workflow + assert mapped_result["data"].shape == np.asarray(in_memory_result["data"]).shape, workflow + np.testing.assert_allclose( + np.asarray(mapped_result["data"]), + np.asarray(in_memory_result["data"]), + rtol=2e-5, + atol=2e-6, + err_msg=workflow, + ) + np.testing.assert_array_equal(np.asarray(mapped), source, err_msg=f"source changed in {workflow}") + + +@eeglab_test(UPSTREAM, "test_checkmmo") +def test_checkmmo_constructs_validated_normal_and_empty_mappings(tmp_path: Path): + values = _data((1, 10)) + path = tmp_path / "values.fdt" + np.ravel(values, order="F").tofile(path) + + mapped = mmo(path, values.shape, writable=True, debug=True) + assert mapped.dataFile == str(path) + assert mapped.dimensions == values.shape + assert mapped.writable + assert mapped.debug + assert mapped.type == "mmo" + np.testing.assert_array_equal(np.asarray(mapped), values) + readonly = mmo(path, values.shape, writable=False) + with pytest.raises(ValueError, match="read-only"): + readonly[0, 0] = 0 + + blank = mmo(None, (2, 3)) + assert blank.shape == (2, 3) + np.testing.assert_array_equal(np.asarray(blank), np.zeros((2, 3), dtype=np.float32)) + + missing = tmp_path / "missing.fdt" + with pytest.raises(FileNotFoundError, match="not found"): + mmo(missing, (2, 3)) + empty = tmp_path / "empty.fdt" + empty.touch() + with pytest.raises(ValueError, match="empty"): + mmo(empty, (2, 3)) + short = tmp_path / "short.fdt" + np.ones(3, dtype=np.float32).tofile(short) + with pytest.raises(ValueError, match="expected 24"): + mmo(short, (2, 3)) + + +@eeglab_test(UPSTREAM, "test_checkmmo2") +def test_checkmmo2_copies_detach_only_when_written(tmp_path: Path): + values = _data((2, 5)) + original = _mapping(tmp_path, "copy-on-write", values) + shallow = copy(original) + nested = deepcopy({"data": original})["data"] + assert shallow.path == original.path + assert nested.path == original.path + + shallow[0, 3] = -4 + nested[1, 4] = -9 + + assert shallow.path != original.path + assert nested.path != original.path + assert shallow.path != nested.path + assert original[0, 3] == values[0, 3] + assert original[1, 4] == values[1, 4] + assert shallow[0, 3] == -4 + assert nested[1, 4] == -9 + + +def _assert_deletion( + tmp_path: Path, + name: str, + values: np.ndarray, + indices: object, + *, + axis: int | None, + transposed: bool, +) -> None: + mapped = _mapping(tmp_path, name, values, transposed=transposed) + mapped.delete(indices, axis=axis) + if axis is None: + expected = np.delete(values.reshape(-1, order="F"), indices) + expected = expected.reshape((-1, 1) if values.ndim == 2 and values.shape[1] == 1 else (1, -1)) + else: + expected = np.delete(values, indices, axis=axis) + assert mapped.shape == expected.shape + np.testing.assert_array_equal(np.asarray(mapped), expected) + + +@eeglab_test(UPSTREAM, "test_checkmmo3") +def test_checkmmo3_deletes_axes_and_column_major_linear_indices(tmp_path: Path): + values3 = _data((8, 8, 2)) + _assert_deletion(tmp_path, "trial-first", values3, 0, axis=2, transposed=False) + _assert_deletion(tmp_path, "trial-last", values3, 1, axis=2, transposed=False) + _assert_deletion(tmp_path, "columns", values3, [1, 3], axis=1, transposed=False) + _assert_deletion(tmp_path, "rows", values3, [3, 4], axis=0, transposed=False) + _assert_deletion(tmp_path, "linear-3d", values3, np.arange(72, 83), axis=None, transposed=False) + + values2 = _data((8, 8)) + _assert_deletion(tmp_path, "rows-2d", values2, [3, 4], axis=0, transposed=False) + _assert_deletion(tmp_path, "columns-2d", values2, [3, 4], axis=1, transposed=False) + _assert_deletion(tmp_path, "linear-2d", values2, 3, axis=None, transposed=False) + _assert_deletion(tmp_path, "row-vector", _data((1, 10)), [3, 6], axis=None, transposed=False) + _assert_deletion(tmp_path, "column-vector", _data((10, 1)), [3, 6], axis=None, transposed=False) + + +@eeglab_test(UPSTREAM, "test_checkmmo3_transposed") +def test_checkmmo3_transposed_deletes_logical_axes(tmp_path: Path): + values3 = _data((8, 8, 2)) + _assert_deletion(tmp_path, "transposed-trial", values3, 0, axis=2, transposed=True) + _assert_deletion(tmp_path, "transposed-rows", values3, [3, 4], axis=0, transposed=True) + values2 = _data((8, 8)) + _assert_deletion(tmp_path, "transposed-rows-2d", values2, [3, 4], axis=0, transposed=True) + _assert_deletion(tmp_path, "transposed-columns-2d", values2, [3, 4], axis=1, transposed=True) + + +def _assert_growth( + tmp_path: Path, + name: str, + values: np.ndarray, + shape: tuple[int, ...], + key: object, + assigned: object, + *, + transposed: bool, +) -> None: + mapped = _mapping(tmp_path, name, values, transposed=transposed) + mapped.resize(shape) + expected = np.zeros(shape, dtype=np.float32) + overlap = tuple(slice(0, value) for value in values.shape) + expected[overlap] = values + mapped[key] = assigned + expected[key] = assigned + assert mapped.shape == shape + np.testing.assert_array_equal(np.asarray(mapped), expected) + + +@eeglab_test(UPSTREAM, "test_checkmmo4") +def test_checkmmo4_grows_normal_mappings_with_zero_fill(tmp_path: Path): + values3 = _data((8, 8, 2)) + _assert_growth(tmp_path, "grow-row", values3, (9, 8, 2), (8, slice(None), slice(None)), 1, transposed=False) + _assert_growth(tmp_path, "grow-column", values3, (8, 9, 2), (slice(None), 8, slice(None)), 1, transposed=False) + _assert_growth(tmp_path, "grow-trial", values3, (8, 8, 3), (slice(None), slice(None), 2), 1, transposed=False) + _assert_growth( + tmp_path, + "grow-all", + values3, + (9, 10, 3), + (8, slice(8, 10), 2), + 1, + transposed=False, + ) + values2 = _data((8, 8)) + _assert_growth(tmp_path, "grow-2d", values2, (9, 10), (8, slice(8, 10)), 1, transposed=False) + _assert_growth(tmp_path, "grow-row-vector", _data((1, 10)), (1, 11), (0, 10), 1, transposed=False) + _assert_growth(tmp_path, "grow-column-vector", _data((10, 1)), (11, 1), (10, 0), 1, transposed=False) + + +@eeglab_test(UPSTREAM, "test_checkmmo4_transposed") +def test_checkmmo4_transposed_grows_logical_axes_with_zero_fill(tmp_path: Path): + values3 = _data((8, 8, 2)) + _assert_growth( + tmp_path, "transposed-grow-row", values3, (9, 8, 2), (8, slice(None), slice(None)), 1, transposed=True + ) + _assert_growth( + tmp_path, "transposed-grow-column", values3, (8, 9, 2), (slice(None), 8, slice(None)), 1, transposed=True + ) + _assert_growth( + tmp_path, "transposed-grow-trial", values3, (8, 8, 3), (slice(None), slice(None), 2), 1, transposed=True + ) + _assert_growth( + tmp_path, + "transposed-grow-all", + values3, + (9, 10, 3), + (8, slice(8, 10), 2), + 1, + transposed=True, + ) + + +def _copy_and_write(mapped: MemmapData, key: object, value: float) -> MemmapData: + local = copy(mapped) + local[key] = value + return local + + +@eeglab_test(UPSTREAM, "test_checkmmo_sub1") +def test_checkmmo_sub1_nested_function_write_detaches_argument(tmp_path: Path): + values = _data((1, 10)) + original = _mapping(tmp_path, "sub1", values) + changed = _copy_and_write(original, (0, 3), 5) + assert original[0, 3] == values[0, 3] + assert changed[0, 3] == 5 + + +@eeglab_test(UPSTREAM, "test_checkmmo_sub2") +def test_checkmmo_sub2_function_argument_remains_isolated_after_return(tmp_path: Path): + values = _data((1, 10)) + original = _mapping(tmp_path, "sub2", values) + + def mutate_argument(argument: MemmapData) -> MemmapData: + result = copy(argument) + result[0, 2] = 2 + return result + + changed = mutate_argument(original) + np.testing.assert_array_equal(np.asarray(original), values) + assert changed[0, 2] == 2 + + +@eeglab_test(UPSTREAM, "test_checkmmo_sub3") +def test_checkmmo_sub3_deepcopy_inside_mapping_preserves_value_semantics(tmp_path: Path): + values = _data((1, 10)) + namespace = {"test": _mapping(tmp_path, "sub3", values)} + copied_namespace = deepcopy(namespace) + copied_namespace["test"][0, 4] = -1 + np.testing.assert_array_equal(np.asarray(namespace["test"]), values) + assert copied_namespace["test"][0, 4] == -1 + + +@eeglab_test(UPSTREAM, "test_checkmmo_sub4") +def test_checkmmo_sub4_nested_container_copy_preserves_value_semantics(tmp_path: Path): + values = _data((1, 10)) + original = _mapping(tmp_path, "sub4", values) + copied_container = deepcopy([{"mapping": original}]) + copied_container[0]["mapping"][0, 5] = -2 + np.testing.assert_array_equal(np.asarray(original), values) + assert copied_container[0]["mapping"][0, 5] == -2 + + +def _create_mapping(tmp_path: Path, name: str, values: np.ndarray) -> MemmapData: + return _mapping(tmp_path, name, values) + + +@eeglab_test(UPSTREAM, "test_checkmmo_sub5") +def test_checkmmo_sub5_helper_created_mapping_writes_through_to_disk(tmp_path: Path): + values = _data((1, 10)) + mapped = _create_mapping(tmp_path, "sub5", values) + path = mapped.path + mapped[0, 3] = 5 + mapped.flush() + reopened = MemmapData(path, values.shape) + assert reopened[0, 3] == 5 + + +@eeglab_test(UPSTREAM, "test_checkmmo_sub6") +def test_checkmmo_sub6_helper_returns_mapping_and_original_values(tmp_path: Path): + values = _data((8, 8)) + + def create_pair() -> tuple[MemmapData, np.ndarray]: + array = values.copy() + return _create_mapping(tmp_path, "sub6", array), array + + mapped, original = create_pair() + np.testing.assert_array_equal(np.asarray(mapped), original) + + +@eeglab_test(UPSTREAM, "test_checkmmo_sub7") +def test_checkmmo_sub7_helper_maps_caller_supplied_multidimensional_data(tmp_path: Path): + values = _data((8, 8, 2)) + mapped = _create_mapping(tmp_path, "sub7", values) + assert mapped.ndim == 3 + assert mapped.size == values.size + np.testing.assert_array_equal(np.asarray(mapped), values) + + +@eeglab_test(UPSTREAM, "test_checkmmo_sub8") +def test_checkmmo_sub8_unique_returned_mapping_mutates_without_file_replacement(tmp_path: Path): + values = _data((8, 8)) + mapped = _create_mapping(tmp_path, "sub8", values) + path = mapped.path + mapped[5, 0] = 5 + mapped.flush() + assert mapped.path == path + assert MemmapData(path, values.shape)[5, 0] == 5 + + +@eeglab_test(UPSTREAM, "test_transposeindices") +def test_transposeindices_exposes_logical_2d_and_3d_indices(tmp_path: Path): + values2 = _data((3, 5)) + physical2 = values2.transpose(1, 0) + path2 = tmp_path / "physical2.dat" + np.ravel(physical2, order="F").tofile(path2) + mapped2 = mmo(path2, values2.shape, transposed=True) + np.testing.assert_array_equal(mapped2[1:, [0, 3]], values2[1:, [0, 3]]) + mapped2[2, 4] = -12 + physical2_reloaded = np.fromfile(path2, dtype=np.float32).reshape(physical2.shape, order="F") + assert physical2_reloaded[4, 2] == -12 + + values3 = _data((3, 5, 2)) + physical3 = values3.transpose(1, 2, 0) + path3 = tmp_path / "physical3.dat" + np.ravel(physical3, order="F").tofile(path3) + mapped3 = mmo(path3, values3.shape, transposed=True) + np.testing.assert_array_equal(mapped3[:, 1:4, 1], values3[:, 1:4, 1]) + mapped3[2, 4, 1] = -21 + physical3_reloaded = np.fromfile(path3, dtype=np.float32).reshape(physical3.shape, order="F") + assert physical3_reloaded[4, 1, 2] == -21 From 6690e67395d37fa5914cd6e75259c57ced50a3ff Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:51 -0700 Subject: [PATCH 24/45] feat: port low-level BDF record tests (#352) --- docs/source/api/data_loading_and_saving.rst | 2 + docs/source/changelog.rst | 4 + src/eegprep/__init__.py | 2 + src/eegprep/functions/sigprocfunc/__init__.py | 4 + src/eegprep/functions/sigprocfunc/openbdf.py | 290 +++++++++++++++++ src/eegprep/functions/sigprocfunc/readbdf.py | 140 ++++++++ tests/test_bdf_records_eeglab_tests.py | 306 ++++++++++++++++++ 7 files changed, 748 insertions(+) create mode 100644 src/eegprep/functions/sigprocfunc/openbdf.py create mode 100644 src/eegprep/functions/sigprocfunc/readbdf.py create mode 100644 tests/test_bdf_records_eeglab_tests.py diff --git a/docs/source/api/data_loading_and_saving.rst b/docs/source/api/data_loading_and_saving.rst index 2cbadc6b..bf8d1231 100644 --- a/docs/source/api/data_loading_and_saving.rst +++ b/docs/source/api/data_loading_and_saving.rst @@ -33,7 +33,9 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.getchanlist eegprep.mne2eeg eegprep.mne2eeg_epochs + eegprep.openbdf eegprep.parsetxt + eegprep.readbdf eegprep.readeetraklocs eegprep.readegilocs eegprep.readelp diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 20057196..5e8fe6d1 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,10 @@ the `GitHub Releases `_ page. Unreleased ========== +- Added standalone ``openbdf`` and ``readbdf`` record-level BDF access. The + reader decodes signed 24-bit samples, supports physical calibration and + variable per-channel sample counts, and correctly infers unknown record + counts from three-byte BDF samples. - ``MemmapData`` now covers current EEGLAB ``mmo`` construction, logical indexing over normal and transposed files, copy-on-write mutation, resizing, and deletion. Disk-backed EEG data remains disk-backed through rejection, diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 49fd43d9..ccabb07e 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -218,6 +218,7 @@ "mne2eeg_epochs": ("eegprep.functions.redefine_functions", "mne2eeg_epochs"), "mmo": ("eegprep.functions.adminfunc.storage", "mmo"), "movav": ("eegprep.functions.sigprocfunc.movav", "movav"), + "openbdf": ("eegprep.functions.sigprocfunc.openbdf", "openbdf"), "bootstat": ("eegprep.functions.timefreqfunc.bootstat", "bootstat"), "correct_mc": ("eegprep.functions.timefreqfunc.correct_mc", "correct_mc"), "correctfit": ("eegprep.functions.timefreqfunc.correctfit", "correctfit"), @@ -391,6 +392,7 @@ "readeetraklocs": ("eegprep.functions.sigprocfunc.readlocs", "readeetraklocs"), "readlocs": ("eegprep.functions.sigprocfunc.readlocs", "readlocs"), "readneurodat": ("eegprep.functions.sigprocfunc.readneurodat", "readneurodat"), + "readbdf": ("eegprep.functions.sigprocfunc.readbdf", "readbdf"), "readtxtfile": ("eegprep.functions.sigprocfunc.readtxtfile", "readtxtfile"), "realproba": ("eegprep.functions.sigprocfunc.realproba", "realproba"), "rejkurt": ("eegprep.functions.sigprocfunc.rejkurt", "rejkurt"), diff --git a/src/eegprep/functions/sigprocfunc/__init__.py b/src/eegprep/functions/sigprocfunc/__init__.py index 35bed18b..b7f9ea4a 100644 --- a/src/eegprep/functions/sigprocfunc/__init__.py +++ b/src/eegprep/functions/sigprocfunc/__init__.py @@ -6,7 +6,9 @@ from .matsel import matsel from .mattocell import mattocell from .nan_mean import nan_mean +from .openbdf import openbdf from .quantile import quantile +from .readbdf import readbdf from .shuffle import shuffle __all__ = [ @@ -16,6 +18,8 @@ "matsel", "mattocell", "nan_mean", + "openbdf", "quantile", + "readbdf", "shuffle", ] diff --git a/src/eegprep/functions/sigprocfunc/openbdf.py b/src/eegprep/functions/sigprocfunc/openbdf.py new file mode 100644 index 00000000..93445f61 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/openbdf.py @@ -0,0 +1,290 @@ +"""Open BioSemi Data Format files for record-oriented access.""" + +from __future__ import annotations + +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, BinaryIO + +import numpy as np + + +logger = logging.getLogger(__name__) + +_FIXED_HEADER_SIZE = 256 +_SIGNAL_HEADER_SIZE = 256 +_BDF_BYTES_PER_SAMPLE = 3 + + +def openbdf(filename: str | Path, *, return_header: bool = False) -> dict[str, Any] | tuple[dict[str, Any], str]: + """Read a BDF header and prepare an EEGLAB-compatible record dataset. + + Unlike the historical MATLAB helper, this function does not leave an open + file descriptor in the returned structure. ``readbdf`` reopens the stored + path for each read, so the result is safe to keep and serialize. + + Args: + filename: BioSemi Data Format file. + return_header: Also return the raw 256-byte fixed header as a Latin-1 + string. + + Returns: + A dictionary containing ``Head`` and ``MX``. If ``return_header`` is + true, returns ``(dataset, raw_header)``. + """ + path = Path(filename).expanduser().resolve() + with path.open("rb") as stream: + fixed = _read_exact(stream, _FIXED_HEADER_SIZE, "fixed BDF header") + version = fixed[:8].decode("latin-1") + if version != "\xffBIOSEMI": + raise ValueError(f"{path} is not a BioSemi BDF file") + + head_length = _parse_integer(fixed[184:192], "header length") + record_count = _parse_integer(fixed[236:244], "record count") + duration = _parse_float(fixed[244:252], "record duration") + signal_count = _parse_integer(fixed[252:256], "signal count") + if signal_count <= 0: + raise ValueError("BDF signal count must be positive") + expected_head_length = _FIXED_HEADER_SIZE + _SIGNAL_HEADER_SIZE * signal_count + if head_length != expected_head_length: + raise ValueError( + f"BDF header length is {head_length}, expected {expected_head_length} for {signal_count} signals" + ) + if duration <= 0: + raise ValueError("BDF record duration must be positive") + + labels = _read_text_fields(stream, 16, signal_count, "signal labels") + transducers = _read_text_fields(stream, 80, signal_count, "transducers") + physical_dimensions = _read_text_fields(stream, 8, signal_count, "physical dimensions") + physical_minimum = _read_numeric_fields(stream, 8, signal_count, "physical minimum") + physical_maximum = _read_numeric_fields(stream, 8, signal_count, "physical maximum") + digital_minimum = _read_numeric_fields(stream, 8, signal_count, "digital minimum") + digital_maximum = _read_numeric_fields(stream, 8, signal_count, "digital maximum") + prefiltering = _read_text_fields(stream, 80, signal_count, "prefiltering") + samples_per_record = _read_numeric_fields(stream, 8, signal_count, "samples per record") + _read_exact(stream, 32 * signal_count, "per-signal reserved header") + + digital_minimum, digital_maximum = _valid_digital_limits(digital_minimum, digital_maximum, signal_count) + physical_minimum, physical_maximum = _valid_physical_limits( + physical_minimum, + physical_maximum, + digital_minimum, + digital_maximum, + signal_count, + ) + samples_per_record = _valid_samples_per_record(samples_per_record, signal_count) + samples_in_block = int(samples_per_record.sum()) + record_bytes = samples_in_block * _BDF_BYTES_PER_SAMPLE + if record_count == -1: + record_count = _infer_record_count(path, head_length, record_bytes) + elif record_count < 0: + raise ValueError("BDF record count must be nonnegative or -1 for unknown") + + calibration, offset = _calibration( + physical_minimum, + physical_maximum, + digital_minimum, + digital_maximum, + ) + maximum_samples = int(samples_per_record.max()) + channel_selection = samples_per_record == maximum_samples + channel_types = "".join( + _channel_type(label, selected) for label, selected in zip(labels, channel_selection, strict=True) + ) + matlab_linear_indices = np.concatenate( + [ + channel * maximum_samples + np.arange(1, count + 1, dtype=np.intp) + for channel, count in enumerate(samples_per_record) + ] + ) + start_date = fixed[168:176].decode("ascii", errors="replace") + start_time = fixed[176:184].decode("ascii", errors="replace") + time_zero, start_datetime = _parse_start(start_date, start_time, version) + + head: dict[str, Any] = { + "FILE": { + "FID": None, + "OPEN": 1, + "Ext": path.suffix.removeprefix("."), + "Name": path.stem, + "Path": str(path.parent), + "POS": head_length, + }, + "FileName": str(path), + "VERSION": version, + "PID": fixed[8:88].decode("ascii", errors="replace").rstrip(), + "RID": fixed[88:168].decode("ascii", errors="replace").rstrip(), + "T0": time_zero, + "StartDateTime": start_datetime, + "HeadLen": head_length, + "NRec": record_count, + "Dur": duration, + "NS": signal_count, + "Label": labels, + "Transducer": transducers, + "PhysDim": physical_dimensions, + "PhysMin": physical_minimum, + "PhysMax": physical_maximum, + "DigMin": digital_minimum, + "DigMax": digital_maximum, + "PreFilt": prefiltering, + "SPR": samples_per_record, + "Cal": calibration, + "Off": offset, + "Calib": np.vstack((offset, np.diag(calibration))), + "SampleRate": samples_per_record.astype(float) / duration, + "Chan_Select": channel_selection, + "ChanTyp": channel_types, + "AS": {"spb": samples_in_block, "IDX2": matlab_linear_indices}, + } + dataset = {"Head": head, "MX": {"ReRef": 1}} + if return_header: + return dataset, fixed.decode("latin-1") + return dataset + + +def _read_exact(stream: BinaryIO, size: int, description: str) -> bytes: + data = stream.read(size) + if len(data) != size: + raise ValueError(f"file ended before the complete {description}") + return data + + +def _read_text_fields(stream: BinaryIO, width: int, count: int, description: str) -> list[str]: + raw = _read_exact(stream, width * count, description) + return [ + raw[index * width : (index + 1) * width].decode("ascii", errors="replace").rstrip() for index in range(count) + ] + + +def _read_numeric_fields( + stream: BinaryIO, + width: int, + count: int, + description: str, +) -> np.ndarray | None: + fields = _read_text_fields(stream, width, count, description) + try: + return np.asarray([float(field) for field in fields], dtype=float) + except ValueError: + return None + + +def _parse_integer(raw: bytes, description: str) -> int: + value = _parse_float(raw, description) + if value != np.trunc(value): + raise ValueError(f"BDF {description} must be an integer") + return int(value) + + +def _parse_float(raw: bytes, description: str) -> float: + try: + value = float(raw.decode("ascii").strip()) + except ValueError as exc: + raise ValueError(f"invalid BDF {description}") from exc + if not np.isfinite(value): + raise ValueError(f"BDF {description} must be finite") + return value + + +def _valid_digital_limits( + minimum: np.ndarray | None, + maximum: np.ndarray | None, + count: int, +) -> tuple[np.ndarray, np.ndarray]: + if minimum is None or minimum.size != count: + logger.warning("BDF digital minimum is missing or invalid; using int16 limits") + minimum = np.full(count, -32768.0) + if maximum is None or maximum.size != count: + logger.warning("BDF digital maximum is missing or invalid; using int16 limits") + maximum = np.full(count, 32767.0) + if np.any(minimum >= maximum): + logger.warning("BDF digital minimum is not smaller than maximum") + return minimum, maximum + + +def _valid_physical_limits( + minimum: np.ndarray | None, + maximum: np.ndarray | None, + digital_minimum: np.ndarray, + digital_maximum: np.ndarray, + count: int, +) -> tuple[np.ndarray, np.ndarray]: + if minimum is None or minimum.size != count: + logger.warning("BDF physical minimum is missing or invalid; using digital minimum") + minimum = digital_minimum.copy() + if maximum is None or maximum.size != count: + logger.warning("BDF physical maximum is missing or invalid; using digital maximum") + maximum = digital_maximum.copy() + if np.any(minimum >= maximum): + logger.warning("BDF physical minimum is not smaller than maximum; using digital limits") + return digital_minimum.copy(), digital_maximum.copy() + return minimum, maximum + + +def _valid_samples_per_record(values: np.ndarray | None, count: int) -> np.ndarray: + if values is None or values.size != count: + raise ValueError("BDF samples per record are missing or invalid") + if np.any(values <= 0) or np.any(values != np.trunc(values)): + raise ValueError("BDF samples per record must contain positive integers") + return values.astype(np.intp) + + +def _calibration( + physical_minimum: np.ndarray, + physical_maximum: np.ndarray, + digital_minimum: np.ndarray, + digital_maximum: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + valid = digital_minimum < digital_maximum + calibration = np.ones(digital_minimum.shape, dtype=float) + offset = np.zeros(digital_minimum.shape, dtype=float) + calibration[valid] = (physical_maximum[valid] - physical_minimum[valid]) / ( + digital_maximum[valid] - digital_minimum[valid] + ) + offset[valid] = physical_minimum[valid] - calibration[valid] * digital_minimum[valid] + positive = calibration > 0 + calibration[~positive] = 1.0 + offset[~positive] = 0.0 + return calibration, offset + + +def _infer_record_count(path: Path, head_length: int, record_bytes: int) -> int: + data_bytes = path.stat().st_size - head_length + if data_bytes < 0: + raise ValueError("BDF file is shorter than its declared header") + count, remainder = divmod(data_bytes, record_bytes) + if remainder: + logger.warning("BDF file has %d trailing bytes outside complete records", remainder) + return count + + +def _channel_type(label: str, selected: np.bool_, /) -> str: + upper = label.upper() + for token, channel_type in (("ECG", "C"), ("EKG", "C"), ("EEG", "E"), ("EOG", "O"), ("EMG", "M")): + if token in upper: + return channel_type + return "N" if selected else " " + + +def _parse_start(date: str, time: str, version: str) -> tuple[list[int], datetime | None]: + try: + day, month, short_year = (int(value) for value in date.split(".")) + hour, minute, second = (int(value) for value in time.split(".")) + except ValueError: + logger.warning("BDF start date or time is invalid") + return [0, 0, 0, 0, 0, 0], None + matlab_year = short_year + if version.startswith("0"): + matlab_year = (2000 if short_year < 91 else 1900) + short_year + full_year = (2000 if short_year < 85 else 1900) + short_year + try: + parsed = datetime(full_year, month, day, hour, minute, second) + except ValueError: + logger.warning("BDF start date or time is outside the calendar") + parsed = None + return [matlab_year, month, day, hour, minute, second], parsed + + +__all__ = ["openbdf"] diff --git a/src/eegprep/functions/sigprocfunc/readbdf.py b/src/eegprep/functions/sigprocfunc/readbdf.py new file mode 100644 index 00000000..1729c7bc --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/readbdf.py @@ -0,0 +1,140 @@ +"""Read selected records from BioSemi Data Format files.""" + +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc._validation import integer_array, integer_scalar + + +_BDF_BYTES_PER_SAMPLE = 3 + + +def readbdf( + dataset: dict[str, Any], + records: Any, + mode: int = 0, +) -> tuple[dict[str, Any], np.ndarray]: + """Read selected 1-based BDF records. + + Modes 0 and 2 apply physical calibration; odd modes retain raw digital + values. Modes 2 and 3 compact each channel's variable-length records at + that channel's own samples-per-record stride, matching EEGLAB. + + Args: + dataset: Structure returned by :func:`openbdf`. + records: Scalar or array of EEGLAB-facing 1-based record numbers. + mode: Legacy record layout and calibration mode (0 through 3). + + Returns: + ``(updated_dataset, last_raw_record)``. ``Record`` in the updated + dataset is channel-major; the raw record is sample-major. + """ + if not isinstance(dataset, dict) or not isinstance(dataset.get("Head"), dict): + raise TypeError("dataset must be a structure returned by openbdf") + read_mode = integer_scalar(mode, "mode") + if read_mode not in {0, 1, 2, 3}: + raise ValueError("mode must be 0, 1, 2, or 3") + indices = integer_array(records, "records").reshape(-1) + head = dataset["Head"] + record_count = int(head["NRec"]) + if np.any(indices < 1) or np.any(indices > record_count): + raise IndexError(f"record numbers must be between 1 and {record_count}") + + samples_per_record = np.asarray(head["SPR"], dtype=np.intp) + signal_count = int(head["NS"]) + if samples_per_record.shape != (signal_count,): + raise ValueError("BDF header SPR does not match NS") + maximum_samples = int(samples_per_record.max()) + samples_in_block = int(samples_per_record.sum()) + record_bytes = samples_in_block * _BDF_BYTES_PER_SAMPLE + output = np.zeros((indices.size * maximum_samples, signal_count), dtype=float) + valid = np.zeros(indices.size * maximum_samples, dtype=np.uint8) + last_raw = np.full((maximum_samples, signal_count), np.nan, dtype=float) + warnings = np.zeros(signal_count, dtype=np.uint8) + + path = _data_path(head) + with path.open("rb") as stream: + for output_record, one_based_record in enumerate(indices): + stream.seek(int(head["HeadLen"]) + (int(one_based_record) - 1) * record_bytes) + packed = stream.read(record_bytes) + if len(packed) != record_bytes: + raise ValueError(f"BDF file is incomplete while reading record {one_based_record}") + decoded = _decode_signed_24(packed) + last_raw = _record_matrix(decoded, samples_per_record, maximum_samples) + _store_record(output, last_raw, samples_per_record, output_record, maximum_samples, compact=read_mode >= 2) + + selected = np.asarray(head["Chan_Select"], dtype=bool) + selected_values = last_raw[:, selected] + selected_minimum = np.asarray(head["DigMin"], dtype=float)[selected] + selected_maximum = np.asarray(head["DigMax"], dtype=float)[selected] + record_valid = np.all( + (selected_values > selected_minimum[None, :]) & (selected_values < selected_maximum[None, :]), + axis=1, + ) + start = output_record * maximum_samples + valid[start : start + maximum_samples] = record_valid.astype(np.uint8) + for channel, count in enumerate(samples_per_record): + values = last_raw[:count, channel] + warnings[channel] |= np.uint8( + np.any(values > head["DigMax"][channel]) or np.any(values < head["DigMin"][channel]) + ) + + if read_mode % 2 == 0: + output = output * np.asarray(head["Cal"], dtype=float)[None, :] + np.asarray(head["Off"], dtype=float)[None, :] + + result = deepcopy(dataset) + result["Record"] = output.T + result["Valid"] = valid[None, :] + result["Idx"] = indices.copy() + result["Head"].setdefault("ERROR", {})["DigMinMax_Warning"] = warnings + return result, last_raw + + +def _data_path(head: dict[str, Any]) -> Path: + if "FileName" in head: + return Path(head["FileName"]) + file_info = head["FILE"] + suffix = f".{file_info['Ext']}" if file_info.get("Ext") else "" + return Path(file_info["Path"]) / f"{file_info['Name']}{suffix}" + + +def _decode_signed_24(packed: bytes) -> np.ndarray: + octets = np.frombuffer(packed, dtype=np.uint8).reshape(-1, _BDF_BYTES_PER_SAMPLE).astype(np.int32) + values = octets[:, 0] | (octets[:, 1] << 8) | (octets[:, 2] << 16) + values[values >= 1 << 23] -= 1 << 24 + return values + + +def _record_matrix(decoded: np.ndarray, samples_per_record: np.ndarray, maximum_samples: int) -> np.ndarray: + record = np.full((maximum_samples, samples_per_record.size), np.nan, dtype=float) + position = 0 + for channel, count in enumerate(samples_per_record): + record[:count, channel] = decoded[position : position + count] + position += count + return record + + +def _store_record( + output: np.ndarray, + record: np.ndarray, + samples_per_record: np.ndarray, + output_record: int, + maximum_samples: int, + *, + compact: bool, +) -> None: + if not compact: + start = output_record * maximum_samples + output[start : start + maximum_samples] = record + return + for channel, count in enumerate(samples_per_record): + start = output_record * count + output[start : start + count, channel] = record[:count, channel] + + +__all__ = ["readbdf"] diff --git a/tests/test_bdf_records_eeglab_tests.py b/tests/test_bdf_records_eeglab_tests.py new file mode 100644 index 00000000..0c1b4dd9 --- /dev/null +++ b/tests/test_bdf_records_eeglab_tests.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Sequence + +import numpy as np +import pytest + +from eegprep import openbdf, readbdf +from tests.eeglab_tests import eeglab_test + + +OPENBDF_SUITE = "unittesting_sigprocfunc/openbdf/sigprocfunc_openbdf_wrapperTest.m" +READBDF_SUITE = "unittesting_binary/readbdf/binary_readbdf_wrapperTest.m" + + +def _field(value: str | int | float | None, width: int) -> bytes: + text = "" if value is None else str(value) + encoded = text.encode("ascii") + if len(encoded) > width: + raise ValueError(f"{text!r} does not fit a {width}-byte BDF field") + return encoded.ljust(width) + + +def _encode_signed_24(values: np.ndarray) -> bytes: + unsigned = np.asarray(values, dtype=np.int64) & 0xFFFFFF + packed = np.empty((unsigned.size, 3), dtype=np.uint8) + packed[:, 0] = unsigned & 0xFF + packed[:, 1] = (unsigned >> 8) & 0xFF + packed[:, 2] = (unsigned >> 16) & 0xFF + return packed.tobytes() + + +def _write_bdf( + path: Path, + *, + labels: Sequence[str], + samples_per_record: Sequence[int], + records: Sequence[Sequence[np.ndarray]] = (), + declared_records: int | None = None, + physical_minimum: Sequence[int | float | None] | None = None, + physical_maximum: Sequence[int | float | None] | None = None, + digital_minimum: Sequence[int | float | None] | None = None, + digital_maximum: Sequence[int | float | None] | None = None, +) -> None: + signal_count = len(labels) + if len(samples_per_record) != signal_count: + raise ValueError("samples_per_record must match labels") + physical_minimum = physical_minimum or [-262144] * signal_count + physical_maximum = physical_maximum or [262144] * signal_count + digital_minimum = digital_minimum or [-8388608] * signal_count + digital_maximum = digital_maximum or [8388607] * signal_count + record_count = len(records) if declared_records is None else declared_records + head_length = 256 + signal_count * 256 + fixed = b"".join( + ( + b"\xffBIOSEMI", + _field("Local subject identification", 80), + _field("Local recording identification", 80), + _field("12.03.07", 8), + _field("15.35.27", 8), + _field(head_length, 8), + _field("24BIT", 44), + _field(record_count, 8), + _field(2, 8), + _field(signal_count, 4), + ) + ) + signal_header = b"".join( + ( + b"".join(_field(value, 16) for value in labels), + b"".join(_field("Active Electrode, pin type", 80) for _ in labels), + b"".join(_field("uV", 8) for _ in labels), + b"".join(_field(value, 8) for value in physical_minimum), + b"".join(_field(value, 8) for value in physical_maximum), + b"".join(_field(value, 8) for value in digital_minimum), + b"".join(_field(value, 8) for value in digital_maximum), + b"".join(_field("HP: DC; LP: 113 Hz", 80) for _ in labels), + b"".join(_field(value, 8) for value in samples_per_record), + b"".join(_field("", 32) for _ in labels), + ) + ) + data = bytearray() + for record in records: + if len(record) != signal_count: + raise ValueError("each record must contain every signal") + for values, count in zip(record, samples_per_record, strict=True): + array = np.asarray(values) + if array.shape != (count,): + raise ValueError("record signal length does not match samples_per_record") + data.extend(_encode_signed_24(array)) + path.write_bytes(fixed + signal_header + data) + + +@eeglab_test(OPENBDF_SUITE, "test_pass_general") +def test_openbdf_general_header_matches_current_suite(tmp_path: Path) -> None: + path = tmp_path / "test.bdf" + labels = [f"A{index}" for index in range(1, 17)] + ["Status"] + samples = [256] * 17 + records = [[np.zeros(256, dtype=int) for _ in labels] for _ in range(30)] + _write_bdf(path, labels=labels, samples_per_record=samples, records=records) + + dataset, raw_header = openbdf(path, return_header=True) + head = dataset["Head"] + + assert dataset["MX"] == {"ReRef": 1} + assert len(raw_header) == 256 + assert raw_header.startswith("\xffBIOSEMI") + assert head["VERSION"] == "\xffBIOSEMI" + assert head["PID"] == "Local subject identification" + assert head["RID"] == "Local recording identification" + assert head["T0"] == [7, 3, 12, 15, 35, 27] + assert head["StartDateTime"].isoformat() == "2007-03-12T15:35:27" + assert head["HeadLen"] == 4608 + assert head["NRec"] == 30 + assert head["Dur"] == 2 + assert head["NS"] == 17 + assert head["Label"] == labels + np.testing.assert_array_equal(head["SPR"], samples) + np.testing.assert_array_equal(head["SampleRate"], np.full(17, 128.0)) + np.testing.assert_array_equal(head["Chan_Select"], np.ones(17, dtype=bool)) + assert head["ChanTyp"] == "N" * 17 + assert head["AS"]["spb"] == 256 * 17 + np.testing.assert_array_equal(head["AS"]["IDX2"], np.arange(1, 256 * 17 + 1)) + np.testing.assert_allclose(head["Cal"][:16], np.full(16, 524288 / 16777215)) + assert head["Cal"][-1] == pytest.approx(524288 / 16777215) + assert head["FILE"]["FID"] is None + assert head["FILE"]["POS"] == 4608 + assert head["FileName"] == str(path.resolve()) + + +@eeglab_test(OPENBDF_SUITE, "test_pass_chan_types") +def test_openbdf_channel_types_match_current_suite(tmp_path: Path) -> None: + path = tmp_path / "test_chan_types.bdf" + _write_bdf( + path, + labels=["A1", "A2", "ECG", "EKG", "EEG", "EOG", "EMG"], + samples_per_record=[256, 200, 256, 256, 256, 256, 256], + ) + + assert openbdf(path)["Head"]["ChanTyp"] == "N CCEOM" + + +@eeglab_test(OPENBDF_SUITE, "test_pass_dig_min_larger_max") +def test_openbdf_invalid_digital_order_disables_calibration(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + path = tmp_path / "test_dig_min_larger_max.bdf" + _write_bdf( + path, + labels=["A1", "A2", "A3"], + samples_per_record=[4, 4, 4], + digital_minimum=[8388608, 8388608, -8388608], + digital_maximum=[8388607, 8388607, 8388607], + ) + + head = openbdf(path)["Head"] + + np.testing.assert_array_equal(head["Cal"][:2], [1, 1]) + np.testing.assert_array_equal(head["Off"][:2], [0, 0]) + assert "digital minimum is not smaller" in caplog.text + + +@eeglab_test(OPENBDF_SUITE, "test_pass_invalid_dig_min_max") +def test_openbdf_missing_digital_limits_use_legacy_fallback(tmp_path: Path) -> None: + path = tmp_path / "test_invalid_dig_min_max.bdf" + _write_bdf( + path, + labels=[f"A{index}" for index in range(7)], + samples_per_record=[4] * 7, + digital_minimum=[None] * 7, + digital_maximum=[None] * 7, + ) + + head = openbdf(path)["Head"] + + np.testing.assert_array_equal(head["DigMin"], np.full(7, -32768)) + np.testing.assert_array_equal(head["DigMax"], np.full(7, 32767)) + + +@eeglab_test(OPENBDF_SUITE, "test_pass_invalid_phys_min_max") +def test_openbdf_missing_physical_limits_use_digital_limits(tmp_path: Path) -> None: + path = tmp_path / "test_invalid_phys_min_max.bdf" + _write_bdf( + path, + labels=[f"A{index}" for index in range(7)], + samples_per_record=[4] * 7, + physical_minimum=[None] * 7, + physical_maximum=[None] * 7, + ) + + head = openbdf(path)["Head"] + + np.testing.assert_array_equal(head["PhysMin"], head["DigMin"]) + np.testing.assert_array_equal(head["PhysMax"], head["DigMax"]) + + +@eeglab_test(OPENBDF_SUITE, "test_pass_phys_min_larger_max") +def test_openbdf_reversed_physical_limits_use_digital_limits(tmp_path: Path) -> None: + path = tmp_path / "test_phys_min_larger_max.bdf" + _write_bdf( + path, + labels=[f"A{index}" for index in range(7)], + samples_per_record=[4] * 7, + physical_minimum=[2, 2, -2, -2, -2, -2, -2], + physical_maximum=[1, 1, 2, 2, 2, 2, 2], + ) + + head = openbdf(path)["Head"] + + np.testing.assert_array_equal(head["PhysMin"], head["DigMin"]) + np.testing.assert_array_equal(head["PhysMax"], head["DigMax"]) + + +@eeglab_test(OPENBDF_SUITE, "test_pass_unknown_record_size") +def test_openbdf_unknown_record_count_uses_three_byte_samples(tmp_path: Path) -> None: + path = tmp_path / "test_unknown_record_size.bdf" + labels = ["A1", "A2"] + records = [[np.arange(4), np.arange(4) + record] for record in range(60)] + _write_bdf(path, labels=labels, samples_per_record=[4, 4], records=records, declared_records=-1) + + assert openbdf(path)["Head"]["NRec"] == 60 + + +@eeglab_test(READBDF_SUITE, "test_pass_general") +def test_readbdf_decodes_selected_records_and_calibrates_them(tmp_path: Path) -> None: + path = tmp_path / "test.bdf" + labels = [f"A{index}" for index in range(1, 17)] + ["Status"] + digital_minimum = [-8388608] * 17 + digital_maximum = [8388607] * 17 + physical_minimum = [-262144] * 16 + [-8388608] + physical_maximum = [262144] * 16 + [8388607] + records: list[list[np.ndarray]] = [] + for record_index in range(1, 5): + record = [record_index * 100000 + channel * 1000 + np.arange(256) - 300000 for channel in range(17)] + records.append(record) + records[1][0][3] = digital_maximum[0] + records[3][1][5] = digital_minimum[1] + _write_bdf( + path, + labels=labels, + samples_per_record=[256] * 17, + records=records, + physical_minimum=physical_minimum, + physical_maximum=physical_maximum, + digital_minimum=digital_minimum, + digital_maximum=digital_maximum, + ) + opened = openbdf(path) + + result, last_raw = readbdf(opened, [1, 2, 4]) + + expected_raw = np.column_stack(records[3]).astype(float) + expected_digital = np.concatenate([np.column_stack(records[index]) for index in (0, 1, 3)], axis=0) + expected_physical = expected_digital * opened["Head"]["Cal"] + opened["Head"]["Off"] + np.testing.assert_array_equal(last_raw, expected_raw) + np.testing.assert_allclose(result["Record"], expected_physical.T) + np.testing.assert_array_equal(result["Idx"], [1, 2, 4]) + assert result["Record"].shape == (17, 256 * 3) + assert result["Valid"].shape == (1, 256 * 3) + invalid = np.flatnonzero(result["Valid"][0] == 0) + np.testing.assert_array_equal(invalid, [256 + 3, 512 + 5]) + raw_result, _ = readbdf(opened, [1, 2, 4], mode=1) + np.testing.assert_array_equal(raw_result["Record"], expected_digital.T) + assert "Record" not in opened + + +def test_readbdf_preserves_variable_rate_padding_and_compact_modes(tmp_path: Path) -> None: + path = tmp_path / "variable.bdf" + records = [ + [np.asarray([1, 2, 3, 4]), np.asarray([10, 11])], + [np.asarray([5, 6, 7, 8]), np.asarray([12, 13])], + ] + _write_bdf( + path, + labels=["EEG", "Aux"], + samples_per_record=[4, 2], + records=records, + physical_minimum=[-8388608, -8388608], + physical_maximum=[8388607, 8388607], + ) + opened = openbdf(path) + + padded, _ = readbdf(opened, [1, 2], mode=1) + compact, _ = readbdf(opened, [1, 2], mode=3) + + np.testing.assert_array_equal(padded["Record"][0], [1, 2, 3, 4, 5, 6, 7, 8]) + np.testing.assert_array_equal(padded["Record"][1], [10, 11, np.nan, np.nan, 12, 13, np.nan, np.nan]) + np.testing.assert_array_equal(compact["Record"][1], [10, 11, 12, 13, 0, 0, 0, 0]) + + +def test_readbdf_rejects_fractional_out_of_range_and_truncated_records(tmp_path: Path) -> None: + path = tmp_path / "short.bdf" + _write_bdf( + path, + labels=["A1"], + samples_per_record=[4], + records=[[np.arange(4)]], + ) + opened = openbdf(path) + + with pytest.raises(ValueError, match="integers"): + readbdf(opened, [1.5]) + with pytest.raises(IndexError, match="between 1 and 1"): + readbdf(opened, [2]) + path.write_bytes(path.read_bytes()[:-1]) + with pytest.raises(ValueError, match="incomplete"): + readbdf(opened, [1]) From 0787dd06cc1d215080c1ed7dadb763f4dfa3ac38 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:51 -0700 Subject: [PATCH 25/45] feat: port legacy ICA decomposition tests (#353) --- docs/source/api/ica_and_components.rst | 20 +- docs/source/api/plotting_and_topography.rst | 1 + docs/source/api/statistics.rst | 12 + docs/source/api/time_frequency.rst | 2 +- docs/source/changelog.rst | 9 + src/eegprep/__init__.py | 17 + src/eegprep/functions/miscfunc/kmeans_st.py | 142 ++++++ src/eegprep/functions/miscfunc/loc_subsets.py | 173 ++++++++ .../functions/miscfunc/make_timewarp.py | 237 ++++++++++ src/eegprep/functions/miscfunc/promax.py | 83 ++++ .../functions/miscfunc/runicalowmem.py | 20 + src/eegprep/functions/miscfunc/runpca.py | 76 ++++ src/eegprep/functions/miscfunc/runpca2.py | 85 ++++ src/eegprep/functions/miscfunc/varimax.py | 111 +++++ src/eegprep/functions/miscfunc/varsort.py | 51 +++ src/eegprep/functions/miscfunc/zica.py | 77 ++++ .../functions/sigprocfunc/_runica_variants.py | 16 + src/eegprep/functions/sigprocfunc/icadefs.py | 30 ++ .../functions/sigprocfunc/kmeanscluster.py | 40 ++ src/eegprep/functions/sigprocfunc/posact.py | 69 +++ .../functions/sigprocfunc/runica_ml2.py | 29 ++ .../functions/sigprocfunc/runica_mlb.py | 24 ++ .../functions/studyfunc/_cluster_kmeans.py | 49 +-- ...est_miscfunc_decomposition_eeglab_tests.py | 407 ++++++++++++++++++ ...est_sigprocfunc_ica_legacy_eeglab_tests.py | 133 ++++++ 25 files changed, 1872 insertions(+), 41 deletions(-) create mode 100644 src/eegprep/functions/miscfunc/kmeans_st.py create mode 100644 src/eegprep/functions/miscfunc/loc_subsets.py create mode 100644 src/eegprep/functions/miscfunc/make_timewarp.py create mode 100644 src/eegprep/functions/miscfunc/promax.py create mode 100644 src/eegprep/functions/miscfunc/runicalowmem.py create mode 100644 src/eegprep/functions/miscfunc/runpca.py create mode 100644 src/eegprep/functions/miscfunc/runpca2.py create mode 100644 src/eegprep/functions/miscfunc/varimax.py create mode 100644 src/eegprep/functions/miscfunc/varsort.py create mode 100644 src/eegprep/functions/miscfunc/zica.py create mode 100644 src/eegprep/functions/sigprocfunc/_runica_variants.py create mode 100644 src/eegprep/functions/sigprocfunc/icadefs.py create mode 100644 src/eegprep/functions/sigprocfunc/kmeanscluster.py create mode 100644 src/eegprep/functions/sigprocfunc/posact.py create mode 100644 src/eegprep/functions/sigprocfunc/runica_ml2.py create mode 100644 src/eegprep/functions/sigprocfunc/runica_mlb.py create mode 100644 tests/test_miscfunc_decomposition_eeglab_tests.py create mode 100644 tests/test_sigprocfunc_ica_legacy_eeglab_tests.py diff --git a/docs/source/api/ica_and_components.rst b/docs/source/api/ica_and_components.rst index 31cec05a..36abac43 100644 --- a/docs/source/api/ica_and_components.rst +++ b/docs/source/api/ica_and_components.rst @@ -5,10 +5,17 @@ ICA and Components Decomposition, component classification, and component measures. +Low-level decomposition helpers accept channel/component-major matrices. The +``runica_ml2``, ``runica_mlb``, and ``runicalowmem`` compatibility names all +delegate to the maintained ``runica`` implementation, preventing numerical +drift between historical copies of the infomax engine. ``varsort`` and ``zica`` +return zero-based component and sample indices. + .. autosummary:: :toctree: generated/ eegprep.ICL_feature_extractor + eegprep.ICADefaults eegprep.compvar eegprep.eeg_amica eegprep.eeg_autocorr @@ -23,10 +30,21 @@ Decomposition, component classification, and component measures. eegprep.eeg_rpsd eegprep.eeg_runica eegprep.icaact + eegprep.icadefs eegprep.icaproj eegprep.icavar eegprep.iclabel eegprep.optimal_kmeans eegprep.picard + eegprep.posact + eegprep.promax eegprep.robust_kmeans - + eegprep.runica + eegprep.runica_ml2 + eegprep.runica_mlb + eegprep.runicalowmem + eegprep.runpca + eegprep.runpca2 + eegprep.varimax + eegprep.varsort + eegprep.zica diff --git a/docs/source/api/plotting_and_topography.rst b/docs/source/api/plotting_and_topography.rst index a65b4871..094c88e4 100644 --- a/docs/source/api/plotting_and_topography.rst +++ b/docs/source/api/plotting_and_topography.rst @@ -10,5 +10,6 @@ Scalp maps and artifact review plots. eegprep.hist2 eegprep.topoplot + eegprep.loc_subsets eegprep.vis_artifacts eegprep.vis_artifacts_diagnostics diff --git a/docs/source/api/statistics.rst b/docs/source/api/statistics.rst index ca92fd43..b06e1859 100644 --- a/docs/source/api/statistics.rst +++ b/docs/source/api/statistics.rst @@ -44,6 +44,18 @@ Data Helpers eegprep.functions.statistics.corrcoef_cell eegprep.functions.statistics.teststat +Clustering +========== + +``kmeans_st`` and ``kmeanscluster`` operate on observations in rows and return +zero-based labels. Supply ``random_state`` for reproducible random starts. + +.. autosummary:: + :toctree: generated/ + + eegprep.kmeans_st + eegprep.kmeanscluster + Effective Dimensionality ======================== diff --git a/docs/source/api/time_frequency.rst b/docs/source/api/time_frequency.rst index 30601967..71e6203e 100644 --- a/docs/source/api/time_frequency.rst +++ b/docs/source/api/time_frequency.rst @@ -17,10 +17,10 @@ Time-frequency decomposition, coherence, phase-amplitude coupling, and time warp eegprep.newtimefitc eegprep.newtimefpowerunit eegprep.newtimeftrialbaseln + eegprep.make_timewarp eegprep.pac eegprep.pac_cont eegprep.tf_cycle_calc eegprep.timef eegprep.timefreq eegprep.timewarp - diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 5e8fe6d1..f7bc33e2 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,15 @@ the `GitHub Releases `_ page. Unreleased ========== +- Current EEGLAB legacy ICA/decomposition tests now have substantive Python + ports for PCA and whitening, Varimax/Promax rotation, component orientation, + variance and z-score ordering, deterministic k-means, spatial channel subsets, + event-sequence time-warp selection, and historical ``runica`` entry points. + The single maintained infomax engine backs all variants. EEGPrep corrects + EEGLAB's all-negative ``posact`` orientation, dimension-reduced ``varsort`` + inverse, and multi-epoch ``zica`` peak-selection defects; interactive demo-only + wrappers are covered by numerical source-recovery checks without becoming + public APIs. - Added standalone ``openbdf`` and ``readbdf`` record-level BDF access. The reader decodes signed 24-bit samples, supports physical calibration and variable per-channel sample counts, and correctly infers unknown record diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index ccabb07e..35bf9f4c 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -49,6 +49,7 @@ "ExtensionStatus": ("eegprep.extensions", "ExtensionStatus"), "ExtensionTestHarness": ("eegprep.extension_testing", "ExtensionTestHarness"), "ExtensionValidationResult": ("eegprep.extensions", "ExtensionValidationResult"), + "ICADefaults": ("eegprep.functions.sigprocfunc.icadefs", "ICADefaults"), "ICL_feature_extractor": ("eegprep.plugins.ICLabel.ICL_feature_extractor", "ICL_feature_extractor"), "LazyImport": ("eegprep.extensions", "LazyImport"), "MemmapData": ("eegprep.functions.adminfunc.storage", "MemmapData"), @@ -195,6 +196,7 @@ "getkeyval": ("eegprep.functions.adminfunc.getkeyval", "getkeyval"), "hist2": ("eegprep.functions.miscfunc.hist2", "hist2"), "icaact": ("eegprep.functions.sigprocfunc.ica_helpers", "icaact"), + "icadefs": ("eegprep.functions.sigprocfunc.icadefs", "icadefs"), "icaproj": ("eegprep.functions.sigprocfunc.ica_helpers", "icaproj"), "icavar": ("eegprep.functions.sigprocfunc.ica_helpers", "icavar"), "iclabel": ("eegprep.plugins.ICLabel.iclabel", "iclabel"), @@ -205,9 +207,13 @@ "interp": ("eegprep.functions.redefine_functions", "interp"), "jointprob": ("eegprep.functions.sigprocfunc.jointprob", "jointprob"), "kaiserbeta": ("eegprep.plugins.firfilt.kaiserbeta", "kaiserbeta"), + "kmeans_st": ("eegprep.functions.miscfunc.kmeans_st", "kmeans_st"), + "kmeanscluster": ("eegprep.functions.sigprocfunc.kmeanscluster", "kmeanscluster"), "kurt": ("eegprep.functions.sigprocfunc.kurt", "kurt"), "lat2point": ("eegprep.functions.redefine_functions", "lat2point"), "listdlg2": ("eegprep.functions.guifunc.listdlg2", "listdlg2"), + "loc_subsets": ("eegprep.functions.miscfunc.loc_subsets", "loc_subsets"), + "make_timewarp": ("eegprep.functions.miscfunc.make_timewarp", "make_timewarp"), "minphaserceps": ("eegprep.plugins.firfilt.minphaserceps", "minphaserceps"), "load_catalog_entries": ("eegprep.extension_catalog_validation", "load_catalog_entries"), "load_extension_catalog": ("eegprep.extension_catalog", "load_extension_catalog"), @@ -251,6 +257,8 @@ "plugin_status": ("eegprep.functions.adminfunc.plugin_menu", "plugin_status"), "plotfresp": ("eegprep.plugins.firfilt.plotfresp", "plotfresp"), "point2lat": ("eegprep.functions.redefine_functions", "point2lat"), + "posact": ("eegprep.functions.sigprocfunc.posact", "posact"), + "promax": ("eegprep.functions.miscfunc.promax", "promax"), "pop_adjustevents": ("eegprep.functions.popfunc.pop_adjustevents", "pop_adjustevents"), "pop_autorej": ("eegprep.functions.popfunc.pop_autorej", "pop_autorej"), "pop_averef": ("eegprep.functions.popfunc.pop_averef", "pop_averef"), @@ -402,6 +410,12 @@ "rmart": ("eegprep.functions.miscfunc.rmart", "rmart"), "rmbase": ("eegprep.functions.sigprocfunc.rmbase", "rmbase"), "rmsave": ("eegprep.functions.miscfunc.rmsave", "rmsave"), + "runica": ("eegprep.functions.sigprocfunc.runica", "runica"), + "runica_ml2": ("eegprep.functions.sigprocfunc.runica_ml2", "runica_ml2"), + "runica_mlb": ("eegprep.functions.sigprocfunc.runica_mlb", "runica_mlb"), + "runicalowmem": ("eegprep.functions.miscfunc.runicalowmem", "runicalowmem"), + "runpca": ("eegprep.functions.miscfunc.runpca", "runpca"), + "runpca2": ("eegprep.functions.miscfunc.runpca2", "runpca2"), "saveset": ("eegprep.functions.redefine_functions", "saveset"), "select": ("eegprep.functions.redefine_functions", "select"), "select_multiple_datasets": ( @@ -412,6 +426,9 @@ "shortread": ("eegprep.functions.miscfunc.shortread", "shortread"), "windows": ("eegprep.plugins.firfilt.windows", "windows"), "snapread": ("eegprep.functions.sigprocfunc.snapread", "snapread"), + "varimax": ("eegprep.functions.miscfunc.varimax", "varimax"), + "varsort": ("eegprep.functions.miscfunc.varsort", "varsort"), + "zica": ("eegprep.functions.miscfunc.zica", "zica"), "optimal_kmeans": ("eegprep.functions.studyfunc.optimal_kmeans", "optimal_kmeans"), "robust_kmeans": ("eegprep.functions.studyfunc.robust_kmeans", "robust_kmeans"), "std_addvarlevel": ("eegprep.functions.studyfunc.std_addvarlevel", "std_addvarlevel"), diff --git a/src/eegprep/functions/miscfunc/kmeans_st.py b/src/eegprep/functions/miscfunc/kmeans_st.py new file mode 100644 index 00000000..02c991a0 --- /dev/null +++ b/src/eegprep/functions/miscfunc/kmeans_st.py @@ -0,0 +1,142 @@ +"""Deterministic k-means clustering for row-wise observations.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +KMEANS_MAX_ITER = 300 +KMEANS_TOLERANCE = 1e-8 + + +def kmeans_st( + data: Any, + clusters: int, + restarts: int = 0, + *, + random_state: int = 0, + max_iter: int = KMEANS_MAX_ITER, +) -> tuple[np.ndarray, np.ndarray, float]: + """Partition observations into clusters with minimum within-cluster SSE. + + Observations are rows and features are columns. Cluster labels are zero-based, + following Python indexing. The first solution and every requested restart use + independent deterministic initial centers derived from ``random_state``. + + Args: + data: Two-dimensional observation matrix. + clusters: Number of non-empty clusters. + restarts: Number of additional random initializations. + random_state: Seed used for center initialization. + max_iter: Maximum Lloyd iterations per initialization. + + Returns: + Centroids, zero-based membership labels, and total squared error. + """ + matrix = _validated_observations(data, clusters) + if int(restarts) != restarts or restarts < 0: + raise ValueError("restarts must be a non-negative integer") + if int(max_iter) != max_iter or max_iter < 1: + raise ValueError("max_iter must be a positive integer") + + labels, centers = _kmeans_labels( + matrix, + int(clusters), + random_state=int(random_state), + n_init=int(restarts) + 1, + max_iter=int(max_iter), + ) + distances = squared_distances(matrix, centers) + sse = float(np.sum(distances[np.arange(matrix.shape[0]), labels])) + return centers, labels, sse + + +def squared_distances(data: np.ndarray, centers: np.ndarray) -> np.ndarray: + """Return squared Euclidean distances from every row to every center.""" + differences = data[:, np.newaxis, :] - centers[np.newaxis, :, :] + return np.sum(differences * differences, axis=2) + + +def _kmeans_labels( + data: np.ndarray, + clusters: int, + *, + random_state: int, + n_init: int, + max_iter: int = KMEANS_MAX_ITER, + initial_centers: np.ndarray | None = None, +) -> tuple[np.ndarray, np.ndarray]: + rng = np.random.default_rng(random_state) + best_labels: np.ndarray | None = None + best_centers: np.ndarray | None = None + best_inertia = float("inf") + + for attempt in range(n_init): + if attempt == 0 and initial_centers is not None: + centers = np.asarray(initial_centers, dtype=float).copy() + else: + centers = data[rng.choice(data.shape[0], size=clusters, replace=False)].copy() + labels = np.zeros(data.shape[0], dtype=int) + for _iteration in range(max_iter): + labels = np.argmin(squared_distances(data, centers), axis=1) + new_centers = _recompute_centers(data, labels, centers, clusters) + if np.allclose(new_centers, centers, rtol=0, atol=KMEANS_TOLERANCE): + centers = new_centers + labels = np.argmin(squared_distances(data, centers), axis=1) + break + centers = new_centers + + labels = np.argmin(squared_distances(data, centers), axis=1) + distances = squared_distances(data, centers) + inertia = float(np.sum(distances[np.arange(data.shape[0]), labels])) + if inertia < best_inertia: + best_inertia = inertia + best_labels = labels.copy() + best_centers = centers.copy() + + if best_labels is None or best_centers is None: + raise ValueError("k-means failed to initialize clusters") + return _canonicalize_clusters(best_labels, best_centers) + + +def _validated_observations(data: Any, clusters: int) -> np.ndarray: + matrix = np.asarray(data, dtype=float) + if matrix.ndim != 2 or matrix.shape[0] == 0 or matrix.shape[1] == 0: + raise ValueError("data must be a non-empty two-dimensional matrix") + if not np.all(np.isfinite(matrix)): + raise ValueError("data must contain only finite values") + if int(clusters) != clusters or not 1 <= int(clusters) <= matrix.shape[0]: + raise ValueError(f"clusters must be an integer from 1 to {matrix.shape[0]}") + return matrix + + +def _recompute_centers( + data: np.ndarray, + labels: np.ndarray, + centers: np.ndarray, + clusters: int, +) -> np.ndarray: + new_centers = np.empty_like(centers) + nearest_distance = np.min(squared_distances(data, centers), axis=1) + available = np.argsort(nearest_distance)[::-1].tolist() + for cluster in range(clusters): + rows = data[labels == cluster] + if rows.size: + new_centers[cluster] = np.mean(rows, axis=0) + continue + fallback = next(index for index in available if labels[index] != cluster) + available.remove(fallback) + new_centers[cluster] = data[fallback] + return new_centers + + +def _canonicalize_clusters(labels: np.ndarray, centers: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + order = np.lexsort(tuple(centers[:, column] for column in reversed(range(centers.shape[1])))) + inverse = np.empty_like(order) + inverse[order] = np.arange(order.size) + return inverse[labels], centers[order] + + +__all__ = ["kmeans_st"] diff --git a/src/eegprep/functions/miscfunc/loc_subsets.py b/src/eegprep/functions/miscfunc/loc_subsets.py new file mode 100644 index 00000000..355f2050 --- /dev/null +++ b/src/eegprep/functions/miscfunc/loc_subsets.py @@ -0,0 +1,173 @@ +"""Build spatially distributed channel-location subsets.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import matplotlib.pyplot as plt +import numpy as np +from scipy.spatial.distance import cdist + + +def loc_subsets( + chanlocs: Sequence[dict[str, Any]], + subset_sizes: Sequence[int], + plot_optimization: bool = False, + plot_subsets: bool = False, + mandatory_channels: Sequence[Sequence[int]] | None = None, + *, + random_state: int = 0, +) -> tuple[list[np.ndarray], np.ndarray, np.ndarray]: + """Separate channels into maximally spaced subsets. + + Channel indices are zero-based. When requested subset sizes do not consume + every channel, a final subset contains the remainder. Mandatory channels + remain in their requested subset throughout optimization. + """ + positions = _positions(chanlocs) + sizes = _subset_sizes(subset_sizes, positions.shape[1]) + mandatory = _mandatory_sets(mandatory_channels, sizes, positions.shape[1]) + fixed = {channel for subset in mandatory for channel in subset} + + rng = np.random.default_rng(random_state) + remaining = np.asarray([channel for channel in range(positions.shape[1]) if channel not in fixed], dtype=int) + remaining = rng.permutation(remaining) + subsets: list[list[int]] = [list(channels) for channels in mandatory] + cursor = 0 + for subset_index, size in enumerate(sizes): + needed = size - len(subsets[subset_index]) + subsets[subset_index].extend(remaining[cursor : cursor + needed].tolist()) + cursor += needed + if cursor < remaining.size: + subsets.append(remaining[cursor:].tolist()) + + distances = cdist(positions.T, positions.T) + history = [_spacing_objective(subsets, distances)] + improved = True + while improved: + improved = False + for first in range(len(subsets) - 1): + for second in range(first + 1, len(subsets)): + for first_position, first_channel in enumerate(subsets[first]): + if first_channel in fixed: + continue + for second_position, second_channel in enumerate(subsets[second]): + if second_channel in fixed: + continue + before = _subset_objective(subsets[first], distances) + _subset_objective( + subsets[second], distances + ) + first_candidate = list(subsets[first]) + second_candidate = list(subsets[second]) + first_candidate[first_position] = second_channel + second_candidate[second_position] = first_channel + after = _subset_objective(first_candidate, distances) + _subset_objective( + second_candidate, distances + ) + if after <= before + np.finfo(float).eps * max(1.0, abs(before)): + continue + subsets[first] = first_candidate + subsets[second] = second_candidate + history.append(history[-1] + after - before) + improved = True + + arrays = [np.asarray(sorted(subset), dtype=int) for subset in subsets] + memberships = np.empty(positions.shape[1], dtype=int) + for subset_index, channels in enumerate(arrays): + memberships[channels] = subset_index + if plot_optimization: + _plot_optimization(history) + if plot_subsets: + _plot_channel_subsets(positions, memberships) + return arrays, memberships, positions + + +def _positions(chanlocs: Sequence[dict[str, Any]]) -> np.ndarray: + if len(chanlocs) == 0: + raise ValueError("chanlocs must contain at least one channel") + rows = [] + for index, location in enumerate(chanlocs): + try: + row = [location[axis] if axis in location else location[axis.lower()] for axis in ("X", "Y", "Z")] + except (KeyError, TypeError) as exc: + raise ValueError(f"chanlocs[{index}] must define finite X, Y, and Z coordinates") from exc + rows.append(row) + positions = np.asarray(rows, dtype=float).T + if positions.shape != (3, len(chanlocs)) or not np.all(np.isfinite(positions)): + raise ValueError("chanlocs must define finite X, Y, and Z coordinates") + return positions + + +def _subset_sizes(values: Sequence[int], channels: int) -> list[int]: + raw = np.asarray(values) + if raw.ndim != 1 or raw.size == 0 or not np.issubdtype(raw.dtype, np.number): + raise ValueError("subset_sizes must be a non-empty sequence of integers") + numeric = raw.astype(float) + if not np.all(np.isfinite(numeric)) or not np.all(numeric == np.floor(numeric)): + raise ValueError("subset_sizes must contain integers") + sizes = numeric.astype(int).tolist() + if min(sizes) < 2: + raise ValueError("requested subsets must each contain at least two channels") + if sum(sizes) > channels: + raise ValueError("requested subset sizes exceed the number of channels") + return sizes + + +def _mandatory_sets( + values: Sequence[Sequence[int]] | None, + sizes: Sequence[int], + channels: int, +) -> list[list[int]]: + result = [[] for _size in sizes] + if values is None: + return result + if len(values) > len(sizes): + raise ValueError("mandatory_channels has more entries than requested subsets") + seen: set[int] = set() + for subset_index, subset in enumerate(values): + raw = np.asarray(subset) + if raw.ndim != 1 or not np.issubdtype(raw.dtype, np.number): + raise ValueError("mandatory channel sets must be one-dimensional integer sequences") + numeric = raw.astype(float) + if not np.all(np.isfinite(numeric)) or not np.all(numeric == np.floor(numeric)): + raise ValueError("mandatory channel indices must be integers") + selected = numeric.astype(int).tolist() + if len(selected) > sizes[subset_index]: + raise ValueError("a mandatory channel set exceeds its requested subset size") + if any(channel < 0 or channel >= channels for channel in selected): + raise ValueError(f"mandatory channel indices must be within 0..{channels - 1}") + if seen.intersection(selected) or len(set(selected)) != len(selected): + raise ValueError("mandatory channel indices must be unique across subsets") + seen.update(selected) + result[subset_index] = selected + return result + + +def _subset_objective(subset: Sequence[int], distances: np.ndarray) -> float: + indices = np.asarray(subset, dtype=int) + return float(np.sum(distances[np.ix_(indices, indices)]) / indices.size) + + +def _spacing_objective(subsets: Sequence[Sequence[int]], distances: np.ndarray) -> float: + return sum(_subset_objective(subset, distances) for subset in subsets) + + +def _plot_optimization(history: Sequence[float]) -> None: + figure, axis = plt.subplots() + axis.plot(np.arange(len(history)), history) + axis.set_xlabel("Number of exchanges") + axis.set_ylabel("Sum of mean within-subset distances") + figure.tight_layout() + + +def _plot_channel_subsets(positions: np.ndarray, memberships: np.ndarray) -> None: + figure = plt.figure() + axis = figure.add_subplot(111, projection="3d") + axis.scatter(positions[0], positions[1], positions[2], c=memberships, s=50) + axis.set_title("Channel Subsets") + axis.set_box_aspect((1, 1, 1)) + figure.tight_layout() + + +__all__ = ["loc_subsets"] diff --git a/src/eegprep/functions/miscfunc/make_timewarp.py b/src/eegprep/functions/miscfunc/make_timewarp.py new file mode 100644 index 00000000..a42900dd --- /dev/null +++ b/src/eegprep/functions/miscfunc/make_timewarp.py @@ -0,0 +1,237 @@ +"""Select ordered epoch events for event-latency time warping.""" + +from __future__ import annotations + +import ast +import operator +from collections.abc import Callable, Mapping, Sequence +from typing import Any, cast + +import numpy as np + + +Condition = str | Callable[[Mapping[str, Any]], bool] | None + +_BINARY_OPERATORS: dict[type[ast.operator], Callable[[Any, Any], Any]] = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.Mod: operator.mod, + ast.Pow: operator.pow, +} +_COMPARISON_OPERATORS: dict[type[ast.cmpop], Callable[[Any, Any], bool]] = { + ast.Eq: operator.eq, + ast.NotEq: operator.ne, + ast.Lt: operator.lt, + ast.LtE: operator.le, + ast.Gt: operator.gt, + ast.GtE: operator.ge, +} + + +def make_timewarp( + eeg: Mapping[str, Any], + event_sequence: Sequence[Any], + *, + baseline_latency: float = 0.0, + event_conditions: Sequence[Condition] | None = None, + max_std_for_absolute: float = np.inf, + max_std_for_relative: float = np.inf, +) -> dict[str, Any]: + """Select epochs containing an ordered event sequence. + + Conditions may be callables receiving the current event-field mapping or + simple arithmetic/comparison strings such as ``"latency < 200"``. Strings + are parsed with a restricted expression evaluator; arbitrary code is never + executed. Returned epoch indices are zero-based. + """ + sequence = list(event_sequence) + if not sequence: + raise ValueError("event_sequence must contain at least one event type") + if not np.isfinite(baseline_latency): + raise ValueError("baseline_latency must be finite") + absolute_limit = _standard_deviation_limit(max_std_for_absolute, "max_std_for_absolute") + relative_limit = _standard_deviation_limit(max_std_for_relative, "max_std_for_relative") + conditions = [] if event_conditions is None else list(event_conditions) + if len(conditions) > len(sequence): + raise ValueError("event_conditions cannot be longer than event_sequence") + conditions.extend([None] * (len(sequence) - len(conditions))) + + selected_latencies: list[list[float]] = [] + selected_epochs: list[int] = [] + epochs = list(eeg.get("epoch", [])) + for epoch_index, epoch in enumerate(epochs): + matches = _ordered_matches(epoch, sequence, conditions, float(baseline_latency)) + if matches is not None: + selected_epochs.append(epoch_index) + selected_latencies.append(matches) + + latencies = np.asarray(selected_latencies, dtype=float) + if latencies.size == 0: + latencies = np.empty((0, len(sequence)), dtype=float) + rejected = _outlier_rows(latencies, absolute_limit, relative_limit) + if rejected.size: + keep = np.ones(latencies.shape[0], dtype=bool) + keep[rejected] = False + latencies = latencies[keep] + selected_epochs = np.asarray(selected_epochs, dtype=int)[keep].tolist() + return { + "latencies": latencies, + "epochs": np.asarray(selected_epochs, dtype=int), + "event_sequence": sequence, + } + + +def _ordered_matches( + epoch: Mapping[str, Any], + sequence: Sequence[Any], + conditions: Sequence[Condition], + baseline_latency: float, +) -> list[float] | None: + event_types = _event_values(epoch.get("eventtype", [])) + event_latencies = _event_values(epoch.get("eventlatency", [])) + if len(event_types) != len(event_latencies): + raise ValueError("each epoch must have one eventlatency per eventtype") + records = [ + (float(_scalar_value(latency)), index) for index, latency in enumerate(event_latencies) if np.size(latency) == 1 + ] + if len(records) != len(event_latencies) or not all(np.isfinite(latency) for latency, _index in records): + raise ValueError("epoch event latencies must be finite scalars") + records.sort(key=lambda item: (item[0], item[1])) + + minimum = baseline_latency + result: list[float] = [] + for requested_type, condition in zip(sequence, conditions): + match = None + for latency, event_index in records: + if latency < minimum: + continue + if not _event_type_matches(event_types[event_index], requested_type): + continue + fields = _condition_fields(epoch, event_index) + if not _condition_matches(condition, fields): + continue + match = latency + break + if match is None: + return None + result.append(match) + minimum = match + return result + + +def _event_values(value: Any) -> list[Any]: + if value is None: + return [] + array = np.asarray(value, dtype=object) + if array.ndim == 0: + return [array.item()] + return array.reshape(-1).tolist() + + +def _scalar_value(value: Any) -> Any: + array = np.asarray(value) + return array.reshape(-1)[0].item() if array.size == 1 else value + + +def _event_type_matches(value: Any, requested: Any) -> bool: + choices = requested if isinstance(requested, (list, tuple, set, np.ndarray)) else [requested] + actual = _scalar_value(value) + for choice in choices: + choice = _scalar_value(choice) + if isinstance(choice, str) and isinstance(actual, (int, float, np.integer, np.floating)): + if format(float(actual), "g") == choice: + return True + elif actual == choice: + return True + return False + + +def _condition_fields(epoch: Mapping[str, Any], event_index: int) -> dict[str, Any]: + fields: dict[str, Any] = {} + for name, raw_value in epoch.items(): + if not name.startswith("event"): + continue + values = _event_values(raw_value) + if not values: + continue + value = values[event_index] if event_index < len(values) else values[0] if len(values) == 1 else None + if value is not None: + fields[name.removeprefix("event")] = _scalar_value(value) + return fields + + +def _condition_matches(condition: Condition, fields: Mapping[str, Any]) -> bool: + if condition is None or condition == "" or condition == "true": + return True + if callable(condition): + callback = cast(Callable[[Mapping[str, Any]], bool], condition) + return bool(callback(fields)) + if not isinstance(condition, str): + raise ValueError("event conditions must be strings, callables, or None") + expression = condition.replace("&&", " and ").replace("||", " or ").replace("~=", "!=") + try: + parsed = ast.parse(expression, mode="eval") + return bool(_evaluate_expression(parsed.body, fields)) + except (SyntaxError, TypeError, ValueError, ZeroDivisionError) as exc: + raise ValueError(f"invalid event condition {condition!r}: {exc}") from exc + + +def _evaluate_expression(node: ast.AST, fields: Mapping[str, Any]) -> Any: + if isinstance(node, ast.Constant) and isinstance(node.value, (str, int, float, bool)): + return node.value + if isinstance(node, ast.Name): + if node.id not in fields: + raise ValueError(f"unknown event field {node.id!r}") + return fields[node.id] + if isinstance(node, ast.BoolOp) and isinstance(node.op, (ast.And, ast.Or)): + values = [bool(_evaluate_expression(value, fields)) for value in node.values] + return all(values) if isinstance(node.op, ast.And) else any(values) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.Not, ast.UAdd, ast.USub)): + value = _evaluate_expression(node.operand, fields) + if isinstance(node.op, ast.Not): + return not value + return value if isinstance(node.op, ast.UAdd) else -value + if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPERATORS: + return _BINARY_OPERATORS[type(node.op)]( + _evaluate_expression(node.left, fields), + _evaluate_expression(node.right, fields), + ) + if isinstance(node, ast.Compare): + left = _evaluate_expression(node.left, fields) + for comparison, comparator in zip(node.ops, node.comparators): + if type(comparison) not in _COMPARISON_OPERATORS: + raise ValueError("unsupported comparison operator") + right = _evaluate_expression(comparator, fields) + if not _COMPARISON_OPERATORS[type(comparison)](left, right): + return False + left = right + return True + raise ValueError(f"unsupported expression element {type(node).__name__}") + + +def _standard_deviation_limit(value: float, name: str) -> float: + numeric = float(value) + if np.isnan(numeric) or numeric < 0: + raise ValueError(f"{name} must be non-negative") + return numeric + + +def _outlier_rows(latencies: np.ndarray, absolute_limit: float, relative_limit: float) -> np.ndarray: + if latencies.shape[0] < 2: + return np.empty(0, dtype=int) + rejected = np.zeros(latencies.shape[0], dtype=bool) + if np.isfinite(absolute_limit): + deviations = np.abs(latencies - np.mean(latencies, axis=0)) + threshold = absolute_limit * np.std(latencies, axis=0, ddof=1) + rejected |= np.any(deviations > threshold, axis=1) + if latencies.shape[1] > 1 and np.isfinite(relative_limit): + relative = np.diff(latencies, axis=1) + deviations = np.abs(relative - np.mean(relative, axis=0)) + threshold = relative_limit * np.std(relative, axis=0, ddof=1) + rejected |= np.any(deviations > threshold, axis=1) + return np.flatnonzero(rejected) + + +__all__ = ["make_timewarp"] diff --git a/src/eegprep/functions/miscfunc/promax.py b/src/eegprep/functions/miscfunc/promax.py new file mode 100644 index 00000000..79054103 --- /dev/null +++ b/src/eegprep/functions/miscfunc/promax.py @@ -0,0 +1,83 @@ +"""Oblique Promax rotation following an orthogonal Varimax rotation.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc.misc import finite_matmul +from eegprep.functions.miscfunc.runpca2 import _canonical_eigenvector_signs +from eegprep.functions.miscfunc.varimax import varimax + + +def promax( + data: Any, + n_components: int | None = None, + max_iterations: int = 5, + *, + power: float = 4.0, + tolerance: float = 1e-5, +) -> tuple[np.ndarray, np.ndarray]: + """Return oblique Promax and orthogonal Varimax row rotations. + + ``n_components`` optionally projects centered input into its leading channel + covariance subspace before rotation. Both returned matrices operate directly + on the original rows. + """ + matrix = np.asarray(data, dtype=float) + if matrix.ndim != 2 or min(matrix.shape) == 0: + raise ValueError("data must be a non-empty two-dimensional matrix") + if not np.all(np.isfinite(matrix)): + raise ValueError("data must contain only finite values") + rows = matrix.shape[0] + count = rows if n_components in (None, 0) else int(n_components) + if count < 1 or count > rows or (n_components not in (None, 0) and count != n_components): + raise ValueError(f"n_components must be an integer from 1 to {rows}") + if int(max_iterations) != max_iterations or max_iterations < 1: + raise ValueError("max_iterations must be a positive integer") + if not np.isfinite(power) or power <= 1: + raise ValueError("power must be greater than 1") + if not np.isfinite(tolerance) or tolerance <= 0: + raise ValueError("tolerance must be a finite positive number") + + working, projection = _principal_projection(matrix, count) + varimax_reduced, rotated = varimax(working, tolerance=tolerance, reorder=True) + varimax_rotation = finite_matmul(varimax_reduced, projection) + if count == 1: + return varimax_rotation.copy(), varimax_rotation + + loadings = rotated.T + rotated_axes = varimax_reduced.T + previous_alignment: float | None = None + for _iteration in range(int(max_iterations)): + target = np.sign(loadings) * np.abs(loadings) ** power + transform = np.linalg.lstsq(loadings, target, rcond=None)[0] + norms = np.linalg.norm(transform, axis=0) + transform = np.divide(transform, norms, out=np.zeros_like(transform), where=norms > 0) + next_axes = finite_matmul(rotated_axes, transform) + loadings = finite_matmul(loadings, transform) + alignment = float(np.vdot(next_axes, rotated_axes).real) + if previous_alignment is not None and abs(alignment - previous_alignment) < tolerance: + rotated_axes = next_axes + break + previous_alignment = alignment + rotated_axes = next_axes + + promax_reduced = rotated_axes.T + return finite_matmul(promax_reduced, projection), varimax_rotation + + +def _principal_projection(data: np.ndarray, count: int) -> tuple[np.ndarray, np.ndarray]: + if count == data.shape[0]: + return data.copy(), np.eye(count) + centered = data - np.mean(data, axis=1, keepdims=True) + covariance = finite_matmul(centered, centered.T) / centered.shape[1] + eigenvalues, eigenvectors = np.linalg.eigh(covariance) + order = np.argsort(eigenvalues)[::-1] + vectors = _canonical_eigenvector_signs(eigenvectors[:, order[:count]]) + projection = vectors.T + return finite_matmul(projection, centered), projection + + +__all__ = ["promax"] diff --git a/src/eegprep/functions/miscfunc/runicalowmem.py b/src/eegprep/functions/miscfunc/runicalowmem.py new file mode 100644 index 00000000..aefef6e2 --- /dev/null +++ b/src/eegprep/functions/miscfunc/runicalowmem.py @@ -0,0 +1,20 @@ +"""Compatibility entry point for EEGLAB's low-memory infomax variant.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.sigprocfunc.runica import runica + + +def runicalowmem(data: Any, **kwargs: Any) -> tuple: + """Run infomax ICA through EEGPrep's single maintained ICA engine. + + EEGPrep's ``runica`` already trains in sample blocks. This compatibility + name deliberately delegates to that implementation so algorithm fixes and + numerical qualification cannot diverge between two copied engines. + """ + return runica(data, **kwargs) + + +__all__ = ["runicalowmem"] diff --git a/src/eegprep/functions/miscfunc/runpca.py b/src/eegprep/functions/miscfunc/runpca.py new file mode 100644 index 00000000..b97f3d50 --- /dev/null +++ b/src/eegprep/functions/miscfunc/runpca.py @@ -0,0 +1,76 @@ +"""Principal-component decomposition using singular-value decomposition.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def runpca( + data: Any, + n_components: int | None = None, + normalize: bool = False, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Decompose channel-major data into principal components. + + Args: + data: Variables by observations matrix. + n_components: Number of principal dimensions, or all available dimensions. + normalize: Put singular-value scaling in the components instead of the + mixing matrix. + + Returns: + Component time courses, mixing vectors, and a diagonal singular-value + matrix. For centered input, ``mixing @ components`` reconstructs the + retained-rank data. + """ + matrix = _pca_matrix(data) + centered = matrix - np.mean(matrix, axis=1, keepdims=True) + maximum = min(centered.shape) + count = _component_count(n_components, maximum, matrix.shape[0]) + + left, singular_values, right_transpose = np.linalg.svd(centered.T, full_matrices=False) + left = left[:, :count] + singular_values = singular_values[:count] + right_transpose = right_transpose[:count] + left, right_transpose = _canonical_svd_signs(left, right_transpose) + + if normalize: + components = (left * singular_values).T + mixing = right_transpose.T + else: + components = left.T + mixing = right_transpose.T * singular_values + return components, mixing, np.diag(singular_values) + + +def _pca_matrix(data: Any) -> np.ndarray: + matrix = np.asarray(data, dtype=float) + if matrix.ndim != 2 or min(matrix.shape) == 0: + raise ValueError("data must be a non-empty variables-by-observations matrix") + if not np.all(np.isfinite(matrix)): + raise ValueError("data must contain only finite values") + return matrix + + +def _component_count(value: int | None, maximum: int, variables: int) -> int: + if value in (None, 0): + return maximum + if int(value) != value or not 1 <= int(value) <= maximum: + if int(value) > variables: + raise ValueError(f"n_components must not exceed the {variables} data rows") + raise ValueError(f"n_components must be an integer from 1 to {maximum}") + return int(value) + + +def _canonical_svd_signs(left: np.ndarray, right_transpose: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + for component in range(right_transpose.shape[0]): + anchor = int(np.argmax(np.abs(right_transpose[component]))) + if right_transpose[component, anchor] < 0: + right_transpose[component] *= -1 + left[:, component] *= -1 + return left, right_transpose + + +__all__ = ["runpca"] diff --git a/src/eegprep/functions/miscfunc/runpca2.py b/src/eegprep/functions/miscfunc/runpca2.py new file mode 100644 index 00000000..2c69890e --- /dev/null +++ b/src/eegprep/functions/miscfunc/runpca2.py @@ -0,0 +1,85 @@ +"""Covariance-based PCA and whitening.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc.misc import finite_matmul +from eegprep.functions.miscfunc.runpca import _component_count, _pca_matrix + + +def runpca2( + data: Any, + n_components: int | None = None, + symmetric: bool = True, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Whiten channel-major data from its channel covariance matrix. + + Args: + data: Channels by observations matrix. + n_components: Number of covariance eigenvectors to retain. + symmetric: Use EEGLAB's compact eigenvector form. ``False`` is accepted + only for a full-rank decomposition and returns a symmetric mixing + operator. + + Returns: + Whitened components, mixing matrix, and descending covariance standard + deviations. + """ + matrix = _pca_matrix(data) + centered = matrix - np.mean(matrix, axis=1, keepdims=True) + channels, frames = centered.shape + count = _component_count(n_components, channels, channels) + if not symmetric and count != channels: + raise ValueError("symmetric=False requires all channel components") + + covariance = finite_matmul(centered, centered.T) / frames + eigenvalues, eigenvectors = np.linalg.eigh(covariance) + order = np.argsort(eigenvalues)[::-1] + eigenvalues = np.clip(eigenvalues[order], 0, None) + eigenvectors = eigenvectors[:, order] + eigenvectors = _canonical_eigenvector_signs(eigenvectors) + scales = np.sqrt(eigenvalues) + + if symmetric: + selected_vectors = eigenvectors[:, :count] + selected_scales = scales[:count] + inverse_scales = np.divide( + 1.0, + selected_scales, + out=np.zeros_like(selected_scales), + where=selected_scales > _rank_tolerance(selected_scales, covariance.shape[0]), + ) + components = inverse_scales[:, np.newaxis] * finite_matmul(selected_vectors.T, centered) + mixing = selected_vectors * selected_scales + return components, mixing, scales + + inverse_scales = np.divide( + 1.0, + scales, + out=np.zeros_like(scales), + where=scales > _rank_tolerance(scales, covariance.shape[0]), + ) + whitened = inverse_scales[:, np.newaxis] * finite_matmul(eigenvectors.T, centered) + components = finite_matmul(eigenvectors, whitened) + mixing = finite_matmul(finite_matmul(eigenvectors, np.diag(scales)), eigenvectors.T) + return components, mixing, scales + + +def _canonical_eigenvector_signs(vectors: np.ndarray) -> np.ndarray: + vectors = vectors.copy() + for component in range(vectors.shape[1]): + anchor = int(np.argmax(np.abs(vectors[:, component]))) + if vectors[anchor, component] < 0: + vectors[:, component] *= -1 + return vectors + + +def _rank_tolerance(scales: np.ndarray, dimension: int) -> float: + maximum = float(np.max(scales, initial=0)) + return np.finfo(float).eps * max(1, dimension) * maximum + + +__all__ = ["runpca2"] diff --git a/src/eegprep/functions/miscfunc/varimax.py b/src/eegprep/functions/miscfunc/varimax.py new file mode 100644 index 00000000..bf9604f5 --- /dev/null +++ b/src/eegprep/functions/miscfunc/varimax.py @@ -0,0 +1,111 @@ +"""Orthogonal Varimax rotation of component rows.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def varimax( + data: Any, + tolerance: float = 1e-4, + reorder: bool | str = True, + *, + max_iterations: int = 50, +) -> tuple[np.ndarray, np.ndarray]: + """Rotate matrix rows toward a sparse, orthogonal simple structure. + + Args: + data: Components by features or observations matrix. + tolerance: Pairwise rotation and convergence tolerance. + reorder: Reorient and sort rows by descending energy. The strings + ``"reorder"`` and ``"noreorder"`` are also accepted. + max_iterations: Maximum sweeps through all row pairs. + + Returns: + Orthogonal rotation and the rotated data ``rotation @ data``. + """ + matrix = np.asarray(data, dtype=float) + if matrix.ndim != 2 or min(matrix.shape) == 0: + raise ValueError("data must be a non-empty two-dimensional matrix") + if not np.all(np.isfinite(matrix)): + raise ValueError("data must contain only finite values") + if not np.isfinite(tolerance) or tolerance <= 0: + raise ValueError("tolerance must be a finite positive number") + if int(max_iterations) != max_iterations or max_iterations < 1: + raise ValueError("max_iterations must be a positive integer") + should_reorder = _reorder_flag(reorder) + + rotated = matrix.copy() + rotation = np.eye(matrix.shape[0]) + previous = _criterion(rotated) + no_improvement = 0 + + for _iteration in range(int(max_iterations)): + changed = False + for first in range(rotated.shape[0] - 1): + for second in range(first + 1, rotated.shape[0]): + row_first = rotated[first] + row_second = rotated[second] + u_value = row_first * row_first - row_second * row_second + v_value = 2 * row_first * row_second + a_value = np.sum(u_value) + b_value = np.sum(v_value) + c_value = np.sum(u_value * u_value - v_value * v_value) + d_value = np.sum(u_value * v_value) + denominator = rotated.shape[1] * c_value + b_value**2 - a_value**2 + numerator = 2 * (rotated.shape[1] * d_value - a_value * b_value) + if abs(numerator) <= tolerance * abs(denominator): + continue + angle = 0.25 * np.arctan2(numerator, denominator) + _rotate_rows(rotation, first, second, angle) + _rotate_rows(rotated, first, second, angle) + changed = True + + current = _criterion(rotated) + if current == 0: + relative_improvement = 0.0 + else: + relative_improvement = (current - previous) / abs(current) + no_improvement = no_improvement + 1 if relative_improvement <= tolerance else 0 + previous = current + if not changed or no_improvement >= 2: + break + + if should_reorder: + signs = np.where(np.sum(rotated, axis=1) >= 0, 1.0, -1.0) + rotation *= signs[:, np.newaxis] + rotated *= signs[:, np.newaxis] + order = np.argsort(np.sum(rotated * rotated, axis=1), kind="stable")[::-1] + rotation = rotation[order] + rotated = rotated[order] + return rotation, rotated + + +def _criterion(data: np.ndarray) -> float: + observations = data.shape[1] + return float(np.sum(np.sum(data**4, axis=1) - np.sum(data**2, axis=1) ** 2 / observations)) + + +def _rotate_rows(matrix: np.ndarray, first: int, second: int, angle: float) -> None: + cosine = np.cos(angle) + sine = np.sin(angle) + first_row = matrix[first].copy() + second_row = matrix[second].copy() + matrix[first] = cosine * first_row + sine * second_row + matrix[second] = -sine * first_row + cosine * second_row + + +def _reorder_flag(value: bool | str) -> bool: + if isinstance(value, str): + normalized = value.lower() + if normalized == "reorder": + return True + if normalized == "noreorder": + return False + raise ValueError("reorder must be a boolean, 'reorder', or 'noreorder'") + return bool(value) + + +__all__ = ["varimax"] diff --git a/src/eegprep/functions/miscfunc/varsort.py b/src/eegprep/functions/miscfunc/varsort.py new file mode 100644 index 00000000..ddb2a389 --- /dev/null +++ b/src/eegprep/functions/miscfunc/varsort.py @@ -0,0 +1,51 @@ +"""Order ICA components by mean scalp-projected power.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc.misc import finite_matmul + + +def varsort( + activations: Any, + weights: Any, + sphere: Any, +) -> tuple[np.ndarray, np.ndarray]: + """Return zero-based component order and descending projected power. + + The pseudoinverse supports PCA-reduced ICA decompositions, unlike the + historical square-only implementation. + """ + activity = _matrix(activations, "activations") + weight_matrix = _matrix(weights, "weights") + sphere_matrix = _matrix(sphere, "sphere") + if activity.shape[0] != weight_matrix.shape[0]: + raise ValueError("activations rows must equal the number of ICA components") + if weight_matrix.shape[1] != sphere_matrix.shape[0]: + raise ValueError("weights columns must equal sphere rows") + if sphere_matrix.shape[1] < 2: + raise ValueError("projected component variance requires at least two channels") + + inverse = np.linalg.pinv(finite_matmul(weight_matrix, sphere_matrix)) + projected_power = np.empty(activity.shape[0]) + for component in range(activity.shape[0]): + projection = inverse[:, component, np.newaxis] * activity[component] + sample_power = np.sum(projection * projection, axis=0) / (projection.shape[0] - 1) + projected_power[component] = np.mean(sample_power) + order = np.argsort(projected_power, kind="stable")[::-1] + return order, projected_power[order] + + +def _matrix(value: Any, name: str) -> np.ndarray: + matrix = np.asarray(value, dtype=float) + if matrix.ndim != 2 or min(matrix.shape) == 0: + raise ValueError(f"{name} must be a non-empty two-dimensional matrix") + if not np.all(np.isfinite(matrix)): + raise ValueError(f"{name} must contain only finite values") + return matrix + + +__all__ = ["varsort"] diff --git a/src/eegprep/functions/miscfunc/zica.py b/src/eegprep/functions/miscfunc/zica.py new file mode 100644 index 00000000..ed0455df --- /dev/null +++ b/src/eegprep/functions/miscfunc/zica.py @@ -0,0 +1,77 @@ +"""Baseline z-scoring and peak ordering of ICA activations.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def zica( + activations: Any, + frames: int | None = None, + baseline_frames: Any = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Z-score ICA activations from repeated within-epoch baseline frames. + + Args: + activations: Components by concatenated samples matrix. + frames: Samples per epoch, or the full data length for continuous data. + baseline_frames: Zero-based frame indices within every epoch. At least + three baseline frames are required. + + Returns: + Z-scored activations, baseline standard deviations, maximum absolute + z-scores, original zero-based component indices, and zero-based peak + sample indices, all ordered by descending peak magnitude. + """ + activity = np.asarray(activations, dtype=float) + if activity.ndim != 2 or min(activity.shape) == 0: + raise ValueError("activations must be a non-empty components-by-samples matrix") + if not np.all(np.isfinite(activity)): + raise ValueError("activations must contain only finite values") + + samples = activity.shape[1] + epoch_frames = samples if frames in (None, 0) else int(frames) + if epoch_frames < 1 or epoch_frames != frames and frames not in (None, 0): + raise ValueError("frames must be a positive integer") + if samples % epoch_frames: + raise ValueError("frames must divide the activation sample count exactly") + baseline = _baseline_indices(baseline_frames, epoch_frames) + + epoch_count = samples // epoch_frames + epoch_activity = activity.reshape(activity.shape[0], epoch_count, epoch_frames) + baseline_activity = epoch_activity[:, :, baseline].reshape(activity.shape[0], -1) + baseline_sd = np.std(baseline_activity, axis=1, ddof=1) + if np.any(baseline_sd == 0): + components = np.flatnonzero(baseline_sd == 0).tolist() + raise ValueError(f"baseline standard deviation is zero for components {components}") + + standardized = activity / baseline_sd[:, np.newaxis] + peak_frames = np.argmax(np.abs(standardized), axis=1) + peaks = np.abs(standardized[np.arange(standardized.shape[0]), peak_frames]) + order = np.argsort(peaks, kind="stable")[::-1] + return standardized[order], baseline_sd[order], peaks[order], order, peak_frames[order] + + +def _baseline_indices(value: Any, frames: int) -> np.ndarray: + if value is None or (np.isscalar(value) and value == 0): + indices = np.arange(frames) + else: + raw = np.asarray(value) + if raw.ndim != 1 or raw.size < 3 or not np.issubdtype(raw.dtype, np.number): + raise ValueError("baseline_frames must contain at least three zero-based indices") + numeric = raw.astype(float) + if not np.all(np.isfinite(numeric)) or not np.all(numeric == np.floor(numeric)): + raise ValueError("baseline_frames must contain integer indices") + indices = numeric.astype(int) + if indices.size < 3: + raise ValueError("baseline_frames must contain at least three indices") + if np.any(indices < 0) or np.any(indices >= frames): + raise ValueError(f"baseline_frames must be within 0..{frames - 1}") + if np.unique(indices).size != indices.size: + raise ValueError("baseline_frames must not contain duplicate indices") + return indices + + +__all__ = ["zica"] diff --git a/src/eegprep/functions/sigprocfunc/_runica_variants.py b/src/eegprep/functions/sigprocfunc/_runica_variants.py new file mode 100644 index 00000000..a3c49471 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/_runica_variants.py @@ -0,0 +1,16 @@ +"""Shared option handling for historical runica variants.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.sigprocfunc.runica import runica + + +def runica_variant(data: Any, defaults: dict[str, Any], kwargs: dict[str, Any]) -> tuple: + """Run the canonical engine after applying a variant's distinct defaults.""" + options = {**defaults, **kwargs} + return runica(data, **options) + + +__all__ = ["runica_variant"] diff --git a/src/eegprep/functions/sigprocfunc/icadefs.py b/src/eegprep/functions/sigprocfunc/icadefs.py new file mode 100644 index 00000000..ceeff3f2 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/icadefs.py @@ -0,0 +1,30 @@ +"""Typed access to stable EEGLAB ICA and display defaults.""" + +from __future__ import annotations + +import platform +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ICADefaults: + """Stable constants historically introduced by EEGLAB's ``icadefs`` script.""" + + ICABINARY: str + DEFAULT_SRATE: float = 256.0175 + DEFAULT_TIMLIM: tuple[int, int] = (-1000, 2000) + DEFAULT_EPOCH: int = 10 + YDIR: int = 1 + HZDIR: str = "up" + BACKCOLOR: tuple[float, float, float] = (0.93, 0.96, 1.0) + BACKEEGLABCOLOR: tuple[float, float, float] = (0.66, 0.76, 1.0) + + +def icadefs() -> ICADefaults: + """Return platform-aware ICA defaults without mutating process globals.""" + system = platform.system() + binary = "binica.exe" if system == "Windows" else "ica_osx" if system == "Darwin" else "ica_linux" + return ICADefaults(ICABINARY=binary) + + +__all__ = ["ICADefaults", "icadefs"] diff --git a/src/eegprep/functions/sigprocfunc/kmeanscluster.py b/src/eegprep/functions/sigprocfunc/kmeanscluster.py new file mode 100644 index 00000000..863ffe6d --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/kmeanscluster.py @@ -0,0 +1,40 @@ +"""Simple deterministic k-means clustering.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc.kmeans_st import ( + _kmeans_labels, + _validated_observations, +) + + +def kmeanscluster( + data: Any, + clusters: int = 1, + randomized: bool = False, + *, + random_state: int = 0, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Cluster row-wise observations using EEGLAB's simple k-means contract. + + Returns zero-based labels, centroids, the converged previous-label vector, + and an unchanged copy of the input observations. Deterministic mode + initializes from the first ``clusters`` rows, as EEGLAB does. + """ + matrix = _validated_observations(data, clusters) + initial = None if randomized else matrix[: int(clusters)] + labels, centers = _kmeans_labels( + matrix, + int(clusters), + random_state=int(random_state), + n_init=1, + initial_centers=initial, + ) + return labels, centers, labels.copy(), matrix.copy() + + +__all__ = ["kmeanscluster"] diff --git a/src/eegprep/functions/sigprocfunc/posact.py b/src/eegprep/functions/sigprocfunc/posact.py new file mode 100644 index 00000000..6cf0c65a --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/posact.py @@ -0,0 +1,69 @@ +"""Orient ICA components toward RMS-positive activations.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc.misc import finite_matmul + + +def posact( + data: Any, + weights: Any, + sphere: Any | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Flip ICA components whose negative samples have the larger RMS. + + Args: + data: Channels by samples input data. + weights: Components by sphered-channels ICA weights. + sphere: Sphering matrix, or ``None`` for identity. + + Returns: + Reoriented activations, inverse unmixing matrix, and weights. All three + outputs describe the same sign convention. + """ + matrix = _finite_matrix(data, "data") + weight_matrix = _finite_matrix(weights, "weights") + sphere_matrix = np.eye(matrix.shape[0]) if sphere is None else _finite_matrix(sphere, "sphere") + if sphere_matrix.shape[1] != matrix.shape[0]: + raise ValueError("sphere columns must equal the number of data channels") + if weight_matrix.shape[1] != sphere_matrix.shape[0]: + raise ValueError("weights columns must equal the number of sphere rows") + + unmixing = finite_matmul(weight_matrix, sphere_matrix) + activations = finite_matmul(unmixing, matrix) + inverse = np.linalg.pinv(unmixing) + orientation = np.ones(activations.shape[0]) + for component, activation in enumerate(activations): + positive = activation[activation >= 0] + negative = activation[activation < 0] + positive_rms = _rms(positive) + negative_rms = _rms(negative) + if negative_rms > positive_rms: + orientation[component] = -1 + + oriented_activations = orientation[:, np.newaxis] * activations + oriented_inverse = inverse * orientation[np.newaxis, :] + oriented_weights = orientation[:, np.newaxis] * weight_matrix + return oriented_activations, oriented_inverse, oriented_weights + + +def _finite_matrix(value: Any, name: str) -> np.ndarray: + matrix = np.asarray(value, dtype=float) + if matrix.ndim != 2 or min(matrix.shape) == 0: + raise ValueError(f"{name} must be a non-empty two-dimensional matrix") + if not np.all(np.isfinite(matrix)): + raise ValueError(f"{name} must contain only finite values") + return matrix + + +def _rms(values: np.ndarray) -> float: + if values.size == 0: + return 0.0 + return float(np.sqrt(np.mean(values * values))) + + +__all__ = ["posact"] diff --git a/src/eegprep/functions/sigprocfunc/runica_ml2.py b/src/eegprep/functions/sigprocfunc/runica_ml2.py new file mode 100644 index 00000000..0e55e960 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/runica_ml2.py @@ -0,0 +1,29 @@ +"""Compatibility entry point for the historical ``runica_ml2`` fork.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.sigprocfunc._runica_variants import runica_variant + + +_ML2_DEFAULTS = { + "extended": 1, + "posact": "on", + "bias": "off", + "anneal": 0.95, + "maxsteps": 500, +} + + +def runica_ml2(data: Any, **kwargs: Any) -> tuple: + """Run the ML2 infomax defaults through the maintained ``runica`` engine. + + The historical fixed 10,000-sample training block is intentionally not + retained; EEGPrep uses ``runica``'s data-dependent block heuristic unless + the caller supplies ``block``. + """ + return runica_variant(data, _ML2_DEFAULTS, kwargs) + + +__all__ = ["runica_ml2"] diff --git a/src/eegprep/functions/sigprocfunc/runica_mlb.py b/src/eegprep/functions/sigprocfunc/runica_mlb.py new file mode 100644 index 00000000..5380860d --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/runica_mlb.py @@ -0,0 +1,24 @@ +"""Compatibility entry point for the historical ``runica_mlb`` fork.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.sigprocfunc._runica_variants import runica_variant + + +_MLB_DEFAULTS = { + "extended": 1, + "posact": "on", + "bias": "on", + "anneal": 0.98, + "maxsteps": 500, +} + + +def runica_mlb(data: Any, **kwargs: Any) -> tuple: + """Run the MLB infomax defaults through the maintained ``runica`` engine.""" + return runica_variant(data, _MLB_DEFAULTS, kwargs) + + +__all__ = ["runica_mlb"] diff --git a/src/eegprep/functions/studyfunc/_cluster_kmeans.py b/src/eegprep/functions/studyfunc/_cluster_kmeans.py index 5a10713b..2bf60a0e 100644 --- a/src/eegprep/functions/studyfunc/_cluster_kmeans.py +++ b/src/eegprep/functions/studyfunc/_cluster_kmeans.py @@ -10,6 +10,8 @@ import numpy as np +from eegprep.functions.miscfunc.kmeans_st import _kmeans_labels, squared_distances + KMEANS_MAX_ITER = 300 KMEANS_N_INIT = 10 @@ -18,45 +20,14 @@ def kmeans_labels(data: np.ndarray, clus_num: int, random_state: int) -> tuple[np.ndarray, np.ndarray]: """Run deterministic multi-restart k-means and return 1-based labels and centers.""" - rng = np.random.default_rng(random_state) - best_labels: np.ndarray | None = None - best_centers: np.ndarray | None = None - best_inertia = float("inf") - for _attempt in range(KMEANS_N_INIT): - centers = data[rng.choice(data.shape[0], size=clus_num, replace=False)].copy() - labels = np.zeros(data.shape[0], dtype=int) - for _iteration in range(KMEANS_MAX_ITER): - labels = np.argmin(squared_distances(data, centers), axis=1) - new_centers = _recompute_centers(data, labels, centers, clus_num) - if np.allclose(new_centers, centers, rtol=0, atol=KMEANS_TOLERANCE): - centers = new_centers - break - centers = new_centers - distances = squared_distances(data, centers) - inertia = float(np.sum(distances[np.arange(data.shape[0]), labels])) - if inertia < best_inertia: - best_inertia = inertia - best_labels = labels.copy() - best_centers = centers.copy() - if best_labels is None or best_centers is None: - raise ValueError("K-means failed to initialize clusters") - return best_labels.astype(int) + 1, best_centers - - -def squared_distances(data: np.ndarray, centers: np.ndarray) -> np.ndarray: - """Return the matrix of squared Euclidean distances from rows to centers.""" - diff = data[:, np.newaxis, :] - centers[np.newaxis, :, :] - return np.sum(diff * diff, axis=2) - - -def _recompute_centers(data: np.ndarray, labels: np.ndarray, centers: np.ndarray, clus_num: int) -> np.ndarray: - new_centers = np.empty_like(centers) - nearest_distance = np.min(squared_distances(data, centers), axis=1) - fallback_index = int(np.argmax(nearest_distance)) - for cluster in range(clus_num): - rows = data[labels == cluster] - new_centers[cluster] = np.mean(rows, axis=0) if rows.size else data[fallback_index] - return new_centers + labels, centers = _kmeans_labels( + data, + clus_num, + random_state=random_state, + n_init=KMEANS_N_INIT, + max_iter=KMEANS_MAX_ITER, + ) + return labels + 1, centers __all__ = ["KMEANS_MAX_ITER", "KMEANS_N_INIT", "KMEANS_TOLERANCE", "kmeans_labels", "squared_distances"] diff --git a/tests/test_miscfunc_decomposition_eeglab_tests.py b/tests/test_miscfunc_decomposition_eeglab_tests.py new file mode 100644 index 00000000..f0125bb7 --- /dev/null +++ b/tests/test_miscfunc_decomposition_eeglab_tests.py @@ -0,0 +1,407 @@ +"""Current eeglab_tests ports for legacy decomposition and ICA helpers.""" + +from __future__ import annotations + +import matplotlib.pyplot as plt +import numpy as np +import pytest +from scipy.optimize import linear_sum_assignment + +from eegprep.functions.miscfunc.kmeans_st import kmeans_st +from eegprep.functions.miscfunc.loc_subsets import loc_subsets +from eegprep.functions.miscfunc.make_timewarp import make_timewarp +from eegprep.functions.miscfunc.misc import finite_matmul +from eegprep.functions.miscfunc.promax import promax +from eegprep.functions.miscfunc.runicalowmem import runicalowmem +from eegprep.functions.miscfunc.runpca import runpca +from eegprep.functions.miscfunc.runpca2 import runpca2 +from eegprep.functions.miscfunc.varimax import varimax +from eegprep.functions.miscfunc.varsort import varsort +from eegprep.functions.miscfunc.zica import zica +from tests.eeglab_tests import eeglab_test + + +MISC_ROOT = "unittesting_miscfunc" + + +@eeglab_test(f"{MISC_ROOT}/kmeans_st/miscfunc_kmeans_st_wrapperTest.m", "test_test_kmeans_st") +def test_kmeans_st_current_suite_cluster_counts_restarts_and_sse(): + rng = np.random.default_rng(10) + observations = np.vstack((rng.normal((-3, 0), 0.15, (100, 2)), rng.normal((3, 0), 0.15, (100, 2)))) + + centers, labels, sse = kmeans_st(observations, 2) + restarted_centers, restarted_labels, restarted_sse = kmeans_st(observations, 2, 10) + many_centers, many_labels, many_sse = kmeans_st(observations, 20, 10) + + assert centers.shape == (2, 2) + assert set(labels.tolist()) == {0, 1} + assert np.mean(labels[:100] == labels[0]) == 1 + assert np.mean(labels[100:] == labels[100]) == 1 + assert labels[0] != labels[100] + np.testing.assert_allclose( + sse, + np.sum((observations - centers[labels]) ** 2), + rtol=1e-14, + atol=1e-14, + ) + assert restarted_centers.shape == (2, 2) + assert restarted_labels.shape == (200,) + assert restarted_sse <= sse + 1e-12 + assert many_centers.shape == (20, 2) + assert len(np.unique(many_labels)) == 20 + assert np.isfinite(many_sse) + + +@eeglab_test(f"{MISC_ROOT}/loc_subsets/miscfunc_loc_subsets_wrapperTest.m", "test_test_loc_subsets") +def test_loc_subsets_current_suite_balances_spatial_sets_and_honors_mandatory_channels(): + angles = np.linspace(0, 2 * np.pi, 12, endpoint=False) + chanlocs = [ + {"labels": f"E{index + 1}", "X": np.cos(angle), "Y": np.sin(angle), "Z": 0.2 * (-1) ** index} + for index, angle in enumerate(angles) + ] + before = set(plt.get_fignums()) + subsets, membership, positions = loc_subsets( + chanlocs, + [4, 4], + True, + True, + [[0, 3], [1, 4]], + ) + created = set(plt.get_fignums()) - before + try: + assert [len(subset) for subset in subsets] == [4, 4, 4] + np.testing.assert_array_equal(np.sort(np.concatenate(subsets)), np.arange(12)) + assert {0, 3}.issubset(subsets[0]) + assert {1, 4}.issubset(subsets[1]) + assert membership.shape == (12,) + assert positions.shape == (3, 12) + assert len(created) == 2 + finally: + for figure in created: + plt.close(figure) + + +def _timewarp_eeg() -> dict: + response_latencies = [300.0, 320.0, 310.0, 900.0, 305.0] + return { + "epoch": [ + { + "eventtype": ["square", "noise", "rt"], + "eventlatency": [[0.0], [100.0], [latency]], + "eventaccuracy": [[1], [0], [1 if index != 1 else 0]], + } + for index, latency in enumerate(response_latencies) + ] + } + + +@eeglab_test(f"{MISC_ROOT}/make_timewarp/miscfunc_make_timewarp_wrapperTest.m", "test_test_make_timewarp") +def test_make_timewarp_current_suite_default_outlier_and_condition_calls(): + eeg = _timewarp_eeg() + default = make_timewarp(eeg, ["square", "rt"]) + broad = make_timewarp( + eeg, + ["square", "rt"], + baseline_latency=0, + max_std_for_absolute=3, + max_std_for_relative=2, + ) + strict = make_timewarp( + eeg, + ["square", "rt"], + baseline_latency=0, + max_std_for_absolute=0.6, + max_std_for_relative=0.4, + ) + conditioned = make_timewarp( + eeg, + ["square", "rt"], + baseline_latency=0, + event_conditions=["latency < 20000", "latency > 1000 || accuracy == 1"], + max_std_for_absolute=3, + max_std_for_relative=2, + ) + + assert default["latencies"].shape == (5, 2) + np.testing.assert_array_equal(default["epochs"], np.arange(5)) + np.testing.assert_array_equal(broad["epochs"], np.arange(5)) + assert 3 not in strict["epochs"] + np.testing.assert_array_equal(conditioned["epochs"], [0, 2, 3, 4]) + assert conditioned["event_sequence"] == ["square", "rt"] + + +@eeglab_test(f"{MISC_ROOT}/promax/miscfunc_promax_wrapperTest.m", "test_pass_column_vector") +def test_promax_current_suite_singular_column_vector_is_finite(): + rotation, orthogonal = promax(np.asarray([[1.0], [2.0], [3.0]])) + assert rotation.shape == orthogonal.shape == (3, 3) + assert np.all(np.isfinite(rotation)) + assert np.all(np.isfinite(orthogonal)) + + +@eeglab_test(f"{MISC_ROOT}/promax/miscfunc_promax_wrapperTest.m", "test_pass_general") +def test_promax_current_suite_general_rotation_contract(): + data = np.asarray([[-1, 0, 1, 2], [5, -2, 3, -4], [0, 1, -1, 0]], dtype=float) + rotation, orthogonal = promax(data) + assert rotation.shape == orthogonal.shape == (3, 3) + np.testing.assert_allclose(finite_matmul(orthogonal, orthogonal.T), np.eye(3), rtol=1e-12, atol=1e-12) + assert np.linalg.matrix_rank(rotation) == 3 + assert np.all(np.isfinite(finite_matmul(rotation, data))) + + +@eeglab_test(f"{MISC_ROOT}/promax/miscfunc_promax_wrapperTest.m", "test_pass_maxit") +def test_promax_current_suite_iteration_limit_is_deterministic(): + data = np.asarray([[-1, 0, 1, 2], [5, -2, 3, -4], [0, 1, -1, 0]], dtype=float) + first = promax(data, max_iterations=2) + second = promax(data, max_iterations=2) + np.testing.assert_allclose(first[0], second[0], rtol=0, atol=0) + np.testing.assert_allclose(first[1], second[1], rtol=0, atol=0) + + +@eeglab_test(f"{MISC_ROOT}/promax/miscfunc_promax_wrapperTest.m", "test_pass_ncomps_small") +def test_promax_current_suite_reduced_rotation_operates_on_original_channels(): + data = np.asarray([[-1, 0, 1, 2], [5, -2, 3, -4], [0, 1, -1, 0]], dtype=float) + rotation, orthogonal = promax(data, n_components=2) + assert rotation.shape == orthogonal.shape == (2, 3) + assert finite_matmul(rotation, data).shape == (2, 4) + np.testing.assert_allclose(finite_matmul(orthogonal, orthogonal.T), np.eye(2), rtol=1e-12, atol=1e-12) + matlab_rotation, matlab_varimax = promax(data, n_components=2, max_iterations=2) + # Golden values were generated with pinned EEGLAB 8ac485f. Component + # signs are mathematically arbitrary, so compare magnitudes. + np.testing.assert_allclose( + np.abs(matlab_rotation), + np.abs( + [ + [0.817648255658059, -0.371658928580924, -0.520321459598312], + [0.753283725590137, 0.251094792144295, -0.627736492228675], + ] + ), + rtol=2e-12, + atol=2e-12, + ) + np.testing.assert_allclose( + np.abs(matlab_varimax), + np.abs( + [ + [0.437473594303329, -0.893099927529104, -0.104830213845220], + [0.680046327931768, 0.404859211314217, -0.611249548777379], + ] + ), + rtol=2e-12, + atol=2e-12, + ) + + +@eeglab_test(f"{MISC_ROOT}/promax/miscfunc_promax_wrapperTest.m", "test_pass_row_vector") +def test_promax_current_suite_one_component_rotation_is_identity(): + rotation, orthogonal = promax(np.asarray([[-1.0, 0.0, 1.0, 92.0]])) + np.testing.assert_array_equal(rotation, [[1.0]]) + np.testing.assert_array_equal(orthogonal, [[1.0]]) + + +@eeglab_test(f"{MISC_ROOT}/icademo/miscfunc_icademo_wrapperTest.m", "test_pass_general") +@eeglab_test(f"{MISC_ROOT}/runicalowmem/miscfunc_runicalowmem_wrapperTest.m", "test_test_runicalowmem") +@eeglab_test(f"{MISC_ROOT}/testica/miscfunc_testica_wrapperTest.m", "test_test_testica") +def test_runicalowmem_current_suite_recovers_deterministic_independent_sources(): + # The current icademo body is entirely commented, while testica is an + # interactive plot benchmark without accuracy assertions. This numerical + # recovery check preserves their scientific intent without porting obsolete + # pause-driven demos as public APIs. + rng = np.random.default_rng(4) + samples = 3000 + sources = np.vstack( + ( + rng.laplace(size=samples), + rng.uniform(-np.sqrt(3), np.sqrt(3), size=samples), + np.sign(np.sin(np.linspace(0, 90, samples))), + ) + ) + mixing = np.asarray([[1.0, 0.5, -0.2], [0.3, 1.0, 0.4], [-0.4, 0.2, 1.0]]) + data = finite_matmul(mixing, sources) + weights, sphere, *_rest = runicalowmem( + data, + extended=1, + seed=11, + maxsteps=128, + stop=1e-6, + verbose="off", + ) + recovered = finite_matmul( + finite_matmul(weights, sphere), + data - np.mean(data, axis=1, keepdims=True), + ) + correlations = np.abs(np.corrcoef(recovered, sources)[:3, 3:]) + recovered_rows, source_rows = linear_sum_assignment(-correlations) + assert np.min(correlations[recovered_rows, source_rows]) > 0.9 + + standard_weights, standard_sphere, *_standard_rest = runicalowmem( + data, + seed=11, + maxsteps=64, + verbose="off", + ) + assert standard_weights.shape == standard_sphere.shape == (3, 3) + assert np.all(np.isfinite(standard_weights)) + assert np.all(np.isfinite(standard_sphere)) + + +@eeglab_test(f"{MISC_ROOT}/runpca/miscfunc_runpca_wrapperTest.m", "test_pass_simple_pca") +def test_runpca_current_suite_rank_one_data_reconstructs_and_orders_variance(): + data = np.vstack((np.arange(8, dtype=float), np.arange(8, dtype=float))) + centered = data - np.mean(data, axis=1, keepdims=True) + components, mixing, singular = runpca(data) + np.testing.assert_allclose(finite_matmul(mixing, components), centered, rtol=1e-13, atol=1e-13) + np.testing.assert_allclose(finite_matmul(components, components.T), np.eye(2), rtol=1e-13, atol=1e-13) + assert singular[0, 0] == pytest.approx(np.sqrt(84), rel=1e-13) + assert singular[1, 1] < 1e-14 + + +@eeglab_test(f"{MISC_ROOT}/runpca2/miscfunc_runpca2_wrapperTest.m", "test_test_runpca2") +def test_runpca2_current_suite_full_reduced_and_large_channel_cases(): + upstream_data = np.asarray( + [ + [2, 5, 3, 6, 7, 2, 6, 8, 1, 2], + [6, 1, 10, 234, 3, 5, 464, 3, 2, 5], + [1, 1, 1, 1, 3, 5, 1, 1, 4, 5], + [4, 23456, 2, 3, 1, 1, 34, 2, 3, 5], + [20, 30, 10, 10, 34, 10, 30, 20, 30, 10], + ], + dtype=float, + ) + _upstream_components, upstream_mixing, upstream_scales = runpca2(upstream_data, 3) + # This upstream matrix spans four orders of magnitude; the 3e-8 relative + # bound covers LAPACK eigenvector differences while remaining far below + # the scale of any scientifically meaningful loading change. + np.testing.assert_allclose( + np.abs(upstream_mixing), + np.abs( + [ + [-0.267285337417358, -0.887335756250489, 0.566078569671753], + [23.9382481949261, -145.190072705975, -0.113221069510140], + [0.433916229756323, 0.692175058147292, -0.018444428160592], + [-7034.9729670268, -0.493135863973243, -0.00437367024143441], + [-3.2042903605228, -1.82911199166489, 8.70677338041587], + ] + ), + rtol=3e-8, + atol=1e-9, + ) + np.testing.assert_allclose( + upstream_scales, + [7035.01444303606, 145.206792255322, 8.72591118345028, 2.25550175815324, 1.20621009228612], + rtol=5e-9, + atol=5e-9, + ) + + rng = np.random.default_rng(9) + for channels, samples, retained in ((5, 10, 3), (32, 100, 26)): + data = rng.normal(size=(channels, samples)) + full_components, full_mixing, scales = runpca2(data) + components, mixing, reduced_scales = runpca2(data, retained) + centered = data - np.mean(data, axis=1, keepdims=True) + np.testing.assert_allclose(finite_matmul(full_mixing, full_components), centered, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose( + finite_matmul(full_components, full_components.T) / samples, + np.eye(channels), + rtol=1e-11, + atol=1e-11, + ) + assert components.shape == (retained, samples) + assert mixing.shape == (channels, retained) + assert scales.shape == reduced_scales.shape == (channels,) + assert np.all(np.diff(scales) <= 0) + + +def _varimax_criterion(data: np.ndarray) -> float: + return float(np.sum(np.mean(data**4, axis=1) - np.mean(data**2, axis=1) ** 2)) + + +@eeglab_test(f"{MISC_ROOT}/varimax/miscfunc_varimax_wrapperTest.m", "test_test_varimax") +def test_varimax_current_suite_default_tolerance_and_reorder_modes(): + data = np.random.default_rng(7).normal(size=(32, 100)) + default_rotation, default_data = varimax(data) + numeric_rotation, numeric_data = varimax(data, 1e-2, True) + named_rotation, named_data = varimax(data, 1e-2, "reorder") + + np.testing.assert_allclose(default_data, finite_matmul(default_rotation, data), rtol=1e-13, atol=1e-13) + np.testing.assert_allclose( + finite_matmul(default_rotation, default_rotation.T), + np.eye(32), + rtol=1e-12, + atol=1e-12, + ) + assert _varimax_criterion(default_data) >= _varimax_criterion(data) - 1e-11 + np.testing.assert_allclose(numeric_rotation, named_rotation, rtol=0, atol=0) + np.testing.assert_allclose(numeric_data, named_data, rtol=0, atol=0) + + upstream_data = np.asarray([[-1, 0, 1, 2], [5, -2, 3, -4], [0, 1, -1, 0]], dtype=float) + upstream_rotation, upstream_rotated = varimax(upstream_data, 1e-2, "reorder") + np.testing.assert_allclose( + upstream_rotation, + [ + [-0.421894420114366, 0.898540791629798, 0.120952652115025], + [0.777001824218500, 0.427079904793369, -0.462461803917697], + [0.467197242540543, 0.101129623251460, 0.878350463006385], + ], + rtol=2e-12, + atol=2e-12, + ) + np.testing.assert_allclose( + upstream_rotated, + [ + [4.91459837826336, -1.67612893114457, 2.15277530266000, -4.43795200674792], + [1.35839769974834, -1.31662161350443, 2.52070334251630, -0.154315970736475], + [0.0384508737167593, 0.676091216503464, -0.107764350711461, 0.529875992075243], + ], + rtol=2e-12, + atol=2e-12, + ) + + +@eeglab_test(f"{MISC_ROOT}/varsort/miscfunc_varsort_wrapperTest.m", "test_test_varsort") +def test_varsort_current_suite_orders_projected_component_power_and_supports_reduction(): + activations = np.asarray([[1.0, -1.0, 1.0, -1.0], [4.0, -4.0, 4.0, -4.0]]) + weights = np.asarray([[1.0, 0.0, 0.0], [0.0, 2.0, 0.0]]) + sphere = np.eye(3) + order, power = varsort(activations, weights, sphere) + np.testing.assert_array_equal(order, [1, 0]) + np.testing.assert_allclose(power, [2.0, 0.5], rtol=1e-14, atol=1e-14) + + square_order, square_power = varsort(activations, np.diag([1.0, 2.0]), np.eye(2)) + np.testing.assert_array_equal(square_order, [1, 0]) + np.testing.assert_allclose(square_power, [4.0, 1.0], rtol=1e-14, atol=1e-14) + + +@eeglab_test(f"{MISC_ROOT}/zica/miscfunc_zica_wrapperTest.m", "test_test_zica") +def test_zica_current_suite_continuous_and_epoched_baselines_use_true_global_peaks(): + activations = np.asarray( + [ + [1, -1, 1, -1, 1, -1, 1, -20], + [2, -2, 2, -2, 2, -2, 2, -2], + [1, 0, -1, 0, 1, 0, -1, 0], + ], + dtype=float, + ) + continuous = zica(activations) + epoched = zica(activations, 4, [0, 1, 2]) + + assert continuous[0].shape == epoched[0].shape == activations.shape + assert continuous[3][0] == 0 + assert continuous[4][0] == 7 + assert epoched[3][0] == 0 + assert epoched[4][0] == 7 + assert np.all(np.diff(epoched[2]) <= 0) + + +def test_decomposition_helpers_reject_invalid_dimensions_and_indices(): + with pytest.raises(ValueError, match="clusters"): + kmeans_st(np.ones((2, 2)), 3) + with pytest.raises(ValueError, match="n_components"): + runpca(np.ones((2, 5)), 3) + with pytest.raises(ValueError, match="divide"): + zica(np.ones((2, 7)), frames=4) + with pytest.raises(ValueError, match="arbitrary code|unsupported expression"): + make_timewarp( + {"epoch": [{"eventtype": ["x"], "eventlatency": [0]}]}, + ["x"], + event_conditions=["__import__('os')"], + ) diff --git a/tests/test_sigprocfunc_ica_legacy_eeglab_tests.py b/tests/test_sigprocfunc_ica_legacy_eeglab_tests.py new file mode 100644 index 00000000..aa586175 --- /dev/null +++ b/tests/test_sigprocfunc_ica_legacy_eeglab_tests.py @@ -0,0 +1,133 @@ +"""Current eeglab_tests ports for legacy low-level ICA functions.""" + +from __future__ import annotations + +import numpy as np + +from eegprep.functions.miscfunc.misc import finite_matmul +from eegprep.functions.sigprocfunc.icadefs import icadefs +from eegprep.functions.sigprocfunc.kmeanscluster import kmeanscluster +from eegprep.functions.sigprocfunc.posact import posact +from eegprep.functions.sigprocfunc.runica import runica +from eegprep.functions.sigprocfunc.runica_ml2 import runica_ml2 +from eegprep.functions.sigprocfunc.runica_mlb import runica_mlb +from tests.eeglab_tests import eeglab_test + + +SIGPROC_ROOT = "unittesting_sigprocfunc" + + +@eeglab_test(f"{SIGPROC_ROOT}/icadefs/sigprocfunc_icadefs_wrapperTest.m", "test_pass_general") +def test_icadefs_current_suite_has_platform_binary_and_sampling_defaults(): + defaults = icadefs() + assert defaults.ICABINARY in {"ica_linux", "ica_osx", "binica.exe"} + assert defaults.DEFAULT_SRATE == 256.0175 + assert defaults.DEFAULT_TIMLIM == (-1000, 2000) + assert len(defaults.BACKCOLOR) == 3 + + +@eeglab_test( + f"{SIGPROC_ROOT}/kmeanscluster/sigprocfunc_kmeanscluster_wrapperTest.m", + "test_test_kmeanscluster", +) +def test_kmeanscluster_current_suite_exact_example_and_high_dimensional_case(): + example = np.asarray([[1, 1], [2, 1], [4, 3], [5, 4]], dtype=float) + labels, centers, previous_labels, unchanged = kmeanscluster(example, 2) + np.testing.assert_array_equal(labels, [0, 0, 1, 1]) + np.testing.assert_allclose(centers, [[1.5, 1.0], [4.5, 3.5]]) + np.testing.assert_array_equal(previous_labels, labels) + np.testing.assert_array_equal(unchanged, example) + + random_data = np.random.default_rng(12).normal(size=(100, 20)) + random_labels, random_centers, random_previous, _random_unchanged = kmeanscluster( + random_data, + 5, + randomized=True, + random_state=3, + ) + assert random_labels.shape == (100,) + assert random_centers.shape == (5, 20) + np.testing.assert_array_equal(random_previous, random_labels) + assert len(np.unique(random_labels)) == 5 + + +@eeglab_test(f"{SIGPROC_ROOT}/posact/sigprocfunc_posact_wrapperTest.m", "test_pass_general") +def test_posact_current_suite_rectangular_weights_exact_outputs(): + data = np.asarray([[-1, 0, 1, 2], [5, -2, 3, -4], [0, 1, -1, 0]], dtype=float) + weights = np.asarray([[1, 2, -1], [-3, -4, 10]], dtype=float) + activations, inverse, oriented_weights = posact(data, weights) + expected_activations = np.asarray([[9, -5, 8, -6], [17, -18, 25, -10]], dtype=float) + expected_inverse = np.linalg.pinv(weights) + expected_inverse[:, 1] *= -1 + expected_weights = np.asarray([[1, 2, -1], [3, 4, -10]], dtype=float) + np.testing.assert_allclose(activations, expected_activations, rtol=1e-14, atol=1e-14) + np.testing.assert_allclose(inverse, expected_inverse, rtol=1e-14, atol=1e-14) + np.testing.assert_allclose(oriented_weights, expected_weights, rtol=1e-14, atol=1e-14) + + +@eeglab_test(f"{SIGPROC_ROOT}/posact/sigprocfunc_posact_wrapperTest.m", "test_pass_sphere") +def test_posact_current_suite_explicit_sphere_exact_outputs(): + data = np.asarray([[-1, 0, 1, 2], [5, -2, 3, -4], [0, 1, -1, 0]], dtype=float) + weights = np.asarray([[1, 2, -1], [-3, -4, 10]], dtype=float) + sphere = np.asarray([[-1, 0, 1], [2, 3, -5], [10, -6, 4]], dtype=float) + activations, inverse, oriented_weights = posact(data, weights, sphere) + np.testing.assert_allclose(activations, [[67, -37, 42, -62], [-455, 201, -178, 478]]) + np.testing.assert_allclose(inverse, np.linalg.pinv(finite_matmul(weights, sphere))) + np.testing.assert_array_equal(oriented_weights, weights) + + +@eeglab_test(f"{SIGPROC_ROOT}/posact/sigprocfunc_posact_wrapperTest.m", "test_pass_weights_symmetric") +def test_posact_current_suite_square_weights_and_all_negative_component(): + data = np.asarray([[-1, 0, 1, 2], [5, -2, 3, -4], [0, 1, -1, 0]], dtype=float) + weights = np.asarray([[1, 2, -1], [-3, -4, 10], [5, 6, 7]], dtype=float) + activations, inverse, oriented_weights = posact(data, weights) + np.testing.assert_allclose( + activations, + [[9, -5, 8, -6], [17, -18, 25, -10], [25, -5, 16, -14]], + ) + np.testing.assert_allclose(finite_matmul(inverse, activations), data, rtol=1e-13, atol=1e-13) + np.testing.assert_array_equal(oriented_weights[1], -weights[1]) + + all_negative, _all_negative_inverse, all_negative_weights = posact( + np.ones((2, 5)), + -np.eye(2), + ) + np.testing.assert_array_equal(all_negative, np.ones((2, 5))) + np.testing.assert_array_equal(all_negative_weights, np.eye(2)) + + +def _small_ica_data() -> np.ndarray: + rng = np.random.default_rng(18) + sources = np.vstack((rng.laplace(size=600), rng.uniform(-2, 2, size=600), rng.normal(size=600) ** 3)) + return finite_matmul( + np.asarray([[1.0, 0.2, -0.3], [0.1, 1.0, 0.4], [-0.2, 0.3, 1.0]]), + sources, + ) + + +def _assert_variant_matches_runica(variant, defaults: dict) -> None: + data = _small_ica_data() + options = {"seed": 29, "maxsteps": 3, "verbose": "off"} + expected = runica(data, **defaults, **options) + actual = variant(data, **options) + assert len(actual) == len(expected) == 6 + for actual_value, expected_value in zip(actual, expected): + np.testing.assert_allclose(actual_value, expected_value, rtol=0, atol=0) + + +@eeglab_test(f"{SIGPROC_ROOT}/runica_ml2/sigprocfunc_runica_ml2_wrapperTest.m", "test_test_runica_ml2") +def test_runica_ml2_current_suite_corrects_miswired_upstream_call(): + # The MATLAB method named test_runica_ml2 accidentally calls runica rather + # than runica_ml2. This corrected port exercises the named compatibility API. + _assert_variant_matches_runica( + runica_ml2, + {"extended": 1, "posact": "on", "bias": "off", "anneal": 0.95}, + ) + + +@eeglab_test(f"{SIGPROC_ROOT}/runica_mlb/sigprocfunc_runica_mlb_wrapperTest.m", "test_test_runica_mlb") +def test_runica_mlb_current_suite_delegates_to_qualified_engine(): + _assert_variant_matches_runica( + runica_mlb, + {"extended": 1, "posact": "on", "bias": "on", "anneal": 0.98}, + ) From 5a22f484d37aeb94efc3a2c9233fbb57bd76cacf Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:52 -0700 Subject: [PATCH 26/45] feat: add Neuroscan CNT loading parity (#354) --- docs/source/api/data_loading_and_saving.rst | 7 + docs/source/changelog.rst | 5 + src/eegprep/__init__.py | 2 + src/eegprep/functions/popfunc/pop_fileio.py | 20 +- src/eegprep/functions/popfunc/pop_loadcnt.py | 141 +++++ src/eegprep/functions/sigprocfunc/loadcnt.py | 564 +++++++++++++++++++ src/eegprep/resources/help/pop_fileio_cnt.md | 7 +- src/eegprep/resources/help/pop_loadcnt.md | 35 ++ tests/test_cnt_eeglab_tests.py | 440 +++++++++++++++ 9 files changed, 1215 insertions(+), 6 deletions(-) create mode 100644 src/eegprep/functions/popfunc/pop_loadcnt.py create mode 100644 src/eegprep/functions/sigprocfunc/loadcnt.py create mode 100644 src/eegprep/resources/help/pop_loadcnt.md create mode 100644 tests/test_cnt_eeglab_tests.py diff --git a/docs/source/api/data_loading_and_saving.rst b/docs/source/api/data_loading_and_saving.rst index bf8d1231..6db9be67 100644 --- a/docs/source/api/data_loading_and_saving.rst +++ b/docs/source/api/data_loading_and_saving.rst @@ -25,6 +25,7 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.eeg_mergelocs eegprep.floatread eegprep.floatwrite + eegprep.loadcnt eegprep.loadeeg eegprep.loadtxt eegprep.loadset @@ -35,6 +36,7 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.mne2eeg_epochs eegprep.openbdf eegprep.parsetxt + eegprep.pop_loadcnt eegprep.readbdf eegprep.readeetraklocs eegprep.readegilocs @@ -49,3 +51,8 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. ``readegilocs`` includes packaged EGI montages for 32/33, 64/65, 128/129, and 256/257-channel nets. + +``loadcnt`` and ``pop_loadcnt`` read Neuroscan CNT data without an EEGLAB +checkout. They support 16- and 32-bit recordings, channel-blocked storage, +microvolt calibration, partial reads, event tables, and ``.fdt``-backed data. +ANT Neuro CNT is a separate format and is not accepted by this reader. diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index f7bc33e2..09ff803a 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,11 @@ the `GitHub Releases `_ page. Unreleased ========== +- Neuroscan ``loadcnt`` and ``pop_loadcnt`` now provide standalone 16/32-bit + CNT import with per-channel microvolt calibration, channel-blocked reads, + partial sample ranges, response and boundary events, and optional ``.fdt`` + memory mapping. The File-IO CNT action uses this reader instead of MNE's + ambiguous auto-detection path. - Current EEGLAB legacy ICA/decomposition tests now have substantive Python ports for PCA and whitening, Varimax/Promax rotation, component orientation, variance and z-score ordering, deterministic k-means, spatial channel subsets, diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 35bf9f4c..b45a1562 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -217,6 +217,7 @@ "minphaserceps": ("eegprep.plugins.firfilt.minphaserceps", "minphaserceps"), "load_catalog_entries": ("eegprep.extension_catalog_validation", "load_catalog_entries"), "load_extension_catalog": ("eegprep.extension_catalog", "load_extension_catalog"), + "loadcnt": ("eegprep.functions.sigprocfunc.loadcnt", "loadcnt"), "loadeeg": ("eegprep.functions.sigprocfunc.loadeeg", "loadeeg"), "loadset": ("eegprep.functions.popfunc.pop_loadset", "loadset"), "loadtxt": ("eegprep.functions.sigprocfunc.loadtxt", "loadtxt"), @@ -335,6 +336,7 @@ "pop_importpres": ("eegprep.functions.popfunc.pop_importpres", "pop_importpres"), "pop_interp": ("eegprep.functions.popfunc.pop_interp", "pop_interp"), "pop_loadbci": ("eegprep.functions.popfunc.pop_loadbci", "pop_loadbci"), + "pop_loadcnt": ("eegprep.functions.popfunc.pop_loadcnt", "pop_loadcnt"), "pop_load_frombids": ("eegprep.functions.popfunc.pop_load_frombids", "pop_load_frombids"), "pop_loadstudy": ("eegprep.functions.studyfunc.pop_loadstudy", "pop_loadstudy"), "pop_loadset": ("eegprep.functions.popfunc.pop_loadset", "pop_loadset"), diff --git a/src/eegprep/functions/popfunc/pop_fileio.py b/src/eegprep/functions/popfunc/pop_fileio.py index d088c8b9..7ac926f8 100644 --- a/src/eegprep/functions/popfunc/pop_fileio.py +++ b/src/eegprep/functions/popfunc/pop_fileio.py @@ -13,6 +13,7 @@ from eegprep.functions.popfunc._file_io import mne_raw_to_eeg from eegprep.functions.popfunc._pop_utils import format_history_value, parse_numeric_sequence from eegprep.functions.popfunc.pop_importdata import pop_importdata +from eegprep.functions.popfunc.pop_loadcnt import pop_loadcnt from eegprep.functions.popfunc.pop_loadset import _is_hdf5_file, pop_loadset from eegprep.functions.popfunc.pop_select import pop_select @@ -44,6 +45,12 @@ def pop_fileio( eeg = pop_importdata("data", str(path), "setname", path.stem, "dataformat", "matlab", **kwargs) elif suffix in {".csv", ".txt", ".tsv", ".npy", ".npz"}: eeg = pop_importdata("data", str(path), "setname", path.stem, **kwargs) + elif suffix == ".cnt": + if blockrange is not None: + start, stop = _blockrange_values(blockrange) + kwargs["t1"] = start + kwargs["lddur"] = stop - start + eeg = pop_loadcnt(path, **kwargs) else: reader = _reader_for_suffix(suffix) raw = reader(str(path), preload=True, verbose=False) @@ -87,16 +94,21 @@ def _reader_for_suffix(suffix: str): def _crop_raw_to_blockrange(raw: mne.io.BaseRaw, blockrange: Any) -> None: + start, stop = _blockrange_values(blockrange) + recording_stop = raw.n_times / float(raw.info["sfreq"]) + if start >= recording_stop: + raise ValueError("blockrange starts after the end of the recording") + raw.crop(tmin=start, tmax=min(stop, recording_stop), include_tmax=False) + + +def _blockrange_values(blockrange: Any) -> tuple[float, float]: values = np.asarray(blockrange, dtype=float).reshape(-1) if values.size != 2 or not np.all(np.isfinite(values)): raise ValueError("blockrange must contain two finite times in seconds") start, stop = (float(value) for value in values) if start < 0 or stop <= start: raise ValueError("blockrange must satisfy 0 <= start < stop") - recording_stop = raw.n_times / float(raw.info["sfreq"]) - if start >= recording_stop: - raise ValueError("blockrange starts after the end of the recording") - raw.crop(tmin=start, tmax=min(stop, recording_stop), include_tmax=False) + return start, stop def _select_imported_data(EEG: dict[str, Any], *, channels: Any, samples: Any, trials: Any) -> dict[str, Any]: diff --git a/src/eegprep/functions/popfunc/pop_loadcnt.py b/src/eegprep/functions/popfunc/pop_loadcnt.py new file mode 100644 index 00000000..82524c50 --- /dev/null +++ b/src/eegprep/functions/popfunc/pop_loadcnt.py @@ -0,0 +1,141 @@ +"""Import Neuroscan CNT recordings into EEGPrep datasets.""" + +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +from typing import Any + +import numpy as np + +from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset +from eegprep.functions.adminfunc.storage import MemmapData +from eegprep.functions.miscfunc.value_parsing import parse_key_value_args +from eegprep.functions.popfunc._pop_utils import format_history_value +from eegprep.functions.popfunc.eeg_emptyset import eeg_emptyset +from eegprep.functions.sigprocfunc.loadcnt import loadcnt + + +def pop_loadcnt( + filename: str | Path, + *args: Any, + return_com: bool = False, + **kwargs: Any, +) -> dict[str, Any] | tuple[dict[str, Any], str]: + """Load a Neuroscan CNT recording into an EEG dictionary. + + ``loadcnt`` options can be supplied as Python keywords or EEGLAB-style + key/value pairs. CNT events are exposed with 1-based EEG latencies; + response-only events are retained when ``keystroke="on"``. + """ + options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) + keystroke = _toggle(options.pop("keystroke", "off"), "keystroke") + path = Path(filename).expanduser() + cnt = loadcnt(path, **options) + data = cnt["data"] + header = cnt["header"] + electrodes = cnt["electloc"] + eeg = _empty_cnt_eeg(path, data, header, electrodes, cnt["ldnsamples"]) + eeg["event"] = [_eeg_event(event, keystroke=keystroke) for event in cnt["event"]] + eeg["event"] = [event for event in eeg["event"] if event is not None] + eeg = eeg_checkset(eeg, "eventconsistency") + events = [dict(event) for event in eeg["event"]] + eeg["urevent"] = np.asarray( + [{key: value for key, value in event.items() if key != "urevent"} for event in events], + dtype=object, + ) + for index, event in enumerate(events): + event["urevent"] = index + eeg["event"] = np.asarray(events, dtype=object) + history_options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) + command = _history_command(path, history_options) + eeg["history"] = command + return (eeg, command) if return_com else eeg + + +def _empty_cnt_eeg( + path: Path, + data: np.ndarray | MemmapData, + header: dict[str, Any], + electrodes: list[dict[str, Any]], + samples: int, +) -> dict[str, Any]: + rate = float(header["rate"]) + eeg = eeg_emptyset() + eeg.update( + { + "setname": "CNT file", + "filename": path.name, + "filepath": str(path.parent), + "subject": str(header.get("patient") or ""), + "comments": f"Original file: {path}", + "nbchan": int(header["nchannels"]), + "pnts": int(samples), + "trials": 1, + "srate": rate, + "xmin": 0.0, + "xmax": (int(samples) - 1) / rate, + "times": np.arange(int(samples), dtype=float) / rate * 1000, + "data": data, + "chanlocs": np.asarray( + [ + { + "labels": electrode["lab"], + "type": "EEG", + "urchan": index, + } + for index, electrode in enumerate(electrodes) + ], + dtype=object, + ), + "saved": "no", + } + ) + if isinstance(data, MemmapData): + eeg["datfile"] = data.path.name + return eeg + + +def _eeg_event(event: dict[str, Any], *, keystroke: bool) -> dict[str, Any] | None: + stimulus = int(event["stimtype"]) + accept_code = int(event["accept_ev1"]) + if accept_code in {11, 14}: + event_type: Any = "boundary" + elif stimulus: + event_type = stimulus + elif not keystroke: + return None + elif int(event["keypad_accept"]): + event_type = f"keypad{int(event['keypad_accept'])}" + else: + event_type = f"keyboard{int(event['keyboard'])}" + result: dict[str, Any] = { + "type": event_type, + "latency": float(event["offset"]), + "duration": 0.0, + } + for key in ("code", "accuracy", "accept", "epochevent"): + if key in event: + result[key] = deepcopy(event[key]) + return result + + +def _history_command(path: Path, options: dict[str, Any]) -> str: + pieces = [format_history_value(path)] + for key, value in options.items(): + pieces.extend([format_history_value(key), format_history_value(value, cell_for_sequence=None)]) + return f"EEG = pop_loadcnt({', '.join(pieces)});" + + +def _toggle(value: Any, name: str) -> bool: + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if text == "on": + return True + if text == "off": + return False + raise ValueError(f"{name} must be 'on' or 'off'") + + +__all__ = ["pop_loadcnt"] diff --git a/src/eegprep/functions/sigprocfunc/loadcnt.py b/src/eegprep/functions/sigprocfunc/loadcnt.py new file mode 100644 index 00000000..60e188f2 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/loadcnt.py @@ -0,0 +1,564 @@ +"""Read Neuroscan continuous CNT recordings.""" + +from __future__ import annotations + +from pathlib import Path +import struct +from typing import Any, BinaryIO +import warnings + +import numpy as np + +from eegprep.functions.adminfunc.storage import MemmapData +from eegprep.functions.miscfunc._validation import integer_scalar +from eegprep.functions.miscfunc.value_parsing import parse_key_value_args + + +_SETUP_SIZE = 900 +_CHANNEL_SIZE = 75 +_NCHANNELS_OFFSET = 370 +_RATE_OFFSET = 376 +_NSAMPLES_OFFSET = 864 +_EVENT_TABLE_OFFSET = 886 +_CONTINUOUS_SECONDS_OFFSET = 890 +_CHANNEL_OFFSET = 894 +_EVENT_HEADER = struct.Struct(" dict[str, Any]: + """Load a Neuroscan CNT file into a low-level CNT dictionary. + + Parameters use the names exposed by EEGLAB's ``loadcnt``. ``sample1`` is + a zero-based starting sample and overrides ``t1``; ``ldnsamples`` overrides + ``lddur``. Data are channel-major and scaled to microvolts by default. + + Args: + filename: Neuroscan CNT file. + *args: Optional EEGLAB-style key/value pairs. + t1: Start time in seconds. + sample1: Zero-based starting sample. + lddur: Duration to read in seconds. + ldnsamples: Number of samples to read. + scale: ``"on"`` for microvolts or ``"off"`` for stored counts. + dataformat: ``"auto"``, ``"int16"``, or ``"int32"``. + blockread: Optional byte block size override; ``1`` means interleaved. + memmapfile: Optional ``.fdt`` path for float32 disk-backed output. + precision: In-memory output precision, ``"single"`` or ``"double"``. + **kwargs: ``data_format`` alias and legacy ``keystroke`` option. + + Returns: + Dictionary containing ``header``, ``electloc``, ``data``, ``event``, + ``dataformat``, ``ldnsamples``, ``labels``, ``Teeg``, and ``tag``. + """ + options = { + "t1": t1, + "sample1": sample1, + "lddur": lddur, + "ldnsamples": ldnsamples, + "scale": scale, + "dataformat": dataformat, + "blockread": blockread, + "memmapfile": memmapfile, + "precision": precision, + } + options.update(parse_key_value_args(args, kwargs, lowercase_kwargs=True)) + unknown = set(options) - _LOAD_OPTIONS + if unknown: + names = ", ".join(sorted(unknown)) + raise ValueError(f"Unsupported loadcnt option(s): {names}") + if "data_format" in options: + if options.get("dataformat", "auto") != "auto": + raise ValueError("Use only one of dataformat and data_format") + options["dataformat"] = options.pop("data_format") + + path = Path(filename).expanduser() + if not path.is_file(): + raise FileNotFoundError(f"CNT file not found: {path}") + file_size = path.stat().st_size + with path.open("rb") as stream: + setup = _read_exact(stream, _SETUP_SIZE, "CNT setup header") + header = _parse_header(setup) + _validate_header(header) + channel_bytes = _read_exact( + stream, + int(header["nchannels"]) * _CHANNEL_SIZE, + "CNT channel headers", + ) + electloc = _parse_electrodes(channel_bytes, int(header["nchannels"])) + + data_offset = _SETUP_SIZE + _CHANNEL_SIZE * int(header["nchannels"]) + event_position = _resolve_event_position(header, file_size, data_offset) + resolved_format = _resolve_data_format(path, header, data_offset, event_position, options["dataformat"]) + bytes_per_sample = 2 if resolved_format == "int16" else 4 + total_samples = _sample_count( + header, + data_offset=data_offset, + event_position=event_position, + bytes_per_sample=bytes_per_sample, + ) + first_sample, sample_count = _selection( + rate=float(header["rate"]), + total_samples=total_samples, + t1=options["t1"], + sample1=options["sample1"], + lddur=options["lddur"], + ldnsamples=options["ldnsamples"], + ) + output_precision = _precision(options["precision"]) + scale_data = _toggle(options["scale"], "scale") + block_samples = _block_samples(header["channeloffset"], options["blockread"], bytes_per_sample) + data = _read_data( + path, + data_offset=data_offset, + channels=int(header["nchannels"]), + total_samples=total_samples, + first_sample=first_sample, + sample_count=sample_count, + dataformat=resolved_format, + block_samples=block_samples, + electrodes=electloc, + scale=scale_data, + precision=output_precision, + memmapfile=options["memmapfile"], + ) + events, event_header = _read_events( + path, + event_position=event_position, + file_size=file_size, + data_offset=data_offset, + channels=int(header["nchannels"]), + bytes_per_sample=bytes_per_sample, + first_sample=first_sample, + sample_count=sample_count, + ) + header["dataformat"] = resolved_format + header["samples"] = total_samples + header["block_samples"] = block_samples + return { + "header": header, + "electloc": electloc, + "data": data, + "event": events, + "dataformat": resolved_format, + "ldnsamples": sample_count, + "sample1": first_sample, + "labels": [electrode["lab"] for electrode in electloc], + "Teeg": event_header, + "tag": _last_byte(path), + } + + +def _parse_header(setup: bytes) -> dict[str, Any]: + return { + "rev": _text(setup[0:12]), + "nextfile": _unpack(" None: + if int(header["nchannels"]) <= 0: + raise ValueError("CNT header reports no channels") + if float(header["rate"]) <= 0: + raise ValueError("CNT header reports a non-positive sampling rate") + if int(header["channeloffset"]) < 0: + raise ValueError("CNT header reports a negative channel block offset") + + +def _parse_electrodes(data: bytes, channels: int) -> list[dict[str, Any]]: + electrodes = [] + for index in range(channels): + record = data[index * _CHANNEL_SIZE : (index + 1) * _CHANNEL_SIZE] + electrodes.append( + { + "lab": _text(record[0:10]), + "reference": _unpack(" int: + low = int(header["eventtablepos"]) + high = int(header["prevfile"]) + combined = low + high * 2**32 + candidates = [combined, low] if high else [low] + for candidate in candidates: + if data_offset <= candidate <= file_size: + return candidate + if low == 0 and int(header["numsamples"]) > 0: + return file_size + raise ValueError("CNT event-table position is outside the file") + + +def _resolve_data_format( + path: Path, + header: dict[str, Any], + data_offset: int, + event_position: int, + requested: Any, +) -> str: + value = str(requested).strip().lower() + if value in {"int16", "int32"}: + return value + if value != "auto": + raise ValueError("dataformat must be 'auto', 'int16', or 'int32'") + + channels = int(header["nchannels"]) + header_samples = int(header["numsamples"]) + data_bytes = event_position - data_offset + if header_samples > 0: + bytes_per_sample, remainder = divmod(data_bytes, channels * header_samples) + if remainder == 0 and bytes_per_sample in {2, 4}: + return f"int{bytes_per_sample * 8}" + + nextfile = int(header["nextfile"]) + if nextfile > 0 and nextfile + 52 < path.stat().st_size: + with path.open("rb") as stream: + stream.seek(nextfile + 52) + flag = stream.read(1) + if flag == b"\x01": + return "int32" + if flag == b"\x00": + return "int16" + + if data_bytes % (channels * 2) == 0: + warnings.warn( + "CNT sample width is ambiguous; using the Neuroscan/EEGLAB int16 fallback. " + "Pass dataformat explicitly to prevent ambiguity.", + RuntimeWarning, + stacklevel=3, + ) + return "int16" + raise ValueError("CNT data size cannot be interpreted as int16 or int32 samples") + + +def _sample_count( + header: dict[str, Any], + *, + data_offset: int, + event_position: int, + bytes_per_sample: int, +) -> int: + frame_bytes = int(header["nchannels"]) * bytes_per_sample + samples, remainder = divmod(event_position - data_offset, frame_bytes) + if remainder: + raise ValueError("CNT data section is not an exact number of channel frames") + if samples <= 0: + raise ValueError("CNT file contains no data samples") + header_samples = int(header["numsamples"]) + if header_samples > 0 and header_samples != samples: + warnings.warn( + f"CNT header reports {header_samples} samples but the data section contains {samples}; " + "using the data-section length.", + RuntimeWarning, + stacklevel=3, + ) + return samples + + +def _selection( + *, + rate: float, + total_samples: int, + t1: Any, + sample1: Any, + lddur: Any, + ldnsamples: Any, +) -> tuple[int, int]: + if sample1 is None or sample1 == "": + start_seconds = _finite_number(t1, "t1") + if start_seconds < 0: + raise ValueError("t1 must be non-negative") + start = _nearest_sample(start_seconds * rate) + else: + start = integer_scalar(sample1, "sample1") + if start < 0: + raise ValueError("sample1 must be non-negative") + if start >= total_samples: + raise ValueError("Requested CNT start sample is outside the recording") + + if ldnsamples is not None and ldnsamples != "": + count = integer_scalar(ldnsamples, "ldnsamples") + elif lddur is not None and lddur != "": + duration = _finite_number(lddur, "lddur") + count = _nearest_sample(duration * rate) + else: + count = total_samples - start + if count <= 0: + raise ValueError("Requested CNT sample count must be positive") + available = total_samples - start + if count > available: + warnings.warn( + f"Requested {count} CNT samples from sample {start}, but only {available} remain; truncating.", + RuntimeWarning, + stacklevel=3, + ) + count = available + return start, count + + +def _block_samples(header_value: Any, override: Any, bytes_per_sample: int) -> int: + value = int(header_value) if override is None or override == "" else integer_scalar(override, "blockread") + if value <= 1: + return 1 + blocks, remainder = divmod(value, bytes_per_sample) + if remainder or blocks <= 0: + raise ValueError("CNT channel block offset must be divisible by the sample width") + return blocks + + +def _read_data( + path: Path, + *, + data_offset: int, + channels: int, + total_samples: int, + first_sample: int, + sample_count: int, + dataformat: str, + block_samples: int, + electrodes: list[dict[str, Any]], + scale: bool, + precision: np.dtype[Any], + memmapfile: Any, +) -> np.ndarray | MemmapData: + mapped_path = None if memmapfile is None or str(memmapfile) == "" else Path(memmapfile).expanduser() + if mapped_path is not None and mapped_path.suffix.lower() != ".fdt": + raise ValueError("memmapfile must use the .fdt suffix") + target_dtype = np.dtype(" None: + lower = max(first_sample, source_start) + upper = min(first_sample + sample_count, source_start + values.shape[1]) + if lower >= upper: + return + selected = values[:, lower - source_start : upper - source_start].astype(target_dtype) + if scale: + selected = (selected - baselines[:, np.newaxis]) * factors[:, np.newaxis] + target[:, lower - first_sample : upper - first_sample] = selected + + full_blocks, tail_samples = divmod(total_samples, block_samples) + bytes_per_block = channels * block_samples * source_dtype.itemsize + first_block = first_sample // block_samples + last_block = min(full_blocks, (first_sample + sample_count + block_samples - 1) // block_samples) + blocks_per_chunk = max(1, 64 * 1024**2 // max(bytes_per_block, 1)) + with path.open("rb") as stream: + block = first_block + while block < last_block: + chunk_blocks = min(blocks_per_chunk, last_block - block) + stream.seek(data_offset + block * bytes_per_block) + payload = _read_exact(stream, chunk_blocks * bytes_per_block, "CNT sample block") + values = np.frombuffer(payload, dtype=source_dtype) + values = values.reshape(chunk_blocks, channels, block_samples).transpose(1, 0, 2) + store(values.reshape(channels, chunk_blocks * block_samples), block * block_samples) + block += chunk_blocks + if tail_samples and first_sample + sample_count > full_blocks * block_samples: + stream.seek(data_offset + full_blocks * bytes_per_block) + payload = _read_exact( + stream, + channels * tail_samples * source_dtype.itemsize, + "CNT final sample block", + ) + store( + np.frombuffer(payload, dtype=source_dtype).reshape(channels, tail_samples), full_blocks * block_samples + ) + + if mapped_path is None: + return target + if isinstance(target, np.memmap): + target.flush() + return MemmapData(mapped_path, (channels, sample_count), dtype=" tuple[list[dict[str, Any]], dict[str, int]]: + if event_position == file_size: + return [], {"teeg": 0, "size": 0, "offset": 0} + with path.open("rb") as stream: + stream.seek(event_position) + header_data = _read_exact(stream, _EVENT_HEADER.size, "CNT event-table header") + event_type, size, offset = _EVENT_HEADER.unpack(header_data) + parser = _EVENT_FORMATS.get(event_type) + if parser is None: + raise ValueError(f"Unsupported CNT event-table type: {event_type}") + if size % parser.size: + raise ValueError("CNT event-table size is not a whole number of records") + if event_position + _EVENT_HEADER.size + size > file_size: + raise ValueError("CNT event table is truncated") + payload = _read_exact(stream, size, "CNT event table") + + events = [] + for fields in parser.iter_unpack(payload): + stimulus, keyboard, keypad_byte, stored_offset = fields[:4] + if event_type == 3: + # Type 3 stores a zero-based global sample frame rather than a + # byte offset. Normalize every table type to a 1-based sample. + sample_number = float(stored_offset) + 1.0 + else: + sample_number = (float(stored_offset) - data_offset) / (channels * bytes_per_sample) + if sample_number != int(sample_number): + raise ValueError("CNT event offset is not aligned to a complete channel frame") + selected_sample = sample_number - first_sample + if selected_sample < 1 or selected_sample > sample_count: + continue + event = { + "stimtype": int(stimulus), + "keyboard": int(keyboard), + "keypad_accept": int(keypad_byte) & 0x0F, + "accept_ev1": int(keypad_byte) >> 4, + "offset": selected_sample, + } + if event_type in {2, 3}: + event.update( + { + "type": int(fields[4]), + "code": int(fields[5]), + "latency": float(fields[6]), + "epochevent": int(fields[7]), + "accept": int(fields[8]), + "accuracy": int(fields[9]), + } + ) + events.append(event) + return events, {"teeg": int(event_type), "size": int(size), "offset": int(offset)} + + +def _read_exact(stream: BinaryIO, size: int, label: str) -> bytes: + data = stream.read(size) + if len(data) != size: + raise ValueError(f"{label} is truncated: expected {size} bytes, found {len(data)}") + return data + + +def _unpack(format_string: str, data: bytes, offset: int) -> Any: + return struct.unpack_from(format_string, data, offset)[0] + + +def _text(data: bytes) -> str: + return data.split(b"\x00", 1)[0].decode("latin-1").strip() + + +def _last_byte(path: Path) -> int: + with path.open("rb") as stream: + stream.seek(-1, 2) + return int(stream.read(1)[0]) + + +def _finite_number(value: Any, name: str) -> float: + try: + number = float(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be numeric") from error + if not np.isfinite(number): + raise ValueError(f"{name} must be finite") + return number + + +def _nearest_sample(value: float) -> int: + return int(np.floor(value + 0.5)) + + +def _precision(value: Any) -> np.dtype[Any]: + name = str(value).strip().lower() + if name == "single": + return np.dtype(np.float32) + if name == "double": + return np.dtype(np.float64) + raise ValueError("precision must be 'single' or 'double'") + + +def _toggle(value: Any, name: str) -> bool: + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if text == "on": + return True + if text == "off": + return False + raise ValueError(f"{name} must be 'on' or 'off'") + + +__all__ = ["loadcnt"] diff --git a/src/eegprep/resources/help/pop_fileio_cnt.md b/src/eegprep/resources/help/pop_fileio_cnt.md index 1f263309..4c42b880 100644 --- a/src/eegprep/resources/help/pop_fileio_cnt.md +++ b/src/eegprep/resources/help/pop_fileio_cnt.md @@ -1,7 +1,7 @@ # POP_FILEIO_CNT - Import Neuroscan CNT recordings -The CNT File-IO menu action imports Neuroscan `.cnt` files through -`pop_fileio`. +The CNT File-IO menu action imports Neuroscan `.cnt` files through the +standalone `pop_loadcnt` reader. `pop_fileio` selects that reader automatically. Usage: @@ -9,6 +9,9 @@ Usage: EEG = pop_fileio("recording.cnt") ``` +For explicit sample ranges, count scaling, keyboard events, or disk-backed +loading, call `pop_loadcnt` directly. + Use this action when the dataset is a CNT recording. For text or array data, use `pop_importdata`. diff --git a/src/eegprep/resources/help/pop_loadcnt.md b/src/eegprep/resources/help/pop_loadcnt.md new file mode 100644 index 00000000..d38ff22f --- /dev/null +++ b/src/eegprep/resources/help/pop_loadcnt.md @@ -0,0 +1,35 @@ +# POP_LOADCNT - Import a Neuroscan CNT recording + +`pop_loadcnt` reads continuous Neuroscan `.cnt` recordings into an EEGPrep +dataset. Data are returned in microvolts with channel-major shape +`(channels, samples)`, and event latencies are 1-based like other EEGPrep +events. + +```python +from eegprep import pop_loadcnt + +EEG = pop_loadcnt("recording.cnt", dataformat="auto") +``` + +Use `sample1` for a zero-based starting sample or `t1` for a starting time in +seconds. `ldnsamples` selects an exact number of samples and takes precedence +over the duration in `lddur`. + +```python +EEG = pop_loadcnt( + "recording.cnt", + dataformat="int32", + sample1=5_000, + ldnsamples=10_000, + keystroke="on", +) +``` + +Set `scale="off"` to obtain stored integer counts represented in the requested +floating-point `precision`. For recordings too large to keep in memory, pass a +`.fdt` path as `memmapfile`; `EEG["data"]` will be a disk-backed array. + +CNT is also used by ANT Neuro, but that is a different file format. This loader +supports Neuroscan CNT files only. + +See also: `loadcnt`, `pop_fileio` diff --git a/tests/test_cnt_eeglab_tests.py b/tests/test_cnt_eeglab_tests.py new file mode 100644 index 00000000..1c22154f --- /dev/null +++ b/tests/test_cnt_eeglab_tests.py @@ -0,0 +1,440 @@ +"""Executable ports of the current EEGLAB CNT loader wrappers. + +Upstream suite: sccn/eeglab_tests@ff605546f3f70868916fb8d49c007472b3257b50 +EEGLAB tree: sccn/eeglab@8ac485f654d6bbb1a6acb8dc9ef3f2eaf3d409ba +CNT implementation: sccn/neuroscanio@5915f10abac2db12f07876b9cf994371973f6c49 + +The two upstream recordings are 14 MB and 27 MB Git LFS objects. These tests +generate specification-level CNT files so ordinary CI executes every option +path without copying those binary recordings into EEGPrep. +""" + +from __future__ import annotations + +from pathlib import Path +import struct +from typing import Any + +import numpy as np +import pytest + +import eegprep +from eegprep.functions.adminfunc.storage import MemmapData +from eegprep.functions.popfunc.pop_fileio import pop_fileio +from eegprep.functions.popfunc.pop_loadcnt import pop_loadcnt +from eegprep.functions.sigprocfunc.loadcnt import loadcnt +from tests.eeglab_tests import eeglab_test + + +LOADCNT_WRAPPER = "unittesting_binary/loadcnt/binary_loadcnt_wrapperTest.m" +POP_LOADCNT_WRAPPER = "unittesting_binary/pop_loadcnt/binary_pop_loadcnt_wrapperTest.m" + + +def _write_cnt( + path: Path, + counts: np.ndarray, + *, + dataformat: str, + rate: int, + block_samples: int = 1, + baselines: tuple[int, ...] | None = None, + sensitivities: tuple[float, ...] | None = None, + calibrations: tuple[float, ...] | None = None, + events: list[dict[str, int | float]] | None = None, + header_samples: int | None = None, + event_type: int = 2, +) -> Path: + counts = np.asarray(counts) + channels, samples = counts.shape + dtype = np.dtype(" tuple[Path, Path, np.ndarray, np.ndarray]: + samples = 1200 + counts16 = np.vstack( + [ + np.arange(samples, dtype=np.int16) - 600, + 400 - np.arange(samples, dtype=np.int16), + ] + ) + counts32 = np.vstack( + [ + np.arange(samples, dtype=np.int32) * 1000 - 500_000, + 300_000 - np.arange(samples, dtype=np.int32) * 250, + ] + ) + events = [ + {"stimtype": 7, "sample": 0, "code": 70}, + {"stimtype": 0, "keyboard": 4, "sample": 10}, + {"stimtype": 0, "keypad": 3, "sample": 20}, + {"stimtype": 99, "accept_ev1": 14, "sample": 30}, + {"stimtype": 8, "sample": 1100}, + ] + int16_file = _write_cnt( + tmp_path / "TEST.CNT", + counts16, + dataformat="int16", + rate=1, + block_samples=5, + events=events, + event_type=1, + ) + int32_file = _write_cnt( + tmp_path / "TEST32BIT_WITHEVENT.CNT", + counts32, + dataformat="int32", + rate=1, + events=events, + ) + return int16_file, int32_file, counts16, counts32 + + +@eeglab_test(LOADCNT_WRAPPER, "test_test_loadcnt") +def test_current_loadcnt_wrapper_cases(cnt_files: tuple[Path, Path, np.ndarray, np.ndarray], tmp_path: Path) -> None: + int16_file, int32_file, _counts16, _counts32 = cnt_files + + case1 = loadcnt(int32_file, "dataformat", "int32") + case2 = loadcnt(int32_file, dataformat="int32", t1=0, lddur=301) + case3 = loadcnt( + int32_file, + dataformat="int32", + t1=0, + sample1=0, + lddur="301", + ldnsamples=1000, + ) + case4 = loadcnt(int16_file, dataformat="int16") + mapped_file = tmp_path / "map.fdt" + case5 = loadcnt(int16_file, dataformat="int16", memmapfile=mapped_file) + case6 = loadcnt(int16_file, dataformat="int16", t1=0, lddur=227) + case7 = loadcnt( + int16_file, + dataformat="int16", + t1=0, + sample1=0, + lddur="227", + ldnsamples=1000, + ) + case8 = loadcnt(int32_file, keystroke="on", dataformat="int32") + + assert case1["data"].shape == (2, 1200) + assert case2["data"].shape == (2, 301) + assert case3["data"].shape == (2, 1000) + assert case4["data"].shape == (2, 1200) + assert isinstance(case5["data"], MemmapData) + np.testing.assert_array_equal(case5["data"], case4["data"]) + assert mapped_file.stat().st_size == 2 * 1200 * 4 + assert case6["data"].shape == (2, 227) + assert case7["data"].shape == (2, 1000) + assert case8["event"] == case1["event"] + + +@eeglab_test(POP_LOADCNT_WRAPPER, "test_test_pop_loadcnt") +def test_current_pop_loadcnt_wrapper_cases( + cnt_files: tuple[Path, Path, np.ndarray, np.ndarray], tmp_path: Path +) -> None: + int16_file, int32_file, _counts16, _counts32 = cnt_files + + case1 = pop_loadcnt(int32_file, dataformat="int32") + case2 = pop_loadcnt(int32_file, dataformat="int32", t1=0, lddur=301) + case3 = pop_loadcnt(int32_file, dataformat="int32", t1=0, sample1=0, lddur="301", ldnsamples=1000) + case4 = pop_loadcnt(int16_file, dataformat="int16") + case5 = pop_loadcnt(int16_file, dataformat="int16", memmapfile=tmp_path / "map.fdt") + case6 = pop_loadcnt(int16_file, dataformat="int16", t1=0, lddur=227) + case7 = pop_loadcnt(int16_file, dataformat="int16", t1=0, sample1=0, lddur="227", ldnsamples=1000) + case8, command = pop_loadcnt(int32_file, keystroke="on", dataformat="int32", return_com=True) + + assert [case1["pnts"], case2["pnts"], case3["pnts"]] == [1200, 301, 1000] + assert [case4["pnts"], case6["pnts"], case7["pnts"]] == [1200, 227, 1000] + assert isinstance(case5["data"], MemmapData) + assert case1["nbchan"] == case4["nbchan"] == 2 + assert case1["srate"] == case4["srate"] == 1 + assert [channel["labels"] for channel in case1["chanlocs"]] == ["E1", "E2"] + assert [event["type"] for event in case1["event"]] == [7, "boundary", 8] + assert [event["type"] for event in case8["event"]] == [7, "keyboard4", "keypad3", "boundary", 8] + assert [event["latency"] for event in case8["event"]] == [1, 11, 21, 31, 1101] + assert [event["urevent"] for event in case8["event"]] == list(range(5)) + assert "'keystroke', 'on'" in command + assert "'dataformat', 'int32'" in command + assert case8["history"] == command + + +def test_loadcnt_scales_counts_per_channel_and_honors_precision( + cnt_files: tuple[Path, Path, np.ndarray, np.ndarray], +) -> None: + int16_file, _int32_file, counts16, _counts32 = cnt_files + unscaled = loadcnt(int16_file, dataformat="int16", scale="off", precision="double") + scaled = loadcnt(int16_file, dataformat="auto", precision="single") + + np.testing.assert_array_equal(unscaled["data"], counts16) + expected = np.empty_like(counts16, dtype=np.float32) + expected[0] = (counts16[0] - 10) * (2.0 * 102.4 / 204.8) + expected[1] = (counts16[1] - 20) * (3.0 * 112.4 / 204.8) + np.testing.assert_allclose(scaled["data"], expected, rtol=1e-6, atol=1e-6) + assert unscaled["data"].dtype == np.float64 + assert scaled["data"].dtype == np.float32 + assert scaled["dataformat"] == "int16" + assert scaled["header"]["block_samples"] == 5 + + +def test_loadcnt_reads_int32_channel_blocks_by_byte_width(tmp_path: Path) -> None: + counts = np.asarray( + [ + [1, 2, 3, 4, 5, 6, 7], + [101, 102, 103, 104, 105, 106, 107], + [-1, -2, -3, -4, -5, -6, -7], + ], + dtype=np.int32, + ) + path = _write_cnt( + tmp_path / "blocked.cnt", + counts, + dataformat="int32", + rate=250, + block_samples=3, + ) + + loaded = loadcnt(path, dataformat="int32", scale="off", sample1=2, ldnsamples=4) + + np.testing.assert_array_equal(loaded["data"], counts[:, 2:6]) + assert loaded["header"]["channeloffset"] == 12 + assert loaded["header"]["block_samples"] == 3 + + +def test_pop_loadcnt_crops_and_rebases_events_at_sample_boundaries( + cnt_files: tuple[Path, Path, np.ndarray, np.ndarray], +) -> None: + _int16_file, int32_file, _counts16, _counts32 = cnt_files + + cropped = pop_loadcnt( + int32_file, + dataformat="int32", + t1=200, + sample1=10, + ldnsamples=21, + keystroke="on", + ) + + assert cropped["pnts"] == 21 + assert [event["type"] for event in cropped["event"]] == ["keyboard4", "keypad3", "boundary"] + assert [event["latency"] for event in cropped["event"]] == [1, 11, 21] + + +def test_pop_loadcnt_normalizes_type3_global_sample_frames(tmp_path: Path) -> None: + path = _write_cnt( + tmp_path / "type3.cnt", + np.arange(20, dtype=np.int32).reshape(2, 10), + dataformat="int32", + rate=10, + events=[{"stimtype": 1, "sample": 0}, {"stimtype": 2, "sample": 9}], + event_type=3, + ) + + low_level = loadcnt(path, dataformat="int32") + eeg = pop_loadcnt(path, dataformat="int32") + + assert [event["offset"] for event in low_level["event"]] == [1, 10] + assert [event["latency"] for event in eeg["event"]] == [1, 10] + + +def test_pop_fileio_routes_cnt_through_standalone_loader( + cnt_files: tuple[Path, Path, np.ndarray, np.ndarray], +) -> None: + int16_file, _int32_file, _counts16, _counts32 = cnt_files + + eeg, command = pop_fileio( + int16_file, + dataformat="int16", + blockrange=[10, 31], + keystroke="on", + return_com=True, + ) + + assert eeg["data"].shape == (2, 21) + assert eeg["event"][0]["latency"] == 1 + assert eeg["event"][0]["type"] == "keyboard4" + assert "'blockrange', [10 31]" in command + assert eeg["history"] == command + + +@pytest.mark.parametrize( + ("operation", "message"), + [ + (lambda path: loadcnt(path, dataformat="float32"), "dataformat"), + (lambda path: loadcnt(path, sample1=-1), "sample1"), + (lambda path: loadcnt(path, sample1=0.5), "sample1"), + (lambda path: loadcnt(path, ldnsamples=0), "sample count"), + (lambda path: loadcnt(path, memmapfile=path.with_suffix(".dat")), ".fdt"), + (lambda path: pop_loadcnt(path, keystroke="maybe"), "keystroke"), + ], +) +def test_cnt_loader_rejects_ambiguous_or_unsafe_options( + cnt_files: tuple[Path, Path, np.ndarray, np.ndarray], + operation: Any, + message: str, +) -> None: + int16_file, _int32_file, _counts16, _counts32 = cnt_files + + with pytest.raises(ValueError, match=message): + operation(int16_file) + + +def test_loadcnt_reports_truncated_headers_and_misaligned_data(tmp_path: Path) -> None: + short = tmp_path / "short.cnt" + short.write_bytes(b"Version 3.0") + with pytest.raises(ValueError, match="setup header is truncated"): + loadcnt(short) + + valid = _write_cnt( + tmp_path / "misaligned.cnt", + np.arange(20, dtype=np.int16).reshape(2, 10), + dataformat="int16", + rate=10, + ) + payload = bytearray(valid.read_bytes()) + struct.pack_into(" None: + int16_file, _int32_file, counts16, _counts32 = cnt_files + + with pytest.warns(RuntimeWarning, match="only 5 remain; truncating"): + loaded = loadcnt(int16_file, dataformat="int16", scale="off", sample1=1195, ldnsamples=20) + + np.testing.assert_array_equal(loaded["data"], counts16[:, -5:]) + assert loaded["ldnsamples"] == 5 + + +def test_loadcnt_validates_event_table_length(tmp_path: Path) -> None: + path = _write_cnt( + tmp_path / "truncated-events.cnt", + np.arange(20, dtype=np.int16).reshape(2, 10), + dataformat="int16", + rate=10, + events=[{"stimtype": 1, "sample": 2}], + ) + payload = bytearray(path.read_bytes()) + event_position = struct.unpack_from(" None: + path = _write_cnt( + tmp_path / "misaligned-event.cnt", + np.arange(20, dtype=np.int16).reshape(2, 10), + dataformat="int16", + rate=10, + events=[{"stimtype": 1, "sample": 2}], + ) + payload = bytearray(path.read_bytes()) + event_position = struct.unpack_from(" None: + counts = np.arange(20, dtype=np.int16).reshape(2, 10) + path = _write_cnt( + tmp_path / "ambiguous.cnt", + counts, + dataformat="int16", + rate=10, + header_samples=0, + ) + + with pytest.warns(RuntimeWarning, match="sample width is ambiguous"): + loaded = loadcnt(path, scale="off") + + assert loaded["dataformat"] == "int16" + np.testing.assert_array_equal(loaded["data"], counts) + + +def test_cnt_public_exports_are_available() -> None: + assert eegprep.loadcnt is loadcnt + assert eegprep.pop_loadcnt is pop_loadcnt From f5f77612a5a750eca8b3006afc519996bca582e9 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:52 -0700 Subject: [PATCH 27/45] Add validated GDF export (#355) --- docs/source/api/data_loading_and_saving.rst | 1 + docs/source/changelog.rst | 7 +- src/eegprep/__init__.py | 1 + src/eegprep/functions/popfunc/pop_writeeeg.py | 20 +- src/eegprep/functions/sigprocfunc/__init__.py | 2 + src/eegprep/functions/sigprocfunc/writegdf.py | 304 ++++++++++++++++++ src/eegprep/resources/help/pop_writeeeg.md | 11 +- tests/test_sample_data_pop_functions.py | 12 +- tests/test_writegdf.py | 169 ++++++++++ 9 files changed, 510 insertions(+), 17 deletions(-) create mode 100644 src/eegprep/functions/sigprocfunc/writegdf.py create mode 100644 tests/test_writegdf.py diff --git a/docs/source/api/data_loading_and_saving.rst b/docs/source/api/data_loading_and_saving.rst index 6db9be67..81f4d756 100644 --- a/docs/source/api/data_loading_and_saving.rst +++ b/docs/source/api/data_loading_and_saving.rst @@ -48,6 +48,7 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.snapread eegprep.shortread eegprep.writelocs + eegprep.writegdf ``readegilocs`` includes packaged EGI montages for 32/33, 64/65, 128/129, and 256/257-channel nets. diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 09ff803a..2596db6c 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,10 @@ the `GitHub Releases `_ page. Unreleased ========== +- ``pop_writeeeg`` now writes standards-compatible GDF 1.25 files with + float64 signal samples, channel labels, rational sampling rates, and GDF + event tables. Numeric event types round-trip through independent readers; + free-text event labels receive explicit file-local uint16 codes. - Neuroscan ``loadcnt`` and ``pop_loadcnt`` now provide standalone 16/32-bit CNT import with per-channel microvolt calibration, channel-blocked reads, partial sample ranges, response and boundary events, and optional ``.fdt`` @@ -79,8 +83,7 @@ Unreleased loading modes, while ``pop_fileio`` applies 1-based channel and inclusive sample/trial selections with consistent dataset bookkeeping. ``pop_writeeeg`` now writes real EDF and BDF files through the installed writer instead of - routing unsupported BDF/GDF format names into MNE's EDF-only exporter; GDF - output fails explicitly until a compatible writer is available. + routing unsupported BDF format names into MNE's EDF-only exporter. - Added ``pop_dipparams``, ``pop_erpimparams``, ``pop_erpparams``, ``pop_erspparams``, ``pop_specparams``, and ``pop_statparams`` for storing EEGLAB-compatible STUDY plotting and statistics settings. Analysis-defining diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index b45a1562..f42a4b5f 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -427,6 +427,7 @@ "signalstat": ("eegprep.functions.sigprocfunc.signalstat", "signalstat"), "shortread": ("eegprep.functions.miscfunc.shortread", "shortread"), "windows": ("eegprep.plugins.firfilt.windows", "windows"), + "writegdf": ("eegprep.functions.sigprocfunc.writegdf", "writegdf"), "snapread": ("eegprep.functions.sigprocfunc.snapread", "snapread"), "varimax": ("eegprep.functions.miscfunc.varimax", "varimax"), "varsort": ("eegprep.functions.miscfunc.varsort", "varsort"), diff --git a/src/eegprep/functions/popfunc/pop_writeeeg.py b/src/eegprep/functions/popfunc/pop_writeeeg.py index 63b47d39..b84da768 100644 --- a/src/eegprep/functions/popfunc/pop_writeeeg.py +++ b/src/eegprep/functions/popfunc/pop_writeeeg.py @@ -1,4 +1,4 @@ -"""Export EEG data to EDF or BDF.""" +"""Export EEG data to EDF, BDF, or GDF.""" from __future__ import annotations @@ -11,10 +11,11 @@ from eegprep.functions.popfunc._file_io import channel_labels, events_to_records from eegprep.functions.popfunc._pop_utils import format_history_value, parse_key_value_args +from eegprep.functions.sigprocfunc.writegdf import writegdf def pop_writeeeg(EEG: dict[str, Any], filename: str | Path, *args: Any, **kwargs: Any) -> str: - """Write continuous EEG data to EDF or BDF.""" + """Write continuous EEG data to EDF, BDF, or GDF.""" path = Path(filename) options = parse_key_value_args(args, kwargs, lowercase_keys=True, lowercase_kwargs=True) output_type = str(options.pop("type", path.suffix.lstrip("."))).lower() @@ -24,10 +25,19 @@ def pop_writeeeg(EEG: dict[str, Any], filename: str | Path, *args: Any, **kwargs raise ValueError("pop_writeeeg output must end in .edf, .bdf, or .gdf") if output_type != path.suffix.lstrip(".").lower(): raise ValueError("TYPE must match the output filename extension") - if output_type == "gdf": - raise NotImplementedError("GDF writing is not supported by EEGPrep's installed Python writer") path.parent.mkdir(parents=True, exist_ok=True) - _write_edf_family(EEG, path, output_type) + if output_type == "gdf": + writegdf( + path, + EEG["data"], + EEG["srate"], + labels=channel_labels(EEG), + events=events_to_records(EEG.get("event")), + subject=str(EEG.get("subject", "")), + recording=str(EEG.get("setname", "")), + ) + else: + _write_edf_family(EEG, path, output_type) pieces = [format_history_value(path)] if "type" in {str(key).lower() for key in kwargs} or args: pieces.extend(["'TYPE'", format_history_value(output_type.upper())]) diff --git a/src/eegprep/functions/sigprocfunc/__init__.py b/src/eegprep/functions/sigprocfunc/__init__.py index b7f9ea4a..20352e7e 100644 --- a/src/eegprep/functions/sigprocfunc/__init__.py +++ b/src/eegprep/functions/sigprocfunc/__init__.py @@ -10,6 +10,7 @@ from .quantile import quantile from .readbdf import readbdf from .shuffle import shuffle +from .writegdf import writegdf __all__ = [ "celltomat", @@ -22,4 +23,5 @@ "quantile", "readbdf", "shuffle", + "writegdf", ] diff --git a/src/eegprep/functions/sigprocfunc/writegdf.py b/src/eegprep/functions/sigprocfunc/writegdf.py new file mode 100644 index 00000000..25d0e450 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/writegdf.py @@ -0,0 +1,304 @@ +"""Write continuous EEG data in General Data Format 1.25.""" + +from __future__ import annotations + +from fractions import Fraction +from pathlib import Path +import struct +from typing import Any +import warnings + +import numpy as np + +_GDF_VERSION = b"GDF 1.25" +_FIXED_HEADER_BYTES = 256 +_SIGNAL_HEADER_BYTES = 256 +_FLOAT64_GDF_TYPE = 17 +_MAX_UINT16 = 2**16 - 1 +_MAX_UINT24 = 2**24 - 1 +_MAX_UINT32 = 2**32 - 1 +_PRIVATE_EVENT_CODE_START = 0x8000 +_BOUNDARY_EVENT_CODE = 0x7FFE + + +def writegdf( + filename: str | Path, + data: Any, + sampling_rate: float, + *, + labels: list[str] | tuple[str, ...] | None = None, + events: Any = None, + subject: str = "", + recording: str = "", +) -> dict[str, int]: + """Write finite continuous channel-major data as GDF 1.25. + + Floating-point GDF samples preserve the input values without integer + quantization. Numeric event types retain their codes. Non-numeric event + labels receive deterministic file-local codes in the private range and are + returned so callers can record that mapping. + + Args: + filename: Destination ending in ``.gdf``. + data: Channels by samples matrix. + sampling_rate: Samples per second. + labels: Optional channel labels; defaults to ``Ch1``, ``Ch2``, etc. + events: Optional event dictionaries with 1-based sample ``latency``. + subject: Optional subject identifier. + recording: Optional recording identifier. + + Returns: + Mapping from non-numeric event labels to their stored uint16 codes. + """ + path = Path(filename).expanduser() + if path.suffix.lower() != ".gdf": + raise ValueError("writegdf output must end in .gdf") + matrix, rate, channel_names = _validated_data(data, sampling_rate, labels) + record_duration = _record_duration(matrix.shape[1], rate) + digital_minimum, digital_maximum = _channel_limits(matrix) + encoded_events, label_codes = _encoded_events(events, matrix.shape[1], matrix.shape[0]) + fixed = _fixed_header(subject, recording, matrix.shape[0], record_duration) + signal = _signal_header(channel_names, matrix.shape[1], digital_minimum, digital_maximum) + + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as stream: + stream.write(fixed) + stream.write(signal) + stream.write(np.asarray(matrix, dtype=" tuple[np.ndarray, float, list[str]]: + raw = np.asarray(data) + if not np.issubdtype(raw.dtype, np.number): + raise TypeError("GDF data must be numeric") + if np.iscomplexobj(raw): + raise ValueError("GDF data must be real") + data = raw.astype(float, copy=False) + if data.ndim != 2 or min(data.shape) == 0: + raise ValueError("writegdf requires non-empty continuous 2-D channel-by-sample data") + if not np.all(np.isfinite(data)): + raise ValueError("writegdf requires finite EEG data") + rate = float(sampling_rate) + if not np.isfinite(rate) or rate <= 0: + raise ValueError("sampling_rate must be finite and positive") + channel_names = [f"Ch{index + 1}" for index in range(data.shape[0])] if labels is None else list(labels) + if len(channel_names) != data.shape[0]: + raise ValueError("channel labels do not match the data rows") + for label in channel_names: + _fixed_text(label, 16, "channel label") + return data, rate, channel_names + + +def _record_duration(samples: int, sampling_rate: float) -> Fraction: + duration = Fraction(samples, 1) / Fraction(str(sampling_rate)) + duration = duration.limit_denominator(_MAX_UINT32) + if duration.numerator > _MAX_UINT32 or duration.denominator > _MAX_UINT32: + raise ValueError("GDF record duration cannot represent this sample count and sampling rate") + represented_rate = samples * duration.denominator / duration.numerator + if not np.isclose(represented_rate, sampling_rate, rtol=1e-12, atol=0): + raise ValueError("GDF record duration cannot represent the sampling rate accurately") + return duration + + +def _channel_limits(data: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + lower = np.floor(np.min(data, axis=1)) + upper = np.ceil(np.max(data, axis=1)) + constant = lower == upper + lower[constant] -= 1 + upper[constant] += 1 + info = np.iinfo(np.int64) + if np.any(lower < info.min) or np.any(upper > info.max): + raise ValueError("GDF channel range exceeds the format's int64 calibration limits") + return lower.astype(" bytes: + patient = _fixed_text(str(subject), 80, "subject") + recording_field = _fixed_text(str(recording), 80, "recording") + header_bytes = _FIXED_HEADER_BYTES + channels * _SIGNAL_HEADER_BYTES + return b"".join( + ( + _GDF_VERSION, + patient, + recording_field, + b"0" * 16, + struct.pack(" bytes: + channels = len(labels) + if samples > np.iinfo(np.int32).max: + raise ValueError("GDF data exceed the per-record sample limit") + return b"".join( + ( + b"".join(_fixed_text(label, 16, "channel label") for label in labels), + bytes(80 * channels), + b"".join(_fixed_text("uV", 8, "physical dimension") for _ in labels), + digital_minimum.astype(" tuple[list[tuple[int, int, int, int]], dict[str, int]]: + records = _event_records(raw_events) + label_codes: dict[str, int] = {} + encoded = [] + reserved_codes = {code for event in records if (code := _standard_event_code(event.get("type", 0))) is not None} + next_private_code = _PRIVATE_EVENT_CODE_START + for event in records: + latency = _finite_value(event.get("latency", 1), "event latency") + position = int(np.floor(latency + 0.5)) + if position < 1 or position > samples: + raise ValueError(f"event latency {latency} is outside the 1..{samples} sample range") + duration = _finite_value(event.get("duration", 0) or 0, "event duration") + if duration < 0: + raise ValueError("event duration must be non-negative") + stored_duration = int(np.floor(duration + 0.5)) + if stored_duration > _MAX_UINT32: + raise ValueError("event duration exceeds the GDF uint32 limit") + channel = _event_channel(event.get("channel", 0), channels) + code, next_private_code = _event_code( + event.get("type", 0), + label_codes, + reserved_codes, + next_private_code, + ) + encoded.append((position, code, channel, stored_duration)) + if label_codes: + warnings.warn( + "GDF stores uint16 event codes rather than free-text labels; non-numeric labels were assigned " + f"file-local codes: {label_codes}", + RuntimeWarning, + stacklevel=3, + ) + return encoded, label_codes + + +def _event_records(events: Any) -> list[dict[str, Any]]: + if events is None: + return [] + if isinstance(events, np.ndarray): + events = events.tolist() + if isinstance(events, dict): + return [dict(events)] + return [dict(event) for event in events] + + +def _event_code( + value: Any, + label_codes: dict[str, int], + reserved_codes: set[int], + next_private: int, +) -> tuple[int, int]: + standard = _standard_event_code(value) + if standard is not None: + return standard, next_private + text = str(value).strip() + if text not in label_codes: + while next_private in reserved_codes or next_private in label_codes.values(): + next_private += 1 + if next_private > _MAX_UINT16: + raise ValueError("too many distinct non-numeric GDF event labels") + label_codes[text] = next_private + next_private += 1 + return label_codes[text], next_private + + +def _standard_event_code(value: Any) -> int | None: + if isinstance(value, str): + text = value.strip() + if text.lower() == "boundary": + return _BOUNDARY_EVENT_CODE + try: + numeric = float(text) + except ValueError: + return None + else: + numeric = _finite_value(value, "event type") + if numeric != np.trunc(numeric) or not 0 <= numeric <= _MAX_UINT16: + raise ValueError("numeric GDF event types must be integers from 0 through 65535") + return int(numeric) + + +def _event_channel(value: Any, channels: int) -> int: + numeric = _finite_value(value, "event channel") + if numeric != np.trunc(numeric) or not 0 <= numeric <= channels: + raise ValueError(f"event channel must be an integer from 0 through {channels}") + return int(numeric) + + +def _event_table(events: list[tuple[int, int, int, int]], sampling_rate: float) -> bytes: + if events and sampling_rate != np.trunc(sampling_rate): + raise ValueError("GDF 1.25 event tables require an integer sampling rate for exact event timing") + event_rate = int(sampling_rate) if events else max(1, min(_MAX_UINT24, round(sampling_rate))) + if not 1 <= event_rate <= _MAX_UINT24: + raise ValueError("GDF event sampling rate must round to a uint24 positive integer") + prefix = b"\x03" + event_rate.to_bytes(3, "little") + struct.pack(" bytes: + try: + encoded = value.encode("latin-1") + except UnicodeEncodeError as exc: + raise ValueError(f"GDF {name} must use Latin-1 characters") from exc + if len(encoded) > width: + raise ValueError(f"GDF {name} must fit in {width} bytes") + return encoded.ljust(width) + + +def _finite_value(value: Any, name: str) -> float: + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be numeric") from exc + if not np.isfinite(numeric): + raise ValueError(f"{name} must be finite") + return numeric + + +__all__ = ["writegdf"] diff --git a/src/eegprep/resources/help/pop_writeeeg.md b/src/eegprep/resources/help/pop_writeeeg.md index d03ce5dd..0484ecae 100644 --- a/src/eegprep/resources/help/pop_writeeeg.md +++ b/src/eegprep/resources/help/pop_writeeeg.md @@ -1,4 +1,4 @@ -# POP_WRITEEG - Export EEG data to EDF/BDF +# POP_WRITEEG - Export EEG data to EDF/BDF/GDF `pop_writeeeg` writes the current dataset to an external EEG file format. @@ -7,12 +7,15 @@ Usage: ```python com = pop_writeeeg(EEG, "recording.edf") com = pop_writeeeg(EEG, "recording.bdf", "TYPE", "BDF") +com = pop_writeeeg(EEG, "recording.gdf", "TYPE", "GDF") ``` The File > Export menu prompts for an output path and records the command in session history. EDF and BDF are written with their standard 16-bit and 24-bit -sample ranges. GDF writing is not yet available and raises a clear error. Use -`pop_saveset` for EEGPrep/EEGLAB `.set` files and `pop_exportbids` for BIDS -folder output. +sample ranges. GDF uses float64 samples, so signal values are not quantized; +numeric event types and sample timing are stored in the GDF event table. Since +GDF event types are uint16 codes, free-text labels receive file-local codes and +raise a warning that reports the mapping. Use `pop_saveset` for EEGPrep/EEGLAB +`.set` files and `pop_exportbids` for BIDS folder output. See also: POP_SAVESET, POP_EXPORTBIDS diff --git a/tests/test_sample_data_pop_functions.py b/tests/test_sample_data_pop_functions.py index d749f986..5d72fa13 100644 --- a/tests/test_sample_data_pop_functions.py +++ b/tests/test_sample_data_pop_functions.py @@ -743,7 +743,7 @@ def test_pop_runscript_can_modify_sample_workspace_namespace(sample_eeg, tmp_pat @eeglab_test("unittesting_popfunc/pop_writeeeg/popfunc_pop_writeeeg_wrapperTest.m", "test_test_pop_writeeeg") -def test_pop_writeeeg_current_suite_edf_and_bdf_roundtrip(tmp_path, sample_eeg): +def test_pop_writeeeg_current_suite_edf_bdf_and_gdf_roundtrip(tmp_path, sample_eeg): eeg = copy.deepcopy(sample_eeg) eeg["data"] = eeg["data"][:2, :256] eeg["nbchan"] = 2 @@ -753,12 +753,15 @@ def test_pop_writeeeg_current_suite_edf_and_bdf_roundtrip(tmp_path, sample_eeg): eeg["chanlocs"] = eeg["chanlocs"][:2] eeg["event"] = [] - for suffix in ("edf", "bdf"): + for suffix in ("edf", "bdf", "gdf"): output = tmp_path / f"sample.{suffix}" command = pop_writeeeg(eeg, output, "TYPE", suffix.upper()) imported = pop_fileio(output) stored_ranges = np.ceil(np.max(eeg["data"], axis=1)) - np.floor(np.min(eeg["data"], axis=1)) - quantization = np.max(stored_ranges) / (65_535 if suffix == "edf" else 16_777_215) + if suffix == "gdf": + quantization = np.finfo(np.float64).eps * max(1, float(np.max(np.abs(eeg["data"])))) + else: + quantization = np.max(stored_ranges) / (65_535 if suffix == "edf" else 16_777_215) assert output.exists() assert imported["data"].shape == eeg["data"].shape @@ -766,9 +769,6 @@ def test_pop_writeeeg_current_suite_edf_and_bdf_roundtrip(tmp_path, sample_eeg): np.testing.assert_allclose(imported["data"], eeg["data"], rtol=0, atol=quantization * 1.1) assert f"'TYPE', '{suffix.upper()}'" in command - with pytest.raises(NotImplementedError, match="GDF writing"): - pop_writeeeg(eeg, tmp_path / "sample.gdf", "TYPE", "GDF") - def test_pop_exportbids_writes_valid_bids_dataset_from_sample(tmp_path, sample_eeg): root, command = pop_exportbids(sample_eeg, tmp_path / "bids", return_com=True) diff --git a/tests/test_writegdf.py b/tests/test_writegdf.py new file mode 100644 index 00000000..db19256e --- /dev/null +++ b/tests/test_writegdf.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from pathlib import Path + +import mne +import numpy as np +import pytest + +import eegprep +from eegprep.functions.popfunc.pop_fileio import pop_fileio +from eegprep.functions.popfunc.pop_writeeeg import pop_writeeeg +from eegprep.functions.sigprocfunc.writegdf import writegdf + + +def _eeg() -> dict: + samples = 257 + time = np.arange(samples) / 256 + data = np.vstack( + ( + 20 * np.sin(2 * np.pi * 10 * time) + 0.125, + 7 * np.cos(2 * np.pi * 3 * time) - 2.75, + ) + ) + return { + "data": data, + "nbchan": 2, + "pnts": samples, + "trials": 1, + "srate": 256.0, + "setname": "GDF round trip", + "subject": "subject-01", + "chanlocs": [{"labels": "Fz"}, {"labels": "Cz"}], + "event": [ + {"type": 10, "latency": 1.0, "duration": 4.0}, + {"type": "20", "latency": 64.25, "duration": 2.0, "channel": 2}, + ], + } + + +def _write(eeg: dict, output: Path) -> dict[str, int]: + return writegdf( + output, + eeg["data"], + eeg["srate"], + labels=[channel["labels"] for channel in eeg["chanlocs"]], + events=eeg["event"], + subject=eeg["subject"], + recording=eeg["setname"], + ) + + +def test_writegdf_roundtrips_samples_rate_labels_and_events_through_mne(tmp_path: Path) -> None: + eeg = _eeg() + output = tmp_path / "roundtrip.gdf" + + mapping = _write(eeg, output) + raw = mne.io.read_raw_gdf(output, preload=True, verbose=False) + imported = pop_fileio(output) + + assert mapping == {} + assert output.read_bytes().startswith(b"GDF 1.25") + assert raw.ch_names == ["Fz", "Cz"] + assert raw.info["sfreq"] == pytest.approx(eeg["srate"], rel=1e-12) + np.testing.assert_allclose(raw.get_data(units="uV"), eeg["data"], rtol=0, atol=1e-12) + np.testing.assert_allclose(imported["data"], eeg["data"], rtol=0, atol=1e-12) + assert [event["type"] for event in imported["event"]] == ["10", "20"] + # MNE stores annotation seconds at microsecond resolution, which is below + # one thousandth of a sample here. + np.testing.assert_allclose([event["latency"] for event in imported["event"]], [1, 64], rtol=0, atol=1e-3) + np.testing.assert_allclose([event["duration"] for event in imported["event"]], [4, 2], rtol=0, atol=1e-3) + + +def test_pop_writeeeg_gdf_history_and_free_text_event_mapping(tmp_path: Path) -> None: + eeg = _eeg() + eeg["event"] = [ + {"type": "stimulus", "latency": 20}, + {"type": "stimulus", "latency": 40}, + {"type": "response", "latency": 60}, + ] + output = tmp_path / "events.gdf" + + with pytest.warns(RuntimeWarning, match="stimulus.*32768.*response.*32769"): + command = pop_writeeeg(eeg, output, "TYPE", "GDF") + raw = mne.io.read_raw_gdf(output, preload=False, verbose=False) + + assert list(raw.annotations.description) == ["32768", "32768", "32769"] + assert list(raw.annotations.onset * eeg["srate"] + 1) == pytest.approx([20, 40, 60], abs=1e-3) + # MNE normalizes zero-duration GDF events to one sample when reading. + assert list(raw.annotations.duration) == [1 / eeg["srate"]] * 3 + assert command == f"LASTCOM = pop_writeeeg(EEG, '{output.as_posix()}', 'TYPE', 'GDF');" + + +def test_writegdf_preserves_fractional_sampling_rates_without_events(tmp_path: Path) -> None: + eeg = _eeg() + eeg["srate"] = 256.0175 + eeg["event"] = [] + output = tmp_path / "fractional-rate.gdf" + + _write(eeg, output) + raw = mne.io.read_raw_gdf(output, preload=True, verbose=False) + + assert raw.info["sfreq"] == pytest.approx(256.0175, rel=1e-12) + np.testing.assert_allclose(raw.get_data(units="uV"), eeg["data"], rtol=0, atol=1e-12) + + +def test_writegdf_supports_sub_hertz_sampling_without_events(tmp_path: Path) -> None: + eeg = _eeg() + eeg["srate"] = 0.5 + eeg["event"] = [] + output = tmp_path / "sub-hertz.gdf" + + _write(eeg, output) + raw = mne.io.read_raw_gdf(output, preload=False, verbose=False) + + assert raw.info["sfreq"] == pytest.approx(0.5, rel=1e-12) + + +def test_writegdf_avoids_collisions_between_numeric_and_text_event_codes(tmp_path: Path) -> None: + eeg = _eeg() + eeg["event"] = [ + {"type": 32768, "latency": 10}, + {"type": "stimulus", "latency": 20}, + ] + output = tmp_path / "codes.gdf" + + with pytest.warns(RuntimeWarning, match="stimulus.*32769"): + mapping = _write(eeg, output) + raw = mne.io.read_raw_gdf(output, preload=False, verbose=False) + + assert mapping == {"stimulus": 32769} + assert list(raw.annotations.description) == ["32768", "32769"] + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + (lambda eeg: eeg.update(data=eeg["data"][:, :, None], trials=2), "continuous"), + (lambda eeg: eeg.update(data=eeg["data"].astype(complex) + 1j), "real"), + (lambda eeg: eeg["data"].__setitem__((0, 0), np.nan), "finite"), + (lambda eeg: eeg.update(srate=0), "sampling_rate"), + (lambda eeg: eeg["chanlocs"][0].update(labels="label-is-more-than-sixteen-bytes"), "16 bytes"), + (lambda eeg: eeg["event"][0].update(latency=1000), "outside"), + (lambda eeg: eeg["event"][0].update(type=2.5), "integers"), + (lambda eeg: eeg.update(srate=256.5), "integer sampling rate"), + ], +) +def test_writegdf_rejects_data_that_cannot_be_represented( + tmp_path: Path, + mutation, + message: str, +) -> None: + eeg = _eeg() + mutation(eeg) + + with pytest.raises(ValueError, match=message): + _write(eeg, tmp_path / "invalid.gdf") + + +def test_writegdf_and_pop_writeeeg_reject_mismatched_output_types(tmp_path: Path) -> None: + eeg = _eeg() + + with pytest.raises(ValueError, match="end in .gdf"): + _write(eeg, tmp_path / "invalid.edf") + with pytest.raises(ValueError, match="TYPE must match"): + pop_writeeeg(eeg, tmp_path / "invalid.gdf", "TYPE", "EDF") + + +def test_writegdf_is_public() -> None: + assert eegprep.writegdf is writegdf From b76d7974c37b66b42bf5eba9138824f322b53185 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:53 -0700 Subject: [PATCH 28/45] feat: add standalone BrainVision loader (#356) --- docs/source/api/data_loading_and_saving.rst | 22 + docs/source/changelog.rst | 6 + docs/source/faq.rst | 9 +- src/eegprep/__init__.py | 1 + src/eegprep/functions/guifunc/menu_actions.py | 4 + src/eegprep/functions/popfunc/pop_fileio.py | 5 + src/eegprep/functions/popfunc/pop_loadbv.py | 748 ++++++++++++++++++ src/eegprep/resources/help/pop_loadbv.md | 31 + tests/test_gui_main_window.py | 22 + tests/test_pop_loadbv.py | 455 +++++++++++ 10 files changed, 1300 insertions(+), 3 deletions(-) create mode 100644 src/eegprep/functions/popfunc/pop_loadbv.py create mode 100644 src/eegprep/resources/help/pop_loadbv.md create mode 100644 tests/test_pop_loadbv.py diff --git a/docs/source/api/data_loading_and_saving.rst b/docs/source/api/data_loading_and_saving.rst index 81f4d756..ec7c81e5 100644 --- a/docs/source/api/data_loading_and_saving.rst +++ b/docs/source/api/data_loading_and_saving.rst @@ -36,6 +36,7 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.mne2eeg_epochs eegprep.openbdf eegprep.parsetxt + eegprep.pop_loadbv eegprep.pop_loadcnt eegprep.readbdf eegprep.readeetraklocs @@ -57,3 +58,24 @@ and 256/257-channel nets. checkout. They support 16- and 32-bit recordings, channel-blocked storage, microvolt calibration, partial reads, event tables, and ``.fdt``-backed data. ANT Neuro CNT is a separate format and is not accepted by this reader. + +BrainVision recordings +---------------------- + +Load a BrainVision Data Exchange recording through its header. The companion +data and marker files are resolved from the header and must remain beside it. + +.. code-block:: python + + from eegprep import pop_loadbv + + EEG = pop_loadbv("subject01.vhdr") + EEG = pop_loadbv("/data/session", "subject01.vhdr", [1001, 5000], [1, 2, 8]) + +``srange`` and ``chans`` use EEGLAB-compatible 1-based indexing; a two-value +sample range is inclusive. Binary and ASCII, multiplexed and vectorized data +are supported. Voltage data is normalized to microvolts while the original +unit and resolution remain in each channel's ``bvunit`` and ``bvresolution`` +fields. Use ``metadata=True`` to inspect dimensions, channels, and events +without loading samples. Uniform marker-based or fixed-time segments are +returned as trials rather than a flattened continuous array. diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 2596db6c..dac7bb33 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,12 @@ the `GitHub Releases `_ page. Unreleased ========== +- ``pop_loadbv`` now provides a standalone BrainVision reader for binary and + ASCII recordings in multiplexed or vectorized orientation. It applies + per-channel resolutions, normalizes voltage channels to microvolts, retains + channel coordinates and marker metadata, supports 1-based sample/channel + selection and metadata-only reads, and rejects truncated or contradictory + files instead of returning an inconsistent EEG dataset. - ``pop_writeeeg`` now writes standards-compatible GDF 1.25 files with float64 signal samples, channel labels, rational sampling rates, and GDF event tables. Numeric event types round-trip through independent readers; diff --git a/docs/source/faq.rst b/docs/source/faq.rst index 0fb70b15..98d3c821 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -88,11 +88,14 @@ EEGPrep supports multiple formats: from pathlib import Path import mne - from eegprep import bids_list_eeg_files, eeg_mne2eeg, pop_load_frombids, pop_loadset + from eegprep import bids_list_eeg_files, eeg_mne2eeg, pop_load_frombids, pop_loadbv, pop_loadset # Load EEGLAB .set file EEG = pop_loadset(Path("sample_data") / "eeglab_data.set") + # Load BrainVision through the .vhdr header + EEG = pop_loadbv("/path/to/recording.vhdr") + # Load from a BIDS dataset files = bids_list_eeg_files("/path/to/bids-root", subjects=["001"]) EEG = pop_load_frombids(files[0]) @@ -110,7 +113,7 @@ EEGPrep supports: - **BIDS**: Brain Imaging Data Structure format - **MNE-Python**: Raw and Epochs objects - **EDF**: European Data Format -- **BrainVision**: .vhdr, .vmrk, .eeg files +- **BrainVision**: ``.vhdr`` headers with companion ``.vmrk`` and ``.eeg``/``.dat`` files - **Neuroscan**: .cnt files How do I apply preprocessing? @@ -444,7 +447,7 @@ Can I use EEGPrep with other data formats? Yes, EEGPrep supports: - EDF (European Data Format) -- BrainVision (.vhdr, .vmrk, .eeg) +- BrainVision (``.vhdr`` with companion ``.vmrk`` and ``.eeg``/``.dat`` files) - Neuroscan (.cnt) - MNE-Python formats diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index f42a4b5f..55a4259b 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -338,6 +338,7 @@ "pop_loadbci": ("eegprep.functions.popfunc.pop_loadbci", "pop_loadbci"), "pop_loadcnt": ("eegprep.functions.popfunc.pop_loadcnt", "pop_loadcnt"), "pop_load_frombids": ("eegprep.functions.popfunc.pop_load_frombids", "pop_load_frombids"), + "pop_loadbv": ("eegprep.functions.popfunc.pop_loadbv", "pop_loadbv"), "pop_loadstudy": ("eegprep.functions.studyfunc.pop_loadstudy", "pop_loadstudy"), "pop_loadset": ("eegprep.functions.popfunc.pop_loadset", "pop_loadset"), "pop_limo": ("eegprep.functions.studyfunc.pop_limo", "pop_limo"), diff --git a/src/eegprep/functions/guifunc/menu_actions.py b/src/eegprep/functions/guifunc/menu_actions.py index 86650549..1fd4f6bb 100644 --- a/src/eegprep/functions/guifunc/menu_actions.py +++ b/src/eegprep/functions/guifunc/menu_actions.py @@ -549,6 +549,10 @@ def _import_dataset(self, action: str, parent: Any | None) -> None: from eegprep.functions.popfunc.pop_fileio_brainvision_mat import pop_fileio_brainvision_mat eeg_out, command = pop_fileio_brainvision_mat(filename, return_com=True) + elif action == "pop_fileio_brainvision": + from eegprep.functions.popfunc.pop_loadbv import pop_loadbv + + eeg_out, command = pop_loadbv(filename, return_com=True) else: from eegprep.functions.popfunc.pop_fileio import pop_fileio diff --git a/src/eegprep/functions/popfunc/pop_fileio.py b/src/eegprep/functions/popfunc/pop_fileio.py index 7ac926f8..e993fdd6 100644 --- a/src/eegprep/functions/popfunc/pop_fileio.py +++ b/src/eegprep/functions/popfunc/pop_fileio.py @@ -13,6 +13,7 @@ from eegprep.functions.popfunc._file_io import mne_raw_to_eeg from eegprep.functions.popfunc._pop_utils import format_history_value, parse_numeric_sequence from eegprep.functions.popfunc.pop_importdata import pop_importdata +from eegprep.functions.popfunc.pop_loadbv import pop_loadbv from eegprep.functions.popfunc.pop_loadcnt import pop_loadcnt from eegprep.functions.popfunc.pop_loadset import _is_hdf5_file, pop_loadset from eegprep.functions.popfunc.pop_select import pop_select @@ -51,6 +52,10 @@ def pop_fileio( kwargs["t1"] = start kwargs["lddur"] = stop - start eeg = pop_loadcnt(path, **kwargs) + elif suffix == ".vhdr" and blockrange is None: + eeg = pop_loadbv(path, srange=samples, chans=channels) + channels = None + samples = None else: reader = _reader_for_suffix(suffix) raw = reader(str(path), preload=True, verbose=False) diff --git a/src/eegprep/functions/popfunc/pop_loadbv.py b/src/eegprep/functions/popfunc/pop_loadbv.py new file mode 100644 index 00000000..96911fdc --- /dev/null +++ b/src/eegprep/functions/popfunc/pop_loadbv.py @@ -0,0 +1,748 @@ +"""Load BrainVision Data Exchange recordings into EEGPrep datasets.""" + +from __future__ import annotations + +from pathlib import Path +import re +from typing import Any +import warnings + +import numpy as np + +from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset +from eegprep.functions.popfunc._pop_utils import format_history_value +from eegprep.functions.popfunc.eeg_emptyset import eeg_emptyset + + +_HEADER_PREFIX = "Brain Vision Data Exchange Header File" +_MARKER_PREFIX = "Brain Vision Data Exchange Marker File" +_BINARY_DTYPES = { + "INT_16": np.dtype(" dict[str, Any] | tuple[dict[str, Any], str]: + """Load a BrainVision ``.vhdr`` recording. + + Args: + path: Directory containing the recording, or the full header path. + hdrfile: Header filename when ``path`` is a directory. + srange: One-based first sample, or inclusive ``[first, last]`` range. + chans: One-based channel indices to load, in the requested order. + metadata: Read metadata and events without loading signal samples. + return_com: Return ``(EEG, command)`` for command-history workflows. + + Returns: + An EEGPrep EEG dictionary, optionally paired with its replay command. + + Notes: + Voltage channels are converted to EEGPrep's microvolt convention. The + original BrainVision unit and resolution remain available as + ``chanlocs[*]["bvunit"]`` and ``chanlocs[*]["bvresolution"]``. + """ + header_path = _header_path(path, hdrfile) + header = _read_configuration(header_path, expected_prefix=_HEADER_PREFIX) + common = _section(header, "common infos") + channel_count = _required_positive_int(common, "NumberOfChannels") + sampling_interval = _required_positive_float(common, "SamplingInterval") + srate = 1_000_000.0 / sampling_interval + data_type = common.get("datatype", "TIMEDOMAIN").strip().upper() + if data_type != "TIMEDOMAIN": + raise ValueError(f"Unsupported BrainVision data type: {data_type}") + data_format = _required(common, "DataFormat").upper() + orientation = _required(common, "DataOrientation").upper() + if orientation not in {"MULTIPLEXED", "VECTORIZED"}: + raise ValueError(f"Unsupported BrainVision data orientation: {orientation}") + + data_file = _required(common, "DataFile") + data_path = _referenced_file(header_path, data_file, fallback_suffixes=(".eeg", ".dat")) + if data_format == "BINARY": + dtype = _binary_dtype(header) + point_count = _binary_point_count(data_path, channel_count, dtype, common) + elif data_format == "ASCII": + dtype = None + ascii_data = _read_ascii_data(data_path, header, channel_count, orientation) + point_count = int(ascii_data.shape[1]) + _validate_declared_points(common, point_count) + else: + raise ValueError(f"Unsupported BrainVision data format: {data_format}") + + first, stop = _sample_bounds(srange, point_count) + selected_channels = _channel_indices(chans, channel_count) + chanlocs, scales = _channel_metadata(header, channel_count, selected_channels) + if metadata: + data = np.array([], dtype=float) + elif data_format == "BINARY": + data = _read_binary_data( + data_path, + dtype, + channel_count, + point_count, + orientation, + selected_channels, + first, + stop, + ) + data *= scales[:, np.newaxis] + else: + data = np.asarray(ascii_data[np.ix_(selected_channels, np.arange(first, stop))], dtype=float) + data *= scales[:, np.newaxis] + + events = _read_events( + header_path, + common, + channel_count=channel_count, + selected_channels=selected_channels, + first=first, + stop=stop, + ) + command = _history_command(header_path, srange, chans, metadata) + eeg = _build_eeg( + header_path, + data_file=data_file, + data=data, + metadata=metadata, + chanlocs=chanlocs, + events=events, + point_count=stop - first, + srate=srate, + common=common, + command=command, + selected_channels=selected_channels, + data_format=data_format, + orientation=orientation, + ) + return (eeg, command) if return_com else eeg + + +def _header_path(path: str | Path, hdrfile: str | Path | None) -> Path: + base = Path(path).expanduser() + if hdrfile is None: + candidate = base + else: + candidate = Path(hdrfile).expanduser() + if not candidate.is_absolute(): + candidate = base / candidate + if candidate.suffix.lower() in {".eeg", ".dat"}: + candidate = candidate.with_suffix(".vhdr") + if candidate.suffix.lower() != ".vhdr": + raise ValueError("pop_loadbv requires a BrainVision .vhdr header") + resolved = _case_insensitive_file(candidate) + if resolved is None: + raise FileNotFoundError(f"BrainVision header file not found: {candidate}") + return resolved + + +def _read_configuration(path: Path, *, expected_prefix: str) -> dict[str, dict[str, str]]: + text = _decode_brainvision_text(path) + first_line = next((line.strip() for line in text.splitlines() if line.strip()), "") + if not first_line.casefold().startswith(expected_prefix.casefold()): + raise ValueError(f"Not a supported {expected_prefix}: {path}") + configuration: dict[str, dict[str, str]] = {} + current_section: str | None = None + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith(";"): + continue + section_match = re.fullmatch(r"\[([^]]+)]", line) + if section_match is not None: + current_section = section_match.group(1).strip().casefold() + configuration.setdefault(current_section, {}) + continue + if current_section is None: + continue + if current_section in {"comment", "marker infos"}: + continue + if "=" not in line: + raise ValueError(f"Malformed BrainVision configuration {path} at line {line_number}: expected key=value") + key, value = line.split("=", 1) + normalized_key = key.strip().casefold() + if not normalized_key: + raise ValueError(f"Malformed BrainVision configuration {path} at line {line_number}: empty key") + section = configuration[current_section] + if normalized_key in section: + raise ValueError( + f"Malformed BrainVision configuration {path} at line {line_number}: duplicate {key.strip()}" + ) + section[normalized_key] = value.strip() + if not configuration: + raise ValueError(f"BrainVision file contains no configuration sections: {path}") + return configuration + + +def _decode_brainvision_text(path: Path) -> str: + payload = path.read_bytes() + for encoding in ("utf-8-sig", "cp1252"): + try: + return payload.decode(encoding) + except UnicodeDecodeError: + continue + raise ValueError(f"BrainVision text file is neither UTF-8 nor Windows-1252: {path}") + + +def _section(configuration: dict[str, dict[str, str]], name: str) -> dict[str, str]: + try: + return configuration[name.casefold()] + except KeyError as error: + raise ValueError(f"BrainVision configuration is missing [{name.title()}]") from error + + +def _required(section: dict[str, str], field: str) -> str: + value = section.get(field.casefold(), "").strip() + if not value: + raise ValueError(f"BrainVision configuration field {field} is required") + return value + + +def _required_positive_int(section: dict[str, str], field: str) -> int: + value = _required(section, field) + try: + parsed = int(value) + except ValueError as error: + raise ValueError(f"BrainVision {field} must be a positive integer") from error + if parsed < 1: + raise ValueError(f"BrainVision {field} must be a positive integer") + return parsed + + +def _required_positive_float(section: dict[str, str], field: str) -> float: + value = _required(section, field) + try: + parsed = float(value) + except ValueError as error: + raise ValueError(f"BrainVision {field} must be a positive number") from error + if not np.isfinite(parsed) or parsed <= 0: + raise ValueError(f"BrainVision {field} must be a positive number") + return parsed + + +def _binary_dtype(header: dict[str, dict[str, str]]) -> np.dtype: + binary = _section(header, "binary infos") + binary_format = _required(binary, "BinaryFormat").upper() + try: + dtype = _BINARY_DTYPES[binary_format] + except KeyError as error: + raise ValueError(f"Unsupported BrainVision binary format: {binary_format}") from error + big_endian = binary.get("usebigendianorder", "NO").strip().upper() + if big_endian not in {"YES", "NO"}: + raise ValueError("BrainVision UseBigEndianOrder must be YES or NO") + return dtype.newbyteorder(">" if big_endian == "YES" else "<") + + +def _binary_point_count( + data_path: Path, + channel_count: int, + dtype: np.dtype, + common: dict[str, str], +) -> int: + frame_bytes = channel_count * dtype.itemsize + byte_count = data_path.stat().st_size + if byte_count % frame_bytes: + raise ValueError("BrainVision binary data is truncated: file size is not divisible by the channel frame size") + point_count = byte_count // frame_bytes + if point_count < 1: + raise ValueError("BrainVision data file contains no samples") + _validate_declared_points(common, point_count) + return point_count + + +def _validate_declared_points(common: dict[str, str], point_count: int) -> None: + declared = common.get("datapoints", "").strip() + if not declared: + return + try: + declared_count = int(declared) + except ValueError as error: + raise ValueError("BrainVision DataPoints must be a positive integer") from error + if declared_count < 1: + raise ValueError("BrainVision DataPoints must be a positive integer") + if declared_count != point_count: + raise ValueError( + f"BrainVision DataPoints declares {declared_count} samples but the data file contains {point_count}" + ) + + +def _sample_bounds(srange: Any, point_count: int) -> tuple[int, int]: + values = _integer_vector(srange, "srange") + if not values: + return 0, point_count + if len(values) == 1: + first, last = values[0], point_count + elif len(values) == 2: + first, last = values + else: + raise ValueError("srange must be a first sample or an inclusive [first, last] range") + if first < 1 or last < first or last > point_count: + raise ValueError(f"srange must be within the available 1:{point_count} samples") + return first - 1, last + + +def _channel_indices(chans: Any, channel_count: int) -> list[int]: + values = _integer_vector(chans, "chans") + if not values: + return list(range(channel_count)) + if any(index < 1 or index > channel_count for index in values): + raise ValueError(f"chans must contain 1-based indices in the range 1:{channel_count}") + return [index - 1 for index in values] + + +def _integer_vector(value: Any, name: str) -> list[int]: + if value is None: + return [] + array = np.asarray(value) + if array.size == 0: + return [] + try: + numeric = np.asarray(value, dtype=float).reshape(-1) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must contain integer values") from error + if not np.all(np.isfinite(numeric)) or not np.all(numeric == np.floor(numeric)): + raise ValueError(f"{name} must contain integer values") + return numeric.astype(int).tolist() + + +def _channel_metadata( + header: dict[str, dict[str, str]], + channel_count: int, + selected_channels: list[int], +) -> tuple[list[dict[str, Any]], np.ndarray]: + channel_infos = _section(header, "channel infos") + coordinates = header.get("coordinates", {}) + parsed_channels: list[dict[str, Any]] = [] + scales: list[float] = [] + for source_index in selected_channels: + key = f"ch{source_index + 1}" + if key not in channel_infos: + raise ValueError(f"BrainVision [Channel Infos] is missing Ch{source_index + 1}") + fields = _split_brainvision_fields(channel_infos[key]) + label = fields[0].strip() if fields and fields[0].strip() else f"Ch{source_index + 1}" + reference = fields[1].strip() if len(fields) > 1 else "" + resolution = _channel_resolution(fields, source_index) + original_unit = fields[3].strip() if len(fields) > 3 and fields[3].strip() else "µV" + unit_key = original_unit.replace("\N{MICRO SIGN}", "µ").casefold() + unit_factor = _VOLTAGE_TO_MICROVOLTS.get(unit_key) + is_voltage = unit_factor is not None + scale = resolution * (unit_factor if unit_factor is not None else 1.0) + chanloc = { + "labels": label, + "ref": reference, + "type": "EEG" if is_voltage else "MISC", + "unit": "µV" if is_voltage else original_unit, + "bvunit": original_unit, + "bvresolution": resolution, + "urchan": source_index, + } + if key in coordinates: + chanloc.update(_coordinate_fields(coordinates[key], source_index)) + parsed_channels.append(chanloc) + scales.append(scale) + return parsed_channels, np.asarray(scales, dtype=float) + + +def _channel_resolution(fields: list[str], source_index: int) -> float: + raw = fields[2].strip() if len(fields) > 2 else "" + if not raw: + return 1.0 + try: + resolution = float(raw) + except ValueError as error: + raise ValueError(f"BrainVision Ch{source_index + 1} resolution must be numeric") from error + if not np.isfinite(resolution): + raise ValueError(f"BrainVision Ch{source_index + 1} resolution must be finite") + return resolution + + +def _coordinate_fields(value: str, source_index: int) -> dict[str, float]: + fields = _split_brainvision_fields(value) + if len(fields) < 3: + raise ValueError(f"BrainVision coordinate Ch{source_index + 1} requires radius, theta, phi") + try: + radius, theta, phi = (float(field.strip()) for field in fields[:3]) + except ValueError as error: + raise ValueError(f"BrainVision coordinate Ch{source_index + 1} must be numeric") from error + if not np.all(np.isfinite([radius, theta, phi])): + raise ValueError(f"BrainVision coordinate Ch{source_index + 1} must be finite") + if radius == theta == phi == 0: + return { + "X": np.nan, + "Y": np.nan, + "Z": np.nan, + "sph_radius": np.nan, + "sph_theta": np.nan, + "sph_phi": np.nan, + } + polar = np.deg2rad(theta) + azimuth = np.deg2rad(phi) + bv_x = radius * np.sin(polar) * np.cos(azimuth) + bv_y = radius * np.sin(polar) * np.sin(azimuth) + return { + "X": float(bv_y), + "Y": float(-bv_x), + "Z": float(radius * np.cos(polar)), + "sph_radius": radius, + "sph_theta": phi - 90.0 * np.sign(theta), + "sph_phi": -abs(theta) + 90.0, + } + + +def _read_binary_data( + data_path: Path, + dtype: np.dtype, + channel_count: int, + point_count: int, + orientation: str, + selected_channels: list[int], + first: int, + stop: int, +) -> np.ndarray: + memory = np.memmap(data_path, dtype=dtype, mode="r") + if orientation == "MULTIPLEXED": + samples = memory.reshape(point_count, channel_count) + return np.asarray(samples[first:stop, selected_channels].T, dtype=float).copy() + channels = memory.reshape(channel_count, point_count) + return np.asarray(channels[np.ix_(selected_channels, np.arange(first, stop))], dtype=float).copy() + + +def _read_ascii_data( + data_path: Path, + header: dict[str, dict[str, str]], + channel_count: int, + orientation: str, +) -> np.ndarray: + ascii_infos = header.get("ascii infos", {}) + skip_lines = _nonnegative_ascii_int(ascii_infos, "SkipLines") + skip_columns = _nonnegative_ascii_int(ascii_infos, "SkipColumns") + decimal_symbol = ascii_infos.get("decimalsymbol", ".").strip() or "." + rows: list[list[float]] = [] + for raw_line in _decode_brainvision_text(data_path).splitlines()[skip_lines:]: + line = raw_line.strip() + if not line: + continue + if decimal_symbol != ".": + line = line.replace(decimal_symbol, ".") + fields = [field for field in re.split(r"[\s,;]+", line) if field] + fields = fields[skip_columns:] + try: + rows.append([float(field) for field in fields]) + except ValueError as error: + raise ValueError(f"BrainVision ASCII data contains a non-numeric value: {raw_line!r}") from error + if not rows or any(len(row) != len(rows[0]) for row in rows): + raise ValueError("BrainVision ASCII data must be a non-empty rectangular numeric table") + matrix = np.asarray(rows, dtype=float) + if orientation == "MULTIPLEXED": + if matrix.shape[1] != channel_count: + raise ValueError("BrainVision multiplexed ASCII data column count does not match NumberOfChannels") + return matrix.T + if matrix.shape[0] != channel_count: + raise ValueError("BrainVision vectorized ASCII data row count does not match NumberOfChannels") + return matrix + + +def _nonnegative_ascii_int(section: dict[str, str], field: str) -> int: + raw = section.get(field.casefold(), "0").strip() or "0" + try: + value = int(raw) + except ValueError as error: + raise ValueError(f"BrainVision {field} must be a nonnegative integer") from error + if value < 0: + raise ValueError(f"BrainVision {field} must be a nonnegative integer") + return value + + +def _read_events( + header_path: Path, + common: dict[str, str], + *, + channel_count: int, + selected_channels: list[int], + first: int, + stop: int, +) -> list[dict[str, Any]]: + marker_name = common.get("markerfile", "").strip() + if not marker_name: + return [] + try: + marker_path = _referenced_file(header_path, marker_name, fallback_suffixes=(".vmrk",)) + except FileNotFoundError: + warnings.warn( + f"BrainVision marker file not found; importing without events: {marker_name}", + RuntimeWarning, + stacklevel=2, + ) + return [] + marker_text = _decode_brainvision_text(marker_path) + if marker_text.strip() == "--corrupted--": + warnings.warn( + f"BrainVision marker file is marked corrupted; importing without events: {marker_path}", + RuntimeWarning, + stacklevel=2, + ) + return [] + marker = _read_configuration(marker_path, expected_prefix=_MARKER_PREFIX) + marker_common = marker.get("common infos", {}) + marker_data_file = marker_common.get("datafile", "").strip() + if marker_data_file and marker_data_file.casefold() != _required(common, "DataFile").casefold(): + warnings.warn( + "BrainVision header and marker files reference different data files", + RuntimeWarning, + stacklevel=2, + ) + parsed: list[dict[str, Any]] = [] + for key, value in _marker_entries(marker_text, marker_path): + match = re.fullmatch(r"mk(\d+)", key, flags=re.IGNORECASE) + if match is None: + raise ValueError(f"Invalid BrainVision marker key: {key}") + marker_number = int(match.group(1)) + parsed.append(_parse_marker(value, marker_number, channel_count)) + events: list[dict[str, Any]] = [] + for source_event in parsed: + latency = float(source_event["latency"]) + if latency < first + 1 or latency > stop: + continue + event = dict(source_event) + event["latency"] = latency - first + source_channel = int(event["channel"]) + if source_channel: + event["bvchannel"] = source_channel + try: + event["channel"] = selected_channels.index(source_channel - 1) + 1 + except ValueError: + event["channel"] = 0 + if event["type"] == "boundary": + event["duration"] = np.nan + event["urevent"] = len(events) + events.append(event) + return events + + +def _parse_marker(value: str, marker_number: int, channel_count: int) -> dict[str, Any]: + fields = _split_brainvision_fields(value) + if len(fields) < 5: + raise ValueError(f"BrainVision marker Mk{marker_number} requires at least five fields") + marker_type = fields[0].strip() + description = fields[1].strip() + try: + latency = float(fields[2].strip()) + duration_float = float(fields[3].strip()) + channel_float = float(fields[4].strip()) + except ValueError as error: + raise ValueError(f"BrainVision marker Mk{marker_number} has invalid numeric fields") from error + if not np.all(np.isfinite([latency, duration_float, channel_float])): + raise ValueError(f"BrainVision marker Mk{marker_number} has non-finite numeric fields") + if duration_float != np.floor(duration_float) or channel_float != np.floor(channel_float): + raise ValueError(f"BrainVision marker Mk{marker_number} duration and channel must be integers") + duration = int(duration_float) + channel = int(channel_float) + if latency < 1 or duration < 0 or channel < 0 or channel > channel_count: + raise ValueError(f"BrainVision marker Mk{marker_number} has out-of-range numeric fields") + event: dict[str, Any] = { + "type": "boundary" if marker_type.casefold() in {"new segment", "dc correction"} else description, + "latency": latency, + "duration": duration, + "channel": channel, + "code": marker_type, + "bvmknum": marker_number, + } + if len(fields) > 5 and fields[5].strip(): + event["bvtime"] = fields[5].strip() + if len(fields) > 6 and fields[6].strip(): + event["visible"] = fields[6].strip() + return event + + +def _split_brainvision_fields(value: str) -> list[str]: + placeholder = "\0BRAINVISION_COMMA\0" + escaped = value.replace(r"\1", placeholder).replace("\x01", placeholder) + return [field.replace(placeholder, ",") for field in escaped.split(",")] + + +def _marker_entries(text: str, path: Path) -> list[tuple[str, str]]: + entries: list[tuple[str, str]] = [] + in_marker_infos = False + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + section_match = re.fullmatch(r"\[([^]]+)]", line) + if section_match is not None: + in_marker_infos = section_match.group(1).strip().casefold() == "marker infos" + continue + if not in_marker_infos or not line or line.startswith(";"): + continue + if "=" not in line: + raise ValueError(f"Malformed BrainVision marker file {path} at line {line_number}") + key, value = line.split("=", 1) + entries.append((key.strip(), value.strip())) + return entries + + +def _referenced_file(header_path: Path, filename: str, *, fallback_suffixes: tuple[str, ...]) -> Path: + requested = Path(filename) + candidate = requested if requested.is_absolute() else header_path.parent / requested + resolved = _case_insensitive_file(candidate) + if resolved is not None: + return resolved + for suffix in fallback_suffixes: + resolved = _case_insensitive_file(header_path.with_suffix(suffix)) + if resolved is not None: + return resolved + raise FileNotFoundError(f"BrainVision referenced file not found: {candidate}") + + +def _case_insensitive_file(path: Path) -> Path | None: + if path.is_file(): + return path + parent = path.parent + if not parent.is_dir(): + return None + expected = path.name.casefold() + return next((candidate for candidate in parent.iterdir() if candidate.name.casefold() == expected), None) + + +def _build_eeg( + header_path: Path, + *, + data_file: str, + data: np.ndarray, + metadata: bool, + chanlocs: list[dict[str, Any]], + events: list[dict[str, Any]], + point_count: int, + srate: float, + common: dict[str, str], + command: str, + selected_channels: list[int], + data_format: str, + orientation: str, +) -> dict[str, Any]: + data, events, point_count, trials, xmin = _segment_data( + data, + events, + point_count=point_count, + srate=srate, + segmentation_type=common.get("segmentationtype", ""), + ) + eeg = eeg_emptyset() + eeg.update( + { + "setname": header_path.stem, + "filename": header_path.name, + "filepath": str(header_path.parent), + "comments": f"Original file: {data_file}", + "nbchan": len(chanlocs), + "trials": trials, + "pnts": point_count, + "srate": srate, + "xmin": xmin, + "xmax": xmin + (point_count - 1) / srate, + "times": (xmin + np.arange(point_count, dtype=float) / srate) * 1000.0, + "data": data, + "chanlocs": np.asarray(chanlocs, dtype=object), + "urchanlocs": np.array([], dtype=object), + "event": np.asarray(events, dtype=object), + "urevent": np.asarray( + [{key: value for key, value in event.items() if key != "urevent"} for event in events], + dtype=object, + ), + "eventdescription": np.array([], dtype=object), + "epoch": np.array([], dtype=object), + "epochdescription": np.array([], dtype=object), + "icachansind": np.array([], dtype=int), + "session": "", + "run": "", + "specdata": {}, + "specicaact": {}, + "ref": "common", + "history": command, + "saved": "no", + "etc": { + "brainvision": { + "data_format": data_format, + "data_orientation": orientation, + "metadata_only": bool(metadata), + "segmentation_type": common.get("segmentationtype", ""), + "source_channels": np.asarray(selected_channels, dtype=int), + } + }, + } + ) + checked = eeg_checkset(eeg) + if metadata: + checked["data"] = np.array([], dtype=float) + return checked + + +def _segment_data( + data: np.ndarray, + events: list[dict[str, Any]], + *, + point_count: int, + srate: float, + segmentation_type: str, +) -> tuple[np.ndarray, list[dict[str, Any]], int, int, float]: + if segmentation_type.strip().casefold() not in {"markerbased", "fixtime"}: + return data, events, point_count, 1, 0.0 + boundary_latencies = sorted({float(event["latency"]) for event in events if event.get("type") == "boundary"}) + if len(boundary_latencies) < 2 or boundary_latencies[0] != 1: + return data, events, point_count, 1, 0.0 + intervals = np.diff([*boundary_latencies, point_count + 1]) + if not np.allclose(intervals, intervals[0], rtol=0, atol=1e-9): + return data, events, point_count, 1, 0.0 + epoch_points = int(round(float(intervals[0]))) + trials = len(boundary_latencies) + if epoch_points < 1 or epoch_points * trials != point_count: + return data, events, point_count, 1, 0.0 + + kept_events = [dict(event) for event in events if event.get("type") != "boundary"] + xmin = 0.0 + time_zero_events = [event for event in kept_events if str(event.get("code", "")).casefold() == "time 0"] + if time_zero_events: + for event in time_zero_events: + event["type"] = "TLE" + xmin = -(float(time_zero_events[0]["latency"]) - 1.0) / srate + for index, event in enumerate(kept_events): + event["epoch"] = int(np.ceil(float(event["latency"]) / epoch_points)) + event["urevent"] = index + if data.size: + data = data.reshape(data.shape[0], trials, epoch_points).transpose(0, 2, 1) + return data, kept_events, epoch_points, trials, xmin + + +def _history_command(header_path: Path, srange: Any, chans: Any, metadata: bool) -> str: + arguments = [ + format_history_value(header_path.parent), + format_history_value(header_path.name), + ] + if srange is not None or chans is not None or metadata: + arguments.append(_history_selection(srange, "srange")) + if chans is not None or metadata: + arguments.append(_history_selection(chans, "chans")) + if metadata: + arguments.append("true") + return f"EEG = pop_loadbv({', '.join(arguments)});" + + +def _history_selection(value: Any, name: str) -> str: + values = _integer_vector(value, name) + if value is not None and np.asarray(value).ndim == 0 and values: + return format_history_value(values[0]) + return format_history_value(values) + + +__all__ = ["pop_loadbv"] diff --git a/src/eegprep/resources/help/pop_loadbv.md b/src/eegprep/resources/help/pop_loadbv.md new file mode 100644 index 00000000..770c0bec --- /dev/null +++ b/src/eegprep/resources/help/pop_loadbv.md @@ -0,0 +1,31 @@ +# POP_LOADBV - Import a BrainVision recording + +`pop_loadbv` imports a BrainVision Data Exchange recording from its `.vhdr` +header. The companion data (`.eeg` or `.dat`) and optional marker (`.vmrk`) +files must remain beside the header, using the filenames recorded in it. + +```python +from eegprep import pop_loadbv + +EEG = pop_loadbv("record.vhdr") +EEG = pop_loadbv("/data/session", "subject01.vhdr") +EEG = pop_loadbv("/data/session", "subject01.vhdr", [1001, 5000], [1, 2, 8]) +``` + +Sample ranges and channel indices are 1-based, and a two-value sample range is +inclusive. A scalar sample selects from that sample through the end. Set +`metadata=True` to read dimensions, channel information, and markers without +loading signal values. + +Binary `INT_16`, `UINT_16`, and `IEEE_FLOAT_32` recordings are supported in +multiplexed and vectorized orientation. Multiplexed and vectorized ASCII data +are also supported. Voltage channels are returned in microvolts; each channel's +original BrainVision unit and resolution are preserved in `bvunit` and +`bvresolution`. + +Marker positions and durations are measured in samples. Event latency remains +1-based, while `urevent` pointers use EEGPrep's 0-based internal convention. +Marker-based or fixed-time recordings with uniformly spaced `New Segment` +markers are returned as channel-by-sample-by-trial arrays. +Malformed headers, invalid selections, and truncated binary data raise a clear +error rather than returning an inconsistent dataset. diff --git a/tests/test_gui_main_window.py b/tests/test_gui_main_window.py index f4d971a1..592362ba 100644 --- a/tests/test_gui_main_window.py +++ b/tests/test_gui_main_window.py @@ -1466,6 +1466,28 @@ def test_file_menu_importdata_dispatch_stores_new_dataset(self): self.assertEqual(session.CURRENTSET, [1]) self.assertEqual(session.ALLCOM[-1], "EEG = pop_importdata('data', '/tmp/data.tsv');") + def test_file_menu_brainvision_dispatch_uses_pop_loadbv(self): + session = EEGPrepSession() + dispatcher = MenuActionDispatcher(session) + imported = _demo_eeg() + imported["setname"] = "brainvision" + command = "EEG = pop_loadbv('/tmp', 'recording.vhdr');" + qt_widgets = _fake_qt_widgets(open_file="/tmp/recording.vhdr") + + with ( + mock.patch("eegprep.functions.guifunc.menu_actions._require_qt_widgets", return_value=qt_widgets), + mock.patch( + "eegprep.functions.popfunc.pop_loadbv.pop_loadbv", + return_value=(imported, command), + ) as loadbv, + ): + dispatcher.dispatch("pop_fileio_brainvision") + + loadbv.assert_called_once_with("/tmp/recording.vhdr", return_com=True) + self.assertEqual(session.EEG["setname"], "brainvision") + self.assertEqual(session.CURRENTSET, [1]) + self.assertEqual(session.ALLCOM[-1], command) + def test_file_menu_import_uses_native_file_dialog_by_default(self): captured = {} diff --git a/tests/test_pop_loadbv.py b/tests/test_pop_loadbv.py new file mode 100644 index 00000000..f146b470 --- /dev/null +++ b/tests/test_pop_loadbv.py @@ -0,0 +1,455 @@ +"""BrainVision loader tests, including the current EEGLAB wrapper port.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import numpy as np +import pytest + +from eegprep import pop_loadbv +from eegprep.functions.popfunc.pop_fileio import pop_fileio +from tests.eeglab_tests import eeglab_test + + +_UPSTREAM_WRAPPER = "unittesting_binary/pop_loadbv/binary_pop_loadbv_wrapperTest.m" +_DTYPES = { + "INT_16": np.dtype(" Path: + channel_data = np.asarray(data) + if channel_data.ndim != 2: + raise ValueError("fixture data must be channel by sample") + channel_count, point_count = channel_data.shape + labels = labels or [f"Ch{index}" for index in range(1, channel_count + 1)] + references = references or [""] * channel_count + resolutions = resolutions or [1.0] * channel_count + units = units or ["µV"] * channel_count + data_name = f"{stem}{data_suffix}" + marker_name = f"{stem}.vmrk" + dtype = _DTYPES[binary_format].newbyteorder(">" if big_endian else "<") + on_disk = channel_data.T if orientation == "MULTIPLEXED" else channel_data + np.asarray(on_disk, dtype=dtype).tofile(directory / data_name) + + channel_lines = [ + f"Ch{index}={label},{reference},{resolution:.12g},{unit}" + for index, (label, reference, resolution, unit) in enumerate( + zip(labels, references, resolutions, units), start=1 + ) + ] + coordinate_section = "" + if coordinates is not None: + coordinate_lines = [ + f"Ch{index}={radius:.12g},{theta:.12g},{phi:.12g}" + for index, (radius, theta, phi) in enumerate(coordinates, start=1) + ] + coordinate_section = "\n[Coordinates]\n" + "\n".join(coordinate_lines) + "\n" + data_points = point_count if declared_points is None else declared_points + data_points_line = f"DataPoints={data_points}\n" if include_data_points else "" + segmentation_line = f"SegmentationType={segmentation_type}\n" if segmentation_type else "" + header = ( + "Brain Vision Data Exchange Header File Version 1.0\n" + "\n[Common Infos]\n" + f"DataFile={data_name}\n" + f"MarkerFile={marker_name}\n" + "DataFormat=BINARY\n" + "DataType=TIMEDOMAIN\n" + f"DataOrientation={orientation}\n" + f"NumberOfChannels={channel_count}\n" + f"{data_points_line}" + f"SamplingInterval={sampling_interval:.12g}\n" + f"{segmentation_line}" + "\n[Binary Infos]\n" + f"BinaryFormat={binary_format}\n" + f"UseBigEndianOrder={'YES' if big_endian else 'NO'}\n" + "\n[Channel Infos]\n" + "\n".join(channel_lines) + "\n" + coordinate_section + ) + header_path = directory / f"{stem}.vhdr" + header_path.write_text(header, encoding="utf-8") + _write_markers(directory / marker_name, data_name, markers or []) + return header_path + + +def _write_ascii_brainvision( + directory: Path, + stem: str, + data: np.ndarray, + *, + orientation: str, + skip_columns: int = 0, +) -> Path: + channel_data = np.asarray(data, dtype=float) + channel_count, point_count = channel_data.shape + rows = channel_data.T if orientation == "MULTIPLEXED" else channel_data + lines = ["header row"] + for index, row in enumerate(rows, start=1): + values = " ".join(f"{value:.12g}" for value in row) + lines.append(f"{index} {values}" if skip_columns else values) + data_name = f"{stem}.dat" + (directory / data_name).write_text("\n".join(lines) + "\n", encoding="utf-8") + marker_name = f"{stem}.vmrk" + header_path = directory / f"{stem}.vhdr" + header_path.write_text( + "Brain Vision Data Exchange Header File Version 1.0\n" + "\n[Common Infos]\n" + f"DataFile={data_name}\n" + f"MarkerFile={marker_name}\n" + "DataFormat=ASCII\n" + "DataType=TIMEDOMAIN\n" + f"DataOrientation={orientation}\n" + f"NumberOfChannels={channel_count}\n" + f"DataPoints={point_count}\n" + "SamplingInterval=2000\n" + "\n[ASCII Infos]\n" + "SkipLines=1\n" + f"SkipColumns={skip_columns}\n" + "DecimalSymbol=.\n" + "\n[Channel Infos]\n" + + "\n".join(f"Ch{index}=E{index},,0.5,µV" for index in range(1, channel_count + 1)) + + "\n", + encoding="utf-8", + ) + _write_markers(directory / marker_name, data_name, []) + return header_path + + +def _write_markers(path: Path, data_name: str, markers: list[str]) -> None: + marker_lines = "\n".join(f"Mk{index}={marker}" for index, marker in enumerate(markers, start=1)) + path.write_text( + "Brain Vision Data Exchange Marker File, Version 1.0\n" + "\n[Common Infos]\n" + f"DataFile={data_name}\n" + "\n[Marker Infos]\n" + f"{marker_lines}\n", + encoding="utf-8", + ) + + +def _assert_continuous_eeg(eeg: dict[str, Any], expected: np.ndarray, srate: float) -> None: + np.testing.assert_allclose(eeg["data"], expected, rtol=1e-6, atol=1e-8) + assert eeg["data"].shape == expected.shape + assert eeg["nbchan"] == expected.shape[0] + assert eeg["pnts"] == expected.shape[1] + assert eeg["trials"] == 1 + assert eeg["srate"] == pytest.approx(srate) + assert eeg["xmin"] == 0 + assert eeg["xmax"] == pytest.approx((expected.shape[1] - 1) / srate) + np.testing.assert_allclose(eeg["times"], np.arange(expected.shape[1]) / srate * 1000) + + +@eeglab_test(_UPSTREAM_WRAPPER, "test_test_pop_loadbv") +def test_pop_loadbv_ports_all_eight_active_upstream_load_calls(tmp_path: Path) -> None: + selected_raw = np.arange(32 * 5, dtype=np.int16).reshape(32, 5) - 50 + selected_header = _write_binary_brainvision( + tmp_path, + "brainvision_genericdataformat_binarymultiplexed_int16", + selected_raw, + ) + selected = pop_loadbv(tmp_path, selected_header.name, 1, list(range(1, 33))) + _assert_continuous_eeg(selected, selected_raw, 250.0) + assert "binarymultiplexed_int16.vhdr', 1, [1 2 3" in selected["history"] + + located_raw = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.int16) + located_header = _write_binary_brainvision( + tmp_path, + "BVA_withchanlocs", + located_raw, + labels=["Cz", "F4"], + coordinates=[(1, 0, 0), (1, 60, 51)], + ) + located = pop_loadbv(tmp_path, located_header.name) + _assert_continuous_eeg(located, located_raw, 250.0) + assert located["chanlocs"][0]["Z"] == pytest.approx(1) + assert located["chanlocs"][1]["X"] == pytest.approx(np.sin(np.deg2rad(60)) * np.sin(np.deg2rad(51))) + assert located["chanlocs"][1]["Y"] == pytest.approx(-np.sin(np.deg2rad(60)) * np.cos(np.deg2rad(51))) + assert located["chanlocs"][1]["Z"] == pytest.approx(0.5) + + cases = [ + ("brainvision_genericdataformat_binarymultiplexed_ieee32", "MULTIPLEXED", "IEEE_FLOAT_32", ".dat"), + ("brainvision_genericdataformat_binarymultiplexed_int16", "MULTIPLEXED", "INT_16", ".dat"), + ("brainvision_genericdataformat_binaryvectorized_ieee", "VECTORIZED", "IEEE_FLOAT_32", ".dat"), + ("brainvision_genericdataformat_binaryvectorized_int16", "VECTORIZED", "INT_16", ".dat"), + ("brainvision_recorder_acquisitiondataformat", "MULTIPLEXED", "INT_16", ".eeg"), + ] + for index, (stem, orientation, binary_format, data_suffix) in enumerate(cases, start=1): + if binary_format == "IEEE_FLOAT_32": + raw = np.array([[0.25, -1.5, 3.125], [4.5, 2.25, -0.75]], dtype=np.float32) * index + else: + raw = np.array([[1, -2, 30], [40, 5, -6]], dtype=np.int16) * index + header = _write_binary_brainvision( + tmp_path, + stem, + raw, + orientation=orientation, + binary_format=binary_format, + data_suffix=data_suffix, + resolutions=[0.5, 2.0], + include_data_points=stem != "brainvision_recorder_acquisitiondataformat", + ) + if stem == "brainvision_recorder_acquisitiondataformat": + header.write_text( + header.read_text(encoding="utf-8") + + "\n[Comment]\nRecorder free-form metadata\n===========================\n" + + "Number of channels: 2\n", + encoding="utf-8", + ) + eeg = pop_loadbv(tmp_path, header.name) + _assert_continuous_eeg(eeg, raw * np.array([[0.5], [2.0]]), 250.0) + assert eeg["etc"]["brainvision"]["data_orientation"] == orientation + assert eeg["comments"] == f"Original file: {stem}{data_suffix}" + + export_raw = np.array([[1, 2, 3, 4, 5, 6], [10, 20, 30, 40, 50, 60]], dtype=np.float32) + export_header = _write_binary_brainvision( + tmp_path, + "EEGLAB_export", + export_raw, + orientation="VECTORIZED", + binary_format="IEEE_FLOAT_32", + markers=[ + "New Segment,,1,0,0,0", + "Time 0,Stimulus,2.25,0,0,0", + "New Segment,,4,0,0,0", + "Stimulus,square,5.5,0,0,0", + ], + segmentation_type="MARKERBASED", + ) + export_marker = tmp_path / "EEGLAB_export.vmrk" + marker_text = export_marker.read_text(encoding="utf-8") + for marker_number in range(2, 5): + marker_text = marker_text.replace(f"Mk{marker_number}=", "Mk1=") + export_marker.write_text(marker_text, encoding="utf-8") + exported = pop_loadbv(tmp_path, export_header.name) + expected_export = export_raw.reshape(2, 2, 3).transpose(0, 2, 1) + np.testing.assert_array_equal(exported["data"], expected_export) + assert exported["data"].shape == (2, 3, 2) + assert exported["pnts"] == 3 + assert exported["trials"] == 2 + assert exported["xmin"] == pytest.approx(-1.25 / 250) + assert exported["xmax"] == pytest.approx(0.75 / 250) + assert [(event["type"], event["latency"], event["epoch"]) for event in exported["event"]] == [ + ("TLE", 2.25, 1), + ("square", 5.5, 2), + ] + + +def test_pop_loadbv_normalizes_units_selects_samples_and_preserves_markers(tmp_path: Path) -> None: + raw = np.array( + [ + [1, 2, 3, 4, 5], + [10, 20, 30, 40, 50], + [100, 200, 300, 400, 500], + [1000, 2000, 3000, 4000, 5000], + ], + dtype=np.int16, + ) + header = _write_binary_brainvision( + tmp_path, + "units", + raw, + labels=[r"Fp\1Left", "B", "C", "D"], + references=["Cz", "", "", ""], + resolutions=[1e-6, 1e-3, 2, 1], + units=["V", "mV", "uV", "nV"], + markers=[ + "New Segment,,1,1,0,20260913123000123456", + "Stimulus,S 1,2,3,4", + r"Response,correct\1fast,4,2,0,,hidden", + "Stimulus,outside,5,1,0", + ], + ) + + eeg, command = pop_loadbv(tmp_path, header.name, [2, 4], [4, 2], return_com=True) + + expected = np.vstack([raw[3, 1:4] * 0.001, raw[1, 1:4]]) + _assert_continuous_eeg(eeg, expected, 250.0) + assert [loc["labels"] for loc in eeg["chanlocs"]] == ["D", "B"] + assert [loc["bvunit"] for loc in eeg["chanlocs"]] == ["nV", "mV"] + assert [loc["unit"] for loc in eeg["chanlocs"]] == ["µV", "µV"] + assert [loc["urchan"] for loc in eeg["chanlocs"]] == [3, 1] + assert [event["type"] for event in eeg["event"]] == ["S 1", "correct,fast"] + assert [event["latency"] for event in eeg["event"]] == [1, 3] + assert [event["duration"] for event in eeg["event"]] == [3, 2] + assert eeg["event"][0]["channel"] == 1 + assert eeg["event"][0]["bvchannel"] == 4 + assert [event["bvmknum"] for event in eeg["event"]] == [2, 3] + assert [event["urevent"] for event in eeg["event"]] == [0, 1] + assert eeg["event"][1]["visible"] == "hidden" + assert [event["type"] for event in eeg["urevent"]] == ["S 1", "correct,fast"] + assert command == f"EEG = pop_loadbv('{tmp_path.as_posix()}', 'units.vhdr', [2 4], [4 2]);" + assert eeg["history"] == command + + complete = pop_loadbv(header) + np.testing.assert_allclose( + complete["data"], + raw * np.array([[1.0], [1.0], [2.0], [0.001]]), + ) + assert complete["chanlocs"][0]["labels"] == "Fp,Left" + assert complete["chanlocs"][0]["ref"] == "Cz" + assert complete["event"][0]["type"] == "boundary" + assert np.isnan(complete["event"][0]["duration"]) + assert complete["event"][0]["bvtime"] == "20260913123000123456" + + tail = pop_loadbv(header, srange=3) + np.testing.assert_allclose( + tail["data"], + raw[:, 2:] * np.array([[1.0], [1.0], [2.0], [0.001]]), + ) + assert tail["pnts"] == 3 + assert [(event["type"], event["latency"]) for event in tail["event"]] == [ + ("correct,fast", 2), + ("outside", 3), + ] + + +def test_pop_loadbv_metadata_only_keeps_dimensions_channels_and_events(tmp_path: Path) -> None: + header = _write_binary_brainvision( + tmp_path, + "metadata", + np.arange(18, dtype=np.int16).reshape(3, 6), + markers=["Stimulus,A,3,1,0"], + ) + + eeg, command = pop_loadbv(header, srange=[2, 5], chans=[3, 1], metadata=True, return_com=True) + + assert eeg["data"].shape == (0,) + assert eeg["nbchan"] == 2 + assert eeg["pnts"] == 4 + assert eeg["trials"] == 1 + assert eeg["srate"] == 250 + assert [loc["labels"] for loc in eeg["chanlocs"]] == ["Ch3", "Ch1"] + assert [(event["type"], event["latency"]) for event in eeg["event"]] == [("A", 2)] + assert eeg["etc"]["brainvision"]["metadata_only"] is True + assert command.endswith("'metadata.vhdr', [2 5], [3 1], true);") + + +@pytest.mark.parametrize("orientation", ["MULTIPLEXED", "VECTORIZED"]) +def test_pop_loadbv_reads_ascii_orientations_with_header_rows( + tmp_path: Path, + orientation: str, +) -> None: + raw = np.array([[1.25, -2.5, 3.75], [4.0, 5.5, -6.25]]) + header = _write_ascii_brainvision(tmp_path, orientation.lower(), raw, orientation=orientation, skip_columns=1) + + eeg = pop_loadbv(header) + + _assert_continuous_eeg(eeg, raw * 0.5, 500.0) + + +def test_pop_loadbv_honors_big_endian_binary_headers(tmp_path: Path) -> None: + raw = np.array([[1, 256, -2], [1024, -1024, 7]], dtype=np.int16) + header = _write_binary_brainvision(tmp_path, "bigendian", raw, big_endian=True) + + eeg = pop_loadbv(header) + + _assert_continuous_eeg(eeg, raw, 250.0) + + +@pytest.mark.parametrize( + ("change", "message"), + [ + (lambda text: text.replace("DataOrientation=MULTIPLEXED", "DataOrientation=UNKNOWN"), "orientation"), + (lambda text: text.replace("DataType=TIMEDOMAIN", "DataType=FREQUENCYDOMAIN"), "data type"), + (lambda text: text.replace("SamplingInterval=4000", "SamplingInterval=0"), "SamplingInterval"), + (lambda text: text.replace("BinaryFormat=INT_16", "BinaryFormat=INT_32"), "binary format"), + (lambda text: text.replace("Ch2=Ch2,,1,µV\n", ""), "missing Ch2"), + (lambda text: text.replace("DataPoints=3", "DataPoints=4"), "declares 4 samples"), + ], +) +def test_pop_loadbv_rejects_malformed_headers( + tmp_path: Path, + change: Callable[[str], str], + message: str, +) -> None: + header = _write_binary_brainvision(tmp_path, "malformed", np.ones((2, 3), dtype=np.int16)) + header.write_text(change(header.read_text(encoding="utf-8")), encoding="utf-8") + + with pytest.raises(ValueError, match=message): + pop_loadbv(header) + + +def test_pop_loadbv_rejects_truncated_binary_and_invalid_selections(tmp_path: Path) -> None: + header = _write_binary_brainvision(tmp_path, "truncated", np.ones((2, 3), dtype=np.int16)) + data_path = tmp_path / "truncated.dat" + data_path.write_bytes(data_path.read_bytes()[:-1]) + + with pytest.raises(ValueError, match="truncated"): + pop_loadbv(header) + + valid = _write_binary_brainvision(tmp_path, "valid", np.ones((2, 3), dtype=np.int16)) + with pytest.raises(ValueError, match="srange"): + pop_loadbv(valid, srange=[0, 2]) + with pytest.raises(ValueError, match="srange"): + pop_loadbv(valid, srange=[2, 4]) + with pytest.raises(ValueError, match="chans"): + pop_loadbv(valid, chans=[3]) + with pytest.raises(ValueError, match="integer"): + pop_loadbv(valid, chans=[1.5]) + + +def test_pop_loadbv_warns_and_loads_when_marker_file_is_missing(tmp_path: Path) -> None: + header = _write_binary_brainvision(tmp_path, "nomarkers", np.ones((1, 3), dtype=np.int16)) + (tmp_path / "nomarkers.vmrk").unlink() + + with pytest.warns(RuntimeWarning, match="marker file not found"): + eeg = pop_loadbv(header) + + assert eeg["event"].size == 0 + assert eeg["urevent"].size == 0 + + +def test_pop_loadbv_accepts_data_path_and_resolves_companions_case_insensitively(tmp_path: Path) -> None: + header = _write_binary_brainvision( + tmp_path, + "case", + np.array([[1, 2, 3]], dtype=np.int16), + data_suffix=".eeg", + ) + header.rename(tmp_path / "CASE.VHDR") + (tmp_path / "case.eeg").rename(tmp_path / "CASE.EEG") + (tmp_path / "case.vmrk").rename(tmp_path / "CASE.VMRK") + + eeg = pop_loadbv(tmp_path / "case.eeg") + + _assert_continuous_eeg(eeg, np.array([[1, 2, 3]]), 250.0) + + +def test_pop_fileio_routes_brainvision_headers_through_the_standalone_loader(tmp_path: Path) -> None: + raw = np.array([[2, 4, 6], [1, 3, 5]], dtype=np.int16) + header = _write_binary_brainvision( + tmp_path, + "fileio", + raw, + orientation="VECTORIZED", + resolutions=[2, 0.5], + ) + + eeg, command = pop_fileio(header, channels=[2], samples=[2, 3], return_com=True) + + _assert_continuous_eeg(eeg, np.array([[1.5, 2.5]]), 250.0) + assert command == f"EEG = pop_fileio('{header.as_posix()}', 'channels', [2], 'samples', [2 3]);" + assert eeg["history"] == command From 90d137ea250daf884c28abff62e918888411bb37 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:53 -0700 Subject: [PATCH 29/45] feat: import ERPSS raw recordings (#357) --- docs/source/api/data_loading_and_saving.rst | 6 + docs/source/api/interactive_pop_workflows.rst | 5 + docs/source/changelog.rst | 5 + src/eegprep/__init__.py | 2 + .../functions/popfunc/pop_read_erpss.py | 73 +++++ .../functions/sigprocfunc/read_erpss.py | 304 ++++++++++++++++++ tests/test_erpss_import_eeglab_tests.py | 264 +++++++++++++++ 7 files changed, 659 insertions(+) create mode 100644 src/eegprep/functions/popfunc/pop_read_erpss.py create mode 100644 src/eegprep/functions/sigprocfunc/read_erpss.py create mode 100644 tests/test_erpss_import_eeglab_tests.py diff --git a/docs/source/api/data_loading_and_saving.rst b/docs/source/api/data_loading_and_saving.rst index ec7c81e5..790be4cb 100644 --- a/docs/source/api/data_loading_and_saving.rst +++ b/docs/source/api/data_loading_and_saving.rst @@ -39,6 +39,7 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.pop_loadbv eegprep.pop_loadcnt eegprep.readbdf + eegprep.read_erpss eegprep.readeetraklocs eegprep.readegilocs eegprep.readelp @@ -79,3 +80,8 @@ unit and resolution remain in each channel's ``bvunit`` and ``bvresolution`` fields. Use ``metadata=True`` to inspect dimensions, channels, and events without loading samples. Uniform marker-based or fixed-time segments are returned as trials rather than a flattened continuous array. + +``read_erpss`` reads uncompressed and delta-compressed ERPSS ``.RAW`` and +``.RDF`` recordings without a compiled MEX extension. It preserves channel +labels and 1-based event sample offsets, and supports both little- and +big-endian files. diff --git a/docs/source/api/interactive_pop_workflows.rst b/docs/source/api/interactive_pop_workflows.rst index 8ae6a635..2bb110ee 100644 --- a/docs/source/api/interactive_pop_workflows.rst +++ b/docs/source/api/interactive_pop_workflows.rst @@ -15,6 +15,9 @@ and inclusive ``samples`` and ``trials`` ranges. ``pop_loadset`` supports metadata-only and 1-based channel loading through ``loadmode``. ``pop_importpres`` recognizes tab-delimited Presentation headers and also accepts EEGLAB's positional event-type, time, and duration field names. +``pop_read_erpss`` imports uncompressed and delta-compressed ERPSS ``.RAW`` +and ``.RDF`` recordings. Pass a sampling rate only when timing is absent from +the recording header. .. autosummary:: :toctree: generated/ @@ -32,6 +35,7 @@ accepts EEGLAB's positional event-type, time, and duration field names. eegprep.pop_load_frombids eegprep.pop_loadset eegprep.pop_loadset_h5 + eegprep.pop_read_erpss eegprep.pop_readlocs eegprep.pop_saveh eegprep.pop_saveset @@ -193,6 +197,7 @@ Other eegprep.pop_mergeset eegprep.pop_multifit eegprep.pop_newset + eegprep.pop_read_erpss eegprep.pop_rejmenu eegprep.pop_rmdat eegprep.pop_runscript diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index dac7bb33..2d26036d 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,11 @@ the `GitHub Releases `_ page. Unreleased ========== +- ERPSS ``.RAW`` and ``.RDF`` import now works without the legacy compiled + MEX decompressor. ``read_erpss`` and ``pop_read_erpss`` support compressed + and uncompressed little- and big-endian recordings, preserve channel labels + and events, apply valid microvolt calibration, and fail clearly on truncated + blocks. - ``pop_loadbv`` now provides a standalone BrainVision reader for binary and ASCII recordings in multiplexed or vectorized orientation. It applies per-channel resolutions, normalizes voltage channels to microvolts, retains diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 55a4259b..3355ca95 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -365,6 +365,7 @@ "pop_rejchanspec": ("eegprep.functions.popfunc.pop_rejchanspec", "pop_rejchanspec"), "pop_rejspec": ("eegprep.functions.popfunc.pop_rejspec", "pop_rejspec"), "pop_rejtrend": ("eegprep.functions.popfunc.pop_rejtrend", "pop_rejtrend"), + "pop_read_erpss": ("eegprep.functions.popfunc.pop_read_erpss", "pop_read_erpss"), "pop_readlocs": ("eegprep.functions.popfunc.pop_readlocs", "pop_readlocs"), "pop_resample": ("eegprep.functions.popfunc.pop_resample", "pop_resample"), "pop_reref": ("eegprep.functions.popfunc.pop_reref", "pop_reref"), @@ -401,6 +402,7 @@ "readegilocs": ("eegprep.functions.sigprocfunc.readegilocs", "readegilocs"), "readelp": ("eegprep.functions.sigprocfunc.readlocs", "readelp"), "readeetraklocs": ("eegprep.functions.sigprocfunc.readlocs", "readeetraklocs"), + "read_erpss": ("eegprep.functions.sigprocfunc.read_erpss", "read_erpss"), "readlocs": ("eegprep.functions.sigprocfunc.readlocs", "readlocs"), "readneurodat": ("eegprep.functions.sigprocfunc.readneurodat", "readneurodat"), "readbdf": ("eegprep.functions.sigprocfunc.readbdf", "readbdf"), diff --git a/src/eegprep/functions/popfunc/pop_read_erpss.py b/src/eegprep/functions/popfunc/pop_read_erpss.py new file mode 100644 index 00000000..9b17a65f --- /dev/null +++ b/src/eegprep/functions/popfunc/pop_read_erpss.py @@ -0,0 +1,73 @@ +"""Import ERPSS ``.RAW`` and ``.RDF`` recordings.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np + +from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset +from eegprep.functions.popfunc._file_io import eeg_from_data +from eegprep.functions.popfunc._pop_utils import format_history_value +from eegprep.functions.sigprocfunc.read_erpss import read_erpss + + +def pop_read_erpss( + filename: str | Path, + srate: float | None = None, + *, + return_com: bool = False, +) -> dict[str, Any] | tuple[dict[str, Any], str]: + """Import an ERPSS recording into an EEG dictionary. + + A valid sampling rate stored in the recording takes precedence over + ``srate``. Pass ``srate`` for older recordings whose headers do not carry + enough timing information. + """ + path = Path(filename) + data, raw_events, header = read_erpss(path) + effective_srate = _effective_srate(header["srate"], srate) + eeg = eeg_from_data( + data, + srate=effective_srate, + setname="ERPSS data", + comments=f"Original file: {path}", + chanlocs=[{"labels": label} for label in header["chanlabels"]], + filename=path.name, + filepath=str(path.parent), + ) + events = [] + for index, event in enumerate(raw_events): + events.append( + { + **event, + "type": event["event_code"], + "latency": float(event["sample_offset"]), + "urevent": index, + } + ) + eeg["event"] = events + eeg["urevent"] = np.asarray( + [{key: value for key, value in event.items() if key != "urevent"} for event in events], + dtype=object, + ) + eeg = eeg_checkset(eeg, "eventconsistency") + command = f"EEG = pop_read_erpss({format_history_value(path)}, {format_history_value(effective_srate)});" + eeg["history"] = command + return (eeg, command) if return_com else eeg + + +def _effective_srate(header_srate: Any, fallback: float | None) -> float: + file_srate = float(header_srate) + if np.isfinite(file_srate) and file_srate >= 0.5: + return file_srate + if fallback is None: + raise ValueError("ERPSS header has no valid sampling rate; pass srate explicitly") + fallback = float(fallback) + if not np.isfinite(fallback) or fallback <= 0: + raise ValueError("srate must be a finite positive number") + return fallback + + +__all__ = ["pop_read_erpss"] diff --git a/src/eegprep/functions/sigprocfunc/read_erpss.py b/src/eegprep/functions/sigprocfunc/read_erpss.py new file mode 100644 index 00000000..7db34b9a --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/read_erpss.py @@ -0,0 +1,304 @@ +"""Read ERPSS ``.RAW`` and ``.RDF`` recordings.""" + +from __future__ import annotations + +from pathlib import Path +import struct +from typing import BinaryIO, Any + +import numpy as np + + +_FILE_HEADER_BYTES = 4096 +_BLOCK_HEADER_BYTES = 512 +_MAX_EVENTS_PER_BLOCK = 110 +_CHANNEL_LABEL_OFFSET = 580 +_CHANNEL_LABEL_BYTES = 8 +_LITTLE_FILE_TAG = b"\x55\xaa\xb0\x00" +_LITTLE_BLOCK_TAG = 0x00F0AA55 +_BIG_FILE_TAG = b"\xaa\x55\x00\xb0" +_BIG_BLOCK_TAG = 0xAA5500F0 + + +def read_erpss(filename: str | Path) -> tuple[np.ndarray, list[dict[str, int]], dict[str, Any]]: + """Read an ERPSS recording into channel-major microvolt data. + + ERPSS files contain a 4096-byte recording header followed by 512-byte + block headers and either interleaved signed 16-bit samples or ERPSS + delta-compressed samples. Event latencies returned in ``sample_offset`` + are 1-based, matching EEG dataset event latencies. + + Args: + filename: ERPSS ``.RAW`` or ``.RDF`` file. + + Returns: + A ``(data, events, header)`` tuple. ``data`` has shape + ``(nchans, nframes)`` and is scaled to microvolts when calibration is + present. Each event contains ``sample_offset``, ``event_code``, and + ``cond_code``. + + Raises: + ValueError: If the file header, a block, or compressed data is invalid + or truncated. + """ + path = Path(filename) + with path.open("rb") as stream: + file_header = stream.read(_FILE_HEADER_BYTES) + if len(file_header) != _FILE_HEADER_BYTES: + raise ValueError("ERPSS file is shorter than its 4096-byte header") + byteorder, prefix, block_tag = _byte_order(file_header[:4]) + compressed = bool(struct.unpack_from(f"{prefix}H", file_header, 4)[0]) + nchans = int(struct.unpack_from(f"{prefix}H", file_header, 6)[0]) + if nchans <= 0: + raise ValueError("ERPSS header declares no channels") + labels = _channel_labels(file_header, nchans) + sample_interval = int(struct.unpack_from(f"{prefix}H", file_header, 552)[0]) + blocks, events, metadata = _read_blocks( + stream, + prefix=prefix, + byteorder=byteorder, + block_tag=block_tag, + nchans=nchans, + compressed=compressed, + ) + + if not blocks: + raise ValueError("ERPSS file contains no data blocks") + data = np.concatenate(blocks, axis=1).astype(float, copy=False) + scale = _microvolt_scale(metadata) + if scale is not None: + data *= scale + srate = _sampling_rate(metadata, sample_interval) + header: dict[str, Any] = { + "nchans": nchans, + "nframes": int(data.shape[1]), + "nblocks": len(blocks), + "compressed": compressed, + "byteorder": byteorder, + "chanlabels": labels, + "srate": srate, + **metadata, + } + if scale is not None: + header["rescaleuv"] = scale + return data, events, header + + +def _byte_order(tag: bytes) -> tuple[str, str, int]: + if tag == _LITTLE_FILE_TAG: + return "little", "<", _LITTLE_BLOCK_TAG + if tag == _BIG_FILE_TAG: + return "big", ">", _BIG_BLOCK_TAG + raise ValueError("File does not start with a recognized ERPSS byte-order tag") + + +def _channel_labels(header: bytes, nchans: int) -> list[str]: + end = _CHANNEL_LABEL_OFFSET + nchans * _CHANNEL_LABEL_BYTES + if end > len(header): + return [f"Ch{index}" for index in range(1, nchans + 1)] + labels = [] + for index in range(nchans): + start = _CHANNEL_LABEL_OFFSET + index * _CHANNEL_LABEL_BYTES + raw = header[start : start + _CHANNEL_LABEL_BYTES] + label = raw.split(b"\x00", 1)[0].decode("latin-1").strip() + labels.append(label or f"Ch{index + 1}") + return labels + + +def _read_blocks( + stream: BinaryIO, + *, + prefix: str, + byteorder: str, + block_tag: int, + nchans: int, + compressed: bool, +) -> tuple[list[np.ndarray], list[dict[str, int]], dict[str, int]]: + blocks: list[np.ndarray] = [] + events: list[dict[str, int]] = [] + metadata: dict[str, int] = {} + samples_before_block = 0 + while True: + raw_header = stream.read(_BLOCK_HEADER_BYTES) + if not raw_header: + break + if len(raw_header) != _BLOCK_HEADER_BYTES: + raise ValueError(f"ERPSS block {len(blocks) + 1} has a truncated header") + fields = struct.unpack_from(f"{prefix}I10H", raw_header) + ( + tag, + _record_type, + block_nchans, + _block_version, + block_size, + ndupsamp, + nrun, + err_detect, + nlost, + nevents, + compressed_words, + ) = fields + block_number = len(blocks) + 1 + if tag != block_tag: + raise ValueError(f"ERPSS block {block_number} has an invalid tag") + if block_nchans != nchans: + raise ValueError(f"ERPSS block {block_number} declares {block_nchans} channels; expected {nchans}") + if block_size <= 0: + raise ValueError(f"ERPSS block {block_number} has no samples") + if nevents > _MAX_EVENTS_PER_BLOCK: + raise ValueError(f"ERPSS block {block_number} declares more than 110 events") + if not blocks: + metadata = _block_metadata(raw_header, prefix) + metadata.update( + { + "ndupsamp": int(ndupsamp), + "nrun": int(nrun), + "err_detect": int(err_detect), + "nlost": int(nlost), + } + ) + events.extend( + _block_events( + raw_header, + prefix=prefix, + nevents=nevents, + block_size=block_size, + samples_before_block=samples_before_block, + block_number=block_number, + ) + ) + payload_bytes = int(compressed_words) * 2 if compressed else nchans * block_size * 2 + payload = stream.read(payload_bytes) + if len(payload) != payload_bytes: + raise ValueError(f"ERPSS block {block_number} has truncated sample data") + if compressed: + flat = _decompress(payload, nchans * block_size, nchans, byteorder) + else: + flat = np.frombuffer(payload, dtype=f"{prefix}i2", count=nchans * block_size).astype(np.int16, copy=False) + blocks.append(flat.reshape(block_size, nchans).T) + samples_before_block += block_size + return blocks, events, metadata + + +def _block_metadata(header: bytes, prefix: str) -> dict[str, int]: + amplif, clock_freq, divider, ad_range_mv, ad_bits, nsteps = struct.unpack_from(f"{prefix}6I", header, 24) + return { + "amplif": int(amplif), + "clock_freq": int(clock_freq), + "divider": int(divider), + "ad_range_mv": int(ad_range_mv), + "ad_bits": int(ad_bits), + "nsteps": int(nsteps), + } + + +def _block_events( + header: bytes, + *, + prefix: str, + nevents: int, + block_size: int, + samples_before_block: int, + block_number: int, +) -> list[dict[str, int]]: + events = [] + for index in range(nevents): + sample, condition, code = struct.unpack_from(f"{prefix}BBH", header, 72 + index * 4) + if sample >= block_size: + raise ValueError(f"ERPSS event {index + 1} in block {block_number} lies outside the block") + events.append( + { + "sample_offset": samples_before_block + int(sample) + 1, + "event_code": int(code), + "cond_code": int(condition), + } + ) + return events + + +def _microvolt_scale(metadata: dict[str, int]) -> float | None: + ad_range_mv = metadata["ad_range_mv"] + amplif = metadata["amplif"] + ad_bits = metadata["ad_bits"] + if ad_range_mv <= 0 or amplif <= 0 or not 1 <= ad_bits <= 32: + return None + return ad_range_mv * 1000.0 / amplif / (2**ad_bits) + + +def _sampling_rate(metadata: dict[str, int], sample_interval: int) -> float: + clock_freq = metadata["clock_freq"] + divider = metadata["divider"] + nsteps = metadata["nsteps"] + if clock_freq > 0 and divider > 0 and nsteps > 0: + candidate = clock_freq / divider / nsteps + return float(candidate) if np.isfinite(candidate) and candidate >= 0.5 else 0.0 + if sample_interval > 0: + return 1_000_000.0 / sample_interval + return 0.0 + + +def _decompress(payload: bytes, sample_count: int, nchans: int, byteorder: str) -> np.ndarray: + if len(payload) % 2: + raise ValueError("ERPSS compressed data must contain complete 16-bit words") + words = np.frombuffer(payload, dtype="u2") + reader = _BitReader(words) + output = np.empty(sample_count, dtype=np.int32) + try: + for index in range(sample_count): + code = reader.read(4) + absolute = False + if code & 0b1000 == 0: + value = _signed(code, 3) + elif code & 0b0100 == 0: + value = ((code & 1) << 4) | reader.read(4) + if code & 0b0010: + value -= 32 + elif code & 0b0010 == 0: + value = reader.read(8) + if code & 1: + value -= 256 + else: + value = _signed(reader.read(12), 12) + absolute = True + if not absolute: + if index < nchans: + raise ValueError("ERPSS compressed channel starts with a delta instead of an absolute sample") + value += int(output[index - nchans]) + if not -32768 <= value <= 32767: + raise ValueError("ERPSS decompression produced a sample outside the int16 range") + output[index] = value + except EOFError as error: + raise ValueError("ERPSS compressed data ended before all samples were decoded") from error + return output.astype(np.int16) + + +def _signed(value: int, bits: int) -> int: + sign = 1 << (bits - 1) + return value - (1 << bits) if value & sign else value + + +class _BitReader: + def __init__(self, words: np.ndarray) -> None: + self._words = words + self._word_index = 0 + self._remaining = 0 + self._word = 0 + + def read(self, count: int) -> int: + value = 0 + while count: + if self._remaining == 0: + if self._word_index >= self._words.size: + raise EOFError + self._word = int(self._words[self._word_index]) + self._word_index += 1 + self._remaining = 16 + take = min(count, self._remaining) + shift = self._remaining - take + value = (value << take) | ((self._word >> shift) & ((1 << take) - 1)) + self._remaining -= take + count -= take + return value + + +__all__ = ["read_erpss"] diff --git a/tests/test_erpss_import_eeglab_tests.py b/tests/test_erpss_import_eeglab_tests.py new file mode 100644 index 00000000..462b0548 --- /dev/null +++ b/tests/test_erpss_import_eeglab_tests.py @@ -0,0 +1,264 @@ +"""Current ``eeglab_tests`` coverage for ERPSS recording import.""" + +from __future__ import annotations + +from pathlib import Path +import struct + +import numpy as np +import pytest + +from eegprep import pop_read_erpss, read_erpss +from tests.eeglab_tests import eeglab_test + + +ERPSS_SOURCE = "unittesting_binary/pop_read_erpss/binary_pop_read_erpss_wrapperTest.m" + + +@eeglab_test(ERPSS_SOURCE, "test_test_pop_read_erpss") +def test_pop_read_erpss_imports_both_upstream_compressed_recordings(tmp_path: Path) -> None: + """Strengthen the upstream test, which only calls the importer without assertions.""" + first_blocks = [ + np.array( + [ + [100, 102, 82, 282, -18, 1982], + [-100, -104, -70, -300, -301, -1800], + ], + dtype=np.int16, + ), + np.array([[50, 53, 21, 277], [-50, -46, -280, -25]], dtype=np.int16), + ] + first = tmp_path / "ERPSSTESTCOMP.RAW" + _write_erpss( + first, + first_blocks, + compressed=True, + byteorder="little", + labels=["Cz", "Pz"], + metadata=(1000, 1_000_000, 1000, 2048, 12, 2), + events=[[(0, 1, 7), (5, 2, 9)], [(1, 3, 11)]], + ) + + data, events, header = read_erpss(first) + + np.testing.assert_array_equal(data, np.concatenate(first_blocks, axis=1) * 0.5) + assert events == [ + {"sample_offset": 1, "event_code": 7, "cond_code": 1}, + {"sample_offset": 6, "event_code": 9, "cond_code": 2}, + {"sample_offset": 8, "event_code": 11, "cond_code": 3}, + ] + assert header["nchans"] == 2 + assert header["nframes"] == 10 + assert header["nblocks"] == 2 + assert header["compressed"] is True + assert header["byteorder"] == "little" + assert header["chanlabels"] == ["Cz", "Pz"] + assert header["srate"] == 500 + assert header["rescaleuv"] == 0.5 + + eeg, command = pop_read_erpss(first, 999, return_com=True) + + np.testing.assert_array_equal(eeg["data"], data) + assert eeg["srate"] == 500 + assert eeg["nbchan"] == 2 + assert eeg["pnts"] == 10 + assert eeg["trials"] == 1 + assert eeg["xmin"] == 0 + assert eeg["xmax"] == pytest.approx(9 / 500) + assert [channel["labels"] for channel in eeg["chanlocs"]] == ["Cz", "Pz"] + assert [event["type"] for event in eeg["event"]] == [7, 9, 11] + assert [event["latency"] for event in eeg["event"]] == [1, 6, 8] + assert [event["urevent"] for event in eeg["event"]] == [0, 1, 2] + assert eeg["setname"] == "ERPSS data" + assert eeg["filename"] == first.name + assert eeg["filepath"] == str(tmp_path) + assert eeg["history"] == command + assert command == f"EEG = pop_read_erpss('{first.as_posix()}', 500);" + + second = tmp_path / "ERPSSCOMPRESSED.RAW" + second_data = np.array([[12, 8, 42], [-10, 24, -206]], dtype=np.int16) + _write_erpss( + second, + [second_data], + compressed=True, + byteorder="big", + labels=["F3", "F4"], + events=[[(2, 4, 128)]], + ) + + second_eeg = pop_read_erpss(second, 500) + + np.testing.assert_array_equal(second_eeg["data"], second_data) + assert second_eeg["srate"] == 500 + assert [event["type"] for event in second_eeg["event"]] == [128] + assert [event["latency"] for event in second_eeg["event"]] == [3] + + +def test_read_erpss_reads_uncompressed_big_endian_samples_and_header_rate(tmp_path: Path) -> None: + recording = tmp_path / "uncompressed.rdf" + expected = np.array( + [[32767, -32768, 123], [-456, 789, -1024], [9, 8, 7]], + dtype=np.int16, + ) + _write_erpss( + recording, + [expected], + compressed=False, + byteorder="big", + labels=["A1", "A2", "EOG"], + sample_interval=4000, + ) + + data, events, header = read_erpss(recording) + eeg = pop_read_erpss(recording) + + np.testing.assert_array_equal(data, expected) + assert events == [] + assert header["compressed"] is False + assert header["byteorder"] == "big" + assert header["srate"] == 250 + assert eeg["srate"] == 250 + assert [channel["labels"] for channel in eeg["chanlocs"]] == ["A1", "A2", "EOG"] + + +def test_read_erpss_rejects_corrupt_and_truncated_recordings(tmp_path: Path) -> None: + data = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.int16) + recording = tmp_path / "recording.raw" + _write_erpss(recording, [data], compressed=True, byteorder="little", labels=["A", "B"]) + + invalid_tag = tmp_path / "invalid-tag.raw" + invalid_tag.write_bytes(b"bad!" + recording.read_bytes()[4:]) + with pytest.raises(ValueError, match="byte-order tag"): + read_erpss(invalid_tag) + + truncated = tmp_path / "truncated.raw" + truncated.write_bytes(recording.read_bytes()[:-1]) + with pytest.raises(ValueError, match="truncated sample data"): + read_erpss(truncated) + + invalid_delta = tmp_path / "invalid-delta.raw" + invalid_bytes = bytearray(recording.read_bytes()) + invalid_bytes[4608:4610] = b"\x00\x00" + invalid_delta.write_bytes(invalid_bytes) + with pytest.raises(ValueError, match="starts with a delta"): + read_erpss(invalid_delta) + + outside_event = tmp_path / "outside-event.raw" + _write_erpss( + outside_event, + [data], + compressed=False, + byteorder="little", + labels=["A", "B"], + events=[[(3, 0, 1)]], + ) + with pytest.raises(ValueError, match="outside the block"): + read_erpss(outside_event) + + +def test_pop_read_erpss_requires_a_rate_when_the_header_has_none(tmp_path: Path) -> None: + recording = tmp_path / "unknown-rate.raw" + _write_erpss( + recording, + [np.array([[1, 2]], dtype=np.int16)], + compressed=False, + byteorder="little", + labels=["Cz"], + ) + + with pytest.raises(ValueError, match="sampling rate"): + pop_read_erpss(recording) + with pytest.raises(ValueError, match="finite positive"): + pop_read_erpss(recording, 0) + + +def _write_erpss( + path: Path, + blocks: list[np.ndarray], + *, + compressed: bool, + byteorder: str, + labels: list[str], + metadata: tuple[int, int, int, int, int, int] = (0, 0, 0, 0, 0, 0), + events: list[list[tuple[int, int, int]]] | None = None, + sample_interval: int = 0, +) -> None: + prefix = "<" if byteorder == "little" else ">" + file_tag = b"\x55\xaa\xb0\x00" if byteorder == "little" else b"\xaa\x55\x00\xb0" + block_tag = 0x00F0AA55 if byteorder == "little" else 0xAA5500F0 + nchans = blocks[0].shape[0] + assert all(block.ndim == 2 and block.shape[0] == nchans for block in blocks) + assert len(labels) == nchans + header = bytearray(4096) + header[:4] = file_tag + struct.pack_into(f"{prefix}H", header, 4, 1 if compressed else 0) + struct.pack_into(f"{prefix}H", header, 6, nchans) + struct.pack_into(f"{prefix}H", header, 552, sample_interval) + for index, label in enumerate(labels): + encoded = label.encode("latin-1") + assert len(encoded) <= 8 + start = 580 + index * 8 + header[start : start + len(encoded)] = encoded + output = bytearray(header) + block_events = events or [[] for _ in blocks] + assert len(block_events) == len(blocks) + for block, current_events in zip(blocks, block_events): + payload = _compress(block, prefix) if compressed else block.T.astype(f"{prefix}i2").tobytes() + raw_block_header = bytearray(512) + struct.pack_into( + f"{prefix}I10H", + raw_block_header, + 0, + block_tag, + 1, + nchans, + 6, + block.shape[1], + 0, + 1, + 0, + 0, + len(current_events), + len(payload) // 2 if compressed else 0, + ) + struct.pack_into(f"{prefix}6I", raw_block_header, 24, *metadata) + for index, event in enumerate(current_events): + struct.pack_into(f"{prefix}BBH", raw_block_header, 72 + index * 4, *event) + output.extend(raw_block_header) + output.extend(payload) + path.write_bytes(output) + + +def _compress(data: np.ndarray, prefix: str) -> bytes: + bits: list[int] = [] + + def append(value: int, width: int) -> None: + bits.extend((value >> shift) & 1 for shift in range(width - 1, -1, -1)) + + nchans = data.shape[0] + flat = data.T.astype(np.int32).reshape(-1) + for index, sample in enumerate(flat): + previous = int(flat[index - nchans]) if index >= nchans else None + difference = int(sample) - previous if previous is not None else None + if difference is not None and -4 <= difference <= 3: + append(difference & 0b111, 4) + elif difference is not None and -32 <= difference <= 31: + encoded = difference & 0b11111 + code = 0b1000 | ((difference < 0) << 1) | (encoded >> 4) + append(code, 4) + append(encoded, 4) + elif difference is not None and -256 <= difference <= 255: + append(0b1101 if difference < 0 else 0b1100, 4) + append(difference & 0xFF, 8) + else: + assert -2048 <= sample <= 2047 + append(0b1110, 4) + append(int(sample) & 0xFFF, 12) + bits.extend([0] * (-len(bits) % 16)) + words = [] + for start in range(0, len(bits), 16): + word = 0 + for bit in bits[start : start + 16]: + word = (word << 1) | bit + words.append(word) + return struct.pack(f"{prefix}{len(words)}H", *words) From 7fc9ded2047d12b76c41fce6e059f6ed4f81c640 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:54 -0700 Subject: [PATCH 30/45] Port EGI RAW loading tests (#358) --- docs/source/api/data_loading_and_saving.rst | 7 + docs/source/api/interactive_pop_workflows.rst | 6 + docs/source/changelog.rst | 12 + src/eegprep/__init__.py | 4 + src/eegprep/functions/popfunc/pop_readegi.py | 193 +++++++++ .../functions/popfunc/pop_readsegegi.py | 102 +++++ src/eegprep/functions/sigprocfunc/readegi.py | 137 ++++++ .../functions/sigprocfunc/readegihdr.py | 131 ++++++ tests/test_egi_raw_eeglab_tests.py | 397 ++++++++++++++++++ 9 files changed, 989 insertions(+) create mode 100644 src/eegprep/functions/popfunc/pop_readegi.py create mode 100644 src/eegprep/functions/popfunc/pop_readsegegi.py create mode 100644 src/eegprep/functions/sigprocfunc/readegi.py create mode 100644 src/eegprep/functions/sigprocfunc/readegihdr.py create mode 100644 tests/test_egi_raw_eeglab_tests.py diff --git a/docs/source/api/data_loading_and_saving.rst b/docs/source/api/data_loading_and_saving.rst index 790be4cb..5cc2ae58 100644 --- a/docs/source/api/data_loading_and_saving.rst +++ b/docs/source/api/data_loading_and_saving.rst @@ -40,6 +40,8 @@ Readers and writers for EEG datasets, channel locations, and MNE interchange. eegprep.pop_loadcnt eegprep.readbdf eegprep.read_erpss + eegprep.readegi + eegprep.readegihdr eegprep.readeetraklocs eegprep.readegilocs eegprep.readelp @@ -85,3 +87,8 @@ returned as trials rather than a flattened continuous array. ``.RDF`` recordings without a compiled MEX extension. It preserves channel labels and 1-based event sample offsets, and supports both little- and big-endian files. + +``readegi`` reads EGI Simple Binary RAW versions 2 through 7 without an +EEGLAB installation. Frame and segment selections are 1-based, while returned +sample arrays are channel-major. ``readegihdr`` reads the same acquisition and +event-code metadata without loading signal samples. diff --git a/docs/source/api/interactive_pop_workflows.rst b/docs/source/api/interactive_pop_workflows.rst index 2bb110ee..61abd3f9 100644 --- a/docs/source/api/interactive_pop_workflows.rst +++ b/docs/source/api/interactive_pop_workflows.rst @@ -18,6 +18,10 @@ accepts EEGLAB's positional event-type, time, and duration field names. ``pop_read_erpss`` imports uncompressed and delta-compressed ERPSS ``.RAW`` and ``.RDF`` recordings. Pass a sampling rate only when timing is absent from the recording header. +``pop_readegi`` imports continuous or equal-length segmented EGI Simple Binary +RAW files, including event channels and segment categories. ``pop_readsegegi`` +joins a numbered continuous series ending in ``001.RAW``, ``002.RAW``, and so +on, and validates that acquisition headers agree before concatenating samples. .. autosummary:: :toctree: generated/ @@ -37,6 +41,8 @@ the recording header. eegprep.pop_loadset_h5 eegprep.pop_read_erpss eegprep.pop_readlocs + eegprep.pop_readegi + eegprep.pop_readsegegi eegprep.pop_saveh eegprep.pop_saveset eegprep.pop_writeeeg diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 2d26036d..a5790069 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,18 @@ the `GitHub Releases `_ page. Unreleased ========== +- EGI Simple Binary RAW versions 2 through 7 can now be read directly through + ``readegihdr``, ``readegi``, ``pop_readegi``, and ``pop_readsegegi``. The + readers support 1-based frame or segment selection, A/D scaling, event + channels, segment categories, equal-length epoched datasets, and validated + numbered continuous-file series without requiring EEGLAB or large test + fixtures. Multi-file imports now report a correct continuous point count + instead of copying the zero-valued segmented header fields. Leading-edge + events are placed on the first nonzero sample, correcting EEGLAB's one-sample + early event-channel conversion, and a trailing channel is removed as a + reference only when the whole channel is empty rather than just ten samples. + Numbered imports reject gaps before later files instead of silently dropping + the remainder of the recording. - ERPSS ``.RAW`` and ``.RDF`` import now works without the legacy compiled MEX decompressor. ``read_erpss`` and ``pop_read_erpss`` support compressed and uncompressed little- and big-endian recordings, preserve channel labels diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 3355ca95..98737538 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -367,6 +367,8 @@ "pop_rejtrend": ("eegprep.functions.popfunc.pop_rejtrend", "pop_rejtrend"), "pop_read_erpss": ("eegprep.functions.popfunc.pop_read_erpss", "pop_read_erpss"), "pop_readlocs": ("eegprep.functions.popfunc.pop_readlocs", "pop_readlocs"), + "pop_readegi": ("eegprep.functions.popfunc.pop_readegi", "pop_readegi"), + "pop_readsegegi": ("eegprep.functions.popfunc.pop_readsegegi", "pop_readsegegi"), "pop_resample": ("eegprep.functions.popfunc.pop_resample", "pop_resample"), "pop_reref": ("eegprep.functions.popfunc.pop_reref", "pop_reref"), "pop_rmdat": ("eegprep.functions.popfunc.pop_rmdat", "pop_rmdat"), @@ -399,6 +401,8 @@ "pop_xfirws": ("eegprep.plugins.firfilt.pop_xfirws", "pop_xfirws"), "pophelp": ("eegprep.functions.guifunc.pophelp", "pophelp"), "supergui": ("eegprep.functions.guifunc.supergui", "supergui"), + "readegi": ("eegprep.functions.sigprocfunc.readegi", "readegi"), + "readegihdr": ("eegprep.functions.sigprocfunc.readegihdr", "readegihdr"), "readegilocs": ("eegprep.functions.sigprocfunc.readegilocs", "readegilocs"), "readelp": ("eegprep.functions.sigprocfunc.readlocs", "readelp"), "readeetraklocs": ("eegprep.functions.sigprocfunc.readlocs", "readeetraklocs"), diff --git a/src/eegprep/functions/popfunc/pop_readegi.py b/src/eegprep/functions/popfunc/pop_readegi.py new file mode 100644 index 00000000..9393759c --- /dev/null +++ b/src/eegprep/functions/popfunc/pop_readegi.py @@ -0,0 +1,193 @@ +"""Import an EGI Simple Binary RAW file into an EEG dataset.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Sequence + +import numpy as np + +from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset, strict_mode +from eegprep.functions.popfunc._file_io import eeg_from_data +from eegprep.functions.popfunc._pop_utils import format_history_value +from eegprep.functions.sigprocfunc.readegi import readegi +from eegprep.functions.sigprocfunc.readegilocs import readegilocs + + +def pop_readegi( + filename: str | Path, + datachunks: int | Sequence[int] | np.ndarray | None = None, + forceversion: int | None = None, + fileloc: str | Path | None = "auto", + *, + return_com: bool = False, +) -> dict[str, Any] | tuple[dict[str, Any], str]: + """Import an EGI Simple Binary file. + + ``datachunks`` uses EEGLAB-facing 1-based frame or segment numbers. EGI + integer A/D values are converted to microvolts by :func:`readegi`, event + channels become dataset events, and segmented recordings are returned as + channel-by-point-by-trial arrays. + """ + path = Path(filename) + header, trial_data, event_data, categories = readegi(path, datachunks, forceversion) + command = _history_command(path, datachunks, forceversion, fileloc) + eeg = _egi_to_eeg( + header, + trial_data, + event_data, + categories, + filename=path, + fileloc=fileloc, + comments=f"Original file: {path}", + ) + eeg["history"] = command + return (eeg, command) if return_com else eeg + + +def _egi_to_eeg( + header: dict[str, Any], + trial_data: np.ndarray, + event_data: np.ndarray, + categories: np.ndarray, + *, + filename: Path, + fileloc: str | Path | None, + comments: str, +) -> dict[str, Any]: + signal_channels = int(header["nchan"]) + channel_locations = [{"labels": f"E{index}"} for index in range(1, signal_channels + 1)] + eeg = eeg_from_data( + trial_data, + nbchan=signal_channels, + srate=float(header["samp_rate"]), + setname="EGI file", + comments=comments, + filename=filename.name, + filepath=str(filename.parent), + chanlocs=channel_locations, + ) + + points, trials = _dataset_dimensions(header, trial_data.shape[1]) + eeg["pnts"] = points + eeg["trials"] = trials + eeg["xmax"] = (points - 1) / float(header["samp_rate"]) if points else 0.0 + eeg["times"] = np.arange(points, dtype=float) / float(header["samp_rate"]) * 1000.0 + eeg["event"] = np.asarray(_event_records(event_data, header["eventcode"], points, trials), dtype=object) + + if _has_empty_reference(eeg["data"]): + eeg["data"] = np.asarray(eeg["data"][:-1, :]) + eeg["nbchan"] = int(eeg["data"].shape[0]) + eeg["chanlocs"] = np.asarray(eeg["chanlocs"][:-1], dtype=object) + + if trials > 1: + flat = np.asarray(eeg["data"]) + eeg["data"] = flat.reshape(flat.shape[0], trials, points).transpose(0, 2, 1) + _apply_segment_categories(eeg, header, categories) + _rebuild_urevents(eeg) + with strict_mode(False): + eeg = eeg_checkset(eeg, "eventconsistency") + if fileloc: + eeg = readegilocs(eeg, None if str(fileloc).lower() == "auto" else str(fileloc)) + eeg["saved"] = "no" + return eeg + + +def _event_records( + event_data: np.ndarray, + event_codes: list[str], + points: int, + trials: int, +) -> list[dict[str, Any]]: + if event_data.shape[1] == 0: + return [] + events = [] + for event_index in range(event_data.shape[0] - 1, -1, -1): + values = np.asarray(event_data[event_index]) + differences = np.diff(np.abs(np.r_[0, values])) + for latency in np.flatnonzero(differences > 0) + 1: + event = {"type": event_codes[event_index], "latency": int(latency)} + if trials > 1: + event["epoch"] = 1 + int((latency - 1) // points) + events.append(event) + events.sort(key=lambda event: (int(event.get("epoch", 0)), int(event["latency"]))) + return events + + +def _dataset_dimensions(header: dict[str, Any], loaded_samples: int) -> tuple[int, int]: + if not header["segmented"]: + return loaded_samples, 1 + points = int(header["segsamps"]) + if points <= 0 or loaded_samples % points: + raise ValueError("Segmented EGI data do not contain complete equal-length trials") + return points, loaded_samples // points + + +def _has_empty_reference(data: np.ndarray) -> bool: + values = np.asarray(data) + if values.ndim != 2 or values.shape[0] == 0 or values.shape[1] == 0: + return False + return bool(np.all(values[-1] == 0)) + + +def _apply_segment_categories( + eeg: dict[str, Any], + header: dict[str, Any], + categories: np.ndarray, +) -> None: + if eeg["trials"] <= 1 or categories.size == 0: + return + events = [dict(event) for event in eeg.get("event", [])] + if not events: + events = [ + { + "epoch": trial + 1, + "type": "TLE", + "latency": 1 + trial * int(eeg["pnts"]), + } + for trial in range(int(eeg["trials"])) + ] + else: + points = int(eeg["pnts"]) + for event in events: + event.setdefault("epoch", 1 + int((float(event["latency"]) - 1) // points)) + names = list(header["catname"]) + for event in events: + epoch = int(event.get("epoch", 0)) + if not 1 <= epoch <= categories.size: + continue + category_index = int(categories[epoch - 1]) + if 1 <= category_index <= len(names): + event["category"] = names[category_index - 1] + eeg["event"] = np.asarray(events, dtype=object) + + +def _rebuild_urevents(eeg: dict[str, Any]) -> None: + events = [dict(event) for event in eeg.get("event", [])] + urevents = [] + for index, event in enumerate(events): + urevent = dict(event) + urevent.pop("urevent", None) + event["urevent"] = index + urevents.append(urevent) + eeg["event"] = np.asarray(events, dtype=object) + eeg["urevent"] = np.asarray(urevents, dtype=object) + + +def _history_command( + filename: Path, + datachunks: int | Sequence[int] | np.ndarray | None, + forceversion: int | None, + fileloc: str | Path | None, +) -> str: + arguments = [format_history_value(filename)] + if datachunks is not None or forceversion is not None or fileloc != "auto": + arguments.append(format_history_value([] if datachunks is None else datachunks)) + if forceversion is not None or fileloc != "auto": + arguments.append(format_history_value(forceversion, none_as_empty=True)) + if fileloc != "auto": + arguments.append(format_history_value(fileloc, none_as_empty=True)) + return f"EEG = pop_readegi({', '.join(arguments)});" + + +__all__ = ["pop_readegi"] diff --git a/src/eegprep/functions/popfunc/pop_readsegegi.py b/src/eegprep/functions/popfunc/pop_readsegegi.py new file mode 100644 index 00000000..196387d6 --- /dev/null +++ b/src/eegprep/functions/popfunc/pop_readsegegi.py @@ -0,0 +1,102 @@ +"""Import a numbered series of EGI Simple Binary RAW files.""" + +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +import re +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._pop_utils import format_history_value +from eegprep.functions.popfunc.pop_readegi import _egi_to_eeg +from eegprep.functions.sigprocfunc.readegi import readegi + + +_NUMBERED_RAW = re.compile(r"^(?P.*?)(?P\d{3})(?P\.[^.]+)$") +_COMPATIBILITY_FIELDS = ("version", "samp_rate", "nchan", "gain", "bits", "range", "eventtypes", "eventcode") + + +def pop_readsegegi( + filename: str | Path, + *, + fileloc: str | Path | None = "auto", + return_com: bool = False, +) -> dict[str, Any] | tuple[dict[str, Any], str]: + """Import the contiguous ``001``, ``002``, ... EGI RAW file series. + + The selected filename may be any member of the series; loading always + begins at ``001``. Files must be compatible continuous EGI recordings. + Missing next-number files end the series, while corrupt or incompatible + files raise an error. + """ + selected = Path(filename) + match = _NUMBERED_RAW.match(selected.name) + if match is None: + raise ValueError("filename must end in a three-digit sequence number and extension") + prefix = match.group("prefix") + suffix = match.group("suffix") + first = selected.with_name(f"{prefix}001{suffix}") + if not first.is_file(): + raise FileNotFoundError(f"First EGI series file not found: {first}") + + paths = [] + headers = [] + signal_blocks = [] + event_blocks = [] + index = 1 + while True: + path = selected.with_name(f"{prefix}{index:03d}{suffix}") + if not path.is_file(): + break + header, signals, events, _categories = readegi(path) + if header["segmented"]: + raise ValueError("pop_readsegegi accepts continuous EGI series; use pop_readegi for epoched files") + if headers: + _check_compatible(headers[0], header, path) + paths.append(path) + headers.append(header) + signal_blocks.append(signals) + event_blocks.append(events) + index += 1 + + later_files = [] + for candidate in selected.parent.iterdir(): + candidate_match = _NUMBERED_RAW.match(candidate.name) + if candidate_match is None: + continue + if candidate_match.group("prefix") != prefix or candidate_match.group("suffix") != suffix: + continue + if int(candidate_match.group("number")) > index: + later_files.append(candidate) + if later_files: + missing = selected.with_name(f"{prefix}{index:03d}{suffix}") + raise ValueError(f"EGI series is missing {missing.name} before {min(later_files).name}") + + header = deepcopy(headers[0]) + trial_data = np.concatenate(signal_blocks, axis=1) + event_data = np.concatenate(event_blocks, axis=1) + header["samples"] = trial_data.shape[1] + eeg = _egi_to_eeg( + header, + trial_data, + event_data, + np.array([], dtype=np.int64), + filename=first, + fileloc=fileloc, + comments=f"Original files: {first} to {paths[-1]}", + ) + command = f"EEG = pop_readsegegi({format_history_value(selected)});" + eeg["history"] = command + return (eeg, command) if return_com else eeg + + +def _check_compatible(reference: dict[str, Any], candidate: dict[str, Any], path: Path) -> None: + mismatches = [field for field in _COMPATIBILITY_FIELDS if candidate[field] != reference[field]] + if mismatches: + fields = ", ".join(mismatches) + raise ValueError(f"Incompatible EGI series header in {path}: {fields}") + + +__all__ = ["pop_readsegegi"] diff --git a/src/eegprep/functions/sigprocfunc/readegi.py b/src/eegprep/functions/sigprocfunc/readegi.py new file mode 100644 index 00000000..855e37c5 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/readegi.py @@ -0,0 +1,137 @@ +"""Read EGI Simple Binary EEG samples and event channels.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Sequence + +import numpy as np + +from eegprep.functions.sigprocfunc.readegihdr import _read_egi_header + + +def readegi( + filename: str | Path, + data_chunks: int | Sequence[int] | np.ndarray | None = None, + forceversion: int | None = None, +) -> tuple[dict[str, Any], np.ndarray, np.ndarray, np.ndarray]: + """Read an EGI Simple Binary file (versions 2 through 7). + + Args: + filename: EGI ``.RAW`` file. + data_chunks: Optional 1-based frame numbers for continuous data or + segment numbers for segmented data. Selected chunks are returned + in recording order, as in EEGLAB. Empty input selects the whole + recording. + forceversion: Optional header-version override from 2 through 7. + + Returns: + ``(header, trial_data, event_data, segment_category_indices)``. + Low-level data arrays are channel-major with selected segments + concatenated along the sample axis, matching EEGLAB's ``readegi``. + """ + path = Path(filename) + with path.open("rb") as stream: + header = _read_egi_header(stream, forceversion) + total_chunks = header["segments"] if header["segmented"] else header["samples"] + selected = _chunk_indices(data_chunks, total_chunks) + if header["segmented"]: + values, categories, start_times = _read_segmented(stream, header, selected) + header["segment_start_times"] = start_times + else: + values = _read_continuous(stream, header, selected) + categories = np.array([], dtype=np.int64) + header["segment_start_times"] = np.array([], dtype=np.int64) + + nchan = int(header["nchan"]) + trial_data = values[:nchan].astype(np.float64, copy=False) + event_data = values[nchan:].astype(np.float64, copy=False) + if header["bits"] != 0 and header["range"] != 0: + trial_data = trial_data * (float(header["range"]) / (2.0 ** int(header["bits"]))) + return header, trial_data, event_data, categories + + +def _read_continuous(stream: Any, header: dict[str, Any], selected: np.ndarray) -> np.ndarray: + frame_values = int(header["nchan"] + header["eventtypes"]) + frame_bytes = frame_values * int(header["sample_width"]) + if selected.size == header["samples"] and np.array_equal(selected, np.arange(header["samples"])): + raw = _read_exact(stream, frame_bytes * selected.size, "continuous EGI samples") + return _decode_samples(raw, header, frame_values, selected.size) + + output = np.empty((frame_values, selected.size), dtype=np.float64) + for output_index, frame_index in enumerate(selected): + stream.seek(int(header["header_bytes"]) + int(frame_index) * frame_bytes) + raw = _read_exact(stream, frame_bytes, f"EGI frame {int(frame_index) + 1}") + output[:, output_index] = np.frombuffer(raw, dtype=header["sample_dtype"]) + return output + + +def _read_segmented( + stream: Any, + header: dict[str, Any], + selected: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + frame_values = int(header["nchan"] + header["eventtypes"]) + samples_per_segment = int(header["segsamps"]) + payload_bytes = frame_values * samples_per_segment * int(header["sample_width"]) + record_bytes = 6 + payload_bytes + blocks = [] + categories = np.empty(selected.size, dtype=np.int64) + start_times = np.empty(selected.size, dtype=np.int64) + for output_index, segment_index in enumerate(selected): + stream.seek(int(header["header_bytes"]) + int(segment_index) * record_bytes) + prefix = _read_exact(stream, 6, f"EGI segment {int(segment_index) + 1} prefix") + categories[output_index] = int.from_bytes(prefix[:2], "big", signed=True) + start_times[output_index] = int.from_bytes(prefix[2:], "big", signed=True) + raw = _read_exact(stream, payload_bytes, f"EGI segment {int(segment_index) + 1} samples") + blocks.append(_decode_samples(raw, header, frame_values, samples_per_segment)) + empty = np.empty((frame_values, 0), dtype=np.float64) + return (np.concatenate(blocks, axis=1) if blocks else empty), categories, start_times + + +def _decode_samples( + raw: bytes, + header: dict[str, Any], + frame_values: int, + samples: int, +) -> np.ndarray: + values = np.frombuffer(raw, dtype=header["sample_dtype"]) + expected = frame_values * samples + if values.size != expected: + raise ValueError(f"Expected {expected} EGI values but decoded {values.size}") + return values.reshape(samples, frame_values).T.astype(np.float64, copy=False) + + +def _chunk_indices(data_chunks: int | Sequence[int] | np.ndarray | None, count: int) -> np.ndarray: + if data_chunks is None: + return np.arange(count, dtype=np.int64) + chunks = np.asarray(data_chunks) + if chunks.size == 0: + return np.arange(count, dtype=np.int64) + if chunks.ndim > 2 or (chunks.ndim == 2 and 1 not in chunks.shape): + raise ValueError("data_chunks must be empty or a vector") + flat = chunks.reshape(-1) + if flat.dtype.kind not in "iuf" or np.issubdtype(flat.dtype, np.bool_): + raise ValueError("data_chunks must contain integer 1-based indices") + try: + numeric = flat.astype(np.float64) + except (TypeError, ValueError) as error: + raise ValueError("data_chunks must contain integer 1-based indices") from error + if not np.all(np.isfinite(numeric)) or not np.all(numeric == np.floor(numeric)): + raise ValueError("data_chunks must contain integer 1-based indices") + indices = numeric.astype(np.int64) - 1 + if np.any(indices < 0) or np.any(indices >= count): + raise ValueError(f"data_chunks must be within the 1-based range 1..{count}") + if np.unique(indices).size != indices.size: + raise ValueError("data_chunks must not contain duplicate indices") + return np.sort(indices) + + +def _read_exact(stream: Any, size: int, field: str) -> bytes: + data = stream.read(size) + if len(data) != size: + raise ValueError(f"Unexpected end of file while reading {field}") + return data + + +__all__ = ["readegi"] diff --git a/src/eegprep/functions/sigprocfunc/readegihdr.py b/src/eegprep/functions/sigprocfunc/readegihdr.py new file mode 100644 index 00000000..bc5cf594 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/readegihdr.py @@ -0,0 +1,131 @@ +"""Read headers from EGI Simple Binary RAW files.""" + +from __future__ import annotations + +from datetime import datetime +from numbers import Integral +from pathlib import Path +import struct +from typing import BinaryIO, Any + +import numpy as np + + +_FIXED_HEADER = struct.Struct(">i6hi5h") +_CONTINUOUS_VERSIONS = {2, 4, 6} +_SEGMENTED_VERSIONS = {3, 5, 7} +_SAMPLE_DTYPES = {2: ">i2", 3: ">i2", 4: ">f4", 5: ">f4", 6: ">f8", 7: ">f8"} + + +def readegihdr(filename: str | Path, forceversion: int | None = None) -> dict[str, Any]: + """Read an EGI Simple Binary header. + + Args: + filename: EGI ``.RAW`` file. + forceversion: Optional version from 2 through 7 to use instead of the + version stored in the file. This is useful for files whose header + reports the wrong member of the same continuous or segmented + format family. + + Returns: + Header fields corresponding to EEGLAB's ``readegihdr`` structure, + plus explicit recording time, sample dtype, and header byte count. + """ + with Path(filename).open("rb") as stream: + return _read_egi_header(stream, forceversion) + + +def _read_egi_header(stream: BinaryIO, forceversion: int | None = None) -> dict[str, Any]: + values = _FIXED_HEADER.unpack(_read_exact(stream, _FIXED_HEADER.size, "fixed EGI header")) + ( + file_version, + year, + month, + day, + hour, + minute, + second, + millisecond, + samp_rate, + nchan, + gain, + bits, + signal_range, + ) = values + version = file_version if forceversion is None else _validated_version(forceversion) + if version not in _SAMPLE_DTYPES: + raise ValueError("EGI Simple Binary versions 2 through 7 are supported") + if samp_rate <= 0 or nchan <= 0: + raise ValueError("EGI header must contain a positive sample rate and channel count") + + samples = 0 + segments = 0 + segsamps = 0 + catname: list[str] = [] + if version in _CONTINUOUS_VERSIONS: + samples = _read_scalar(stream, ">i", "sample count") + if samples < 0: + raise ValueError("EGI sample count must not be negative") + elif version in _SEGMENTED_VERSIONS: + categories = _read_scalar(stream, ">h", "category count") + if categories < 0: + raise ValueError("EGI category count must not be negative") + for _ in range(categories): + name_length = _read_scalar(stream, ">B", "category-name length") + catname.append(_decode_text(_read_exact(stream, name_length, "category name"))) + segments = _read_scalar(stream, ">h", "segment count") + segsamps = _read_scalar(stream, ">i", "samples per segment") + if segments < 0 or segsamps < 0: + raise ValueError("EGI segment counts must not be negative") + + eventtypes = _read_scalar(stream, ">h", "event-type count") + if eventtypes < 0: + raise ValueError("EGI event-type count must not be negative") + eventcode = [_decode_text(_read_exact(stream, 4, "event code")).rstrip() for _ in range(eventtypes)] + + return { + "version": version, + "file_version": file_version, + "samp_rate": samp_rate, + "nchan": nchan, + "gain": gain, + "bits": bits, + "range": signal_range, + "samples": samples, + "segments": segments, + "segsamps": segsamps, + "eventtypes": eventtypes, + "categories": len(catname), + "catname": catname, + "eventcode": eventcode, + "recording_time": datetime(year, month, day, hour, minute, second, millisecond * 1000), + "segmented": version in _SEGMENTED_VERSIONS, + "sample_dtype": _SAMPLE_DTYPES[version], + "sample_width": np.dtype(_SAMPLE_DTYPES[version]).itemsize, + "header_bytes": stream.tell(), + } + + +def _validated_version(version: int) -> int: + if isinstance(version, bool) or not isinstance(version, Integral) or version not in _SAMPLE_DTYPES: + raise ValueError("forceversion must be an integer from 2 through 7") + return int(version) + + +def _read_scalar(stream: BinaryIO, format_string: str, field: str) -> int: + parser = struct.Struct(format_string) + return int(parser.unpack(_read_exact(stream, parser.size, field))[0]) + + +def _read_exact(stream: BinaryIO, size: int, field: str) -> bytes: + data = stream.read(size) + if len(data) != size: + raise ValueError(f"Unexpected end of file while reading {field}") + return data + + +def _decode_text(data: bytes) -> str: + return data.decode("latin-1").rstrip("\x00") + + +__all__ = ["readegihdr"] diff --git a/tests/test_egi_raw_eeglab_tests.py b/tests/test_egi_raw_eeglab_tests.py new file mode 100644 index 00000000..49b46d07 --- /dev/null +++ b/tests/test_egi_raw_eeglab_tests.py @@ -0,0 +1,397 @@ +"""Behavioral ports of the current EEGLAB EGI Simple Binary tests.""" + +from __future__ import annotations + +from pathlib import Path +import struct + +import numpy as np +import pytest + +from eegprep import pop_readegi, pop_readsegegi, readegi, readegihdr +from tests.eeglab_tests import eeglab_test + + +BINARY_SUITE = "unittesting_binary" +_SAMPLE_DTYPES = {2: ">i2", 3: ">i2", 4: ">f4", 5: ">f4", 6: ">f8", 7: ">f8"} + + +def _segmented_values() -> tuple[np.ndarray, np.ndarray]: + signals = np.array( + [ + [[1, 11, 21], [2, 12, 22], [3, 13, 23], [4, 14, 24]], + [[31, 41, 51], [32, 42, 52], [33, 43, 53], [34, 44, 54]], + ], + dtype=float, + ) + events = np.array([[[0, 0, 0], [0, 0, 0], [5, 6, 7], [0, 0, 0]]], dtype=float) + return signals, events + + +def _write_egi_raw( + path: Path, + version: int, + signals: np.ndarray, + *, + events: np.ndarray | None = None, + event_codes: tuple[str, ...] = (), + category_names: tuple[str, ...] = ("Eyes open", "Eyes closed"), + category_indices: tuple[int, ...] | None = None, + segment_start_times: tuple[int, ...] | None = None, + sample_rate: int = 250, + gain: int = 1, + bits: int = 12, + signal_range: int = 4096, + header_version: int | None = None, +) -> None: + segmented = version in {3, 5, 7} + data = np.asarray(signals) + expected_ndim = 3 if segmented else 2 + if data.ndim != expected_ndim: + raise ValueError(f"version {version} data must have {expected_ndim} dimensions") + nchan = data.shape[0] + sample_shape = data.shape[1:] + if events is None: + event_data = np.empty((0, *sample_shape)) + else: + event_data = np.asarray(events) + if event_data.shape != (len(event_codes), *sample_shape): + raise ValueError("event data shape does not match event codes and signal samples") + + encoded_version = header_version or version + output = bytearray( + struct.pack( + ">i6hi5h", + encoded_version, + 2025, + 2, + 3, + 4, + 5, + 6, + 7, + sample_rate, + nchan, + gain, + bits, + signal_range, + ) + ) + if segmented: + segments = data.shape[2] + categories = category_indices or tuple(1 for _ in range(segments)) + start_times = segment_start_times or tuple(index * 100 for index in range(segments)) + output.extend(struct.pack(">h", len(category_names))) + for name in category_names: + encoded = name.encode("latin-1") + output.extend(struct.pack(">B", len(encoded))) + output.extend(encoded) + output.extend(struct.pack(">hi", segments, data.shape[1])) + else: + output.extend(struct.pack(">i", data.shape[1])) + categories = () + start_times = () + output.extend(struct.pack(">h", len(event_codes))) + for code in event_codes: + output.extend(code.encode("latin-1")[:4].ljust(4, b" ")) + + dtype = _SAMPLE_DTYPES[version] + if segmented: + for segment, (category, start_time) in enumerate(zip(categories, start_times)): + output.extend(struct.pack(">hi", category, start_time)) + block = np.vstack((data[:, :, segment], event_data[:, :, segment])) + output.extend(block.T.astype(dtype).tobytes()) + else: + block = np.vstack((data, event_data)) + output.extend(block.T.astype(dtype).tobytes()) + path.write_bytes(output) + + +@eeglab_test(f"{BINARY_SUITE}/readegi/binary_readegi_wrapperTest.m", "test_test_readegi") +def test_readegi_decodes_selected_segments_samples_events_and_categories(tmp_path: Path) -> None: + signals, events = _segmented_values() + filename = tmp_path / "TESTEGI.RAW" + _write_egi_raw( + filename, + 3, + signals, + events=events, + event_codes=("stim",), + category_indices=(1, 2, 2), + segment_start_times=(100, 200, 300), + ) + + header, trial_data, event_data, categories = readegi(filename, [3, 1]) + + assert header["version"] == 3 + assert header["segmented"] is True + assert header["samp_rate"] == 250 + assert header["nchan"] == 2 + assert header["segments"] == 3 + assert header["segsamps"] == 4 + assert header["eventcode"] == ["stim"] + assert header["catname"] == ["Eyes open", "Eyes closed"] + assert header["segment_start_times"].tolist() == [100, 300] + np.testing.assert_array_equal(trial_data, np.concatenate((signals[:, :, 0], signals[:, :, 2]), axis=1)) + np.testing.assert_array_equal(event_data, np.concatenate((events[:, :, 0], events[:, :, 2]), axis=1)) + np.testing.assert_array_equal(categories, [1, 2]) + + +@eeglab_test(f"{BINARY_SUITE}/pop_readegi/binary_pop_readegi_wrapperTest.m", "test_test_pop_readegi") +def test_pop_readegi_builds_an_epoched_dataset_with_events_and_categories(tmp_path: Path) -> None: + signals, events = _segmented_values() + filename = tmp_path / "TESTEGI.RAW" + _write_egi_raw( + filename, + 3, + signals, + events=events, + event_codes=("stim",), + category_indices=(1, 2, 2), + ) + + eeg, command = pop_readegi(filename, [1, 3], fileloc="", return_com=True) + + assert eeg["data"].shape == (2, 4, 2) + np.testing.assert_array_equal(eeg["data"], signals[:, :, [0, 2]]) + assert eeg["nbchan"] == 2 + assert eeg["pnts"] == 4 + assert eeg["trials"] == 2 + assert eeg["srate"] == 250 + assert eeg["xmin"] == 0 + assert eeg["xmax"] == pytest.approx(3 / 250) + np.testing.assert_allclose(eeg["times"], [0, 4, 8, 12]) + assert [event["type"] for event in eeg["event"]] == ["stim", "stim"] + assert [event["latency"] for event in eeg["event"]] == [3, 7] + assert [event["epoch"] for event in eeg["event"]] == [1, 2] + assert [event["category"] for event in eeg["event"]] == ["Eyes open", "Eyes closed"] + assert [event["urevent"] for event in eeg["event"]] == [0, 1] + assert eeg["history"] == command + assert command == f"EEG = pop_readegi('{filename.as_posix()}', [1 3], [], '');" + + +@eeglab_test( + f"{BINARY_SUITE}/pop_readsegegi/binary_pop_readsegegi_wrapperTest.m", + "test_test_pop_readsegegi", +) +def test_pop_readsegegi_joins_the_numbered_continuous_series_with_valid_metadata(tmp_path: Path) -> None: + first_signals = np.array([[1, 2, 3, 4, 5], [11, 12, 13, 14, 15]], dtype=float) + second_signals = np.array([[6, 7, 8, 9], [16, 17, 18, 19]], dtype=float) + first_events = np.array([[0, 0, 1, 0, 0]], dtype=float) + second_events = np.array([[0, 1, 0, 0]], dtype=float) + first = tmp_path / "TEST_001.RAW" + second = tmp_path / "TEST_002.RAW" + _write_egi_raw(first, 2, first_signals, events=first_events, event_codes=("stim",)) + _write_egi_raw(second, 2, second_signals, events=second_events, event_codes=("stim",)) + + eeg, command = pop_readsegegi(second, fileloc="", return_com=True) + + np.testing.assert_array_equal(eeg["data"], np.concatenate((first_signals, second_signals), axis=1)) + assert eeg["data"].shape == (2, 9) + assert eeg["nbchan"] == 2 + assert eeg["pnts"] == 9 + assert eeg["trials"] == 1 + assert eeg["xmax"] == pytest.approx(8 / 250) + assert [event["type"] for event in eeg["event"]] == ["stim", "stim"] + assert [event["latency"] for event in eeg["event"]] == [3, 7] + assert "TEST_001.RAW" in eeg["comments"] + assert "TEST_002.RAW" in eeg["comments"] + assert command == f"EEG = pop_readsegegi('{second.as_posix()}');" + assert eeg["history"] == command + + +@pytest.mark.parametrize("version", [2, 4, 6]) +def test_readegi_supports_every_continuous_sample_encoding(tmp_path: Path, version: int) -> None: + values = np.array([[1, -2, 3], [4, 5, -6]], dtype=float) + if version >= 4: + values += 0.25 + filename = tmp_path / f"continuous-v{version}.raw" + _write_egi_raw(filename, version, values, bits=0, signal_range=0) + + header, actual, event_data, categories = readegi(filename) + + assert header["version"] == version + np.testing.assert_allclose(actual, values, rtol=1e-6) + assert event_data.shape == (0, 3) + assert categories.size == 0 + + +@pytest.mark.parametrize("version", [3, 5, 7]) +def test_readegi_supports_every_segmented_sample_encoding(tmp_path: Path, version: int) -> None: + values = np.array([[[1, 5], [2, 6], [3, 7], [4, 8]]], dtype=float) + if version >= 5: + values += 0.125 + filename = tmp_path / f"segmented-v{version}.raw" + _write_egi_raw(filename, version, values, category_indices=(1, 2), bits=0, signal_range=0) + + header, actual, event_data, categories = readegi(filename) + + assert header["version"] == version + np.testing.assert_allclose(actual, np.concatenate((values[:, :, 0], values[:, :, 1]), axis=1), rtol=1e-6) + assert event_data.shape == (0, 8) + np.testing.assert_array_equal(categories, [1, 2]) + + +def test_readegi_applies_ad_scaling_only_to_eeg_channels(tmp_path: Path) -> None: + signals = np.array([[2048, -2048]], dtype=float) + events = np.array([[0, 9]], dtype=float) + filename = tmp_path / "scaled.raw" + _write_egi_raw( + filename, + 2, + signals, + events=events, + event_codes=("stim",), + bits=12, + signal_range=1000, + ) + + _header, actual, event_data, _categories = readegi(filename) + + np.testing.assert_array_equal(actual, [[500, -500]]) + np.testing.assert_array_equal(event_data, events) + + +def test_readegi_forceversion_recovers_a_mislabeled_sample_encoding(tmp_path: Path) -> None: + signals = np.array([[1.25, -2.5], [3.75, 4.5]]) + filename = tmp_path / "mislabeled.raw" + _write_egi_raw(filename, 4, signals, header_version=2, bits=0, signal_range=0) + + header, actual, _events, _categories = readegi(filename, forceversion=4) + + assert header["file_version"] == 2 + assert header["version"] == 4 + np.testing.assert_array_equal(actual, signals) + + +def test_readegihdr_reads_metadata_without_samples(tmp_path: Path) -> None: + signals = np.ones((2, 3, 2)) + filename = tmp_path / "header.raw" + _write_egi_raw( + filename, + 5, + signals, + event_codes=(), + category_names=("A", "B"), + category_indices=(1, 2), + bits=0, + signal_range=0, + ) + + header = readegihdr(filename) + + assert header["recording_time"].isoformat() == "2025-02-03T04:05:06.007000" + assert header["sample_dtype"] == ">f4" + assert header["sample_width"] == 4 + assert header["header_bytes"] < filename.stat().st_size + + +@pytest.mark.parametrize("chunks", [[0], [4], [1.5], [True], ["1"], [1, 1], [[1, 2], [3, 1]]]) +def test_readegi_rejects_invalid_chunk_vectors(tmp_path: Path, chunks: object) -> None: + filename = tmp_path / "chunks.raw" + _write_egi_raw(filename, 2, np.ones((2, 3))) + + with pytest.raises(ValueError, match="data_chunks"): + readegi(filename, chunks) # type: ignore[arg-type] + + +def test_readegi_accepts_a_scalar_chunk_and_numpy_integer_forceversion(tmp_path: Path) -> None: + filename = tmp_path / "scalar.raw" + signals = np.array([[1, 2, 3], [4, 5, 6]], dtype=float) + _write_egi_raw(filename, 2, signals) + + header, data, _events, _categories = readegi(filename, 2, np.int64(2)) + + assert header["version"] == 2 + np.testing.assert_array_equal(data, signals[:, 1:2]) + + +def test_readegi_reports_truncated_sample_data(tmp_path: Path) -> None: + filename = tmp_path / "truncated.raw" + _write_egi_raw(filename, 2, np.ones((2, 3))) + filename.write_bytes(filename.read_bytes()[:-1]) + + with pytest.raises(ValueError, match="Unexpected end of file"): + readegi(filename) + + +def test_pop_readegi_creates_time_locking_events_when_segment_event_channels_are_absent(tmp_path: Path) -> None: + signals = np.arange(1, 13, dtype=float).reshape(2, 3, 2) + filename = tmp_path / "categories.raw" + _write_egi_raw(filename, 3, signals, category_indices=(2, 1)) + + eeg = pop_readegi(filename, fileloc="") + + assert [event["type"] for event in eeg["event"]] == ["TLE", "TLE"] + assert [event["latency"] for event in eeg["event"]] == [1, 4] + assert [event["category"] for event in eeg["event"]] == ["Eyes closed", "Eyes open"] + + +def test_pop_readegi_places_leading_edges_on_the_first_nonzero_sample(tmp_path: Path) -> None: + filename = tmp_path / "leading-edge.raw" + events = np.array([[4, 0, 0, 7, 0]], dtype=float) + _write_egi_raw(filename, 2, np.ones((2, 5)), events=events, event_codes=("stim",)) + + eeg = pop_readegi(filename, fileloc="") + + assert [event["latency"] for event in eeg["event"]] == [1, 4] + + +def test_pop_readegi_removes_an_empty_trailing_reference_channel(tmp_path: Path) -> None: + filename = tmp_path / "reference.raw" + _write_egi_raw(filename, 2, np.array([[1, 2, 3], [0, 0, 0]], dtype=float)) + + eeg = pop_readegi(filename, fileloc="") + + np.testing.assert_array_equal(eeg["data"], [[1, 2, 3]]) + assert eeg["nbchan"] == 1 + assert [location["labels"] for location in eeg["chanlocs"]] == ["E1"] + + +def test_pop_readegi_keeps_a_trailing_channel_that_becomes_nonzero_late(tmp_path: Path) -> None: + filename = tmp_path / "late-signal.raw" + signals = np.vstack((np.arange(12), np.r_[np.zeros(10), 1, 2])) + _write_egi_raw(filename, 2, signals) + + eeg = pop_readegi(filename, fileloc="") + + np.testing.assert_array_equal(eeg["data"], signals) + assert eeg["nbchan"] == 2 + + +def test_pop_readsegegi_rejects_incompatible_series_headers(tmp_path: Path) -> None: + first = tmp_path / "run_001.RAW" + second = tmp_path / "run_002.RAW" + _write_egi_raw(first, 2, np.ones((2, 3)), sample_rate=250) + _write_egi_raw(second, 2, np.ones((2, 3)), sample_rate=500) + + with pytest.raises(ValueError, match="samp_rate"): + pop_readsegegi(first, fileloc="") + + +def test_pop_readsegegi_rejects_a_gap_before_a_later_series_file(tmp_path: Path) -> None: + first = tmp_path / "run_001.RAW" + third = tmp_path / "run_003.RAW" + _write_egi_raw(first, 2, np.ones((2, 3))) + _write_egi_raw(third, 2, np.ones((2, 3))) + + with pytest.raises(ValueError, match="missing run_002.RAW before run_003.RAW"): + pop_readsegegi(first, fileloc="") + + +def test_pop_readsegegi_does_not_hide_a_corrupt_next_file(tmp_path: Path) -> None: + first = tmp_path / "run_001.RAW" + second = tmp_path / "run_002.RAW" + _write_egi_raw(first, 2, np.ones((2, 3))) + second.write_bytes(b"not an EGI file") + + with pytest.raises(ValueError, match="Unexpected end of file"): + pop_readsegegi(first, fileloc="") + + +def test_pop_readsegegi_requires_a_numbered_filename_and_first_file(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="three-digit"): + pop_readsegegi(tmp_path / "recording.RAW") + _write_egi_raw(tmp_path / "run_002.RAW", 2, np.ones((2, 3))) + with pytest.raises(FileNotFoundError, match="run_001.RAW"): + pop_readsegegi(tmp_path / "run_002.RAW") From 37b5ac9aa56a421c65242d84ae463fd8a089f693 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:54 -0700 Subject: [PATCH 31/45] Port segmented EGI MATLAB import tests (#359) --- docs/source/api/interactive_pop_workflows.rst | 6 + docs/source/changelog.rst | 7 + src/eegprep/__init__.py | 1 + .../functions/popfunc/pop_importegimat.py | 232 ++++++++++++++++++ .../resources/help/pop_importegimat.md | 27 ++ tests/test_egi_mat_eeglab_tests.py | 217 ++++++++++++++++ 6 files changed, 490 insertions(+) create mode 100644 src/eegprep/functions/popfunc/pop_importegimat.py create mode 100644 src/eegprep/resources/help/pop_importegimat.md create mode 100644 tests/test_egi_mat_eeglab_tests.py diff --git a/docs/source/api/interactive_pop_workflows.rst b/docs/source/api/interactive_pop_workflows.rst index 61abd3f9..193624d7 100644 --- a/docs/source/api/interactive_pop_workflows.rst +++ b/docs/source/api/interactive_pop_workflows.rst @@ -22,6 +22,11 @@ the recording header. RAW files, including event channels and segment categories. ``pop_readsegegi`` joins a numbered continuous series ending in ``001.RAW``, ``002.RAW``, and so on, and validates that acquisition headers agree before concatenating samples. +``pop_importegimat`` reads EGI Net Station MATLAB exports. Segment variables +named ``_Segment`` become trials with one condition event +per trial; continuous exports are read from the ``Session`` variable by +default. An embedded ``samplingRate`` takes precedence over the supplied rate, +and ``latpoint0`` is expressed in milliseconds. .. autosummary:: :toctree: generated/ @@ -34,6 +39,7 @@ on, and validates that acquisition headers agree before concatenating samples. eegprep.pop_exportbids eegprep.importevent eegprep.pop_importdata + eegprep.pop_importegimat eegprep.pop_importepoch eegprep.pop_importevent eegprep.pop_load_frombids diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index a5790069..948beb7d 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,13 @@ the `GitHub Releases `_ page. Unreleased ========== +- Added standalone ``pop_importegimat`` support for continuous and segmented + EGI Net Station MATLAB exports, including embedded sampling rates, trial + events and timing, empty-reference removal, packaged EGI montages, and + replayable command history. Unlike EEGLAB, segment numbering may contain + gaps or exceed the number of file variables, and a 1 ms pre-stimulus offset + is preserved correctly. A reference channel is removed only when all of its + trials and samples are empty. - EGI Simple Binary RAW versions 2 through 7 can now be read directly through ``readegihdr``, ``readegi``, ``pop_readegi``, and ``pop_readsegegi``. The readers support 1-based frame or segment selection, A/D scaling, event diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 98737538..dd3d4940 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -330,6 +330,7 @@ "pop_importbids": ("eegprep.plugins.EEG_BIDS.pop_importbids", "pop_importbids"), "pop_importgroupvar": ("eegprep.functions.studyfunc.pop_importgroupvar", "pop_importgroupvar"), "pop_importdata": ("eegprep.functions.popfunc.pop_importdata", "pop_importdata"), + "pop_importegimat": ("eegprep.functions.popfunc.pop_importegimat", "pop_importegimat"), "pop_importepoch": ("eegprep.functions.popfunc.pop_importepoch", "pop_importepoch"), "pop_importerplab": ("eegprep.functions.popfunc.pop_importerplab", "pop_importerplab"), "pop_importevent": ("eegprep.functions.popfunc.pop_importevent", "pop_importevent"), diff --git a/src/eegprep/functions/popfunc/pop_importegimat.py b/src/eegprep/functions/popfunc/pop_importegimat.py new file mode 100644 index 00000000..4946166d --- /dev/null +++ b/src/eegprep/functions/popfunc/pop_importegimat.py @@ -0,0 +1,232 @@ +"""Import EGI Net Station MATLAB exports into an EEGPrep dataset.""" + +from __future__ import annotations + +import math +from pathlib import Path +import re +from typing import Any + +import numpy as np +import scipy.io + +from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset, strict_mode +from eegprep.functions.popfunc._file_io import eeg_from_data +from eegprep.functions.popfunc._pop_utils import format_history_value +from eegprep.functions.sigprocfunc.readegilocs import readegilocs + + +_SEGMENT_FIELD = re.compile(r"^(?P.+)_Segment(?P\d+)$") + + +def pop_importegimat( + filename: str | Path, + srate: float | None = None, + latpoint0: float = 0.0, + data_field: str = "Session", + *, + fileloc: str | Path | None = "auto", + return_com: bool = False, +) -> dict[str, Any] | tuple[dict[str, Any], str]: + """Import a segmented or continuous EGI Net Station MATLAB file. + + Net Station segmented exports store one channel-by-sample matrix per + ``_Segment`` variable. They become trials ordered first + by condition name and then by segment number, with one event per trial. + Continuous exports are read from ``data_field`` (``"Session"`` by + default). An embedded scalar ``samplingRate`` takes precedence over + ``srate``. + + Args: + filename: MATLAB file exported by EGI Net Station. + srate: Sampling rate in Hz when the file does not contain + ``samplingRate``. + latpoint0: Milliseconds from the start of each segment to time zero. + data_field: Continuous-data variable name or MATLAB-compatible prefix. + fileloc: EGI montage filename. ``"auto"`` selects the packaged montage + from the imported channel count; an empty string skips locations. + return_com: Return ``(EEG, command)`` when true. + + Returns: + An EEG dictionary, optionally paired with its replayable history + command. + """ + path = Path(filename) + variables = _load_variables(path) + effective_srate = _sampling_rate(variables.get("samplingRate"), fallback=srate) + latency_ms = _finite_scalar(latpoint0, "latpoint0") + field_name = str(data_field) + if not field_name: + raise ValueError("data_field must be a non-empty MATLAB variable name") + + segment_fields = _find_segment_fields(variables) + if segment_fields: + data, events = _segmented_data(variables, segment_fields, effective_srate, latency_ms) + if _has_empty_reference(data): + data = data[:-1] + eeg = eeg_from_data( + data, + srate=effective_srate, + setname=str(path.with_suffix("")), + nbchan=data.shape[0], + xmin=-latency_ms / 1000.0, + filename=path.name, + filepath=str(path.parent), + ) + eeg["event"], eeg["urevent"] = _events_with_urevents(events) + with strict_mode(False): + eeg = eeg_checkset(eeg, "eventconsistency") + else: + data = _continuous_data(variables, field_name) + eeg = eeg_from_data( + data, + srate=effective_srate, + nbchan=data.shape[0], + filename=path.name, + filepath=str(path.parent), + ) + + eeg = _apply_egi_locations(eeg, fileloc) + command = _history_command(path, effective_srate, latency_ms, field_name, fileloc) + eeg["history"] = command + return (eeg, command) if return_com else eeg + + +def _load_variables(path: Path) -> dict[str, Any]: + if not path.is_file(): + raise FileNotFoundError(f"EGI MATLAB file not found: {path}") + loaded = scipy.io.loadmat(path, squeeze_me=False, struct_as_record=False) + return {name: value for name, value in loaded.items() if not name.startswith("__")} + + +def _sampling_rate(embedded: Any, *, fallback: float | None) -> float: + value = fallback if embedded is None else embedded + if value is None: + raise ValueError("srate is required when the MATLAB file has no samplingRate variable") + rate = _finite_scalar(value, "srate") + if rate <= 0: + raise ValueError("srate must be positive") + return rate + + +def _finite_scalar(value: Any, name: str) -> float: + values = np.asarray(value).ravel() + if values.size != 1: + raise ValueError(f"{name} must be a scalar") + try: + scalar = float(values[0]) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be numeric") from error + if not math.isfinite(scalar): + raise ValueError(f"{name} must be finite") + return scalar + + +def _find_segment_fields(variables: dict[str, Any]) -> list[tuple[str, int, str]]: + segments = [] + for name in variables: + match = _SEGMENT_FIELD.fullmatch(name) + if match: + segments.append((match.group("type"), int(match.group("number")), name)) + return sorted(segments, key=lambda item: (item[0], item[1])) + + +def _segmented_data( + variables: dict[str, Any], + segment_fields: list[tuple[str, int, str]], + srate: float, + latency_ms: float, +) -> tuple[np.ndarray, list[dict[str, Any]]]: + trials = [] + events = [] + expected_shape: tuple[int, int] | None = None + for trial_index, (event_type, _segment_number, field_name) in enumerate(segment_fields): + trial = _numeric_matrix(variables[field_name], field_name) + if expected_shape is None: + expected_shape = trial.shape + elif trial.shape != expected_shape: + raise ValueError( + f"All EGI segment variables must have the same shape; " + f"{field_name} has {trial.shape}, expected {expected_shape}" + ) + trials.append(trial) + pnts = trial.shape[1] + events.append( + { + "type": event_type, + "latency": latency_ms / 1000.0 * srate + 1.0 + trial_index * pnts, + "epoch": trial_index + 1, + } + ) + + stacked = np.stack(trials, axis=2) + output_dtype = np.complex64 if np.iscomplexobj(stacked) else np.float32 + return stacked.astype(output_dtype), events + + +def _continuous_data(variables: dict[str, Any], data_field: str) -> np.ndarray: + if data_field in variables: + field_name = data_field + else: + matches = [name for name in variables if name.startswith(data_field)] + if not matches: + raise ValueError(f"MATLAB data field not found: {data_field}") + field_name = matches[0] + return _numeric_matrix(variables[field_name], field_name) + + +def _numeric_matrix(value: Any, field_name: str) -> np.ndarray: + matrix = np.asarray(value) + if matrix.ndim != 2: + raise ValueError(f"EGI MATLAB variable {field_name!r} must be a 2-D channel-by-sample matrix") + if matrix.shape[0] == 0 or matrix.shape[1] == 0: + raise ValueError(f"EGI MATLAB variable {field_name!r} must not be empty") + if not np.issubdtype(matrix.dtype, np.number): + raise ValueError(f"EGI MATLAB variable {field_name!r} must contain numeric data") + return matrix + + +def _has_empty_reference(data: np.ndarray) -> bool: + return bool(np.all(data[-1] == 0)) + + +def _events_with_urevents( + events: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + normalized = [] + urevents = [] + for index, event in enumerate(events): + urevent = dict(event) + event_with_pointer = dict(event) + event_with_pointer["urevent"] = index + normalized.append(event_with_pointer) + urevents.append(urevent) + return normalized, urevents + + +def _apply_egi_locations(eeg: dict[str, Any], fileloc: str | Path | None) -> dict[str, Any]: + if fileloc == "": + return eeg + selected = None if fileloc in {None, "auto"} else str(fileloc) + return readegilocs(eeg, selected) + + +def _history_command( + path: Path, + srate: float, + latpoint0: float, + data_field: str, + fileloc: str | Path | None, +) -> str: + arguments = [ + format_history_value(path), + format_history_value(srate), + format_history_value(latpoint0), + format_history_value(data_field), + ] + if fileloc not in {None, "auto"}: + arguments.append(f"fileloc={format_history_value(fileloc)}") + return f"EEG = pop_importegimat({', '.join(arguments)});" + + +__all__ = ["pop_importegimat"] diff --git a/src/eegprep/resources/help/pop_importegimat.md b/src/eegprep/resources/help/pop_importegimat.md new file mode 100644 index 00000000..9fb4d661 --- /dev/null +++ b/src/eegprep/resources/help/pop_importegimat.md @@ -0,0 +1,27 @@ +# POP_IMPORTEGIMAT - Import an EGI Net Station MATLAB export + +`pop_importegimat` imports continuous and segmented MATLAB files exported by +EGI Net Station. + +Usage: + +```python +EEG = pop_importegimat("segmented.mat") +EEG = pop_importegimat("segmented.mat", srate=250, latpoint0=100) +EEG = pop_importegimat("continuous.mat", srate=500, data_field="Session") +EEG, com = pop_importegimat("segmented.mat", return_com=True) +``` + +Segment variables must be named `_Segment` and contain +channel-by-sample numeric matrices of identical shape. They are ordered by +condition name and numeric segment number. `latpoint0` gives the time-zero +offset from the start of each segment in milliseconds. A scalar +`samplingRate` variable in the file overrides the `srate` argument; otherwise +`srate` is required. + +For continuous exports, `data_field` is `Session` by default and may also be a +variable-name prefix. EGI channel locations are selected from the packaged +montages when the channel count is recognized. Pass `fileloc=""` to leave the +default numbered channel labels in place. + +See also: POP_IMPORTDATA, POP_FILEIO, READEGILOCS diff --git a/tests/test_egi_mat_eeglab_tests.py b/tests/test_egi_mat_eeglab_tests.py new file mode 100644 index 00000000..6158d67f --- /dev/null +++ b/tests/test_egi_mat_eeglab_tests.py @@ -0,0 +1,217 @@ +"""Ports of the current EEGLAB segmented EGI MATLAB import test.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from scipy.io import savemat + +from eegprep import pop_importegimat +from tests.eeglab_tests import eeglab_test + + +UPSTREAM_WRAPPER = "unittesting_binary/pop_importegimat/binary_pop_importegimat_wrapperTest.m" + + +def _segmented_fixture(path: Path, *, trials: int = 3, pnts: int = 12) -> list[np.ndarray]: + segments = [] + variables: dict[str, object] = {"samplingRate": np.array([[250.0]])} + for trial in range(trials): + segment = np.arange(129 * pnts, dtype=np.float64).reshape(129, pnts) + trial * 10_000 + segment[-1] = 0 + segments.append(segment) + variables[f"CM1CP1POS_Segment{trial + 1}"] = segment + variables["ECI"] = np.array([["ignored by EEGLAB"]], dtype=object) + savemat(path, variables) + return segments + + +@eeglab_test(UPSTREAM_WRAPPER, "test_test_pop_importegimat") +def test_pop_importegimat_loads_segmented_netstation_export(tmp_path: Path) -> None: + """Turn the upstream assertion-free smoke call into an observable contract.""" + path = tmp_path / "segmented_matlab.mat" + segments = _segmented_fixture(path) + + eeg, command = pop_importegimat(path, return_com=True) + + expected = np.stack([segment[:-1] for segment in segments], axis=2).astype(np.float32) + np.testing.assert_array_equal(eeg["data"], expected) + assert eeg["data"].dtype == np.float32 + assert (eeg["nbchan"], eeg["pnts"], eeg["trials"]) == (128, 12, 3) + assert eeg["srate"] == 250 + assert eeg["xmin"] == 0 + assert eeg["xmax"] == pytest.approx(11 / 250) + np.testing.assert_allclose(eeg["times"], np.arange(12) / 250 * 1000) + + events = list(eeg["event"]) + assert [event["type"] for event in events] == ["CM1CP1POS"] * 3 + assert [event["latency"] for event in events] == [1, 13, 25] + assert [event["epoch"] for event in events] == [1, 2, 3] + assert [event["urevent"] for event in events] == [0, 1, 2] + assert [urevent["latency"] for urevent in eeg["urevent"]] == [1, 13, 25] + assert [epoch["event"] for epoch in eeg["epoch"]] == [[0], [1], [2]] + + assert len(eeg["chanlocs"]) == 128 + assert eeg["chanlocs"][0]["labels"] == "E1" + assert len(eeg["chaninfo"]["nodatchans"]) == 4 + assert eeg["filename"] == path.name + assert eeg["filepath"] == str(path.parent) + assert eeg["setname"] == str(path.with_suffix("")) + assert command == f"EEG = pop_importegimat('{path.as_posix()}', 250, 0, 'Session');" + assert eeg["history"] == command + + +def test_pop_importegimat_applies_latency_offset_in_milliseconds(tmp_path: Path) -> None: + path = tmp_path / "offset.mat" + _segmented_fixture(path, trials=2, pnts=40) + + eeg = pop_importegimat(path, latpoint0=100, fileloc="") + + assert eeg["xmin"] == pytest.approx(-0.1) + assert eeg["xmax"] == pytest.approx(-0.1 + 39 / 250) + assert eeg["times"][0] == pytest.approx(-100) + assert [event["latency"] for event in eeg["event"]] == [26, 66] + assert len(eeg["chanlocs"]) == 128 + assert eeg["chaninfo"] == {} + + +def test_pop_importegimat_handles_one_millisecond_offset(tmp_path: Path) -> None: + """Guard the exact value affected by EEGLAB's ``latpoint0 ~= 1`` typo.""" + path = tmp_path / "one_ms.mat" + _segmented_fixture(path, trials=2) + + eeg = pop_importegimat(path, latpoint0=1, fileloc="") + + assert eeg["xmin"] == pytest.approx(-0.001) + assert eeg["event"][0]["latency"] == pytest.approx(1.25) + + +def test_pop_importegimat_uses_embedded_sampling_rate(tmp_path: Path) -> None: + path = tmp_path / "embedded_rate.mat" + savemat( + path, + { + "samplingRate": np.array([[500.0]]), + "A_Segment1": np.ones((2, 20)), + "A_Segment2": np.full((2, 20), 2.0), + }, + ) + + eeg, command = pop_importegimat(path, srate=128, latpoint0=20, fileloc="", return_com=True) + + assert eeg["srate"] == 500 + assert [event["latency"] for event in eeg["event"]] == [11, 31] + assert ", 500, 20, 'Session'" in command + + +def test_pop_importegimat_loads_continuous_session_data(tmp_path: Path) -> None: + path = tmp_path / "continuous.mat" + data = np.arange(129 * 12, dtype=np.float64).reshape(129, 12) + data[-1] = 0 + savemat(path, {"samplingRate": np.array([[1000.0]]), "Session": data}) + + eeg = pop_importegimat(path, srate=250) + + np.testing.assert_array_equal(eeg["data"], data) + assert eeg["data"].dtype == np.float64 + assert (eeg["nbchan"], eeg["pnts"], eeg["trials"]) == (129, 12, 1) + assert eeg["srate"] == 1000 + assert eeg["setname"] == "" + assert len(eeg["chanlocs"]) == 129 + assert len(eeg["chaninfo"]["nodatchans"]) == 3 + assert len(eeg["event"]) == 0 + + +def test_pop_importegimat_accepts_continuous_field_prefix(tmp_path: Path) -> None: + path = tmp_path / "continuous_prefix.mat" + data = np.arange(8, dtype=np.float64).reshape(2, 4) + savemat(path, {"NetStationSession": data}) + + eeg, command = pop_importegimat( + path, + srate=200, + data_field="NetStation", + fileloc="", + return_com=True, + ) + + np.testing.assert_array_equal(eeg["data"], data) + assert eeg["srate"] == 200 + assert "'NetStation'" in command + + +def test_pop_importegimat_orders_types_and_numeric_segment_numbers(tmp_path: Path) -> None: + path = tmp_path / "ordering.mat" + savemat( + path, + { + "Z_Segment100": np.full((2, 3), 100.0), + "A_Segment10": np.full((2, 3), 10.0), + "A_Segment2": np.full((2, 3), 2.0), + }, + ) + + eeg = pop_importegimat(path, srate=250, fileloc="") + + assert [event["type"] for event in eeg["event"]] == ["A", "A", "Z"] + np.testing.assert_array_equal(eeg["data"][0, 0], np.array([2, 10, 100], dtype=np.float32)) + assert [event["latency"] for event in eeg["event"]] == [1, 4, 7] + + +def test_pop_importegimat_keeps_a_reference_channel_with_late_signal(tmp_path: Path) -> None: + path = tmp_path / "late_reference.mat" + segment = np.vstack((np.arange(12), np.r_[np.zeros(10), 1, 2])) + savemat(path, {"samplingRate": [[250]], "A_Segment1": segment}) + + eeg = pop_importegimat(path, fileloc="") + + np.testing.assert_array_equal(eeg["data"], segment.astype(np.float32)) + assert eeg["nbchan"] == 2 + + +@pytest.mark.parametrize( + ("variables", "kwargs", "message"), + [ + ({"Session": np.ones((2, 3))}, {}, "srate is required"), + ({"samplingRate": [[0]], "Session": np.ones((2, 3))}, {}, "srate must be positive"), + ( + {"samplingRate": [[100], [200]], "Session": np.ones((2, 3))}, + {}, + "srate must be a scalar", + ), + ({"samplingRate": [[250]], "Other": np.ones((2, 3))}, {}, "data field not found"), + ( + {"samplingRate": [[250]], "Session": np.ones((2, 3, 4))}, + {}, + "must be a 2-D", + ), + ( + {"samplingRate": [[250]], "A_Segment1": np.ones((2, 3)), "A_Segment2": np.ones((2, 4))}, + {}, + "same shape", + ), + ( + {"samplingRate": [[250]], "A_Segment1": np.ones((2, 3))}, + {"latpoint0": np.inf}, + "latpoint0 must be finite", + ), + ], +) +def test_pop_importegimat_rejects_invalid_inputs( + tmp_path: Path, + variables: dict[str, object], + kwargs: dict[str, object], + message: str, +) -> None: + path = tmp_path / "invalid.mat" + savemat(path, variables) + + with pytest.raises(ValueError, match=message): + pop_importegimat(path, fileloc="", **kwargs) + + +def test_pop_importegimat_requires_an_existing_file(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError, match="EGI MATLAB file not found"): + pop_importegimat(tmp_path / "missing.mat") From 3384ceb5571109f034fcaf79b13f30b99aee5829 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:54 -0700 Subject: [PATCH 32/45] Add statcond arraycomp compatibility (#360) --- docs/source/api/statistics.rst | 8 ++++ docs/source/changelog.rst | 4 ++ src/eegprep/functions/statistics/statcond.py | 44 ++++++++++++++++---- tests/test_statcond_eeglab_tests.py | 8 +++- tests/test_statistics_package.py | 16 +++++++ 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/docs/source/api/statistics.rst b/docs/source/api/statistics.rst index b06e1859..6b95e44e 100644 --- a/docs/source/api/statistics.rst +++ b/docs/source/api/statistics.rst @@ -12,6 +12,14 @@ surrogate replications. Condition Tests =============== +``statcond(..., return_resampling_array=True)`` returns a +``SurrogateDistribution``. With ``arraycomp="on"`` (the default), it contains +``naccu`` condition grids; every condition keeps its original feature and case +shape. ``arraycomp="off"`` returns one grid and is compatible with EEGLAB's +incremental resampling contract. For ordinary statistical results, the off +mode still computes all ``naccu`` statistics while holding one resampled grid +at a time. + .. autosummary:: :toctree: generated/ diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 948beb7d..5583886b 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,10 @@ the `GitHub Releases `_ page. Unreleased ========== +- ``statcond`` now accepts EEGLAB's ``arraycomp`` switch. Batched mode returns + all requested resampling grids, while off mode exposes one compatibility + grid or computes inference iteratively to bound peak memory without changing + seeded statistics. - Added standalone ``pop_importegimat`` support for continuous and segmented EGI Net Station MATLAB exports, including embedded sampling rates, trial events and timing, empty-reference removal, packaged EGI montages, and diff --git a/src/eegprep/functions/statistics/statcond.py b/src/eegprep/functions/statistics/statcond.py index 0a818926..4575f5c6 100644 --- a/src/eegprep/functions/statistics/statcond.py +++ b/src/eegprep/functions/statistics/statcond.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Iterator, Sequence +from collections.abc import Iterable, Iterator, Sequence from dataclasses import dataclass from typing import Any @@ -15,6 +15,7 @@ effect_map, normalize_method, paired_flag, + rng_from_seed, ) from eegprep.functions.statistics.anova1_cell import anova1_cell from eegprep.functions.statistics.anova1rm_cell import anova1rm_cell @@ -63,6 +64,7 @@ def statcond( surrog: Any = None, stats: Any = None, return_resampling_array: bool = False, + arraycomp: str | bool = True, ) -> StatcondResult | SurrogateDistribution: """Compare condition arrays using EEGLAB-style t-tests or ANOVAs. @@ -84,11 +86,15 @@ def statcond( stats: Observed statistic to pair with ``surrog``. return_resampling_array: Return surrogate condition grids instead of computing statistics. + arraycomp: ``True``/``"on"`` batches all resamples. ``False``/``"off"`` + computes one resample at a time and, with + ``return_resampling_array=True``, returns one grid as in EEGLAB. """ method_name = normalize_method(mode or method) grid = condition_grid(data, axis=axis, min_cases=2) paired_flag_value = paired_flag(grid, paired) + arraycomp_flag_value = _arraycomp_flag(arraycomp) if return_resampling_array: if method_name == "param": raise ValueError("return_resampling_array requires 'perm' or 'bootstrap'") @@ -96,7 +102,7 @@ def statcond( grid, method=method_name, pairing="on" if paired_flag_value else "off", - naccu=naccu, + naccu=naccu if arraycomp_flag_value else 1, rng=rng, ) @@ -134,6 +140,7 @@ def statcond( variance=variance, forceanova=forceanova, rng=rng, + arraycomp=arraycomp_flag_value, ) empirical_tail = "one" if statistic_kind.startswith("f") else tail pvalue = _surrogate_pvalues(surrogate_stat, observed_stat, empirical_tail) @@ -199,14 +206,22 @@ def _compute_surrogate_statistics( variance: str, forceanova: bool, rng: np.random.Generator | int | None, + arraycomp: bool, ) -> Any: - distribution = surrogdistrib( - grid, - method=method, - pairing="on" if paired else "off", - naccu=naccu, - rng=rng, - ) + pairing = "on" if paired else "off" + if arraycomp: + distribution: Iterable[Any] = surrogdistrib( + grid, + method=method, + pairing=pairing, + naccu=naccu, + rng=rng, + ).samples + else: + generator = rng_from_seed(rng) + distribution = ( + surrogdistrib(grid, method=method, pairing=pairing, naccu=1, rng=generator).samples[0] for _ in range(naccu) + ) stats = [] for sample in distribution: sample_stat, _sample_df, _kind = _compute_statistic( @@ -259,4 +274,15 @@ def _ci_tail(tail: str) -> str: return tail_name +def _arraycomp_flag(value: str | bool) -> bool: + if isinstance(value, str): + normalized = value.lower() + if normalized not in {"on", "off"}: + raise ValueError("arraycomp must be 'on', 'off', True, or False") + return normalized == "on" + if isinstance(value, (bool, np.bool_)): + return bool(value) + raise ValueError("arraycomp must be 'on', 'off', True, or False") + + __all__ = ["StatcondResult", "TwoWayEffects", "statcond"] diff --git a/tests/test_statcond_eeglab_tests.py b/tests/test_statcond_eeglab_tests.py index 53ab8164..f6b68927 100644 --- a/tests/test_statcond_eeglab_tests.py +++ b/tests/test_statcond_eeglab_tests.py @@ -382,6 +382,7 @@ def _assert_resampling_case( paired: str, naccu: int, seed: int, + arraycomp: bool, ) -> None: originals = tuple(condition.copy() for condition in conditions) result = statcond( @@ -391,6 +392,7 @@ def _assert_resampling_case( naccu=naccu, rng=seed, return_resampling_array=True, + arraycomp=arraycomp, ) repeated = statcond( conditions, @@ -399,10 +401,11 @@ def _assert_resampling_case( naccu=naccu, rng=seed, return_resampling_array=True, + arraycomp=arraycomp, ) assert isinstance(result, SurrogateDistribution) assert isinstance(repeated, SurrogateDistribution) - assert len(result) == naccu + assert len(result) == (naccu if arraycomp else 1) source_traces = np.stack([_last_feature_trace(condition) for condition in conditions]) pooled_source = source_traces.ravel() @@ -449,8 +452,9 @@ def _assert_resampling_suite() -> None: conditions, method=method, paired=paired, - naccu=10 if vectorized else 1, + naccu=10, seed=seed, + arraycomp=vectorized, ) diff --git a/tests/test_statistics_package.py b/tests/test_statistics_package.py index dace5f47..dac94178 100644 --- a/tests/test_statistics_package.py +++ b/tests/test_statistics_package.py @@ -235,6 +235,22 @@ def test_nonparametric_statcond_and_surrogdistrib_are_seeded(): assert all(sample[0][0].shape == first.shape for sample in surrogates) +def test_statcond_arraycomp_off_streams_the_same_seeded_statistics(): + rng = np.random.default_rng(30) + data = [rng.normal(size=(3, 8)), rng.normal(size=(3, 8))] + + batched = statcond(data, method="perm", paired="on", naccu=12, rng=42, arraycomp="on") + iterative = statcond(data, method="perm", paired="on", naccu=12, rng=42, arraycomp="off") + + npt.assert_array_equal(iterative.surrogate, batched.surrogate) + npt.assert_array_equal(iterative.pvalue, batched.pvalue) + + +def test_statcond_rejects_unknown_arraycomp_mode(): + with pytest.raises(ValueError, match="arraycomp"): + statcond([np.arange(4), np.arange(4)], arraycomp="sometimes") + + def test_statcond_supplied_surrogates_return_alpha_ci_and_mask(): surrogate = np.array([[1.0, 2.0, 3.0, 4.0], [4.0, 5.0, 6.0, 7.0]]) observed = np.array([3.5, 7.5]) From ba69010151f35c6182af7096446405854975e9ed Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:55 -0700 Subject: [PATCH 33/45] test: lock statcond unpaired factor order (#361) --- docs/source/api/statistics.rst | 14 ++++++ .../functions/statistics/anova2_cell.py | 7 ++- src/eegprep/functions/statistics/statcond.py | 14 +++++- tests/test_statcond_eeglab_tests.py | 43 +++++++++++++++++-- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/docs/source/api/statistics.rst b/docs/source/api/statistics.rst index 6b95e44e..c4571157 100644 --- a/docs/source/api/statistics.rst +++ b/docs/source/api/statistics.rst @@ -31,6 +31,20 @@ at a time. eegprep.functions.statistics.anova2_cell eegprep.functions.statistics.anova2rm_cell +Two-way factor order +-------------------- + +``statcond`` represents every two-way result computed from condition arrays as +``TwoWayEffects(rows, columns, interaction)``. Attribute access and iteration +use that order for paired and unpaired designs, including statistics, degrees +of freedom, p-values, and nonparametric outputs. + +This deliberately resolves an inconsistency in EEGLAB's MATLAB implementation. +Although ``statcond`` documents rows, columns, and interaction, its unpaired +branch forwards ``anova2_cell`` outputs in columns, rows, and interaction order. +When translating positional unpaired MATLAB results, EEGPrep's ``rows`` value +therefore corresponds to the second MATLAB cell and ``columns`` to the first. + Multiple Comparisons and Surrogates =================================== diff --git a/src/eegprep/functions/statistics/anova2_cell.py b/src/eegprep/functions/statistics/anova2_cell.py index dab2cf02..61cbb807 100644 --- a/src/eegprep/functions/statistics/anova2_cell.py +++ b/src/eegprep/functions/statistics/anova2_cell.py @@ -10,7 +10,12 @@ def anova2_cell(data: Any, *, axis: int = -1) -> TwoWayAnovaResult: - """Compute balanced two-way unpaired ANOVA F-statistics.""" + """Compute balanced two-way unpaired ANOVA with named factor results. + + The returned effects are consistently named ``rows``, ``columns``, and + ``interaction``. This avoids relying on the positional output order of the + corresponding MATLAB helper. + """ stacked = two_way_stack(data, axis=axis, name="anova2_cell") values = anova_values(stacked) diff --git a/src/eegprep/functions/statistics/statcond.py b/src/eegprep/functions/statistics/statcond.py index 4575f5c6..f23e7aab 100644 --- a/src/eegprep/functions/statistics/statcond.py +++ b/src/eegprep/functions/statistics/statcond.py @@ -30,7 +30,13 @@ @dataclass(frozen=True) class StatcondResult: - """Result returned by :func:`statcond`.""" + """Result returned by :func:`statcond`. + + For a two-way design computed from condition arrays, the ``stat``, ``df``, + and ``pvalue`` fields are :class:`TwoWayEffects` in row, column, and + interaction order. Effect-valued nonparametric outputs follow the same + order. + """ stat: Any df: Any @@ -89,6 +95,12 @@ def statcond( arraycomp: ``True``/``"on"`` batches all resamples. ``False``/``"off"`` computes one resample at a time and, with ``return_resampling_array=True``, returns one grid as in EEGLAB. + + Returns: + A :class:`StatcondResult`, or a :class:`SurrogateDistribution` when + ``return_resampling_array`` is true. Computed two-way result fields use + named ``rows``, ``columns``, and ``interaction`` effects in that order + for both paired and unpaired designs. """ method_name = normalize_method(mode or method) diff --git a/tests/test_statcond_eeglab_tests.py b/tests/test_statcond_eeglab_tests.py index f6b68927..177bb699 100644 --- a/tests/test_statcond_eeglab_tests.py +++ b/tests/test_statcond_eeglab_tests.py @@ -186,6 +186,43 @@ def _reference_grid() -> tuple[tuple[np.ndarray, ...], tuple[np.ndarray, ...]]: ) +def _unpaired_factor_order_grid() -> tuple[tuple[np.ndarray, ...], tuple[np.ndarray, ...]]: + return ( + ( + np.array([1, 2, 4, 3, 5], dtype=float), + np.array([2, 2, 3, 5, 4], dtype=float), + np.array([3, 4, 2, 5, 6], dtype=float), + ), + ( + np.array([2, 3, 1, 4, 5], dtype=float), + np.array([4, 2, 5, 3, 6], dtype=float), + np.array([5, 6, 3, 7, 4], dtype=float), + ), + ) + + +def _assert_unpaired_two_way_matlab_golden() -> None: + result = statcond(_unpaired_factor_order_grid(), method="param", paired="off") + + # Direct MATLAB output from EEGLAB 8ac485f is positionally columns, + # rows, interaction for the unpaired branch. Map those values to the + # documented factor meanings instead of preserving the positional defect. + matlab_statistics = (2.40845084190369, 1.14084577560425, 0.295774638652802) + matlab_dfs = ((2, 24), (1, 24), (2, 24)) + matlab_pvalues = (0.111369788646698, 0.296099960803986, 0.746627926826477) + expected_statistics = TwoWayEffects(matlab_statistics[1], matlab_statistics[0], matlab_statistics[2]) + expected_dfs = TwoWayEffects(matlab_dfs[1], matlab_dfs[0], matlab_dfs[2]) + expected_pvalues = TwoWayEffects(matlab_pvalues[1], matlab_pvalues[0], matlab_pvalues[2]) + + assert isinstance(result.stat, TwoWayEffects) + assert result.df == expected_dfs + assert isinstance(result.pvalue, TwoWayEffects) + for actual, expected in zip(result.stat, expected_statistics, strict=True): + np.testing.assert_allclose(actual, expected, rtol=1e-6, atol=1e-7) + for actual, expected in zip(result.pvalue, expected_pvalues, strict=True): + np.testing.assert_allclose(actual, expected, rtol=1e-6, atol=1e-7) + + def _assert_effects(result: StatcondResult, expected: tuple[TwoWayEffects, TwoWayEffects, TwoWayEffects]) -> None: expected_statistics, expected_dfs, expected_pvalues = expected assert isinstance(result.stat, TwoWayEffects) @@ -241,11 +278,9 @@ def test_statcond_unpaired_one_way_anova_matches_independent_reference(): @eeglab_test(STATCOND_REGRESSION, "test_6") @eeglab_test(STATCOND_CLASS, "unpaired2Anova") -def test_statcond_unpaired_two_way_anova_matches_independent_reference(): - # t_statcond.mat records the first two effects in column/row order here, - # contrary to statcond.m's documented row/column order and its paired - # branch. Check the mathematical factors rather than copying that defect. +def test_statcond_unpaired_two_way_anova_uses_documented_factor_order(): _assert_unpaired_two_way_reference() + _assert_unpaired_two_way_matlab_golden() def _dimensional_conditions() -> tuple[tuple[tuple[np.ndarray, ...], tuple[int, ...]], ...]: From 2d6707257e3610c27cef69a3501e15cbea7887e3 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:55 -0700 Subject: [PATCH 34/45] feat: port low-level plotting helpers (#362) --- docs/parity/eeglab_core_parity_matrix.json | 65 ++-- docs/source/api/plotting_and_topography.rst | 31 +- docs/source/changelog.rst | 7 + src/eegprep/__init__.py | 7 + src/eegprep/functions/sigprocfunc/__init__.py | 14 + src/eegprep/functions/sigprocfunc/cbar.py | 147 ++++++++ src/eegprep/functions/sigprocfunc/copyaxis.py | 98 ++++++ .../functions/sigprocfunc/forcelocs.py | 87 +++++ src/eegprep/functions/sigprocfunc/headplot.py | 62 +++- .../functions/sigprocfunc/plotcurve.py | 300 ++++++++++++++++ src/eegprep/functions/sigprocfunc/sbplot.py | 107 ++++++ src/eegprep/functions/sigprocfunc/slider.py | 106 ++++++ tests/test_phase4_plot_wrappers.py | 19 +- .../test_sigprocfunc_plotting_eeglab_tests.py | 329 ++++++++++++++++++ 14 files changed, 1339 insertions(+), 40 deletions(-) create mode 100644 src/eegprep/functions/sigprocfunc/cbar.py create mode 100644 src/eegprep/functions/sigprocfunc/copyaxis.py create mode 100644 src/eegprep/functions/sigprocfunc/forcelocs.py create mode 100644 src/eegprep/functions/sigprocfunc/plotcurve.py create mode 100644 src/eegprep/functions/sigprocfunc/sbplot.py create mode 100644 src/eegprep/functions/sigprocfunc/slider.py create mode 100644 tests/test_sigprocfunc_plotting_eeglab_tests.py diff --git a/docs/parity/eeglab_core_parity_matrix.json b/docs/parity/eeglab_core_parity_matrix.json index 665dc65f..12356802 100644 --- a/docs/parity/eeglab_core_parity_matrix.json +++ b/docs/parity/eeglab_core_parity_matrix.json @@ -5034,15 +5034,15 @@ { "eeglab_path": "functions/sigprocfunc/cbar.m", "eeglab_name": "cbar", - "eegprep_equivalent": null, + "eegprep_equivalent": "src/eegprep/functions/sigprocfunc/cbar.py", "gap_category": "1_long_tail_helper_coverage", - "status": "matlab_runtime_skip", - "rationale": "Phase 7 triage classified this as MATLAB figure/GUI/runtime support not reached by EEGPrep Qt, matplotlib, console, or tested preprocessing workflows.", + "status": "implemented", + "rationale": "EEGPrep provides standalone vertical, horizontal, positive, full-colormap, partial-colormap, and labelled Matplotlib color bars.", "responsible_phase": "none", "user_facing_surface": [ "helper" ], - "test_notes": "No runtime port; covered by parity matrix validation and absence from menu/action workflows." + "test_notes": "Covered by all five current eeglab_tests cbar wrapper methods in tests/test_sigprocfunc_plotting_eeglab_tests.py, with artist arrays, layout, orientation, and ticks asserted." }, { "eeglab_path": "functions/sigprocfunc/celltomat.m", @@ -5151,15 +5151,15 @@ { "eeglab_path": "functions/sigprocfunc/copyaxis.m", "eeglab_name": "copyaxis", - "eegprep_equivalent": null, + "eegprep_equivalent": "src/eegprep/functions/sigprocfunc/copyaxis.py", "gap_category": "1_long_tail_helper_coverage", - "status": "matlab_runtime_skip", - "rationale": "Phase 7 triage classified this as MATLAB figure/GUI/runtime support not reached by EEGPrep Qt, matplotlib, console, or tested preprocessing workflows.", + "status": "implemented", + "rationale": "EEGPrep copies current or explicit Matplotlib axes into standalone figures, preserving common scientific line/image content and axes metadata.", "responsible_phase": "none", "user_facing_surface": [ "helper" ], - "test_notes": "No runtime port; covered by parity matrix validation and absence from menu/action workflows." + "test_notes": "Covered by the current copyaxis wrapper method in tests/test_sigprocfunc_plotting_eeglab_tests.py with line, image, labels, limits, styles, and legend assertions." }, { "eeglab_path": "functions/sigprocfunc/coregister.m", @@ -5468,21 +5468,16 @@ { "eeglab_path": "functions/sigprocfunc/forcelocs.m", "eeglab_name": "forcelocs", - "eegprep_equivalent": null, + "eegprep_equivalent": "src/eegprep/functions/sigprocfunc/forcelocs.py", "gap_category": "7_file_format_channel_location", - "status": "stale_skip", - "rationale": "Manual channel-rotation GUI helper has no in-scope caller or deterministic workflow; Phase 2 covers recentering and coordinate conversion through pop_chancenter/convertlocs.", + "status": "implemented", + "rationale": "EEGPrep performs deterministic X-Z and Y-Z whole-montage rotations and refreshes Cartesian, spherical, BESA, and topographic coordinate fields.", "responsible_phase": "none", - "user_facing_surface": [], - "test_notes": "No runtime parity test required for stale helper classification; matrix validator enforces stale policy fields.", - "stale_policy": { - "menu_reachable": false, - "documented_user_api": false, - "called_by_in_scope_workflow": false, - "required_by_parity_tests": false, - "needed_as_phase_helper": false, - "likely_user_alias": false - } + "user_facing_surface": [ + "helper", + "file_channel" + ], + "test_notes": "Covered by both current forcelocs wrapper methods in tests/test_sigprocfunc_plotting_eeglab_tests.py against their exact expected 3-D and derived coordinate values." }, { "eeglab_path": "functions/sigprocfunc/gettempfolder.m", @@ -5511,12 +5506,12 @@ "eegprep_equivalent": "src/eegprep/functions/sigprocfunc/headplot.py", "gap_category": "3_unsupported_options", "status": "implemented", - "rationale": "Phase 5 implemented plotmeshonly head/sphere preview and orilocs setup behavior for standalone headplot spline setup.", + "rationale": "EEGPrep implements standalone spline setup and 3-D rendering, mesh/original-location options, wireframe lighting-off mode, and the spherical/cartesian example commands.", "responsible_phase": "none", "user_facing_surface": [ "helper" ], - "test_notes": "Covered by tests/test_phase4_plot_wrappers.py spline reuse, plotmeshonly preview, orilocs metadata, sample headplot rendering, GUI setup replay, and MATLAB parity tests when available." + "test_notes": "All five current headplot wrapper methods carry provenance across tests/test_phase4_plot_wrappers.py and tests/test_sigprocfunc_plotting_eeglab_tests.py; spline/interpolation MATLAB parity tests run when available." }, { "eeglab_path": "functions/sigprocfunc/icaact.m", @@ -5876,15 +5871,15 @@ { "eeglab_path": "functions/sigprocfunc/plotcurve.m", "eeglab_name": "plotcurve", - "eegprep_equivalent": null, + "eegprep_equivalent": "src/eegprep/functions/sigprocfunc/plotcurve.py", "gap_category": "1_long_tail_helper_coverage", - "status": "matlab_runtime_skip", - "rationale": "Phase 7 triage classified this as MATLAB figure/GUI/runtime support not reached by EEGPrep Qt, matplotlib, console, or tested preprocessing workflows.", + "status": "implemented", + "rationale": "EEGPrep provides standalone multi-curve, mean, error-band, significance-region, labelled, marker, and scalp-inset plotting on Matplotlib axes.", "responsible_phase": "none", "user_facing_surface": [ "helper" ], - "test_notes": "No runtime port; covered by parity matrix validation and absence from menu/action workflows." + "test_notes": "The current plotcurve wrapper's eight active cases are covered together in tests/test_sigprocfunc_plotting_eeglab_tests.py with numerical line, mean, mask-region, marker, label, legend, and limit assertions." }, { "eeglab_path": "functions/sigprocfunc/plotdata.m", @@ -6301,15 +6296,15 @@ { "eeglab_path": "functions/sigprocfunc/sbplot.m", "eeglab_name": "sbplot", - "eegprep_equivalent": null, + "eegprep_equivalent": "src/eegprep/functions/sigprocfunc/sbplot.py", "gap_category": "1_long_tail_helper_coverage", - "status": "matlab_runtime_skip", - "rationale": "Phase 7 triage classified this as MATLAB figure/GUI/runtime support not reached by EEGPrep Qt, matplotlib, console, or tested preprocessing workflows.", + "status": "implemented", + "rationale": "EEGPrep creates non-destructive Matplotlib axes in one-based grid cells or spanning opposite corner cells, including tiling within explicit parent bounds.", "responsible_phase": "none", "user_facing_surface": [ "helper" ], - "test_notes": "No runtime port; covered by parity matrix validation and absence from menu/action workflows." + "test_notes": "The current sbplot wrapper's grid, spanning, property, and parent-axis cases are covered in tests/test_sigprocfunc_plotting_eeglab_tests.py with concrete bounds and face-color assertions." }, { "eeglab_path": "functions/sigprocfunc/shuffle.m", @@ -6348,15 +6343,15 @@ { "eeglab_path": "functions/sigprocfunc/slider.m", "eeglab_name": "slider", - "eegprep_equivalent": null, + "eegprep_equivalent": "src/eegprep/functions/sigprocfunc/slider.py", "gap_category": "1_long_tail_helper_coverage", - "status": "matlab_runtime_skip", - "rationale": "Phase 7 triage classified this as MATLAB figure/GUI/runtime support not reached by EEGPrep Qt, matplotlib, console, or tested preprocessing workflows.", + "status": "implemented", + "rationale": "EEGPrep adds Matplotlib horizontal/vertical viewport widgets, applies EEGLAB's magnification transform, and supports exact layout restoration and optional dismissal suppression.", "responsible_phase": "none", "user_facing_surface": [ "helper" ], - "test_notes": "No runtime port; covered by parity matrix validation and absence from menu/action workflows." + "test_notes": "The current slider wrapper's four active control combinations are covered in tests/test_sigprocfunc_plotting_eeglab_tests.py with widget, panning, magnification, suppression, and restoration assertions." }, { "eeglab_path": "functions/sigprocfunc/snapread.m", diff --git a/docs/source/api/plotting_and_topography.rst b/docs/source/api/plotting_and_topography.rst index 094c88e4..ba8f50fc 100644 --- a/docs/source/api/plotting_and_topography.rst +++ b/docs/source/api/plotting_and_topography.rst @@ -3,13 +3,42 @@ Plotting and Topography ======================= -Scalp maps and artifact review plots. +Scalp maps, curve summaries, figure-layout helpers, and artifact review plots. .. autosummary:: :toctree: generated/ eegprep.hist2 + eegprep.cbar + eegprep.copyaxis + eegprep.forcelocs + eegprep.headplot + eegprep.plotcurve + eegprep.sbplot + eegprep.slider eegprep.topoplot eegprep.loc_subsets eegprep.vis_artifacts eegprep.vis_artifacts_diagnostics + +Low-level EEGLAB plotting workflows +----------------------------------- + +The low-level helpers retain EEGLAB's familiar names while returning ordinary +Matplotlib objects. ``cbar`` draws full or one-based partial colormap ranges; +``copyaxis`` copies the current plot into a standalone figure; and ``sbplot`` +creates axes that may span opposite corners of a one-based subplot grid. +``slider`` returns its widget handles so scripts can pan or remove a magnified +viewport without relying on hidden callback strings. + +``plotcurve`` accepts data with curves in rows or columns, plots individual and +mean traces, and supports confidence-limit highlighting. A two-value +``maskarray=[low, high]`` marks samples whose comparison values fall outside +that interval. ``highlightmode="background"`` shades the curve axes, while +``highlightmode="bottom"`` puts the significance runs in a narrow lower axes. + +``forcelocs`` rotates X/Z or Y/Z channel coordinates and refreshes the +spherical and topographic fields after every rotation. ``headplot`` renders +reusable 3-D spline maps; ``headplot("example")`` and +``headplot("cartesian")`` return and print the corresponding electrode-file +examples for interactive use. diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 5583886b..ce6637e7 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -48,6 +48,13 @@ Unreleased float64 signal samples, channel labels, rational sampling rates, and GDF event tables. Numeric event types round-trip through independent readers; free-text event labels receive explicit file-local uint16 codes. +- Added the low-level EEGLAB plotting helpers ``cbar``, ``copyaxis``, + ``forcelocs``, ``plotcurve``, ``sbplot``, and ``slider`` as standalone + Matplotlib APIs. They support partial color scales, copied scientific axes, + exact montage rotations, confidence-region highlighting, spanning subplot + layouts, and pannable magnified figures. ``headplot`` also supports its + ``example`` and ``cartesian`` command modes, and lighting-off plots now have + explicit wireframe coverage. - Neuroscan ``loadcnt`` and ``pop_loadcnt`` now provide standalone 16/32-bit CNT import with per-channel microvolt calibration, channel-blocked reads, partial sample ranges, response and boundary events, and optional ``.fdt`` diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index dd3d4940..78bf532b 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -65,6 +65,7 @@ ), "bundled_plugins": ("eegprep.functions.adminfunc.plugin_menu", "bundled_plugins"), "cart2topo": ("eegprep.functions.sigprocfunc.cart2topo", "cart2topo"), + "cbar": ("eegprep.functions.sigprocfunc.cbar", "cbar"), "celltomat": ("eegprep.functions.sigprocfunc.celltomat", "celltomat"), "chancenter": ("eegprep.functions.sigprocfunc.chancenter", "chancenter"), "check_extension_compatibility": ("eegprep.extensions", "check_extension_compatibility"), @@ -84,6 +85,7 @@ "compare": ("eegprep.functions.redefine_functions", "compare"), "compvar": ("eegprep.functions.sigprocfunc.ica_helpers", "compvar"), "convertlocs": ("eegprep.functions.sigprocfunc.convertlocs", "convertlocs"), + "copyaxis": ("eegprep.functions.sigprocfunc.copyaxis", "copyaxis"), "covary": ("eegprep.functions.miscfunc.covary", "covary"), "coregister": ("eegprep.functions.sigprocfunc.coregister", "coregister"), "decodechan": ("eegprep.functions.redefine_functions", "decodechan"), @@ -110,9 +112,12 @@ "pcexpand": ("eegprep.functions.miscfunc.pcexpand", "pcexpand"), "pcsquash": ("eegprep.functions.miscfunc.pcsquash", "pcsquash"), "perminv": ("eegprep.functions.miscfunc.perminv", "perminv"), + "plotcurve": ("eegprep.functions.sigprocfunc.plotcurve", "plotcurve"), "quantile": ("eegprep.functions.sigprocfunc.quantile", "quantile"), "scanfold": ("eegprep.functions.miscfunc.scanfold", "scanfold"), + "sbplot": ("eegprep.functions.sigprocfunc.sbplot", "sbplot"), "shuffle": ("eegprep.functions.sigprocfunc.shuffle", "shuffle"), + "slider": ("eegprep.functions.sigprocfunc.slider", "slider"), "uniquef": ("eegprep.functions.miscfunc.uniquef", "uniquef"), "vectdata": ("eegprep.functions.miscfunc.vectdata", "vectdata"), "eeg_addnewevents": ("eegprep.functions.popfunc.eeg_addnewevents", "eeg_addnewevents"), @@ -189,12 +194,14 @@ "floatwrite": ("eegprep.functions.sigprocfunc.floatwrite", "floatwrite"), "finputcheck": ("eegprep.functions.guifunc.finputcheck", "finputcheck"), "format_plugin_menu": ("eegprep.functions.adminfunc.plugin_menu", "format_plugin_menu"), + "forcelocs": ("eegprep.functions.sigprocfunc.forcelocs", "forcelocs"), "gui": ("eegprep.functions.adminfunc.eeglab", "gui"), "getchanlist": ("eegprep.functions.popfunc.getchanlist", "getchanlist"), "getipsph": ("eegprep.functions.miscfunc.getipsph", "getipsph"), "gethelpvar": ("eegprep.functions.adminfunc.gethelpvar", "gethelpvar"), "getkeyval": ("eegprep.functions.adminfunc.getkeyval", "getkeyval"), "hist2": ("eegprep.functions.miscfunc.hist2", "hist2"), + "headplot": ("eegprep.functions.sigprocfunc.headplot", "headplot"), "icaact": ("eegprep.functions.sigprocfunc.ica_helpers", "icaact"), "icadefs": ("eegprep.functions.sigprocfunc.icadefs", "icadefs"), "icaproj": ("eegprep.functions.sigprocfunc.ica_helpers", "icaproj"), diff --git a/src/eegprep/functions/sigprocfunc/__init__.py b/src/eegprep/functions/sigprocfunc/__init__.py index 20352e7e..d9a5199f 100644 --- a/src/eegprep/functions/sigprocfunc/__init__.py +++ b/src/eegprep/functions/sigprocfunc/__init__.py @@ -1,27 +1,41 @@ """EEGLAB-style signal processing function modules.""" +from .cbar import cbar from .celltomat import celltomat +from .copyaxis import copyaxis from .eyelike import eyelike from .fastif import fastif +from .forcelocs import forcelocs +from .headplot import headplot from .matsel import matsel from .mattocell import mattocell from .nan_mean import nan_mean from .openbdf import openbdf +from .plotcurve import plotcurve from .quantile import quantile from .readbdf import readbdf +from .sbplot import sbplot from .shuffle import shuffle +from .slider import slider from .writegdf import writegdf __all__ = [ + "cbar", "celltomat", + "copyaxis", "eyelike", "fastif", + "forcelocs", + "headplot", "matsel", "mattocell", "nan_mean", "openbdf", + "plotcurve", "quantile", "readbdf", + "sbplot", "shuffle", + "slider", "writegdf", ] diff --git a/src/eegprep/functions/sigprocfunc/cbar.py b/src/eegprep/functions/sigprocfunc/cbar.py new file mode 100644 index 00000000..3a10f1ca --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/cbar.py @@ -0,0 +1,147 @@ +"""EEGLAB-compatible full and partial color bars.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.axes import Axes +from matplotlib.colors import Colormap + + +def cbar( + arg: str | Axes | int | None = "vert", + colors: int | Sequence[int] = 0, + minmax: Sequence[float] | None = None, + grad: int = 5, + *, + ax: Axes | None = None, + cmap: str | Colormap | None = None, +) -> Axes: + """Display a full or partial color bar, following EEGLAB ``cbar``. + + Args: + arg: ``"vert"``, ``"horiz"``, ``"pos"``, or an existing axes. + Passing an axes draws into it and infers orientation from its shape. + colors: One-based colormap indices, or an integer count of colors to + remove from the upper end. Zero displays the full colormap. + minmax: Optional values represented by the two ends of the color bar. + grad: Number of tick labels when ``minmax`` is supplied. + ax: Existing axes to draw into. This is the keyword equivalent of + passing an axes as ``arg``. + cmap: Matplotlib colormap name or object. The source axes' colormap is + used when possible, otherwise Matplotlib's default is used. + + Returns: + The axes containing the color strip. + """ + orientation, target, positive = _resolve_target(arg, ax) + parent = target if target is not None else plt.gca() + colormap = _resolve_colormap(parent, cmap) + rgba = _selected_colors(colormap, colors, positive=positive) + target = target or _create_colorbar_axes(parent, orientation) + + if orientation == "vert": + target.imshow(rgba[:, np.newaxis, :], origin="lower", aspect="auto", extent=(0.0, 1.0, 0.0, 1.0)) + target.set_xticks([]) + target.yaxis.tick_right() + else: + target.imshow(rgba[np.newaxis, :, :], origin="lower", aspect="auto", extent=(0.0, 1.0, 0.0, 1.0)) + target.set_yticks([]) + + if minmax is not None: + _set_value_ticks(target, orientation, minmax, grad) + target.set_gid("cbar") + return target + + +def _resolve_target(arg: str | Axes | int | None, ax: Axes | None) -> tuple[str, Axes | None, bool]: + if isinstance(arg, Axes): + if ax is not None and ax is not arg: + raise ValueError("cbar received two different target axes") + target = arg + bounds = target.get_position().bounds + return ("horiz" if bounds[2] > bounds[3] else "vert"), target, False + if arg == 0: + arg = "vert" + if ax is not None: + bounds = ax.get_position().bounds + orientation = str(arg or ("horiz" if bounds[2] > bounds[3] else "vert")).lower() + if orientation not in {"vert", "horiz", "pos"}: + raise ValueError("cbar orientation must be 'vert', 'horiz', or 'pos'") + return ("vert" if orientation == "pos" else orientation), ax, orientation == "pos" + orientation = str(arg or "vert").lower() + if orientation not in {"vert", "horiz", "pos"}: + raise ValueError("cbar orientation must be 'vert', 'horiz', or 'pos'") + return ("vert" if orientation == "pos" else orientation), None, orientation == "pos" + + +def _resolve_colormap(ax: Axes, value: str | Colormap | None) -> Colormap: + if isinstance(value, Colormap): + return value + if value is not None: + return plt.get_cmap(value) + for artist in (*ax.images, *ax.collections): + artist_cmap = getattr(artist, "cmap", None) + if artist_cmap is not None: + return artist_cmap + return plt.get_cmap() + + +def _selected_colors(cmap: Colormap, colors: int | Sequence[int], *, positive: bool) -> np.ndarray: + samples = np.asarray(cmap(np.linspace(0.0, 1.0, cmap.N)), dtype=float) + raw = np.asarray(colors) + if raw.ndim == 0: + truncate = int(raw.item()) + if truncate < 0 or truncate >= samples.shape[0]: + raise ValueError("cbar color truncation must leave at least one colormap color") + first = int(np.ceil(samples.shape[0] / 2.0)) - 1 if positive else 0 + return samples[first : samples.shape[0] - truncate] + indices = np.asarray(colors, dtype=int).ravel() + if indices.size == 0: + raise ValueError("cbar colors must not be empty") + if np.any(indices < 1) or np.any(indices > samples.shape[0]): + raise ValueError("cbar color indices exceed the active colormap") + return samples[indices - 1] + + +def _create_colorbar_axes(parent: Axes, orientation: str) -> Axes: + figure = parent.figure + left, bottom, width, height = parent.get_position().bounds + if orientation == "vert": + return figure.add_axes([left + width + 0.02, bottom, 0.04 * width, height]) + parent.set_position([left, bottom + 0.175 * height, width, 0.825 * height]) + return figure.add_axes([left, bottom, width, 0.075 * height]) + + +def _set_value_ticks(ax: Axes, orientation: str, minmax: Sequence[float], grad: int) -> None: + limits = np.asarray(minmax, dtype=float).ravel() + if limits.size != 2 or not np.isfinite(limits).all(): + raise ValueError("cbar minmax must contain two finite values") + if grad < 2: + raise ValueError("cbar grad must be at least 2") + positions = np.linspace(0.0, 1.0, int(grad)) + labels = _rounded_labels(limits, int(grad)) + if orientation == "vert": + ax.set_yticks(positions, labels) + else: + ax.set_xticks(positions, labels) + + +def _rounded_labels(limits: np.ndarray, grad: int) -> np.ndarray: + labels = np.linspace(float(limits[0]), float(limits[1]), grad) + maximum = float(np.max(np.abs(limits))) + if maximum == 0: + return labels + decade = int(np.floor(np.log10(maximum))) + if decade < 1: + scale = 10.0 ** (1 - decade) + return np.round(labels * scale) / scale + if decade == 1: + scale = 10.0 ** (2 - decade) + return np.round(labels * scale) / scale + return np.round(labels) + + +__all__ = ["cbar"] diff --git a/src/eegprep/functions/sigprocfunc/copyaxis.py b/src/eegprep/functions/sigprocfunc/copyaxis.py new file mode 100644 index 00000000..981b091d --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/copyaxis.py @@ -0,0 +1,98 @@ +"""Copy a Matplotlib axes into a new figure, like EEGLAB ``copyaxis``.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import matplotlib.pyplot as plt +from matplotlib.axes import Axes +from matplotlib.figure import Figure + + +def copyaxis(command: Callable[[Axes], Any] | None = None, *, source: Axes | None = None) -> Figure: + """Copy the current axes into an enlarged standalone figure. + + Common scientific plot content (lines and images), labels, scales, limits, + and legends are reproduced. A Python callback may customize the copied + axes; MATLAB command strings are intentionally not evaluated. + + Args: + command: Optional callback receiving the copied axes. + source: Axes to copy. The current axes is used by default. + + Returns: + The new figure containing the copied axes. + """ + if command is not None and not callable(command): + raise TypeError("copyaxis command must be a callable receiving the copied axes") + source = source or plt.gca() + source_figure = source.get_figure(root=True) + if source_figure is None: + raise ValueError("copyaxis source must belong to a figure") + figure = plt.figure(figsize=source_figure.get_size_inches(), facecolor=source_figure.get_facecolor()) + target = figure.add_axes([0.13, 0.11, 0.775, 0.815]) + _copy_axes_content(source, target) + if command is not None: + command(target) + return figure + + +def _copy_axes_content(source: Axes, target: Axes) -> None: + for line in source.lines: + (copied,) = target.plot( + line.get_xdata(), + line.get_ydata(), + color=line.get_color(), + linestyle=line.get_linestyle(), + linewidth=line.get_linewidth(), + marker=line.get_marker(), + markersize=line.get_markersize(), + markerfacecolor=line.get_markerfacecolor(), + markeredgecolor=line.get_markeredgecolor(), + alpha=line.get_alpha(), + label=line.get_label(), + ) + copied.set_drawstyle(line.get_drawstyle()) + for image in source.images: + target.imshow( + image.get_array(), + cmap=image.get_cmap(), + norm=image.norm, + aspect=source.get_aspect(), + interpolation=image.get_interpolation(), + origin=image.origin, + extent=image.get_extent(), + alpha=image.get_alpha(), + ) + for text in source.texts: + target.text( + *text.get_position(), + text.get_text(), + color=text.get_color(), + fontsize=text.get_fontsize(), + horizontalalignment=text.get_horizontalalignment(), + verticalalignment=text.get_verticalalignment(), + transform=target.transAxes if text.get_transform() is source.transAxes else target.transData, + ) + + target.set( + xlim=source.get_xlim(), + ylim=source.get_ylim(), + xscale=source.get_xscale(), + yscale=source.get_yscale(), + xlabel=source.get_xlabel(), + ylabel=source.get_ylabel(), + title=source.get_title(), + facecolor=source.get_facecolor(), + ) + target.tick_params(labelsize=14) + target.xaxis.label.set_fontsize(16) + target.yaxis.label.set_fontsize(16) + target.title.set_fontsize(16) + handles, labels = target.get_legend_handles_labels() + if source.get_legend() is not None and handles: + target.legend(handles, labels) + + +__all__ = ["copyaxis"] diff --git a/src/eegprep/functions/sigprocfunc/forcelocs.py b/src/eegprep/functions/sigprocfunc/forcelocs.py new file mode 100644 index 00000000..d9d01c9a --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/forcelocs.py @@ -0,0 +1,87 @@ +"""Rotate channel coordinates so named electrodes reach requested X/Y values.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._chanutils import chanlocs_as_list +from eegprep.functions.sigprocfunc.convertlocs import convertlocs + + +def forcelocs(chanlocs: Any, *locations: Any) -> Any: + """Rotate an electrode montage to force named channels to X/Y positions. + + Each location specification is ``(value, axis, label, ...)``. The mean + coordinate of the named electrodes is rotated with the corresponding Z + coordinate on the unit sphere. Specifications are applied in order. + + Args: + chanlocs: EEGLAB channel-location dictionaries with X/Y/Z coordinates. + *locations: One or more ``(value, "x"|"y", channel labels...)`` specs. + + Returns: + A deep-copied montage with all coordinate representations refreshed. + """ + output = convertlocs(chanlocs, "cart2all") + locs = chanlocs_as_list(output) + if not locs: + raise ValueError("forcelocs requires at least one channel location") + for specification in locations: + value, axis, labels = _parse_specification(specification) + indices = _matching_channels(locs, labels) + coordinate = axis.upper() + current = float(np.mean([float(locs[index][coordinate]) for index in indices])) + current_z = float(np.mean([float(locs[index]["Z"]) for index in indices])) + angle = _rotation_angle(current, current_z, value) + for loc in locs: + rotated, rotated_z = _rotate(float(loc[coordinate]), float(loc["Z"]), angle) + loc[coordinate] = rotated + loc["Z"] = rotated_z + refreshed = convertlocs(locs, "cart2all") + locs[:] = refreshed + if isinstance(output, dict): + return locs[0] + return locs + + +def _parse_specification(specification: Any) -> tuple[float, str, list[str]]: + values = list(specification) + if len(values) < 3: + raise ValueError("forcelocs specifications must be (value, axis, label, ...)") + value = float(values[0]) + axis = str(values[1]).lower() + labels = [str(label).lower() for label in values[2:]] + if not np.isfinite(value): + raise ValueError("forcelocs target coordinate must be finite") + if axis not in {"x", "y"}: + raise ValueError("forcelocs axis must be 'x' or 'y'") + return value, axis, labels + + +def _matching_channels(locs: list[dict[str, Any]], labels: list[str]) -> list[int]: + wanted = set(labels) + matched = [index for index, loc in enumerate(locs) if str(loc.get("labels", "")).lower() in wanted] + missing = sorted(wanted - {str(locs[index].get("labels", "")).lower() for index in matched}) + if missing: + raise ValueError(f"forcelocs channel labels not found: {', '.join(missing)}") + return matched + + +def _rotation_angle(current: float, current_z: float, target: float) -> float: + radius = float(np.hypot(current, current_z)) + # A rotation cannot increase the selected mean's radius. EEGLAB's complex + # square-root calculation effectively saturates an out-of-range request at + # the positive-Z hemisphere boundary; clipping makes that behavior explicit. + reachable = float(np.clip(target, -radius, radius)) + target_z = float(np.sqrt(max(0.0, radius * radius - reachable * reachable))) + return float(np.angle(reachable + 1j * target_z) - np.angle(current + 1j * current_z)) + + +def _rotate(value: float, z: float, angle: float) -> tuple[float, float]: + rotated = (value + 1j * z) * np.exp(1j * angle) + return float(np.real(rotated)), float(np.imag(rotated)) + + +__all__ = ["forcelocs"] diff --git a/src/eegprep/functions/sigprocfunc/headplot.py b/src/eegprep/functions/sigprocfunc/headplot.py index 9c88c390..099c4303 100644 --- a/src/eegprep/functions/sigprocfunc/headplot.py +++ b/src/eegprep/functions/sigprocfunc/headplot.py @@ -42,6 +42,46 @@ # spline system. This is intentionally not a diagonal ridge; keep it for # numerical parity with EEGLAB history/setup replays. EEGLAB_SPLINE_LAMBDA = 0.1 +HEADPLOT_SPHERICAL_EXAMPLE = """ +Example of a headplot() electrode angles file (spherical coords.) +Fields: chan_num cor_deg horiz_deg channel_name + + 1 -90 -72 Fp1. + 2 90 72 Fp2. + 3 -62 -57 F3.. + 4 62 57 F4.. + 5 -45 0 C3.. + 6 45 0 C4.. + 7 -118 2 A1.. + 8 118 -2 A2.. + 9 -62 57 P3.. + 10 62 -57 P4.. + 11 -90 72 O1.. + 12 90 -72 O2.. + 13 -90 -36 F7.. + 14 90 36 F8.. + 15 -90 0 T3.. + 16 90 0 T4.. + 17 -90 36 T5.. + 18 90 -36 T6.. + 19 45 90 Fz.. + 20 0 0 Cz.. + 21 45 -90 Pz.. + +A 90 deg coronal rotation points to right ear, -90 to left. +A positive horizontal rotation is counterclockwise from above. +Use pol2sph() to convert from topoplot() format to spherical. +Channel names should have 4 chars (. = space). +See also: headplot('cartesian') +""".lstrip() +HEADPLOT_CARTESIAN_EXAMPLE = """ +Example of a headplot() electrode location file (cartesian coords.) +Fields: chan_num x y z channel_name + + 1 0.4528 0.8888 -0.0694 Fp1. +Channel names should have 4 chars (. = space). +See also: headplot('example') +""".lstrip() @dataclass(frozen=True) @@ -75,18 +115,32 @@ class HeadplotSpline: headplot_version: int -def headplot(values: Any, arg1: Any, **kwargs: Any): +def headplot(values: Any, arg1: Any = None, **kwargs: Any): """Plot values on a spline-interpolated 3-D head mesh. Args: values: One data value per electrode, or one value per original channel when the spline stores channel indices. arg1: Path to a ``.spl`` file created by :func:`headplot_setup`. + Omit this for the ``"example"`` and ``"cartesian"`` commands. **kwargs: EEGLAB-style options including ``meshfile``, ``title``, ``maplimits``, ``electrodes``, ``labels``, ``view`` and ``cbar``. """ - if isinstance(values, str) and values.lower() == "setup": - return headplot_setup(arg1, kwargs.pop("splinefile"), **kwargs) + if isinstance(values, str): + command = values.lower() + if command == "setup": + if arg1 is None or "splinefile" not in kwargs: + raise ValueError("headplot setup requires channel locations and splinefile") + return headplot_setup(arg1, kwargs.pop("splinefile"), **kwargs) + if command in {"example", "demo"}: + print(HEADPLOT_SPHERICAL_EXAMPLE, end="") + return HEADPLOT_SPHERICAL_EXAMPLE + if command == "cartesian": + print(HEADPLOT_CARTESIAN_EXAMPLE, end="") + return HEADPLOT_CARTESIAN_EXAMPLE + raise ValueError(f"Unknown headplot command: {values}") + if arg1 is None: + raise ValueError("headplot requires a spline file") spline = load_headplot_spline(arg1) data = _values_for_spline(values, spline) mesh = load_headplot_mesh(kwargs.get("meshfile") or spline.meshfile or DEFAULT_MESH) @@ -718,6 +772,8 @@ def _is_on(value: str | bool) -> bool: __all__ = [ "HeadplotMesh", "HeadplotSpline", + "HEADPLOT_CARTESIAN_EXAMPLE", + "HEADPLOT_SPHERICAL_EXAMPLE", "MAPLIMIT_PADDING", "default_headplot_transform", "default_headplot_mesh_transform", diff --git a/src/eegprep/functions/sigprocfunc/plotcurve.py b/src/eegprep/functions/sigprocfunc/plotcurve.py new file mode 100644 index 00000000..2b7f3fc4 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/plotcurve.py @@ -0,0 +1,300 @@ +"""Plot one or more curves with EEGLAB-style significance highlighting.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.axes import Axes + +from eegprep.functions.sigprocfunc.topoplot import topoplot + + +DEFAULT_CURVE_COLORS = ("r", "g", "b", "c", "m") + + +def plotcurve(times: Any, data: Any, *args: Any, target: Axes | None = None, **kwargs: Any) -> Axes: + """Plot curves and highlight samples outside significance limits. + + ``data`` is normalized to ``(curves, times)``. Options may be passed as + EEGLAB-style key/value positional pairs or Python keywords. Supported + options include ``maskarray``, ``val2mask``, ``highlightmode``, + ``plotmean``, ``plotindiv``, ``plotstderr``, labels, limits, legends, + vertical markers, colors, and scalp-map insets. + + Args: + times: One-dimensional time coordinates. + data: Curve values with one dimension matching ``times``. + *args: Optional EEGLAB-style key/value pairs. + target: Existing axes. The current axes is used by default. + **kwargs: Plot options named as in EEGLAB ``plotcurve``. + + Returns: + The primary curve axes. + """ + options = _parse_options(args, kwargs) + time_values = np.asarray(times, dtype=float).ravel() + if time_values.size == 0 or not np.isfinite(time_values).all(): + raise ValueError("plotcurve times must contain finite values") + curves = _as_curves(data, time_values.size) + ax = target or plt.gca() + + plot_stderr = options["plotstderr"] + if plot_stderr is not None and np.asarray(plot_stderr).size: + center = np.nanmean(curves, axis=0) + error = np.broadcast_to(np.asarray(plot_stderr, dtype=float).squeeze(), center.shape) + ax.fill_between( + time_values, + center - error, + center + error, + color=_style_parts(options["colors"][0])[0], + alpha=float(options["transparent"]), + edgecolor="none", + ) + + plotted = _curves_to_plot(curves, options["plotmean"], options["plotindiv"]) + lines = [] + for index, values in enumerate(plotted): + is_mean = options["plotmean"] and options["plotindiv"] and index == plotted.shape[0] - 1 + color, linestyle = ("k", "-") if is_mean else _style_parts(options["colors"][index % len(options["colors"])]) + (line,) = ax.plot( + time_values, + values, + color=color, + linestyle=linestyle, + linewidth=2 if is_mean else 1.5, + ) + lines.append(line) + + y_limits = _y_limits(plotted, options["ylim"], plot_stderr) + ax.set_ylim(y_limits) + ax.set_xlim(float(time_values[0]), float(time_values[-1])) + + mask = options["maskarray"] + if mask is not None and np.asarray(mask).size: + comparison = curves if options["val2mask"] is None else _comparison_values(options["val2mask"], curves.shape) + regions = _significant_regions(mask, comparison, time_values.size) + _highlight_regions(ax, time_values, regions, options["highlightmode"], options["xlabel"]) + + for value in np.asarray(options["marktimes"], dtype=float).ravel(): + if np.isfinite(value): + ax.axvline(value, color="k", linestyle="--", linewidth=float(options["linewidth"])) + for value in np.asarray(options["vert"], dtype=float).ravel(): + if np.isfinite(value): + ax.axvline(value, color="m", linewidth=1) + + if options["plottopo"] is not None and np.asarray(options["plottopo"]).size: + _plot_topographies(ax, options["plottopo"], options["chanlocs"], options["plottopotitle"]) + _finish_axes(ax, lines, options) + return ax + + +def _parse_options(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: + if len(args) % 2: + raise ValueError("plotcurve optional arguments must be key/value pairs") + supplied = {str(args[index]).lower(): args[index + 1] for index in range(0, len(args), 2)} + supplied.update({str(key).lower(): value for key, value in kwargs.items()}) + defaults: dict[str, Any] = { + "maskarray": None, + "val2mask": None, + "highlightmode": "background", + "plotmean": False, + "plotindiv": True, + "traceinfo": "off", + "logpval": False, + "title": "", + "xlabel": "", + "plotmode": "single", + "plotstderr": None, + "ylabel": "", + "legend": (), + "transparent": 0.5, + "colors": DEFAULT_CURVE_COLORS, + "plottopotitle": (), + "chanlocs": None, + "ylim": None, + "vert": (), + "plottopo": None, + "linewidth": 2, + "marktimes": (), + } + unknown = set(supplied) - set(defaults) + if unknown: + raise ValueError(f"Unsupported plotcurve option: {sorted(unknown)[0]}") + defaults.update(supplied) + defaults["plotmean"] = _is_on(defaults["plotmean"]) + defaults["plotindiv"] = _is_on(defaults["plotindiv"]) + defaults["logpval"] = _is_on(defaults["logpval"]) + if not defaults["plotindiv"]: + defaults["plotmean"] = True + defaults["highlightmode"] = str(defaults["highlightmode"]).lower() + if defaults["highlightmode"] not in {"background", "bottom"}: + raise ValueError("plotcurve highlightmode must be 'background' or 'bottom'") + defaults["plotmode"] = str(defaults["plotmode"]).lower() + if defaults["plotmode"] not in {"single", "topo"}: + raise ValueError("plotcurve plotmode must be 'single' or 'topo'") + colors = defaults["colors"] + defaults["colors"] = tuple(colors) if not isinstance(colors, str) else (colors,) + if not defaults["colors"]: + defaults["colors"] = DEFAULT_CURVE_COLORS + return defaults + + +def _as_curves(data: Any, time_count: int) -> np.ndarray: + values = np.asarray(data, dtype=float) + if values.size == 0: + raise ValueError("plotcurve data must not be empty") + values = np.squeeze(values) + if values.ndim == 1: + if values.size != time_count: + raise ValueError("Size of time input and array input does not match") + return values.reshape(1, time_count) + if values.shape[-1] == time_count: + return values.reshape(-1, time_count) + if values.shape[0] == time_count: + return np.moveaxis(values, 0, -1).reshape(-1, time_count) + if values.size % time_count: + raise ValueError("Size of time input and array input does not match") + return values.reshape(-1, time_count) + + +def _curves_to_plot(curves: np.ndarray, plot_mean: bool, plot_individual: bool) -> np.ndarray: + mean = np.nanmean(curves, axis=0, keepdims=True) + if not plot_individual: + return mean + if plot_mean: + return np.vstack([curves, mean]) + return curves + + +def _comparison_values(value: Any, shape: tuple[int, int]) -> np.ndarray: + values = np.asarray(value, dtype=float) + if values.ndim == 0: + return np.full(shape, float(values)) + return np.broadcast_to(values, shape) + + +def _significant_regions(mask: Any, values: np.ndarray, time_count: int) -> np.ndarray: + limits = np.asarray(mask) + if limits.ndim == 1 and limits.size == time_count and np.all(np.isin(np.unique(limits), [0, 1])): + return limits.astype(bool) + numeric = np.asarray(mask, dtype=float) + if numeric.ndim == 1 and numeric.size == 2: + outside = (values < numeric[0]) | (values > numeric[1]) + return np.any(outside, axis=0) + if numeric.shape == values.shape: + return np.any(values >= numeric, axis=0) + if numeric.ndim == 2 and numeric.shape == (values.shape[0], 2): + outside = (values < numeric[:, :1]) | (values > numeric[:, 1:]) + return np.any(outside, axis=0) + if numeric.ndim == 3 and numeric.shape[:2] == values.shape and numeric.shape[2] == 2: + outside = (values < numeric[:, :, 0]) | (values > numeric[:, :, 1]) + return np.any(outside, axis=0) + if numeric.ndim == 1 and numeric.size in {1, values.shape[0]}: + threshold = np.broadcast_to(numeric.reshape(-1, 1), values.shape) + return np.any(values >= threshold, axis=0) + raise ValueError("plotcurve maskarray shape does not match data") + + +def _highlight_regions(ax: Axes, times: np.ndarray, regions: np.ndarray, mode: str, xlabel: str) -> None: + target = ax + if mode == "bottom": + left, bottom, width, height = ax.get_position().bounds + ax.set_position([left + 0.1 * width, bottom + 0.1 * height, 0.9 * width, 0.85 * height]) + target = ax.figure.add_axes([left + 0.1 * width, bottom + 0.05 * height, 0.9 * width, 0.05 * height]) + target.set_xlim(float(times[0]), float(times[-1])) + target.set_ylim(0.0, 1.0) + target.set_yticks([]) + target.set_xlabel(xlabel) + ax.set_xticks([]) + else: + ax.set_xlabel(xlabel) + for start, stop in _true_runs(regions): + right = min(stop, times.size - 1) + target.axvspan(times[start], times[right], color="0.75" if mode == "background" else "k", zorder=-1) + + +def _true_runs(values: np.ndarray) -> list[tuple[int, int]]: + regions = np.asarray(values, dtype=bool).ravel() + changes = np.diff(np.pad(regions.astype(int), (1, 1))) + starts = np.flatnonzero(changes == 1) + stops = np.flatnonzero(changes == -1) + return list(zip(starts.tolist(), stops.tolist())) + + +def _y_limits(data: np.ndarray, requested: Any, stderr: Any) -> tuple[float, float]: + if requested is not None and np.asarray(requested).size: + values = np.asarray(requested, dtype=float).ravel() + if values.size == 1: + return float(values[0]), float(np.nanmax(data)) + if values.size != 2: + raise ValueError("plotcurve ylim must contain one or two values") + if np.all(values == 0): + return 0.0, 1.0 + return float(values[0]), float(values[1]) + low, high = float(np.nanmin(data)), float(np.nanmax(data)) + if stderr is not None and np.asarray(stderr).size: + error = float(np.nanmax(np.abs(np.asarray(stderr, dtype=float)))) + low, high = low - error, high + error + span = high - low + padding = span / 10.0 if span else max(abs(high) / 10.0, 0.1) + return low - padding, high + padding + + +def _plot_topographies(ax: Axes, values: Any, chanlocs: Any, titles: Sequence[str]) -> None: + if chanlocs is None: + raise ValueError("plotcurve plottopo requires chanlocs") + maps = np.asarray(values, dtype=float) + if maps.ndim == 1: + maps = maps[np.newaxis, :] + left, bottom, width, height = ax.get_position().bounds + ax.set_position([left, bottom, width, height / 2.0]) + for index, map_values in enumerate(maps): + topo_ax = ax.figure.add_axes( + [left + index * width / maps.shape[0], bottom + height / 2.0, width / maps.shape[0], height / 2.0] + ) + topoplot(map_values, chanlocs, axes=topo_ax, colorbar=False) + if index < len(titles): + topo_ax.set_title(str(titles[index])) + + +def _finish_axes(ax: Axes, lines: list[Any], options: dict[str, Any]) -> None: + if options["plotmode"] == "topo": + low, high = ax.get_ylim() + left, right = ax.get_xlim() + ax.text(left + 0.1 * (right - left), high - 0.2 * (high - low), str(options["title"])) + ax.axvline(0.0, color="k", linewidth=1) + ax.axhline(0.0, color="k", linewidth=1) + ax.set_axis_off() + setattr(ax, "_eegprep_plotcurve_metadata", (options["xlabel"], options["ylabel"], options["legend"])) + return + ax.set_title(str(options["title"])) + if options["maskarray"] is None or not np.asarray(options["maskarray"]).size: + ax.set_xlabel(str(options["xlabel"])) + ax.set_ylabel(str(options["ylabel"])) + labels = tuple(str(value) for value in options["legend"]) + if labels: + ax.legend(lines[: len(labels)], labels, loc="lower right") + if options["logpval"]: + ticks = ax.get_yticks() + ax.set_yticks(ticks, np.round(10.0 ** (-ticks) * 1000.0) / 1000.0) + ax.invert_yaxis() + + +def _style_parts(value: Any) -> tuple[Any, str]: + if not isinstance(value, str) or len(value) <= 1: + return value, "-" + if value[0] in "rgbcmykw" and value[1:] in {"-", "--", ":", "-."}: + return value[0], value[1:] + return value, "-" + + +def _is_on(value: Any) -> bool: + if isinstance(value, str): + return value.lower() == "on" + return bool(value) + + +__all__ = ["DEFAULT_CURVE_COLORS", "plotcurve"] diff --git a/src/eegprep/functions/sigprocfunc/sbplot.py b/src/eegprep/functions/sigprocfunc/sbplot.py new file mode 100644 index 00000000..c5cde45b --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/sbplot.py @@ -0,0 +1,107 @@ +"""Create axes spanning arbitrary positions in an EEGLAB-style subplot grid.""" + +from __future__ import annotations + +from typing import Any + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.axes import Axes + + +DEFAULT_AXES_POSITION = (0.13, 0.11, 0.775, 0.815) + + +def sbplot( + rows: int, + columns: int, + grid_position: int | tuple[int, int] | list[int], + *properties: Any, + ax: Axes | None = None, + **kwargs: Any, +) -> Axes: + """Create an axes in one tile or across two corner tiles. + + Unlike :func:`matplotlib.pyplot.subplot`, underlying axes are retained. + A two-element ``grid_position`` describes opposite corners using MATLAB's + one-based, row-major subplot numbering. + + Args: + rows: Number of grid rows. + columns: Number of grid columns. + grid_position: One tile index or two opposite corner indices. + *properties: Optional EEGLAB-style axes property/value pairs. A leading + ``"ax", axes`` pair uses that axes' bounds as the tiling region. + ax: Existing axes whose bounds define the tiling region. + **kwargs: Matplotlib axes properties, such as ``facecolor``. + + Returns: + The newly created axes. + """ + rows, columns = int(rows), int(columns) + if rows < 1 or columns < 1: + raise ValueError("sbplot rows and columns must be positive") + property_items = list(properties) + if len(property_items) >= 2 and str(property_items[0]).lower() == "ax": + if ax is not None and property_items[1] is not ax: + raise ValueError("sbplot received two different parent axes") + ax = property_items[1] + property_items = property_items[2:] + if len(property_items) % 2: + raise ValueError("sbplot axes properties must be property/value pairs") + + corners = np.asarray(grid_position, dtype=int).ravel() + if corners.size not in {1, 2}: + raise ValueError("sbplot grid_position must contain one index or two corner indices") + if np.any(corners < 1) or np.any(corners > rows * columns): + raise ValueError(f"sbplot indices must be within 1..{rows * columns}") + + figure = ax.figure if ax is not None else plt.gcf() + bounds = ax.get_position().bounds if ax is not None else DEFAULT_AXES_POSITION + position = _grid_bounds(rows, columns, corners, bounds) + created = figure.add_axes(position) + options = _property_options(property_items, kwargs) + if options: + created.set(**options) + return created + + +def _grid_bounds( + rows: int, columns: int, corners: np.ndarray, bounds: tuple[float, float, float, float] +) -> tuple[float, float, float, float]: + left, bottom, width, height = bounds + if columns == 2: + x_space = width * 0.27 / (columns - 0.27) + else: + x_space = 0.9 * width * 0.27 / (columns - 0.9 * 0.27) + if rows == 2: + y_space = height * 0.27 / (rows - 0.27) + else: + y_space = 0.9 * height * 0.27 / (rows - 0.9 * 0.27) + cell_width = (width - x_space * (columns - 1)) / columns + cell_height = (height - y_space * (rows - 1)) / rows + + row_indices = (corners - 1) // columns + column_indices = (corners - 1) % columns + first_column, last_column = int(np.min(column_indices)), int(np.max(column_indices)) + first_row, last_row = int(np.min(row_indices)), int(np.max(row_indices)) + column_span = last_column - first_column + 1 + row_span = last_row - first_row + 1 + lower_row = rows - last_row - 1 + return ( + left + (x_space + cell_width) * first_column, + bottom + (y_space + cell_height) * lower_row - 0.03, + x_space * (column_span - 1) + cell_width * column_span, + y_space * (row_span - 1) + cell_height * row_span, + ) + + +def _property_options(properties: list[Any], kwargs: dict[str, Any]) -> dict[str, Any]: + options = dict(kwargs) + for index in range(0, len(properties), 2): + options[str(properties[index])] = properties[index + 1] + aliases = {"color": "facecolor"} + return {aliases.get(str(key).lower(), str(key).lower()): value for key, value in options.items()} + + +__all__ = ["DEFAULT_AXES_POSITION", "sbplot"] diff --git a/src/eegprep/functions/sigprocfunc/slider.py b/src/eegprep/functions/sigprocfunc/slider.py new file mode 100644 index 00000000..90eb1461 --- /dev/null +++ b/src/eegprep/functions/sigprocfunc/slider.py @@ -0,0 +1,106 @@ +"""Add EEGLAB-style viewport sliders to a Matplotlib figure.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from matplotlib.axes import Axes +from matplotlib.figure import Figure +from matplotlib.widgets import Button, Slider + + +@dataclass +class SliderControls: + """Handles and original layout for controls created by :func:`slider`.""" + + figure: Figure + target_axes: tuple[Axes, ...] + original_positions: tuple[tuple[float, float, float, float], ...] + horizontal: Slider | None + vertical: Slider | None + dismiss: Button + + def remove(self) -> None: + """Restore the original axes positions and remove all viewport controls.""" + for axis, position in zip(self.target_axes, self.original_positions): + axis.set_position(position) + for widget in (self.horizontal, self.vertical, self.dismiss): + if widget is not None and widget.ax in self.figure.axes: + self.figure.delaxes(widget.ax) + if getattr(self.figure, "_eegprep_slider_controls", None) is self: + delattr(self.figure, "_eegprep_slider_controls") + self.figure.canvas.draw_idle() + + +def slider( + handler: Figure, + horiz: bool | int, + vert: bool | int = False, + horizmag: float = 1.0, + vertmag: float = 1.0, + allowsup: bool | int = True, +) -> SliderControls: + """Add horizontal and/or vertical viewport controls to a figure. + + The figure's existing axes are magnified using the same normalized-position + transformation as EEGLAB. Moving a control pans those axes; the ``x`` + button restores their exact original positions and removes the controls. + + Args: + handler: Figure whose axes should be magnified and panned. + horiz: Whether to add a horizontal slider. + vert: Whether to add a vertical slider. + horizmag: Horizontal magnification, at least one. + vertmag: Vertical magnification, at least one. + allowsup: Whether the dismiss button is active. + + Returns: + Handles for programmatic control and removal. + """ + if not isinstance(handler, Figure): + raise TypeError("slider handler must be a Matplotlib Figure") + horizmag, vertmag = float(horizmag), float(vertmag) + if horizmag < 1 or vertmag < 1: + raise ValueError("slider magnification factors must be at least 1") + target_axes = tuple(handler.axes) + original = tuple(axis.get_position().bounds for axis in target_axes) + + horizontal_widget = None + vertical_widget = None + if vert: + vertical_widget = Slider( + handler.add_axes([0.94, 0.15, 0.02, 0.70]), "", 0.0, 1.0, valinit=1.0, orientation="vertical" + ) + if horiz: + horizontal_widget = Slider(handler.add_axes([0.15, 0.04, 0.70, 0.03]), "", 0.0, 1.0, valinit=0.0) + dismiss = Button(handler.add_axes([0.94, 0.04, 0.035, 0.04]), "x") + controls = SliderControls(handler, target_axes, original, horizontal_widget, vertical_widget, dismiss) + + def update(_value: float | None = None) -> None: + horizontal_value = horizontal_widget.val if horizontal_widget is not None else 1.0 + vertical_value = vertical_widget.val if vertical_widget is not None else 1.0 + for axis, (left, bottom, width, height) in zip(target_axes, original): + axis.set_position( + [ + left * horizmag - (horizmag - 1.0) * horizontal_value, + bottom * vertmag - (vertmag - 1.0) * vertical_value, + width * horizmag, + height * vertmag, + ] + ) + handler.canvas.draw_idle() + + if horizontal_widget is not None: + horizontal_widget.on_changed(update) + if vertical_widget is not None: + vertical_widget.on_changed(update) + if allowsup: + dismiss.on_clicked(lambda _event: controls.remove()) + else: + dismiss.set_active(False) + setattr(handler, "_eegprep_slider_controls", controls) + update() + return controls + + +__all__ = ["SliderControls", "slider"] diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index 8d1abe62..a7874cdd 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -440,7 +440,6 @@ def test_headplot_setup_file_can_be_reused_for_sample_data(sample_eeg, tmp_path) plt.close(figure) -@eeglab_test(_HEADPLOT_SOURCE, "test_pass_wireframe") def test_headplot_setup_plotmeshonly_and_orilocs_options(sample_eeg, tmp_path): transform = [0, -10, 0, -0.1, 0, -1.6, 1100, 1100, 1100] preview_file = tmp_path / "preview.spl" @@ -468,6 +467,24 @@ def test_headplot_setup_plotmeshonly_and_orilocs_options(sample_eeg, tmp_path): np.testing.assert_allclose(spline.new_electrodes, np.column_stack([spline.xe, spline.ye, spline.ze])) +@eeglab_test(_HEADPLOT_SOURCE, "test_pass_wireframe") +def test_headplot_lighting_off_draws_wireframe_edges(sample_eeg, tmp_path): + splinefile = headplot_setup( + sample_eeg["chanlocs"], + tmp_path / "wireframe.spl", + chaninfo=sample_eeg["chaninfo"], + transform=[0, -10, 0, -0.1, 0, -1.6, 1100, 1100, 1100], + ) + values = np.nanmean(np.asarray(sample_eeg["data"], dtype=float), axis=1) + + figure = headplot(values, splinefile, lighting="off", electrodes="off") + mesh = figure.axes[0].collections[0] + + assert np.any(np.asarray(mesh.get_linewidths()) > 0) + assert np.asarray(mesh.get_edgecolors()).size > 0 + plt.close(figure) + + def test_pop_headplot_setup_reuses_existing_spline_file(sample_eeg, tmp_path): eeg = deepcopy(sample_eeg) splinefile = tmp_path / "existing.spl" diff --git a/tests/test_sigprocfunc_plotting_eeglab_tests.py b/tests/test_sigprocfunc_plotting_eeglab_tests.py new file mode 100644 index 00000000..96176ea5 --- /dev/null +++ b/tests/test_sigprocfunc_plotting_eeglab_tests.py @@ -0,0 +1,329 @@ +"""Behavioral ports of the current EEGLAB low-level plotting wrappers.""" + +from __future__ import annotations + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np + +from eegprep.functions.sigprocfunc.cbar import cbar +from eegprep.functions.sigprocfunc.copyaxis import copyaxis +from eegprep.functions.sigprocfunc.forcelocs import forcelocs +from eegprep.functions.sigprocfunc.headplot import headplot +from eegprep.functions.sigprocfunc.plotcurve import plotcurve +from eegprep.functions.sigprocfunc.sbplot import DEFAULT_AXES_POSITION, sbplot +from eegprep.functions.sigprocfunc.slider import slider +from tests.eeglab_tests import eeglab_test + + +SIGPROC = "unittesting_sigprocfunc" + + +def _source(name: str) -> str: + return f"{SIGPROC}/{name}/sigprocfunc_{name}_wrapperTest.m" + + +@eeglab_test(_source("cbar"), "test_pass_general") +def test_cbar_default_is_a_tagged_vertical_full_colormap() -> None: + figure, _source_axes = plt.subplots() + + axes = cbar() + + assert axes.get_gid() == "cbar" + assert axes.images[0].get_array().shape == (plt.get_cmap().N, 1, 4) + assert not axes.get_xticks().size + assert axes.yaxis.get_ticks_position() == "right" + plt.close(figure) + + +@eeglab_test(_source("cbar"), "test_pass_horiz") +def test_cbar_horizontal_moves_the_source_axes_and_draws_full_colormap() -> None: + figure, source = plt.subplots() + before = source.get_position().bounds + + axes = cbar("horiz") + + assert axes.images[0].get_array().shape == (1, plt.get_cmap().N, 4) + assert source.get_position().height < before[3] + assert axes.get_position().width == source.get_position().width + assert not axes.get_yticks().size + plt.close(figure) + + +@eeglab_test(_source("cbar"), "test_pass_horiz_color") +def test_cbar_horizontal_can_show_a_partial_one_based_color_range() -> None: + figure, _source_axes = plt.subplots() + + axes = cbar("horiz", np.arange(33, 65)) + + rgba = np.asarray(axes.images[0].get_array()) + expected = plt.get_cmap()(np.linspace(0.0, 1.0, plt.get_cmap().N))[32:64] + assert rgba.shape == (1, 32, 4) + np.testing.assert_allclose(rgba[0], expected) + plt.close(figure) + + +@eeglab_test(_source("cbar"), "test_pass_vert") +def test_cbar_vertical_does_not_resize_the_source_axes() -> None: + figure, source = plt.subplots() + before = source.get_position().bounds + + axes = cbar("vert") + + np.testing.assert_allclose(source.get_position().bounds, before) + assert axes.get_position().x0 > source.get_position().x1 + assert axes.images[0].get_array().shape[1] == 1 + plt.close(figure) + + +@eeglab_test(_source("cbar"), "test_pass_vert_color") +def test_cbar_vertical_partial_range_preserves_order_and_value_ticks() -> None: + figure, _source_axes = plt.subplots() + + axes = cbar("vert", np.arange(33, 65), minmax=(-0.25, 0.75), grad=5) + + rgba = np.asarray(axes.images[0].get_array()) + expected = plt.get_cmap()(np.linspace(0.0, 1.0, plt.get_cmap().N))[32:64] + np.testing.assert_allclose(rgba[:, 0], expected) + np.testing.assert_allclose(axes.get_yticks(), np.linspace(0.0, 1.0, 5)) + assert [label.get_text() for label in axes.get_yticklabels()] == ["-0.25", "0.0", "0.25", "0.5", "0.75"] + plt.close(figure) + + +@eeglab_test(_source("copyaxis"), "test_pass_no_arg") +def test_copyaxis_without_arguments_copies_current_scientific_plot() -> None: + source_figure, source = plt.subplots() + source.plot([0, 1, 2], [2, 1, 3], "o--", label="Pz") + source.set(xlabel="Time (s)", ylabel="Amplitude (µV)", title="ERP", xlim=(-1, 3), ylim=(-2, 4)) + source.legend() + plt.sca(source) + + copied_figure = copyaxis() + copied = copied_figure.axes[0] + + np.testing.assert_array_equal(copied.lines[0].get_xdata(), source.lines[0].get_xdata()) + np.testing.assert_array_equal(copied.lines[0].get_ydata(), source.lines[0].get_ydata()) + assert copied.lines[0].get_linestyle() == "--" + assert copied.get_xlabel() == "Time (s)" + assert copied.get_ylabel() == "Amplitude (µV)" + assert copied.get_title() == "ERP" + np.testing.assert_allclose(copied.get_xlim(), (-1, 3)) + assert [text.get_text() for text in copied.get_legend().get_texts()] == ["Pz"] + plt.close(source_figure) + plt.close(copied_figure) + + image_figure, image_axes = plt.subplots() + image_axes.imshow([[1.0, 2.0], [3.0, 4.0]], cmap="turbo", origin="lower") + copied_image_figure = copyaxis(source=image_axes) + np.testing.assert_array_equal(copied_image_figure.axes[0].images[0].get_array(), [[1.0, 2.0], [3.0, 4.0]]) + assert copied_image_figure.axes[0].images[0].get_cmap().name == "turbo" + plt.close(image_figure) + plt.close(copied_image_figure) + + +def _x_rotation_locs() -> list[dict[str, float | str]]: + return [ + {"labels": "a", "X": -np.sqrt(2) / 2, "Y": np.sqrt(2) / 2, "Z": 0.0}, + {"labels": "b", "X": 1.0, "Y": 0.0, "Z": 0.0}, + {"labels": "c", "X": 0.0, "Y": -1.0, "Z": 0.0}, + {"labels": "d", "X": np.sqrt(2) / 2, "Y": -np.sqrt(2) / 2, "Z": 0.0}, + ] + + +@eeglab_test(_source("forcelocs"), "test_pass_x") +def test_forcelocs_rotates_xz_plane_and_refreshes_all_coordinate_systems() -> None: + original = _x_rotation_locs() + + result = forcelocs(original, (-0.5, "x", "b")) + + expected = np.asarray( + [ + [np.sqrt(2) / 4, np.sqrt(2) / 2, -np.sqrt(6) / 4], + [-0.5, 0.0, np.sqrt(12) / 4], + [0.0, -1.0, 0.0], + [-np.sqrt(2) / 4, -np.sqrt(2) / 2, np.sqrt(6) / 4], + ] + ) + np.testing.assert_allclose([[loc["X"], loc["Y"], loc["Z"]] for loc in result], expected, atol=1e-12) + np.testing.assert_allclose([loc["sph_radius"] for loc in result], 1.0, atol=1e-12) + assert result[1]["theta"] == -180.0 + assert original[1]["X"] == 1.0 + + +@eeglab_test(_source("forcelocs"), "test_pass_y") +def test_forcelocs_rotates_yz_plane_and_matches_eeglab_expected_montage() -> None: + original = _x_rotation_locs() + original[0].update({"X": -0.5, "Y": 0.5, "Z": np.sqrt(2) / 2}) + + result = forcelocs(original, (1.0, "y", "a")) + + expected = np.asarray( + [ + [-0.5, np.sqrt(12) / 4, 0.0], + [1.0, 0.0, 0.0], + [0.0, -np.sqrt(1 / 3), np.sqrt(2 / 3)], + [np.sqrt(2) / 2, -np.sqrt(2 / 3) / 2, np.sqrt(1 / 3)], + ] + ) + np.testing.assert_allclose([[loc["X"], loc["Y"], loc["Z"]] for loc in result], expected, atol=1e-12) + np.testing.assert_allclose([result[0]["theta"], result[0]["radius"]], [-120.0, 0.5], atol=1e-12) + np.testing.assert_allclose([result[2]["sph_theta"], result[2]["sph_phi"]], [-90.0, 54.735610317245346]) + + +@eeglab_test(_source("plotcurve"), "test_test_plotcurve") +def test_plotcurve_current_wrapper_cases_have_observable_curve_and_mask_behavior() -> None: + times = np.arange(-4.0, 5.01, 0.01) + x = np.arange(1.0, 10.01, 0.01) + data = np.vstack([x, np.sin(x * 1.7), np.exp(x) / np.exp(10), np.cos(x), np.sin(x)]) + + figure1, axis1 = plt.subplots() + plotcurve(times, data, target=axis1) + assert len(axis1.lines) == 5 + np.testing.assert_allclose(axis1.lines[0].get_ydata(), x) + + figure2, axis2 = plt.subplots() + plotcurve(times, data.T, target=axis2) + np.testing.assert_allclose(axis2.lines[4].get_ydata(), np.sin(x)) + + figure3, axis3 = plt.subplots() + plotcurve( + times, + data, + target=axis3, + xlabel="Time point /ms", + ylabel="EP", + legend=["linear", "oscillation", "exponential", "cos", "sin"], + title="Unit testing", + vert=[-3, -1.6, 2.2345], + linewidth=1, + ) + assert axis3.get_title() == "Unit testing" + assert axis3.get_xlabel() == "Time point /ms" + assert axis3.get_ylabel() == "EP" + assert [text.get_text() for text in axis3.get_legend().get_texts()][0] == "linear" + magenta = [line for line in axis3.lines if line.get_color() == "m" and np.asarray(line.get_xdata()).size == 2] + np.testing.assert_allclose([line.get_xdata()[0] for line in magenta], [-3, -1.6, 2.2345]) + + figure4, axis4 = plt.subplots() + plotcurve(times, data, target=axis4, maskarray=[-0.001, 0.001]) + assert axis4.patches + assert any(patch.get_x() + patch.get_width() > 4.0 for patch in axis4.patches) + + figure5, axis5 = plt.subplots() + plotcurve(times, data, target=axis5, val2mask=0.5) + assert not axis5.patches + + figure6, axis6 = plt.subplots() + plotcurve(times, data, target=axis6, plotmean="on") + assert len(axis6.lines) == 6 + assert axis6.lines[-1].get_color() == "k" + assert axis6.lines[-1].get_linewidth() == 2 + np.testing.assert_allclose(axis6.lines[-1].get_ydata(), np.mean(data, axis=0)) + + figure7, axis7 = plt.subplots() + single = 0.8 * np.sin(x) + plotcurve(times, single, target=axis7, maskarray=[-0.5, 0.5], highlightmode="background") + assert len(axis7.lines) == 1 + assert len(axis7.patches) >= 2 + + figure8, axis8 = plt.subplots() + chanlocs = [{"labels": name} for name in ("Fz", "Cz", "Pz", "Oz", "Iz")] + plotcurve(times, data, target=axis8, chanlocs=chanlocs) + assert len(axis8.lines) == len(chanlocs) + np.testing.assert_allclose(axis8.get_xlim(), (times[0], times[-1])) + for figure in (figure1, figure2, figure3, figure4, figure5, figure6, figure7, figure8): + plt.close(figure) + + +@eeglab_test(_source("sbplot"), "test_test_sbplot") +def test_sbplot_current_wrapper_cases_span_grid_and_honor_properties_and_parent() -> None: + figure = plt.figure() + sixth = sbplot(3, 3, 6) + spanning = sbplot(3, 3, [7, 2]) + forty_seventh = sbplot(8, 7, 47) + + assert sixth in figure.axes and spanning in figure.axes and forty_seventh in figure.axes + assert spanning.get_position().width > sixth.get_position().width + assert spanning.get_position().height > sixth.get_position().height + assert forty_seventh.get_position().width < sixth.get_position().width + np.testing.assert_allclose(DEFAULT_AXES_POSITION, (0.13, 0.11, 0.775, 0.815)) + plt.close(figure) + + color_figure = plt.figure() + colored = sbplot(3, 3, 3, "Color", "r") + np.testing.assert_allclose(colored.get_facecolor(), (1.0, 0.0, 0.0, 1.0)) + plt.close(color_figure) + + parent_figure = plt.figure() + parent = parent_figure.add_axes([0.1, 0.1, 0.8, 0.6]) + nested = sbplot(3, 3, 3, "ax", parent) + assert nested.figure is parent_figure + assert nested.get_position().x0 >= parent.get_position().x0 + assert nested.get_position().x1 <= parent.get_position().x1 + 1e-12 + plt.close(parent_figure) + + +@eeglab_test(_source("slider"), "test_test_slider") +def test_slider_current_wrapper_cases_create_controls_and_pan_magnified_axes() -> None: + figure1, axis1 = plt.subplots() + original1 = axis1.get_position().bounds + controls1 = slider(figure1, 0, 0) + assert controls1.horizontal is None and controls1.vertical is None + np.testing.assert_allclose(axis1.get_position().bounds, original1) + + figure2, axis2 = plt.subplots() + controls2 = slider(figure2, 0, 1) + assert controls2.horizontal is None and controls2.vertical is not None + controls2.vertical.set_val(0.25) + np.testing.assert_allclose(axis2.get_position().bounds, controls2.original_positions[0]) + + figure3, axis3 = plt.subplots() + controls3 = slider(figure3, 1, 0) + assert controls3.horizontal is not None and controls3.vertical is None + controls3.horizontal.set_val(0.75) + np.testing.assert_allclose(axis3.get_position().bounds, controls3.original_positions[0]) + + figure4, axis4 = plt.subplots() + controls4 = slider(figure4, 1, 1, 1.2, 1.2, 0) + original4 = controls4.original_positions[0] + initial4 = axis4.get_position().bounds + np.testing.assert_allclose(initial4[2:], np.asarray(original4[2:]) * 1.2) + assert controls4.dismiss.active is False + controls4.horizontal.set_val(1.0) + assert axis4.get_position().x0 < initial4[0] + controls4.remove() + np.testing.assert_allclose(axis4.get_position().bounds, original4) + assert len(figure4.axes) == 1 + for figure in (figure1, figure2, figure3, figure4): + plt.close(figure) + + +@eeglab_test(_source("headplot"), "test_pass_cartesian") +def test_headplot_cartesian_command_returns_and_prints_parseable_example(capsys) -> None: + before = set(plt.get_fignums()) + + text = headplot("cartesian") + + assert capsys.readouterr().out == text + assert "chan_num x y z" in text + fields = text.splitlines()[3].split() + assert fields[:4] == ["1", "0.4528", "0.8888", "-0.0694"] + assert set(plt.get_fignums()) == before + + +@eeglab_test(_source("headplot"), "test_pass_example") +def test_headplot_example_command_returns_and_prints_spherical_table(capsys) -> None: + before = set(plt.get_fignums()) + + text = headplot("example") + + assert capsys.readouterr().out == text + assert "chan_num cor_deg horiz_deg" in text + rows = [line.split() for line in text.splitlines() if line.strip() and line.strip()[0].isdigit()] + assert len(rows) == 21 + assert rows[0][:4] == ["1", "-90", "-72", "Fp1."] + assert rows[-1][:4] == ["21", "45", "-90", "Pz.."] + assert set(plt.get_fignums()) == before From 7d7e71c5daf2dfb49f7d6006f40d98fb8e59fcbd Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:55 -0700 Subject: [PATCH 35/45] feat: add CORRMAP component (#363) --- docs/source/api/study_workflows.rst | 8 + docs/source/changelog.rst | 5 + src/eegprep/__init__.py | 2 + src/eegprep/functions/studyfunc/corrmap.py | 401 ++++++++++++++++++ .../functions/studyfunc/pop_corrmap.py | 37 ++ tests/test_study_workflows_eeglab_tests.py | 108 +++++ 6 files changed, 561 insertions(+) create mode 100644 src/eegprep/functions/studyfunc/corrmap.py create mode 100644 src/eegprep/functions/studyfunc/pop_corrmap.py diff --git a/docs/source/api/study_workflows.rst b/docs/source/api/study_workflows.rst index 85b5ad9e..caf51246 100644 --- a/docs/source/api/study_workflows.rst +++ b/docs/source/api/study_workflows.rst @@ -31,9 +31,17 @@ Study Setup and Validation Clustering ---------- +``corrmap`` performs two-pass, polarity-invariant component matching against a +template scalp map. Its average templates are RMS-normalized so arbitrary ICA +column scale cannot bias which components are selected on the second pass. +Datasets must use the same labelled montage; use ``std_interp`` first when +montages differ. ``pop_corrmap`` is the history-producing scripted wrapper. + .. autosummary:: :toctree: generated/ + eegprep.corrmap + eegprep.pop_corrmap eegprep.std_apcluster eegprep.std_centroid eegprep.std_clustplot diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index ce6637e7..3eea2050 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,11 @@ the `GitHub Releases `_ page. Unreleased ========== +- Added standalone ``corrmap`` and ``pop_corrmap`` component-template + matching for STUDY workflows. Two-pass correlation handles ICA polarity, + searches deterministic automatic thresholds, can store matched bad + components and child clusters, and RMS-normalizes average maps so arbitrary + ICA scaling cannot change the second-pass template. - ``statcond`` now accepts EEGLAB's ``arraycomp`` switch. Batched mode returns all requested resampling grids, while off mode exposes one compatibility grid or computes inference iteratively to bound peak memory without changing diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index 78bf532b..a580878b 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -86,6 +86,7 @@ "compvar": ("eegprep.functions.sigprocfunc.ica_helpers", "compvar"), "convertlocs": ("eegprep.functions.sigprocfunc.convertlocs", "convertlocs"), "copyaxis": ("eegprep.functions.sigprocfunc.copyaxis", "copyaxis"), + "corrmap": ("eegprep.functions.studyfunc.corrmap", "corrmap"), "covary": ("eegprep.functions.miscfunc.covary", "covary"), "coregister": ("eegprep.functions.sigprocfunc.coregister", "coregister"), "decodechan": ("eegprep.functions.redefine_functions", "decodechan"), @@ -284,6 +285,7 @@ "pop_clustedit": ("eegprep.functions.studyfunc.pop_clustedit", "pop_clustedit"), "pop_compareerps": ("eegprep.functions.popfunc.pop_compareerps", "pop_compareerps"), "pop_copyset": ("eegprep.functions.popfunc.pop_copyset", "pop_copyset"), + "pop_corrmap": ("eegprep.functions.studyfunc.pop_corrmap", "pop_corrmap"), "pop_crossf": ("eegprep.functions.popfunc.pop_crossf", "pop_crossf"), "pop_delset": ("eegprep.functions.adminfunc.pop_delset", "pop_delset"), "pop_dipfit_gridsearch": ("eegprep.plugins.dipfit.pop_dipfit_gridsearch", "pop_dipfit_gridsearch"), diff --git a/src/eegprep/functions/studyfunc/corrmap.py b/src/eegprep/functions/studyfunc/corrmap.py new file mode 100644 index 00000000..4ce685b9 --- /dev/null +++ b/src/eegprep/functions/studyfunc/corrmap.py @@ -0,0 +1,401 @@ +"""Match STUDY ICA components to a template scalp map by correlation.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc._chanutils import chanlocs_as_list +from eegprep.functions.popfunc._pop_utils import is_on, parse_key_value_args +from eegprep.functions.popfunc.plot_utils import component_map_data +from eegprep.functions.studyfunc._cluster_utils import ensure_parent_cluster, rows_for_cluster +from eegprep.functions.studyfunc._study_utils import as_alleeg_list +from eegprep.functions.studyfunc.std_checkset import std_checkset +from eegprep.functions.studyfunc.std_createclust import std_createclust +from eegprep.functions.studyfunc.std_findsameica import std_findsameica + + +AUTO_THRESHOLDS = tuple(value / 100.0 for value in range(95, 54, -1)) + + +def corrmap( + STUDY: dict[str, Any] | None, + ALLEEG: Any, + n_tmp: int, + index: int, + *args: Any, + **kwargs: Any, +) -> tuple[dict[str, Any], dict[str, Any], list[dict[str, Any]]]: + """Find ICA maps correlated with one template component. + + The calculation follows CORRMAP's two-pass procedure: match every unique + ICA decomposition to the requested component, build a polarity-aligned + average map, then match again to that average. Automatic mode evaluates + thresholds from 0.95 through 0.55 and chooses the most self-consistent + pair of averages. + + Args: + STUDY: EEGPrep STUDY dictionary. + ALLEEG: Loaded EEG datasets. + n_tmp: One-based template dataset index in ``ALLEEG``. + index: One-based template ICA component. + *args: EEGLAB-style option/value pairs. + **kwargs: Options ``th``, ``ics``, ``clname``, ``badcomps``, + ``resetclusters``, ``chanlocs``, ``title``, ``pl``, and ``plot``. + + Returns: + ``(CORRMAP, STUDY, ALLEEG)`` with correlations, selected components, + polarity-aligned averages, and any requested cluster updates. + + Notes: + Component maps are RMS-normalized before averaging. Correlation is + scale invariant, and the normalization prevents arbitrary ICA column + scaling from weighting the second-pass template. + """ + options = _options(args, kwargs) + datasets = as_alleeg_list(ALLEEG) + if not datasets: + raise ValueError("corrmap requires loaded ALLEEG datasets") + template_dataset = _one_based(n_tmp, len(datasets), "template dataset") + if options["plot"]: + raise NotImplementedError("CORRMAP summary plotting is not implemented; use plot='off'") + if _has_value(options["chanlocs"]): + raise NotImplementedError( + "CORRMAP channel interpolation is not implemented; align montages with std_interp first" + ) + + study, datasets = std_checkset(STUDY, datasets) + groups, _group_indices = std_findsameica(datasets) + dataset_indices = [group[0] for group in groups] + selected_datasets = [datasets[value - 1] for value in dataset_indices] + template_group = next( + (group_index for group_index, group in enumerate(groups) if template_dataset in group), + None, + ) + if template_group is None: + raise ValueError("template dataset has no ICA decomposition") + + maps, chanlocs = _aligned_component_maps(selected_datasets) + template_component = _one_based(index, maps[template_group].shape[1], "template component") + template = maps[template_group][:, template_component - 1] + _validate_template(template) + match_count = options["ics"] + if any(values.shape[1] < match_count for values in maps): + raise ValueError("corrmap ics cannot exceed the component count of any selected dataset") + + if options["threshold"] == "auto": + candidates = [] + for threshold in AUTO_THRESHOLDS: + result = _two_pass(maps, template, template_group, template_component - 1, match_count, threshold) + if result is not None: + candidates.append((result["similarity"], threshold, result)) + if not candidates: + raise ValueError("No ICA components exceed the lowest automatic CORRMAP threshold (0.55)") + _similarity, threshold, selected = max(candidates, key=lambda item: (item[0], item[1])) + else: + threshold = float(options["threshold"]) + selected = _two_pass(maps, template, template_group, template_component - 1, match_count, threshold) + if selected is None: + raise ValueError(f"No ICA components exceed the CORRMAP threshold ({threshold:g})") + + first, second = selected["passes"] + info = _result_structure( + datasets, + dataset_indices, + template_dataset, + template_component, + chanlocs, + options, + threshold, + first, + second, + selected["similarity"], + ) + output_datasets = deepcopy(datasets) + if options["badcomps"]: + _store_bad_components(output_datasets, dataset_indices, second) + if options["clname"]: + study = _store_cluster(study, output_datasets, dataset_indices, second, options) + study["saved"] = "no" + return info, study, output_datasets + + +def _options(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: + supplied = parse_key_value_args(args, kwargs, lowercase_kwargs=True) + allowed = {"chanlocs", "th", "ics", "pl", "resetclusters", "plot", "title", "clname", "badcomps"} + unknown = set(supplied) - allowed + if unknown: + raise ValueError(f"Unknown corrmap option(s): {', '.join(sorted(unknown))}") + threshold_value = supplied.get("th", "auto") + if isinstance(threshold_value, str) and threshold_value.strip().lower() == "auto": + threshold: str | float = "auto" + else: + threshold = float(threshold_value) + if not 0.0 < threshold < 1.0: + raise ValueError("corrmap th must be 'auto' or a value strictly between 0 and 1") + ics = int(supplied.get("ics", 2)) + if ics not in {1, 2, 3}: + raise ValueError("corrmap ics must be 1, 2, or 3") + pl = str(supplied.get("pl", "2nd")).lower() + if pl not in {"none", "2nd", "both"}: + raise ValueError("corrmap pl must be 'none', '2nd', or 'both'") + return { + "chanlocs": supplied.get("chanlocs", []), + "threshold": threshold, + "ics": ics, + "pl": pl, + "resetclusters": is_on(supplied.get("resetclusters", False)), + "plot": is_on(supplied.get("plot", False)), + "title": str(supplied.get("title") or ""), + "clname": str(supplied.get("clname") or ""), + "badcomps": is_on(supplied.get("badcomps", False)), + } + + +def _one_based(value: Any, maximum: int, label: str) -> int: + number = int(value) + if number != value or number < 1 or number > maximum: + raise ValueError(f"corrmap {label} must be 1-based and within 1..{maximum}") + return number + + +def _aligned_component_maps(datasets: list[dict[str, Any]]) -> tuple[list[np.ndarray], list[dict[str, Any]]]: + first_maps, first_locs = component_map_data(datasets[0]) + reference_labels = _labels(first_locs) + output = [np.asarray(first_maps, dtype=float)] + for eeg in datasets[1:]: + values, locs = component_map_data(eeg) + values = np.asarray(values, dtype=float) + labels = _labels(locs) + if reference_labels and labels: + if len(set(labels)) != len(labels) or len(set(reference_labels)) != len(reference_labels): + raise ValueError("corrmap requires unique channel labels") + lookup = {label: position for position, label in enumerate(labels)} + if set(lookup) != set(reference_labels): + raise ValueError("corrmap requires the same channel montage in every ICA decomposition") + values = values[[lookup[label] for label in reference_labels], :] + elif values.shape[0] != first_maps.shape[0]: + raise ValueError("corrmap requires equal component-map channel counts") + output.append(values) + if any(not np.isfinite(values).all() for values in output): + raise ValueError("corrmap component maps must contain only finite values") + return output, chanlocs_as_list(first_locs) + + +def _labels(chanlocs: list[dict[str, Any]]) -> list[str]: + labels = [str(loc.get("labels") or "").strip().casefold() for loc in chanlocs] + return labels if labels and all(labels) else [] + + +def _validate_template(template: np.ndarray) -> None: + if template.size < 2 or not np.isfinite(template).all() or np.allclose(template, template[0]): + raise ValueError("corrmap template component must be finite and spatially nonconstant") + + +def _two_pass( + maps: list[np.ndarray], + template: np.ndarray, + template_dataset: int, + template_component: int, + ics: int, + threshold: float, +) -> dict[str, Any] | None: + first = _match_pass(maps, template, ics, threshold, exclude=(template_dataset, template_component)) + if first is None: + return None + second = _match_pass(maps, first["average"], ics, threshold) + if second is None: + return None + similarity = abs(_correlation(first["average"], second["average"])) + return {"passes": (first, second), "similarity": similarity} + + +def _match_pass( + maps: list[np.ndarray], + template: np.ndarray, + ics: int, + threshold: float, + *, + exclude: tuple[int, int] | None = None, +) -> dict[str, Any] | None: + candidates = [] + for dataset_index, values in enumerate(maps): + correlations = np.asarray( + [_correlation(template, values[:, component]) for component in range(values.shape[1])] + ) + if exclude is not None and exclude[0] == dataset_index: + correlations[exclude[1]] = 0.0 + order = np.argsort(-np.abs(correlations), kind="stable")[:ics] + for component in order: + correlation = float(correlations[component]) + candidates.append((abs(correlation), dataset_index, int(component), correlation)) + candidates.sort(key=lambda item: -item[0]) + absolute = np.asarray([item[0] for item in candidates]) + candidate_sets = np.asarray([item[1] + 1 for item in candidates], dtype=int) + candidate_components = np.asarray([item[2] + 1 for item in candidates], dtype=int) + signed = np.asarray([item[3] for item in candidates]) + selected_count = int(np.count_nonzero(absolute > threshold)) + if selected_count == 0: + return None + selected_candidates = candidates[:selected_count] + selected_absolute = absolute[:selected_count] + sets = candidate_sets[:selected_count] + components = candidate_components[:selected_count] + selected_signed = signed[:selected_count] + oriented = [] + for _absolute, dataset_index, component, correlation in selected_candidates: + values = maps[dataset_index][:, component] + rms = float(np.sqrt(np.mean(values**2))) + if rms == 0.0: + continue + oriented.append(values / rms * (1.0 if correlation >= 0.0 else -1.0)) + if not oriented: + return None + counts = np.bincount(sets, minlength=len(maps) + 1)[1:] + return { + "abs_values": absolute, + "signed_values": signed, + "candidate_sets": candidate_sets, + "candidate_ics": candidate_components, + "sets": sets, + "ics": components, + "polarity": np.where(selected_signed < 0.0, -1, 1), + "average": np.mean(np.vstack(oriented), axis=0), + "mean_corr": float(np.tanh(np.mean(np.arctanh(np.clip(selected_absolute, 0.0, 1.0 - 1e-12))))), + "dataset_counts": counts, + } + + +def _correlation(left: np.ndarray, right: np.ndarray) -> float: + left_centered = np.asarray(left, dtype=float) - float(np.mean(left)) + right_centered = np.asarray(right, dtype=float) - float(np.mean(right)) + denominator = float(np.linalg.norm(left_centered) * np.linalg.norm(right_centered)) + if denominator == 0.0: + return 0.0 + return float(np.clip(np.dot(left_centered, right_centered) / denominator, -1.0, 1.0)) + + +def _result_structure( + datasets: list[dict[str, Any]], + dataset_indices: list[int], + template_dataset: int, + template_component: int, + chanlocs: list[dict[str, Any]], + options: dict[str, Any], + threshold: float, + first: dict[str, Any], + second: dict[str, Any], + similarity: float, +) -> dict[str, Any]: + passes = (first, second) + absent = [(np.flatnonzero(values["dataset_counts"] == 0) + 1).astype(int) for values in passes] + return { + "datasetindices": np.asarray(dataset_indices, dtype=int), + "template": { + "setname": str(datasets[template_dataset - 1].get("setname") or ""), + "index": template_dataset, + "ic": template_component, + }, + "datasets": { + "setnames": [str(datasets[value - 1].get("setname") or "") for value in dataset_indices], + "index": np.arange(1, len(dataset_indices) + 1, dtype=int), + "ics": np.asarray([np.asarray(datasets[value - 1]["icawinv"]).shape[1] for value in dataset_indices]), + }, + "input": { + "chanlocs": options["chanlocs"], + "corr_th": options["threshold"], + "ics_sel": options["ics"], + "plots": options["pl"], + "title": options["title"], + "clname": options["clname"], + "badcomps": "yes" if options["badcomps"] else "no", + }, + "corr": { + "abs_values": [values["abs_values"] for values in passes], + "signed_values": [values["signed_values"] for values in passes], + "sets": [values["candidate_sets"] for values in passes], + "ics": [values["candidate_ics"] for values in passes], + }, + "clust": { + "best_th": threshold, + "ics": np.asarray([values["ics"].size for values in passes], dtype=int), + "sets": { + "number": np.asarray([np.count_nonzero(values["dataset_counts"]) for values in passes], dtype=int), + "more_oneIC": np.asarray( + [np.count_nonzero(values["dataset_counts"] > 1) for values in passes], dtype=int + ), + "absent": absent, + }, + "mean_corr": np.asarray([values["mean_corr"] for values in passes]), + "similarity": similarity, + }, + "output": { + "chanlocs": deepcopy(chanlocs), + "average_plot": [values["average"] for values in passes], + "sets": [values["sets"] for values in passes], + "ics": [values["ics"] for values in passes], + "polarity": [values["polarity"] for values in passes], + }, + } + + +def _store_bad_components(datasets: list[dict[str, Any]], dataset_indices: list[int], selected: dict[str, Any]) -> None: + for selected_set, component in zip(selected["sets"], selected["ics"], strict=True): + dataset_index = dataset_indices[int(selected_set) - 1] - 1 + existing_value = datasets[dataset_index].get("badcomps") + existing = ( + [int(value) for value in np.asarray(existing_value, dtype=int).ravel()] + if _has_value(existing_value) + else [] + ) + datasets[dataset_index]["badcomps"] = sorted(set([*existing, int(component)])) + + +def _has_value(value: Any) -> bool: + if value is None or isinstance(value, str) and not value: + return False + if isinstance(value, np.ndarray): + return value.size > 0 + if isinstance(value, (list, tuple, dict, set)): + return len(value) > 0 + return True + + +def _store_cluster( + study: dict[str, Any], + datasets: list[dict[str, Any]], + dataset_indices: list[int], + selected: dict[str, Any], + options: dict[str, Any], +) -> dict[str, Any]: + study = deepcopy(study) + if options["resetclusters"]: + study["cluster"] = [] + for dataset_index, info in enumerate(study.get("datasetinfo") or []): + component_count = np.asarray(datasets[dataset_index].get("icaweights", [])).shape[0] + info["comps"] = list(range(1, component_count + 1)) + study = ensure_parent_cluster(study, datasets) + parent_sets, parent_comps = rows_for_cluster(study, datasets, 1) + matched = { + (dataset_indices[int(selected_set) - 1], int(component)) + for selected_set, component in zip(selected["sets"], selected["ics"], strict=True) + } + labels = np.zeros(parent_comps.size, dtype=int) + for row, component in enumerate(parent_comps): + if (int(parent_sets[0, row]), int(component)) in matched: + labels[row] = 1 + if not np.any(labels): + raise ValueError("CORRMAP matches are absent from the STUDY parent cluster") + return std_createclust( + study, + datasets, + clusterind=labels, + algorithm=["correlation (CORRMAP)", float(options["ics"])], + name=options["clname"], + ignore0="on", + ) + + +__all__ = ["AUTO_THRESHOLDS", "corrmap"] diff --git a/src/eegprep/functions/studyfunc/pop_corrmap.py b/src/eegprep/functions/studyfunc/pop_corrmap.py new file mode 100644 index 00000000..b719f812 --- /dev/null +++ b/src/eegprep/functions/studyfunc/pop_corrmap.py @@ -0,0 +1,37 @@ +"""EEGLAB-style wrapper for CORRMAP component matching.""" + +from __future__ import annotations + +from typing import Any + +from eegprep.functions.popfunc._pop_utils import parse_key_value_args +from eegprep.functions.popfunc.plot_utils import python_literal +from eegprep.functions.studyfunc.corrmap import corrmap + + +def pop_corrmap( + STUDY: dict[str, Any] | None, + ALLEEG: Any, + n_tmp: int, + index: int, + *args: Any, + return_com: bool = False, + **kwargs: Any, +) -> Any: + """Run noninteractive CORRMAP matching and optionally return history. + + Scripted calls suppress CORRMAP's summary figures, as in the MATLAB + ``pop_corrmap`` command-line path. Use the returned ``CORRMAP`` dictionary + for correlations, selected components, polarities, and average maps. + """ + options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) + options["plot"] = "off" + result, study, datasets = corrmap(STUDY, ALLEEG, n_tmp, index, **options) + command_options = {key: value for key, value in options.items() if key != "plot"} + pieces = ["STUDY", "ALLEEG", python_literal(n_tmp), python_literal(index)] + pieces.extend(f"{key}={python_literal(value)}" for key, value in command_options.items()) + command = f"CORRMAP, STUDY, ALLEEG = pop_corrmap({', '.join(pieces)})" + return (result, study, datasets, command) if return_com else (result, study, datasets) + + +__all__ = ["pop_corrmap"] diff --git a/tests/test_study_workflows_eeglab_tests.py b/tests/test_study_workflows_eeglab_tests.py index 2eb2facf..26105f3f 100644 --- a/tests/test_study_workflows_eeglab_tests.py +++ b/tests/test_study_workflows_eeglab_tests.py @@ -11,11 +11,14 @@ from matplotlib import pyplot as plt import numpy as np +import pytest from eegprep.functions.popfunc.plot_utils import component_activations from eegprep.functions.popfunc.pop_saveset import pop_saveset from eegprep.functions.studyfunc.pop_clust import pop_clust +from eegprep.functions.studyfunc.pop_corrmap import pop_corrmap from eegprep.functions.studyfunc.pop_study import pop_study +from eegprep.functions.studyfunc.corrmap import corrmap from eegprep.functions.studyfunc.std_editset import std_editset from eegprep.functions.studyfunc.std_erpplot import std_erpplot from eegprep.functions.studyfunc.std_erspplot import std_erspplot @@ -130,6 +133,111 @@ def _study_pair(*, n_channels: int = 4, n_components: int = 3) -> tuple[dict, li return pop_study(None, datasets, name="Generated N400 study") +def _corrmap_study(*, scales: tuple[float, float] = (4.0, 0.2)) -> tuple[dict, list[dict]]: + template = np.array([-2.0, -1.0, -0.2, 0.5, 1.2, 2.0]) + alternating = np.array([1.0, -1.0, 1.0, -1.0, 1.0, -1.0]) + biphasic = np.array([1.0, 0.0, -1.0, 1.0, 0.0, -1.0]) + unrelated = np.array([-0.5, 0.5, 1.0, -1.0, 0.5, -0.5]) + near_template = template + np.array([0.1, -0.05, 0.03, -0.04, 0.02, -0.08]) + inverse_maps = ( + np.column_stack([template, alternating, biphasic]), + np.column_stack([alternating, -scales[0] * template, unrelated]), + np.column_stack([biphasic, unrelated, scales[1] * near_template]), + np.column_stack([alternating, biphasic, unrelated]), + ) + datasets = [] + for dataset_index, maps in enumerate(inverse_maps, start=1): + eeg = _deterministic_eeg( + f"corrmap_{dataset_index}", + f"S{dataset_index:02d}", + "target", + n_channels=6, + n_components=3, + ) + eeg["icawinv"] = maps + eeg["icaweights"] = np.linalg.pinv(maps) + eeg["icasphere"] = np.eye(6) + datasets.append(eeg) + return pop_study(None, datasets, name="Generated CORRMAP study") + + +@_reference("pop_corrmap", "test_test_pop_corrmap") +def test_pop_corrmap_matches_polarity_builds_cluster_and_is_scale_invariant(): + study, alleeg = _corrmap_study() + + result, matched_study, matched_datasets, command = pop_corrmap( + study, + alleeg, + 1, + 1, + "chanlocs", + "", + "th", + "auto", + "ics", + 1, + "title", + "Cluster test2", + "clname", + "test2", + "badcomps", + "yes", + "resetclusters", + "off", + return_com=True, + ) + + second_pairs = dict(zip(result["output"]["sets"][1], result["output"]["ics"][1], strict=True)) + second_polarities = dict(zip(result["output"]["sets"][1], result["output"]["polarity"][1], strict=True)) + # Direct output from CORRMAP 6d1b06e on these maps is first-pass sets + # [2, 3], components [2, 3], then second-pass sets/components [1, 2, 3]. + assert second_pairs == {1: 1, 2: 2, 3: 3} + assert second_polarities == {1: 1, 2: -1, 3: 1} + np.testing.assert_array_equal(result["output"]["sets"][0], [2, 3]) + np.testing.assert_array_equal(result["output"]["ics"][0], [2, 3]) + np.testing.assert_allclose(result["corr"]["abs_values"][0][:3], [1.0, 0.9993692940674627, 0.5046949386828399]) + np.testing.assert_array_equal(result["clust"]["sets"]["absent"][0], [1, 4]) + np.testing.assert_array_equal(result["clust"]["sets"]["absent"][1], [4]) + assert result["clust"]["best_th"] == 0.95 + assert result["clust"]["similarity"] > 0.999 + + child = matched_study["cluster"][1] + assert child["name"] == "test2 1" + assert child["algorithm"][0] == "correlation (CORRMAP)" + assert dict(zip(child["sets"][0], child["comps"], strict=True)) == second_pairs + assert matched_study["cluster"][0]["child"] == ["test2 1"] + assert [eeg.get("badcomps", []) for eeg in matched_datasets] == [[1], [2], [3], []] + assert all("badcomps" not in eeg for eeg in alleeg) + assert command.startswith("CORRMAP, STUDY, ALLEEG = pop_corrmap(") + + scaled_study, scaled_alleeg = _corrmap_study(scales=(400.0, 0.002)) + scaled, _study, _datasets = pop_corrmap(scaled_study, scaled_alleeg, 1, 1, th="auto", ics=1) + np.testing.assert_allclose(result["output"]["average_plot"], scaled["output"]["average_plot"], atol=1e-12) + + +def test_corrmap_aligns_labelled_montages_and_rejects_unsupported_inputs(): + study, alleeg = _corrmap_study() + expected, _study, _datasets = corrmap(study, alleeg, 1, 1, th=0.95, ics=1) + permutation = np.array([5, 3, 1, 4, 2, 0]) + alleeg[1]["icawinv"] = np.asarray(alleeg[1]["icawinv"])[permutation] + alleeg[1]["icaweights"] = np.linalg.pinv(alleeg[1]["icawinv"]) + alleeg[1]["chanlocs"] = [alleeg[1]["chanlocs"][index] for index in permutation] + reordered_study, alleeg = pop_study(None, alleeg, name="Reordered CORRMAP study") + + actual, _study, _datasets = corrmap(reordered_study, alleeg, 1, 1, th=0.95, ics=1) + + np.testing.assert_allclose(actual["corr"]["abs_values"], expected["corr"]["abs_values"], atol=1e-12) + np.testing.assert_allclose(actual["output"]["average_plot"], expected["output"]["average_plot"], atol=1e-12) + with pytest.raises(ValueError, match="strictly between 0 and 1"): + corrmap(study, alleeg, 1, 1, th=1.0) + with pytest.raises(ValueError, match="1, 2, or 3"): + corrmap(study, alleeg, 1, 1, ics=4) + with pytest.raises(ValueError, match="template component"): + corrmap(study, alleeg, 1, 4, th=0.8, ics=1) + with pytest.raises(NotImplementedError, match="summary plotting"): + corrmap(study, alleeg, 1, 1, th=0.8, ics=1, plot="on") + + @_reference("std_editset", "test_test_std_editset") def test_std_editset_loads_generated_sets_assigns_metadata_and_removes_a_dataset(tmp_path: Path): first = _deterministic_eeg("ignore", "", "") From bdde52c97336d0d408f0a075c033330fbb8c27dd Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:56 -0700 Subject: [PATCH 36/45] feat: add statcondfieldtrip backend (#364) --- docs/parity/eeglab_core_parity_matrix.json | 10 +- docs/source/api/statistics.rst | 32 ++ docs/source/changelog.rst | 3 + src/eegprep/functions/statistics/__init__.py | 5 + src/eegprep/functions/statistics/_core.py | 3 + .../functions/statistics/statcondfieldtrip.py | 279 ++++++++++++++++++ tests/test_statcondfieldtrip_eeglab_tests.py | 226 ++++++++++++++ 7 files changed, 553 insertions(+), 5 deletions(-) create mode 100644 src/eegprep/functions/statistics/statcondfieldtrip.py create mode 100644 tests/test_statcondfieldtrip_eeglab_tests.py diff --git a/docs/parity/eeglab_core_parity_matrix.json b/docs/parity/eeglab_core_parity_matrix.json index 12356802..5d54a868 100644 --- a/docs/parity/eeglab_core_parity_matrix.json +++ b/docs/parity/eeglab_core_parity_matrix.json @@ -6759,16 +6759,16 @@ { "eeglab_path": "functions/statistics/statcondfieldtrip.m", "eeglab_name": "statcondfieldtrip", - "eegprep_equivalent": null, + "eegprep_equivalent": "src/eegprep/functions/statistics/statcondfieldtrip.py", "gap_category": "5_statistics_package", - "status": "external_dependency_skip", - "rationale": "Depends on an external MATLAB toolbox, plugin, or web/path integration outside this core parity epic.", - "responsible_phase": "none", + "status": "implemented", + "rationale": "Implemented as a standalone analytic and permutation backend for the maintained paired/unpaired t-test and unpaired one-way ANOVA contract, including pointwise and max-statistic corrections. FieldTrip structure conversion and cluster machinery remain explicit unsupported boundaries.", + "responsible_phase": "phase_3", "user_facing_surface": [ "helper", "statistics" ], - "test_notes": "No standalone parity test required unless a later phase supplies a real Python dependency and reclassifies the row." + "test_notes": "Covered by deterministic SciPy references and the active calls in the current eeglab_tests statcondfieldtrip wrapper; the wrapper's kmean guard typo is not reproduced." }, { "eeglab_path": "functions/statistics/surrogdistrib.m", diff --git a/docs/source/api/statistics.rst b/docs/source/api/statistics.rst index c4571157..993f2d78 100644 --- a/docs/source/api/statistics.rst +++ b/docs/source/api/statistics.rst @@ -24,6 +24,7 @@ at a time. :toctree: generated/ eegprep.functions.statistics.statcond + eegprep.functions.statistics.statcondfieldtrip eegprep.functions.statistics.ttest_cell eegprep.functions.statistics.ttest2_cell eegprep.functions.statistics.anova1_cell @@ -45,6 +46,37 @@ branch forwards ``anova2_cell`` outputs in columns, rows, and interaction order. When translating positional unpaired MATLAB results, EEGPrep's ``rows`` value therefore corresponds to the second MATLAB cell and ``columns`` to the first. +FieldTrip-style inference +------------------------- + +``statcondfieldtrip`` is a standalone Python backend for the scientifically +active behavior in EEGLAB's FieldTrip wrapper. It accepts condition arrays +directly and does not require MATLAB, EEGLAB, or FieldTrip at runtime. Cases +occupy the final axis by default; all preceding feature axes are preserved in +the statistic, p-value, and mask. + +Supported designs are paired or equal-variance unpaired two-condition t-tests +and unpaired one-way ANOVA. Analytic inference and seeded Monte Carlo +permutation inference are available. Multiple-comparison options are ``none``, +``bonferroni``, ``holm``, ``fdr``, and Monte Carlo ``max`` correction; +``bonferoni`` and ``holms`` remain accepted migration spellings. + +As in FieldTrip, Bonferroni, Holm, and FDR leave the reported pointwise +``pvalue`` unchanged and apply their correction to ``mask``. Monte Carlo +probabilities use a plus-one estimate, so finite randomization runs cannot +report zero probability. Max-statistic correction returns family-wise +corrected probabilities and masks; two-condition tests use absolute t values +for conventional two-sided inference. This fulfills the EEGLAB wrapper's +documented two-tailed output contract directly; FieldTrip's internal +``correcttail='alpha'`` representation instead pairs a one-tail probability +with a halved alpha threshold. + +Paired one-way and two-way ANOVA are rejected because the maintained EEGLAB +test disables those FieldTrip paths. Cluster correction and spatial-neighbour +inputs are also rejected: faithful cluster inference requires an explicit +adjacency graph and cluster-forming/statistic policy. Use ``statcond`` for the +supported paired and two-way ANOVA designs without FieldTrip correction. + Multiple Comparisons and Surrogates =================================== diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 3eea2050..a0ce359a 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,9 @@ the `GitHub Releases `_ page. Unreleased ========== +- Added a standalone ``statcondfieldtrip`` backend for paired and unpaired + t-tests and unpaired one-way ANOVA, with analytic or seeded permutation + inference and Bonferroni, Holm, FDR, and max-statistic correction. - Added standalone ``corrmap`` and ``pop_corrmap`` component-template matching for STUDY workflows. Two-pass correlation handles ICA polarity, searches deterministic automatic thresholds, can store matched bad diff --git a/src/eegprep/functions/statistics/__init__.py b/src/eegprep/functions/statistics/__init__.py index fb78a2be..9153adb0 100644 --- a/src/eegprep/functions/statistics/__init__.py +++ b/src/eegprep/functions/statistics/__init__.py @@ -16,6 +16,7 @@ "stat_surrogate_ci", "stat_surrogate_pvals", "statcond", + "statcondfieldtrip", "surrogdistrib", "teststat", "ttest2_cell", @@ -29,6 +30,7 @@ ConcatenatedData = _MODULES["concatdata"].ConcatenatedData FDRResult = _MODULES["fdr"].FDRResult StatcondResult = _MODULES["statcond"].StatcondResult +StatcondFieldtripResult = _MODULES["statcondfieldtrip"].StatcondFieldtripResult SurrogateDistribution = _MODULES["surrogdistrib"].SurrogateDistribution TwoWayAnovaResult = _MODULES["anova2_cell"].TwoWayAnovaResult TwoWayEffects = _MODULES["statcond"].TwoWayEffects @@ -42,6 +44,7 @@ stat_surrogate_ci = _MODULES["stat_surrogate_ci"].stat_surrogate_ci stat_surrogate_pvals = _MODULES["stat_surrogate_pvals"].stat_surrogate_pvals statcond = _MODULES["statcond"].statcond +statcondfieldtrip = _MODULES["statcondfieldtrip"].statcondfieldtrip surrogdistrib = _MODULES["surrogdistrib"].surrogdistrib teststat = _MODULES["teststat"].teststat ttest2_cell = _MODULES["ttest2_cell"].ttest2_cell @@ -53,6 +56,7 @@ "ConcatenatedData", "FDRResult", "StatcondResult", + "StatcondFieldtripResult", "SurrogateDistribution", "TwoWayAnovaResult", "TwoWayEffects", @@ -66,6 +70,7 @@ "stat_surrogate_ci", "stat_surrogate_pvals", "statcond", + "statcondfieldtrip", "surrogdistrib", "teststat", "ttest2_cell", diff --git a/src/eegprep/functions/statistics/_core.py b/src/eegprep/functions/statistics/_core.py index 07d99897..da8eb1fa 100644 --- a/src/eegprep/functions/statistics/_core.py +++ b/src/eegprep/functions/statistics/_core.py @@ -16,6 +16,7 @@ from eegprep.functions.statistics.stat_surrogate_ci import stat_surrogate_ci from eegprep.functions.statistics.stat_surrogate_pvals import stat_surrogate_pvals from eegprep.functions.statistics.statcond import StatcondResult, statcond +from eegprep.functions.statistics.statcondfieldtrip import StatcondFieldtripResult, statcondfieldtrip from eegprep.functions.statistics.surrogdistrib import SurrogateDistribution, surrogdistrib from eegprep.functions.statistics.teststat import teststat from eegprep.functions.statistics.ttest2_cell import ttest2_cell @@ -25,6 +26,7 @@ "ConcatenatedData", "FDRResult", "StatcondResult", + "StatcondFieldtripResult", "SurrogateDistribution", "TwoWayAnovaResult", "TwoWayEffects", @@ -38,6 +40,7 @@ "stat_surrogate_ci", "stat_surrogate_pvals", "statcond", + "statcondfieldtrip", "surrogdistrib", "teststat", "ttest2_cell", diff --git a/src/eegprep/functions/statistics/statcondfieldtrip.py b/src/eegprep/functions/statistics/statcondfieldtrip.py new file mode 100644 index 00000000..a4a77965 --- /dev/null +++ b/src/eegprep/functions/statistics/statcondfieldtrip.py @@ -0,0 +1,279 @@ +"""Standalone FieldTrip-style condition statistics boundary.""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +from operator import index +from typing import Any + +import numpy as np + +from eegprep.functions.statistics._shared import condition_grid, paired_flag +from eegprep.functions.statistics.fdr import fdr +from eegprep.functions.statistics.statcond import StatcondResult, statcond + + +@dataclass(frozen=True) +class StatcondFieldtripResult: + """Statistical result with FieldTrip-style multiple-comparison output.""" + + stat: Any + df: Any + pvalue: np.ndarray + mask: np.ndarray + raw_pvalue: np.ndarray + surrogate: Any + method: str + mcorrect: str + paired: bool + + def __iter__(self) -> Iterator[Any]: + yield self.stat + yield self.df + yield self.pvalue + + +def statcondfieldtrip( + data: Any, + *, + paired: str | bool = "auto", + method: str = "analytic", + mode: str | None = None, + naccu: int = 200, + variance: str = "homogenous", + mcorrect: str = "none", + alpha: float = 0.05, + axis: int = -1, + rng: np.random.Generator | int | None = None, + neighbours: Any = None, +) -> StatcondFieldtripResult: + """Compare conditions using the supported ``statcondfieldtrip`` contract. + + The standalone backend supports paired and unpaired two-condition t-tests + plus unpaired one-way ANOVA. Condition arrays may have any number of + feature axes; cases occupy ``axis`` and result arrays preserve the feature + shape. + + Args: + data: Sequence of condition arrays. + paired: Pairing mode. ``"auto"`` pairs conditions only when every case + count is equal. + method: ``"analytic"`` or ``"montecarlo"``. EEGLAB aliases + ``"param"``, ``"parametric"``, ``"perm"``, ``"permutation"``, + and ``"bootstrap"`` are accepted; bootstrap follows EEGLAB's + FieldTrip wrapper and selects permutation inference. + mode: EEGLAB alias for ``method``. A non-empty value takes precedence. + naccu: Number of permutations for Monte Carlo inference. + variance: Equal-variance mode for an unpaired t-test. Both spellings of + ``"homogeneous"`` are accepted. + mcorrect: Multiple-comparison correction: ``"none"``, + ``"bonferroni"``, ``"holm"``, ``"fdr"``, or ``"max"``. + EEGLAB/FieldTrip spellings ``"bonferoni"`` and ``"holms"`` are + accepted. Max correction requires Monte Carlo inference. + alpha: Family significance threshold used to form ``mask``. + axis: Case axis in every condition array. + rng: Optional NumPy generator or seed for Monte Carlo inference. + neighbours: Reserved FieldTrip spatial-neighbour input. Non-empty + values require cluster correction and are not supported. + + Returns: + A structured result. Iteration yields ``stat``, ``df``, and ``pvalue`` + for compatibility with the MATLAB function's three outputs. Pointwise + corrections leave ``pvalue`` unadjusted and correct ``mask`` instead; + max-statistic correction returns family-wise corrected ``pvalue``. + + Raises: + NotImplementedError: For paired one-way ANOVA, two-way designs, + cluster correction, or spatial-neighbour statistics. + ValueError: For invalid methods, corrections, alpha, variance, or + condition shapes. + """ + + method_name = _normalize_method(mode if mode else method) + correction_name = _normalize_correction(mcorrect) + alpha_value = float(alpha) + if not np.isfinite(alpha_value) or not 0 < alpha_value <= 1: + raise ValueError("alpha must be greater than 0 and at most 1") + if _has_values(neighbours): + raise NotImplementedError("spatial-neighbour statistics require the unsupported cluster backend") + if correction_name == "cluster": + raise NotImplementedError("cluster correction requires an explicit adjacency graph and cluster policy") + if correction_name == "max" and method_name != "montecarlo": + raise ValueError("max correction requires method='montecarlo'") + + grid = condition_grid(data, axis=axis, min_cases=2) + if len(grid) != 1: + raise NotImplementedError("statcondfieldtrip two-way designs are not supported") + n_conditions = len(grid[0]) + if n_conditions < 2: + raise ValueError("statcondfieldtrip requires at least two conditions") + paired_value = paired_flag(grid, paired) + if n_conditions > 2 and paired_value: + raise NotImplementedError("statcondfieldtrip paired one-way ANOVA is not supported") + + variance_name = _normalize_variance(variance) + if n_conditions == 2 and not paired_value and variance_name != "homogenous": + raise NotImplementedError("FieldTrip parity supports only equal-variance unpaired t-tests") + + try: + count = index(naccu) + except TypeError: + raise ValueError("naccu must be an integer") from None + if isinstance(naccu, bool): + raise ValueError("naccu must be an integer") + if count < 1: + raise ValueError("naccu must be at least 1") + statcond_method = "param" if method_name == "analytic" else "perm" + result = statcond( + grid[0], + paired=paired_value, + method=statcond_method, + naccu=count, + variance=variance_name, + rng=rng, + ) + if not isinstance(result, StatcondResult): # pragma: no cover - fixed by call arguments + raise RuntimeError("statcond returned resampling arrays instead of statistics") + + raw_pvalue = np.asarray(result.pvalue, dtype=float) + if method_name == "montecarlo": + raw_pvalue = _montecarlo_pvalues( + result.stat, + result.surrogate, + two_sided=n_conditions == 2, + ) + if correction_name == "max": + pvalue = _max_statistic_pvalues( + result.stat, + result.surrogate, + two_sided=n_conditions == 2, + ) + mask = pvalue <= alpha_value + else: + pvalue = raw_pvalue.copy() + mask = _corrected_mask(raw_pvalue, alpha_value, correction_name) + return StatcondFieldtripResult( + stat=result.stat, + df=result.df, + pvalue=pvalue, + mask=mask, + raw_pvalue=raw_pvalue, + surrogate=result.surrogate, + method=method_name, + mcorrect=correction_name, + paired=paired_value, + ) + + +def _normalize_method(method: str) -> str: + method_name = str(method).lower() + if method_name in {"analytic", "param", "parametric"}: + return "analytic" + if method_name in {"montecarlo", "perm", "permutation", "bootstrap"}: + return "montecarlo" + raise ValueError("method must be 'analytic' or 'montecarlo'") + + +def _normalize_correction(correction: str) -> str: + correction_name = str(correction).lower() + aliases = { + "no": "none", + "none": "none", + "bonferoni": "bonferroni", + "bonferroni": "bonferroni", + "holm": "holm", + "holms": "holm", + "fdr": "fdr", + "max": "max", + "cluster": "cluster", + } + if correction_name not in aliases: + raise ValueError("mcorrect must be 'none', 'bonferroni', 'holm', 'fdr', 'max', or 'cluster'") + return aliases[correction_name] + + +def _normalize_variance(variance: str) -> str: + variance_name = str(variance).lower() + aliases = { + "homogenous": "homogenous", + "homogeneous": "homogenous", + "inhomogenous": "inhomogenous", + "inhomogeneous": "inhomogenous", + } + if variance_name not in aliases: + raise ValueError("variance must be 'homogenous' or 'inhomogenous'") + return aliases[variance_name] + + +def _has_values(value: Any) -> bool: + if value is None: + return False + if isinstance(value, dict): + return bool(value) + return np.asarray(value, dtype=object).size > 0 + + +def _corrected_mask(pvalues: np.ndarray, alpha: float, correction: str) -> np.ndarray: + values = np.asarray(pvalues, dtype=float) + if correction == "none": + return values <= alpha + + flat = values.reshape(-1) + finite = np.isfinite(flat) + selected = flat[finite] + mask = np.zeros(flat.shape, dtype=bool) + if selected.size == 0: + return mask.reshape(values.shape) + if correction == "bonferroni": + mask[finite] = selected <= alpha / selected.size + return mask.reshape(values.shape) + if correction == "fdr": + return fdr(values, alpha).mask + + order = np.argsort(selected) + ordered = selected[order] + count = selected.size + accepted = np.zeros(count, dtype=bool) + for rank, pvalue in enumerate(ordered): + if pvalue > alpha / (count - rank): + break + accepted[rank] = True + selected_mask = np.zeros(count, dtype=bool) + selected_mask[order] = accepted + mask[finite] = selected_mask + return mask.reshape(values.shape) + + +def _montecarlo_pvalues(statistic: Any, surrogate: Any, *, two_sided: bool) -> np.ndarray: + if surrogate is None: + raise ValueError("Monte Carlo inference requires a surrogate statistic distribution") + observed = np.asarray(statistic, dtype=float) + distribution = np.asarray(surrogate, dtype=float) + if distribution.shape[:-1] != observed.shape: + raise ValueError("surrogate shape must equal statistic shape plus a final permutation axis") + if two_sided: + observed = np.abs(observed) + distribution = np.abs(distribution) + exceedances = np.sum(distribution >= observed[..., np.newaxis], axis=-1) + pvalues = (exceedances + 1) / (distribution.shape[-1] + 1) + return np.where(np.isnan(observed), np.nan, pvalues) + + +def _max_statistic_pvalues(statistic: Any, surrogate: Any, *, two_sided: bool) -> np.ndarray: + if surrogate is None: + raise ValueError("max correction requires a surrogate statistic distribution") + observed = np.asarray(statistic, dtype=float) + distribution = np.asarray(surrogate, dtype=float) + if distribution.shape[:-1] != observed.shape: + raise ValueError("surrogate shape must equal statistic shape plus a final permutation axis") + if two_sided: + observed = np.abs(observed) + distribution = np.abs(distribution) + null_maximum = np.max(distribution.reshape(-1, distribution.shape[-1]), axis=0) + exceedances = np.sum(null_maximum >= observed[..., np.newaxis], axis=-1) + pvalues = (exceedances + 1) / (distribution.shape[-1] + 1) + return np.where(np.isnan(observed), np.nan, pvalues) + + +__all__ = ["StatcondFieldtripResult", "statcondfieldtrip"] diff --git a/tests/test_statcondfieldtrip_eeglab_tests.py b/tests/test_statcondfieldtrip_eeglab_tests.py new file mode 100644 index 00000000..26887537 --- /dev/null +++ b/tests/test_statcondfieldtrip_eeglab_tests.py @@ -0,0 +1,226 @@ +"""Ports of the maintained EEGLAB ``statcondfieldtrip`` wrapper test.""" + +from __future__ import annotations + +import importlib +from collections.abc import Sequence + +import numpy as np +import pytest +from scipy import stats as scipy_stats + +from eegprep.functions.statistics import StatcondFieldtripResult, statcondfieldtrip +from tests.eeglab_tests import eeglab_test + + +STATCONDFIELDTRIP_SCRIPT = "unittesting_statistics/statcondfieldtrip/test_statcondfieldtrip.m" +STATCONDFIELDTRIP_WRAPPER = "unittesting_statistics/statcondfieldtrip/statistics_statcondfieldtrip_wrapperTest.m" + + +def _reference_conditions() -> tuple[np.ndarray, np.ndarray, np.ndarray]: + rng = np.random.default_rng(941) + first = rng.normal(size=10) + second = rng.normal(loc=0.5, size=10) + third = rng.normal(loc=0.2, size=10) + return first, second, third + + +def _assert_active_vector_calls() -> None: + first, second, third = _reference_conditions() + + paired = statcondfieldtrip([first, second], paired="on", mode="param", method="analytic") + paired_reference = scipy_stats.ttest_rel(first, second) + np.testing.assert_allclose(paired.stat, paired_reference.statistic, rtol=1e-13, atol=1e-13) + assert paired.df == 9 + np.testing.assert_allclose(paired.pvalue, paired_reference.pvalue, rtol=1e-13, atol=1e-13) + + unpaired = statcondfieldtrip( + [first, second], + paired="off", + mode="param", + method="analytic", + variance="homogenous", + ) + unpaired_reference = scipy_stats.ttest_ind(first, second, equal_var=True) + np.testing.assert_allclose(unpaired.stat, unpaired_reference.statistic, rtol=1e-13, atol=1e-13) + assert unpaired.df == 18 + np.testing.assert_allclose(unpaired.pvalue, unpaired_reference.pvalue, rtol=1e-13, atol=1e-13) + + one_way = statcondfieldtrip([first, second, third], paired="off", mode="param", method="analytic") + one_way_reference = scipy_stats.f_oneway(first, second, third) + np.testing.assert_allclose(one_way.stat, one_way_reference.statistic, rtol=1e-13, atol=1e-13) + assert one_way.df == (2, 27) + np.testing.assert_allclose(one_way.pvalue, one_way_reference.pvalue, rtol=1e-13, atol=1e-13) + + +def _dimensional_conditions() -> tuple[tuple[tuple[np.ndarray, ...], tuple[int, ...]], ...]: + rng = np.random.default_rng(942) + base = tuple(rng.normal(loc=offset, size=10) for offset in (0.0, 0.5, 0.2)) + matrix = tuple(rng.normal(loc=offset, size=(8, 10)) for offset in (0.0, 0.5, 0.2)) + cube = tuple(rng.normal(loc=offset, size=(4, 8, 10)) for offset in (0.0, 0.5, 0.2)) + for condition_index in range(3): + matrix[condition_index][3, :] = base[condition_index] + cube[condition_index][1, 3, :] = base[condition_index] + return ((base, ()), (matrix, (3,)), (cube, (1, 3))) + + +def _value_at(value: np.ndarray | float, index: tuple[int, ...]) -> float: + values = np.asarray(value) + return float(values[index] if index else values) + + +def _assert_active_dimension_calls(*, paired: bool, one_way: bool) -> None: + results: list[tuple[StatcondFieldtripResult, tuple[int, ...]]] = [] + for conditions, index in _dimensional_conditions(): + data: Sequence[np.ndarray] = conditions if one_way else conditions[:2] + result = statcondfieldtrip(data, paired=paired, method="analytic") + assert np.asarray(result.stat).shape == conditions[0].shape[:-1] + assert result.pvalue.shape == conditions[0].shape[:-1] + assert result.mask.shape == conditions[0].shape[:-1] + results.append((result, index)) + + baseline, _ = results[0] + for result, index in results[1:]: + np.testing.assert_allclose(_value_at(result.stat, index), baseline.stat, rtol=1e-13, atol=1e-13) + np.testing.assert_allclose(_value_at(result.pvalue, index), baseline.pvalue, rtol=1e-13, atol=1e-13) + assert result.df == baseline.df + + +@eeglab_test(STATCONDFIELDTRIP_WRAPPER, "test_test_statcondfieldtrip") +@eeglab_test(STATCONDFIELDTRIP_SCRIPT, "test_statcondfieldtrip") +def test_current_statcondfieldtrip_wrapper_executes_its_scientific_intent(): + # The MATLAB script returns before these calls because its `exist('kmean')` + # guard contains a typo. Execute the active body rather than porting a no-op. + _assert_active_vector_calls() + _assert_active_dimension_calls(paired=True, one_way=False) + _assert_active_dimension_calls(paired=False, one_way=False) + _assert_active_dimension_calls(paired=False, one_way=True) + + +def test_statcondfieldtrip_preserves_features_with_a_nonfinal_case_axis(): + first, second, _ = _reference_conditions() + data = [np.stack([first, first + 0.1], axis=1), np.stack([second, second - 0.1], axis=1)] + + result = statcondfieldtrip(data, paired="on", axis=0) + + assert np.asarray(result.stat).shape == (2,) + expected = scipy_stats.ttest_rel(data[0], data[1], axis=0) + np.testing.assert_allclose(result.stat, expected.statistic, rtol=1e-13, atol=1e-13) + np.testing.assert_allclose(result.pvalue, expected.pvalue, rtol=1e-13, atol=1e-13) + + +def test_statcondfieldtrip_applies_standard_pointwise_corrections(): + target_pvalues = np.array([0.2, 0.04, 0.02, 0.001]) + residual = np.arange(12, dtype=float) + residual = (residual - np.mean(residual)) / np.std(residual, ddof=1) + offsets = scipy_stats.t.isf(target_pvalues / 2, df=11) / np.sqrt(12) + first = np.zeros((4, 12)) + second = residual[np.newaxis, :] + offsets[:, np.newaxis] + raw = statcondfieldtrip([first, second], paired="on") + np.testing.assert_allclose(raw.pvalue, target_pvalues, rtol=1e-10, atol=1e-13) + + bonferroni = statcondfieldtrip([first, second], paired="on", mcorrect="bonferoni") + np.testing.assert_array_equal(bonferroni.pvalue, raw.pvalue) + np.testing.assert_array_equal(bonferroni.mask, raw.pvalue <= 0.05 / 4) + assert bonferroni.mcorrect == "bonferroni" + + order = np.argsort(raw.pvalue) + ordered = raw.pvalue[order] + holm_accepted = np.zeros(4, dtype=bool) + for rank, pvalue in enumerate(ordered): + if pvalue > 0.05 / (4 - rank): + break + holm_accepted[rank] = True + holm_expected = np.zeros(4, dtype=bool) + holm_expected[order] = holm_accepted + holm = statcondfieldtrip([first, second], paired="on", mcorrect="holms") + np.testing.assert_array_equal(holm.pvalue, raw.pvalue) + np.testing.assert_array_equal(holm.mask, holm_expected) + assert holm.mcorrect == "holm" + + accepted = ordered <= np.arange(1, 5) / 4 * 0.05 + threshold = ordered[np.flatnonzero(accepted).max()] if np.any(accepted) else 0.0 + fdr_expected = raw.pvalue <= threshold + fdr_result = statcondfieldtrip([first, second], paired="on", mcorrect="fdr") + np.testing.assert_array_equal(fdr_result.pvalue, raw.pvalue) + np.testing.assert_array_equal(fdr_result.mask, fdr_expected) + assert np.count_nonzero(raw.mask) == 3 + assert np.count_nonzero(bonferroni.mask) == 1 + assert np.count_nonzero(holm.mask) == 1 + assert np.count_nonzero(fdr_result.mask) == 2 + + +def test_statcondfieldtrip_montecarlo_max_correction_is_seeded_and_familywise(): + rng = np.random.default_rng(944) + first = rng.normal(size=(5, 10)) + second = first + np.linspace(0.0, 0.8, 5)[:, np.newaxis] + rng.normal(scale=0.5, size=(5, 10)) + + result = statcondfieldtrip( + [first, second], + paired="on", + method="montecarlo", + naccu=128, + mcorrect="max", + rng=61, + ) + repeated = statcondfieldtrip( + [first, second], + paired="on", + method="permutation", + naccu=128, + mcorrect="max", + rng=61, + ) + + assert result.method == "montecarlo" + assert result.surrogate.shape == (5, 128) + np.testing.assert_array_equal(result.pvalue, repeated.pvalue) + null_maximum = np.max(np.abs(result.surrogate), axis=0) + expected = (np.sum(null_maximum >= np.abs(result.stat)[:, np.newaxis], axis=-1) + 1) / 129 + np.testing.assert_array_equal(result.pvalue, expected) + np.testing.assert_allclose(result.pvalue * 129, np.round(result.pvalue * 129), atol=1e-13) + + pointwise_expected = (np.sum(np.abs(result.surrogate) >= np.abs(result.stat)[:, np.newaxis], axis=-1) + 1) / 129 + np.testing.assert_array_equal(result.raw_pvalue, pointwise_expected) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"method": "glm"}, "method"), + ({"mcorrect": "hochberg"}, "mcorrect"), + ({"alpha": 0.0}, "alpha"), + ({"naccu": 0}, "naccu"), + ({"naccu": 1.5}, "naccu"), + ({"mcorrect": "max"}, "max correction"), + ({"mcorrect": "cluster"}, "cluster correction"), + ({"neighbours": [{"label": "Cz"}]}, "spatial-neighbour"), + ], +) +def test_statcondfieldtrip_rejects_invalid_or_unavailable_inference_options(kwargs, message): + first, second, _ = _reference_conditions() + with pytest.raises((ValueError, NotImplementedError), match=message): + statcondfieldtrip([first, second], paired="on", **kwargs) + + +def test_statcondfieldtrip_rejects_designs_disabled_by_the_maintained_wrapper(): + first, second, third = _reference_conditions() + with pytest.raises(NotImplementedError, match="paired one-way"): + statcondfieldtrip([first, second, third], paired="on") + with pytest.raises(NotImplementedError, match="two-way"): + statcondfieldtrip(((first, second), (second, third)), paired="off") + with pytest.raises(NotImplementedError, match="equal-variance"): + statcondfieldtrip([first, second], paired="off", variance="inhomogenous") + with pytest.raises(ValueError, match="variance"): + statcondfieldtrip([first, second], variance="pooled") + with pytest.raises(ValueError, match="same number of cases"): + statcondfieldtrip([first, second[:-1]], paired="on") + + +def test_statcondfieldtrip_remains_a_package_callable_after_submodule_import(): + import eegprep.functions.statistics as statistics + + module = importlib.import_module("eegprep.functions.statistics.statcondfieldtrip") + + assert statistics.statcondfieldtrip is module.statcondfieldtrip + assert statistics.StatcondFieldtripResult is module.StatcondFieldtripResult From 14dce1505a5509e0191570cba1874e7764cac021 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:56 -0700 Subject: [PATCH 37/45] Port miscellaneous visualization test families (#365) --- docs/source/api/plotting_and_topography.rst | 16 +- docs/source/changelog.rst | 9 + src/eegprep/__init__.py | 9 + src/eegprep/functions/miscfunc/_log_image.py | 143 ++++++ src/eegprep/functions/miscfunc/eegmovie.py | 310 ++++++++++++ src/eegprep/functions/miscfunc/gradmap.py | 113 +++++ src/eegprep/functions/miscfunc/gradplot.py | 17 + src/eegprep/functions/miscfunc/headmovie.py | 56 +++ .../functions/miscfunc/imagescloglog.py | 29 ++ src/eegprep/functions/miscfunc/imagesclogy.py | 47 ++ src/eegprep/functions/miscfunc/seemovie.py | 82 +++ src/eegprep/functions/miscfunc/setfont.py | 118 +++++ src/eegprep/functions/miscfunc/show_events.py | 211 ++++++++ tests/test_misc_visual_movie_eeglab_tests.py | 476 ++++++++++++++++++ 14 files changed, 1635 insertions(+), 1 deletion(-) create mode 100644 src/eegprep/functions/miscfunc/_log_image.py create mode 100644 src/eegprep/functions/miscfunc/eegmovie.py create mode 100644 src/eegprep/functions/miscfunc/gradmap.py create mode 100644 src/eegprep/functions/miscfunc/gradplot.py create mode 100644 src/eegprep/functions/miscfunc/headmovie.py create mode 100644 src/eegprep/functions/miscfunc/imagescloglog.py create mode 100644 src/eegprep/functions/miscfunc/imagesclogy.py create mode 100644 src/eegprep/functions/miscfunc/seemovie.py create mode 100644 src/eegprep/functions/miscfunc/setfont.py create mode 100644 src/eegprep/functions/miscfunc/show_events.py create mode 100644 tests/test_misc_visual_movie_eeglab_tests.py diff --git a/docs/source/api/plotting_and_topography.rst b/docs/source/api/plotting_and_topography.rst index ba8f50fc..4683c21e 100644 --- a/docs/source/api/plotting_and_topography.rst +++ b/docs/source/api/plotting_and_topography.rst @@ -3,7 +3,12 @@ Plotting and Topography ======================= -Scalp maps, curve summaries, figure-layout helpers, and artifact review plots. +Scalp maps, curve summaries, figure-layout helpers, logarithmic image displays, +event rasters, artifact review plots, and replayable scalp movies. Movie frames +are returned as ``uint8`` RGB arrays so they can be inspected in notebooks, +tested headlessly, encoded by downstream tools, or replayed with ``seemovie``. +The ``movieframes`` argument follows EEGLAB's 1-based public indexing +convention. .. autosummary:: :toctree: generated/ @@ -16,6 +21,15 @@ Scalp maps, curve summaries, figure-layout helpers, and artifact review plots. eegprep.plotcurve eegprep.sbplot eegprep.slider + eegprep.gradmap + eegprep.gradplot + eegprep.imagesclogy + eegprep.imagescloglog + eegprep.show_events + eegprep.setfont + eegprep.eegmovie + eegprep.headmovie + eegprep.seemovie eegprep.topoplot eegprep.loc_subsets eegprep.vis_artifacts diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index a0ce359a..c3840edb 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,15 @@ the `GitHub Releases `_ page. Unreleased ========== +- Added standalone miscellaneous visualization utilities for spatial scalp-map + gradients, logarithmic-axis matrix images, epoched event rasters, recursive + Matplotlib font styling, and replayable 2-D/3-D scalp movies. Movie generation + returns direct RGB frames for headless notebooks and tests while + preserving EEGLAB's 1-based ``movieframes`` boundary and legacy camera paths. + Current tests for obsolete ``eegplotgold``/``eegplotsold`` and MATLAB menu + handle introspection instead exercise EEGPrep's maintained browser and + declarative menu model; MATLAB help-site builders and callback-string + ``textgui`` remain explicit exclusions. - Added a standalone ``statcondfieldtrip`` backend for paired and unpaired t-tests and unpaired one-way ANOVA, with analytic or seeded permutation inference and Bonferroni, Holm, FDR, and max-statistic correction. diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index a580878b..aa81a4a3 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -136,6 +136,7 @@ "eeg_decodechan": ("eegprep.functions.popfunc.eeg_decodechan", "eeg_decodechan"), "eeg_eeg2mne": ("eegprep.functions.miscfunc.eeg_eeg2mne", "eeg_eeg2mne"), "eeg_eegrej": ("eegprep.functions.popfunc.eeg_eegrej", "eeg_eegrej"), + "eegmovie": ("eegprep.functions.miscfunc.eegmovie", "eegmovie"), "eeg_icalabelstat": ("eegprep.plugins.ICLabel.eeg_icalabelstat", "eeg_icalabelstat"), "eeg_emptyset": ("eegprep.functions.popfunc.eeg_emptyset", "eeg_emptyset"), "eeg_eventhist": ("eegprep.functions.popfunc.eeg_eventhist", "eeg_eventhist"), @@ -201,8 +202,13 @@ "getipsph": ("eegprep.functions.miscfunc.getipsph", "getipsph"), "gethelpvar": ("eegprep.functions.adminfunc.gethelpvar", "gethelpvar"), "getkeyval": ("eegprep.functions.adminfunc.getkeyval", "getkeyval"), + "gradmap": ("eegprep.functions.miscfunc.gradmap", "gradmap"), + "gradplot": ("eegprep.functions.miscfunc.gradplot", "gradplot"), + "headmovie": ("eegprep.functions.miscfunc.headmovie", "headmovie"), "hist2": ("eegprep.functions.miscfunc.hist2", "hist2"), "headplot": ("eegprep.functions.sigprocfunc.headplot", "headplot"), + "imagescloglog": ("eegprep.functions.miscfunc.imagescloglog", "imagescloglog"), + "imagesclogy": ("eegprep.functions.miscfunc.imagesclogy", "imagesclogy"), "icaact": ("eegprep.functions.sigprocfunc.ica_helpers", "icaact"), "icadefs": ("eegprep.functions.sigprocfunc.icadefs", "icadefs"), "icaproj": ("eegprep.functions.sigprocfunc.ica_helpers", "icaproj"), @@ -441,6 +447,9 @@ "eegprep.functions.guifunc.select_multiple_datasets", "select_multiple_datasets", ), + "seemovie": ("eegprep.functions.miscfunc.seemovie", "seemovie"), + "setfont": ("eegprep.functions.miscfunc.setfont", "setfont"), + "show_events": ("eegprep.functions.miscfunc.show_events", "show_events"), "signalstat": ("eegprep.functions.sigprocfunc.signalstat", "signalstat"), "shortread": ("eegprep.functions.miscfunc.shortread", "shortread"), "windows": ("eegprep.plugins.firfilt.windows", "windows"), diff --git a/src/eegprep/functions/miscfunc/_log_image.py b/src/eegprep/functions/miscfunc/_log_image.py new file mode 100644 index 00000000..faaf8d54 --- /dev/null +++ b/src/eegprep/functions/miscfunc/_log_image.py @@ -0,0 +1,143 @@ +"""Shared implementation for logarithmic image plots.""" + +from __future__ import annotations + +from typing import Any + +import matplotlib.pyplot as plt +from matplotlib.collections import QuadMesh +import numpy as np + +from eegprep.functions.popfunc.plot_utils import show_figures + + +def log_image( + times: Any, + freqs: Any, + data: Any, + clim: Any, + xticks: Any, + yticks: Any, + properties: dict[str, Any], + *, + log_x: bool, + ax: Any, +) -> QuadMesh: + x = _axis_values(times, "times", positive=log_x) + y = _axis_values(freqs, "freqs", positive=True) + values = np.asarray(data) + if values.ndim != 2 or values.shape != (y.size, x.size): + raise ValueError(f"data must have shape ({y.size}, {x.size}) for the supplied freqs and times") + + own_figure = ax is None + if own_figure: + figure, ax = plt.subplots() + else: + figure = ax.figure + limits = _color_limits(clim) + mesh = ax.pcolormesh( + _cell_edges(x, logarithmic=log_x), + _cell_edges(y, logarithmic=True), + values, + shading="flat", + vmin=None if limits is None else limits[0], + vmax=None if limits is None else limits[1], + ) + ax.set_xscale("log" if log_x else "linear") + ax.set_yscale("log") + x_tick_values = _ticks(x, xticks, logarithmic=log_x) + if x_tick_values is not None: + ax.set_xticks(x_tick_values) + ax.set_yticks(_ticks(y, yticks, logarithmic=True)) + ax.minorticks_off() + ax.tick_params(direction="out") + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.set_facecolor("none") + _apply_axes_properties(ax, properties) + if own_figure: + show_figures(figure) + return mesh + + +def _axis_values(value: Any, name: str, *, positive: bool) -> np.ndarray: + values = np.asarray(value, dtype=float).ravel() + if values.size < 2: + raise ValueError(f"{name} must contain at least two values") + if not np.all(np.isfinite(values)): + raise ValueError(f"{name} must contain only finite values") + if positive and np.any(values <= 0): + raise ValueError(f"{name} must be positive for a logarithmic axis") + if np.any(np.diff(values) <= 0): + raise ValueError(f"{name} must be strictly increasing") + return values + + +def _cell_edges(values: np.ndarray, *, logarithmic: bool) -> np.ndarray: + if logarithmic: + interior = np.sqrt(values[:-1] * values[1:]) + return np.concatenate(([values[0] ** 2 / interior[0]], interior, [values[-1] ** 2 / interior[-1]])) + interior = (values[:-1] + values[1:]) / 2.0 + return np.concatenate( + ( + [values[0] - (interior[0] - values[0])], + interior, + [values[-1] + values[-1] - interior[-1]], + ) + ) + + +def _ticks(values: np.ndarray, requested: Any, *, logarithmic: bool) -> np.ndarray | None: + if requested is not None and np.asarray(requested).size: + ticks = np.asarray(requested, dtype=float).ravel() + if not np.all(np.isfinite(ticks)): + raise ValueError("tick values must be finite") + if logarithmic and np.any(ticks <= 0): + raise ValueError("tick values must be positive for a logarithmic axis") + return ticks + if not logarithmic: + return None + raw = np.exp(np.linspace(np.log(values[0]), np.log(values[-1]), 10)) + rounded = np.ceil(raw - np.finfo(float).eps * np.maximum(1.0, np.abs(raw))) + return np.unique(rounded) + + +def _color_limits(clim: Any) -> tuple[float, float] | None: + if clim is None or np.asarray(clim).size == 0: + return None + values = np.asarray(clim, dtype=float).ravel() + if values.size != 2 or not np.all(np.isfinite(values)) or values[0] >= values[1]: + raise ValueError("clim must contain two increasing finite values") + return float(values[0]), float(values[1]) + + +def _apply_axes_properties(ax: Any, properties: dict[str, Any]) -> None: + for name, value in properties.items(): + normalized = name.replace("_", "").lower() + if normalized == "xgrid": + ax.xaxis.grid(_on(value)) + elif normalized == "ygrid": + ax.yaxis.grid(_on(value)) + elif normalized == "grid": + ax.grid(_on(value)) + elif normalized == "xlim": + ax.set_xlim(value) + elif normalized == "ylim": + ax.set_ylim(value) + elif normalized == "xlabel": + ax.set_xlabel(value) + elif normalized == "ylabel": + ax.set_ylabel(value) + elif normalized == "title": + ax.set_title(value) + else: + raise ValueError(f"unsupported axes property: {name}") + + +def _on(value: Any) -> bool: + if isinstance(value, str): + if value.lower() in {"on", "true"}: + return True + if value.lower() in {"off", "false"}: + return False + return bool(value) diff --git a/src/eegprep/functions/miscfunc/eegmovie.py b/src/eegprep/functions/miscfunc/eegmovie.py new file mode 100644 index 00000000..8446b0fe --- /dev/null +++ b/src/eegprep/functions/miscfunc/eegmovie.py @@ -0,0 +1,310 @@ +"""Create headless-friendly movies of evolving EEG scalp maps.""" + +from __future__ import annotations + +from contextlib import nullcontext +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any + +import matplotlib.pyplot as plt +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.figure import Figure +import numpy as np + +from eegprep.functions.popfunc._chanutils import chanlocs_as_list +from eegprep.functions.popfunc.plot_utils import show_figures +from eegprep.functions.sigprocfunc.headplot import headplot, headplot_setup +from eegprep.functions.sigprocfunc.readlocs import readlocs +from eegprep.functions.sigprocfunc.topoplot import topoplot + +DEFAULT_MOVIE_SRATE = 256.0 +DEFAULT_CAMERA_PATH = np.asarray([-127.0, 0.0, 30.0, 0.0]) + + +def eegmovie( + data: Any, + srate: float = 0, + elec_locs: Any = None, + *args: Any, + plot: str | bool = "on", + spline_file: str | Path | None = None, + **kwargs: Any, +) -> tuple[np.ndarray, np.ndarray]: + """Render channel-by-frame data as a sequence of RGB scalp-map images. + + EEGLAB name/value options are accepted as positional pairs or keywords. + ``movieframes`` remains 1-based at this public compatibility boundary. + The returned movie is an unsigned-byte array shaped + ``(frames, height, width, 3)`` and can be replayed with :func:`seemovie`. + """ + options = _movie_options(args, kwargs) + values = np.asarray(data, dtype=float) + if values.ndim != 2 or values.shape[0] < 3 or values.shape[1] == 0: + raise ValueError("data must be a non-empty channels x frames matrix with at least three channels") + if not np.all(np.isfinite(values)): + raise ValueError("data must contain only finite values") + sample_rate = DEFAULT_MOVIE_SRATE if not srate else float(srate) + if not np.isfinite(sample_rate) or sample_rate <= 0: + raise ValueError("srate must be positive, or zero to use 256 Hz") + locs = _channel_locations(elec_locs, values.shape[0]) + frame_indices = _frame_indices(options.pop("movieframes", None), values.shape[1]) + limits = movie_limits(values, options.pop("minmax", None)) + mode = str(options.pop("mode", "2D")).upper() + if mode not in {"2D", "3D"}: + raise ValueError("mode must be '2D' or '3D'") + + title = str(options.pop("title", "") or "") + start_seconds = float(options.pop("startsec", 0.0) or 0.0) + timecourse = _on(options.pop("timecourse", "on")) + frame_number = _on(options.pop("framenum", "on")) + show_time = _on(options.pop("time", "off")) + if show_time: + frame_number = False + vertical_times = np.asarray(options.pop("vert", []), dtype=float).ravel() + camera_path = _camera_path(options.pop("camerapath", None)) if mode == "3D" else DEFAULT_CAMERA_PATH[np.newaxis, :] + topoplot_options = _plot_options(options.pop("topoplotopt", None)) + headplot_options = _plot_options(options.pop("headplotopt", None)) + if options: + raise ValueError(f"eegmovie: unrecognized option: {next(iter(options))!r}") + + figure = plt.figure(figsize=(7.5, 4.5), dpi=80) + if timecourse: + layout = figure.add_gridspec(1, 2, width_ratios=(3.0, 1.0)) + map_axis = figure.add_subplot(layout[0, 0], projection="3d" if mode == "3D" else None) + trace_axis = figure.add_subplot(layout[0, 1]) + _draw_timecourse(trace_axis, values, sample_rate, start_seconds, vertical_times) + else: + map_axis = figure.add_subplot(111, projection="3d" if mode == "3D" else None) + trace_axis = None + figure.patch.set_facecolor("white") + + temporary = ( + TemporaryDirectory(prefix="eegprep-eegmovie-") if mode == "3D" and spline_file is None else nullcontext() + ) + with temporary as temporary_directory: + active_spline = spline_file + if mode == "3D" and active_spline is None: + active_spline = Path(str(temporary_directory)) / "eegmovie.spl" + headplot_setup(locs, active_spline) + views = _camera_views(camera_path, frame_indices) + rendered = [] + for output_index, data_index in enumerate(frame_indices): + map_axis.clear() + if mode == "2D": + topoplot( + values[:, data_index], + locs, + axes=map_axis, + maplimits=limits, + **topoplot_options, + ) + else: + headplot( + values[:, data_index], + active_spline, + ax=map_axis, + maplimits=limits, + view=views[output_index], + tight_layout=False, + **headplot_options, + ) + if title: + map_axis.set_title(title) + if frame_number: + _frame_text(map_axis, mode, str(output_index + 1)) + elif show_time: + seconds = start_seconds + data_index / sample_rate + _frame_text(map_axis, mode, f"{seconds:.3f} s") + cursor = None + if trace_axis is not None: + cursor = trace_axis.axvline(start_seconds + data_index / sample_rate, color="b") + rendered.append(_capture_rgb(figure)) + if cursor is not None: + cursor.remove() + + movie = np.stack(rendered) + colormap = np.vstack((plt.get_cmap("turbo")(np.linspace(0, 1, 64))[:, :3], [1.0, 1.0, 1.0])) + show_figures(figure, plot=plot) + return movie, colormap + + +def movie_limits(data: np.ndarray, minmax: Any = None) -> tuple[float, float]: + """Return EEGLAB's symmetric movie color limits.""" + if minmax is not None and np.asarray(minmax).size: + requested = np.asarray(minmax, dtype=float).ravel() + if requested.size == 1 and requested[0] != 0: + requested = np.asarray([-abs(requested[0]), abs(requested[0])]) + if requested.size == 2 and requested[0] < requested[1] and np.all(np.isfinite(requested)): + return float(requested[0]), float(requested[1]) + if requested.size != 1 or requested[0] != 0: + raise ValueError("minmax must be zero, a positive scalar, or two increasing finite values") + data_min = float(np.min(data)) + data_max = float(np.max(data)) + absolute_maximum = max(abs(data_min), abs(data_max)) + padding = 0.05 * (data_max - data_min) + limit = absolute_maximum + padding + if limit == 0: + limit = 1.0 + return -limit, limit + + +def _movie_options(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: + if args and not isinstance(args[0], str): + names = ("title", "movieframes", "minmax", "startsec") + legacy_count = min(len(args), len(names)) + options = {names[index]: args[index] for index in range(legacy_count)} + remainder = args[legacy_count:] + if remainder: + options["topoplotopt"] = remainder + else: + if len(args) % 2: + raise TypeError("eegmovie options must be name/value pairs") + options = {} + for index in range(0, len(args), 2): + if not isinstance(args[index], str): + raise TypeError("eegmovie option names must be strings") + options[args[index].lower()] = args[index + 1] + for name, value in kwargs.items(): + key = name.lower() + if key in options: + raise TypeError(f"eegmovie option {name!r} was supplied twice") + options[key] = value + return options + + +def _channel_locations(elec_locs: Any, channel_count: int) -> list[dict[str, Any]]: + if elec_locs is None or (np.isscalar(elec_locs) and elec_locs == 0): + raise ValueError("elec_locs must provide one channel position per data row") + if isinstance(elec_locs, (str, Path)): + locs = readlocs(elec_locs) + else: + positions = np.asarray(elec_locs) + if np.iscomplexobj(positions) and positions.ndim == 1: + locs = [] + for index, position in enumerate(positions): + locs.append( + { + "labels": str(index + 1), + "theta": float(np.rad2deg(np.angle(position))), + "radius": float(abs(position)), + } + ) + else: + locs = chanlocs_as_list(elec_locs) + if len(locs) != channel_count: + raise ValueError("elec_locs must provide one channel position per data row") + return locs + + +def _frame_indices(movieframes: Any, frame_count: int) -> np.ndarray: + if movieframes is None or np.asarray(movieframes).size == 0: + return np.arange(frame_count) + requested = np.asarray(movieframes) + if requested.size == 1 and requested.reshape(-1)[0] == 0: + return np.arange(frame_count) + numeric = np.asarray(movieframes, dtype=float).ravel() + if not np.all(np.isfinite(numeric)) or not np.all(numeric == np.floor(numeric)): + raise ValueError("movieframes must contain integer frame numbers") + indices = numeric.astype(int) - 1 + if np.any(indices < 0) or np.any(indices >= frame_count): + raise ValueError("movieframes contains a frame outside the data") + return indices + + +def _plot_options(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return dict(value) + sequence = list(value) + if len(sequence) % 2: + raise TypeError("plot options must be name/value pairs") + return {str(sequence[index]): sequence[index + 1] for index in range(0, len(sequence), 2)} + + +def _camera_path(value: Any) -> np.ndarray: + if value is None or (np.asarray(value).size == 1 and float(np.asarray(value).item()) == 0): + return DEFAULT_CAMERA_PATH[np.newaxis, :].copy() + path = np.asarray(value, dtype=float) + if path.ndim == 1: + if not 1 <= path.size <= 4: + raise ValueError("camerapath must have one to four columns") + defaults = DEFAULT_CAMERA_PATH.copy() + defaults[: path.size] = path + path = defaults[np.newaxis, :] + if path.ndim != 2 or path.shape[1] != 4 or not np.all(np.isfinite(path)): + raise ValueError("camerapath must be a finite matrix with four columns") + return path + + +def _camera_views(path: np.ndarray, frame_indices: np.ndarray) -> list[tuple[float, float]]: + azimuth, azimuth_step, elevation, elevation_step = path[0] + next_row = 1 + views = [] + for frame_index in frame_indices: + frame_number = int(frame_index) + 1 + if next_row < len(path) and frame_number == int(path[next_row, 0]): + azimuth_step = path[next_row, 1] + elevation_step = path[next_row, 3] + next_row += 1 + views.append((float(azimuth), float(np.clip(elevation, -89.99, 89.99)))) + azimuth += azimuth_step + elevation += elevation_step + return views + + +def _draw_timecourse( + axis: Any, + data: np.ndarray, + srate: float, + start_seconds: float, + vertical_times: np.ndarray, +) -> None: + times = start_seconds + np.arange(data.shape[1]) / srate + channel_range = np.ptp(data, axis=1) + spacing = float(np.max(channel_range)) if channel_range.size else 1.0 + if spacing == 0: + spacing = 1.0 + offsets = spacing * np.arange(data.shape[0]) + axis.plot(times, -data.T + offsets, color="r", linewidth=0.6) + for latency in vertical_times: + axis.axvline(float(latency), color="k", linewidth=0.8) + axis.set_xlim(times[0], times[-1] if len(times) > 1 else times[0] + 1 / srate) + axis.set_yticks(offsets) + axis.set_yticklabels([str(index + 1) for index in range(data.shape[0])]) + axis.grid(axis="y") + axis.set_xlabel("Time (s)") + + +def _capture_rgb(figure: Figure) -> np.ndarray: + original_canvas = figure.canvas + if isinstance(original_canvas, FigureCanvasAgg): + original_canvas.draw() + return np.asarray(original_canvas.buffer_rgba(), dtype=np.uint8)[..., :3].copy() + raster_canvas = FigureCanvasAgg(figure) + raster_canvas.draw() + frame = np.asarray(raster_canvas.buffer_rgba(), dtype=np.uint8)[..., :3].copy() + figure.set_canvas(original_canvas) + return frame + + +def _frame_text(axis: Any, mode: str, value: str) -> None: + if mode == "3D": + axis.text2D(0.03, 0.03, value, transform=axis.transAxes) + else: + axis.text(0.03, 0.03, value, transform=axis.transAxes) + + +def _on(value: Any) -> bool: + if isinstance(value, str): + if value.lower() == "on": + return True + if value.lower() == "off": + return False + if isinstance(value, (bool, np.bool_)): + return bool(value) + raise ValueError("on/off options must be 'on', 'off', True, or False") + + +__all__ = ["eegmovie"] diff --git a/src/eegprep/functions/miscfunc/gradmap.py b/src/eegprep/functions/miscfunc/gradmap.py new file mode 100644 index 00000000..bd562fa4 --- /dev/null +++ b/src/eegprep/functions/miscfunc/gradmap.py @@ -0,0 +1,113 @@ +"""Spatial gradients of EEG scalp maps.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import matplotlib.pyplot as plt +import numpy as np + +from eegprep.functions.popfunc._chanutils import chanlocs_as_list +from eegprep.functions.popfunc.plot_utils import show_figures +from eegprep.functions.sigprocfunc.readlocs import readlocs +from eegprep.functions.sigprocfunc.topoplot import griddata_v4 + + +def gradmap(maps: Any, locations: Any, draw: bool | int = False) -> tuple[np.ndarray, np.ndarray]: + """Compute x/y gradients of one or more scalp maps at electrode sites. + + Args: + maps: Values shaped ``channels x maps`` or a single channel vector. + locations: Channel-location records, a location filename, complex + ``x + yj`` positions, or an ``(channels, 2)`` coordinate array. + draw: Draw interpolated contours and gradient arrows when true. + + Returns: + ``(grad_x, grad_y)`` arrays shaped ``channels x maps``. + """ + values = np.asarray(maps, dtype=float) + if values.ndim == 1: + values = values[:, np.newaxis] + if values.ndim != 2 or values.shape[0] < 3 or values.shape[1] == 0: + raise ValueError("maps must contain at least three channels and one map") + if not np.all(np.isfinite(values)): + raise ValueError("maps must contain only finite values") + x, y = _coordinates(locations) + if x.size != values.shape[0]: + raise ValueError("locations must contain one position per map channel") + if np.unique(np.column_stack((x, y)), axis=0).shape[0] < 3: + raise ValueError("at least three distinct electrode positions are required") + + grid_scale = 2 * values.shape[0] + 5 + axis = np.linspace(-0.5, 0.5, grid_scale) + grid_x, grid_y = np.meshgrid(axis, axis) + nearest_x = np.abs(axis[:, np.newaxis] - x).argmin(axis=0) + nearest_y = np.abs(axis[:, np.newaxis] - y).argmin(axis=0) + grad_x = np.empty_like(values, dtype=float) + grad_y = np.empty_like(values, dtype=float) + plot_data = [] + + for map_index in range(values.shape[1]): + interpolated = griddata_v4(x, y, values[:, map_index], grid_x, grid_y) + grid_grad_y, grid_grad_x = np.gradient(interpolated) + grad_x[:, map_index] = grid_grad_x[nearest_y, nearest_x] + grad_y[:, map_index] = grid_grad_y[nearest_y, nearest_x] + plot_data.append((interpolated, grid_grad_x, grid_grad_y)) + + if bool(draw): + _draw_gradients(axis, plot_data) + return grad_x, grad_y + + +def _coordinates(locations: Any) -> tuple[np.ndarray, np.ndarray]: + if isinstance(locations, (str, Path)): + locations = readlocs(locations) + array = np.asarray(locations) + if np.iscomplexobj(array) and array.ndim == 1: + x = np.real(array).astype(float) + y = np.imag(array).astype(float) + elif array.ndim == 2 and array.shape[1] == 2 and np.issubdtype(array.dtype, np.number): + x = np.asarray(array[:, 0], dtype=float) + y = np.asarray(array[:, 1], dtype=float) + else: + records = chanlocs_as_list(locations) + try: + theta = np.deg2rad(np.asarray([record["theta"] for record in records], dtype=float)) + radius = np.asarray([record["radius"] for record in records], dtype=float) + except (KeyError, TypeError, ValueError) as error: + raise ValueError("channel locations require finite theta and radius values") from error + x = np.cos(theta) * radius + y = np.sin(theta) * radius + if not np.all(np.isfinite(x)) or not np.all(np.isfinite(y)): + raise ValueError("electrode coordinates must be finite") + return x, y + + +def _draw_gradients(axis: np.ndarray, maps: list[tuple[np.ndarray, np.ndarray, np.ndarray]]) -> None: + columns = int(np.ceil(np.sqrt(len(maps)))) + rows = int(np.ceil(len(maps) / columns)) + figure, axes = plt.subplots(rows, columns, squeeze=False) + grid_x, grid_y = np.meshgrid(axis, axis) + mask = np.hypot(grid_x, grid_y) > 0.5 + for index, (interpolated, grad_x, grad_y) in enumerate(maps): + target = axes.flat[index] + display = np.where(mask, np.nan, interpolated) + target.contour(grid_x, grid_y, display) + step = max(1, axis.size // 20) + target.quiver( + grid_x[::step, ::step], + grid_y[::step, ::step], + np.where(mask, np.nan, grad_x)[::step, ::step], + np.where(mask, np.nan, grad_y)[::step, ::step], + ) + target.set_title(f"Map {index + 1}") + target.set_aspect("equal") + target.set_axis_off() + for target in axes.flat[len(maps) :]: + target.set_visible(False) + figure.tight_layout() + show_figures(figure) + + +__all__ = ["gradmap"] diff --git a/src/eegprep/functions/miscfunc/gradplot.py b/src/eegprep/functions/miscfunc/gradplot.py new file mode 100644 index 00000000..a15d9a63 --- /dev/null +++ b/src/eegprep/functions/miscfunc/gradplot.py @@ -0,0 +1,17 @@ +"""Plot spatial gradients of EEG scalp maps.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc.gradmap import gradmap + + +def gradplot(maps: Any, locations: Any, draw: bool | int = False) -> tuple[np.ndarray, np.ndarray]: + """Compute scalp-map gradients and optionally draw their vector fields.""" + return gradmap(maps, locations, draw=draw) + + +__all__ = ["gradplot"] diff --git a/src/eegprep/functions/miscfunc/headmovie.py b/src/eegprep/functions/miscfunc/headmovie.py new file mode 100644 index 00000000..c00d164c --- /dev/null +++ b/src/eegprep/functions/miscfunc/headmovie.py @@ -0,0 +1,56 @@ +"""Legacy 3-D scalp-movie compatibility wrapper.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np + +from eegprep.functions.miscfunc.eegmovie import eegmovie, movie_limits + + +def headmovie( + data: Any, + elec_loc: Any, + spline_file: str | Path | None = None, + srate: float = 0, + title: str = "", + camerapath: Any = None, + movieframes: Any = None, + minmax: Any = None, + startsec: float = 0, + *args: Any, + plot: str | bool = "on", + **kwargs: Any, +) -> tuple[np.ndarray, np.ndarray, float, float]: + """Render a 3-D scalp movie using EEGPrep's maintained ``eegmovie`` path. + + ``headmovie`` is deprecated in EEGLAB, but keeping this thin wrapper makes + historical scripts usable while sharing the tested renderer. + """ + values = np.asarray(data, dtype=float) + if np.isscalar(spline_file) and spline_file == 0: + spline_file = None + headplot_options = list(args) + for name, value in kwargs.items(): + headplot_options.extend((name, value)) + movie, colormap = eegmovie( + values, + srate, + elec_loc, + mode="3D", + headplotopt=headplot_options, + title=title, + camerapath=camerapath, + movieframes=movieframes, + minmax=minmax, + startsec=startsec, + spline_file=spline_file, + plot=plot, + ) + lower, upper = movie_limits(values, minmax) + return movie, colormap, lower, upper + + +__all__ = ["headmovie"] diff --git a/src/eegprep/functions/miscfunc/imagescloglog.py b/src/eegprep/functions/miscfunc/imagescloglog.py new file mode 100644 index 00000000..3f8023c4 --- /dev/null +++ b/src/eegprep/functions/miscfunc/imagescloglog.py @@ -0,0 +1,29 @@ +"""Display a matrix with logarithmic time and frequency axes.""" + +from __future__ import annotations + +from typing import Any + +from matplotlib.collections import QuadMesh + +from eegprep.functions.miscfunc._log_image import log_image +from eegprep.functions.miscfunc.imagesclogy import _properties + + +def imagescloglog( + times: Any, + freqs: Any, + data: Any, + clim: Any = None, + xticks: Any = None, + yticks: Any = None, + *args: Any, + ax: Any = None, + **kwargs: Any, +) -> QuadMesh: + """Plot frequency-by-time values on logarithmic x- and y-axes.""" + properties = _properties(args, kwargs) + return log_image(times, freqs, data, clim, xticks, yticks, properties, log_x=True, ax=ax) + + +__all__ = ["imagescloglog"] diff --git a/src/eegprep/functions/miscfunc/imagesclogy.py b/src/eegprep/functions/miscfunc/imagesclogy.py new file mode 100644 index 00000000..de718f2c --- /dev/null +++ b/src/eegprep/functions/miscfunc/imagesclogy.py @@ -0,0 +1,47 @@ +"""Display a matrix with a logarithmic frequency axis.""" + +from __future__ import annotations + +from typing import Any + +from matplotlib.collections import QuadMesh + +from eegprep.functions.miscfunc._log_image import log_image + + +def imagesclogy( + times: Any, + freqs: Any, + data: Any, + clim: Any = None, + xticks: Any = None, + yticks: Any = None, + *args: Any, + ax: Any = None, + **kwargs: Any, +) -> QuadMesh: + """Plot frequency-by-time values on a logarithmic y-axis. + + Extra positional arguments are interpreted as EEGLAB-style axes property + name/value pairs. The returned ``QuadMesh`` exposes the plotted data and + color limits for headless workflows. + """ + properties = _properties(args, kwargs) + return log_image(times, freqs, data, clim, xticks, yticks, properties, log_x=False, ax=ax) + + +def _properties(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: + if len(args) % 2: + raise TypeError("additional plot properties must be name/value pairs") + properties = dict(kwargs) + for index in range(0, len(args), 2): + name = args[index] + if not isinstance(name, str): + raise TypeError("plot property names must be strings") + if name in properties: + raise TypeError(f"plot property {name!r} was supplied twice") + properties[name] = args[index + 1] + return properties + + +__all__ = ["imagesclogy"] diff --git a/src/eegprep/functions/miscfunc/seemovie.py b/src/eegprep/functions/miscfunc/seemovie.py new file mode 100644 index 00000000..045d28c8 --- /dev/null +++ b/src/eegprep/functions/miscfunc/seemovie.py @@ -0,0 +1,82 @@ +"""Replay RGB or indexed image sequences as Matplotlib animations.""" + +from __future__ import annotations + +from typing import Any + +import matplotlib.pyplot as plt +from matplotlib.animation import FuncAnimation +from matplotlib.colors import ListedColormap +import numpy as np + +from eegprep.functions.popfunc.plot_utils import show_figures + + +def seemovie( + movie: Any, + ntimes: int = -10, + colormap: Any = None, + *, + fps: float = 10, + ax: Any = None, + plot: str | bool = "on", +) -> FuncAnimation: + """Return an animation for an EEGPrep movie. + + Positive ``ntimes`` values replay forward. Zero selects EEGLAB's default + of ten forward/backward repetitions, and negative values request that many + forward/backward repetitions. + """ + frames = np.asarray(movie) + if frames.ndim not in {3, 4} or frames.shape[0] == 0: + raise ValueError("movie must be a non-empty frames x height x width image sequence") + if frames.ndim == 4 and frames.shape[-1] not in {3, 4}: + raise ValueError("RGB movie frames must have three or four color channels") + frame_rate = float(fps) + if not np.isfinite(frame_rate) or frame_rate <= 0: + raise ValueError("fps must be positive") + if ntimes == 0: + ntimes = -10 + repeats = abs(int(ntimes)) + if int(ntimes) != ntimes: + raise ValueError("ntimes must be an integer") + forward = list(range(frames.shape[0])) + sequence = forward * repeats + if ntimes <= 0: + sequence = (forward + forward[-2:0:-1]) * repeats + + own_figure = ax is None + if own_figure: + figure, ax = plt.subplots() + else: + figure = ax.figure + image_options: dict[str, Any] = {"animated": True} + if frames.ndim == 3: + if colormap is None: + image_options["cmap"] = "gray" + else: + colors = np.asarray(colormap, dtype=float) + if colors.ndim != 2 or colors.shape[1] not in {3, 4}: + raise ValueError("colormap must contain RGB or RGBA rows") + image_options["cmap"] = ListedColormap(colors) + image = ax.imshow(frames[sequence[0]], **image_options) + ax.set_axis_off() + + def update(frame_index: int) -> tuple[Any]: + image.set_data(frames[frame_index]) + return (image,) + + animation = FuncAnimation( + figure, + update, + frames=sequence, + interval=1000.0 / frame_rate, + blit=True, + repeat=False, + ) + if own_figure: + show_figures(figure, plot=plot) + return animation + + +__all__ = ["seemovie"] diff --git a/src/eegprep/functions/miscfunc/setfont.py b/src/eegprep/functions/miscfunc/setfont.py new file mode 100644 index 00000000..262ec866 --- /dev/null +++ b/src/eegprep/functions/miscfunc/setfont.py @@ -0,0 +1,118 @@ +"""Matplotlib equivalent of EEGLAB's recursive ``setfont`` helper.""" + +from __future__ import annotations + +from typing import Any + +from matplotlib.axes import Axes +from matplotlib.figure import Figure +from matplotlib.text import Text + + +def setfont(handle: Figure | Axes, *args: Any, handletype: str | None = None, **kwargs: Any) -> Figure | Axes: + """Apply font properties to text in a Matplotlib figure or axes. + + ``handletype`` may be ``"xlabels"``, ``"ylabels"``, ``"titles"``, + ``"axis"``, or ``"strings"``. EEGLAB-style positional name/value pairs + and Python keyword properties are both accepted. + """ + positional = list(args) + if positional and str(positional[0]).lower() == "handletype": + if len(positional) < 2: + raise TypeError("handletype requires a value") + if handletype is not None: + raise TypeError("handletype was supplied twice") + handletype = str(positional[1]) + positional = positional[2:] + if len(positional) % 2: + raise TypeError("font properties must be name/value pairs") + properties = dict(kwargs) + for index in range(0, len(positional), 2): + name = positional[index] + if not isinstance(name, str): + raise TypeError("font property names must be strings") + properties[name] = positional[index + 1] + normalized = {_property_name(name): value for name, value in properties.items()} + axes = _axes(handle) + category = "" if handletype is None else handletype.lower() + if category not in {"", "xlabels", "ylabels", "titles", "axis", "strings"}: + raise ValueError(f"unrecognized handletype: {handletype}") + + if category == "xlabels": + targets: list[Any] = [axis.xaxis.label for axis in axes] + elif category == "ylabels": + targets = [axis.yaxis.label for axis in axes] + elif category == "titles": + targets = [axis.title for axis in axes] + elif category == "axis": + targets = axes + elif category == "strings": + targets = _all_text(handle) + else: + targets = [*axes, *_all_text(handle)] + + for target in _unique_objects(targets): + if isinstance(target, Axes): + _set_axis_font(target, normalized) + elif isinstance(target, Text): + target.set(**normalized) + return handle + + +def _axes(handle: Figure | Axes) -> list[Axes]: + if isinstance(handle, Figure): + return list(handle.axes) + if isinstance(handle, Axes): + return [handle] + raise TypeError("handle must be a Matplotlib Figure or Axes") + + +def _all_text(handle: Figure | Axes) -> list[Text]: + return list(handle.findobj(match=Text)) + + +def _unique_objects(values: list[Any]) -> list[Any]: + result = [] + seen = set() + for value in values: + identifier = id(value) + if identifier not in seen: + seen.add(identifier) + result.append(value) + return result + + +def _property_name(name: str) -> str: + normalized = name.replace("_", "").lower() + aliases = { + "fontsize": "fontsize", + "fontname": "fontfamily", + "fontfamily": "fontfamily", + "fontweight": "fontweight", + "fontstyle": "fontstyle", + "color": "color", + } + if normalized not in aliases: + raise ValueError(f"unsupported font property: {name}") + return aliases[normalized] + + +def _set_axis_font(axis: Axes, properties: dict[str, Any]) -> None: + text_properties = {name: value for name, value in properties.items() if name != "color"} + color = properties.get("color") + tick_properties: dict[str, Any] = {} + if "fontsize" in text_properties: + tick_properties["labelsize"] = text_properties.pop("fontsize") + if color is not None: + tick_properties["colors"] = color + axis.tick_params(axis="both", **tick_properties) + for label in [*axis.get_xticklabels(), *axis.get_yticklabels()]: + label.set(**text_properties) + if color is not None: + axis.xaxis.label.set_color(color) + axis.yaxis.label.set_color(color) + for spine in axis.spines.values(): + spine.set_color(color) + + +__all__ = ["setfont"] diff --git a/src/eegprep/functions/miscfunc/show_events.py b/src/eegprep/functions/miscfunc/show_events.py new file mode 100644 index 00000000..6b9db2fa --- /dev/null +++ b/src/eegprep/functions/miscfunc/show_events.py @@ -0,0 +1,211 @@ +"""Render event timing across epoched EEG data.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +import numpy as np + +from eegprep.functions.miscfunc.unique_cell_string import unique_cell_string +from eegprep.functions.popfunc._pop_utils import parse_key_value_args +from eegprep.functions.popfunc.plot_utils import show_figures + + +_MATLAB_LINE_COLORS = np.asarray( + [ + [0.0000, 0.4470, 0.7410], + [0.8500, 0.3250, 0.0980], + [0.9290, 0.6940, 0.1250], + [0.4940, 0.1840, 0.5560], + [0.4660, 0.6740, 0.1880], + [0.3010, 0.7450, 0.9330], + [0.6350, 0.0780, 0.1840], + ], + dtype=float, +) + + +def show_events( + EEG: Mapping[str, Any], + *args: Any, + event_thickness_coef: float = 1.0, + event_names: Any = None, + time_warp: Mapping[str, Any] | None = None, + ax: Any = None, + plot: str | bool = "on", + image_shape: tuple[int, int] = (960, 1200), + **kwargs: Any, +) -> np.ndarray: + """Return an RGB raster showing epoch-relative event latencies. + + Rows represent epochs, columns span ``EEG.xmin`` to ``EEG.xmax``, and + event types receive stable categorical colors. Events excluded by a + ``make_timewarp`` result are dimmed. A figure is also drawn unless + ``plot="off"`` is requested. + """ + options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) + event_thickness_coef = float( + options.pop("eventthicknesscoef", options.pop("event_thickness_coef", event_thickness_coef)) + ) + event_names = options.pop("eventnames", options.pop("event_names", event_names)) + time_warp = options.pop("timewarp", options.pop("time_warp", time_warp)) + if options: + raise ValueError(f"show_events: unrecognized option: {next(iter(options))!r}") + if not np.isfinite(event_thickness_coef) or event_thickness_coef < 0: + raise ValueError("event_thickness_coef must be a non-negative finite number") + height, width = (int(image_shape[0]), int(image_shape[1])) + if height <= 0 or width <= 0: + raise ValueError("image_shape dimensions must be positive") + + epochs = list(EEG.get("epoch", [])) + if not epochs: + raise ValueError("show_events requires an epoched EEG dataset") + epoch_events = [_epoch_events(epoch) for epoch in epochs] + names = _event_names(event_names, time_warp, epoch_events) + if not names: + raise ValueError("show_events found no named events to display") + xmin_ms = float(EEG.get("xmin", 0.0)) * 1000.0 + xmax_ms = float(EEG.get("xmax", 0.0)) * 1000.0 + if not np.isfinite(xmin_ms) or not np.isfinite(xmax_ms) or xmax_ms <= xmin_ms: + raise ValueError("EEG.xmin and EEG.xmax must define a finite positive epoch duration") + + colors = _line_colors(len(names)) + marker_width = _event_marker_width(epoch_events, width, xmax_ms - xmin_ms, event_thickness_coef) + image = np.zeros((height, width, 3), dtype=float) + for epoch_index, events in enumerate(epoch_events): + row_start = round(epoch_index * height / len(epochs)) + row_stop = round((epoch_index + 1) * height / len(epochs)) + for event_type, latency in events: + if event_type not in names: + continue + event_index = names.index(event_type) + center = round(width * (latency - xmin_ms) / (xmax_ms - xmin_ms)) + start = max(0, center - marker_width // 2) + stop = min(width, start + marker_width) + if stop <= 0 or start >= width: + continue + color = colors[event_index] + if not _accepted(time_warp, epoch_index, latency): + color = color * 0.3 + image[row_start:row_stop, start:stop] = np.maximum(image[row_start:row_stop, start:stop], color) + + own_figure = ax is None + if own_figure: + figure, ax = plt.subplots() + else: + figure = ax.figure + ax.imshow( + image, + aspect="auto", + origin="upper", + extent=(xmin_ms, xmax_ms, len(epochs) + 0.5, 0.5), + ) + handles = [ + Line2D([], [], color=color, linewidth=8, label=name.replace("_", "-")) for name, color in zip(names, colors) + ] + ax.legend(handles=handles, loc="upper left") + ax.set_xlabel("Latency (ms)") + ax.set_ylabel("Epochs") + if own_figure: + figure.tight_layout() + show_figures(figure, plot=plot) + return image + + +def _epoch_events(epoch: Any) -> list[tuple[str, float]]: + if not isinstance(epoch, Mapping): + raise ValueError("EEG.epoch entries must be mappings") + types = _values(epoch.get("eventtype", [])) + latencies = _values(epoch.get("eventlatency", [])) + if len(types) != len(latencies): + raise ValueError("each epoch must contain one eventlatency per eventtype") + result = [] + for event_type, latency in zip(types, latencies): + numeric_latency = float(_scalar(latency)) + if not np.isfinite(numeric_latency): + raise ValueError("epoch event latencies must be finite scalars") + result.append((_event_string(event_type), numeric_latency)) + return result + + +def _values(value: Any) -> list[Any]: + array = np.asarray(value, dtype=object) + if array.size == 0: + return [] + return array.reshape(-1).tolist() + + +def _scalar(value: Any) -> Any: + array = np.asarray(value) + if array.size != 1: + raise ValueError("epoch event latencies must be finite scalars") + return array.reshape(-1)[0].item() + + +def _event_string(value: Any) -> str: + value = _scalar(value) + if isinstance(value, (int, float, np.integer, np.floating)): + return format(float(value), "g") + return str(value) + + +def _event_names( + requested: Any, + time_warp: Mapping[str, Any] | None, + epoch_events: list[list[tuple[str, float]]], +) -> list[str]: + if requested is not None and np.asarray(requested, dtype=object).size: + values = [_event_string(value) for value in _values(requested)] + elif time_warp: + sequence = time_warp.get("event_sequence", time_warp.get("eventSequence", [])) + values = [] + for value in _values(sequence): + if isinstance(value, (list, tuple, np.ndarray)): + values.extend(_event_string(item) for item in _values(value)) + else: + values.append(_event_string(value)) + else: + values = [event_type for events in epoch_events for event_type, _latency in events] + return unique_cell_string(values) + + +def _event_marker_width( + epoch_events: list[list[tuple[str, float]]], + image_width: int, + duration_ms: float, + coefficient: float, +) -> int: + intervals = [] + for events in epoch_events: + latencies = sorted(latency for _event_type, latency in events) + intervals.extend(np.diff(latencies).tolist()) + if not intervals: + return max(1, round(0.05 * coefficient)) + quantile = float(np.quantile(np.asarray(intervals, dtype=float), 0.2)) + return max(1, round(coefficient * 0.5 * image_width * quantile / duration_ms)) + + +def _line_colors(count: int) -> np.ndarray: + return np.vstack([_MATLAB_LINE_COLORS[index % len(_MATLAB_LINE_COLORS)] for index in range(count)]) + + +def _accepted(time_warp: Mapping[str, Any] | None, epoch_index: int, latency: float) -> bool: + if not time_warp: + return True + epochs = np.asarray(time_warp.get("epochs", []), dtype=int).ravel() + python_style = "event_sequence" in time_warp + target = epoch_index if python_style else epoch_index + 1 + matches = np.flatnonzero(epochs == target) + if matches.size == 0: + return False + latencies = np.asarray(time_warp.get("latencies", []), dtype=float) + if latencies.size == 0: + return True + row = latencies.reshape(len(epochs), -1)[matches[0]] + return bool(np.any(np.isclose(row, latency, rtol=0.0, atol=1e-9))) + + +__all__ = ["show_events"] diff --git a/tests/test_misc_visual_movie_eeglab_tests.py b/tests/test_misc_visual_movie_eeglab_tests.py new file mode 100644 index 00000000..37a40d02 --- /dev/null +++ b/tests/test_misc_visual_movie_eeglab_tests.py @@ -0,0 +1,476 @@ +"""Ports of current EEGLAB miscellaneous visualization/movie tests.""" + +from __future__ import annotations + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import pytest + +from eegprep.functions.guifunc.menu_spec import menu_item, menu_to_inventory +from eegprep.functions.miscfunc.eegmovie import eegmovie +from eegprep.functions.miscfunc.gradmap import gradmap +from eegprep.functions.miscfunc.gradplot import gradplot +from eegprep.functions.miscfunc.headmovie import headmovie +from eegprep.functions.miscfunc.imagescloglog import imagescloglog +from eegprep.functions.miscfunc.imagesclogy import imagesclogy +from eegprep.functions.miscfunc.seemovie import seemovie +from eegprep.functions.miscfunc.setfont import setfont +from eegprep.functions.miscfunc.show_events import show_events +from eegprep.functions.sigprocfunc.eegplot import eegplot +from eegprep.functions.sigprocfunc.headplot import headplot_setup +from tests.eeglab_tests import eeglab_test + + +EEGMOVIE = "unittesting_miscfunc/eegmovie/miscfunc_eegmovie_wrapperTest.m" +EEGPLOTGOLD = "unittesting_miscfunc/eegplotgold/miscfunc_eegplotgold_wrapperTest.m" +EEGPLOTSOLD = "unittesting_miscfunc/eegplotsold/miscfunc_eegplotsold_wrapperTest.m" +GETALLMENUS = "unittesting_miscfunc/getallmenus/miscfunc_getallmenus_wrapperTest.m" +GRADMAP = "unittesting_miscfunc/gradmap/miscfunc_gradmap_wrapperTest.m" +GRADPLOT = "unittesting_miscfunc/gradplot/miscfunc_gradplot_wrapperTest.m" +HEADMOVIE = "unittesting_miscfunc/headmovie/miscfunc_headmovie_wrapperTest.m" +HELP2HTML = "unittesting_miscfunc/help2html/miscfunc_help2html_wrapperTest.m" +HELPFOREXE = "unittesting_miscfunc/helpforexe/miscfunc_helpforexe_wrapperTest.m" +IMAGESCLOGLOG = "unittesting_miscfunc/imagescloglog/miscfunc_imagescloglog_wrapperTest.m" +IMAGESCLOGY = "unittesting_miscfunc/imagesclogy/miscfunc_imagesclogy_wrapperTest.m" +MAKEHTML = "unittesting_miscfunc/makehtml/miscfunc_makehtml_wrapperTest.m" +SEEMOVIE = "unittesting_miscfunc/seemovie/miscfunc_seemovie_wrapperTest.m" +SETFONT = "unittesting_miscfunc/setfont/miscfunc_setfont_wrapperTest.m" +SHOW_EVENTS = "unittesting_miscfunc/show_events/miscfunc_show_events_wrapperTest.m" +TEXTGUI = "unittesting_miscfunc/textgui/miscfunc_textgui_wrapperTest.m" + + +def _polar_locations(count: int = 8) -> list[dict[str, float | str]]: + angles = np.linspace(0.0, 360.0, count, endpoint=False) + locations = [] + for index, angle in enumerate(angles): + radians = np.deg2rad(angle) + locations.append( + { + "labels": f"E{index + 1}", + "theta": float(angle), + "radius": 0.4, + "X": float(np.cos(radians)), + "Y": float(np.sin(radians)), + "Z": 0.5, + } + ) + return locations + + +def _center_gradient_input() -> tuple[np.ndarray, np.ndarray]: + scale = np.sqrt(2.0) / 2.0 + x = np.asarray([scale, 1, scale, 0, 0, 0, -scale, -1, -scale]) / 2.0 + y = np.asarray([-scale, 0, scale, -1, 0, 1, -scale, 0, scale]) / 2.0 + values = np.asarray([1, 1, 1, 1, 2, 1, 1, 1, 1], dtype=float) + return values, x + 1j * y + + +def _assert_center_gradient(function) -> None: + values, locations = _center_gradient_input() + gradient_x, gradient_y = function(values, locations, True) + assert gradient_x.shape == gradient_y.shape == (9, 1) + assert np.all(gradient_x[:3] < 0) + assert np.allclose(gradient_x[3:6], 0, atol=1e-12) + assert np.all(gradient_x[6:] > 0) + assert np.all(gradient_y[[0, 3, 6]] > 0) + assert np.allclose(gradient_y[[1, 4, 7]], 0, atol=1e-12) + assert np.all(gradient_y[[2, 5, 8]] < 0) + assert plt.get_fignums() + plt.close("all") + + +def _assert_corner_gradient(function) -> None: + x = np.asarray([1, 1, 1, 0, 0, 0, -1, -1, -1], dtype=float) / 2.0 + y = np.asarray([-1, 0, 1, -1, 0, 1, -1, 0, 1], dtype=float) / 2.0 + values = np.asarray([3, 4, 5, 2, 3, 4, 1, 2, 3], dtype=float) + gradient_x, gradient_y = function(values, x + 1j * y, True) + assert np.all(gradient_x >= -1e-12) + assert np.all(gradient_y >= -1e-12) + assert plt.get_fignums() + plt.close("all") + + +def _write_center_locations(path) -> None: + theta = (-45, 0, 45, -90, 0, 90, -135, 180, 135) + radius = (0.5, 0.5, 0.5, 0.5, 0.0, 0.5, 0.5, 0.5, 0.5) + rows = [f"{index}\t{angle}\t{rad}\tE{index}" for index, (angle, rad) in enumerate(zip(theta, radius), 1)] + path.write_text("\n".join(rows) + "\n", encoding="utf-8") + + +@eeglab_test(EEGMOVIE, "test_fail_no_arg") +def test_eegmovie_requires_data() -> None: + with pytest.raises(TypeError): + eegmovie() # ty: ignore[missing-argument] + + +@eeglab_test(EEGMOVIE, "test_pass_general") +def test_eegmovie_returns_replayable_rgb_frames() -> None: + data = np.arange(32, dtype=float).reshape(8, 4) + movie, colormap = eegmovie( + data, + 128, + _polar_locations(), + movieframes=[1, 4], + timecourse="off", + plot="off", + ) + assert movie.shape[0] == 2 + assert movie.ndim == 4 and movie.shape[-1] == 3 + assert movie.dtype == np.uint8 + assert not np.array_equal(movie[0], movie[1]) + assert colormap.shape == (65, 3) + assert np.all((colormap >= 0) & (colormap <= 1)) + + +@eeglab_test(EEGPLOTGOLD, "test_fail_no_chanfile") +def test_modern_eegplot_does_not_require_legacy_channel_file() -> None: + model = eegplot(np.zeros((3, 10)), show=False) + assert model.data.channel_labels == ("1", "2", "3") + + +@eeglab_test(EEGPLOTGOLD, "test_pass_all_args") +def test_modern_eegplot_normalizes_all_relevant_legacy_display_inputs() -> None: + model = eegplot( + np.arange(40, dtype=float).reshape(4, 10), + "srate", + 20, + "spacing", + 5, + "winlength", + 0.4, + "title", + "legacy trace", + "show", + False, + ) + assert model.state.srate == 20 + assert model.state.spacing == 5 + assert model.state.winlength == 0.4 + assert model.state.title == "legacy trace" + + +@eeglab_test(EEGPLOTGOLD, "test_pass_general") +def test_modern_eegplot_builds_a_channel_major_browser_model() -> None: + values = np.arange(24, dtype=float).reshape(3, 8) + model = eegplot(values, show=False) + assert np.array_equal(model.data.flat_data, values) + assert model.data.n_channels == 3 + + +@eeglab_test(EEGPLOTGOLD, "test_pass_no_chanlocs") +def test_modern_eegplot_uses_numeric_labels_without_locations() -> None: + model = eegplot(np.zeros((4, 12)), show=False) + assert model.data.channel_labels == ("1", "2", "3", "4") + + +@eeglab_test(EEGPLOTGOLD, "test_pass_no_chanlocs_large") +def test_modern_eegplot_supports_large_location_free_montages() -> None: + model = eegplot(np.zeros((128, 2)), show=False) + assert model.data.n_channels == 128 + assert model.data.channel_labels[-1] == "128" + + +@eeglab_test(EEGPLOTGOLD, "test_pass_no_title") +def test_modern_eegplot_has_a_stable_empty_title_default() -> None: + assert eegplot(np.zeros((3, 4)), show=False).state.title == "Scroll activity -- eegplot()" + + +@eeglab_test(EEGPLOTSOLD, "test_pass_one_arg") +def test_modern_eegplot_replaces_the_one_argument_eegplotsold_path() -> None: + model = eegplot(np.ones((3, 5)), show=False) + assert model.data.total_samples == 5 + + +@eeglab_test(EEGPLOTSOLD, "test_pass_general") +def test_modern_eegplot_replaces_the_general_eegplotsold_path() -> None: + model = eegplot(np.ones((3, 50)), srate=100, show=False) + assert model.state.srate == 100 + + +@eeglab_test(EEGPLOTSOLD, "test_pass_all_args") +def test_modern_eegplot_replaces_eegplotsold_display_options() -> None: + model = eegplot(np.ones((3, 50)), srate=100, limits=(0.1, 0.3), color=("r",), show=False) + assert model.state.limits == (0.1, 0.3) + assert model.state.colors == ("r",) + + +@eeglab_test(GETALLMENUS, "test_pass_general") +def test_declarative_menu_inventory_replaces_matlab_handle_introspection() -> None: + items = ( + menu_item("a", children=(menu_item("aa"), menu_item("ab"))), + menu_item("b"), + menu_item("d", children=(menu_item("da"),)), + ) + inventory = menu_to_inventory(items) + assert [item["label"] for item in inventory] == ["a", "b", "d"] + assert [item["label"] for item in inventory[0]["children"]] == ["aa", "ab"] + assert inventory[2]["children"][0]["label"] == "da" + + +@eeglab_test(GRADMAP, "test_pass_center") +def test_gradmap_center_points_outward() -> None: + _assert_center_gradient(gradmap) + + +@eeglab_test(GRADMAP, "test_pass_center_file") +def test_gradmap_reads_eeglab_location_files(tmp_path) -> None: + location_file = tmp_path / "test.locs" + _write_center_locations(location_file) + values, _locations = _center_gradient_input() + gradient_x, gradient_y = gradmap(values, location_file, True) + assert np.all(gradient_x[:3] < 0) + assert np.all(gradient_y[[0, 3, 6]] > 0) + plt.close("all") + + +@eeglab_test(GRADMAP, "test_pass_corner") +def test_gradmap_corner_is_nonnegative() -> None: + _assert_corner_gradient(gradmap) + + +@eeglab_test(GRADPLOT, "test_fail_no_arg") +def test_gradplot_requires_inputs() -> None: + with pytest.raises(TypeError): + gradplot() # ty: ignore[missing-argument] + + +@eeglab_test(GRADPLOT, "test_pass_center") +def test_gradplot_center_points_outward() -> None: + _assert_center_gradient(gradplot) + + +@eeglab_test(GRADPLOT, "test_pass_center_file") +def test_gradplot_reads_eeglab_location_files(tmp_path) -> None: + location_file = tmp_path / "test.locs" + _write_center_locations(location_file) + values, _locations = _center_gradient_input() + gradient_x, gradient_y = gradplot(values, location_file, True) + assert np.all(gradient_x[:3] < 0) + assert np.all(gradient_y[[0, 3, 6]] > 0) + plt.close("all") + + +@eeglab_test(GRADPLOT, "test_pass_corner") +def test_gradplot_corner_is_nonnegative() -> None: + _assert_corner_gradient(gradplot) + + +@pytest.fixture(scope="module") +def headmovie_inputs(tmp_path_factory): + locations = _polar_locations() + spline = tmp_path_factory.mktemp("headmovie") / "movie.spl" + headplot_setup(locations, spline) + data = np.arange(24, dtype=float).reshape(8, 3) + return data, locations, spline + + +def _assert_headmovie(result, expected_frames: int) -> np.ndarray: + movie, colormap, lower, upper = result + assert movie.shape[0] == expected_frames + assert movie.shape[-1] == 3 and movie.dtype == np.uint8 + assert colormap.shape == (65, 3) + assert lower < 0 < upper and lower == -upper + return movie + + +@eeglab_test(HEADMOVIE, "test_pass_general") +def test_headmovie_general(headmovie_inputs) -> None: + data, locations, spline = headmovie_inputs + _assert_headmovie(headmovie(data, locations, spline, movieframes=[1], plot="off"), 1) + + +@eeglab_test(HEADMOVIE, "test_pass_camera") +def test_headmovie_camera_path_changes_the_view(headmovie_inputs) -> None: + data, locations, spline = headmovie_inputs + movie = _assert_headmovie( + headmovie(data, locations, spline, camerapath=[-127, 30, 30, 0], movieframes=[1, 2], plot="off"), + 2, + ) + assert not np.array_equal(movie[0], movie[1]) + + +@eeglab_test(HEADMOVIE, "test_pass_elevation") +def test_headmovie_elevation_path_changes_the_view(headmovie_inputs) -> None: + data, locations, spline = headmovie_inputs + movie = _assert_headmovie( + headmovie(data, locations, spline, camerapath=[-127, 0, 10, 20], movieframes=[1, 2], plot="off"), + 2, + ) + assert not np.array_equal(movie[0], movie[1]) + + +def _image_data() -> tuple[np.ndarray, np.ndarray, np.ndarray]: + times = np.arange(1, 5, dtype=float) + frequencies = np.arange(1, 5, dtype=float) + values = np.arange(1, 17, dtype=float).reshape(4, 4) + return times, frequencies, values + + +@eeglab_test(IMAGESCLOGY, "test_pass_general") +@eeglab_test(IMAGESCLOGY, "test_pass_clim") +def test_imagesclogy_data_and_color_limits() -> None: + times, frequencies, values = _image_data() + figure, axis = plt.subplots() + mesh = imagesclogy(times, frequencies, values, [10, 16], ax=axis) + assert axis.get_xscale() == "linear" + assert axis.get_yscale() == "log" + assert np.array_equal(mesh.get_array(), values) + assert mesh.get_clim() == (10.0, 16.0) + plt.close(figure) + + +@eeglab_test(IMAGESCLOGY, "test_i_pass_clim_xticks") +def test_imagesclogy_manual_color_check_has_deterministic_assertions() -> None: + times, frequencies, values = _image_data() + figure, axis = plt.subplots() + mesh = imagesclogy(times, frequencies, values, [8, 16], times, ax=axis) + assert mesh.norm(8) == 0 + assert mesh.norm(16) == 1 + assert np.array_equal(axis.get_xticks(), times) + plt.close(figure) + + +@eeglab_test(IMAGESCLOGY, "test_pass_ticks") +@eeglab_test(IMAGESCLOGY, "test_pass_xticks") +def test_imagesclogy_custom_ticks() -> None: + times, frequencies, values = _image_data() + figure, axis = plt.subplots() + imagesclogy(times, frequencies, values, None, [2, 3, 4], [1, 2], ax=axis) + assert np.array_equal(axis.get_xticks(), [2, 3, 4]) + assert np.array_equal(axis.get_yticks(), [1, 2]) + plt.close(figure) + + +@eeglab_test(IMAGESCLOGY, "test_pass_varargin") +def test_imagesclogy_applies_axes_properties() -> None: + times, frequencies, values = _image_data() + figure, axis = plt.subplots() + imagesclogy(times, frequencies, values, None, None, None, "YGrid", "on", ax=axis) + assert any(line.get_visible() for line in axis.get_ygridlines()) + plt.close(figure) + + +@eeglab_test(IMAGESCLOGLOG, "test_pass_general") +@eeglab_test(IMAGESCLOGLOG, "test_pass_clim") +def test_imagescloglog_data_and_color_limits() -> None: + times, frequencies, values = _image_data() + figure, axis = plt.subplots() + mesh = imagescloglog(times, frequencies, values, [10, 16], ax=axis) + assert axis.get_xscale() == axis.get_yscale() == "log" + assert np.array_equal(mesh.get_array(), values) + assert mesh.get_clim() == (10.0, 16.0) + plt.close(figure) + + +@eeglab_test(IMAGESCLOGLOG, "test_i_pass_clim_xticks") +def test_imagescloglog_manual_color_check_has_deterministic_assertions() -> None: + times, frequencies, values = _image_data() + figure, axis = plt.subplots() + mesh = imagescloglog(times, frequencies, values, [8, 16], times, ax=axis) + assert mesh.norm(8) == 0 + assert mesh.norm(16) == 1 + assert np.array_equal(axis.get_xticks(), times) + plt.close(figure) + + +@eeglab_test(IMAGESCLOGLOG, "test_pass_ticks") +@eeglab_test(IMAGESCLOGLOG, "test_pass_xticks") +def test_imagescloglog_custom_ticks() -> None: + times, frequencies, values = _image_data() + figure, axis = plt.subplots() + imagescloglog(times, frequencies, values, None, [2, 3, 4], [1, 2], ax=axis) + assert np.array_equal(axis.get_xticks(), [2, 3, 4]) + assert np.array_equal(axis.get_yticks(), [1, 2]) + plt.close(figure) + + +@eeglab_test(IMAGESCLOGLOG, "test_pass_varargin") +def test_imagescloglog_applies_axes_properties() -> None: + times, frequencies, values = _image_data() + figure, axis = plt.subplots() + imagescloglog(times, frequencies, values, None, None, None, "XGrid", "on", ax=axis) + assert any(line.get_visible() for line in axis.get_xgridlines()) + plt.close(figure) + + +@eeglab_test(SEEMOVIE, "test_test_seemovie") +def test_seemovie_preserves_legacy_forward_backward_sequence() -> None: + frames = np.zeros((4, 5, 6, 3), dtype=np.uint8) + frames[:, :, :, 0] = np.arange(4)[:, None, None] + colormap = plt.get_cmap("turbo")(np.linspace(0, 1, 65))[:, :3] + animation = seemovie(frames, -1, colormap, fps=20, plot="off") + assert list(animation.new_frame_seq()) == [0, 1, 2, 3, 2, 1] + + +@eeglab_test(SETFONT, "test_test_setfont") +def test_setfont_updates_all_text_then_selected_xlabels() -> None: + figure, axis = plt.subplots() + axis.plot(np.arange(10)) + axis.set_xlabel("test") + axis.set_ylabel("test2") + axis.set_title("test3") + setfont(figure, "fontsize", 12) + setfont(figure, "handletype", "xlabels", "fontsize", 18) + assert axis.xaxis.label.get_fontsize() == 18 + assert axis.yaxis.label.get_fontsize() == 12 + assert axis.title.get_fontsize() == 12 + plt.close(figure) + + +@eeglab_test(SHOW_EVENTS, "test_test_show_events") +def test_show_events_renders_and_dims_timewarp_rejections() -> None: + eeg = { + "xmin": -0.2, + "xmax": 0.8, + "epoch": [ + {"eventtype": ["square", "rt"], "eventlatency": [0, 300]}, + {"eventtype": ["square", "rt"], "eventlatency": [0, 500]}, + {"eventtype": ["square", "rt"], "eventlatency": [0, 700]}, + ], + } + baseline = show_events(eeg, plot="off", image_shape=(30, 100)) + time_warp = { + "event_sequence": ["square", "rt"], + "epochs": np.asarray([0, 2]), + "latencies": np.asarray([[0, 300], [0, 700]]), + } + warped = show_events( + eeg, + "eventThicknessCoef", + 0.5, + "eventNames", + ["square", "rt"], + "timeWarp", + time_warp, + plot="off", + image_shape=(30, 100), + ) + assert baseline.shape == warped.shape == (30, 100, 3) + square_column = 20 + assert np.allclose(warped[15, square_column], baseline[15, square_column] * 0.3) + assert np.allclose(warped[5, square_column], baseline[5, square_column]) + + +@eeglab_test(HELP2HTML, "test_pass_general") +@eeglab_test(HELP2HTML, "test_pass_one_arg") +def test_help2html_is_superseded_by_sphinx() -> None: + pytest.skip("current MATLAB bodies are commented out; EEGPrep publishes help through Sphinx") + + +@eeglab_test(HELPFOREXE, "test_test_helpforexe") +def test_helpforexe_is_matlab_compiler_specific() -> None: + pytest.skip("generating MATLAB help_*.m compiler shims is not part of a standalone Python runtime") + + +@eeglab_test(MAKEHTML, "test_pass_general") +def test_makehtml_is_superseded_by_sphinx() -> None: + pytest.skip("current MATLAB body is commented out; EEGPrep builds its website with Sphinx") + + +@eeglab_test(TEXTGUI, "test_test_textgui") +def test_textgui_callback_eval_is_intentionally_excluded() -> None: + pytest.skip("MATLAB textgui executes callback strings; EEGPrep uses safe declarative dialogs and packaged help") From 1d4525ca63c8e192cf31878011e1f688b4cad08a Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:56 -0700 Subject: [PATCH 38/45] Add grouped STUDY plotting parity (#366) --- docs/source/api/study_workflows.rst | 2 + docs/source/changelog.rst | 4 + .../examples/plot_group_analysis_study.py | 5 +- docs/source/user_guide/study_workflows.rst | 28 + src/eegprep/__init__.py | 2 + .../functions/studyfunc/_std_measureplot.py | 737 ++++++++++++++++-- .../studyfunc/_study_measure_cells.py | 258 ++++++ .../functions/studyfunc/std_erpplot.py | 2 +- .../functions/studyfunc/std_erspplot.py | 2 +- .../functions/studyfunc/std_itcplot.py | 2 +- .../functions/studyfunc/std_precomp.py | 211 ++++- .../functions/studyfunc/std_specplot.py | 2 +- src/eegprep/functions/studyfunc/std_stat.py | 219 ++++++ .../functions/studyfunc/std_topoplot.py | 233 ++++++ ...tudy_grouped_measure_plots_eeglab_tests.py | 369 +++++++++ tests/test_study_measures.py | 16 +- tests/test_study_workflows_eeglab_tests.py | 33 +- 17 files changed, 2014 insertions(+), 111 deletions(-) create mode 100644 src/eegprep/functions/studyfunc/_study_measure_cells.py create mode 100644 src/eegprep/functions/studyfunc/std_stat.py create mode 100644 src/eegprep/functions/studyfunc/std_topoplot.py create mode 100644 tests/test_study_grouped_measure_plots_eeglab_tests.py diff --git a/docs/source/api/study_workflows.rst b/docs/source/api/study_workflows.rst index caf51246..229d1fae 100644 --- a/docs/source/api/study_workflows.rst +++ b/docs/source/api/study_workflows.rst @@ -73,6 +73,7 @@ Measures eegprep.std_readitc eegprep.std_readspec eegprep.std_specplot + eegprep.std_topoplot Plot and Statistics Parameters ------------------------------ @@ -86,6 +87,7 @@ Plot and Statistics Parameters eegprep.pop_erspparams eegprep.pop_specparams eegprep.pop_statparams + eegprep.std_stat Plotting -------- diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index c3840edb..11556922 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,10 @@ the `GitHub Releases `_ page. Unreleased ========== +- STUDY ERP, spectrum, ERSP, ITC, and scalp-map functions now arrange cached + measures by condition and group, retain subject or component cases, return + parametric or resampled statistics and FDR masks, reconstruct saved + time-frequency trials, and plot grouped traces, images, and topographies. - Added standalone miscellaneous visualization utilities for spatial scalp-map gradients, logarithmic-axis matrix images, epoched event rasters, recursive Matplotlib font styling, and replayable 2-D/3-D scalp movies. Movie generation diff --git a/docs/source/examples/plot_group_analysis_study.py b/docs/source/examples/plot_group_analysis_study.py index 57d1569a..3794b512 100644 --- a/docs/source/examples/plot_group_analysis_study.py +++ b/docs/source/examples/plot_group_analysis_study.py @@ -86,8 +86,9 @@ print("cached fields:", sorted(k for k in STUDY["changrp"][0] if k.endswith("data"))) # %% -# Read the cached ERP for one channel without opening a figure. Cells are -# ``(subjects, times)``; drop ``noplot`` to get a Matplotlib figure back. +# Read the cached ERP for one channel without opening a figure. A one-factor +# design returns one ``(times, subjects)`` cell per level; drop ``noplot`` to +# get a Matplotlib figure back. STUDY, erpdata, erptimes, _ = std_erpplot(STUDY, ALLEEG, channels=[1], noplot="on") print("erp cells:", len(erpdata), "cell shape:", erpdata[0].shape) diff --git a/docs/source/user_guide/study_workflows.rst b/docs/source/user_guide/study_workflows.rst index 5dfd227d..b86d93b4 100644 --- a/docs/source/user_guide/study_workflows.rst +++ b/docs/source/user_guide/study_workflows.rst @@ -101,6 +101,14 @@ Precompute and plot STUDY measures: STUDY, erpdata, erptimes, fig = std_erpplot(STUDY, ALLEEG, channels=[1]) STUDY, itcdata, itctimes, itcfreqs = std_readitc(STUDY, ALLEEG, channels=[1]) +Plot functions arrange these caches into the selected design before returning +them. A one-factor design returns a list with one array per factor level. A +two-factor design returns ``data[condition][group]``. Samples are the leading +axes and subjects or cluster members are on the last axis; for example, ERP +cells are ``times x cases`` and ERSP cells are ``frequencies x times x cases``. +This matches the cell organization used by EEGLAB while keeping the arrays +directly usable from NumPy. + Channel measures are stored in ``STUDY.changrp``. Component measures are stored on the parent ``STUDY.cluster[0]`` entry so preclustering can read the same cached arrays. Cached measure fields follow EEGLAB names such as ``erpdata``, @@ -120,6 +128,26 @@ Store reusable plot and statistics choices on the STUDY before plotting: STUDY = pop_erpparams(STUDY, timerange=[-200, 800], plotconditions="together") STUDY = pop_statparams(STUDY, condstats="on", method="perm", naccu=2000) +Pass ``return_stats=True`` to ``std_erpplot``, ``std_specplot``, +``std_erspplot``, or ``std_itcplot`` to receive ``pgroup``, ``pcond``, and +``pinter`` before the returned figure. These are p-values when ``threshold`` +is NaN and numeric significance masks when a finite ``threshold`` is set, +matching EEGLAB's output contract. A sequence of thresholds produces EEGLAB's +graded masks. ``mcorrect="fdr"`` applies +Benjamini-Hochberg correction. +The same masks are attached to ``fig.eegprep_plot_metadata["statistics"]`` so +downstream reporting code can inspect exactly what the plot highlighted. + +Use ``savetrials="on"`` with ERSP/ITC precomputation when the per-trial +time-frequency representation is needed. ``erspdatatrials`` stores linear, +baseline-corrected power, so averaging trials and converting to decibels +reproduces ``erspdata``. ``itcdatatrials`` stores phase in radians. Both are +EEGPrep-owned cache fields and remain independent of an EEGLAB installation. + +For component clusters, ``std_topoplot`` draws polarity-aligned centroid or +member scalp maps and caches ``topo``, ``topoall``, and ``topopol`` on each +plotted cluster. + The corresponding ``pop_erpimparams``, ``pop_erspparams``, ``pop_specparams``, and ``pop_dipparams`` functions store settings under ``STUDY["etc"]`` using EEGLAB field names. Changing an ERP time range, spectrum frequency range, or diff --git a/src/eegprep/__init__.py b/src/eegprep/__init__.py index aa81a4a3..7880350d 100644 --- a/src/eegprep/__init__.py +++ b/src/eegprep/__init__.py @@ -518,7 +518,9 @@ "std_selectdesign": ("eegprep.functions.studyfunc.std_selectdesign", "std_selectdesign"), "std_selsubject": ("eegprep.functions.studyfunc.std_selsubject", "std_selsubject"), "std_specplot": ("eegprep.functions.studyfunc.std_specplot", "std_specplot"), + "std_stat": ("eegprep.functions.studyfunc.std_stat", "std_stat"), "std_substudy": ("eegprep.functions.studyfunc.std_substudy", "std_substudy"), + "std_topoplot": ("eegprep.functions.studyfunc.std_topoplot", "std_topoplot"), "std_uniformfiles": ("eegprep.functions.studyfunc.std_uniformfiles", "std_uniformfiles"), "std_uniformsetinds": ("eegprep.functions.studyfunc.std_uniformsetinds", "std_uniformsetinds"), "timef": ("eegprep.functions.timefreqfunc.timef", "timef"), diff --git a/src/eegprep/functions/studyfunc/_std_measureplot.py b/src/eegprep/functions/studyfunc/_std_measureplot.py index 8ef19302..df26b265 100644 --- a/src/eegprep/functions/studyfunc/_std_measureplot.py +++ b/src/eegprep/functions/studyfunc/_std_measureplot.py @@ -1,26 +1,62 @@ -"""Shared plotting helpers for STUDY cached measures.""" +"""Shared reading, grouping, statistics, and plotting for STUDY measures.""" from __future__ import annotations +from copy import deepcopy from typing import Any import matplotlib.pyplot as plt import numpy as np from eegprep.functions.popfunc._pop_utils import is_on, parse_key_value_args +from eegprep.functions.sigprocfunc.topoplot import topoplot +from eegprep.functions.studyfunc._cluster_utils import cluster_list +from eegprep.functions.studyfunc._study_measure_cells import ( + GroupedMeasure, + group_channel_measures, + group_component_measures, +) from eegprep.functions.studyfunc._study_utils import MEASURE_DATA_FIELDS, build_python_call, range_mask from eegprep.functions.studyfunc.std_readdata import std_readdata +from eegprep.functions.studyfunc.std_stat import StudyStatistics, std_stat LINE_MEASURES = {"erp", "spec"} +PARAMETER_SECTIONS = {"erp": "erpparams", "spec": "specparams", "ersp": "erspparams", "itc": "erspparams"} +PARAMETER_OPTIONS = { + "filter", + "subtractsubjectmean", + "timerange", + "freqrange", + "topotime", + "topofreq", + "averagechan", + "detachplots", + "ylim", + "ersplim", + "itclim", + "maskdata", + "averagemode", + "subbaseline", + "plotgroups", + "plotconditions", +} +STATISTIC_OPTIONS = { + "effect", + "groupstats", + "condstats", + "singletrials", + "statistics", + "threshold", + "alpha", + "method", + "mcorrect", + "naccu", +} def default_measure_target(study: dict[str, Any], field: str, channels: Any, clusters: Any, components: Any): - """Choose channels-vs-parent-cluster default target by cached measure field. - - Returns ``("channels", clusters)`` when any STUDY channel group caches - ``field``; otherwise defaults to the parent component cluster (``1``). - """ + """Choose channels-vs-parent-cluster default target by cached measure field.""" if channels is not None or clusters is not None or components is not None: return channels, clusters if any(isinstance(group, dict) and field in group for group in study.get("changrp") or []): @@ -39,36 +75,84 @@ def std_measureplot( design: int | None = None, noplot: str | bool = "off", plotmode: str = "normal", + return_stats: bool = False, return_com: bool = False, **kwargs: Any, ) -> tuple[Any, ...]: - """Read and optionally plot cached STUDY measure data.""" + """Read cached measures, arrange design cells, compute statistics, and plot.""" options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) channels = options.pop("channels", channels) clusters = options.pop("clusters", clusters) components = options.pop("components", options.pop("comps", components)) - design = options.pop("design", design) + design = int(options.pop("design", design or STUDY.get("currentdesign") or 1)) noplot = options.pop("noplot", noplot) plotmode = str(options.pop("plotmode", plotmode) or "normal").lower() - timerange = options.pop("timerange", None) - freqrange = options.pop("freqrange", None) - ignored = {"subject", "plotsubjects", "topoplotopt", "plotconditions", "mode"} - unsupported = sorted(key for key in options if key not in ignored) - if unsupported: - raise ValueError(f"Unknown std_{datatype}plot option(s): {', '.join(unsupported)}") + return_stats = bool(options.pop("return_stats", return_stats)) + subject = options.pop("subject", None) + plotsubjects = options.pop("plotsubjects", "off") + mode = str(options.pop("mode", "") or "").lower() + if mode in {"comps", "apart"}: + plotsubjects = "on" + elif mode not in {"", "centroid", "together"}: + raise ValueError("mode must be 'centroid'/'together' or 'comps'/'apart'") + options.pop("statmode", None) + plotstderr = options.pop("plotstderr", "off") + topoplotopt = options.pop("topoplotopt", None) + caxis = options.pop("caxis", None) + + parameters, statistic_options = _plot_options(STUDY, datatype, options) + timerange = parameters.get("timerange") + freqrange = parameters.get("freqrange") + plotconditions = str(parameters.get("plotconditions") or "apart").lower() + plotgroups = str(parameters.get("plotgroups") or "apart").lower() + if plotconditions not in {"apart", "together"} or plotgroups not in {"apart", "together"}: + raise ValueError("plotconditions and plotgroups must be 'apart' or 'together'") channels, clusters = _default_target(STUDY, datatype, channels, clusters, components) - study, data, x_axis, y_axis = std_readdata( + study, grouped, x_axis, y_axis, target_titles = _read_grouped( STUDY, ALLEEG, - datatype=datatype, + datatype, channels=channels, clusters=clusters, components=components, design=design, + subject=subject, ) - data, x_axis, y_axis = _apply_ranges(data, datatype, x_axis, y_axis, timerange, freqrange) - figure = None if is_on(noplot) or plotmode == "none" else plot_measure_data(data, datatype, x_axis, y_axis) + channel_names = _channel_names(study, channels) if channels is not None else None + grouped = [_apply_group_ranges(item, datatype, x_axis, y_axis, timerange, freqrange) for item in grouped] + x_axis = _axis_subset(x_axis, timerange if datatype != "spec" else freqrange) + if datatype not in LINE_MEASURES: + y_axis = _axis_subset(y_axis, freqrange) + + stats_config = _statistics_config(study, statistic_options, design=design) + statistics = [std_stat(item.cells, stats_config, return_result=True) for item in grouped] + topography = _topography_requested(parameters, datatype) + if topography and channels is None: + raise ValueError("topotime/topofreq scalp maps require channel measures") + figure = None + if not is_on(noplot) and plotmode != "none": + figure = _plot_grouped_measures( + grouped, + datatype, + x_axis, + y_axis, + statistics, + target_titles, + ALLEEG, + topography=topography, + channel_names=channel_names, + topotime=parameters.get("topotime"), + topofreq=parameters.get("topofreq"), + plotconditions=plotconditions, + plotgroups=plotgroups, + plotsubjects=is_on(plotsubjects), + plotstderr=is_on(plotstderr), + caxis=caxis if caxis is not None else _measure_limits(parameters, datatype), + topoplotopt=topoplotopt, + ) + output_data = grouped[-1].output() + output_statistics = statistics[-1] command = _history_command( datatype, channels=channels, @@ -77,53 +161,572 @@ def std_measureplot( design=design, noplot=noplot, plotmode=plotmode, - timerange=timerange, - freqrange=freqrange, + subject=subject, + plotsubjects=plotsubjects, + return_stats=return_stats, + **parameters, + **statistic_options, + ) + return _result( + datatype, + study, + output_data, + x_axis, + y_axis, + output_statistics, + figure, + command, + return_stats=return_stats, + return_com=return_com, ) - return _result(datatype, study, data, x_axis, y_axis, figure, command, return_com=return_com) -def _default_target( - study: dict[str, Any], datatype: str, channels: Any, clusters: Any, components: Any -) -> tuple[Any, Any]: - return default_measure_target(study, MEASURE_DATA_FIELDS[datatype], channels, clusters, components) +def _plot_options( + study: dict[str, Any], datatype: str, options: dict[str, Any] +) -> tuple[dict[str, Any], dict[str, Any]]: + section = PARAMETER_SECTIONS[datatype] + stored = (study.get("etc") or {}).get(section) + parameters = deepcopy(stored) if isinstance(stored, dict) else {} + statistics = {} + unsupported = [] + for key, value in options.items(): + if key in PARAMETER_OPTIONS: + parameters[key] = value + elif key in STATISTIC_OPTIONS: + statistics[key] = value + else: + unsupported.append(key) + if unsupported: + raise ValueError(f"Unknown std_{datatype}plot option(s): {', '.join(sorted(unsupported))}") + return parameters, statistics + + +def _statistics_config(study: dict[str, Any], overrides: dict[str, Any], *, design: int) -> dict[str, Any]: + stored = (study.get("etc") or {}).get("statistics") + config = deepcopy(stored) if isinstance(stored, dict) else {} + eeglab = config.get("eeglab") + if not isinstance(eeglab, dict): + eeglab = {} + config["eeglab"] = eeglab + for key, value in overrides.items(): + normalized = {"threshold": "alpha", "statistics": "method"}.get(key, key) + if normalized in {"effect", "groupstats", "condstats", "singletrials", "mode"}: + config[normalized] = value + else: + eeglab[normalized] = value + variables = _design_variables(study, design) + config["paired"] = [str(variable.get("pairing") or "off") for variable in variables[:2]] + while len(config["paired"]) < 2: + config["paired"].append("off") + return config -def _apply_ranges( - data: list[np.ndarray], +def _read_grouped( + study: dict[str, Any], + alleeg: list[dict[str, Any]] | None, + datatype: str, + *, + channels: Any, + clusters: Any, + components: Any, + design: int, + subject: Any, +) -> tuple[dict[str, Any], list[GroupedMeasure], np.ndarray, np.ndarray, list[str]]: + if channels is not None: + study, raw, x_axis, y_axis = std_readdata(study, alleeg, datatype=datatype, channels=channels) + grouped = group_channel_measures(study, raw, datatype, design=design, subject=subject) + names = _channel_names(study, channels) + return study, [grouped], x_axis, y_axis, [", ".join(names)] + + cluster_indices = _cluster_indices(study, clusters) + grouped_targets = [] + target_titles = [] + x_axis = np.asarray([]) + y_axis = np.asarray([]) + for cluster_index in cluster_indices: + study, raw, current_x, current_y = std_readdata( + study, + alleeg, + datatype=datatype, + clusters=cluster_index, + components=None, + ) + x_axis = _shared_axis(x_axis, current_x, "measure") + y_axis = _shared_axis(y_axis, current_y, "frequency") + grouped_targets.append( + group_component_measures( + study, + raw[0], + datatype, + cluster_index=cluster_index, + components=components, + design=design, + subject=subject, + ) + ) + target_titles.append(str(cluster_list(study)[cluster_index - 1].get("name") or f"Cluster {cluster_index}")) + return study, grouped_targets, x_axis, y_axis, target_titles + + +def _shared_axis(existing: np.ndarray, current: np.ndarray, name: str) -> np.ndarray: + current = np.asarray(current, dtype=float) + if existing.size == 0: + return current + if existing.shape != current.shape or not np.allclose(existing, current): + raise ValueError(f"selected targets do not share a common {name} axis") + return existing + + +def _apply_group_ranges( + grouped: GroupedMeasure, datatype: str, x_axis: np.ndarray, y_axis: np.ndarray, timerange: Any, freqrange: Any, -) -> tuple[list[np.ndarray], np.ndarray, np.ndarray]: +) -> GroupedMeasure: + x_mask = _axis_mask(x_axis, timerange if datatype != "spec" else freqrange) + y_mask = _axis_mask(y_axis, freqrange) if datatype not in LINE_MEASURES else None + cells = [] + for row in grouped.cells: + selected_row = [] + for values in row: + array = np.asarray(values) + if datatype in LINE_MEASURES: + selected_row.append(array if x_mask is None else array[x_mask, ...]) + else: + if y_mask is not None: + array = array[y_mask, ...] + if x_mask is not None: + array = array[:, x_mask, ...] + selected_row.append(array) + cells.append(selected_row) + return GroupedMeasure(cells, grouped.conditions, grouped.groups, grouped.cases) + + +def _plot_grouped_measures( + targets: list[GroupedMeasure], + datatype: str, + x_axis: np.ndarray, + y_axis: np.ndarray, + statistics: list[StudyStatistics], + target_titles: list[str], + alleeg: list[dict[str, Any]] | None, + **options: Any, +) -> Any: + if options.pop("topography"): + return _plot_channel_topographies(targets[0], datatype, x_axis, y_axis, statistics[0], alleeg, **options) + if len(targets) > 1: + return _plot_multiple_targets(targets, datatype, x_axis, y_axis, statistics, target_titles, **options) + grouped = targets[0] + if datatype in LINE_MEASURES: + return _plot_grouped_lines(grouped, datatype, x_axis, statistics[0], title=target_titles[0], **options) + return _plot_grouped_images(grouped, datatype, x_axis, y_axis, statistics[0], title=target_titles[0], **options) + + +def _plot_grouped_lines( + grouped: GroupedMeasure, + datatype: str, + x_axis: np.ndarray, + statistics: StudyStatistics, + *, + title: str, + plotconditions: str, + plotgroups: str, + plotsubjects: bool, + plotstderr: bool, + caxis: Any, + **_options: Any, +) -> Any: + rows = 1 if plotconditions == "together" else len(grouped.conditions) + columns = 1 if plotgroups == "together" else len(grouped.groups) + fig, axes = plt.subplots(rows, columns, squeeze=False, figsize=(5 * columns, 3.5 * rows)) + for condition_index, condition in enumerate(grouped.conditions): + for group_index, group in enumerate(grouped.groups): + axis = axes[0 if rows == 1 else condition_index, 0 if columns == 1 else group_index] + values = grouped.cells[condition_index][group_index] + _draw_line_cell( + axis, + x_axis, + values, + f"{condition} / {group}", + plotsubjects=plotsubjects, + plotstderr=plotstderr, + ) + _draw_line_significance(axis, x_axis, statistics, condition_index, group_index) + for row in axes: + for axis in row: + axis.set_xlabel("Time (ms)" if datatype == "erp" else "Frequency (Hz)") + axis.set_ylabel("uV" if datatype == "erp" else "Power 10*log10(uV^2/Hz)") + axis.grid(True, alpha=0.25) + if caxis is not None and np.asarray(caxis).size == 2: + axis.set_ylim(np.asarray(caxis, dtype=float).ravel()) + if axis.lines: + axis.legend(fontsize=8) + fig.suptitle(f"STUDY {datatype.upper()} — {title}") + _attach_metadata(fig, grouped, statistics) + fig.tight_layout() + return fig + + +def _draw_line_cell( + axis: Any, + x_axis: np.ndarray, + values: np.ndarray, + label: str, + *, + plotsubjects: bool, + plotstderr: bool, +) -> None: + array = np.asarray(values, dtype=float) + if array.ndim == 3: + array = np.nanmean(array, axis=1) + if array.ndim != 2: + raise ValueError("grouped line cells must be samples x cases, with an optional channel axis") + if array.shape[-1] == 0: + axis.set_title(f"{label} (no observations)") + return + if plotsubjects: + for case_index in range(array.shape[-1]): + axis.plot(x_axis, array[:, case_index], color="0.7", linewidth=0.8, alpha=0.8) + mean = np.nanmean(array, axis=-1) + axis.plot(x_axis, mean, linewidth=2.0, label=label) + if plotstderr and array.shape[-1] > 1: + sem = np.nanstd(array, axis=-1, ddof=1) / np.sqrt(array.shape[-1]) + axis.fill_between(x_axis, mean - sem, mean + sem, alpha=0.2) + + +def _draw_line_significance( + axis: Any, + x_axis: np.ndarray, + statistics: StudyStatistics, + condition_index: int, + group_index: int, +) -> None: + masks = [] + if group_index < len(statistics.condmask): + masks.append(statistics.condmask[group_index]) + if condition_index < len(statistics.groupmask): + masks.append(statistics.groupmask[condition_index]) + if not masks: + return + mask = np.logical_or.reduce([np.asarray(value, dtype=bool) for value in masks]) + while mask.ndim > 1: + mask = np.any(mask, axis=-1) + if mask.size == x_axis.size and np.any(mask): + axis.fill_between(x_axis, 0, 1, where=mask, color="gold", alpha=0.14, transform=axis.get_xaxis_transform()) + + +def _plot_grouped_images( + grouped: GroupedMeasure, + datatype: str, + x_axis: np.ndarray, + y_axis: np.ndarray, + statistics: StudyStatistics, + *, + title: str, + caxis: Any, + plotsubjects: bool, + **_options: Any, +) -> Any: + panels = _image_panels(grouped, datatype, plotsubjects=plotsubjects) + columns = int(np.ceil(np.sqrt(len(panels)))) + rows = int(np.ceil(len(panels) / columns)) + fig, axes = plt.subplots(rows, columns, squeeze=False, figsize=(5 * columns, 3.8 * rows)) + limits = np.asarray(caxis, dtype=float).ravel() if caxis is not None else np.asarray([]) + for axis, panel in zip(axes.flat, panels): + condition_index, group_index, label, image = panel + kwargs = {"vmin": limits[0], "vmax": limits[1]} if limits.size == 2 else {} + mesh = axis.imshow( + image, + aspect="auto", + origin="lower", + extent=[float(x_axis[0]), float(x_axis[-1]), float(y_axis[0]), float(y_axis[-1])], + **kwargs, + ) + mask = _image_mask(statistics, condition_index, group_index) + if mask is not None and mask.shape == image.shape and np.any(mask): + axis.contour(x_axis, y_axis, mask.astype(float), levels=[0.5], colors="white", linewidths=1.0) + axis.set_title(label) + axis.set_xlabel("Time (ms)") + axis.set_ylabel("Frequency (Hz)") + fig.colorbar(mesh, ax=axis) + for axis in axes.flat[len(panels) :]: + axis.set_visible(False) + fig.suptitle(f"STUDY {datatype.upper()} — {title}") + _attach_metadata(fig, grouped, statistics) + fig.tight_layout() + return fig + + +def _image_panels( + grouped: GroupedMeasure, datatype: str, *, plotsubjects: bool +) -> list[tuple[int, int, str, np.ndarray]]: + panels = [] + for condition_index, condition in enumerate(grouped.conditions): + for group_index, group in enumerate(grouped.groups): + cell = grouped.cells[condition_index][group_index] + if plotsubjects and cell.shape[-1]: + for case_index, case in enumerate(grouped.cases[condition_index][group_index]): + panels.append( + (condition_index, group_index, f"{condition} / {group} — {case}", _case_image(cell, case_index)) + ) + else: + panels.append((condition_index, group_index, f"{condition} / {group}", _mean_image(cell, datatype))) + return panels + + +def _case_image(values: np.ndarray, case_index: int) -> np.ndarray: + array = np.asarray(values, dtype=float)[..., case_index] + return np.nanmean(array, axis=-1) if array.ndim == 3 else array + + +def _mean_image(values: np.ndarray, datatype: str) -> np.ndarray: + array = np.asarray(values, dtype=float) + if array.ndim == 4: + array = np.sqrt(np.nanmean(array**2, axis=2)) if datatype == "ersp" else np.nanmean(array, axis=2) + if array.ndim != 3: + raise ValueError("grouped time-frequency cells must be frequency x time x cases") + if array.shape[-1] == 0: + return np.full(array.shape[:2], np.nan) + return np.nanmean(array, axis=-1) + + +def _image_mask(statistics: StudyStatistics, condition_index: int, group_index: int) -> np.ndarray | None: + masks = [] + if group_index < len(statistics.condmask): + masks.append(statistics.condmask[group_index]) + if condition_index < len(statistics.groupmask): + masks.append(statistics.groupmask[condition_index]) + if not masks: + return None + mask = np.logical_or.reduce([np.asarray(value, dtype=bool) for value in masks]) + while mask.ndim > 2: + mask = np.any(mask, axis=-1) + return mask + + +def _plot_multiple_targets( + targets: list[GroupedMeasure], + datatype: str, + x_axis: np.ndarray, + y_axis: np.ndarray, + statistics: list[StudyStatistics], + titles: list[str], + **options: Any, +) -> Any: + columns = int(np.ceil(np.sqrt(len(targets)))) + rows = int(np.ceil(len(targets) / columns)) + fig, axes = plt.subplots(rows, columns, squeeze=False, figsize=(5 * columns, 3.8 * rows)) + for index, (grouped, title) in enumerate(zip(targets, titles)): + axis = axes.flat[index] + cell_means = [] + for row in grouped.cells: + for cell in row: + array = np.asarray(cell, dtype=float) + if datatype in LINE_MEASURES: + while array.ndim > 2: + array = np.nanmean(array, axis=1) + cell_means.append(np.nanmean(array, axis=-1)) + else: + cell_means.append(_mean_image(array, datatype)) + mean = np.nanmean(np.stack(cell_means), axis=0) + if datatype in LINE_MEASURES: + axis.plot(x_axis, mean) + axis.set_xlabel("Time (ms)" if datatype == "erp" else "Frequency (Hz)") + else: + axis.imshow( + mean, + aspect="auto", + origin="lower", + extent=[float(x_axis[0]), float(x_axis[-1]), float(y_axis[0]), float(y_axis[-1])], + ) + axis.set_title(title) + for axis in axes.flat[len(targets) :]: + axis.set_visible(False) + fig.suptitle(f"STUDY {datatype.upper()} clusters") + setattr(fig, "eegprep_plot_metadata", {"targets": titles, "statistics": statistics}) + fig.tight_layout() + return fig + + +def _plot_channel_topographies( + grouped: GroupedMeasure, + datatype: str, + x_axis: np.ndarray, + y_axis: np.ndarray, + statistics: StudyStatistics, + alleeg: list[dict[str, Any]] | None, + *, + channel_names: list[str] | None, + topotime: Any, + topofreq: Any, + caxis: Any, + topoplotopt: Any, + **_options: Any, +) -> Any: + datasets = list(alleeg or []) + if not datasets: + raise ValueError("channel topographies require ALLEEG channel locations") + channel_count = _channel_count(grouped.cells[0][0], datatype) + if channel_names is None or len(channel_names) != channel_count: + raise ValueError("channel topographies require names for every selected channel") + available = { + str(location.get("labels") or "").lower(): location for location in (datasets[0].get("chanlocs") or []) + } + missing = [name for name in channel_names if name.lower() not in available] + if missing: + raise ValueError(f"channel locations are missing for: {', '.join(missing)}") + chanlocs = [available[name.lower()] for name in channel_names] + if len(chanlocs) != channel_count or channel_count < 2: + raise ValueError("channel topographies require matching channel locations") + rows = len(grouped.conditions) + columns = len(grouped.groups) + fig, axes = plt.subplots(rows, columns, squeeze=False, figsize=(4 * columns, 3.8 * rows)) + limits = np.asarray(caxis, dtype=float).ravel() if caxis is not None else np.asarray([]) + topo_kwargs = _topoplot_options(topoplotopt) + if limits.size == 2: + topo_kwargs["maplimits"] = limits + for condition_index, condition in enumerate(grouped.conditions): + for group_index, group in enumerate(grouped.groups): + values = _topography_values( + grouped.cells[condition_index][group_index], datatype, x_axis, y_axis, topotime, topofreq + ) + axis = axes[condition_index, group_index] + if np.isfinite(values).any(): + topoplot(values, chanlocs, axes=axis, colorbar=False, **topo_kwargs) + else: + axis.axis("off") + axis.set_title(f"{condition} / {group}") + fig.suptitle(f"STUDY {datatype.upper()} scalp map") + _attach_metadata(fig, grouped, statistics) + fig.tight_layout() + return fig + + +def _channel_count(values: np.ndarray, datatype: str) -> int: + array = np.asarray(values) + expected = 3 if datatype in LINE_MEASURES else 4 + if array.ndim != expected: + raise ValueError("scalp-map plotting requires two or more selected channels") + return int(array.shape[-2]) + + +def _topography_values( + values: np.ndarray, + datatype: str, + x_axis: np.ndarray, + y_axis: np.ndarray, + topotime: Any, + topofreq: Any, +) -> np.ndarray: + array = np.asarray(values, dtype=float) + if datatype in LINE_MEASURES: + bounds = topotime if datatype == "erp" else topofreq + mask = _nearest_or_range_mask(x_axis, bounds) + return np.nanmean(array[mask, ...], axis=(0, -1)) + time_mask = _nearest_or_range_mask(x_axis, topotime) + frequency_mask = _nearest_or_range_mask(y_axis, topofreq) + selected = array[frequency_mask, ...][:, time_mask, ...] + return np.nanmean(selected, axis=(0, 1, -1)) + + +def _nearest_or_range_mask(axis: np.ndarray, bounds: Any) -> np.ndarray: + values = np.asarray(bounds if bounds is not None else [], dtype=float).ravel() + if values.size == 0: + return np.ones(axis.size, dtype=bool) + if values.size == 1: + mask = np.zeros(axis.size, dtype=bool) + mask[int(np.argmin(np.abs(axis - values[0])))] = True + return mask + return (axis >= np.min(values)) & (axis <= np.max(values)) + + +def _topoplot_options(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return dict(value) + if not isinstance(value, (list, tuple)) or len(value) % 2: + raise ValueError("topoplotopt must be a dictionary or key/value sequence") + return {str(value[index]).lower(): value[index + 1] for index in range(0, len(value), 2)} + + +def _attach_metadata(figure: Any, grouped: GroupedMeasure, statistics: StudyStatistics) -> None: + setattr( + figure, + "eegprep_plot_metadata", + { + "conditions": grouped.conditions, + "groups": grouped.groups, + "cases": grouped.cases, + "statistics": statistics, + }, + ) + + +def _topography_requested(parameters: dict[str, Any], datatype: str) -> bool: if datatype == "erp": - return _select_last_axis(data, x_axis, timerange), _axis_subset(x_axis, timerange), y_axis + return _has_values(parameters.get("topotime")) if datatype == "spec": - return _select_last_axis(data, x_axis, freqrange), _axis_subset(x_axis, freqrange), y_axis - data = _select_last_axis(data, x_axis, timerange) - x_axis = _axis_subset(x_axis, timerange) - data = _select_frequency_axis(data, y_axis, freqrange) - y_axis = _axis_subset(y_axis, freqrange) - return data, x_axis, y_axis + return _has_values(parameters.get("topofreq")) + return _has_values(parameters.get("topotime")) and _has_values(parameters.get("topofreq")) -def _select_last_axis(data: list[np.ndarray], axis: np.ndarray, bounds: Any) -> list[np.ndarray]: - mask = _axis_mask(axis, bounds) - if mask is None: - return data - return [np.asarray(values)[..., mask] for values in data] +def _measure_limits(parameters: dict[str, Any], datatype: str) -> Any: + return parameters.get({"erp": "ylim", "spec": "ylim", "ersp": "ersplim", "itc": "itclim"}[datatype]) -def _select_frequency_axis(data: list[np.ndarray], axis: np.ndarray, bounds: Any) -> list[np.ndarray]: - mask = _axis_mask(axis, bounds) - if mask is None: - return data - selected = [] - for values in data: - array = np.asarray(values) - selected.append(array[..., mask, :]) - return selected +def _has_values(value: Any) -> bool: + if value is None: + return False + array = np.asarray(value).reshape(-1) + return bool(array.size and not np.isnan(np.asarray(array, dtype=float)[0])) + + +def _channel_names(study: dict[str, Any], channels: Any) -> list[str]: + groups = [group for group in study.get("changrp") or [] if isinstance(group, dict)] + if isinstance(channels, str) and channels == "channels": + return [str(group.get("name") or "channel") for group in groups] + if isinstance(channels, str): + return [channels] + if isinstance(channels, (list, tuple)) and channels and isinstance(channels[0], str): + return [str(value) for value in channels] + indices = np.asarray(channels if channels is not None else [], dtype=int).ravel() + if indices.size == 0: + return [str(group.get("name") or "channel") for group in groups] + return [str(groups[int(index) - 1].get("name") or index) for index in indices] + + +def _cluster_indices(study: dict[str, Any], clusters: Any) -> list[int]: + entries = cluster_list(study) + if isinstance(clusters, str) and clusters.lower() == "all": + selected = [index for index in range(2, len(entries) + 1) if not _excluded_cluster(entries[index - 1])] + return selected or [1] + values = np.asarray(clusters if clusters is not None else [1], dtype=int).ravel() + if not values.size or np.any(values < 1) or np.any(values > len(entries)): + raise ValueError(f"clusters must be 1-based and within 1..{len(entries)}") + return values.astype(int).tolist() + + +def _excluded_cluster(cluster: dict[str, Any]) -> bool: + name = str(cluster.get("name") or "").lower() + return name.startswith("notclust") or name.startswith("parentcluster") + + +def _design_variables(study: dict[str, Any], design: int) -> list[dict[str, Any]]: + designs = study.get("design") or [] + if not designs: + return [] + if design < 1 or design > len(designs): + raise ValueError(f"design must be 1-based and within 1..{len(designs)}") + return [value for value in designs[design - 1].get("variable") or [] if isinstance(value, dict)] + + +def _default_target( + study: dict[str, Any], datatype: str, channels: Any, clusters: Any, components: Any +) -> tuple[Any, Any]: + return default_measure_target(study, MEASURE_DATA_FIELDS[datatype], channels, clusters, components) def _axis_subset(axis: np.ndarray, bounds: Any) -> np.ndarray: @@ -152,7 +755,7 @@ def plot_measure_data( title: str | None = None, line_labels: list[str] | None = None, ) -> Any: - """Plot cached STUDY measure arrays read by ``std_readdata``.""" + """Plot raw cached arrays (the compact contract used by ``pop_chanplot``).""" if datatype in LINE_MEASURES: return _plot_lines(data, datatype, x_axis, title=title, line_labels=line_labels) return _plot_image(data, datatype, x_axis, y_axis, title=title) @@ -184,26 +787,17 @@ def _line_series(data: list[np.ndarray], line_labels: list[str] | None) -> list[ for group_index, values in enumerate(data, start=1): array = np.asarray(values, dtype=float) if array.ndim == 2: - series.append((_next_label(labels, f"Group {group_index}"), np.nanmean(array, axis=0))) + series.append((next(labels, f"Group {group_index}"), np.nanmean(array, axis=0))) elif array.ndim == 3: for component_index, component_values in enumerate(np.nanmean(array, axis=0), start=1): - series.append((_next_label(labels, f"IC {component_index}"), component_values)) + series.append((next(labels, f"IC {component_index}"), component_values)) else: raise ValueError("line measure data must be 2-D or 3-D") return series -def _next_label(labels: Any, fallback: str) -> str: - return next(labels, fallback) - - def _plot_image( - data: list[np.ndarray], - datatype: str, - x_axis: np.ndarray, - y_axis: np.ndarray, - *, - title: str | None, + data: list[np.ndarray], datatype: str, x_axis: np.ndarray, y_axis: np.ndarray, *, title: str | None ) -> Any: images = [] for values in data: @@ -235,6 +829,9 @@ def _history_command(datatype: str, **kwargs: Any) -> str: targets = ("STUDY", f"{datatype.upper()}DATA", _line_axis_name(datatype), "FIGURE") else: targets = ("STUDY", f"{datatype.upper()}DATA", f"{datatype.upper()}TIMES", f"{datatype.upper()}FREQS", "FIGURE") + if kwargs.pop("return_stats", False): + targets = (*targets[:-1], "PGROUP", "PCOND", "PINTER", targets[-1]) + kwargs["return_stats"] = True return build_python_call(targets, f"std_{datatype}plot", "STUDY", "ALLEEG", **kwargs) @@ -245,16 +842,22 @@ def _line_axis_name(datatype: str) -> str: def _result( datatype: str, study: dict[str, Any], - data: list[np.ndarray], + data: Any, x_axis: np.ndarray, y_axis: np.ndarray, + statistics: StudyStatistics, figure: Any, command: str, *, + return_stats: bool, return_com: bool, ) -> tuple[Any, ...]: - if datatype in LINE_MEASURES: - result = (study, data, x_axis, figure) - else: - result = (study, data, x_axis, y_axis, figure) + result = (study, data, x_axis) if datatype in LINE_MEASURES else (study, data, x_axis, y_axis) + if return_stats: + pcond, pgroup, pinter = statistics.output() + result = (*result, pgroup, pcond, pinter) + result = (*result, figure) return (*result, command) if return_com else result + + +__all__ = ["default_measure_target", "plot_measure_data", "std_measureplot"] diff --git a/src/eegprep/functions/studyfunc/_study_measure_cells.py b/src/eegprep/functions/studyfunc/_study_measure_cells.py new file mode 100644 index 00000000..f88222d2 --- /dev/null +++ b/src/eegprep/functions/studyfunc/_study_measure_cells.py @@ -0,0 +1,258 @@ +"""Arrange dataset/component measure caches into STUDY design cells.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from eegprep.functions.popfunc.plot_utils import numeric_vector +from eegprep.functions.studyfunc._cluster_utils import cluster_list, sets_array +from eegprep.functions.studyfunc.std_readdata import component_dataset_axis, component_measure_axis + + +@dataclass(frozen=True) +class GroupedMeasure: + """A condition-by-group measure grid with case metadata.""" + + cells: list[list[np.ndarray]] + conditions: list[str] + groups: list[str] + cases: list[list[list[str]]] + + def output(self) -> list[np.ndarray] | list[list[np.ndarray]]: + """Use a flat condition list for one-factor designs.""" + if len(self.groups) == 1: + return [row[0] for row in self.cells] + return self.cells + + +def group_channel_measures( + study: dict[str, Any], + data: list[np.ndarray], + datatype: str, + *, + design: int, + subject: Any = None, +) -> GroupedMeasure: + """Combine selected channel caches and split their dataset axis by design.""" + arrays = [np.asarray(values) for values in data] + if not arrays: + raise ValueError("No channel measure data were selected") + expected = arrays[0].shape + if any(array.shape != expected for array in arrays): + raise ValueError("selected channel caches must have matching shapes") + raw = np.stack(arrays, axis=1) + if len(arrays) == 1: + raw = raw[:, 0, ...] + dataset_ids = np.arange(1, raw.shape[0] + 1, dtype=int) + return _group_cases(study, raw, dataset_ids, datatype, design=design, subject=subject, component_cases=False) + + +def group_component_measures( + study: dict[str, Any], + data: np.ndarray, + datatype: str, + *, + cluster_index: int, + components: Any, + design: int, + subject: Any = None, +) -> GroupedMeasure: + """Split parent or child component measures by STUDY design cells.""" + clusters = cluster_list(study) + source = clusters[0] + values = np.asarray(data) + if cluster_index == 1: + dataset_axis = component_dataset_axis(source, values.shape[0]) + component_axis = component_measure_axis(source, values.shape[1]) + selection = _component_positions(components, component_axis) + rows = [] + dataset_ids = [] + case_labels = [] + for dataset_position, dataset_id in enumerate(dataset_axis.tolist()): + for component_position in selection.tolist(): + row = values[dataset_position, component_position, ...] + if np.isnan(row).all(): + continue + component_id = int(component_axis[component_position]) + rows.append(row) + dataset_ids.append(int(dataset_id)) + case_labels.append(_component_label(study, int(dataset_id), component_id)) + else: + cluster = clusters[cluster_index - 1] + sets = sets_array(cluster.get("sets")).astype(int)[0] + comps = np.asarray(cluster.get("comps") or [], dtype=int).ravel() + positions = _cluster_member_positions(components, comps.size) + rows = [values[position] for position in positions] + dataset_ids = [int(sets[position]) for position in positions] + case_labels = [_component_label(study, int(sets[position]), int(comps[position])) for position in positions] + if not rows: + raise ValueError("Selected STUDY cluster contains no cached components") + return _group_cases( + study, + np.asarray(rows), + np.asarray(dataset_ids, dtype=int), + datatype, + design=design, + subject=subject, + component_cases=True, + case_labels=case_labels, + ) + + +def _group_cases( + study: dict[str, Any], + raw: np.ndarray, + dataset_ids: np.ndarray, + datatype: str, + *, + design: int, + subject: Any, + component_cases: bool, + case_labels: list[str] | None = None, +) -> GroupedMeasure: + design_info = _design(study, design) + variables = [item for item in design_info.get("variable") or [] if isinstance(item, dict)] + if len(variables) > 2: + raise NotImplementedError("STUDY measure plots support at most two design variables") + conditions = _levels(variables[0]) if variables else [("All conditions", None)] + groups = _levels(variables[1]) if len(variables) > 1 else [("All groups", None)] + datasetinfo = study.get("datasetinfo") or [] + selected_subjects = _subject_values(subject) + allowed_cases = {str(value) for value in (design_info.get("cases") or {}).get("value", [])} + + cells: list[list[np.ndarray]] = [] + labels: list[list[list[str]]] = [] + for condition_label, condition_value in conditions: + row = [] + row_labels = [] + for group_label, group_value in groups: + positions = [] + labels_for_cell = [] + for position, dataset_id in enumerate(dataset_ids.tolist()): + info = datasetinfo[dataset_id - 1] + subject_name = str(info.get("subject") or f"S{dataset_id}") + if selected_subjects and subject_name not in selected_subjects: + continue + if allowed_cases and subject_name not in allowed_cases: + continue + if variables and not _matches(info.get(variables[0].get("label")), condition_value): + continue + if len(variables) > 1 and not _matches(info.get(variables[1].get("label")), group_value): + continue + positions.append(position) + labels_for_cell.append(case_labels[position] if case_labels else subject_name) + selected = raw[np.asarray(positions, dtype=int), ...] if positions else raw[:0, ...] + if not component_cases: + selected, labels_for_cell = _average_repeated_subjects(selected, labels_for_cell) + row.append(_case_last(selected, datatype)) + row_labels.append(labels_for_cell) + cells.append(row) + labels.append(row_labels) + return GroupedMeasure( + cells, + [label for label, _value in conditions], + [label for label, _value in groups], + labels, + ) + + +def _design(study: dict[str, Any], design: int) -> dict[str, Any]: + designs = study.get("design") or [] + if not designs: + return {"variable": [], "cases": {"value": []}} + if design < 1 or design > len(designs): + raise ValueError(f"design must be 1-based and within 1..{len(designs)}") + return designs[design - 1] + + +def _levels(variable: dict[str, Any]) -> list[tuple[str, Any]]: + values = list(variable.get("value") or []) + if not values: + return [(str(variable.get("label") or "All"), None)] + return [(_level_label(value), value) for value in values] + + +def _level_label(value: Any) -> str: + if isinstance(value, (list, tuple)): + return " + ".join(str(item) for item in value) + return str(value) + + +def _matches(actual: Any, level: Any) -> bool: + if level is None: + return True + if isinstance(level, (list, tuple)): + return any(_matches(actual, item) for item in level) + return _equal(actual, level) + + +def _equal(left: Any, right: Any) -> bool: + try: + return bool(float(left) == float(right)) + except (TypeError, ValueError): + return str(left) == str(right) + + +def _average_repeated_subjects(values: np.ndarray, labels: list[str]) -> tuple[np.ndarray, list[str]]: + ordered = list(dict.fromkeys(labels)) + if len(ordered) == len(labels): + return values, labels + averaged = [np.nanmean(values[np.asarray([label == subject for label in labels])], axis=0) for subject in ordered] + return np.asarray(averaged), ordered + + +def _case_last(values: np.ndarray, datatype: str) -> np.ndarray: + if datatype in {"erp", "spec"}: + if values.ndim not in {2, 3}: + raise ValueError("line-measure caches must have case, sample, and optional channel axes") + order = (values.ndim - 1,) if values.ndim == 2 else (2, 1) + return np.transpose(values, (*order, 0)) + if values.ndim not in {3, 4}: + raise ValueError("time-frequency caches must have case, frequency, time, and optional channel axes") + order = (1, 2) if values.ndim == 3 else (2, 3, 1) + return np.transpose(values, (*order, 0)) + + +def _component_positions(components: Any, axis: np.ndarray) -> np.ndarray: + if components is None or (isinstance(components, str) and components.lower() == "all"): + return np.arange(axis.size, dtype=int) + requested = numeric_vector(components, dtype=int) + positions = [] + for component in requested.tolist(): + found = np.where(axis == int(component))[0] + if not found.size: + raise ValueError(f"component {component} is not present in the parent cluster cache") + positions.append(int(found[0])) + return np.asarray(positions, dtype=int) + + +def _cluster_member_positions(components: Any, count: int) -> np.ndarray: + if components is None or (isinstance(components, str) and components.lower() == "all"): + return np.arange(count, dtype=int) + requested = numeric_vector(components, dtype=int) + if np.any(requested < 1) or np.any(requested > count): + raise ValueError(f"comps must be 1-based cluster member positions within 1..{count}") + return requested - 1 + + +def _component_label(study: dict[str, Any], dataset_id: int, component_id: int) -> str: + info = (study.get("datasetinfo") or [{}])[dataset_id - 1] + return f"{info.get('subject') or f'S{dataset_id}'}/IC{component_id}" + + +def _subject_values(subject: Any) -> set[str]: + if subject is None or subject == "": + return set() + if isinstance(subject, str): + return {subject} + if isinstance(subject, np.ndarray): + subject = subject.tolist() + if isinstance(subject, (list, tuple, set)): + return {str(item) for item in subject} + return {str(subject)} + + +__all__ = ["GroupedMeasure", "group_channel_measures", "group_component_measures"] diff --git a/src/eegprep/functions/studyfunc/std_erpplot.py b/src/eegprep/functions/studyfunc/std_erpplot.py index dcc17803..13bddc39 100644 --- a/src/eegprep/functions/studyfunc/std_erpplot.py +++ b/src/eegprep/functions/studyfunc/std_erpplot.py @@ -8,7 +8,7 @@ def std_erpplot(STUDY: dict[str, Any], ALLEEG: list[dict[str, Any]] | None, *args: Any, **kwargs: Any): - """Read and plot precomputed STUDY ERP measures.""" + """Plot precomputed ERPs grouped by the selected STUDY design.""" return std_measureplot(STUDY, ALLEEG, "erp", *args, **kwargs) diff --git a/src/eegprep/functions/studyfunc/std_erspplot.py b/src/eegprep/functions/studyfunc/std_erspplot.py index 2d0817bc..233e2ca6 100644 --- a/src/eegprep/functions/studyfunc/std_erspplot.py +++ b/src/eegprep/functions/studyfunc/std_erspplot.py @@ -8,7 +8,7 @@ def std_erspplot(STUDY: dict[str, Any], ALLEEG: list[dict[str, Any]] | None, *args: Any, **kwargs: Any): - """Read and plot precomputed STUDY ERSP measures.""" + """Plot precomputed ERSPs grouped by the selected STUDY design.""" return std_measureplot(STUDY, ALLEEG, "ersp", *args, **kwargs) diff --git a/src/eegprep/functions/studyfunc/std_itcplot.py b/src/eegprep/functions/studyfunc/std_itcplot.py index c56f585c..219b72a7 100644 --- a/src/eegprep/functions/studyfunc/std_itcplot.py +++ b/src/eegprep/functions/studyfunc/std_itcplot.py @@ -8,7 +8,7 @@ def std_itcplot(STUDY: dict[str, Any], ALLEEG: list[dict[str, Any]] | None, *args: Any, **kwargs: Any): - """Read and plot precomputed STUDY ITC measures.""" + """Plot precomputed ITCs grouped by the selected STUDY design.""" return std_measureplot(STUDY, ALLEEG, "itc", *args, **kwargs) diff --git a/src/eegprep/functions/studyfunc/std_precomp.py b/src/eegprep/functions/studyfunc/std_precomp.py index 4272fad7..670600a8 100644 --- a/src/eegprep/functions/studyfunc/std_precomp.py +++ b/src/eegprep/functions/studyfunc/std_precomp.py @@ -21,6 +21,8 @@ from eegprep.functions.studyfunc._study_utils import as_alleeg_list, build_python_call, ensure_study from eegprep.functions.studyfunc.std_checkset import std_checkset from eegprep.functions.timefreqfunc.newtimef import newtimef +from eegprep.functions.timefreqfunc.newtimefbaseln import newtimefbaseln +from eegprep.functions.timefreqfunc.newtimeftrialbaseln import newtimeftrialbaseln MEASURE_NAMES = ("erp", "spec", "ersp", "itc") @@ -37,6 +39,7 @@ def std_precomp( ersp: str | bool = "off", itc: str | bool = "off", scalp: str | bool = "off", + savetrials: str | bool = "off", allcomps: str | bool = "off", recompute: str | bool = "off", design: int | None = None, @@ -49,8 +52,9 @@ def std_precomp( """Precompute ERP, spectrum, ERSP, and ITC measures for a STUDY. Measures are stored directly in ``STUDY.changrp`` for channels and in the - parent ``STUDY.cluster`` entry for components. Field names follow EEGLAB's - cached-measure names while avoiding EEGLAB sidecar files at runtime. + parent ``STUDY.cluster`` entry for components. ``savetrials='on'`` also + retains baseline-corrected ERSP trial power and ITC phase. Field names + follow EEGLAB's cached-measure names without runtime sidecar dependencies. """ datasets = as_alleeg_list(ALLEEG) if not datasets: @@ -62,13 +66,14 @@ def std_precomp( ersp = options.pop("ersp", ersp) itc = options.pop("itc", itc) scalp = options.pop("scalp", scalp) + savetrials = options.pop("savetrials", savetrials) allcomps = options.pop("allcomps", allcomps) recompute = options.pop("recompute", recompute) design = options.pop("design", design if design is not None else study.get("currentdesign") or 1) erpparams = options.pop("erpparams", erpparams) specparams = options.pop("specparams", specparams) erspparams = options.pop("erspparams", erspparams) - ignored = {"interp", "savetrials", "rmicacomps", "rmclust", "cell", "erpim", "erpimparams"} + ignored = {"interp", "rmicacomps", "rmclust", "cell", "erpim", "erpimparams"} ignored_present = sorted(key for key in options if key in ignored) if ignored_present: logger.warning("std_precomp: ignoring EEGLAB-only option(s): %s", ", ".join(ignored_present)) @@ -92,6 +97,7 @@ def std_precomp( erpparams=_params_dict(erpparams), specparams=_params_dict(specparams), erspparams=_params_dict(erspparams), + savetrials=is_on(savetrials), ) else: cluster = _precompute_components( @@ -107,6 +113,7 @@ def std_precomp( erpparams=_params_dict(erpparams), specparams=_params_dict(specparams), erspparams=_params_dict(erspparams), + savetrials=is_on(savetrials), ) clusters = list(study.get("cluster") or []) if not clusters: @@ -131,6 +138,7 @@ def std_precomp( erpparams=erpparams, specparams=specparams, erspparams=erspparams, + savetrials=savetrials, ) return (study, datasets, command) if return_com else (study, datasets) @@ -146,6 +154,7 @@ def _precompute_channels( erpparams: dict[str, Any], specparams: dict[str, Any], erspparams: dict[str, Any], + savetrials: bool, ) -> list[dict[str, Any]]: labels = channel_labels(datasets[0]) selected = _channel_indices(chanorcomp, labels) @@ -176,27 +185,66 @@ def _precompute_channels( _carry(entry, prior, ("specdata", "specfreqs")) else: entry["specdata"], entry["specfreqs"] = _channel_spec(datasets, channel_index, specparams) - ersp_cached = _keep_cached(prior, "erspdata", force) if "ersp" in computed else True - itc_cached = _keep_cached(prior, "itcdata", force) if "itc" in computed else True + ersp_cached = ( + _keep_cached(prior, "erspdata", force) and (not savetrials or "erspdatatrials" in prior) + if "ersp" in computed + else True + ) + itc_cached = ( + _keep_cached(prior, "itcdata", force) and (not savetrials or "itcdatatrials" in prior) + if "itc" in computed + else True + ) if ("ersp" in computed and not ersp_cached) or ("itc" in computed and not itc_cached): - tf = _channel_time_frequency(datasets, channel_index, erspparams) + tf = _channel_time_frequency(datasets, channel_index, erspparams, savetrials=savetrials) else: tf = None if "ersp" in computed: if ersp_cached: - _carry(entry, prior, ("erspdata", "ersptimes", "erspfreqs", "erspbase")) + _carry( + entry, + prior, + ( + "erspdata", + "ersptimes", + "erspfreqs", + "erspbase", + "erspdatatrials", + "erspsubjinds", + "ersptrialinfo", + ), + ) else: entry["erspdata"] = tf["erspdata"] entry["ersptimes"] = tf["times"] entry["erspfreqs"] = tf["freqs"] entry["erspbase"] = tf["powbase"] + if savetrials: + entry["erspdatatrials"] = tf["erspdatatrials"] + entry["erspsubjinds"] = tf["subjinds"] + entry["ersptrialinfo"] = tf["trialinfo"] if "itc" in computed: if itc_cached: - _carry(entry, prior, ("itcdata", "itctimes", "itcfreqs")) + _carry( + entry, + prior, + ("itcdata", "itctimes", "itcfreqs", "itcdatatrials", "itcsubjinds", "itctrialinfo"), + ) else: entry["itcdata"] = tf["itcdata"] entry["itctimes"] = tf["times"] entry["itcfreqs"] = tf["freqs"] + if savetrials: + entry["itcdatatrials"] = tf["itcdatatrials"] + entry["itcsubjinds"] = tf["subjinds"] + entry["itctrialinfo"] = tf["trialinfo"] + if savetrials and tf is not None: + trial_cache = {} + if "ersp" in computed: + trial_cache["erspdatatrials"] = "linear baseline-corrected power" + if "itc" in computed: + trial_cache["itcdatatrials"] = "phase radians" + entry["measureinfo"]["trial_cache"] = trial_cache groups.append(entry) return groups @@ -215,6 +263,7 @@ def _precompute_components( erpparams: dict[str, Any], specparams: dict[str, Any], erspparams: dict[str, Any], + savetrials: bool, ) -> dict[str, Any]: try: activations = [component_activations(eeg) for eeg in datasets] @@ -252,27 +301,60 @@ def _precompute_components( cluster["specdata"], cluster["specfreqs"] = _component_spec( datasets, activations, selected, selection_mask, specparams ) - ersp_cached = _keep_cached(cached, "erspdata", force) if "ersp" in computed else True - itc_cached = _keep_cached(cached, "itcdata", force) if "itc" in computed else True + ersp_cached = ( + _keep_cached(cached, "erspdata", force) and (not savetrials or "erspdatatrials" in cached) + if "ersp" in computed + else True + ) + itc_cached = ( + _keep_cached(cached, "itcdata", force) and (not savetrials or "itcdatatrials" in cached) + if "itc" in computed + else True + ) if ("ersp" in computed and not ersp_cached) or ("itc" in computed and not itc_cached): - tf = _component_time_frequency(datasets, activations, selected, selection_mask, erspparams) + tf = _component_time_frequency( + datasets, activations, selected, selection_mask, erspparams, savetrials=savetrials + ) else: tf = None if "ersp" in computed: if ersp_cached: - _carry(cluster, cached, ("erspdata", "ersptimes", "erspfreqs", "erspbase")) + _carry( + cluster, + cached, + ("erspdata", "ersptimes", "erspfreqs", "erspbase", "erspdatatrials", "erspsubjinds", "ersptrialinfo"), + ) else: cluster["erspdata"] = tf["erspdata"] cluster["ersptimes"] = tf["times"] cluster["erspfreqs"] = tf["freqs"] cluster["erspbase"] = tf["powbase"] + if savetrials: + cluster["erspdatatrials"] = tf["erspdatatrials"] + cluster["erspsubjinds"] = tf["subjinds"] + cluster["ersptrialinfo"] = tf["trialinfo"] if "itc" in computed: if itc_cached: - _carry(cluster, cached, ("itcdata", "itctimes", "itcfreqs")) + _carry( + cluster, + cached, + ("itcdata", "itctimes", "itcfreqs", "itcdatatrials", "itcsubjinds", "itctrialinfo"), + ) else: cluster["itcdata"] = tf["itcdata"] cluster["itctimes"] = tf["times"] cluster["itcfreqs"] = tf["freqs"] + if savetrials: + cluster["itcdatatrials"] = tf["itcdatatrials"] + cluster["itcsubjinds"] = tf["subjinds"] + cluster["itctrialinfo"] = tf["trialinfo"] + if savetrials and tf is not None: + trial_cache = {} + if "ersp" in computed: + trial_cache["erspdatatrials"] = "linear baseline-corrected power" + if "itc" in computed: + trial_cache["itcdatatrials"] = "phase radians" + cluster["measureinfo"]["trial_cache"] = trial_cache return cluster @@ -365,13 +447,15 @@ def _component_spec( def _channel_time_frequency( - datasets: list[dict[str, Any]], channel_index: int, params: dict[str, Any] + datasets: list[dict[str, Any]], channel_index: int, params: dict[str, Any], *, savetrials: bool ) -> dict[str, Any]: values = [] itc_values = [] powbase_values = [] times = None freqs = None + trial_power = [] + trial_phase = [] for eeg in datasets: result = _newtimef(eeg_epoch_data(eeg)[channel_index, :, :], eeg, params) times = _check_axis(times, result.times, "time-frequency times") @@ -379,13 +463,19 @@ def _channel_time_frequency( values.append(result.ersp) itc_values.append(np.abs(result.itc)) powbase_values.append(result.powbase) - return { + if savetrials: + trial_power.append(_baseline_corrected_trial_power(result, params)) + trial_phase.append(np.angle(result.tfdata)) + output = { "erspdata": np.asarray(values, dtype=float).tolist(), "itcdata": np.asarray(itc_values, dtype=float).tolist(), "powbase": np.asarray(powbase_values, dtype=float).tolist(), "times": np.asarray(times, dtype=float).tolist(), "freqs": np.asarray(freqs, dtype=float).tolist(), } + if savetrials: + output.update(_trial_cache_metadata(datasets, trial_power, trial_phase)) + return output def _component_time_frequency( @@ -394,21 +484,29 @@ def _component_time_frequency( selected: np.ndarray, selection_mask: np.ndarray, params: dict[str, Any], + *, + savetrials: bool, ) -> dict[str, Any]: values: list[list[np.ndarray | None]] = [] itc_values: list[list[np.ndarray | None]] = [] powbase_values: list[list[np.ndarray | None]] = [] times = None freqs = None + trial_power: list[list[np.ndarray | None]] = [] + trial_phase: list[list[np.ndarray | None]] = [] for dataset_index, (eeg, acts) in enumerate(zip(datasets, activations)): dataset_ersp = [] dataset_itc = [] dataset_powbase = [] + dataset_trial_power = [] + dataset_trial_phase = [] for component_position, component_index in enumerate(selected): if not selection_mask[dataset_index, component_position]: dataset_ersp.append(None) dataset_itc.append(None) dataset_powbase.append(None) + dataset_trial_power.append(None) + dataset_trial_phase.append(None) continue result = _newtimef(acts[component_index, :, :], eeg, params) times = _check_axis(times, result.times, "time-frequency times") @@ -416,19 +514,97 @@ def _component_time_frequency( dataset_ersp.append(result.ersp) dataset_itc.append(np.abs(result.itc)) dataset_powbase.append(result.powbase) + if savetrials: + dataset_trial_power.append(_baseline_corrected_trial_power(result, params)) + dataset_trial_phase.append(np.angle(result.tfdata)) values.append(dataset_ersp) itc_values.append(dataset_itc) powbase_values.append(dataset_powbase) + if savetrials: + trial_power.append(dataset_trial_power) + trial_phase.append(dataset_trial_phase) if times is None or freqs is None: raise ValueError("component time-frequency precompute has no selected components") tf_shape = (len(freqs), len(times)) - return { + output = { "erspdata": _fill_missing_component_rows(values, tf_shape).tolist(), "itcdata": _fill_missing_component_rows(itc_values, tf_shape).tolist(), "powbase": _fill_missing_component_rows(powbase_values, len(freqs)).tolist(), "times": np.asarray(times, dtype=float).tolist(), "freqs": np.asarray(freqs, dtype=float).tolist(), } + if savetrials: + output.update(_component_trial_cache_metadata(datasets, trial_power, trial_phase)) + return output + + +def _baseline_corrected_trial_power(result: Any, params: dict[str, Any]) -> np.ndarray: + power = np.abs(result.tfdata) ** 2 + baseline = params.get("baseline", 0) + basenorm = str(params.get("basenorm", "off")) + trialbase = str(params.get("trialbase", "off")) + corrected = newtimeftrialbaseln( + power, + result.times, + baseline=baseline, + basenorm=basenorm, + trialbase=trialbase, + ) + corrected, _indices, _base = newtimefbaseln( + corrected, + result.times, + baseline=baseline, + basenorm=basenorm, + trialbase=trialbase, + singletrials="on", + ) + return np.asarray(corrected, dtype=float) + + +def _trial_cache_metadata( + datasets: list[dict[str, Any]], trial_power: list[np.ndarray], trial_phase: list[np.ndarray] +) -> dict[str, Any]: + return { + "erspdatatrials": [values.tolist() for values in trial_power], + "itcdatatrials": [values.tolist() for values in trial_phase], + "subjinds": [list(range(1, values.shape[-1] + 1)) for values in trial_power], + "trialinfo": [_dataset_trialinfo(eeg, values.shape[-1]) for eeg, values in zip(datasets, trial_power)], + } + + +def _component_trial_cache_metadata( + datasets: list[dict[str, Any]], + trial_power: list[list[np.ndarray | None]], + trial_phase: list[list[np.ndarray | None]], +) -> dict[str, Any]: + return { + "erspdatatrials": [ + [None if values is None else values.tolist() for values in dataset] for dataset in trial_power + ], + "itcdatatrials": [ + [None if values is None else values.tolist() for values in dataset] for dataset in trial_phase + ], + "subjinds": [ + [None if values is None else list(range(1, values.shape[-1] + 1)) for values in dataset] + for dataset in trial_power + ], + "trialinfo": [ + _dataset_trialinfo(eeg, _first_trial_count(dataset)) for eeg, dataset in zip(datasets, trial_power) + ], + } + + +def _first_trial_count(dataset: list[np.ndarray | None]) -> int: + return next((values.shape[-1] for values in dataset if values is not None), 0) + + +def _dataset_trialinfo(eeg: dict[str, Any], count: int) -> list[dict[str, Any]]: + rows = eeg.get("trialinfo") + if not isinstance(rows, list): + rows = [] + return [ + deepcopy(rows[index]) if index < len(rows) and isinstance(rows[index], dict) else {} for index in range(count) + ] def _component_scale(maps: np.ndarray, component_index: int) -> float: @@ -692,6 +868,7 @@ def _history_command( erpparams: Any, specparams: Any, erspparams: Any, + savetrials: Any, ) -> str: kwargs: dict[str, Any] = {name: "on" if name in computed else "off" for name in MEASURE_NAMES} kwargs["design"] = design @@ -701,6 +878,8 @@ def _history_command( kwargs["scalp"] = "on" if is_on(recompute): kwargs["recompute"] = "on" + if is_on(savetrials): + kwargs["savetrials"] = "on" if erpparams: kwargs["erpparams"] = erpparams if specparams: diff --git a/src/eegprep/functions/studyfunc/std_specplot.py b/src/eegprep/functions/studyfunc/std_specplot.py index 5395fcf9..6187f3f2 100644 --- a/src/eegprep/functions/studyfunc/std_specplot.py +++ b/src/eegprep/functions/studyfunc/std_specplot.py @@ -8,7 +8,7 @@ def std_specplot(STUDY: dict[str, Any], ALLEEG: list[dict[str, Any]] | None, *args: Any, **kwargs: Any): - """Read and plot precomputed STUDY spectrum measures.""" + """Plot precomputed spectra grouped by the selected STUDY design.""" return std_measureplot(STUDY, ALLEEG, "spec", *args, **kwargs) diff --git a/src/eegprep/functions/studyfunc/std_stat.py b/src/eegprep/functions/studyfunc/std_stat.py new file mode 100644 index 00000000..e0ff631d --- /dev/null +++ b/src/eegprep/functions/studyfunc/std_stat.py @@ -0,0 +1,219 @@ +"""Statistics for condition-by-group STUDY measure cells.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from eegprep.functions.statistics._shared import TwoWayEffects +from eegprep.functions.statistics.statcond import StatcondResult, statcond +from eegprep.functions.studyfunc.pop_statparams import COMMON_DEFAULTS, EEGLAB_DEFAULTS + + +@dataclass(frozen=True) +class StudyStatistics: + """P-values and significance masks for a STUDY measure grid.""" + + pcond: list[np.ndarray] + pgroup: list[np.ndarray] + pinter: list[np.ndarray] + condmask: list[np.ndarray] + groupmask: list[np.ndarray] + intermask: list[np.ndarray] + alpha: float | tuple[float, ...] | None + method: str + mcorrect: str + + def __iter__(self): + yield from self.output() + + def output(self) -> tuple[list[np.ndarray], list[np.ndarray], list[np.ndarray]]: + """Return EEGLAB outputs: p-values, or masks when alpha is set.""" + if self.alpha is None: + return self.pcond, self.pgroup, self.pinter + return self.condmask, self.groupmask, self.intermask + + +def std_stat( + data: Any, + options: dict[str, Any] | None = None, + *, + return_result: bool = False, + rng: np.random.Generator | int | None = 0, + **kwargs: Any, +) -> tuple[list[np.ndarray], list[np.ndarray], list[np.ndarray]] | StudyStatistics: + """Compute EEGLAB-style condition, group, and interaction statistics. + + ``data`` is a rectangular ``condition x group`` grid. Each numeric cell + stores observations on its last axis. Parametric, permutation, and + bootstrap methods are delegated to :func:`eegprep.statcond`; FDR correction + is applied across every sample in each returned effect. + """ + config = _statistics_options(options, kwargs) + grid = _condition_grid(data) + if not _enabled(config.get("condstats")) and not _enabled(config.get("groupstats")): + result = StudyStatistics( + [], + [], + [], + [], + [], + [], + _alpha(config["eeglab"].get("alpha")), + str(config["eeglab"].get("method") or "param").lower(), + str(config["eeglab"].get("mcorrect") or "none").lower(), + ) + return result if return_result else result.output() + method = str(config["eeglab"]["method"] or "param").lower() + naccu = config["eeglab"]["naccu"] + naccu = 2000 if naccu is None or np.asarray(naccu).size == 0 else int(np.asarray(naccu).reshape(-1)[0]) + pairing = list(config.get("paired") or ["off", "off"]) + while len(pairing) < 2: + pairing.append("off") + + pcond: list[np.ndarray] = [] + if _enabled(config.get("condstats")) and len(grid) > 1: + for group_index in range(len(grid[0])): + values = [row[group_index] for row in grid] + result = _run_statcond(values, paired=pairing[0], method=method, naccu=naccu, rng=rng) + pcond.append(np.asarray(result.pvalue, dtype=float)) + + pgroup: list[np.ndarray] = [] + if _enabled(config.get("groupstats")) and len(grid[0]) > 1: + for row in grid: + result = _run_statcond(row, paired=pairing[1], method=method, naccu=naccu, rng=rng) + pgroup.append(np.asarray(result.pvalue, dtype=float)) + + pinter: list[np.ndarray] = [] + if (pcond or pgroup) and len(grid) > 1 and len(grid[0]) > 1: + # EEGLAB uses the unpaired factor when a mixed design contains one. + interaction_pairing = "on" if all(_enabled(value) for value in pairing[:2]) else "off" + result = _run_statcond(grid, paired=interaction_pairing, method=method, naccu=naccu, rng=rng) + if not isinstance(result.pvalue, TwoWayEffects): + raise RuntimeError("two-factor STUDY statistics did not return three effects") + pinter = [ + np.asarray(result.pvalue.rows, dtype=float), + np.asarray(result.pvalue.columns, dtype=float), + np.asarray(result.pvalue.interaction, dtype=float), + ] + + mcorrect = str(config["eeglab"].get("mcorrect") or "none").lower() + if mcorrect not in {"none", "fdr"}: + raise NotImplementedError("STUDY statistics currently support mcorrect='none' or 'fdr'") + if mcorrect == "fdr": + pcond = [_fdr_adjust(values) for values in pcond] + pgroup = [_fdr_adjust(values) for values in pgroup] + pinter = [_fdr_adjust(values) for values in pinter] + + alpha = _alpha(config["eeglab"].get("alpha")) + condmask = _masks(pcond, alpha) + groupmask = _masks(pgroup, alpha) + intermask = _masks(pinter, alpha) + result = StudyStatistics( + pcond, + pgroup, + pinter, + condmask, + groupmask, + intermask, + alpha, + method, + mcorrect, + ) + return result if return_result else result.output() + + +def _run_statcond(data: Any, *, paired: Any, method: str, naccu: int, rng: Any) -> StatcondResult: + result = statcond(data, paired=paired, method=method, naccu=naccu, axis=-1, rng=rng) + if not isinstance(result, StatcondResult): + raise RuntimeError("STUDY statistics unexpectedly returned a resampling array") + return result + + +def _condition_grid(data: Any) -> list[list[np.ndarray]]: + if not isinstance(data, (list, tuple)) or not data: + raise ValueError("STUDY statistics require a non-empty condition grid") + rows = list(data) + if not isinstance(rows[0], (list, tuple)): + rows = [[value] for value in rows] + width = len(rows[0]) + if width == 0 or any(len(row) != width for row in rows): + raise ValueError("STUDY condition/group cells must form a rectangular grid") + return [[np.asarray(value) for value in row] for row in rows] + + +def _statistics_options(options: dict[str, Any] | None, overrides: dict[str, Any]) -> dict[str, Any]: + config = deepcopy(options) if isinstance(options, dict) else {} + if isinstance(config.get("etc"), dict): + config = deepcopy(config["etc"].get("statistics") or {}) + for key, value in COMMON_DEFAULTS.items(): + config.setdefault(key, deepcopy(value)) + eeglab = config.get("eeglab") + if not isinstance(eeglab, dict): + eeglab = {} + for key, value in EEGLAB_DEFAULTS.items(): + eeglab.setdefault(key, deepcopy(value)) + config["eeglab"] = eeglab + for key, value in overrides.items(): + target = {"threshold": "alpha", "statistics": "method"}.get(key.lower(), key.lower()) + if target in COMMON_DEFAULTS or target == "paired": + config[target] = value + elif target in EEGLAB_DEFAULTS: + config["eeglab"][target] = value + else: + raise ValueError(f"Unknown std_stat option: {key}") + if str(config.get("mode") or "eeglab").lower() != "eeglab": + raise NotImplementedError("FieldTrip STUDY statistics require the external FieldTrip backend") + return config + + +def _fdr_adjust(values: np.ndarray) -> np.ndarray: + array = np.asarray(values, dtype=float) + flat = array.ravel() + finite_positions = np.flatnonzero(np.isfinite(flat)) + output = np.full_like(flat, np.nan) + if finite_positions.size == 0: + return output.reshape(array.shape) + finite = flat[finite_positions] + order = np.argsort(finite) + ranked = finite[order] + adjusted = ranked * ranked.size / np.arange(1, ranked.size + 1, dtype=float) + adjusted = np.minimum.accumulate(adjusted[::-1])[::-1] + finite_output = np.empty_like(adjusted) + finite_output[order] = np.minimum(adjusted, 1.0) + output[finite_positions] = finite_output + return output.reshape(array.shape) + + +def _alpha(value: Any) -> float | tuple[float, ...] | None: + array = np.asarray(value, dtype=float).reshape(-1) + if array.size == 0 or np.isnan(array).all(): + return None + if np.isnan(array).any() or np.any((array <= 0) | (array > 1)): + raise ValueError("STUDY statistics threshold must be in (0, 1]") + values = tuple(float(item) for item in np.sort(array)) + return values[0] if len(values) == 1 else values + + +def _masks(values: list[np.ndarray], alpha: float | tuple[float, ...] | None) -> list[np.ndarray]: + if alpha is None: + return [] + thresholds = (alpha,) if isinstance(alpha, float) else alpha + masks = [] + for value in values: + mask = np.zeros(np.asarray(value).shape, dtype=float) + for index, threshold in enumerate(thresholds): + selected = np.isfinite(value) & (value < threshold) & (mask == 0) + mask[selected] = len(thresholds) - index + masks.append(mask) + return masks + + +def _enabled(value: Any) -> bool: + return value is True or (isinstance(value, str) and value.lower() == "on") + + +__all__ = ["StudyStatistics", "std_stat"] diff --git a/src/eegprep/functions/studyfunc/std_topoplot.py b/src/eegprep/functions/studyfunc/std_topoplot.py new file mode 100644 index 00000000..8dc3b0bb --- /dev/null +++ b/src/eegprep/functions/studyfunc/std_topoplot.py @@ -0,0 +1,233 @@ +"""Plot STUDY component-cluster scalp maps.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +import matplotlib.pyplot as plt +import numpy as np + +from eegprep.functions.popfunc._pop_utils import parse_key_value_args +from eegprep.functions.popfunc.plot_utils import numeric_vector +from eegprep.functions.sigprocfunc.topoplot import topoplot +from eegprep.functions.studyfunc._cluster_utils import cluster_list, sets_array +from eegprep.functions.studyfunc._study_utils import build_python_call, ensure_study +from eegprep.functions.studyfunc.std_readdata import std_readtopo + + +def std_topoplot( + STUDY: dict[str, Any], + ALLEEG: list[dict[str, Any]] | None, + *args: Any, + clusters: Any = "all", + components: Any = None, + mode: str = "together", + figure: str | bool = "on", + plotrad: float = 0.5, + return_com: bool = False, + **kwargs: Any, +) -> Any: + """Plot polarity-aligned cluster centroids or their component maps. + + Cluster and component indices are 1-based. For a child cluster, ``comps`` + selects member positions, matching EEGLAB's ``std_topoplot`` contract. + """ + options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) + clusters = options.pop("clusters", clusters) + components = options.pop("components", options.pop("comps", components)) + mode = str(options.pop("mode", mode) or "together").lower() + figure = options.pop("figure", figure) + plotrad = float(options.pop("plotrad", plotrad)) + if "plotsubjects" in options: + options.pop("plotsubjects") + mode = "apart" + if options: + raise ValueError(f"Unknown std_topoplot option(s): {', '.join(sorted(options))}") + mode = {"centroid": "together", "comps": "apart"}.get(mode, mode) + if mode not in {"together", "apart"}: + raise ValueError("mode must be 'together'/'centroid' or 'apart'/'comps'") + + study = ensure_study(STUDY) + datasets = list(ALLEEG or []) + if not datasets: + raise ValueError("std_topoplot requires ALLEEG channel locations") + selected = _cluster_indices(study, clusters) + records = [_cluster_maps(study, datasets, index, components if len(selected) == 1 else None) for index in selected] + _cache_cluster_maps(study, records) + output_figure = ( + _plot_centroids(records, datasets, figure, plotrad) + if mode == "together" + else _plot_components(records, datasets, figure, plotrad) + ) + command = build_python_call( + ("STUDY", "FIGURE"), + "std_topoplot", + "STUDY", + "ALLEEG", + clusters=clusters, + components=components, + mode=mode, + figure=figure, + plotrad=plotrad, + ) + return (study, output_figure, command) if return_com else (study, output_figure) + + +def _cluster_maps( + study: dict[str, Any], datasets: list[dict[str, Any]], cluster_index: int, components: Any +) -> dict[str, Any]: + _study, raw, _channel_axis = std_readtopo(study, datasets, clusters=cluster_index) + maps = np.asarray(raw[0], dtype=float) + cluster = cluster_list(study)[cluster_index - 1] + if maps.ndim == 3: + maps = maps.reshape(-1, maps.shape[-1]) + maps = maps[~np.isnan(maps).all(axis=1)] + if maps.ndim != 2 or maps.shape[0] == 0: + raise ValueError(f"cluster {cluster_index} contains no cached scalp maps") + positions = _member_positions(components, maps.shape[0]) + maps = maps[positions] + aligned, polarity = _align_polarity(maps) + sets = sets_array(cluster.get("sets")).astype(int) + comps = np.asarray(cluster.get("comps") or [], dtype=int).ravel() + labels = [] + for position in positions.tolist(): + if cluster_index == 1 or position >= comps.size or sets.size == 0: + labels.append(f"Map {position + 1}") + continue + dataset_id = int(sets[0, position]) + subject = str((study.get("datasetinfo") or [{}])[dataset_id - 1].get("subject") or f"S{dataset_id}") + labels.append(f"{subject}/IC{int(comps[position])}") + return { + "index": cluster_index, + "name": str(cluster.get("name") or f"Cluster {cluster_index}"), + "maps": aligned, + "centroid": np.nanmean(aligned, axis=0), + "polarity": polarity, + "labels": labels, + } + + +def _align_polarity(maps: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + aligned = np.asarray(maps, dtype=float).copy() + reference = aligned[0] + polarity = np.ones(aligned.shape[0], dtype=int) + for index in range(1, aligned.shape[0]): + finite = np.isfinite(reference) & np.isfinite(aligned[index]) + if np.count_nonzero(finite) >= 2 and np.dot(reference[finite], aligned[index, finite]) < 0: + aligned[index] *= -1 + polarity[index] = -1 + return aligned, polarity + + +def _cache_cluster_maps(study: dict[str, Any], records: list[dict[str, Any]]) -> None: + clusters = deepcopy(cluster_list(study)) + for record in records: + cluster = clusters[record["index"] - 1] + cluster["topo"] = record["centroid"].tolist() + cluster["topoall"] = record["maps"].tolist() + cluster["topopol"] = record["polarity"].tolist() + study["cluster"] = clusters + study["saved"] = "no" + + +def _plot_centroids(records: list[dict[str, Any]], datasets: list[dict[str, Any]], figure: Any, plotrad: float) -> Any: + rows, columns = _grid(len(records)) + fig = _figure(figure, rows, columns) + axes = _axes(fig, rows, columns) + chanlocs = list(datasets[0].get("chanlocs") or []) + for axis, record in zip(axes.flat, records): + _draw_topography(axis, record["centroid"], chanlocs, plotrad) + axis.set_title(f"{record['name']} ({record['maps'].shape[0]} ICs)") + _hide_unused(axes, len(records)) + fig.suptitle("Average scalp map for all clusters" if len(records) > 1 else records[0]["name"]) + setattr(fig, "eegprep_plot_metadata", {"mode": "centroid", "clusters": [record["index"] for record in records]}) + fig.tight_layout() + return fig + + +def _plot_components(records: list[dict[str, Any]], datasets: list[dict[str, Any]], figure: Any, plotrad: float) -> Any: + count = sum(record["maps"].shape[0] + 1 for record in records) + rows, columns = _grid(count) + fig = _figure(figure, rows, columns) + axes = _axes(fig, rows, columns) + chanlocs = list(datasets[0].get("chanlocs") or []) + position = 0 + for record in records: + _draw_topography(axes.flat[position], record["centroid"], chanlocs, plotrad) + axes.flat[position].set_title(f"{record['name']} mean") + position += 1 + for values, label in zip(record["maps"], record["labels"]): + _draw_topography(axes.flat[position], values, chanlocs, plotrad) + axes.flat[position].set_title(label) + position += 1 + _hide_unused(axes, position) + fig.suptitle("Cluster component scalp maps") + setattr( + fig, + "eegprep_plot_metadata", + {"mode": "components", "clusters": [record["index"] for record in records]}, + ) + fig.tight_layout() + return fig + + +def _draw_topography(axis: Any, values: np.ndarray, chanlocs: list[dict[str, Any]], plotrad: float) -> None: + if len(chanlocs) != values.size: + raise ValueError("component scalp-map length does not match ALLEEG channel locations") + topoplot(values, chanlocs, axes=axis, colorbar=False, plotrad=plotrad, intrad=plotrad) + + +def _cluster_indices(study: dict[str, Any], clusters: Any) -> list[int]: + entries = cluster_list(study) + if isinstance(clusters, str): + if clusters.lower() != "all": + raise ValueError("clusters must be numeric or 'all'") + selected = [ + index + for index, cluster in enumerate(entries[1:], start=2) + if not str(cluster.get("name") or "").lower().startswith(("notclust", "parentcluster")) + ] + return selected or [1] + values = numeric_vector(clusters, dtype=int) + if values.size == 0: + return _cluster_indices(study, "all") + if np.any(values < 1) or np.any(values > len(entries)): + raise ValueError(f"clusters must be 1-based and within 1..{len(entries)}") + return values.astype(int).tolist() + + +def _member_positions(components: Any, count: int) -> np.ndarray: + if components is None or (isinstance(components, str) and components.lower() == "all"): + return np.arange(count, dtype=int) + values = numeric_vector(components, dtype=int) + if np.any(values < 1) or np.any(values > count): + raise ValueError(f"comps must be 1-based cluster member positions within 1..{count}") + return values - 1 + + +def _grid(count: int) -> tuple[int, int]: + columns = int(np.ceil(np.sqrt(count))) + return int(np.ceil(count / columns)), columns + + +def _figure(value: Any, rows: int, columns: int) -> Any: + if value is False or (isinstance(value, str) and value.lower() == "off"): + fig = plt.gcf() + fig.clear() + return fig + return plt.figure(figsize=(3.6 * columns, 3.4 * rows)) + + +def _axes(fig: Any, rows: int, columns: int) -> np.ndarray: + return np.asarray( + [fig.add_subplot(rows, columns, index) for index in range(1, rows * columns + 1)], dtype=object + ).reshape(rows, columns) + + +def _hide_unused(axes: np.ndarray, count: int) -> None: + for axis in axes.flat[count:]: + axis.set_visible(False) + + +__all__ = ["std_topoplot"] diff --git a/tests/test_study_grouped_measure_plots_eeglab_tests.py b/tests/test_study_grouped_measure_plots_eeglab_tests.py new file mode 100644 index 00000000..81ee18bb --- /dev/null +++ b/tests/test_study_grouped_measure_plots_eeglab_tests.py @@ -0,0 +1,369 @@ +"""Ports of the maintained grouped STUDY measure-plot wrapper methods. + +Upstream suite: sccn/eeglab_tests@ff605546f3f70868916fb8d49c007472b3257b50 +EEGLAB tree: sccn/eeglab@8ac485f654d6bbb1a6acb8dc9ef3f2eaf3d409ba +""" + +from __future__ import annotations + +from copy import deepcopy + +import matplotlib + +matplotlib.use("Agg") + +from matplotlib import pyplot as plt +import numpy as np +from scipy.stats import ttest_rel + +from eegprep import ( + pop_statparams, + pop_study, + std_erpplot, + std_erspplot, + std_itcplot, + std_precomp, + std_specplot, + std_stat, + std_topoplot, +) +from tests.eeglab_tests import eeglab_test + + +STUDYFUNC_ROOT = "unittesting_studyfunc" + + +def _reference(wrapper: str, test: str): + source = f"{STUDYFUNC_ROOT}/{wrapper}/studyfunc_{wrapper}_wrapperTest.m" + return eeglab_test(source, test) + + +def _factorial_study(*, n_channels: int = 6, n_components: int = 2) -> tuple[dict, list[dict]]: + datasets = [] + srate = 64.0 + pnts = 64 + trials = 4 + seconds = np.arange(pnts, dtype=float) / srate + for group_index, group in enumerate(("control", "patient")): + for subject_index in range(2): + subject = f"{group[0].upper()}{subject_index + 1:02d}" + subject_shift = 0.025 * (subject_index + 1) + for condition_index, condition in enumerate(("standard", "target")): + condition_shift = condition_index * (0.35 + 0.04 * subject_index) + amplitude = 1.0 + 0.2 * group_index + 0.1 * condition_index + subject_shift + data = np.empty((n_channels, pnts, trials), dtype=float) + activations = np.empty((n_components, pnts, trials), dtype=float) + for trial in range(trials): + phase = trial * np.pi / 12 + for channel in range(n_channels): + data[channel, :, trial] = ( + amplitude * np.sin(2 * np.pi * (6 + channel) * seconds + phase) + + condition_shift + + 0.15 * group_index + ) + for component in range(n_components): + activations[component, :, trial] = (amplitude + 0.1 * component) * np.sin( + 2 * np.pi * (8 + 2 * component) * seconds + phase + ) + condition_shift + mixing = np.zeros((n_channels, n_components), dtype=float) + mixing[:n_components] = np.eye(n_components) + if group_index == 1 and subject_index == 1 and condition_index == 1: + mixing[:, 0] *= -1 + weights = np.zeros((n_components, n_channels), dtype=float) + weights[:, :n_components] = np.eye(n_components) + chanlocs = [] + for channel in range(n_channels): + angle = 2 * np.pi * channel / n_channels + chanlocs.append( + { + "labels": f"Ch{channel + 1}", + "theta": float(np.degrees(angle)), + "radius": 0.4, + "X": float(np.cos(angle)), + "Y": float(np.sin(angle)), + "Z": 0.0, + } + ) + datasets.append( + { + "setname": f"{subject}_{condition}", + "subject": subject, + "condition": condition, + "group": group, + "session": 1, + "run": 1, + "data": data, + "nbchan": n_channels, + "pnts": pnts, + "trials": trials, + "srate": srate, + "xmin": 0.0, + "xmax": float(seconds[-1]), + "times": seconds * 1000.0, + "chanlocs": chanlocs, + "icaact": activations, + "icawinv": mixing, + "icaweights": weights, + "icasphere": np.eye(n_channels), + "icachansind": list(range(n_channels)), + "event": [], + "urevent": [], + "epoch": [{} for _trial in range(trials)], + "etc": {}, + } + ) + return pop_study(None, datasets, name="Deterministic 2 x 2 study") + + +def _component_clusters(study: dict) -> dict: + study = deepcopy(study) + parent = study["cluster"][0] + dataset_ids = list(range(1, len(study["datasetinfo"]) + 1)) + study["cluster"] = [ + parent, + {"name": "Cluster 1", "sets": [dataset_ids], "comps": [1] * len(dataset_ids), "child": []}, + {"name": "Cluster 2", "sets": [dataset_ids], "comps": [2] * len(dataset_ids), "child": []}, + ] + parent["child"] = ["Cluster 1", "Cluster 2"] + return study + + +def test_std_stat_fdr_preserves_undefined_samples_and_graded_thresholds(): + condition_a = np.asarray([[1.0, 1.0, 1.0], [1.0, 2.0, 3.0]]) + condition_b = np.asarray([[1.0, 1.0, 1.0], [2.0, 4.0, 8.0]]) + + pcond, _pgroup, _pinter = std_stat( + [condition_a, condition_b], + condstats="on", + paired=["on", "off"], + method="param", + mcorrect="fdr", + ) + assert np.isnan(pcond[0][0]) + assert np.isfinite(pcond[0][1]) + + pvalue = float(pcond[0][1]) + masks, _group_masks, _interaction_masks = std_stat( + [condition_a, condition_b], + condstats="on", + paired=["on", "off"], + method="param", + mcorrect="fdr", + threshold=[pvalue / 2, min(pvalue * 2, 1.0)], + ) + np.testing.assert_array_equal(masks[0], [0.0, 1.0]) + + +@_reference("std_erpplot", "test_test_std_erpplot") +def test_std_erpplot_groups_design_cells_and_returns_statistics_and_masks(): + study, alleeg = _factorial_study() + study, alleeg = std_precomp(study, alleeg, [1], erp="on", recompute="on") + study = pop_statparams(study, condstats="on", groupstats="on", threshold=np.nan, method="param") + + result = std_erpplot(study, alleeg, channels=[1], return_stats=True, plotstderr="on") + _study, cells, times, pgroup, pcond, pinter, figure = result + + assert [[cell.shape for cell in row] for row in cells] == [[(64, 2), (64, 2)], [(64, 2), (64, 2)]] + expected = ttest_rel(cells[0][0], cells[1][0], axis=-1).pvalue + np.testing.assert_allclose(pcond[0], expected) + assert len(pgroup) == 2 + assert len(pinter) == 3 + assert len(figure.axes) == 4 + assert figure.eegprep_plot_metadata["statistics"].condmask == [] + plt.close(figure) + + mask_result = std_erpplot(study, alleeg, channels=[1], threshold=0.05, return_stats=True) + _study, _cells, _times, _pgroup, condition_masks, _pinter, mask_figure = mask_result + assert set(np.unique(condition_masks[0])) <= {0.0, 1.0} + np.testing.assert_array_equal(condition_masks, mask_figure.eegprep_plot_metadata["statistics"].condmask) + plt.close(mask_figure) + + _study, _cells, _times, together = std_erpplot( + study, + alleeg, + channels=[1], + condstats="off", + groupstats="off", + plotconditions="together", + plotgroups="together", + plotsubjects="on", + ) + assert len(together.axes) == 1 + assert len(together.axes[0].lines) == 12 + plt.close(together) + + +@_reference("std_erspplot", "test_test_std_erspplot") +def test_std_erspplot_supports_clusters_subject_panels_and_channel_topographies(): + study, alleeg = _factorial_study() + tf_params = {"cycles": 0, "nfreqs": 5, "timesout": 5, "baseline": np.nan} + study, alleeg = std_precomp(study, alleeg, "channels", ersp="on", savetrials="on", erspparams=tf_params) + study, alleeg = std_precomp(study, alleeg, "components", ersp="on", savetrials="on", erspparams=tf_params) + study = _component_clusters(study) + + _study, cluster_cells, times, freqs, cluster_figure = std_erspplot(study, alleeg, clusters=[2, 3]) + assert cluster_cells[0][0].shape == (freqs.size, times.size, 2) + assert len(cluster_figure.axes) >= 2 + plt.close(cluster_figure) + + _study, subject_cells, _times, _freqs, subject_figure = std_erspplot( + study, alleeg, channels=[1], subject="C01", plotsubjects="on" + ) + assert sum(cell.shape[-1] for row in subject_cells for cell in row) == 2 + assert len(subject_figure.axes) >= 4 + plt.close(subject_figure) + + _study, topo_cells, _times, _freqs, topo_figure = std_erspplot( + study, alleeg, channels="channels", topofreq=8, topotime=400, caxis=[-3, 3] + ) + assert topo_cells[0][0].shape[-2] == 6 + assert len(topo_figure.axes) == 4 + assert all(axis.images or not axis.get_visible() for axis in topo_figure.axes) + plt.close(topo_figure) + + +@_reference("std_erspplot", "test_test_std_erspplot2_2") +def test_std_erspplot_channel_saved_trials_reproduce_the_cached_ersp(): + study, alleeg = _factorial_study() + params = {"cycles": 0, "nfreqs": 5, "timesout": 5, "baseline": np.nan} + study, alleeg = std_precomp(study, alleeg, [1], ersp="on", savetrials="on", recompute="on", erspparams=params) + cache = study["changrp"][0] + + for dataset_index, trials in enumerate(cache["erspdatatrials"]): + reconstructed = 10 * np.log10(np.mean(np.asarray(trials), axis=-1)) + np.testing.assert_allclose(reconstructed, np.asarray(cache["erspdata"])[dataset_index], atol=1e-12) + _study, cells, times, freqs, figure = std_erspplot(study, alleeg, channels=[1]) + assert cells[0][0].shape == (freqs.size, times.size, 2) + assert cache["measureinfo"]["trial_cache"]["erspdatatrials"] == "linear baseline-corrected power" + plt.close(figure) + + +@_reference("std_erspplot", "test_test_std_erspplot3_2") +def test_std_erspplot_component_saved_trials_reproduce_the_cached_ersp(): + study, alleeg = _factorial_study() + for info in study["datasetinfo"]: + info["comps"] = [2] + params = {"cycles": 0, "nfreqs": 5, "timesout": 5, "baseline": np.nan} + study, alleeg = std_precomp( + study, alleeg, "components", ersp="on", savetrials="on", recompute="on", erspparams=params + ) + cache = study["cluster"][0] + + for dataset_index, component_trials in enumerate(cache["erspdatatrials"]): + reconstructed = 10 * np.log10(np.mean(np.asarray(component_trials[0]), axis=-1)) + np.testing.assert_allclose(reconstructed, np.asarray(cache["erspdata"])[dataset_index, 0], atol=1e-12) + _study, cells, times, freqs, figure = std_erspplot(study, alleeg, clusters=1, components=[2]) + assert cells[0][0].shape == (freqs.size, times.size, 2) + plt.close(figure) + + +@_reference("std_itcplot", "test_test_std_itcplot") +def test_std_itcplot_supports_centroids_component_panels_channels_and_subjects(): + study, alleeg = _factorial_study() + tf_params = {"cycles": 0, "nfreqs": 4, "timesout": 4, "baseline": np.nan} + study, alleeg = std_precomp(study, alleeg, [1], itc="on", savetrials="on", erspparams=tf_params) + channel_cache = study["changrp"][0] + for dataset_index, phases in enumerate(channel_cache["itcdatatrials"]): + reconstructed = np.abs(np.mean(np.exp(1j * np.asarray(phases)), axis=-1)) + np.testing.assert_allclose(reconstructed, np.asarray(channel_cache["itcdata"])[dataset_index]) + study, alleeg = std_precomp(study, alleeg, "components", itc="on", erspparams=tf_params) + study = _component_clusters(study) + + _study, cells, times, freqs, centroid = std_itcplot(study, alleeg, clusters=2, mode="centroid") + assert cells[0][0].shape == (freqs.size, times.size, 2) + assert np.nanmin(cells[0][0]) >= 0 + plt.close(centroid) + + _study, _cells, _times, _freqs, components = std_itcplot(study, alleeg, clusters=2, mode="comps") + assert len(components.axes) >= 8 + plt.close(components) + + _study, channel_cells, _times, _freqs, channel_figure = std_itcplot( + study, alleeg, channels=[1], subject="P01", plotsubjects="on" + ) + assert sum(cell.shape[-1] for row in channel_cells for cell in row) == 2 + plt.close(channel_figure) + + +@_reference("std_specplot", "test_test_std_specplot") +def test_std_specplot_supports_clusters_fdr_subject_traces_and_channel_topography(): + study, alleeg = _factorial_study() + study, alleeg = std_precomp(study, alleeg, "channels", spec="on", recompute="on") + study, alleeg = std_precomp(study, alleeg, "components", spec="on", recompute="on") + study = _component_clusters(study) + + result = std_specplot( + study, + alleeg, + clusters=2, + condstats="on", + plotconditions="together", + threshold=0.05, + mcorrect="fdr", + return_stats=True, + ) + _study, cells, frequencies, _pgroup, pcond, _pinter, figure = result + assert cells[0][0].shape == (frequencies.size, 2) + assert len(pcond) == 2 + assert figure.eegprep_plot_metadata["statistics"].mcorrect == "fdr" + plt.close(figure) + + _study, _cells, _frequencies, subject_figure = std_specplot( + study, alleeg, channels=[1], subject="C01", plotsubjects="on", plotconditions="together" + ) + assert sum(len(axis.lines) for axis in subject_figure.axes) >= 4 + plt.close(subject_figure) + + _study, topo_cells, _frequencies, topo_figure = std_specplot(study, alleeg, channels="channels", topofreq=8) + assert topo_cells[0][0].shape[-2] == 6 + assert len(topo_figure.axes) == 4 + plt.close(topo_figure) + + +@_reference("std_specplot", "test_test_std_specplot2") +def test_std_specplot_group_and_condition_layout_controls_preserve_design_cells(): + study, alleeg = _factorial_study() + study, alleeg = std_precomp(study, alleeg, [1], spec="on", recompute="on") + + for plotconditions, plotgroups, expected_axes in ( + ("apart", "apart", 4), + ("together", "apart", 2), + ("apart", "together", 2), + ("together", "together", 1), + ): + _study, cells, frequencies, figure = std_specplot( + study, + alleeg, + channels=[1], + plotconditions=plotconditions, + plotgroups=plotgroups, + plotsubjects="on", + ) + assert [[cell.shape for cell in row] for row in cells] == [ + [(frequencies.size, 2), (frequencies.size, 2)], + [(frequencies.size, 2), (frequencies.size, 2)], + ] + assert len(figure.axes) == expected_axes + plt.close(figure) + + +@_reference("std_topoplot", "test_test_std_topoplot") +def test_std_topoplot_draws_all_centroids_component_maps_and_selected_members(): + study, alleeg = _factorial_study() + study, alleeg = std_precomp(study, alleeg, "components", erp="on", scalp="on", recompute="on") + study = _component_clusters(study) + + study, all_figure = std_topoplot(study, alleeg, clusters="all", mode="centroid") + assert len(all_figure.axes) == 2 + assert all(cluster.get("topo") for cluster in study["cluster"][1:]) + plt.close(all_figure) + + study, component_figure = std_topoplot(study, alleeg, clusters=2, mode="comps") + assert len(component_figure.axes) == len(study["cluster"][1]["comps"]) + 1 + assert set(study["cluster"][1]["topopol"]) <= {-1, 1} + plt.close(component_figure) + + _study, selected_figure = std_topoplot(study, alleeg, clusters=2, components=[4], mode="comps") + assert len(selected_figure.axes) == 2 + assert selected_figure.axes[1].get_title().endswith("/IC1") + plt.close(selected_figure) diff --git a/tests/test_study_measures.py b/tests/test_study_measures.py index 99caf9d8..835f1a48 100644 --- a/tests/test_study_measures.py +++ b/tests/test_study_measures.py @@ -183,23 +183,24 @@ def test_study_measure_roundtrip_and_std_plot_helpers(tmp_path): study, itcdata, itctimes, itcfreqs, itcfig = std_itcplot(study, alleeg, channels=[1]) plotted, plot_command, plotfig = pop_chanplot(study, alleeg, channels=["Ch1"], measure="erp", return_com=True) - assert erpdata[0].shape[0] == 2 + assert len(erpdata) == 2 + assert all(cell.shape == (erptimes.size, 1) for cell in erpdata) assert erptimes[0] >= 0 - assert specdata[0].shape[0] == 2 - assert specfreqs.size == specdata[0].shape[1] - assert erspdata[0].shape == itcdata[0].shape + assert len(specdata) == 2 + assert all(cell.shape == (specfreqs.size, 1) for cell in specdata) + assert all(ersp.shape == itc.shape for ersp, itc in zip(erspdata, itcdata)) assert ersptimes.size == itctimes.size assert erspfreqs.size == itcfreqs.size assert erpcom.startswith("STUDY, ERPDATA, ERPTIMES, FIGURE = std_erpplot(") namespace = {"STUDY": study, "ALLEEG": alleeg, "std_erpplot": std_erpplot} exec(erpcom, namespace) assert isinstance(namespace["STUDY"], dict) - assert namespace["ERPDATA"][0].shape == erpdata[0].shape + assert [cell.shape for cell in namespace["ERPDATA"]] == [cell.shape for cell in erpdata] assert erspcom.startswith("STUDY, ERSPDATA, ERSPTIMES, ERSPFREQS, FIGURE = std_erspplot(") namespace = {"STUDY": study, "ALLEEG": alleeg, "std_erspplot": std_erspplot} exec(erspcom, namespace) assert isinstance(namespace["STUDY"], dict) - assert namespace["ERSPDATA"][0].shape == erspdata[0].shape + assert [cell.shape for cell in namespace["ERSPDATA"]] == [cell.shape for cell in erspdata] assert plotted["etc"]["last_chanplot"]["channels"] == [1] assert "channels=['Ch1']" in plot_command @@ -354,7 +355,8 @@ def test_child_cluster_measure_reads_slice_parent_component_cache(): _study, erpdata, _times, figure, _command = std_erpplot(study, alleeg, clusters=[2], noplot="on", return_com=True) - assert erpdata[0].shape == (len(child["comps"]), first["pnts"]) + assert all(cell.shape[0] == first["pnts"] for cell in erpdata) + assert sum(cell.shape[-1] for cell in erpdata) == len(child["comps"]) assert figure is None with pytest.raises(ValueError, match="subject filter requires"): std_readerp(study, alleeg, clusters=[2], subject="S01") diff --git a/tests/test_study_workflows_eeglab_tests.py b/tests/test_study_workflows_eeglab_tests.py index 26105f3f..77d8e981 100644 --- a/tests/test_study_workflows_eeglab_tests.py +++ b/tests/test_study_workflows_eeglab_tests.py @@ -423,8 +423,9 @@ def test_std_erpplot_channel_output_matches_direct_epoch_average(): _study, erpdata, erptimes, figure = std_erpplot(study, alleeg, channels=[1]) - expected = np.stack([np.mean(eeg["data"][0], axis=1) for eeg in alleeg]) - np.testing.assert_allclose(erpdata[0], expected, atol=1e-12) + expected = {eeg["condition"]: np.mean(eeg["data"][0], axis=1) for eeg in alleeg} + for condition, cell in zip(["standard", "target"], erpdata): + np.testing.assert_allclose(cell[:, 0], expected[condition], atol=1e-12) np.testing.assert_allclose(erptimes, alleeg[0]["times"], atol=1e-12) assert len(figure.axes[0].lines) == 1 plt.close(figure) @@ -441,7 +442,8 @@ def test_std_erpplot_component_output_matches_direct_scaled_activation_average() for eeg in alleeg: scale = float(np.sqrt(np.mean(np.asarray(eeg["icawinv"])[:, 1] ** 2))) expected.append(np.mean(component_activations(eeg)[1], axis=1) * scale) - np.testing.assert_allclose(erpdata[0][:, 0, :], np.stack(expected), atol=1e-12) + np.testing.assert_allclose(erpdata[0][:, 0], expected[1], atol=1e-12) + np.testing.assert_allclose(erpdata[1][:, 0], expected[0], atol=1e-12) np.testing.assert_allclose(erptimes, alleeg[0]["times"], atol=1e-12) plt.close(figure) @@ -453,9 +455,8 @@ def test_std_specplot_channel_output_preserves_known_oscillation_peak(): _study, specdata, frequencies, figure = std_specplot(study, alleeg, channels=[1]) - assert frequencies[int(np.argmax(specdata[0][0]))] == 8.0 - assert frequencies[int(np.argmax(specdata[0][1]))] == 8.0 - assert np.isfinite(specdata[0]).all() + assert all(frequencies[int(np.argmax(cell[:, 0]))] == 8.0 for cell in specdata) + assert all(np.isfinite(cell).all() for cell in specdata) plt.close(figure) @@ -466,9 +467,8 @@ def test_std_specplot_component_output_preserves_known_activation_peak(): _study, specdata, frequencies, figure = std_specplot(study, alleeg, clusters=1, components=[3]) - assert frequencies[int(np.argmax(specdata[0][0, 0]))] == 12.0 - assert frequencies[int(np.argmax(specdata[0][1, 0]))] == 12.0 - assert np.isfinite(specdata[0]).all() + assert all(frequencies[int(np.argmax(cell[:, 0]))] == 12.0 for cell in specdata) + assert all(np.isfinite(cell).all() for cell in specdata) plt.close(figure) @@ -486,10 +486,12 @@ def test_std_erspplot_channel_output_matches_precomputed_axes_and_cache(): _study, erspdata, times, frequencies, figure = std_erspplot(study, alleeg, channels=[1]) - np.testing.assert_allclose(erspdata[0], study["changrp"][0]["erspdata"]) + raw = np.asarray(study["changrp"][0]["erspdata"]) + np.testing.assert_allclose(erspdata[0][..., 0], raw[1]) + np.testing.assert_allclose(erspdata[1][..., 0], raw[0]) np.testing.assert_allclose(times, study["changrp"][0]["ersptimes"]) np.testing.assert_allclose(frequencies, study["changrp"][0]["erspfreqs"]) - assert np.isfinite(erspdata[0]).all() + assert all(np.isfinite(cell).all() for cell in erspdata) plt.close(figure) @@ -507,8 +509,9 @@ def test_std_erspplot_component_output_selects_the_requested_component(): _study, erspdata, times, frequencies, figure = std_erspplot(study, alleeg, clusters=1, components=[2]) - expected = np.asarray(study["cluster"][0]["erspdata"])[:, 1:2] - np.testing.assert_allclose(erspdata[0], expected) - assert erspdata[0].shape == (2, 1, frequencies.size, times.size) - assert np.isfinite(erspdata[0]).all() + expected = np.asarray(study["cluster"][0]["erspdata"])[:, 1] + np.testing.assert_allclose(erspdata[0][..., 0], expected[1]) + np.testing.assert_allclose(erspdata[1][..., 0], expected[0]) + assert all(cell.shape == (frequencies.size, times.size, 1) for cell in erspdata) + assert all(np.isfinite(cell).all() for cell in erspdata) plt.close(figure) From 6137b8483a34d75f731a2adda08deb4b5a851f00 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:57 -0700 Subject: [PATCH 39/45] feat: port STUDY dipole plotting (#367) --- docs/parity/eeglab_core_parity_matrix.json | 11 +- docs/parity/eeglab_final_parity_matrix.json | 4 +- docs/source/changelog.rst | 3 + docs/source/user_guide/study_workflows.rst | 25 +- .../studyfunc/_source_limitations.py | 6 +- .../functions/studyfunc/std_dipplot.py | 337 +++++++++++++++++- src/eegprep/resources/help/eeg_helpstudy.md | 6 +- tests/test_study_dipplot_eeglab_tests.py | 197 ++++++++++ tests/test_study_measures.py | 5 +- 9 files changed, 568 insertions(+), 26 deletions(-) create mode 100644 tests/test_study_dipplot_eeglab_tests.py diff --git a/docs/parity/eeglab_core_parity_matrix.json b/docs/parity/eeglab_core_parity_matrix.json index 5d54a868..28177f28 100644 --- a/docs/parity/eeglab_core_parity_matrix.json +++ b/docs/parity/eeglab_core_parity_matrix.json @@ -7578,17 +7578,16 @@ { "eeglab_path": "functions/studyfunc/std_dipplot.m", "eeglab_name": "std_dipplot", - "eegprep_equivalent": null, + "eegprep_equivalent": "src/eegprep/functions/studyfunc/std_dipplot.py", "gap_category": "4_study_group_depth", - "status": "external_dependency_skip", - "rationale": "Dipole plotting depends on DIPFIT/dipole model outputs outside EEGPrep core.", - "responsible_phase": "phase_6", + "status": "implemented", + "rationale": "EEGPrep selects already-localized DIPFIT models through 1-based STUDY cluster membership, computes inspectable centroids, and renders standalone matplotlib views without EEGLAB or FieldTrip at runtime.", + "responsible_phase": "none", "user_facing_surface": [ "api", "study" ], - "test_notes": "No standalone parity test required unless a later phase supplies the external dependency and reclassifies this row.", - "follow_up_issue": "https://github.com/sccn/eegprep/issues/149" + "test_notes": "The current std_dipplot wrapper method is provenance-linked to behavioral tests that assert selected dataset/component indices, bilateral coordinates, moments, residual variance, centroids, plot labels, and the joined and centroid modes." }, { "eeglab_path": "functions/studyfunc/std_editset.m", diff --git a/docs/parity/eeglab_final_parity_matrix.json b/docs/parity/eeglab_final_parity_matrix.json index 19a25e57..0fc63901 100644 --- a/docs/parity/eeglab_final_parity_matrix.json +++ b/docs/parity/eeglab_final_parity_matrix.json @@ -951,7 +951,7 @@ "status": "partial", "responsible_phase": "phase_4", "phase_issue": "#161 STUDY statistics, PAC, and LIMO-compatible workflows", - "rationale": "EEGPrep now owns STUDY channel interpolation and distance-based neighbor/adjacency preparation. STUDY dipole cluster/source plotting remains an explicit DIPFIT/FieldTrip backend boundary coordinated with Phase 3.", + "rationale": "EEGPrep owns STUDY channel interpolation, distance-based neighbor/adjacency preparation, and standalone plotting of existing DIPFIT models through std_dipplot. The broader std_dipoleclusters workflow remains an explicit DIPFIT/FieldTrip backend boundary.", "user_facing_surface": [ "STUDY interpolation", "STUDY dipole clusters", @@ -960,7 +960,7 @@ "docs_targets": [ "docs/source/user_guide/study_workflows.rst" ], - "test_notes": "Focused tests cover neighbor adjacency, STUDY interpolation, and explicit source-workflow limitation paths." + "test_notes": "Focused tests cover neighbor adjacency, STUDY interpolation, provenance-linked std_dipplot selections and centroids, and the remaining explicit std_dipoleclusters source-workflow limitation." }, { "row_id": "docs-history-console-migration", diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 11556922..291c625f 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,9 @@ the `GitHub Releases `_ page. Unreleased ========== +- ``std_dipplot`` now selects existing DIPFIT models through one-based STUDY + cluster membership, returns their coordinates, moments, residual variances, + and centroids, and renders joined, centroid, member, and separated views. - STUDY ERP, spectrum, ERSP, ITC, and scalp-map functions now arrange cached measures by condition and group, retain subject or component cases, return parametric or resampled statistics and FDR masks, reconstruct saved diff --git a/docs/source/user_guide/study_workflows.rst b/docs/source/user_guide/study_workflows.rst index b86d93b4..8f5f4ef2 100644 --- a/docs/source/user_guide/study_workflows.rst +++ b/docs/source/user_guide/study_workflows.rst @@ -298,6 +298,24 @@ list and a LIMO-compatible channel adjacency matrix from loaded channel locations. ``std_interp`` interpolates requested missing channels across STUDY datasets using EEGPrep's existing channel interpolation backend. +Plotting localized cluster dipoles +================================== + +``std_dipplot`` visualizes DIPFIT models that are already stored in each +dataset's ``EEG["dipfit"]["model"]`` field. Cluster membership is read from +``STUDY["cluster"]`` using EEGLAB-facing 1-based dataset and component +indices. The function returns the exact selected models and computed cluster +centroids alongside the figures, so positions, moments, and residual variance +remain inspectable outside the plot. + +Use ``mode="joined"`` to draw selected cluster members and centroids together, +or ``mode="centroid"`` to draw only cluster centroids. The standard +``apart``, ``together``, ``multicolor``, and ``comps`` layouts are also +available. ``comps`` selects 1-based positions within the cluster membership +list, consistent with the EEGLAB ``std_plotcompdip`` convention. Set +``plot=False`` to prepare and validate the same numerical plot inputs without +creating figures. + Limitations =========== @@ -306,10 +324,9 @@ EEGPrep does not silently emulate EEGLAB's external LIMO toolbox. ``pop_limo``, ``std_readfilelimo`` raise clear ``NotImplementedError`` messages rather than creating placeholder LIMO results. -STUDY-level DIPFIT/FieldTrip source workflows such as ``std_dipplot`` and -``std_dipoleclusters`` remain explicit source-backend boundaries. Use the -dedicated EEGPrep DIPFIT helpers for dataset-level source workflows and keep -STUDY source statistics behind a tested backend contract. +``std_dipplot`` does not compute dipole models: localize components first with +the EEGPrep DIPFIT workflow. The broader FieldTrip-dependent +``std_dipoleclusters`` source workflow remains an explicit backend boundary. See the :ref:`interactive_console` guide for mixed GUI plus console usage and the :ref:`gui_help_menus` guide for menu inventory behavior. diff --git a/src/eegprep/functions/studyfunc/_source_limitations.py b/src/eegprep/functions/studyfunc/_source_limitations.py index 8c1e059e..906132df 100644 --- a/src/eegprep/functions/studyfunc/_source_limitations.py +++ b/src/eegprep/functions/studyfunc/_source_limitations.py @@ -6,9 +6,9 @@ SOURCE_STUDY_LIMITATION = ( - "Standalone EEGPrep does not compute EEGLAB FieldTrip/DIPFIT STUDY source workflows from " - "std_dipplot or std_dipoleclusters. Run the DIPFIT/source-localization workflow first and use " - "the dedicated EEGPrep DIPFIT plotting helpers for dataset-level dipoles." + "Standalone EEGPrep does not compute the EEGLAB FieldTrip/DIPFIT STUDY source workflows used by " + "std_dipoleclusters. Run source localization first; std_dipplot can visualize existing " + "EEG.dipfit.model results by STUDY cluster." ) diff --git a/src/eegprep/functions/studyfunc/std_dipplot.py b/src/eegprep/functions/studyfunc/std_dipplot.py index b4c4f6b6..b43870f5 100644 --- a/src/eegprep/functions/studyfunc/std_dipplot.py +++ b/src/eegprep/functions/studyfunc/std_dipplot.py @@ -1,15 +1,342 @@ -"""Explicit limitation for STUDY-level dipole plotting.""" +"""Plot already-localized DIPFIT components from STUDY clusters.""" from __future__ import annotations +from copy import deepcopy from typing import Any -from eegprep.functions.studyfunc._source_limitations import raise_source_study_limitation +import matplotlib.pyplot as plt +import numpy as np +from eegprep.functions.popfunc._pop_utils import is_on, parse_key_value_args +from eegprep.functions.studyfunc._cluster_utils import ( + checked_study_and_datasets, + cluster_at, + cluster_list, + dataset_for_study_set, + sets_array, +) +from eegprep.functions.studyfunc._study_utils import build_python_call +from eegprep.plugins.dipfit._utils import normalize_model_list, one_based_indices -def std_dipplot(*args: Any, **kwargs: Any) -> None: - """Report the standalone boundary for STUDY-level source plotting.""" - raise_source_study_limitation("std_dipplot", *args, **kwargs) + +_VALID_MODES = {"apart", "together", "multicolor", "joined", "centroid", "comps"} + + +def std_dipplot( + STUDY: dict[str, Any] | None, + ALLEEG: Any, + *args: Any, + clusters: Any = None, + comps: Any = None, + mode: str | None = None, + dipcolor: Any = None, + dipsize: Any = None, + plot: bool = True, + return_com: bool = False, + **kwargs: Any, +) -> tuple[Any, ...]: + """Select and plot localized components from one or more STUDY clusters. + + This standalone workflow consumes existing ``EEG["dipfit"]["model"]`` + entries; it does not run source localization. Cluster, dataset, and + component indices are EEGLAB-facing and therefore 1-based. ``comps`` + selects positions within each cluster's component list, matching EEGLAB's + ``std_plotcompdip`` convention rather than selecting component numbers. + + The returned selection dictionaries expose ``dipoles`` and ``centroid``. + Each dipole records its study-set index, ALLEEG index, component number, + subject, ``posxyz``, ``momxyz``, and residual variance ``rv``. Modes + ``joined`` and ``centroid`` implement the intent of the current EEGLAB test + calls, which the pinned MATLAB implementation otherwise silently ignores. + + Args: + STUDY: STUDY dictionary containing cluster membership. + ALLEEG: Loaded EEG dataset dictionaries with DIPFIT models. + clusters: 1-based cluster indices or ``"all"``. The parent cluster is + excluded from ``"all"`` as in EEGLAB. + comps: Optional 1-based positions within each selected cluster. + mode: ``"apart"``, ``"together"``, ``"multicolor"``, ``"joined"``, + ``"centroid"``, or ``"comps"``. + dipcolor: Optional color or one color per selected cluster. + dipsize: Optional marker size or one size per selected cluster. + plot: Whether to create matplotlib figures. + return_com: Append a replayable Python command to the result. + + Returns: + ``(STUDY, selections, figures)`` and, when requested, the command. + ``STUDY.cluster[*].dipole`` is updated with each selected centroid. + """ + options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) + clusters = options.pop("clusters", clusters) + comps = options.pop("comps", comps) + mode = options.pop("mode", mode) + dipcolor = options.pop("dipcolor", dipcolor) + dipsize = options.pop("dipsize", dipsize) + plot = is_on(options.pop("plot", plot)) + ignored = {"plotsubjects"} + unsupported = sorted(key for key in options if key not in ignored) + if unsupported: + raise ValueError(f"Unknown std_dipplot option(s): {', '.join(unsupported)}") + + study, datasets = checked_study_and_datasets(STUDY, ALLEEG) + cluster_indices = _cluster_indices(study, clusters) + selected_mode = _mode(mode, len(cluster_indices)) + selections = [_select_cluster(study, datasets, index, comps) for index in cluster_indices] + _validate_coordinate_formats(selections) + for selection in selections: + study["cluster"][selection["cluster_index"] - 1]["dipole"] = deepcopy(selection["centroid"]) + + colors = _plot_values(dipcolor, len(selections), "tab10") + sizes = _plot_values(dipsize, len(selections), 55.0) + figures = _plot_selections(selections, selected_mode, colors, sizes) if plot else [] + command = build_python_call( + ("STUDY", "DIPOLES", "FIGURES"), + "std_dipplot", + "STUDY", + "ALLEEG", + clusters=clusters, + comps=comps, + mode=selected_mode, + dipcolor=dipcolor, + dipsize=dipsize, + plot=plot if not plot else None, + ) + result = (study, selections, figures) + return (*result, command) if return_com else result + + +def _cluster_indices(study: dict[str, Any], clusters: Any) -> list[int]: + all_clusters = cluster_list(study) + if isinstance(clusters, str): + if clusters.lower() != "all": + raise ValueError("clusters must be 1-based indices or 'all'") + indices = list(range(2, len(all_clusters) + 1)) + elif clusters is None: + indices = [ + index + for index, cluster in enumerate(all_clusters[1:], start=2) + if not str(cluster.get("name") or "").lower().startswith(("notclust", "parentcluster")) + ] + elif _is_empty(clusters): + indices = list(range(2, len(all_clusters) + 1)) + else: + indices = one_based_indices(clusters, limit=len(all_clusters)) + if not indices: + raise ValueError("No STUDY clusters are available to plot") + return indices + + +def _mode(mode: str | None, cluster_count: int) -> str: + value = str(mode or ("apart" if cluster_count == 1 else "together")).strip().lower() + if value not in _VALID_MODES: + valid = ", ".join(sorted(_VALID_MODES)) + raise ValueError(f"mode must be one of {valid}") + return value + + +def _select_cluster( + study: dict[str, Any], datasets: list[dict[str, Any]], cluster_index: int, comps: Any +) -> dict[str, Any]: + cluster = cluster_at(study, cluster_index) + component_numbers = np.asarray(cluster.get("comps") or [], dtype=int).ravel() + study_sets = sets_array(cluster.get("sets")) + if study_sets.shape[1] != component_numbers.size: + raise ValueError(f"STUDY cluster {cluster_index} sets and comps lengths do not match") + selected_members = None if isinstance(comps, str) and comps.strip().lower() == "all" else comps + member_indices = one_based_indices(selected_members, limit=component_numbers.size, default_all=True) + dipoles = [] + for member_index in member_indices: + study_set = int(study_sets[0, member_index - 1]) + component = int(component_numbers[member_index - 1]) + dataset = dataset_for_study_set(study, datasets, study_set) + models = normalize_model_list(dataset) + if component < 1 or component > len(models): + raise ValueError( + f"STUDY cluster {cluster_index} component {component} is outside dataset {study_set} DIPFIT models" + ) + normalized = _dipole_model(models[component - 1], cluster_index, component) + if normalized is None: + continue + dataset_index = int(study["datasetinfo"][study_set - 1].get("index") or study_set) + normalized.update( + { + "member_index": member_index, + "study_set": study_set, + "dataset_index": dataset_index, + "component": component, + "subject": str(study["datasetinfo"][study_set - 1].get("subject") or ""), + "coordformat": str((dataset.get("dipfit") or {}).get("coordformat") or ""), + } + ) + dipoles.append(normalized) + if not dipoles: + raise ValueError(f"STUDY cluster {cluster_index} has no localized dipoles in the selection") + return { + "cluster_index": cluster_index, + "name": str(cluster.get("name") or f"Cluster {cluster_index}"), + "dipoles": dipoles, + "centroid": _centroid(dipoles), + "coordformat": _coordinate_format(dipoles), + } + + +def _dipole_model(model: dict[str, Any], cluster_index: int, component: int) -> dict[str, Any] | None: + positions = np.asarray(model.get("posxyz", []), dtype=float) + if positions.size == 0: + return None + positions = _xyz_matrix(positions, "posxyz", cluster_index, component) + moments = _xyz_matrix(model.get("momxyz", []), "momxyz", cluster_index, component) + if moments.shape != positions.shape: + raise ValueError(f"STUDY cluster {cluster_index} component {component} posxyz and momxyz shapes differ") + if positions.shape[0] == 2 and np.array_equal(positions[1], np.zeros(3)): + positions = positions[:1] + moments = moments[:1] + rv = np.asarray(model.get("rv", []), dtype=float) + if rv.size != 1 or not np.isfinite(rv.item()): + raise ValueError(f"STUDY cluster {cluster_index} component {component} rv must be one finite value") + return {"posxyz": positions.copy(), "momxyz": moments.copy(), "rv": float(rv.item())} + + +def _xyz_matrix(values: Any, name: str, cluster_index: int, component: int) -> np.ndarray: + array = np.asarray(values, dtype=float) + if array.ndim == 1: + array = array.reshape(1, -1) + if array.ndim != 2 or array.shape[1] != 3 or not np.all(np.isfinite(array)): + raise ValueError(f"STUDY cluster {cluster_index} component {component} {name} must be a finite n-by-3 array") + return array + + +def _centroid(dipoles: list[dict[str, Any]]) -> dict[str, Any]: + positions = np.stack([np.mean(dipole["posxyz"], axis=0) for dipole in dipoles]) + moments = np.stack([np.mean(dipole["momxyz"], axis=0) for dipole in dipoles]) + return { + "posxyz": np.mean(positions, axis=0, keepdims=True), + "momxyz": np.mean(moments, axis=0, keepdims=True), + "rv": float(np.mean([dipole["rv"] for dipole in dipoles])), + } + + +def _coordinate_format(dipoles: list[dict[str, Any]]) -> str: + formats = {dipole["coordformat"].strip().lower() for dipole in dipoles if dipole["coordformat"].strip()} + if len(formats) > 1: + raise ValueError("Selected STUDY dipoles use incompatible coordinate formats") + return next((dipole["coordformat"] for dipole in dipoles if dipole["coordformat"].strip()), "") + + +def _validate_coordinate_formats(selections: list[dict[str, Any]]) -> None: + formats = {selection["coordformat"].strip().lower() for selection in selections if selection["coordformat"].strip()} + if len(formats) > 1: + raise ValueError("Selected STUDY clusters use incompatible coordinate formats") + + +def _plot_values(value: Any, count: int, default: Any) -> list[Any]: + if value is None or _is_empty(value): + if default == "tab10": + return [plt.get_cmap(default)(index % 10) for index in range(count)] + return [default] * count + if isinstance(value, str) or np.asarray(value).ndim == 0: + return [value] * count + values = list(value) + if len(values) == 1: + return values * count + if len(values) != count: + raise ValueError(f"plot style must contain one value or {count} cluster values") + return values + + +def _plot_selections(selections: list[dict[str, Any]], mode: str, colors: list[Any], sizes: list[Any]) -> list[Any]: + if mode == "apart": + return [ + _cluster_figure(selection, color, size) + for selection, color, size in zip(selections, colors, sizes, strict=True) + ] + if mode == "together": + figure = plt.figure(figsize=(6.2 * len(selections), 5.5)) + for index, (selection, color, size) in enumerate(zip(selections, colors, sizes, strict=True), start=1): + axis = figure.add_subplot(1, len(selections), index, projection="3d") + _plot_cluster(axis, selection, color, size, include_dipoles=True, include_centroid=True) + axis.set_title(str(selection["name"])) + _finish_axis(axis, [selection]) + figure.tight_layout() + return [figure] + figure = plt.figure(figsize=(7.2, 6.0)) + axis = figure.add_subplot(111, projection="3d") + for selection, color, size in zip(selections, colors, sizes, strict=True): + _plot_cluster( + axis, + selection, + color, + size, + include_dipoles=mode != "centroid", + include_centroid=mode != "comps", + ) + axis.set_title("STUDY dipole centroids" if mode == "centroid" else "STUDY cluster dipoles") + _finish_axis(axis, selections) + figure.tight_layout() + return [figure] + + +def _cluster_figure(selection: dict[str, Any], color: Any, size: Any) -> Any: + figure = plt.figure(figsize=(7.2, 6.0)) + axis = figure.add_subplot(111, projection="3d") + _plot_cluster(axis, selection, color, size, include_dipoles=True, include_centroid=True) + axis.set_title(str(selection["name"])) + _finish_axis(axis, [selection]) + figure.tight_layout() + return figure + + +def _plot_cluster( + axis: Any, + selection: dict[str, Any], + color: Any, + size: Any, + *, + include_dipoles: bool, + include_centroid: bool, +) -> None: + if include_dipoles: + for dipole in selection["dipoles"]: + positions = dipole["posxyz"] + moments = dipole["momxyz"] + label = f"{selection['name']}: {dipole['subject']} IC{dipole['component']} (RV {dipole['rv'] * 100:.1f}%)" + axis.scatter(*positions.T, s=float(size), color=color, label=label) + axis.quiver(*positions.T, *moments.T, length=15.0, normalize=True, color=color) + if include_centroid: + centroid = selection["centroid"] + label = f"{selection['name']} centroid (RV {centroid['rv'] * 100:.1f}%)" + axis.scatter( + *centroid["posxyz"].T, + s=float(size) * 1.35, + marker="D", + color=color, + edgecolor="black", + label=label, + ) + + +def _finish_axis(axis: Any, selections: list[dict[str, Any]]) -> None: + positions = [dipole["posxyz"] for selection in selections for dipole in selection["dipoles"]] + values = np.concatenate(positions, axis=0) + center = np.mean(values, axis=0) + radius = max(float(np.max(np.linalg.norm(values - center, axis=1))), 10.0) + axis.set_xlim(center[0] - radius, center[0] + radius) + axis.set_ylim(center[1] - radius, center[1] + radius) + axis.set_zlim(center[2] - radius, center[2] + radius) + axis.set_xlabel("X") + axis.set_ylabel("Y") + axis.set_zlabel("Z") + formats = [selection["coordformat"] for selection in selections if selection["coordformat"]] + if formats: + axis.text2D(0.02, 0.98, formats[0], transform=axis.transAxes, va="top") + axis.legend(fontsize=7) + + +def _is_empty(value: Any) -> bool: + if isinstance(value, np.ndarray): + return value.size == 0 + return isinstance(value, (list, tuple)) and not value __all__ = ["std_dipplot"] diff --git a/src/eegprep/resources/help/eeg_helpstudy.md b/src/eegprep/resources/help/eeg_helpstudy.md index abfc7d6b..d1cf8f6d 100644 --- a/src/eegprep/resources/help/eeg_helpstudy.md +++ b/src/eegprep/resources/help/eeg_helpstudy.md @@ -34,7 +34,9 @@ parent `STUDY.cluster[0]` entry by `pop_precomp`. LIMO result computation and browsing are not silently emulated. `pop_limo`, `pop_limoresults`, `std_limo`, `std_limoresults`, and `std_readfilelimo` report that standalone EEGPrep does not run EEGLAB's external LIMO toolbox -workflow. STUDY source workflows such as `std_dipplot` and -`std_dipoleclusters` remain behind the DIPFIT/FieldTrip backend boundary. +workflow. `std_dipplot` plots existing `EEG.dipfit.model` values by STUDY +cluster and returns its selected coordinates, moments, residual variances, and +centroids for inspection. The broader `std_dipoleclusters` workflow remains +behind the DIPFIT/FieldTrip backend boundary. See also: POP_STUDY, POP_PRECOMP, POP_CHANPLOT, POP_LOADSTUDY, POP_SAVESTUDY, POP_PRECLUST, POP_CLUST, POP_CLUSTEDIT, STD_PAC diff --git a/tests/test_study_dipplot_eeglab_tests.py b/tests/test_study_dipplot_eeglab_tests.py new file mode 100644 index 00000000..ed3ff6f0 --- /dev/null +++ b/tests/test_study_dipplot_eeglab_tests.py @@ -0,0 +1,197 @@ +"""Behavioral port of the current EEGLAB ``std_dipplot`` wrapper test.""" + +from __future__ import annotations + +import ast + +import matplotlib + +matplotlib.use("Agg") + +from matplotlib import pyplot as plt +import numpy as np +import pytest + +import eegprep +from eegprep.functions.studyfunc.std_dipplot import std_dipplot +from tests.eeglab_tests import eeglab_test + + +UPSTREAM = "unittesting_studyfunc/std_dipplot/studyfunc_std_dipplot_wrapperTest.m" + + +def _dipole(posxyz, momxyz, rv): + return {"posxyz": posxyz, "momxyz": momxyz, "rv": rv} + + +def _study_with_dipoles() -> tuple[dict, list[dict]]: + datasets = [ + { + "setname": "subject-01", + "subject": "S01", + "dipfit": { + "coordformat": "MNI", + "model": [ + _dipole([10.0, 1.0, 20.0], [1.0, 0.0, 1.0], 0.1), + _dipole( + [[-20.0, 5.0, 30.0], [20.0, 5.0, 30.0]], + [[-0.5, 1.0, 0.0], [0.5, 1.0, 0.0]], + 0.2, + ), + _dipole([], [], 0.5), + ], + }, + }, + { + "setname": "subject-02", + "subject": "S02", + "dipfit": { + "coordformat": "MNI", + "model": [ + _dipole([30.0, -10.0, 40.0], [-1.0, 0.0, 0.0], 0.3), + _dipole([50.0, 0.0, 60.0], [0.0, 1.0, 0.0], 0.4), + ], + }, + }, + ] + study = { + "name": "Localized components", + "datasetinfo": [ + {"index": 1, "subject": "S01", "comps": [1, 2, 3]}, + {"index": 2, "subject": "S02", "comps": [1, 2]}, + ], + "cluster": [ + { + "name": "ParentCluster", + "sets": [[1, 1, 1, 2, 2]], + "comps": [1, 2, 3, 1, 2], + }, + {"name": "Cluster A", "sets": [[1, 2]], "comps": [2, 1]}, + {"name": "Cluster B", "sets": [[1, 2]], "comps": [1, 2]}, + ], + } + return study, datasets + + +@eeglab_test(UPSTREAM, "test_test_std_dipplot") +def test_current_wrapper_joined_and_centroid_modes_select_and_plot_real_dipfit_values(): + study, alleeg = _study_with_dipoles() + + updated, selections, figures, command = std_dipplot( + study, + alleeg, + "clusters", + "all", + "mode", + "joined", + return_com=True, + ) + + assert [selection["cluster_index"] for selection in selections] == [2, 3] + assert [selection["coordformat"] for selection in selections] == ["MNI", "MNI"] + assert [ + (dipole["study_set"], dipole["dataset_index"], dipole["component"]) + for selection in selections + for dipole in selection["dipoles"] + ] == [(1, 1, 2), (2, 2, 1), (1, 1, 1), (2, 2, 2)] + np.testing.assert_allclose( + selections[0]["dipoles"][0]["posxyz"], + [[-20.0, 5.0, 30.0], [20.0, 5.0, 30.0]], + ) + np.testing.assert_allclose(selections[0]["centroid"]["posxyz"], [[15.0, -2.5, 35.0]]) + np.testing.assert_allclose(selections[0]["centroid"]["momxyz"], [[-0.5, 0.5, 0.0]]) + assert selections[0]["centroid"]["rv"] == pytest.approx(0.25) + np.testing.assert_allclose(updated["cluster"][1]["dipole"]["posxyz"], [[15.0, -2.5, 35.0]]) + assert len(figures) == 1 + labels = figures[0].axes[0].get_legend_handles_labels()[1] + assert "Cluster A: S01 IC2 (RV 20.0%)" in labels + assert "Cluster B centroid (RV 25.0%)" in labels + assert command.startswith("STUDY, DIPOLES, FIGURES = std_dipplot(") + ast.parse(command) + plt.close(figures[0]) + + updated, selections, figures = std_dipplot(study, alleeg, "clusters", 3, "mode", "centroid") + + assert len(selections) == 1 + np.testing.assert_allclose(selections[0]["centroid"]["posxyz"], [[30.0, 0.5, 40.0]]) + assert selections[0]["centroid"]["rv"] == pytest.approx(0.25) + assert figures[0].axes[0].get_legend_handles_labels()[1] == ["Cluster B centroid (RV 25.0%)"] + np.testing.assert_allclose(updated["cluster"][2]["dipole"]["posxyz"], [[30.0, 0.5, 40.0]]) + plt.close(figures[0]) + + +def test_comps_selects_one_based_cluster_member_positions_without_plotting(): + study, alleeg = _study_with_dipoles() + + _study, selections, figures = std_dipplot(study, alleeg, clusters=2, comps=2, mode="comps", plot=False) + + assert figures == [] + assert len(selections[0]["dipoles"]) == 1 + assert selections[0]["dipoles"][0]["member_index"] == 2 + assert selections[0]["dipoles"][0]["study_set"] == 2 + assert selections[0]["dipoles"][0]["component"] == 1 + np.testing.assert_allclose(selections[0]["dipoles"][0]["posxyz"], [[30.0, -10.0, 40.0]]) + + _study, selections, _figures = std_dipplot(study, alleeg, clusters=2, comps="all", plot=False) + assert [dipole["member_index"] for dipole in selections[0]["dipoles"]] == [1, 2] + + +def test_unlocalized_members_are_excluded_from_centroid_inputs(): + study, alleeg = _study_with_dipoles() + study["cluster"][2] = {"name": "Cluster B", "sets": [[1, 2]], "comps": [3, 2]} + + _study, selections, _figures = std_dipplot(study, alleeg, clusters=3, plot=False) + + assert [(dipole["study_set"], dipole["component"]) for dipole in selections[0]["dipoles"]] == [(2, 2)] + np.testing.assert_allclose(selections[0]["centroid"]["posxyz"], [[50.0, 0.0, 60.0]]) + assert selections[0]["centroid"]["rv"] == pytest.approx(0.4) + + +def test_malformed_dipfit_values_and_unknown_modes_fail_clearly(): + study, alleeg = _study_with_dipoles() + + with pytest.raises(ValueError, match="mode must be one of"): + std_dipplot(study, alleeg, clusters=2, mode="silent-no-op", plot=False) + + alleeg[0]["dipfit"]["model"][1]["momxyz"] = [[1.0, 0.0, 0.0]] + with pytest.raises(ValueError, match="posxyz and momxyz shapes differ"): + std_dipplot(study, alleeg, clusters=2, plot=False) + + +def test_incompatible_coordinate_formats_are_not_combined(): + study, alleeg = _study_with_dipoles() + alleeg[1]["dipfit"]["coordformat"] = "spherical" + + with pytest.raises(ValueError, match="incompatible coordinate formats"): + std_dipplot(study, alleeg, clusters=2, plot=False) + + +@pytest.mark.parametrize( + ("mode", "figure_count", "axes_per_figure"), + [ + ("apart", 2, [1, 1]), + ("together", 1, [2]), + ("multicolor", 1, [1]), + ("comps", 1, [1]), + ], +) +def test_supported_member_plot_layouts_render(mode, figure_count, axes_per_figure): + study, alleeg = _study_with_dipoles() + + _study, _selections, figures = std_dipplot( + study, + alleeg, + clusters="all", + mode=mode, + dipcolor=["navy", "darkorange"], + dipsize=[35, 45], + ) + + assert len(figures) == figure_count + assert [len(figure.axes) for figure in figures] == axes_per_figure + for figure in figures: + plt.close(figure) + + +def test_std_dipplot_remains_available_from_the_package_api(): + assert eegprep.std_dipplot is std_dipplot diff --git a/tests/test_study_measures.py b/tests/test_study_measures.py index 835f1a48..927a3974 100644 --- a/tests/test_study_measures.py +++ b/tests/test_study_measures.py @@ -26,7 +26,6 @@ from eegprep.functions.studyfunc.std_erpplot import std_erpplot from eegprep.functions.studyfunc.std_erspplot import std_erspplot from eegprep.functions.studyfunc.std_dipoleclusters import std_dipoleclusters -from eegprep.functions.studyfunc.std_dipplot import std_dipplot from eegprep.functions.studyfunc.std_interp import std_interp from eegprep.functions.studyfunc.std_itcplot import std_itcplot from eegprep.functions.studyfunc.std_limodesign import std_limodesign @@ -561,8 +560,6 @@ def test_std_interp_adds_requested_missing_channels_without_dropping_existing(): assert eegprep.std_interp is std_interp -def test_source_dependent_study_helpers_report_explicit_boundary(): - with pytest.raises(NotImplementedError, match="FieldTrip/DIPFIT STUDY source workflows"): - std_dipplot({}, []) +def test_source_dependent_dipoleclusters_reports_explicit_boundary(): with pytest.raises(NotImplementedError, match="FieldTrip/DIPFIT STUDY source workflows"): std_dipoleclusters({}, []) From dfbf07f2bff7d2ca21b64e454ca47da02f909123 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:57 -0700 Subject: [PATCH 40/45] feat: support trial-level STUDY factor cells (#368) --- docs/source/changelog.rst | 5 + docs/source/user_guide/study_workflows.rst | 21 +- .../functions/studyfunc/_std_measureplot.py | 8 +- .../studyfunc/_study_measure_cells.py | 167 +++++++++++++++- .../functions/studyfunc/std_precomp.py | 177 ++++++++++++----- tests/test_study_trial_factor_measures.py | 183 ++++++++++++++++++ 6 files changed, 501 insertions(+), 60 deletions(-) create mode 100644 tests/test_study_trial_factor_measures.py diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 291c625f..0f4f1db2 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,11 @@ the `GitHub Releases `_ page. Unreleased ========== +- STUDY measure designs can now use categorical factors stored per trial. With + ``savetrials="on"``, ERP, spectrum, ERSP, and ITC plots select the matching + trials within each dataset and then aggregate one case per subject or + component. Spectrum and ERSP trials are averaged in linear power before dB + conversion, and ITC phases are averaged circularly. - ``std_dipplot`` now selects existing DIPFIT models through one-based STUDY cluster membership, returns their coordinates, moments, residual variances, and centroids, and renders joined, centroid, member, and separated views. diff --git a/docs/source/user_guide/study_workflows.rst b/docs/source/user_guide/study_workflows.rst index 8f5f4ef2..011ada5f 100644 --- a/docs/source/user_guide/study_workflows.rst +++ b/docs/source/user_guide/study_workflows.rst @@ -113,8 +113,9 @@ Channel measures are stored in ``STUDY.changrp``. Component measures are stored on the parent ``STUDY.cluster[0]`` entry so preclustering can read the same cached arrays. Cached measure fields follow EEGLAB names such as ``erpdata``, ``specdata``, ``erspdata``, and ``itcdata``. The selected ``design`` is recorded -in each measure group's metadata. EEGPrep stores dataset-level averages in the -current standalone cache rather than EEGLAB sidecar measure files. +in each measure group's metadata. EEGPrep stores dataset-level averages, and +optionally single-trial values, in the current standalone cache rather than +EEGLAB sidecar measure files. ``pop_chanplot`` reads cached channel and component measures through the same ``std_readdata``/``std_erpplot``/``std_erspplot`` cache contract used by scripts, so GUI and console plots slice axes and cached channel groups consistently. @@ -138,11 +139,17 @@ Benjamini-Hochberg correction. The same masks are attached to ``fig.eegprep_plot_metadata["statistics"]`` so downstream reporting code can inspect exactly what the plot highlighted. -Use ``savetrials="on"`` with ERSP/ITC precomputation when the per-trial -time-frequency representation is needed. ``erspdatatrials`` stores linear, -baseline-corrected power, so averaging trials and converting to decibels -reproduces ``erspdata``. ``itcdatatrials`` stores phase in radians. Both are -EEGPrep-owned cache fields and remain independent of an EEGLAB installation. +Use ``savetrials="on"`` when a design factor varies between trials within one +dataset. The measure plots select trials from ``datasetinfo[*]["trialinfo"]`` +and aggregate them within each subject before forming condition and group +cells. ``erpdatatrials`` stores baseline-corrected amplitudes; +``specdatatrials`` stores linear spectral density; ``erspdatatrials`` stores +linear, baseline-corrected power; and ``itcdatatrials`` stores phase in radians. +Spectrum and ERSP power are averaged before conversion to decibels, while ITC +uses circular phase averaging. These EEGPrep-owned cache fields remain +independent of an EEGLAB installation. A trial-level design without matching +single-trial caches raises an error instructing you to rerun ``std_precomp`` +with ``savetrials="on"`` instead of silently grouping dataset averages. For component clusters, ``std_topoplot`` draws polarity-aligned centroid or member scalp maps and caches ``topo``, ``topoall``, and ``topopol`` on each diff --git a/src/eegprep/functions/studyfunc/_std_measureplot.py b/src/eegprep/functions/studyfunc/_std_measureplot.py index df26b265..3967cbd4 100644 --- a/src/eegprep/functions/studyfunc/_std_measureplot.py +++ b/src/eegprep/functions/studyfunc/_std_measureplot.py @@ -234,8 +234,14 @@ def _read_grouped( ) -> tuple[dict[str, Any], list[GroupedMeasure], np.ndarray, np.ndarray, list[str]]: if channels is not None: study, raw, x_axis, y_axis = std_readdata(study, alleeg, datatype=datatype, channels=channels) - grouped = group_channel_measures(study, raw, datatype, design=design, subject=subject) names = _channel_names(study, channels) + cache_lookup = { + str(group.get("name") or "").lower(): group + for group in study.get("changrp") or [] + if isinstance(group, dict) + } + caches = [cache_lookup[name.lower()] for name in names] + grouped = group_channel_measures(study, raw, datatype, design=design, subject=subject, caches=caches) return study, [grouped], x_axis, y_axis, [", ".join(names)] cluster_indices = _cluster_indices(study, clusters) diff --git a/src/eegprep/functions/studyfunc/_study_measure_cells.py b/src/eegprep/functions/studyfunc/_study_measure_cells.py index f88222d2..f2e6321d 100644 --- a/src/eegprep/functions/studyfunc/_study_measure_cells.py +++ b/src/eegprep/functions/studyfunc/_study_measure_cells.py @@ -35,6 +35,7 @@ def group_channel_measures( *, design: int, subject: Any = None, + caches: list[dict[str, Any]] | None = None, ) -> GroupedMeasure: """Combine selected channel caches and split their dataset axis by design.""" arrays = [np.asarray(values) for values in data] @@ -47,7 +48,18 @@ def group_channel_measures( if len(arrays) == 1: raw = raw[:, 0, ...] dataset_ids = np.arange(1, raw.shape[0] + 1, dtype=int) - return _group_cases(study, raw, dataset_ids, datatype, design=design, subject=subject, component_cases=False) + trial_values, trialinfo = _channel_trial_cases(caches or [], datatype, raw.shape[0]) + return _group_cases( + study, + raw, + dataset_ids, + datatype, + design=design, + subject=subject, + component_cases=False, + trial_values=trial_values, + trialinfo=trialinfo, + ) def group_component_measures( @@ -59,6 +71,7 @@ def group_component_measures( components: Any, design: int, subject: Any = None, + source_cache: dict[str, Any] | None = None, ) -> GroupedMeasure: """Split parent or child component measures by STUDY design cells.""" clusters = cluster_list(study) @@ -71,6 +84,8 @@ def group_component_measures( rows = [] dataset_ids = [] case_labels = [] + trial_values = [] + trialinfo = [] for dataset_position, dataset_id in enumerate(dataset_axis.tolist()): for component_position in selection.tolist(): row = values[dataset_position, component_position, ...] @@ -80,6 +95,11 @@ def group_component_measures( rows.append(row) dataset_ids.append(int(dataset_id)) case_labels.append(_component_label(study, int(dataset_id), component_id)) + trial_value, trial_rows = _component_trial_case( + source_cache or source, datatype, dataset_position, component_position + ) + trial_values.append(trial_value) + trialinfo.append(trial_rows) else: cluster = clusters[cluster_index - 1] sets = sets_array(cluster.get("sets")).astype(int)[0] @@ -88,6 +108,23 @@ def group_component_measures( rows = [values[position] for position in positions] dataset_ids = [int(sets[position]) for position in positions] case_labels = [_component_label(study, int(sets[position]), int(comps[position])) for position in positions] + trial_values = [] + trialinfo = [] + source_cache = source_cache or source + dataset_axis = component_dataset_axis( + source_cache, np.asarray(source_cache.get(_data_field(datatype))).shape[0] + ) + component_axis = component_measure_axis( + source_cache, np.asarray(source_cache.get(_data_field(datatype))).shape[1] + ) + for position in positions.tolist(): + dataset_position = _axis_position(dataset_axis, int(sets[position]), "dataset") + component_position = _axis_position(component_axis, int(comps[position]), "component") + trial_value, trial_rows = _component_trial_case( + source_cache, datatype, dataset_position, component_position + ) + trial_values.append(trial_value) + trialinfo.append(trial_rows) if not rows: raise ValueError("Selected STUDY cluster contains no cached components") return _group_cases( @@ -99,6 +136,8 @@ def group_component_measures( subject=subject, component_cases=True, case_labels=case_labels, + trial_values=trial_values, + trialinfo=trialinfo, ) @@ -112,6 +151,8 @@ def _group_cases( subject: Any, component_cases: bool, case_labels: list[str] | None = None, + trial_values: list[np.ndarray | None] | None = None, + trialinfo: list[list[dict[str, Any]]] | None = None, ) -> GroupedMeasure: design_info = _design(study, design) variables = [item for item in design_info.get("variable") or [] if isinstance(item, dict)] @@ -129,7 +170,7 @@ def _group_cases( row = [] row_labels = [] for group_label, group_value in groups: - positions = [] + selected_values = [] labels_for_cell = [] for position, dataset_id in enumerate(dataset_ids.tolist()): info = datasetinfo[dataset_id - 1] @@ -138,13 +179,20 @@ def _group_cases( continue if allowed_cases and subject_name not in allowed_cases: continue - if variables and not _matches(info.get(variables[0].get("label")), condition_value): - continue - if len(variables) > 1 and not _matches(info.get(variables[1].get("label")), group_value): + levels = [condition_value, group_value] + trial_value = trial_values[position] if trial_values else None + trial_rows = trialinfo[position] if trialinfo else [] + if not trial_rows: + trial_rows = [row for row in info.get("trialinfo") or [] if isinstance(row, dict)] + matches, trial_mask = _case_selection(info, variables, levels, trial_rows, trial_value) + if not matches: continue - positions.append(position) + value = raw[position] + if trial_mask is not None: + value = _aggregate_trials(trial_value[..., trial_mask], datatype) + selected_values.append(value) labels_for_cell.append(case_labels[position] if case_labels else subject_name) - selected = raw[np.asarray(positions, dtype=int), ...] if positions else raw[:0, ...] + selected = np.asarray(selected_values) if selected_values else raw[:0, ...] if not component_cases: selected, labels_for_cell = _average_repeated_subjects(selected, labels_for_cell) row.append(_case_last(selected, datatype)) @@ -159,6 +207,111 @@ def _group_cases( ) +def _case_selection( + info: dict[str, Any], + variables: list[dict[str, Any]], + levels: list[Any], + trialinfo: list[dict[str, Any]], + trial_values: np.ndarray | None, +) -> tuple[bool, np.ndarray | None]: + trial_mask = None + for variable, level in zip(variables, levels): + label = str(variable.get("label") or "") + if label in info and _has_dataset_value(info[label]): + if not _matches(info[label], level): + return False, None + continue + if not any(label in row for row in trialinfo): + return False, None + if trial_values is None: + raise ValueError(f"design variable {label!r} is trial-level; rerun std_precomp with savetrials='on'") + if len(trialinfo) != trial_values.shape[-1]: + raise ValueError("single-trial measure cache and trialinfo lengths do not match") + current = np.asarray([_matches(row.get(label), level) for row in trialinfo], dtype=bool) + trial_mask = current if trial_mask is None else trial_mask & current + if trial_mask is not None and not np.any(trial_mask): + return False, None + return True, trial_mask + + +def _has_dataset_value(value: Any) -> bool: + return value is not None and not (isinstance(value, str) and value == "") + + +def _aggregate_trials(values: np.ndarray, datatype: str) -> np.ndarray: + if datatype in {"spec", "ersp"}: + power = np.nanmean(values, axis=-1) + return 10.0 * np.log10(np.maximum(power, np.finfo(float).tiny)) + if datatype == "itc": + return np.abs(np.nanmean(np.exp(1j * values), axis=-1)) + return np.nanmean(values, axis=-1) + + +def _channel_trial_cases( + caches: list[dict[str, Any]], datatype: str, dataset_count: int +) -> tuple[list[np.ndarray | None], list[list[dict[str, Any]]]]: + field = _trial_field(datatype) + info_field = _trialinfo_field(datatype) + values: list[np.ndarray | None] = [] + all_trialinfo: list[list[dict[str, Any]]] = [] + for dataset_position in range(dataset_count): + channel_trials = [] + rows: list[dict[str, Any]] = [] + for cache in caches: + stored = cache.get(field) + if not isinstance(stored, list) or dataset_position >= len(stored): + channel_trials = [] + break + channel_trials.append(np.asarray(stored[dataset_position], dtype=float)) + cached_rows = cache.get(info_field) + if isinstance(cached_rows, list) and dataset_position < len(cached_rows): + rows = [row for row in cached_rows[dataset_position] if isinstance(row, dict)] + if channel_trials: + shape = channel_trials[0].shape + if any(item.shape != shape for item in channel_trials): + raise ValueError("selected channel single-trial caches must have matching shapes") + values.append(channel_trials[0] if len(channel_trials) == 1 else np.stack(channel_trials, axis=0)) + else: + values.append(None) + all_trialinfo.append(rows) + return values, all_trialinfo + + +def _component_trial_case( + source: dict[str, Any], datatype: str, dataset_position: int, component_position: int +) -> tuple[np.ndarray | None, list[dict[str, Any]]]: + stored = source.get(_trial_field(datatype)) + value = None + if isinstance(stored, list) and dataset_position < len(stored): + dataset = stored[dataset_position] + if isinstance(dataset, list) and component_position < len(dataset) and dataset[component_position] is not None: + value = np.asarray(dataset[component_position], dtype=float) + stored_rows = source.get(_trialinfo_field(datatype)) + rows = [] + if isinstance(stored_rows, list) and dataset_position < len(stored_rows): + rows = [row for row in stored_rows[dataset_position] if isinstance(row, dict)] + return value, rows + + +def _trial_field(datatype: str) -> str: + return f"{datatype}datatrials" + + +def _trialinfo_field(datatype: str) -> str: + return f"{datatype}trialinfo" + + +def _data_field(datatype: str) -> str: + return f"{datatype}data" + + +def _axis_position(axis: np.ndarray, value: int, label: str) -> int: + found = np.where(axis == value)[0] + if not found.size: + raise ValueError(f"{label} {value} is not present in the parent cluster cache") + return int(found[0]) + + def _design(study: dict[str, Any], design: int) -> dict[str, Any]: designs = study.get("design") or [] if not designs: diff --git a/src/eegprep/functions/studyfunc/std_precomp.py b/src/eegprep/functions/studyfunc/std_precomp.py index 670600a8..3713a1cd 100644 --- a/src/eegprep/functions/studyfunc/std_precomp.py +++ b/src/eegprep/functions/studyfunc/std_precomp.py @@ -18,7 +18,12 @@ ) from eegprep.functions.popfunc._pop_utils import is_on, parse_key_value_args, parse_text_tokens from eegprep.functions.sigprocfunc.spectopo import compute_spectra -from eegprep.functions.studyfunc._study_utils import as_alleeg_list, build_python_call, ensure_study +from eegprep.functions.studyfunc._study_utils import ( + as_alleeg_list, + build_python_call, + ensure_study, + trialinfo_from_eeg, +) from eegprep.functions.studyfunc.std_checkset import std_checkset from eegprep.functions.timefreqfunc.newtimef import newtimef from eegprep.functions.timefreqfunc.newtimefbaseln import newtimefbaseln @@ -53,8 +58,9 @@ def std_precomp( Measures are stored directly in ``STUDY.changrp`` for channels and in the parent ``STUDY.cluster`` entry for components. ``savetrials='on'`` also - retains baseline-corrected ERSP trial power and ITC phase. Field names - follow EEGLAB's cached-measure names without runtime sidecar dependencies. + retains baseline-corrected ERP trials, linear spectral/ERSP trial power, + and ITC phase. Field names follow EEGLAB's cached-measure names without + runtime sidecar dependencies. """ datasets = as_alleeg_list(ALLEEG) if not datasets: @@ -176,15 +182,25 @@ def _precompute_channels( }, } if "erp" in computed: - if _keep_cached(prior, "erpdata", force): - _carry(entry, prior, ("erpdata", "erptimes")) + erp_cached = _keep_cached(prior, "erpdata", force) and (not savetrials or "erpdatatrials" in prior) + if erp_cached: + _carry(entry, prior, ("erpdata", "erptimes", "erpdatatrials", "erptrialinfo")) else: - entry["erpdata"], entry["erptimes"] = _channel_erp(datasets, channel_index, erpparams) + erpdata, erptimes, erptrials = _channel_erp(datasets, channel_index, erpparams) + entry["erpdata"], entry["erptimes"] = erpdata, erptimes + if savetrials: + entry["erpdatatrials"] = erptrials + entry["erptrialinfo"] = _trialinfo_metadata(datasets, erptrials) if "spec" in computed: - if _keep_cached(prior, "specdata", force): - _carry(entry, prior, ("specdata", "specfreqs")) + spec_cached = _keep_cached(prior, "specdata", force) and (not savetrials or "specdatatrials" in prior) + if spec_cached: + _carry(entry, prior, ("specdata", "specfreqs", "specdatatrials", "spectrialinfo")) else: - entry["specdata"], entry["specfreqs"] = _channel_spec(datasets, channel_index, specparams) + specdata, specfreqs, spectrials = _channel_spec(datasets, channel_index, specparams) + entry["specdata"], entry["specfreqs"] = specdata, specfreqs + if savetrials: + entry["specdatatrials"] = spectrials + entry["spectrialinfo"] = _trialinfo_metadata(datasets, spectrials) ersp_cached = ( _keep_cached(prior, "erspdata", force) and (not savetrials or "erspdatatrials" in prior) if "ersp" in computed @@ -238,8 +254,12 @@ def _precompute_channels( entry["itcdatatrials"] = tf["itcdatatrials"] entry["itcsubjinds"] = tf["subjinds"] entry["itctrialinfo"] = tf["trialinfo"] - if savetrials and tf is not None: + if savetrials: trial_cache = {} + if "erp" in computed: + trial_cache["erpdatatrials"] = "baseline-corrected amplitude" + if "spec" in computed: + trial_cache["specdatatrials"] = "linear power spectral density" if "ersp" in computed: trial_cache["erspdatatrials"] = "linear baseline-corrected power" if "itc" in computed: @@ -288,19 +308,27 @@ def _precompute_components( if scalp: cluster["topo"] = _component_topographies(datasets, selected, selection_mask) if "erp" in computed: - if _keep_cached(cached, "erpdata", force): - _carry(cluster, cached, ("erpdata", "erptimes")) + erp_cached = _keep_cached(cached, "erpdata", force) and (not savetrials or "erpdatatrials" in cached) + if erp_cached: + _carry(cluster, cached, ("erpdata", "erptimes", "erpdatatrials", "erptrialinfo")) else: - cluster["erpdata"], cluster["erptimes"] = _component_erp( - datasets, activations, selected, selection_mask, erpparams - ) + erpdata, erptimes, erptrials = _component_erp(datasets, activations, selected, selection_mask, erpparams) + cluster["erpdata"], cluster["erptimes"] = erpdata, erptimes + if savetrials: + cluster["erpdatatrials"] = erptrials + cluster["erptrialinfo"] = _component_trialinfo_metadata(datasets, erptrials) if "spec" in computed: - if _keep_cached(cached, "specdata", force): - _carry(cluster, cached, ("specdata", "specfreqs")) + spec_cached = _keep_cached(cached, "specdata", force) and (not savetrials or "specdatatrials" in cached) + if spec_cached: + _carry(cluster, cached, ("specdata", "specfreqs", "specdatatrials", "spectrialinfo")) else: - cluster["specdata"], cluster["specfreqs"] = _component_spec( + specdata, specfreqs, spectrials = _component_spec( datasets, activations, selected, selection_mask, specparams ) + cluster["specdata"], cluster["specfreqs"] = specdata, specfreqs + if savetrials: + cluster["specdatatrials"] = spectrials + cluster["spectrialinfo"] = _component_trialinfo_metadata(datasets, spectrials) ersp_cached = ( _keep_cached(cached, "erspdata", force) and (not savetrials or "erspdatatrials" in cached) if "ersp" in computed @@ -348,8 +376,12 @@ def _precompute_components( cluster["itcdatatrials"] = tf["itcdatatrials"] cluster["itcsubjinds"] = tf["subjinds"] cluster["itctrialinfo"] = tf["trialinfo"] - if savetrials and tf is not None: + if savetrials: trial_cache = {} + if "erp" in computed: + trial_cache["erpdatatrials"] = "baseline-corrected amplitude" + if "spec" in computed: + trial_cache["specdatatrials"] = "linear power spectral density" if "ersp" in computed: trial_cache["erspdatatrials"] = "linear baseline-corrected power" if "itc" in computed: @@ -360,15 +392,17 @@ def _precompute_components( def _channel_erp( datasets: list[dict[str, Any]], channel_index: int, params: dict[str, Any] -) -> tuple[list[list[float]], list[float]]: +) -> tuple[list[list[float]], list[float], list[Any]]: times = _shared_times(datasets) baseline = params.get("rmbase") values = [] + trials = [] for eeg in datasets: data = eeg_epoch_data(eeg)[channel_index, :, :] data = _remove_baseline(data, times, baseline) values.append(np.nanmean(data, axis=1)) - return np.asarray(values, dtype=float).tolist(), times.tolist() + trials.append(data) + return np.asarray(values, dtype=float).tolist(), times.tolist(), [item.tolist() for item in trials] def _component_erp( @@ -377,42 +411,50 @@ def _component_erp( selected: np.ndarray, selection_mask: np.ndarray, params: dict[str, Any], -) -> tuple[list[Any], list[float]]: +) -> tuple[list[Any], list[float], list[Any]]: times = _shared_times(datasets) baseline = params.get("rmbase") values = [] + trials = [] for dataset_index, (eeg, acts) in enumerate(zip(datasets, activations)): maps = np.asarray(eeg.get("icawinv", []), dtype=float) dataset_values = [] + dataset_trials = [] for component_position, component_index in enumerate(selected): if not selection_mask[dataset_index, component_position]: dataset_values.append(np.full(times.shape, np.nan)) + dataset_trials.append(None) continue scale = _component_scale(maps, int(component_index)) data = _remove_baseline(acts[component_index, :, :], times, baseline) dataset_values.append(np.nanmean(data, axis=1) * scale) + dataset_trials.append(data * scale) values.append(np.asarray(dataset_values, dtype=float)) - return np.asarray(values, dtype=float).tolist(), times.tolist() + trials.append(dataset_trials) + return ( + np.asarray(values, dtype=float).tolist(), + times.tolist(), + [[None if item is None else item.tolist() for item in dataset] for dataset in trials], + ) def _channel_spec( datasets: list[dict[str, Any]], channel_index: int, params: dict[str, Any] -) -> tuple[list[list[float]], list[float]]: +) -> tuple[list[list[float]], list[float], list[Any]]: spectra = [] + trials = [] freqs = None for eeg in datasets: data = eeg_epoch_data(eeg)[channel_index : channel_index + 1, :, :] - spectrum, frequency_values, _specstd = compute_spectra( - data, - int(eeg.get("pnts", data.shape[1]) or data.shape[1]), - float(eeg.get("srate", 1.0) or 1.0), - winsize=_optional_int(params.get("winsize")), - overlap=_optional_int(params.get("overlap"), default=0), - nfft=_optional_int(params.get("nfft")), - ) + trial_power, frequency_values = _single_trial_spectra(data, eeg, params) freqs = _check_axis(freqs, frequency_values, "spectrum frequencies") - spectra.append(spectrum[0]) - return np.asarray(spectra, dtype=float).tolist(), np.asarray(freqs, dtype=float).tolist() + spectra.append(_power_to_db(np.nanmean(trial_power, axis=-1))[0]) + trials.append(trial_power[0]) + return ( + np.asarray(spectra, dtype=float).tolist(), + np.asarray(freqs, dtype=float).tolist(), + [item.tolist() for item in trials], + ) def _component_spec( @@ -421,29 +463,58 @@ def _component_spec( selected: np.ndarray, selection_mask: np.ndarray, params: dict[str, Any], -) -> tuple[list[Any], list[float]]: +) -> tuple[list[Any], list[float], list[Any]]: spectra: list[list[np.ndarray | None]] = [] + trials: list[list[np.ndarray | None]] = [] freqs = None for dataset_index, (eeg, acts) in enumerate(zip(datasets, activations)): dataset_values = [] + dataset_trials = [] for component_position, component_index in enumerate(selected): if not selection_mask[dataset_index, component_position]: dataset_values.append(None) + dataset_trials.append(None) continue - spectrum, frequency_values, _specstd = compute_spectra( + trial_power, frequency_values = _single_trial_spectra( acts[component_index : component_index + 1, :, :], - int(eeg.get("pnts", acts.shape[1]) or acts.shape[1]), - float(eeg.get("srate", 1.0) or 1.0), - winsize=_optional_int(params.get("winsize")), - overlap=_optional_int(params.get("overlap"), default=0), - nfft=_optional_int(params.get("nfft")), + eeg, + params, ) freqs = _check_axis(freqs, frequency_values, "spectrum frequencies") - dataset_values.append(np.asarray(spectrum[0], dtype=float)) + dataset_values.append(_power_to_db(np.nanmean(trial_power, axis=-1))[0]) + dataset_trials.append(trial_power[0]) spectra.append(dataset_values) + trials.append(dataset_trials) if freqs is None: raise ValueError("component spectrum precompute has no selected components") - return _fill_missing_component_rows(spectra, len(freqs)).tolist(), np.asarray(freqs, dtype=float).tolist() + return ( + _fill_missing_component_rows(spectra, len(freqs)).tolist(), + np.asarray(freqs, dtype=float).tolist(), + [[None if item is None else item.tolist() for item in dataset] for dataset in trials], + ) + + +def _single_trial_spectra( + data: np.ndarray, eeg: dict[str, Any], params: dict[str, Any] +) -> tuple[np.ndarray, np.ndarray]: + powers = [] + freqs = None + for trial_index in range(data.shape[-1]): + spectrum, frequency_values, _specstd = compute_spectra( + data[:, :, trial_index : trial_index + 1], + int(eeg.get("pnts", data.shape[1]) or data.shape[1]), + float(eeg.get("srate", 1.0) or 1.0), + winsize=_optional_int(params.get("winsize")), + overlap=_optional_int(params.get("overlap"), default=0), + nfft=_optional_int(params.get("nfft")), + ) + freqs = _check_axis(freqs, frequency_values, "spectrum frequencies") + powers.append(10.0 ** (np.asarray(spectrum, dtype=float) / 10.0)) + return np.stack(powers, axis=-1), np.asarray(freqs, dtype=float) + + +def _power_to_db(power: np.ndarray) -> np.ndarray: + return 10.0 * np.log10(np.maximum(power, np.finfo(float).tiny)) def _channel_time_frequency( @@ -572,6 +643,22 @@ def _trial_cache_metadata( } +def _trialinfo_metadata(datasets: list[dict[str, Any]], trial_values: list[Any]) -> list[list[dict[str, Any]]]: + return [_dataset_trialinfo(eeg, np.asarray(values).shape[-1]) for eeg, values in zip(datasets, trial_values)] + + +def _component_trialinfo_metadata( + datasets: list[dict[str, Any]], trial_values: list[list[Any | None]] +) -> list[list[dict[str, Any]]]: + return [ + _dataset_trialinfo(eeg, _first_serialized_trial_count(dataset)) for eeg, dataset in zip(datasets, trial_values) + ] + + +def _first_serialized_trial_count(dataset: list[Any | None]) -> int: + return next((np.asarray(values).shape[-1] for values in dataset if values is not None), 0) + + def _component_trial_cache_metadata( datasets: list[dict[str, Any]], trial_power: list[list[np.ndarray | None]], @@ -600,8 +687,8 @@ def _first_trial_count(dataset: list[np.ndarray | None]) -> int: def _dataset_trialinfo(eeg: dict[str, Any], count: int) -> list[dict[str, Any]]: rows = eeg.get("trialinfo") - if not isinstance(rows, list): - rows = [] + if not isinstance(rows, list) or not rows: + rows = trialinfo_from_eeg(eeg) return [ deepcopy(rows[index]) if index < len(rows) and isinstance(rows[index], dict) else {} for index in range(count) ] diff --git a/tests/test_study_trial_factor_measures.py b/tests/test_study_trial_factor_measures.py new file mode 100644 index 00000000..759acffd --- /dev/null +++ b/tests/test_study_trial_factor_measures.py @@ -0,0 +1,183 @@ +"""Regression coverage for STUDY designs whose factors vary within datasets.""" + +from __future__ import annotations + +from copy import deepcopy + +import numpy as np +import pytest + +from eegprep import pop_study, std_erpplot, std_erspplot, std_itcplot, std_precomp, std_specplot +from eegprep.functions.studyfunc.std_makedesign import std_makedesign + + +def _trial_factor_study() -> tuple[dict, list[dict]]: + datasets = [] + srate = 64.0 + pnts = 64 + trials = 6 + seconds = np.arange(pnts, dtype=float) / srate + conditions = ["standard", "target"] * 3 + for group_index, group in enumerate(("control", "patient")): + for subject_index in range(2): + subject = f"{group[0].upper()}{subject_index + 1:02d}" + data = np.empty((2, pnts, trials), dtype=float) + activations = np.empty((1, pnts, trials), dtype=float) + for trial, condition in enumerate(conditions): + condition_index = int(condition == "target") + amplitude = 1.0 + 0.25 * group_index + 0.1 * subject_index + 0.4 * condition_index + phase = 0.07 * trial + data[0, :, trial] = amplitude * np.sin(2 * np.pi * 8 * seconds + phase) + condition_index + data[1, :, trial] = (amplitude + 0.2) * np.cos(2 * np.pi * 12 * seconds + phase) + activations[0, :, trial] = amplitude * np.sin(2 * np.pi * 10 * seconds + phase) + datasets.append( + { + "setname": subject, + "subject": subject, + "condition": "", + "group": group, + "session": 1, + "run": 1, + "data": data, + "nbchan": 2, + "pnts": pnts, + "trials": trials, + "srate": srate, + "xmin": 0.0, + "xmax": float(seconds[-1]), + "times": seconds * 1000.0, + "chanlocs": [{"labels": "Cz"}, {"labels": "Pz"}], + "icaact": activations, + "icawinv": np.asarray([[1.0], [0.0]]), + "icaweights": np.asarray([[1.0, 0.0]]), + "icasphere": np.eye(2), + "icachansind": [0, 1], + "trialinfo": [{"condition": condition} for condition in conditions], + "event": [], + "urevent": [], + "epoch": [{"condition": condition} for condition in conditions], + "etc": {}, + } + ) + study, alleeg = pop_study(None, datasets, name="Trial-factor study") + study = std_makedesign( + study, + alleeg, + 1, + variable1="condition", + values1=["standard", "target"], + variable2="group", + values2=["control", "patient"], + ) + return study, alleeg + + +def _precompute_all(study: dict, alleeg: list[dict]) -> tuple[dict, list[dict]]: + tf_params = {"cycles": 0, "nfreqs": 4, "timesout": 5, "baseline": np.nan} + study, alleeg = std_precomp( + study, + alleeg, + [1], + erp="on", + spec="on", + ersp="on", + itc="on", + savetrials="on", + recompute="on", + erspparams=tf_params, + ) + return std_precomp( + study, + alleeg, + "components", + erp="on", + spec="on", + ersp="on", + itc="on", + savetrials="on", + recompute="on", + erspparams=tf_params, + ) + + +def _expected_cells(cache: dict, datatype: str, *, component: bool = False) -> list[list[np.ndarray]]: + field = f"{datatype}datatrials" + info_field = f"{datatype}trialinfo" + output = [] + for condition in ("standard", "target"): + row = [] + for dataset_indices in ((0, 1), (2, 3)): + cases = [] + for dataset_index in dataset_indices: + values = cache[field][dataset_index] + if component: + values = values[0] + values = np.asarray(values, dtype=float) + mask = np.asarray( + [item["condition"] == condition for item in cache[info_field][dataset_index]], dtype=bool + ) + selected = values[..., mask] + if datatype in {"spec", "ersp"}: + case = 10 * np.log10(np.mean(selected, axis=-1)) + elif datatype == "itc": + case = np.abs(np.mean(np.exp(1j * selected), axis=-1)) + else: + case = np.mean(selected, axis=-1) + cases.append(case) + row.append(np.stack(cases, axis=-1)) + output.append(row) + return output + + +@pytest.mark.parametrize( + ("datatype", "plotter"), + [ + ("erp", std_erpplot), + ("spec", std_specplot), + ("ersp", std_erspplot), + ("itc", std_itcplot), + ], +) +def test_trial_factor_channel_cells_reconstruct_from_single_trial_caches(datatype, plotter): + study, alleeg = _precompute_all(*_trial_factor_study()) + cache = study["changrp"][0] + + result = plotter(study, alleeg, channels=[1], noplot="on") + cells = result[1] + + expected = _expected_cells(cache, datatype) + assert [[values.shape[-1] for values in row] for row in cells] == [[2, 2], [2, 2]] + for actual_row, expected_row in zip(cells, expected): + for actual, expected_values in zip(actual_row, expected_row): + np.testing.assert_allclose(actual, expected_values, atol=1e-12) + + +@pytest.mark.parametrize( + ("datatype", "plotter"), + [ + ("erp", std_erpplot), + ("spec", std_specplot), + ("ersp", std_erspplot), + ("itc", std_itcplot), + ], +) +def test_trial_factor_component_cluster_preserves_membership_in_every_cell(datatype, plotter): + study, alleeg = _precompute_all(*_trial_factor_study()) + study = deepcopy(study) + study["cluster"].append({"name": "IC1", "sets": [[1, 2, 3, 4]], "comps": [1, 1, 1, 1], "child": []}) + + result = plotter(study, alleeg, clusters=2, noplot="on") + cells = result[1] + + expected = _expected_cells(study["cluster"][0], datatype, component=True) + for actual_row, expected_row in zip(cells, expected): + for actual, expected_values in zip(actual_row, expected_row): + np.testing.assert_allclose(actual, expected_values, atol=1e-12) + + +def test_trial_factor_plot_requires_single_trial_precompute(): + study, alleeg = _trial_factor_study() + study, alleeg = std_precomp(study, alleeg, [1], erp="on") + + with pytest.raises(ValueError, match="savetrials='on'"): + std_erpplot(study, alleeg, channels=[1], noplot="on") From 2614f8744d8c550fd3a2b258f89cab0a03d27933 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:57 -0700 Subject: [PATCH 41/45] Implement standalone LIMO workflows (#369) * feat: implement standalone LIMO workflows * fix: validate LIMO design cells --- docs/source/changelog.rst | 7 + docs/source/user_guide/study_workflows.rst | 35 +- src/eegprep/functions/studyfunc/_limo_io.py | 61 +++ .../functions/studyfunc/_limo_limitations.py | 20 - src/eegprep/functions/studyfunc/pop_limo.py | 276 ++++++++++++- .../functions/studyfunc/pop_limoresults.py | 47 ++- src/eegprep/functions/studyfunc/std_limo.py | 294 +++++++++++++- .../functions/studyfunc/std_limoresults.py | 369 +++++++++++++++++- .../functions/studyfunc/std_readfilelimo.py | 22 +- .../resources/help/eeg_helpstatistics.md | 11 +- src/eegprep/resources/help/eeg_helpstudy.md | 16 +- src/eegprep/resources/help/pop_limo.md | 19 +- src/eegprep/resources/help/pop_limoresults.md | 14 +- src/eegprep/resources/help/std_limodesign.md | 4 +- tests/test_limo_eeglab_tests.py | 282 +++++++++++++ tests/test_study_end_to_end.py | 4 +- 16 files changed, 1398 insertions(+), 83 deletions(-) create mode 100644 src/eegprep/functions/studyfunc/_limo_io.py delete mode 100644 src/eegprep/functions/studyfunc/_limo_limitations.py create mode 100644 tests/test_limo_eeglab_tests.py diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 0f4f1db2..9a8c017b 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -39,6 +39,13 @@ Unreleased searches deterministic automatic thresholds, can store matched bad components and child clusters, and RMS-normalizes average maps so arbitrary ICA scaling cannot change the second-pass template. +- Added a standalone first- and second-level LIMO-compatible workflow. + ``pop_limo`` now fits OLS, robust WLS, and Tukey-bisquare IRLS models from + active STUDY designs, persists safe versioned results, and exposes model + diagnostics. Contrasts, core group tests, regression, ANOVA/ANCOVA, + repeated-measures ANOVA, and weighted summaries are available through + ``std_limoresults`` and ``pop_limoresults``. MATLAB ``.mat`` interchange, + bootstrap, TFCE, and LIMO plotting remain explicit boundaries. - ``statcond`` now accepts EEGLAB's ``arraycomp`` switch. Batched mode returns all requested resampling grids, while off mode exposes one compatibility grid or computes inference iteratively to bound peak memory without changing diff --git a/docs/source/user_guide/study_workflows.rst b/docs/source/user_guide/study_workflows.rst index 011ada5f..426bcdfb 100644 --- a/docs/source/user_guide/study_workflows.rst +++ b/docs/source/user_guide/study_workflows.rst @@ -294,11 +294,24 @@ surrogate-tail convention with FDR correction available through the statistics module. These definitions are intentionally not collapsed when they answer different inferential questions. -The feasible in-package LIMO-compatible layer is design preparation: -``std_limodesign`` builds categorical and continuous matrices from -``pop_listfactors`` output and trial metadata, including interaction and split -regressor descriptions. It can write ``categorical_variables.txt`` and -``continuous_variables.txt`` for downstream analysis code. +EEGPrep provides a standalone LIMO-compatible path from design preparation to +core hierarchical statistics. ``std_limodesign`` builds categorical and +continuous matrices from ``pop_listfactors`` output and trial metadata, +including interaction and split-regressor descriptions. ``pop_limo`` uses the +active STUDY design to fit mass-univariate OLS, LIMO PCOut-weighted WLS, or +Tukey-bisquare IRLS models to epoched channel or component data. The +returned model dictionaries contain betas, fitted values, residuals, R², +residual variance, standard errors, t statistics, p values, robust weights, +and their exact design matrix. + +Pass ``outputdir`` to ``pop_limo`` to write versioned ``.npz`` model files. +These files never require pickle and can be reopened with +``std_readfilelimo``. ``std_limoresults`` and ``pop_limoresults`` compute and +store first-level contrasts, one-sample, paired and Welch two-sample tests, +mass-univariate regression, one-way ANOVA, ANCOVA, repeated-measures ANOVA, +and mean or inverse-variance-weighted summaries. Group statistics always use +the first axis as subjects; first-level parameter selection remains 1-based at +the EEGLAB-facing boundary. ``std_prepare_neighbors`` creates a distance-based FieldTrip-like neighbor list and a LIMO-compatible channel adjacency matrix from loaded channel @@ -326,10 +339,14 @@ creating figures. Limitations =========== -EEGPrep does not silently emulate EEGLAB's external LIMO toolbox. ``pop_limo``, -``pop_limoresults``, ``std_limo``, ``std_limoresults``, and -``std_readfilelimo`` raise clear ``NotImplementedError`` messages rather than -creating placeholder LIMO results. +The standalone layer does not silently read version-dependent MATLAB LIMO +``.mat`` structures. Convert them explicitly or use LIMO in MATLAB; +``std_readfilelimo`` accepts only EEGPrep-owned ``.npz`` output. First-level +and group bootstrap, TFCE correction, factorial repeated-measures designs, +and LIMO's result plotting/report interface remain explicit unsupported +boundaries. WLS follows LIMO's PCOut residual projection and uses one robust +observation weight across the fitted channel/time grid. IRLS uses feature-wise +Tukey-bisquare weights. ``std_dipplot`` does not compute dipole models: localize components first with the EEGPrep DIPFIT workflow. The broader FieldTrip-dependent diff --git a/src/eegprep/functions/studyfunc/_limo_io.py b/src/eegprep/functions/studyfunc/_limo_io.py new file mode 100644 index 00000000..b90729ba --- /dev/null +++ b/src/eegprep/functions/studyfunc/_limo_io.py @@ -0,0 +1,61 @@ +"""Persistence helpers for EEGPrep-owned LIMO-compatible results.""" + +from __future__ import annotations + +from pathlib import Path +import json +from typing import Any + +import numpy as np + + +_METADATA_KEY = "__eegprep_limo_metadata__" +_FORMAT_VERSION = 1 + + +def save_limo_result(result: dict[str, Any], file: str | Path) -> Path: + """Save a model or result without pickle-backed object arrays.""" + path = Path(file) + if path.suffix.lower() != ".npz": + path = path.with_suffix(".npz") + path.parent.mkdir(parents=True, exist_ok=True) + arrays: dict[str, np.ndarray] = {} + metadata: dict[str, Any] = {"format_version": _FORMAT_VERSION} + for key, value in result.items(): + if isinstance(value, np.ndarray): + arrays[key] = value + elif isinstance(value, (str, int, float, bool)) or value is None: + metadata[key] = value + elif isinstance(value, (list, tuple)) and all(isinstance(item, (str, int, float, bool)) for item in value): + metadata[key] = list(value) + else: + raise TypeError(f"LIMO result field {key!r} cannot be stored safely") + arrays[_METADATA_KEY] = np.asarray(json.dumps(metadata, sort_keys=True)) + np.savez_compressed(path, **arrays) + return path + + +def load_limo_result(file: str | Path) -> dict[str, Any]: + """Load an EEGPrep-owned ``.npz`` LIMO model or result.""" + path = Path(file) + if path.suffix.lower() != ".npz": + raise NotImplementedError( + "std_readfilelimo reads EEGPrep-owned .npz outputs only; MATLAB LIMO .mat files require " + "the external LIMO toolbox or an explicit conversion step" + ) + try: + archive = np.load(path, allow_pickle=False) + except (OSError, ValueError) as exc: + raise ValueError(f"Could not read EEGPrep LIMO output {path}") from exc + with archive: + if _METADATA_KEY not in archive.files: + raise ValueError(f"{path} is not an EEGPrep LIMO output") + metadata = json.loads(str(archive[_METADATA_KEY].item())) + version = metadata.pop("format_version", None) + if version != _FORMAT_VERSION: + raise ValueError(f"Unsupported EEGPrep LIMO format version: {version!r}") + arrays = {key: np.array(archive[key], copy=True) for key in archive.files if key != _METADATA_KEY} + return {**metadata, **arrays} + + +__all__ = ["load_limo_result", "save_limo_result"] diff --git a/src/eegprep/functions/studyfunc/_limo_limitations.py b/src/eegprep/functions/studyfunc/_limo_limitations.py deleted file mode 100644 index 7b336864..00000000 --- a/src/eegprep/functions/studyfunc/_limo_limitations.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Shared user-facing messages for unsupported LIMO workflows.""" - -from __future__ import annotations - -from typing import Any - - -LIMO_LIMITATION = ( - "Standalone EEGPrep does not implement EEGLAB's external LIMO toolbox workflow. " - "Use EEGPrep statistics helpers and std_limodesign for deterministic in-package analyses and " - "LIMO-compatible design matrices, or run LIMO in EEGLAB/MATLAB for model fitting and result browsing." -) - - -def raise_limo_limitation(function_name: str, *_args: Any, **_kwargs: Any) -> None: - """Raise a clear limitation for an external-only LIMO entry point.""" - raise NotImplementedError(f"{function_name}: {LIMO_LIMITATION}") - - -__all__ = ["LIMO_LIMITATION", "raise_limo_limitation"] diff --git a/src/eegprep/functions/studyfunc/pop_limo.py b/src/eegprep/functions/studyfunc/pop_limo.py index 1283ee24..c0acbcdf 100644 --- a/src/eegprep/functions/studyfunc/pop_limo.py +++ b/src/eegprep/functions/studyfunc/pop_limo.py @@ -1,15 +1,281 @@ -"""Explicit limitation for EEGLAB LIMO preparation workflows.""" +"""Run first-level LIMO-compatible models for a STUDY.""" from __future__ import annotations +from copy import deepcopy +from itertools import product +from pathlib import Path +import re from typing import Any -from eegprep.functions.studyfunc._limo_limitations import raise_limo_limitation +import numpy as np +from eegprep.functions.popfunc._pop_utils import is_on, parse_key_value_args +from eegprep.functions.popfunc.plot_utils import component_activations +from eegprep.functions.studyfunc._limo_io import save_limo_result +from eegprep.functions.studyfunc._study_utils import ( + build_python_call, + ensure_study, + trialinfo_rows, + value_matches, +) +from eegprep.functions.studyfunc.std_checkset import std_checkset +from eegprep.functions.studyfunc.std_limo import std_limo +from eegprep.functions.studyfunc.std_maketrialinfo import std_maketrialinfo -def pop_limo(*args: Any, **kwargs: Any) -> None: - """Report that standalone EEGPrep does not run external LIMO workflows.""" - raise_limo_limitation("pop_limo", *args, **kwargs) + +def pop_limo( + STUDY: dict[str, Any], + ALLEEG: list[dict[str, Any]] | None, + *args: Any, + method: str = "OLS", + measure: str = "daterp", + timelim: Any = None, + splitreg: str | bool = "off", + interaction: str | bool = "off", + erase: str | bool = "on", + outputdir: str | Path | None = None, + return_com: bool = False, + **kwargs: Any, +) -> Any: + """Fit first-level models for every selected STUDY dataset. + + The active ``STUDY.design`` supplies categorical levels, continuous + regressors, and subject selection. Results are returned in memory and can + optionally be written as safe, versioned ``.npz`` files. + """ + mode, option_args = _mode_and_args(args) + options = parse_key_value_args(option_args, kwargs, lowercase_kwargs=True) + method = str(options.pop("method", method)).upper() + measure = str(options.pop("measure", measure)).lower() + timelim = options.pop("timelim", timelim) + splitreg = options.pop("splitreg", splitreg) + interaction = options.pop("interaction", interaction) + erase = options.pop("erase", erase) + outputdir = options.pop("outputdir", outputdir) + nboot = int(options.pop("nboot", 0) or 0) + tfce = int(options.pop("tfce", 0) or 0) + if options: + raise ValueError(f"Unknown pop_limo option(s): {', '.join(sorted(options))}") + if measure not in {"daterp", "erp"}: + raise NotImplementedError("pop_limo currently fits epoched time-domain data only (measure='daterp')") + if nboot or tfce: + raise NotImplementedError("first-level LIMO bootstrap and TFCE inference are not implemented in EEGPrep") + + study = ensure_study(STUDY) + _active_design(study) + study, datasets = std_checkset(study, ALLEEG) + study, generated_trialinfo = std_maketrialinfo(study, datasets) + design = _active_design(study) + selected_subjects = {str(value) for value in design.get("cases", {}).get("value", [])} + destination = Path(outputdir).expanduser().resolve() if outputdir else None + models = [] + paths = [] + dataset_indices = [] + for dataset_index, (eeg, fallback_rows) in enumerate(zip(datasets, generated_trialinfo), start=1): + info = study["datasetinfo"][dataset_index - 1] + subject = str(info.get("subject") or eeg.get("subject") or f"S{dataset_index}") + if selected_subjects and subject not in selected_subjects: + continue + data, times = _model_data(eeg, mode, timelim) + rows = trialinfo_rows(info.get("trialinfo")) or fallback_rows + rows = _enrich_rows(rows, info, data.shape[-1]) + matrix, names, keep = _design_matrix( + design, + rows, + splitreg=is_on(splitreg), + interaction=is_on(interaction), + ) + model = std_limo(data[..., keep], matrix, method=method, parameter_names=names, times=times) + model["dataset_index"] = dataset_index + model["subject"] = subject + model["measure"] = "daterp" + models.append(model) + dataset_indices.append(dataset_index) + if destination is not None: + path = destination / f"{_safe_name(subject)}_{dataset_index}_limo_{method.lower()}.npz" + if path.exists() and not is_on(erase): + raise FileExistsError(f"LIMO model already exists: {path}; use erase='on' to replace it") + paths.append(str(save_limo_result(model, path))) + if not models: + raise ValueError("the active STUDY design selected no datasets") + + model_files = { + "models": models, + "files": paths, + "mat": paths, + "Beta": paths, + "dataset_indices": dataset_indices, + "method": method, + "measure": "daterp", + } + study["limo"] = { + "design": int(study.get("currentdesign") or 1), + "method": method, + "measure": "daterp", + "model_files": paths, + "dataset_indices": dataset_indices, + "chanloc": deepcopy(datasets[dataset_indices[0] - 1].get("chanlocs") or []), + "unsupported": ["MATLAB .mat interchange", "bootstrap", "TFCE", "LIMO plotting"], + } + study["saved"] = "no" + command = build_python_call( + ("STUDY", "ALLEEG", "model_files"), + "pop_limo", + "STUDY", + "ALLEEG", + method=method, + measure="daterp", + timelim=timelim, + splitreg="on" if is_on(splitreg) else "off", + interaction="on" if is_on(interaction) else "off", + erase="on" if is_on(erase) else "off", + outputdir=str(destination) if destination else None, + ) + result = (study, datasets, model_files) + return (*result, command) if return_com else result + + +def _mode_and_args(args: tuple[Any, ...]) -> tuple[str, tuple[Any, ...]]: + if args and isinstance(args[0], str) and args[0].lower() in {"dat", "data", "channels", "ica", "components"}: + return args[0].lower(), args[1:] + return "dat", args + + +def _active_design(study: dict[str, Any]) -> dict[str, Any]: + designs = study.get("design") or [] + index = int(study.get("currentdesign") or 1) + if index < 1 or index > len(designs): + raise ValueError("pop_limo requires a valid active STUDY design") + design = designs[index - 1] + if not isinstance(design, dict): + raise ValueError("the active STUDY design is invalid") + return design + + +def _model_data(eeg: dict[str, Any], mode: str, timelim: Any) -> tuple[np.ndarray, np.ndarray]: + data = component_activations(eeg) if mode in {"ica", "components"} else np.asarray(eeg.get("data"), dtype=float) + if data.ndim != 3 or int(eeg.get("trials", data.shape[-1]) or 1) <= 1: + raise ValueError("pop_limo requires epoched channel-by-time-by-trial data") + times = np.asarray(eeg.get("times", []), dtype=float).ravel() + if times.size != data.shape[1]: + srate = float(eeg.get("srate", 1.0) or 1.0) + xmin = float(eeg.get("xmin", 0.0) or 0.0) + times = (np.arange(data.shape[1], dtype=float) / srate + xmin) * 1000.0 + if timelim is None: + return data, times + bounds = np.asarray(timelim, dtype=float).ravel() + if bounds.size != 2 or bounds[0] > bounds[1]: + raise ValueError("timelim must contain increasing [start, stop] milliseconds") + keep = (times >= bounds[0]) & (times <= bounds[1]) + if not np.any(keep): + raise ValueError("timelim does not overlap the EEG time axis") + return data[:, keep, :], times[keep] + + +def _enrich_rows(rows: list[dict[str, Any]], info: dict[str, Any], trials: int) -> list[dict[str, Any]]: + if rows and len(rows) != trials: + raise ValueError(f"trial metadata has {len(rows)} rows but the EEG dataset has {trials} trials") + if not rows: + rows = [{} for _index in range(trials)] + constants = { + key: value + for key, value in info.items() + if key not in {"index", "filename", "filepath", "comps", "trialinfo"} and value not in (None, "") + } + return [{**constants, **row} for row in rows] + + +def _design_matrix( + design: dict[str, Any], rows: list[dict[str, Any]], *, splitreg: bool, interaction: bool +) -> tuple[np.ndarray, list[str], np.ndarray]: + categorical: list[tuple[str, list[Any]]] = [] + continuous: list[str] = [] + for variable in design.get("variable") or []: + if not isinstance(variable, dict): + continue + if str(variable.get("level") or "one").lower() != "one": + continue + label = str(variable.get("label") or "") + if not label: + continue + if str(variable.get("vartype") or "categorical").lower() == "continuous": + continuous.append(label) + else: + categorical.append((label, list(variable.get("value") or []))) + columns: list[np.ndarray] = [] + names: list[str] = [] + main_effects: list[list[np.ndarray]] = [] + for label, levels in categorical: + effects = [] + for level in levels: + column = np.asarray([value_matches(row.get(label), level) for row in rows], dtype=float) + if not np.any(column): + raise ValueError(f"the first-level dataset has no trials for {label}={_level_name(level)}") + columns.append(column) + effects.append(column) + names.append(f"{label}={_level_name(level)}") + main_effects.append(effects) + if interaction and len(main_effects) > 1: + for combination in product(*main_effects): + column = np.prod(np.vstack(combination), axis=0) + if not np.any(column): + raise ValueError("the requested first-level interaction contains an empty factor cell") + columns.append(column) + for levels in product(*(levels for _label, levels in categorical)): + names.append( + ":".join(f"{label}={_level_name(level)}" for (label, _values), level in zip(categorical, levels)) + ) + joint_conditions = list(product(*main_effects)) if main_effects else [] + joint_names = list(product(*(levels for _label, levels in categorical))) if categorical else [] + for label in continuous: + values = np.asarray([_numeric_value(row.get(label)) for row in rows], dtype=float) + finite = np.isfinite(values) + if np.count_nonzero(finite) > 1: + scale = float(np.std(values[finite], ddof=1)) + if scale == 0: + raise ValueError(f"continuous first-level regressor {label!r} has zero variance") + values[finite] = (values[finite] - float(np.mean(values[finite]))) / scale + if splitreg and joint_conditions: + for condition, level_names in zip(joint_conditions, joint_names): + membership = np.prod(np.vstack(condition), axis=0).astype(bool) + column = np.zeros_like(values) + column[membership] = values[membership] + columns.append(column) + suffix = ":".join( + f"{factor_label}={_level_name(level)}" + for (factor_label, _levels), level in zip(categorical, level_names) + ) + names.append(f"{label}|{suffix}") + else: + columns.append(values) + names.append(label) + columns.append(np.ones(len(rows), dtype=float)) + names.append("constant") + matrix = np.column_stack(columns) + keep = np.all(np.isfinite(matrix), axis=1) + if not np.any(keep): + raise ValueError("no trials have complete values for the active LIMO design") + matrix = matrix[keep] + return matrix, names, keep + + +def _numeric_value(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return np.nan + + +def _level_name(value: Any) -> str: + if isinstance(value, (list, tuple)): + return "&".join(str(item) for item in value) + return str(value) + + +def _safe_name(value: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._") + return cleaned or "subject" __all__ = ["pop_limo"] diff --git a/src/eegprep/functions/studyfunc/pop_limoresults.py b/src/eegprep/functions/studyfunc/pop_limoresults.py index 218673f2..a5db2222 100644 --- a/src/eegprep/functions/studyfunc/pop_limoresults.py +++ b/src/eegprep/functions/studyfunc/pop_limoresults.py @@ -1,15 +1,52 @@ -"""Explicit limitation for EEGLAB LIMO result browsing.""" +"""Store second-level LIMO-compatible results in a STUDY.""" from __future__ import annotations +from copy import deepcopy from typing import Any -from eegprep.functions.studyfunc._limo_limitations import raise_limo_limitation +from eegprep.functions.popfunc._pop_utils import parse_key_value_args +from eegprep.functions.studyfunc._study_utils import build_python_call, ensure_study +from eegprep.functions.studyfunc.std_limoresults import std_limoresults -def pop_limoresults(*args: Any, **kwargs: Any) -> None: - """Report that standalone EEGPrep does not browse external LIMO results.""" - raise_limo_limitation("pop_limoresults", *args, **kwargs) +def pop_limoresults( + STUDY: dict[str, Any], + source: Any = None, + *args: Any, + analysis: str = "one sample t-test", + return_com: bool = False, + **kwargs: Any, +) -> Any: + """Compute and retain one standalone LIMO second-level result.""" + study = ensure_study(STUDY) + options = parse_key_value_args(args, kwargs, lowercase_kwargs=True) + analysis = str(options.pop("analysis", analysis)) + if source is None: + source = options.pop("source", None) + if source is None: + stored_limo = study.get("limo") + limo = stored_limo if isinstance(stored_limo, dict) else {} + source = limo.get("model_files") + if source is None: + raise ValueError("pop_limoresults requires model files or numerical result data") + result = std_limoresults(source, analysis, **options) + stored_limo = study.get("limo") + limo = deepcopy(stored_limo) if isinstance(stored_limo, dict) else {} + summaries = list(limo.get("results") or []) + summaries.append(result) + limo["results"] = summaries + study["limo"] = limo + study["saved"] = "no" + command = build_python_call( + ("STUDY", "result"), + "pop_limoresults", + "STUDY", + "source", + analysis=analysis, + **options, + ) + return (study, result, command) if return_com else (study, result) __all__ = ["pop_limoresults"] diff --git a/src/eegprep/functions/studyfunc/std_limo.py b/src/eegprep/functions/studyfunc/std_limo.py index 8fc862aa..45443cdc 100644 --- a/src/eegprep/functions/studyfunc/std_limo.py +++ b/src/eegprep/functions/studyfunc/std_limo.py @@ -1,15 +1,297 @@ -"""Explicit limitation for EEGLAB LIMO model helpers.""" +"""Mass-univariate first-level models for LIMO-compatible workflows.""" from __future__ import annotations from typing import Any +import warnings -from eegprep.functions.studyfunc._limo_limitations import raise_limo_limitation +import numpy as np +from scipy import stats -def std_limo(*args: Any, **kwargs: Any) -> None: - """Report that standalone EEGPrep does not run external LIMO models.""" - raise_limo_limitation("std_limo", *args, **kwargs) +LIMO_METHODS = {"OLS", "WLS", "IRLS"} +_TUKEY_TUNING = 4.685 -__all__ = ["std_limo"] +def std_limo( + data: Any, + design: Any, + *, + method: str = "OLS", + parameter_names: list[str] | None = None, + times: Any = None, +) -> dict[str, Any]: + """Fit a first-level mass-univariate linear model. + + Args: + data: EEG observations with trials on the final axis. Channel-by-time- + by-trial arrays are the usual input, but any feature dimensions are + accepted. + design: Two-dimensional trial-by-parameter design matrix. + method: ``"OLS"``, ``"WLS"``, or Tukey-bisquare ``"IRLS"``. + parameter_names: Optional names for the columns of ``design``. + times: Optional time coordinates retained in the result. + + Returns: + A dictionary containing beta estimates, fitted values, residuals, + inferential statistics, the design matrix, and robust weights. + """ + values = np.asarray(data, dtype=float) + matrix = np.asarray(design, dtype=float) + if values.ndim < 2: + raise ValueError("data must have one or more feature dimensions and a final trial axis") + if matrix.ndim != 2: + raise ValueError("design must be a two-dimensional trial-by-parameter matrix") + if values.shape[-1] != matrix.shape[0]: + raise ValueError("the data trial axis must match the number of design rows") + if not np.all(np.isfinite(matrix)): + raise ValueError("design must contain only finite values") + method_name = str(method).upper() + if method_name not in LIMO_METHODS: + raise ValueError("method must be 'OLS', 'WLS', or 'IRLS'") + rank = int(np.linalg.matrix_rank(matrix)) + if matrix.shape[0] <= rank: + raise ValueError("the first-level model needs more trials than independent design columns") + names = parameter_names or [f"parameter_{index}" for index in range(1, matrix.shape[1] + 1)] + if len(names) != matrix.shape[1]: + raise ValueError("parameter_names must have one entry per design column") + + feature_shape = values.shape[:-1] + response = np.moveaxis(values, -1, 0).reshape(matrix.shape[0], -1) + if not np.all(np.isfinite(response)): + raise ValueError("std_limo requires finite first-level data") + if method_name == "OLS": + fit = _fit_global_weights(matrix, response, np.ones(matrix.shape[0], dtype=float)) + reduction = 0 + elif method_name == "WLS": + initial = _fit_global_weights(matrix, response, np.ones(matrix.shape[0], dtype=float)) + weights, reduction = _observation_weights(initial["residuals"], matrix) + fit = _fit_global_weights(matrix, response, weights) + else: + fit = _fit_irls(matrix, response) + reduction = 0 + + result = { + "kind": "first_level", + "method": method_name, + "design": matrix, + "parameter_names": list(names), + "rank": rank, + "df_residual": int(matrix.shape[0] - rank), + "betas": _reshape_parameters(fit["betas"], feature_shape), + "fitted": _reshape_trials(fit["fitted"], feature_shape), + "residuals": _reshape_trials(fit["residuals"], feature_shape), + "r2": np.asarray(fit["r2"]).reshape(feature_shape), + "sigma2": np.asarray(fit["sigma2"]).reshape(feature_shape), + "stderr": _reshape_parameters(fit["stderr"], feature_shape), + "t": _reshape_parameters(fit["t"], feature_shape), + "p": _reshape_parameters(fit["p"], feature_shape), + "weights": _reshape_weights(fit["weights"], feature_shape), + "weight_reduction": reduction, + "converged": bool(fit["converged"]), + } + if times is not None: + coordinates = np.asarray(times, dtype=float).ravel() + if values.ndim < 3 or coordinates.size != values.shape[-2]: + raise ValueError("times must match the penultimate data axis") + result["times"] = coordinates + return result + + +def _fit_global_weights(design: np.ndarray, response: np.ndarray, weights: np.ndarray) -> dict[str, Any]: + row_scale = weights[:, None] + weighted_design = design * row_scale + betas = np.linalg.pinv(weighted_design) @ (response * row_scale) + fitted = np.einsum("np,pf->nf", design, betas) + residuals = response - fitted + rank = int(np.linalg.matrix_rank(weighted_design)) + df = design.shape[0] - rank + weighted_sse = np.sum(row_scale**2 * residuals**2, axis=0) + sigma2 = weighted_sse / df + covariance = np.linalg.pinv(weighted_design.T @ weighted_design) + stderr = np.sqrt(np.maximum(covariance.diagonal()[:, None] * sigma2[None, :], 0.0)) + fit = _finish_fit(response, betas, fitted, residuals, sigma2, stderr, weights[:, None], df) + fit["converged"] = True + return fit + + +def _fit_irls(design: np.ndarray, response: np.ndarray) -> dict[str, Any]: + rank = int(np.linalg.matrix_rank(design)) + df = design.shape[0] - rank + betas = np.linalg.pinv(design) @ response + inverse = np.linalg.pinv(np.einsum("np,nq->pq", design, design)) + projected = np.einsum("np,pq->nq", design, inverse) + leverage = np.einsum("np,np->n", projected, design) + adjustment = 1.0 / np.sqrt(np.maximum(1.0 - leverage, np.finfo(float).eps)) + adjustment[~np.isfinite(adjustment)] = 1.0 + old_error = 1.0 + new_error = 10.0 + weights = np.ones_like(response) + converged = False + for _iteration in range(100): + if abs(old_error - new_error) <= 1e-4: + converged = True + break + old_error = new_error + residuals = response - np.einsum("np,pf->nf", design, betas) + adjusted = residuals * adjustment[:, None] + scale = np.maximum(np.median(np.abs(adjusted), axis=0) / 0.6745, 1e-5) + standardized = adjusted / (_TUKEY_TUNING * scale[None, :]) + weights = np.sqrt(_tukey_weights(standardized)) + for feature in range(response.shape[1]): + weighted_design = design * weights[:, feature, None] + betas[:, feature] = np.linalg.pinv(weighted_design) @ (response[:, feature] * weights[:, feature]) + new_error = float(np.sum(residuals**2)) + if not converged: + warnings.warn("LIMO IRLS did not converge after 100 iterations", RuntimeWarning, stacklevel=2) + + fitted = np.einsum("np,pf->nf", design, betas) + residuals = response - fitted + sigma2 = np.empty(response.shape[1], dtype=float) + stderr = np.empty_like(betas) + for feature in range(response.shape[1]): + weighted_design = design * weights[:, feature, None] + variance = float(np.sum((weights[:, feature] * residuals[:, feature]) ** 2) / df) + covariance = np.linalg.pinv(weighted_design.T @ weighted_design) + sigma2[feature] = variance + stderr[:, feature] = np.sqrt(np.maximum(covariance.diagonal() * variance, 0.0)) + fit = _finish_fit(response, betas, fitted, residuals, sigma2, stderr, weights, df) + fit["converged"] = converged + return fit + + +def _finish_fit( + response: np.ndarray, + betas: np.ndarray, + fitted: np.ndarray, + residuals: np.ndarray, + sigma2: np.ndarray, + stderr: np.ndarray, + weights: np.ndarray, + df: int, +) -> dict[str, Any]: + with np.errstate(divide="ignore", invalid="ignore"): + t_values = np.divide(betas, stderr, out=np.zeros_like(betas), where=stderr > 0) + exact = (stderr == 0) & (betas != 0) + t_values[exact] = np.copysign(np.inf, betas[exact]) + p_values = 2.0 * stats.t.sf(np.abs(t_values), df) + centered = response - np.mean(response, axis=0, keepdims=True) + total = np.sum(centered**2, axis=0) + error = np.sum(residuals**2, axis=0) + r2 = np.divide(total - error, total, out=np.zeros_like(total), where=total > 0) + return { + "betas": betas, + "fitted": fitted, + "residuals": residuals, + "sigma2": sigma2, + "stderr": stderr, + "t": t_values, + "p": p_values, + "r2": r2, + "weights": weights, + } + + +def _observation_weights(residuals: np.ndarray, design: np.ndarray) -> tuple[np.ndarray, int]: + inverse = np.linalg.pinv(np.einsum("np,nq->pq", design, design)) + projected = np.einsum("np,pq->nq", design, inverse) + leverage = np.clip(np.einsum("np,np->n", projected, design), 0.0, 1.0) + adjusted = residuals / np.sqrt(np.maximum(1.0 - leverage[:, None], np.finfo(float).eps)) + scales = np.maximum(np.median(np.abs(adjusted), axis=0) / 0.6745, 1e-5) + standardized = adjusted / (_TUKEY_TUNING * scales[None, :]) + weights, reduction = _pcout_weights(standardized) + if np.count_nonzero(weights) <= np.linalg.matrix_rank(design): + return np.ones_like(weights), reduction + return weights, reduction + + +def _pcout_weights(values: np.ndarray) -> tuple[np.ndarray, int]: + """Return LIMO PCOut location/scatter weights without its plotting path.""" + median = np.median(values, axis=0) + mad = np.median(np.abs(values - median[None, :]), axis=0) + retained = mad > 1e-6 + if not np.any(retained): + raise ValueError("WLS cannot be computed because all adjusted residual dimensions are constant") + data = values[:, retained] + median = np.median(data, axis=0) + mad = np.median(np.abs(data - median[None, :]), axis=0) * 1.4826 + robust = (data - median[None, :]) / mad[None, :] + centered = robust - np.mean(robust, axis=0, keepdims=True) + _left, singular, right = np.linalg.svd(centered, full_matrices=False) + variance = singular**2 / max(1, data.shape[0] - 1) + cumulative = np.cumsum(variance) / np.sum(variance) + above = np.flatnonzero(cumulative > 0.99) + components = int(above[0] + 1) if above.size else singular.size + reduction = int(data.shape[1] - components) + projected = robust @ right[:components].T + projected_median = np.median(projected, axis=0) + projected_mad = np.median(np.abs(projected - projected_median[None, :]), axis=0) * 1.4826 + projected_mad = np.maximum(projected_mad, np.finfo(float).eps) + scaled = (projected - projected_median[None, :]) / projected_mad[None, :] + + kurtosis = np.abs(np.mean(scaled**4, axis=0) - 3.0) + if float(np.sum(kurtosis)) <= np.finfo(float).eps: + kurtosis = np.ones_like(kurtosis) + location_norm = np.sqrt(np.sum((scaled * (kurtosis / np.sum(kurtosis))[None, :]) ** 2, axis=1)) + chi_median = float(np.sqrt(stats.chi2.ppf(0.5, components))) + location_distance = location_norm * chi_median / max(float(np.median(location_norm)), np.finfo(float).eps) + location_weight = _translated_biweight(location_distance) + + scatter_norm = np.sqrt(np.sum(scaled**2, axis=1)) + scatter_distance = scatter_norm * chi_median / max(float(np.median(scatter_norm)), np.finfo(float).eps) + lower = float(np.sqrt(stats.chi2.ppf(0.25, components))) + upper = float(np.sqrt(stats.chi2.ppf(0.99, components))) + scatter_weight = np.square(1.0 - np.square((scatter_distance - lower) / (upper - lower))) + scatter_weight[scatter_distance < lower] = 1.0 + scatter_weight[scatter_distance > upper] = 0.0 + offset = 0.25 + return (location_weight + offset) * (scatter_weight + offset) / (1.0 + offset) ** 2, reduction + + +def _translated_biweight(distance: np.ndarray) -> np.ndarray: + lower = _matlab_quantile(distance, 1.0 / 3.0) + mad = float(np.median(np.abs(distance - np.median(distance)))) + upper = float(np.median(distance) + 2.5 * mad * 1.4826) + if upper <= lower: + return np.ones_like(distance) + weights = np.square(1.0 - np.square((distance - lower) / (upper - lower))) + weights[distance < lower] = 1.0 + weights[distance > upper] = 0.0 + return weights + + +def _matlab_quantile(values: np.ndarray, probability: float) -> float: + ordered = np.sort(np.asarray(values, dtype=float).ravel()) + position = ordered.size * probability + 0.5 + if position <= 1.0: + return float(ordered[0]) + if position >= ordered.size: + return float(ordered[-1]) + lower = int(np.floor(position)) + fraction = position - lower + return float(ordered[lower - 1] * (1.0 - fraction) + ordered[lower] * fraction) + + +def _tukey_weights(standardized: np.ndarray) -> np.ndarray: + scaled = np.asarray(standardized, dtype=float) + weights = np.zeros_like(scaled) + inside = np.abs(scaled) < 1.0 + weights[inside] = (1.0 - scaled[inside] ** 2) ** 2 + return weights + + +def _reshape_parameters(values: np.ndarray, feature_shape: tuple[int, ...]) -> np.ndarray: + return np.moveaxis(values.reshape((values.shape[0], *feature_shape)), 0, -1) + + +def _reshape_trials(values: np.ndarray, feature_shape: tuple[int, ...]) -> np.ndarray: + return np.moveaxis(values.reshape((values.shape[0], *feature_shape)), 0, -1) + + +def _reshape_weights(values: np.ndarray, feature_shape: tuple[int, ...]) -> np.ndarray: + if values.shape[1] == 1: + return values[:, 0] + return np.moveaxis(values.reshape((values.shape[0], *feature_shape)), 0, -1) + + +__all__ = ["LIMO_METHODS", "std_limo"] diff --git a/src/eegprep/functions/studyfunc/std_limoresults.py b/src/eegprep/functions/studyfunc/std_limoresults.py index d5633452..00f798dc 100644 --- a/src/eegprep/functions/studyfunc/std_limoresults.py +++ b/src/eegprep/functions/studyfunc/std_limoresults.py @@ -1,15 +1,374 @@ -"""Explicit limitation for EEGLAB LIMO result helpers.""" +"""Second-level statistics for EEGPrep-owned LIMO-compatible models.""" from __future__ import annotations +from pathlib import Path from typing import Any -from eegprep.functions.studyfunc._limo_limitations import raise_limo_limitation +import numpy as np +from scipy import stats +from eegprep.functions.studyfunc._limo_io import save_limo_result +from eegprep.functions.studyfunc.std_readfilelimo import std_readfilelimo -def std_limoresults(*args: Any, **kwargs: Any) -> None: - """Report that standalone EEGPrep does not compute external LIMO results.""" - raise_limo_limitation("std_limoresults", *args, **kwargs) + +def std_limoresults( + source: Any, + analysis: str = "one sample t-test", + *, + contrast: Any = None, + parameter: int | None = None, + data2: Any = None, + regressors: Any = None, + groups: Any = None, + covariates: Any = None, + estimator: str = "mean", + variances: Any = None, + alpha: float = 0.05, + nboot: int = 0, + tfce: int = 0, + outputfile: str | Path | None = None, +) -> dict[str, Any]: + """Compute contrasts, group tests, regressions, or central tendencies. + + Statistical tests operate across the first (subject) axis. ``source`` may + contain arrays, in-memory first-level model dictionaries, or paths created + by :func:`pop_limo`. Parameter indices follow EEGLAB's 1-based convention. + """ + if nboot or tfce: + raise NotImplementedError("LIMO bootstrap and TFCE correction are not implemented in EEGPrep") + if not 0.0 < float(alpha) < 1.0: + raise ValueError("alpha must be between zero and one") + name = _analysis_name(analysis) + if name == "load": + return std_readfilelimo(source) + if name == "contrast": + result = _contrasts(source, contrast=contrast, parameter=parameter) + elif name == "one sample t-test": + values = _subject_values(source, contrast=contrast, parameter=parameter) + result = _one_sample(values) + elif name == "paired t-test": + first = _subject_values(source, contrast=contrast, parameter=parameter) + second = _subject_values(data2, contrast=contrast, parameter=parameter) + result = _paired(first, second) + elif name == "two samples t-test": + first = _subject_values(source, contrast=contrast, parameter=parameter) + second = _subject_values(data2, contrast=contrast, parameter=parameter) + result = _two_sample(first, second) + elif name in {"n-ways anova", "one-way anova"}: + result = _one_way_anova(source) + elif name == "ancova": + result = _ancova(_array(source), groups, covariates) + elif name == "repeated measures anova": + result = _repeated_measures(_array(source)) + elif name == "regression": + result = _regression(_array(source), regressors) + elif name == "central tendency": + result = _central_tendency(_array(source), estimator=estimator, variances=variances, alpha=float(alpha)) + else: + raise ValueError(f"Unknown LIMO analysis: {analysis!r}") + result["kind"] = "second_level" + result["analysis"] = name + result["alpha"] = float(alpha) + if outputfile is not None: + result["file"] = str(save_limo_result(result, outputfile)) + return result + + +def _analysis_name(value: str) -> str: + text = " ".join(str(value).lower().replace("_", " ").replace("-", " ").split()) + aliases = { + "one sample": "one sample t-test", + "one sample t test": "one sample t-test", + "paired": "paired t-test", + "paired t test": "paired t-test", + "two sample": "two samples t-test", + "two samples t test": "two samples t-test", + "two sample t test": "two samples t-test", + "anova": "one-way anova", + "n ways anova": "n-ways anova", + "repeated measures": "repeated measures anova", + } + return aliases.get(text, text) + + +def _contrasts(source: Any, *, contrast: Any, parameter: int | None) -> dict[str, Any]: + models = _models(source) + if not models: + raise ValueError("contrast analysis requires one or more first-level models") + estimates = [] + contrast_matrix = _contrast_matrix(contrast, parameter, models[0]) + names = list(models[0].get("parameter_names") or []) + for model in models: + betas = np.asarray(model.get("betas"), dtype=float) + if betas.ndim < 1 or betas.shape[-1] != contrast_matrix.shape[1]: + raise ValueError("all first-level beta arrays must share the contrast parameter axis") + if list(model.get("parameter_names") or []) != names: + raise ValueError("all first-level models must share parameter_names") + estimates.append(np.einsum("...p,cp->c...", betas, contrast_matrix)) + return { + "contrast": contrast_matrix, + "parameter_names": names, + "estimates": np.stack(estimates, axis=0), + "dataset_indices": np.asarray([int(model.get("dataset_index", 0)) for model in models], dtype=int), + } + + +def _contrast_matrix(contrast: Any, parameter: int | None, model: dict[str, Any]) -> np.ndarray: + count = np.asarray(model.get("betas")).shape[-1] + if parameter is not None: + index = int(parameter) + if index < 1 or index > count: + raise ValueError(f"parameter must be 1-based and within 1..{count}") + matrix = np.zeros((1, count), dtype=float) + matrix[0, index - 1] = 1.0 + return matrix + if contrast is None: + raise ValueError("specify a contrast or a 1-based parameter index") + matrix = np.asarray(contrast, dtype=float) + if matrix.ndim == 1: + matrix = matrix[None, :] + if matrix.ndim != 2 or matrix.shape[1] != count: + raise ValueError(f"contrast must have {count} columns") + if not np.all(np.isfinite(matrix)): + raise ValueError("contrast must contain finite values") + return matrix + + +def _subject_values(source: Any, *, contrast: Any, parameter: int | None) -> np.ndarray: + if _looks_like_models(source): + estimates = _contrasts(source, contrast=contrast, parameter=parameter)["estimates"] + return estimates[:, 0] if estimates.shape[1] == 1 else estimates + if isinstance(source, dict) and "estimates" in source: + estimates = np.asarray(source["estimates"], dtype=float) + return estimates[:, 0] if estimates.ndim > 1 and estimates.shape[1] == 1 else estimates + return _array(source) + + +def _one_sample(values: np.ndarray) -> dict[str, Any]: + if values.shape[0] < 2: + raise ValueError("one-sample inference requires at least two subjects") + count = np.sum(np.isfinite(values), axis=0) + mean = np.nanmean(values, axis=0) + standard_error = np.nanstd(values, axis=0, ddof=1) / np.sqrt(count) + with np.errstate(divide="ignore", invalid="ignore"): + t_values = np.divide(mean, standard_error, out=np.zeros_like(mean), where=standard_error > 0) + exact = (standard_error == 0) & (mean != 0) + t_values[exact] = np.copysign(np.inf, mean[exact]) + p_values = 2.0 * stats.t.sf(np.abs(t_values), count - 1) + return { + "mean": mean, + "t": t_values, + "p": p_values, + "df": count - 1, + "n": count, + } + + +def _paired(first: np.ndarray, second: np.ndarray) -> dict[str, Any]: + if first.shape != second.shape: + raise ValueError("paired samples must have identical shapes") + result = _one_sample(first - second) + result["difference"] = result.pop("mean") + return result + + +def _two_sample(first: np.ndarray, second: np.ndarray) -> dict[str, Any]: + if first.shape[1:] != second.shape[1:]: + raise ValueError("the two samples must share feature dimensions") + if first.shape[0] < 2 or second.shape[0] < 2: + raise ValueError("two-sample inference requires at least two subjects per group") + test = stats.ttest_ind(first, second, axis=0, equal_var=False, nan_policy="omit") + var1 = np.nanvar(first, axis=0, ddof=1) + var2 = np.nanvar(second, axis=0, ddof=1) + n1 = np.sum(np.isfinite(first), axis=0) + n2 = np.sum(np.isfinite(second), axis=0) + term1 = var1 / n1 + term2 = var2 / n2 + with np.errstate(divide="ignore", invalid="ignore"): + df = (term1 + term2) ** 2 / (term1**2 / (n1 - 1) + term2**2 / (n2 - 1)) + return { + "difference": np.nanmean(first, axis=0) - np.nanmean(second, axis=0), + "t": np.asarray(test.statistic), + "p": np.asarray(test.pvalue), + "df": df, + "n1": n1, + "n2": n2, + } + + +def _regression(values: np.ndarray, regressors: Any) -> dict[str, Any]: + predictors = np.asarray(regressors, dtype=float) + if predictors.ndim == 1: + predictors = predictors[:, None] + if predictors.ndim != 2 or predictors.shape[0] != values.shape[0]: + raise ValueError("regressors must be a subject-by-predictor matrix") + if not np.all(np.isfinite(predictors)) or not np.all(np.isfinite(values)): + raise ValueError("regression requires finite data and regressors") + design = np.column_stack((predictors, np.ones(predictors.shape[0], dtype=float))) + rank = int(np.linalg.matrix_rank(design)) + if design.shape[0] <= rank: + raise ValueError("regression needs more subjects than independent design columns") + shape = values.shape[1:] + response = values.reshape(values.shape[0], -1) + inverse = np.linalg.pinv(design.T @ design) + beta = np.linalg.pinv(design) @ response + residual = response - design @ beta + df = design.shape[0] - rank + sigma2 = np.sum(residual**2, axis=0) / df + stderr = np.sqrt(np.maximum(inverse.diagonal()[:, None] * sigma2[None, :], 0.0)) + with np.errstate(divide="ignore", invalid="ignore"): + t_values = np.divide(beta, stderr, out=np.zeros_like(beta), where=stderr > 0) + exact = (stderr == 0) & (beta != 0) + t_values[exact] = np.copysign(np.inf, beta[exact]) + p_values = 2.0 * stats.t.sf(np.abs(t_values), df) + total = np.sum((response - np.mean(response, axis=0, keepdims=True)) ** 2, axis=0) + r2 = np.divide(total - np.sum(residual**2, axis=0), total, out=np.zeros_like(total), where=total > 0) + return { + "design": design, + "betas": np.moveaxis(beta.reshape((beta.shape[0], *shape)), 0, -1), + "stderr": np.moveaxis(stderr.reshape((stderr.shape[0], *shape)), 0, -1), + "t": np.moveaxis(t_values.reshape((t_values.shape[0], *shape)), 0, -1), + "p": np.moveaxis(p_values.reshape((p_values.shape[0], *shape)), 0, -1), + "r2": r2.reshape(shape), + "df": df, + } + + +def _one_way_anova(source: Any) -> dict[str, Any]: + if not isinstance(source, (list, tuple)): + raise TypeError("one-way ANOVA source must be a sequence of group arrays") + samples = [_array(item) for item in source] + if len(samples) < 2 or any(sample.shape[0] < 2 for sample in samples): + raise ValueError("one-way ANOVA requires at least two groups with two subjects each") + if len({sample.shape[1:] for sample in samples}) != 1: + raise ValueError("all ANOVA groups must share feature dimensions") + test = stats.f_oneway(*samples, axis=0, nan_policy="omit") + return { + "f": np.asarray(test.statistic), + "p": np.asarray(test.pvalue), + "df_between": len(samples) - 1, + "df_within": sum(np.sum(np.isfinite(sample), axis=0) for sample in samples) - len(samples), + "group_means": np.stack([np.nanmean(sample, axis=0) for sample in samples]), + } + + +def _ancova(values: np.ndarray, groups: Any, covariates: Any) -> dict[str, Any]: + labels = np.asarray(groups) + covars = np.asarray(covariates, dtype=float) + if covars.ndim == 1: + covars = covars[:, None] + if labels.ndim != 1 or labels.size != values.shape[0] or covars.shape[0] != values.shape[0]: + raise ValueError("groups and covariates must have one row per subject") + if not np.all(np.isfinite(values)) or not np.all(np.isfinite(covars)): + raise ValueError("ANCOVA requires finite data and covariates") + levels = list(dict.fromkeys(labels.tolist())) + if len(levels) < 2: + raise ValueError("ANCOVA requires at least two groups") + dummy = np.column_stack([labels == level for level in levels[1:]]).astype(float) + full = np.column_stack((covars, dummy, np.ones(values.shape[0], dtype=float))) + reduced = np.column_stack((covars, np.ones(values.shape[0], dtype=float))) + response = values.reshape(values.shape[0], -1) + full_residual = response - full @ (np.linalg.pinv(full) @ response) + reduced_residual = response - reduced @ (np.linalg.pinv(reduced) @ response) + full_sse = np.sum(full_residual**2, axis=0) + reduced_sse = np.sum(reduced_residual**2, axis=0) + df_group = len(levels) - 1 + df_error = values.shape[0] - np.linalg.matrix_rank(full) + if df_error <= 0: + raise ValueError("ANCOVA needs more subjects than independent design columns") + with np.errstate(divide="ignore", invalid="ignore"): + f_values = ((reduced_sse - full_sse) / df_group) / (full_sse / df_error) + f_values = np.maximum(f_values, 0.0).reshape(values.shape[1:]) + return { + "f": f_values, + "p": stats.f.sf(f_values, df_group, df_error), + "df_group": df_group, + "df_error": int(df_error), + "levels": [str(level) for level in levels], + } + + +def _repeated_measures(values: np.ndarray) -> dict[str, Any]: + if values.ndim < 2 or values.shape[0] < 2 or values.shape[1] < 2: + raise ValueError("repeated-measures ANOVA requires subject-by-condition data") + if not np.all(np.isfinite(values)): + raise ValueError("repeated-measures ANOVA requires a complete finite array") + n_subjects, n_conditions = values.shape[:2] + grand = np.mean(values, axis=(0, 1)) + condition_mean = np.mean(values, axis=0) + subject_mean = np.mean(values, axis=1) + total = np.sum((values - grand) ** 2, axis=(0, 1)) + condition_ss = n_subjects * np.sum((condition_mean - grand) ** 2, axis=0) + subject_ss = n_conditions * np.sum((subject_mean - grand) ** 2, axis=0) + error_ss = np.maximum(total - condition_ss - subject_ss, 0.0) + df_condition = n_conditions - 1 + df_error = (n_subjects - 1) * df_condition + with np.errstate(divide="ignore", invalid="ignore"): + f_values = (condition_ss / df_condition) / (error_ss / df_error) + return { + "f": f_values, + "p": stats.f.sf(f_values, df_condition, df_error), + "df_condition": df_condition, + "df_error": df_error, + "condition_means": condition_mean, + } + + +def _central_tendency(values: np.ndarray, *, estimator: str, variances: Any, alpha: float) -> dict[str, Any]: + if values.shape[0] < 2: + raise ValueError("central tendency requires at least two subjects") + name = " ".join(str(estimator).lower().split()) + if name == "mean": + center = np.nanmean(values, axis=0) + count = np.sum(np.isfinite(values), axis=0) + se = stats.sem(values, axis=0, nan_policy="omit") + elif name == "weighted mean": + if variances is None: + raise ValueError("weighted mean requires subject-level variances") + variance = np.asarray(variances, dtype=float) + if variance.shape != values.shape or not np.all(np.isfinite(variance)) or np.any(variance <= 0): + raise ValueError("variances must be finite, positive, and match the data shape") + if not np.all(np.isfinite(values)): + raise ValueError("weighted mean requires finite subject values") + weights = 1.0 / variance + center = np.sum(weights * values, axis=0) / np.sum(weights, axis=0) + se = np.sqrt(1.0 / np.sum(weights, axis=0)) + count = np.sum(np.isfinite(values), axis=0) + else: + raise ValueError("estimator must be 'mean' or 'weighted mean'") + critical = stats.t.ppf(1.0 - alpha / 2.0, np.maximum(count - 1, 1)) + return {"estimate": center, "se": se, "ci": np.stack((center - critical * se, center + critical * se)), "n": count} + + +def _models(source: Any) -> list[dict[str, Any]]: + if isinstance(source, dict) and "models" in source: + source = source["models"] or source.get("files") or [] + if isinstance(source, (str, Path, dict)): + source = [source] + if not isinstance(source, (list, tuple)): + return [] + loaded = [std_readfilelimo(item) for item in source] + if any(not isinstance(item, dict) or item.get("kind") != "first_level" for item in loaded): + raise ValueError("all contrast inputs must be EEGPrep first-level LIMO models") + return loaded + + +def _looks_like_models(source: Any) -> bool: + if isinstance(source, dict): + return source.get("kind") == "first_level" or "models" in source + if isinstance(source, (str, Path)): + return True + if isinstance(source, (list, tuple)) and source: + return isinstance(source[0], (dict, str, Path)) + return False + + +def _array(value: Any) -> np.ndarray: + array = np.asarray(value, dtype=float) + if array.ndim == 0: + raise ValueError("LIMO second-level data must have a subject axis") + return array __all__ = ["std_limoresults"] diff --git a/src/eegprep/functions/studyfunc/std_readfilelimo.py b/src/eegprep/functions/studyfunc/std_readfilelimo.py index 9e6eaa95..20512345 100644 --- a/src/eegprep/functions/studyfunc/std_readfilelimo.py +++ b/src/eegprep/functions/studyfunc/std_readfilelimo.py @@ -1,15 +1,27 @@ -"""Explicit limitation for EEGLAB LIMO file readers.""" +"""Read EEGPrep-owned LIMO-compatible model and result files.""" from __future__ import annotations +from pathlib import Path from typing import Any -from eegprep.functions.studyfunc._limo_limitations import raise_limo_limitation +from eegprep.functions.studyfunc._limo_io import load_limo_result -def std_readfilelimo(*args: Any, **kwargs: Any) -> None: - """Report that standalone EEGPrep does not parse external LIMO files.""" - raise_limo_limitation("std_readfilelimo", *args, **kwargs) +def std_readfilelimo(source: Any) -> Any: + """Load one or more EEGPrep LIMO ``.npz`` outputs. + + Dictionaries already in memory are returned unchanged. MATLAB ``.mat`` + LIMO structures remain an explicit external-toolbox boundary because their + layout varies across LIMO releases. + """ + if isinstance(source, dict): + return source + if isinstance(source, (list, tuple)): + return [std_readfilelimo(item) for item in source] + if isinstance(source, (str, Path)): + return load_limo_result(source) + raise TypeError("source must be a result dictionary, path, or sequence of either") __all__ = ["std_readfilelimo"] diff --git a/src/eegprep/resources/help/eeg_helpstatistics.md b/src/eegprep/resources/help/eeg_helpstatistics.md index 3e15b719..9ce82121 100644 --- a/src/eegprep/resources/help/eeg_helpstatistics.md +++ b/src/eegprep/resources/help/eeg_helpstatistics.md @@ -11,9 +11,12 @@ Implemented user-facing wrappers: condition tests and multiple-comparison utilities. - `std_limodesign`: LIMO-compatible design matrix construction for STUDY factors and trial metadata. +- `std_limo` and `pop_limo`: mass-univariate OLS, PCOut-weighted WLS, and + Tukey-bisquare IRLS first-level models. +- `std_limoresults` and `pop_limoresults`: contrasts, t tests, regression, + ANOVA/ANCOVA, repeated-measures ANOVA, and weighted summaries. -External LIMO model fitting/result browsing and FieldTrip cluster-statistics -execution remain explicit optional-backend boundaries. EEGPrep does not create -placeholder LIMO results. +MATLAB LIMO `.mat` interchange, bootstrap, TFCE, LIMO's plotting interface, +and FieldTrip cluster-statistics execution remain explicit boundaries. -See also: POP_SIGNALSTAT, POP_EVENTSTAT, STD_LIMODESIGN +See also: POP_SIGNALSTAT, POP_EVENTSTAT, STD_LIMODESIGN, POP_LIMO, POP_LIMORESULTS diff --git a/src/eegprep/resources/help/eeg_helpstudy.md b/src/eegprep/resources/help/eeg_helpstudy.md index d1cf8f6d..9ffd18f6 100644 --- a/src/eegprep/resources/help/eeg_helpstudy.md +++ b/src/eegprep/resources/help/eeg_helpstudy.md @@ -31,12 +31,14 @@ Implemented STUDY actions: Component ERP, spectrum, ERSP, and ITC clustering inputs are cached on the parent `STUDY.cluster[0]` entry by `pop_precomp`. -LIMO result computation and browsing are not silently emulated. `pop_limo`, -`pop_limoresults`, `std_limo`, `std_limoresults`, and `std_readfilelimo` -report that standalone EEGPrep does not run EEGLAB's external LIMO toolbox -workflow. `std_dipplot` plots existing `EEG.dipfit.model` values by STUDY -cluster and returns its selected coordinates, moments, residual variances, and -centroids for inspection. The broader `std_dipoleclusters` workflow remains -behind the DIPFIT/FieldTrip backend boundary. +`pop_limo` fits standalone first-level OLS, robust WLS, and IRLS models from +the active design. `pop_limoresults` computes core contrasts and group +statistics; `std_readfilelimo` reloads EEGPrep's versioned `.npz` output. +Bootstrap, TFCE, MATLAB LIMO `.mat` interchange, and the external result +plotting interface remain explicit boundaries. `std_dipplot` plots existing +`EEG.dipfit.model` values by STUDY cluster and returns selected coordinates, +moments, residual variances, and centroids. The broader +`std_dipoleclusters` workflow remains behind the DIPFIT/FieldTrip backend +boundary. See also: POP_STUDY, POP_PRECOMP, POP_CHANPLOT, POP_LOADSTUDY, POP_SAVESTUDY, POP_PRECLUST, POP_CLUST, POP_CLUSTEDIT, STD_PAC diff --git a/src/eegprep/resources/help/pop_limo.md b/src/eegprep/resources/help/pop_limo.md index e1e66574..be548a7c 100644 --- a/src/eegprep/resources/help/pop_limo.md +++ b/src/eegprep/resources/help/pop_limo.md @@ -1,12 +1,15 @@ -# POP_LIMO - LIMO limitation +# POP_LIMO - Fit first-level LIMO models -EEGPrep does not implement EEGLAB's external LIMO toolbox workflow. +`pop_limo(STUDY, ALLEEG, ...)` fits the active STUDY design to every +selected subject's epoched data. Supported methods are ordinary least squares +(`OLS`), LIMO PCOut-weighted least squares (`WLS`), and feature-wise +Tukey-bisquare iteratively reweighted least squares (`IRLS`). Use `timelim` to +select milliseconds and `outputdir` to save safe, versioned `.npz` models. -Calling `pop_limo` raises a clear `NotImplementedError` instead of creating -placeholder LIMO files or pretending external MATLAB behavior is available. -Use EEGPrep's standalone statistics helpers and `std_limodesign` for -in-package analyses and LIMO-compatible design matrices, or run LIMO in -EEGLAB/MATLAB when you need the external LIMO model-fitting and result -browsing workflow. +The returned models include the exact design, parameter names, betas, fitted +values, residuals, R², residual variance, standard errors, t and p values, and +robust weights. Channel and component time-domain models are supported. +Bootstrap, TFCE, non-time-domain measures, and MATLAB LIMO `.mat` interchange +remain explicit unsupported boundaries. See also: POP_LIMORESULTS, STD_LIMODESIGN, EEG_HELPSTATISTICS diff --git a/src/eegprep/resources/help/pop_limoresults.md b/src/eegprep/resources/help/pop_limoresults.md index 78f97679..1a335183 100644 --- a/src/eegprep/resources/help/pop_limoresults.md +++ b/src/eegprep/resources/help/pop_limoresults.md @@ -1,9 +1,13 @@ -# POP_LIMORESULTS - LIMO result limitation +# POP_LIMORESULTS - Compute LIMO group results -EEGPrep does not browse or compute EEGLAB LIMO result files in standalone -Python. +`pop_limoresults(STUDY, source, analysis=...)` computes a standalone +second-level result and stores it in `STUDY.limo.results`. Sources may be +numeric subject arrays, in-memory first-level models, or `.npz` files written +by `pop_limo`. -Calling `pop_limoresults` raises a clear `NotImplementedError`. This avoids -silently treating external-toolbox results as native EEGPrep outputs. +Supported analyses include contrasts, one-sample, paired and Welch two-sample +t tests, regression, one-way ANOVA, ANCOVA, repeated-measures ANOVA, and mean +or inverse-variance-weighted summaries. Bootstrap, TFCE, plotting, and direct +MATLAB LIMO `.mat` loading are not silently emulated. See also: POP_LIMO, EEG_HELPSTUDY diff --git a/src/eegprep/resources/help/std_limodesign.md b/src/eegprep/resources/help/std_limodesign.md index 0e70cc5a..4c8e3f72 100644 --- a/src/eegprep/resources/help/std_limodesign.md +++ b/src/eegprep/resources/help/std_limodesign.md @@ -5,7 +5,7 @@ factor descriptors and trial metadata. It supports categorical interactions, split continuous regressors, description only mode, and optional export of `categorical_variables.txt` and -`continuous_variables.txt`. It does not run the external EEGLAB LIMO model -fitting or result-browsing workflow. +`continuous_variables.txt`. Use `pop_limo` to fit the active STUDY design in +standalone EEGPrep. See also: POP_LISTFACTORS, STD_BUILDDESIGNMAT, POP_LIMO diff --git a/tests/test_limo_eeglab_tests.py b/tests/test_limo_eeglab_tests.py new file mode 100644 index 00000000..167ac9b8 --- /dev/null +++ b/tests/test_limo_eeglab_tests.py @@ -0,0 +1,282 @@ +"""Substantive generated-data ports of the current EEGLAB LIMO tests.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from scipy import stats + +import eegprep +from eegprep.functions.studyfunc._limo_io import save_limo_result +from eegprep.functions.studyfunc.pop_limo import pop_limo +from eegprep.functions.studyfunc.pop_limoresults import pop_limoresults +from eegprep.functions.studyfunc.pop_study import pop_study +from eegprep.functions.studyfunc.std_limo import std_limo +from eegprep.functions.studyfunc.std_limoresults import std_limoresults +from eegprep.functions.studyfunc.std_maketrialinfo import std_maketrialinfo +from eegprep.functions.studyfunc.std_makedesign import std_makedesign +from eegprep.functions.studyfunc.std_readfilelimo import std_readfilelimo +from tests.eeglab_tests import eeglab_test + + +LIMO_WRAPPER = "unittesting_limo/limo_wrapperTest.m" + + +def _limo_eeg(subject_index: int) -> dict: + rng = np.random.default_rng(900 + subject_index) + trials = 24 + times = np.linspace(-100.0, 300.0, 9) + waveform = np.exp(-(((times - 150.0) / 90.0) ** 2)) + faces = np.asarray(["famous"] * (trials // 2) + ["scrambled"] * (trials // 2)) + reaction_time = np.linspace(0.25, 0.75, trials) + data = np.empty((2, times.size, trials), dtype=float) + for trial, face in enumerate(faces): + face_effect = 0.75 if face == "famous" else -0.75 + for channel in range(2): + data[channel, :, trial] = ( + 0.2 * (channel + 1) + + face_effect * waveform + + 0.35 * reaction_time[trial] + + 0.02 * rng.standard_normal(times.size) + ) + data[:, :, -1] += 3.0 + events = [ + { + "type": str(face), + "face": str(face), + "rt": float(reaction_time[trial]), + "epoch": trial + 1, + "latency": trial * times.size + 3, + "urevent": trial + 1, + } + for trial, face in enumerate(faces) + ] + return { + "setname": f"subject_{subject_index:02d}", + "subject": f"S{subject_index:02d}", + "condition": "face task", + "group": "control" if subject_index <= 3 else "patient", + "data": data, + "nbchan": 2, + "pnts": times.size, + "trials": trials, + "srate": 20.0, + "xmin": -0.1, + "xmax": 0.3, + "times": times, + "chanlocs": [{"labels": "Fz"}, {"labels": "Cz"}], + "event": events, + "urevent": [{key: value for key, value in event.items() if key != "urevent"} for event in events], + "epoch": [{"event": [trial + 1], "eventtype": [str(faces[trial])]} for trial in range(trials)], + "etc": {}, + } + + +@eeglab_test(LIMO_WRAPPER, "limo_test1") +def test_limo_preprocessing_statistics_workflow_fits_wls_and_repeated_measures(tmp_path: Path): + """Port the maintained preprocessing/statistics script using generated data.""" + datasets = [_limo_eeg(index) for index in range(1, 7)] + study, alleeg = pop_study(None, datasets, name="Generated face study") + study, _trialinfo = std_maketrialinfo(study, alleeg) + study = std_makedesign( + study, + alleeg, + 1, + name="FaceRepetition", + variable1="face", + values1=["famous", "scrambled"], + vartype1="categorical", + subjselect=[f"S{index:02d}" for index in range(1, 7)], + ) + + study, returned, model_files, command = pop_limo( + study, + alleeg, + method="WLS", + measure="daterp", + timelim=[-50, 250], + outputdir=tmp_path / "models", + return_com=True, + ) + + assert len(returned) == len(alleeg) + assert all(left is right for left, right in zip(returned, alleeg)) + assert study["limo"]["method"] == "WLS" + assert len(model_files["files"]) == 6 + assert command.startswith("STUDY, ALLEEG, model_files = pop_limo(") + assert all(Path(file).is_file() for file in model_files["files"]) + first = std_readfilelimo(model_files["files"][0]) + np.testing.assert_allclose(first["betas"], model_files["models"][0]["betas"]) + assert first["parameter_names"] == ["face=famous", "face=scrambled", "constant"] + assert first["weights"][-1] < np.median(first["weights"]) + + conditions = std_limoresults( + model_files, + "contrast", + contrast=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + ) + repeated = std_limoresults(conditions["estimates"], "Repeated Measures ANOVA") + assert repeated["condition_means"].shape == (2, 2, 7) + assert np.nanmedian(repeated["f"]) > 100.0 + assert np.nanmedian(repeated["p"]) < 1e-6 + + differences = conditions["estimates"][:, 0] - conditions["estimates"][:, 1] + variances = np.full_like(differences, 0.04) + summary = std_limoresults( + differences, + "central tendency", + estimator="weighted mean", + variances=variances, + ) + np.testing.assert_allclose(summary["estimate"], np.mean(differences, axis=0), atol=1e-12) + assert np.all(summary["ci"][0] <= summary["estimate"]) + assert np.all(summary["ci"][1] >= summary["estimate"]) + + updated, contrast_result, result_command = pop_limoresults( + study, + model_files, + analysis="contrast", + contrast=[1.0, -1.0, 0.0], + return_com=True, + ) + assert updated["limo"]["results"][-1]["analysis"] == "contrast" + assert contrast_result["estimates"].shape == (6, 1, 2, 7) + assert result_command.startswith("STUDY, result = pop_limoresults(") + assert eegprep.pop_limo is pop_limo + assert eegprep.std_limoresults is std_limoresults + + mixed_study = std_makedesign( + study, + returned, + 2, + name="Face_time", + variable1="face", + values1=["famous", "scrambled"], + variable2="rt", + vartype2="continuous", + ) + _mixed_study, _mixed_eeg, mixed_files = pop_limo(mixed_study, returned, method="OLS", splitreg="on") + assert mixed_files["models"][0]["parameter_names"] == [ + "face=famous", + "face=scrambled", + "rt|face=famous", + "rt|face=scrambled", + "constant", + ] + + +@eeglab_test(LIMO_WRAPPER, "limo_test2") +def test_limo_integration_covers_first_level_contrasts_and_core_group_models(tmp_path: Path): + """Port the maintained first-/second-level integration script numerically.""" + rng = np.random.default_rng(44) + trials = 30 + condition = np.tile([0.0, 1.0], trials // 2) + covariate = np.linspace(-1.0, 1.0, trials) + design = np.column_stack((condition, covariate, np.ones(trials))) + true_beta = np.asarray( + [ + [[1.2, 0.5, -0.3], [0.8, -0.25, 0.1], [1.5, 0.2, 0.0]], + [[-0.4, 0.75, 0.5], [0.6, 0.1, -0.2], [0.2, -0.5, 0.9]], + ] + ) + response = np.einsum("tp,cfp->cft", design, true_beta) + response += 0.01 * rng.standard_normal(response.shape) + + ols = std_limo(response, design, method="OLS", parameter_names=["condition", "rt", "constant"]) + expected = np.linalg.pinv(design) @ np.moveaxis(response, -1, 0).reshape(trials, -1) + expected = np.moveaxis(expected.reshape((3, 2, 3)), 0, -1) + np.testing.assert_allclose(ols["betas"], expected, rtol=1e-12, atol=1e-12) + assert np.min(ols["r2"]) > 0.998 + assert np.all((ols["p"] >= 0.0) & (ols["p"] <= 1.0)) + + # Golden output from limo_WLS at LIMO Toolbox bff6d166c5338f05d7ed9b37c1b7615d667492af. + limo_x = np.column_stack((np.ones(12), np.linspace(-1.0, 1.0, 12))) + limo_beta = np.asarray([[1.0, 2.0, 3.0, 4.0], [0.5, -1.0, 1.5, -0.25]]) + limo_y = limo_x @ limo_beta + limo_y[-1] += [5.0, 4.0, 6.0, 3.0] + wls = std_limo(limo_y.T, limo_x, method="WLS") + np.testing.assert_allclose( + wls["betas"].T, + [ + [1.00173322768859, 2.00138658215087, 3.00207987322631, 4.00103993661315], + [0.504984652334702, -0.996012278132239, 1.50598158280164, -0.247009208599179], + ], + rtol=2e-12, + atol=2e-12, + ) + np.testing.assert_allclose( + wls["weights"], + [ + 0.620542368031203, + 0.846061602506803, + 0.972393649026113, + 0.99996019537856, + 1.0, + 1.0, + 0.999977822653864, + 0.975127729877988, + 0.860292565590187, + 0.657526761562131, + 0.404658627171921, + 0.04, + ], + rtol=2e-12, + atol=2e-12, + ) + assert wls["weight_reduction"] == 3 + limo_irls = std_limo(limo_y.T, limo_x, method="IRLS") + np.testing.assert_allclose(limo_irls["betas"].T, limo_beta, rtol=2e-12, atol=2e-12) + np.testing.assert_allclose(limo_irls["weights"][:, -1], 0.0, atol=1e-15) + + contaminated = response.copy() + contaminated[:, :, -1] += 30.0 + contaminated_ols = std_limo(contaminated, design, method="OLS") + irls = std_limo(contaminated, design, method="IRLS") + ols_error = np.linalg.norm(contaminated_ols["betas"] - true_beta) + irls_error = np.linalg.norm(irls["betas"] - true_beta) + assert irls_error < ols_error * 0.2 + assert np.max(irls["weights"][..., -1]) < 0.1 + + file = tmp_path / "first_level.npz" + saved = save_limo_result({**ols, "dataset_index": 1, "subject": "S01"}, file) + loaded = std_limoresults(saved, "load") + np.testing.assert_allclose(loaded["residuals"], ols["residuals"]) + with pytest.raises(NotImplementedError, match="MATLAB LIMO .mat"): + std_readfilelimo(tmp_path / "LIMO.mat") + with pytest.raises(NotImplementedError, match="bootstrap and TFCE"): + std_limoresults(np.ones((6, 2)), nboot=101) + + subjects = 12 + x = np.linspace(-1.0, 1.0, subjects) + noise = rng.normal(scale=0.05, size=(subjects, 2, 3)) + group_values = 0.4 + 1.7 * x[:, None, None] + noise + one_sample = std_limoresults(group_values, "one sample t-test", outputfile=tmp_path / "one_sample.npz") + scipy_one = stats.ttest_1samp(group_values, 0.0, axis=0) + np.testing.assert_allclose(one_sample["t"], scipy_one.statistic) + np.testing.assert_allclose(one_sample["p"], scipy_one.pvalue) + reloaded_one_sample = std_readfilelimo(one_sample["file"]) + np.testing.assert_allclose(reloaded_one_sample["t"], one_sample["t"]) + + paired_other = group_values - (0.25 + 0.01 * x[:, None, None]) + paired = std_limoresults(group_values, "paired t-test", data2=paired_other) + np.testing.assert_allclose(paired["difference"], 0.25, atol=1e-12) + first_group, second_group = group_values[:6], group_values[6:] + independent = std_limoresults(first_group, "two samples t-test", data2=second_group) + scipy_two = stats.ttest_ind(first_group, second_group, axis=0, equal_var=False) + np.testing.assert_allclose(independent["t"], scipy_two.statistic) + np.testing.assert_allclose(independent["p"], scipy_two.pvalue) + + regression = std_limoresults(group_values, "regression", regressors=x) + np.testing.assert_allclose(regression["betas"][..., 0], 1.7, atol=0.1) + assert np.min(regression["r2"]) > 0.98 + anova = std_limoresults([first_group, second_group], "N-Ways ANOVA") + scipy_anova = stats.f_oneway(first_group, second_group, axis=0) + np.testing.assert_allclose(anova["f"], scipy_anova.statistic) + + labels = np.asarray(["control"] * 6 + ["patient"] * 6) + ancova_data = 1.2 * x[:, None] + (labels == "patient")[:, None] * 0.8 + rng.normal(0.0, 0.02, (subjects, 2)) + ancova = std_limoresults(ancova_data, "ANCOVA", groups=labels, covariates=x) + assert np.min(ancova["f"]) > 100.0 + assert np.max(ancova["p"]) < 1e-6 diff --git a/tests/test_study_end_to_end.py b/tests/test_study_end_to_end.py index 77f7888b..960c9880 100644 --- a/tests/test_study_end_to_end.py +++ b/tests/test_study_end_to_end.py @@ -183,8 +183,8 @@ def test_study_menu_actions_owned_by_epic_are_implemented(): } -def test_limo_entry_points_report_standalone_limitation(): - with pytest.raises(NotImplementedError, match="does not implement EEGLAB's external LIMO toolbox"): +def test_limo_entry_points_require_an_active_study_design(): + with pytest.raises(ValueError, match="valid active STUDY design"): pop_limo({}, []) From 9955545d1074a3ff101ee6e1d1dbbca16630ca56 Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:58 -0700 Subject: [PATCH 42/45] feat: add cluster permutation statistics (#370) --- docs/parity/eeglab_core_parity_matrix.json | 4 +- docs/source/api/statistics.rst | 42 +- docs/source/changelog.rst | 5 +- src/eegprep/functions/statistics/__init__.py | 2 + src/eegprep/functions/statistics/_core.py | 7 +- .../functions/statistics/statcondfieldtrip.py | 380 ++++++++++++++++-- tests/test_statcondfieldtrip_eeglab_tests.py | 230 ++++++++++- 7 files changed, 628 insertions(+), 42 deletions(-) diff --git a/docs/parity/eeglab_core_parity_matrix.json b/docs/parity/eeglab_core_parity_matrix.json index 28177f28..1332755b 100644 --- a/docs/parity/eeglab_core_parity_matrix.json +++ b/docs/parity/eeglab_core_parity_matrix.json @@ -6762,13 +6762,13 @@ "eegprep_equivalent": "src/eegprep/functions/statistics/statcondfieldtrip.py", "gap_category": "5_statistics_package", "status": "implemented", - "rationale": "Implemented as a standalone analytic and permutation backend for the maintained paired/unpaired t-test and unpaired one-way ANOVA contract, including pointwise and max-statistic corrections. FieldTrip structure conversion and cluster machinery remain explicit unsupported boundaries.", + "rationale": "Implemented as a standalone analytic and permutation backend for the maintained paired/unpaired t-test and unpaired one-way ANOVA contract, including pointwise, max-statistic, and explicit-adjacency max-cluster-mass corrections. Automatic FieldTrip structure conversion and neighbour construction remain explicit unsupported boundaries.", "responsible_phase": "phase_3", "user_facing_surface": [ "helper", "statistics" ], - "test_notes": "Covered by deterministic SciPy references and the active calls in the current eeglab_tests statcondfieldtrip wrapper; the wrapper's kmean guard typo is not reproduced." + "test_notes": "Covered by deterministic SciPy references, seeded Monte Carlo tests, exhaustively enumerated paired and unpaired cluster-null fixtures, and the active calls in the current eeglab_tests statcondfieldtrip wrapper; the wrapper's kmean guard typo is not reproduced." }, { "eeglab_path": "functions/statistics/surrogdistrib.m", diff --git a/docs/source/api/statistics.rst b/docs/source/api/statistics.rst index 993f2d78..897e53a1 100644 --- a/docs/source/api/statistics.rst +++ b/docs/source/api/statistics.rst @@ -58,8 +58,8 @@ the statistic, p-value, and mask. Supported designs are paired or equal-variance unpaired two-condition t-tests and unpaired one-way ANOVA. Analytic inference and seeded Monte Carlo permutation inference are available. Multiple-comparison options are ``none``, -``bonferroni``, ``holm``, ``fdr``, and Monte Carlo ``max`` correction; -``bonferoni`` and ``holms`` remain accepted migration spellings. +``bonferroni``, ``holm``, ``fdr``, and Monte Carlo ``max`` or ``cluster`` +correction; ``bonferoni`` and ``holms`` remain accepted migration spellings. As in FieldTrip, Bonferroni, Holm, and FDR leave the reported pointwise ``pvalue`` unchanged and apply their correction to ``mask``. Monte Carlo @@ -72,10 +72,40 @@ documented two-tailed output contract directly; FieldTrip's internal with a halved alpha threshold. Paired one-way and two-way ANOVA are rejected because the maintained EEGLAB -test disables those FieldTrip paths. Cluster correction and spatial-neighbour -inputs are also rejected: faithful cluster inference requires an explicit -adjacency graph and cluster-forming/statistic policy. Use ``statcond`` for the -supported paired and two-way ANOVA designs without FieldTrip correction. +test disables those FieldTrip paths. Use ``statcond`` for the supported paired +and two-way ANOVA designs without FieldTrip correction. + +Cluster correction requires ``method="montecarlo"`` and an explicit +``neighbours`` adjacency matrix. The matrix is square and symmetric over every +feature in C-order-flattened result space; it may be a dense NumPy array or a +SciPy sparse matrix. Its diagonal is ignored. EEGPrep does not infer sensor, +time, or frequency neighbours because bare condition arrays contain no axis +metadata. Construct the complete adjacency graph before calling the function. +For channel-only statistics, ``std_prepare_neighbors`` already returns the +usable matrix as ``limostruct["channeighbstructmat"]``. Add time/frequency +connectivity explicitly when those dimensions are present. + +The supported cluster policy is a parametric cluster-forming threshold and +FieldTrip's default ``clusterstatistic="maxsum"``. Set ``clusteralpha`` to +derive the critical t or F value, or provide a positive ``clustercritval`` +directly. Positive and negative t-value clusters are formed separately. The +largest absolute cluster mass across both signs is retained for every +permutation, giving one direct two-sided family-wise null distribution; F tests +use their right tail. This is intentionally more explicit than FieldTrip's +internal representation using separate one-tail probabilities and a corrected +alpha threshold. + +Paired two-condition permutations swap labels independently within each case; +unpaired permutations pool case labels and split them back into the original +group sizes. Integer ``naccu`` values use a seedable Monte Carlo sample and the +plus-one probability estimate. ``naccu="all"`` enumerates the complete null +space for designs requiring at most 100,000 assignments and reports exact +frequencies. Cluster-corrected ``pvalue`` is constant within each observed +cluster and one outside clusters. ``cluster_labels``, ``clusters``, +``cluster_null``, and ``cluster_critical_value`` expose the inference details. +Automatic channel geometry, nonparametric cluster-forming thresholds, +max-size/weighted cluster statistics, minimum-neighbour pruning, and TFCE are +explicitly unsupported. Multiple Comparisons and Surrogates =================================== diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 9a8c017b..cd551b82 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -33,7 +33,10 @@ Unreleased ``textgui`` remain explicit exclusions. - Added a standalone ``statcondfieldtrip`` backend for paired and unpaired t-tests and unpaired one-way ANOVA, with analytic or seeded permutation - inference and Bonferroni, Holm, FDR, and max-statistic correction. + inference and Bonferroni, Holm, FDR, max-statistic, and max-cluster-mass + correction. Cluster inference uses explicit dense or sparse adjacency, + correct paired/unpaired label exchangeability, a direct two-sided null, and + either seeded Monte Carlo sampling or bounded exact enumeration. - Added standalone ``corrmap`` and ``pop_corrmap`` component-template matching for STUDY workflows. Two-pass correlation handles ICA polarity, searches deterministic automatic thresholds, can store matched bad diff --git a/src/eegprep/functions/statistics/__init__.py b/src/eegprep/functions/statistics/__init__.py index 9153adb0..fb6dd02d 100644 --- a/src/eegprep/functions/statistics/__init__.py +++ b/src/eegprep/functions/statistics/__init__.py @@ -30,6 +30,7 @@ ConcatenatedData = _MODULES["concatdata"].ConcatenatedData FDRResult = _MODULES["fdr"].FDRResult StatcondResult = _MODULES["statcond"].StatcondResult +StatcondFieldtripCluster = _MODULES["statcondfieldtrip"].StatcondFieldtripCluster StatcondFieldtripResult = _MODULES["statcondfieldtrip"].StatcondFieldtripResult SurrogateDistribution = _MODULES["surrogdistrib"].SurrogateDistribution TwoWayAnovaResult = _MODULES["anova2_cell"].TwoWayAnovaResult @@ -56,6 +57,7 @@ "ConcatenatedData", "FDRResult", "StatcondResult", + "StatcondFieldtripCluster", "StatcondFieldtripResult", "SurrogateDistribution", "TwoWayAnovaResult", diff --git a/src/eegprep/functions/statistics/_core.py b/src/eegprep/functions/statistics/_core.py index da8eb1fa..febd3a85 100644 --- a/src/eegprep/functions/statistics/_core.py +++ b/src/eegprep/functions/statistics/_core.py @@ -16,7 +16,11 @@ from eegprep.functions.statistics.stat_surrogate_ci import stat_surrogate_ci from eegprep.functions.statistics.stat_surrogate_pvals import stat_surrogate_pvals from eegprep.functions.statistics.statcond import StatcondResult, statcond -from eegprep.functions.statistics.statcondfieldtrip import StatcondFieldtripResult, statcondfieldtrip +from eegprep.functions.statistics.statcondfieldtrip import ( + StatcondFieldtripCluster, + StatcondFieldtripResult, + statcondfieldtrip, +) from eegprep.functions.statistics.surrogdistrib import SurrogateDistribution, surrogdistrib from eegprep.functions.statistics.teststat import teststat from eegprep.functions.statistics.ttest2_cell import ttest2_cell @@ -26,6 +30,7 @@ "ConcatenatedData", "FDRResult", "StatcondResult", + "StatcondFieldtripCluster", "StatcondFieldtripResult", "SurrogateDistribution", "TwoWayAnovaResult", diff --git a/src/eegprep/functions/statistics/statcondfieldtrip.py b/src/eegprep/functions/statistics/statcondfieldtrip.py index a4a77965..8b195917 100644 --- a/src/eegprep/functions/statistics/statcondfieldtrip.py +++ b/src/eegprep/functions/statistics/statcondfieldtrip.py @@ -4,16 +4,34 @@ from collections.abc import Iterator from dataclasses import dataclass +from itertools import combinations, product +from math import comb from operator import index from typing import Any import numpy as np +from scipy import sparse +from scipy import stats as scipy_stats from eegprep.functions.statistics._shared import condition_grid, paired_flag from eegprep.functions.statistics.fdr import fdr from eegprep.functions.statistics.statcond import StatcondResult, statcond +MAX_EXACT_PERMUTATIONS = 100_000 + + +@dataclass(frozen=True) +class StatcondFieldtripCluster: + """One observed cluster from cluster-based permutation inference.""" + + label: int + sign: int + indices: tuple[int, ...] + mass: float + pvalue: float + + @dataclass(frozen=True) class StatcondFieldtripResult: """Statistical result with FieldTrip-style multiple-comparison output.""" @@ -27,6 +45,11 @@ class StatcondFieldtripResult: method: str mcorrect: str paired: bool + cluster_labels: np.ndarray | None = None + clusters: tuple[StatcondFieldtripCluster, ...] = () + cluster_null: np.ndarray | None = None + cluster_critical_value: float | None = None + exact: bool = False def __iter__(self) -> Iterator[Any]: yield self.stat @@ -40,13 +63,17 @@ def statcondfieldtrip( paired: str | bool = "auto", method: str = "analytic", mode: str | None = None, - naccu: int = 200, + naccu: int | str = 200, variance: str = "homogenous", mcorrect: str = "none", alpha: float = 0.05, axis: int = -1, rng: np.random.Generator | int | None = None, neighbours: Any = None, + clusteralpha: float = 0.05, + clusterstatistic: str = "maxsum", + clusterthreshold: str = "parametric", + clustercritval: float | None = None, ) -> StatcondFieldtripResult: """Compare conditions using the supported ``statcondfieldtrip`` contract. @@ -64,28 +91,41 @@ def statcondfieldtrip( and ``"bootstrap"`` are accepted; bootstrap follows EEGLAB's FieldTrip wrapper and selects permutation inference. mode: EEGLAB alias for ``method``. A non-empty value takes precedence. - naccu: Number of permutations for Monte Carlo inference. + naccu: Number of permutations for Monte Carlo inference, or ``"all"`` + to enumerate small permutation spaces exactly. variance: Equal-variance mode for an unpaired t-test. Both spellings of ``"homogeneous"`` are accepted. mcorrect: Multiple-comparison correction: ``"none"``, - ``"bonferroni"``, ``"holm"``, ``"fdr"``, or ``"max"``. + ``"bonferroni"``, ``"holm"``, ``"fdr"``, ``"max"``, or + ``"cluster"``. EEGLAB/FieldTrip spellings ``"bonferoni"`` and ``"holms"`` are accepted. Max correction requires Monte Carlo inference. alpha: Family significance threshold used to form ``mask``. axis: Case axis in every condition array. rng: Optional NumPy generator or seed for Monte Carlo inference. - neighbours: Reserved FieldTrip spatial-neighbour input. Non-empty - values require cluster correction and are not supported. + neighbours: Explicit square, symmetric adjacency matrix over features + flattened in NumPy C order. Dense and SciPy sparse matrices are + supported. Required for cluster correction and otherwise rejected. + clusteralpha: Per-feature cluster-forming probability. Two-condition + tests split it equally between the two tails. + clusterstatistic: Cluster statistic. Only FieldTrip's default + ``"maxsum"`` is supported. + clusterthreshold: Cluster-forming threshold policy. Only + ``"parametric"`` is supported. + clustercritval: Optional positive statistic cutoff overriding the + value derived from ``clusteralpha`` and the reference distribution. Returns: A structured result. Iteration yields ``stat``, ``df``, and ``pvalue`` for compatibility with the MATLAB function's three outputs. Pointwise corrections leave ``pvalue`` unadjusted and correct ``mask`` instead; - max-statistic correction returns family-wise corrected ``pvalue``. + max-statistic and cluster correction return family-wise corrected + ``pvalue``. Cluster results also expose the observed labels, cluster + records, critical statistic, and maximum-cluster null distribution. Raises: - NotImplementedError: For paired one-way ANOVA, two-way designs, - cluster correction, or spatial-neighbour statistics. + NotImplementedError: For paired one-way ANOVA, two-way designs, or + unsupported cluster-statistic and threshold policies. ValueError: For invalid methods, corrections, alpha, variance, or condition shapes. """ @@ -95,12 +135,12 @@ def statcondfieldtrip( alpha_value = float(alpha) if not np.isfinite(alpha_value) or not 0 < alpha_value <= 1: raise ValueError("alpha must be greater than 0 and at most 1") - if _has_values(neighbours): - raise NotImplementedError("spatial-neighbour statistics require the unsupported cluster backend") - if correction_name == "cluster": - raise NotImplementedError("cluster correction requires an explicit adjacency graph and cluster policy") + if correction_name != "cluster" and _has_values(neighbours): + raise ValueError("neighbours requires mcorrect='cluster'") if correction_name == "max" and method_name != "montecarlo": raise ValueError("max correction requires method='montecarlo'") + if correction_name == "cluster" and method_name != "montecarlo": + raise ValueError("cluster correction requires method='montecarlo'") grid = condition_grid(data, axis=axis, min_cases=2) if len(grid) != 1: @@ -116,40 +156,65 @@ def statcondfieldtrip( if n_conditions == 2 and not paired_value and variance_name != "homogenous": raise NotImplementedError("FieldTrip parity supports only equal-variance unpaired t-tests") - try: - count = index(naccu) - except TypeError: - raise ValueError("naccu must be an integer") from None - if isinstance(naccu, bool): - raise ValueError("naccu must be an integer") - if count < 1: - raise ValueError("naccu must be at least 1") - statcond_method = "param" if method_name == "analytic" else "perm" + count = _randomization_count(naccu) + exact = count is None + if exact and method_name != "montecarlo": + raise ValueError("naccu='all' requires method='montecarlo'") + result = statcond( grid[0], paired=paired_value, - method=statcond_method, - naccu=count, + method="param" if exact or method_name == "analytic" else "perm", + naccu=1 if exact else count, variance=variance_name, rng=rng, ) if not isinstance(result, StatcondResult): # pragma: no cover - fixed by call arguments raise RuntimeError("statcond returned resampling arrays instead of statistics") + surrogate = result.surrogate + if exact: + surrogate = _exact_surrogate_statistics(grid[0], paired=paired_value, variance=variance_name) + raw_pvalue = np.asarray(result.pvalue, dtype=float) if method_name == "montecarlo": raw_pvalue = _montecarlo_pvalues( result.stat, - result.surrogate, + surrogate, two_sided=n_conditions == 2, + plus_one=not exact, ) + cluster_labels = None + clusters: tuple[StatcondFieldtripCluster, ...] = () + cluster_null = None + cluster_critical_value = None if correction_name == "max": pvalue = _max_statistic_pvalues( result.stat, - result.surrogate, + surrogate, two_sided=n_conditions == 2, + plus_one=not exact, ) mask = pvalue <= alpha_value + elif correction_name == "cluster": + adjacency = _validate_adjacency(neighbours, np.asarray(result.stat).size) + cluster_critical_value = _cluster_critical_value( + result.df, + two_sided=n_conditions == 2, + clusteralpha=clusteralpha, + clusterthreshold=clusterthreshold, + clustercritval=clustercritval, + clusterstatistic=clusterstatistic, + ) + pvalue, mask, cluster_labels, clusters, cluster_null = _cluster_correction( + result.stat, + surrogate, + adjacency, + critical_value=cluster_critical_value, + alpha=alpha_value, + two_sided=n_conditions == 2, + plus_one=not exact, + ) else: pvalue = raw_pvalue.copy() mask = _corrected_mask(raw_pvalue, alpha_value, correction_name) @@ -159,13 +224,96 @@ def statcondfieldtrip( pvalue=pvalue, mask=mask, raw_pvalue=raw_pvalue, - surrogate=result.surrogate, + surrogate=surrogate, method=method_name, mcorrect=correction_name, paired=paired_value, + cluster_labels=cluster_labels, + clusters=clusters, + cluster_null=cluster_null, + cluster_critical_value=cluster_critical_value, + exact=exact, ) +def _randomization_count(naccu: int | str) -> int | None: + if isinstance(naccu, str): + if naccu.lower() == "all": + return None + raise ValueError("naccu must be an integer or 'all'") + try: + count = index(naccu) + except TypeError: + raise ValueError("naccu must be an integer or 'all'") from None + if isinstance(naccu, bool): + raise ValueError("naccu must be an integer or 'all'") + if count < 1: + raise ValueError("naccu must be at least 1") + return count + + +def _exact_surrogate_statistics( + conditions: tuple[np.ndarray, ...], + *, + paired: bool, + variance: str, +) -> np.ndarray: + total = _exact_permutation_count(conditions, paired=paired) + if total > MAX_EXACT_PERMUTATIONS: + raise ValueError( + f"naccu='all' would require {total} permutations; the standalone limit is {MAX_EXACT_PERMUTATIONS}" + ) + statistics = [] + for sample in _exact_condition_permutations(conditions, paired=paired): + sample_result = statcond(sample, paired=paired, method="param", variance=variance) + if not isinstance(sample_result, StatcondResult): # pragma: no cover - fixed by call arguments + raise RuntimeError("statcond returned resampling arrays instead of statistics") + statistics.append(np.asarray(sample_result.stat, dtype=float)) + return np.stack(statistics, axis=-1) + + +def _exact_permutation_count(conditions: tuple[np.ndarray, ...], *, paired: bool) -> int: + if paired: + return 2 ** conditions[0].shape[-1] + remaining = sum(condition.shape[-1] for condition in conditions) + count = 1 + for condition in conditions[:-1]: + condition_count = condition.shape[-1] + count *= comb(remaining, condition_count) + remaining -= condition_count + return count + + +def _exact_condition_permutations( + conditions: tuple[np.ndarray, ...], + *, + paired: bool, +) -> Iterator[tuple[np.ndarray, ...]]: + if paired: + first, second = conditions + reshape = (1,) * (first.ndim - 1) + (first.shape[-1],) + for swapped in product((False, True), repeat=first.shape[-1]): + mask = np.asarray(swapped, dtype=bool).reshape(reshape) + yield (np.where(mask, second, first), np.where(mask, first, second)) + return + + pooled = np.concatenate(conditions, axis=-1) + counts = tuple(condition.shape[-1] for condition in conditions) + for groups in _index_partitions(tuple(range(pooled.shape[-1])), counts): + yield tuple(np.take(pooled, group, axis=-1) for group in groups) + + +def _index_partitions(indices: tuple[int, ...], counts: tuple[int, ...]) -> Iterator[tuple[tuple[int, ...], ...]]: + if len(counts) == 1: + yield (indices,) + return + for first in combinations(indices, counts[0]): + selected = set(first) + remaining = tuple(value for value in indices if value not in selected) + for rest in _index_partitions(remaining, counts[1:]): + yield (first, *rest) + + def _normalize_method(method: str) -> str: method_name = str(method).lower() if method_name in {"analytic", "param", "parametric"}: @@ -209,11 +357,167 @@ def _normalize_variance(variance: str) -> str: def _has_values(value: Any) -> bool: if value is None: return False + if sparse.issparse(value): + return value.shape != (0, 0) if isinstance(value, dict): return bool(value) return np.asarray(value, dtype=object).size > 0 +def _validate_adjacency(neighbours: Any, feature_count: int) -> tuple[np.ndarray, ...]: + if neighbours is None: + raise ValueError("cluster correction requires an explicit neighbours adjacency matrix") + if sparse.issparse(neighbours): + raw_matrix = sparse.csr_matrix(neighbours) + if not (np.issubdtype(raw_matrix.dtype, np.number) or np.issubdtype(raw_matrix.dtype, np.bool_)): + raise TypeError("neighbours must be a numeric or boolean adjacency matrix") + if np.any(~np.isfinite(raw_matrix.data)): + raise ValueError("neighbours must contain only finite values") + if np.iscomplexobj(raw_matrix.data): + raise TypeError("neighbours must be a real-valued adjacency matrix") + matrix = raw_matrix.astype(bool) + if matrix.shape != (feature_count, feature_count): + raise ValueError(f"neighbours must have shape ({feature_count}, {feature_count}), got {matrix.shape}") + matrix.setdiag(False) + matrix.eliminate_zeros() + if (matrix != matrix.T).nnz: + raise ValueError("neighbours must be symmetric") + matrix.sort_indices() + return tuple(matrix.indices[matrix.indptr[row] : matrix.indptr[row + 1]] for row in range(feature_count)) + + matrix = np.asarray(neighbours) + if matrix.ndim != 2 or matrix.shape != (feature_count, feature_count): + raise ValueError(f"neighbours must have shape ({feature_count}, {feature_count}), got {matrix.shape}") + if not (np.issubdtype(matrix.dtype, np.number) or np.issubdtype(matrix.dtype, np.bool_)): + raise TypeError("neighbours must be a numeric or boolean adjacency matrix") + if np.any(~np.isfinite(matrix)): + raise ValueError("neighbours must contain only finite values") + if np.iscomplexobj(matrix): + raise TypeError("neighbours must be a real-valued adjacency matrix") + connected = matrix != 0 + if not np.array_equal(connected, connected.T): + raise ValueError("neighbours must be symmetric") + np.fill_diagonal(connected, False) + return tuple(np.flatnonzero(connected[row]) for row in range(feature_count)) + + +def _cluster_critical_value( + df: Any, + *, + two_sided: bool, + clusteralpha: float, + clusterthreshold: str, + clustercritval: float | None, + clusterstatistic: str, +) -> float: + if str(clusterstatistic).lower() != "maxsum": + raise NotImplementedError("clusterstatistic supports only 'maxsum'") + if str(clusterthreshold).lower() != "parametric": + raise NotImplementedError("clusterthreshold supports only 'parametric'") + if clustercritval is not None: + critical = float(clustercritval) + if not np.isfinite(critical) or critical <= 0: + raise ValueError("clustercritval must be a finite positive statistic") + return critical + + cluster_alpha = float(clusteralpha) + if not np.isfinite(cluster_alpha) or not 0 < cluster_alpha < 1: + raise ValueError("clusteralpha must be between 0 and 1") + if two_sided: + critical = scipy_stats.t.isf(cluster_alpha / 2, df) + else: + critical = scipy_stats.f.isf(cluster_alpha, df[0], df[1]) + return float(critical) + + +def _cluster_correction( + statistic: Any, + surrogate: Any, + adjacency: tuple[np.ndarray, ...], + *, + critical_value: float, + alpha: float, + two_sided: bool, + plus_one: bool, +) -> tuple[ + np.ndarray, + np.ndarray, + np.ndarray, + tuple[StatcondFieldtripCluster, ...], + np.ndarray, +]: + if surrogate is None: + raise ValueError("cluster correction requires a surrogate statistic distribution") + observed = np.asarray(statistic, dtype=float) + distribution = np.asarray(surrogate, dtype=float) + if distribution.shape[:-1] != observed.shape: + raise ValueError("surrogate shape must equal statistic shape plus a final permutation axis") + + observed_candidates = _cluster_candidates( + observed, + adjacency, + critical_value=critical_value, + two_sided=two_sided, + ) + null_maximum = np.zeros(distribution.shape[-1], dtype=float) + for permutation in range(distribution.shape[-1]): + candidates = _cluster_candidates( + distribution[..., permutation], + adjacency, + critical_value=critical_value, + two_sided=two_sided, + ) + null_maximum[permutation] = max((mass for _sign, _indices, mass in candidates), default=0.0) + + pvalue = np.ones(observed.shape, dtype=float) + labels = np.zeros(observed.shape, dtype=int) + clusters = [] + flat_pvalue = pvalue.reshape(-1) + flat_labels = labels.reshape(-1) + denominator = null_maximum.size + int(plus_one) + for label, (sign, indices, mass) in enumerate(observed_candidates, start=1): + exceedances = np.count_nonzero(null_maximum >= mass) + int(plus_one) + cluster_pvalue = float(exceedances / denominator) + flat_pvalue[list(indices)] = cluster_pvalue + flat_labels[list(indices)] = label + clusters.append(StatcondFieldtripCluster(label, sign, indices, mass, cluster_pvalue)) + pvalue[np.isnan(observed)] = np.nan + return pvalue, pvalue <= alpha, labels, tuple(clusters), null_maximum + + +def _cluster_candidates( + statistic: Any, + adjacency: tuple[np.ndarray, ...], + *, + critical_value: float, + two_sided: bool, +) -> tuple[tuple[int, tuple[int, ...], float], ...]: + values = np.asarray(statistic, dtype=float).reshape(-1) + signs = (1, -1) if two_sided else (1,) + clusters = [] + for sign in signs: + active = sign * values >= critical_value + visited = np.zeros(values.size, dtype=bool) + for start in np.flatnonzero(active): + if visited[start]: + continue + stack = [int(start)] + visited[start] = True + indices = [] + while stack: + current = stack.pop() + indices.append(current) + for neighbour in adjacency[current]: + neighbour_index = int(neighbour) + if active[neighbour_index] and not visited[neighbour_index]: + visited[neighbour_index] = True + stack.append(neighbour_index) + ordered = tuple(sorted(indices)) + mass = float(np.sum(sign * values[list(ordered)])) + clusters.append((sign, ordered, mass)) + return tuple(clusters) + + def _corrected_mask(pvalues: np.ndarray, alpha: float, correction: str) -> np.ndarray: values = np.asarray(pvalues, dtype=float) if correction == "none": @@ -245,7 +549,13 @@ def _corrected_mask(pvalues: np.ndarray, alpha: float, correction: str) -> np.nd return mask.reshape(values.shape) -def _montecarlo_pvalues(statistic: Any, surrogate: Any, *, two_sided: bool) -> np.ndarray: +def _montecarlo_pvalues( + statistic: Any, + surrogate: Any, + *, + two_sided: bool, + plus_one: bool = True, +) -> np.ndarray: if surrogate is None: raise ValueError("Monte Carlo inference requires a surrogate statistic distribution") observed = np.asarray(statistic, dtype=float) @@ -256,11 +566,18 @@ def _montecarlo_pvalues(statistic: Any, surrogate: Any, *, two_sided: bool) -> n observed = np.abs(observed) distribution = np.abs(distribution) exceedances = np.sum(distribution >= observed[..., np.newaxis], axis=-1) - pvalues = (exceedances + 1) / (distribution.shape[-1] + 1) + correction = int(plus_one) + pvalues = (exceedances + correction) / (distribution.shape[-1] + correction) return np.where(np.isnan(observed), np.nan, pvalues) -def _max_statistic_pvalues(statistic: Any, surrogate: Any, *, two_sided: bool) -> np.ndarray: +def _max_statistic_pvalues( + statistic: Any, + surrogate: Any, + *, + two_sided: bool, + plus_one: bool = True, +) -> np.ndarray: if surrogate is None: raise ValueError("max correction requires a surrogate statistic distribution") observed = np.asarray(statistic, dtype=float) @@ -272,8 +589,9 @@ def _max_statistic_pvalues(statistic: Any, surrogate: Any, *, two_sided: bool) - distribution = np.abs(distribution) null_maximum = np.max(distribution.reshape(-1, distribution.shape[-1]), axis=0) exceedances = np.sum(null_maximum >= observed[..., np.newaxis], axis=-1) - pvalues = (exceedances + 1) / (distribution.shape[-1] + 1) + correction = int(plus_one) + pvalues = (exceedances + correction) / (distribution.shape[-1] + correction) return np.where(np.isnan(observed), np.nan, pvalues) -__all__ = ["StatcondFieldtripResult", "statcondfieldtrip"] +__all__ = ["StatcondFieldtripCluster", "StatcondFieldtripResult", "statcondfieldtrip"] diff --git a/tests/test_statcondfieldtrip_eeglab_tests.py b/tests/test_statcondfieldtrip_eeglab_tests.py index 26887537..fc6b5023 100644 --- a/tests/test_statcondfieldtrip_eeglab_tests.py +++ b/tests/test_statcondfieldtrip_eeglab_tests.py @@ -4,9 +4,11 @@ import importlib from collections.abc import Sequence +from itertools import combinations, product import numpy as np import pytest +from scipy import sparse from scipy import stats as scipy_stats from eegprep.functions.statistics import StatcondFieldtripResult, statcondfieldtrip @@ -16,6 +18,10 @@ STATCONDFIELDTRIP_SCRIPT = "unittesting_statistics/statcondfieldtrip/test_statcondfieldtrip.m" STATCONDFIELDTRIP_WRAPPER = "unittesting_statistics/statcondfieldtrip/statistics_statcondfieldtrip_wrapperTest.m" +# Cluster-policy oracle: fieldtrip/fieldtrip@8e2307d7e7284c6870a5d12e244d9dc95a1faae3, +# ft_statistics_montecarlo.m and private/clusterstat.m. The exhaustive fixtures +# below independently check its label-exchangeability and maxsum principles. + def _reference_conditions() -> tuple[np.ndarray, np.ndarray, np.ndarray]: rng = np.random.default_rng(941) @@ -184,6 +190,185 @@ def test_statcondfieldtrip_montecarlo_max_correction_is_seeded_and_familywise(): np.testing.assert_array_equal(result.raw_pvalue, pointwise_expected) +def test_statcondfieldtrip_exact_paired_cluster_null_is_exhaustively_checkable(): + first = np.array([[1.0, 2.0, 3.0], [2.0, 3.0, 4.0], [-2.0, -3.0, -4.0]]) + second = np.zeros_like(first) + adjacency = sparse.csr_matrix([[0, 1, 0], [1, 0, 1], [0, 1, 0]]) + + result = statcondfieldtrip( + [first, second], + paired="on", + method="montecarlo", + naccu="all", + mcorrect="cluster", + neighbours=adjacency, + clusteralpha=0.2, + alpha=0.25, + ) + + critical = scipy_stats.t.isf(0.1, df=2) + np.testing.assert_allclose(result.stat, np.sqrt(3) * np.array([2.0, 3.0, -3.0])) + np.testing.assert_allclose(result.cluster_critical_value, critical) + np.testing.assert_allclose(np.sort(result.cluster_null), [0.0] * 6 + [5 * np.sqrt(3)] * 2, atol=1e-14) + np.testing.assert_allclose(result.raw_pvalue, [0.25, 0.25, 0.25]) + np.testing.assert_allclose(result.pvalue, [0.25, 0.25, 0.25]) + np.testing.assert_array_equal(result.mask, [True, True, True]) + np.testing.assert_array_equal(result.cluster_labels, [1, 1, 2]) + assert result.exact is True + assert [(cluster.sign, cluster.indices) for cluster in result.clusters] == [ + (1, (0, 1)), + (-1, (2,)), + ] + np.testing.assert_allclose([cluster.mass for cluster in result.clusters], [5 * np.sqrt(3), 3 * np.sqrt(3)]) + np.testing.assert_allclose([cluster.pvalue for cluster in result.clusters], [0.25, 0.25]) + + # Three paired cases have exactly 2**3 label-swap assignments. Independent + # enumeration confirms that only the unchanged and globally swapped designs + # cross the two-sided threshold, so the exact cluster probability is 2/8. + exhaustive = [] + for signs in product((-1.0, 1.0), repeat=3): + statistic = scipy_stats.ttest_1samp(first * np.asarray(signs), 0.0, axis=-1).statistic + masses = [0.0] + if np.all(statistic[:2] >= critical): + masses.append(np.sum(statistic[:2])) + if np.all(statistic[:2] <= -critical): + masses.append(-np.sum(statistic[:2])) + if statistic[2] >= critical: + masses.append(statistic[2]) + if statistic[2] <= -critical: + masses.append(-statistic[2]) + exhaustive.append(max(masses)) + np.testing.assert_allclose(np.sort(result.cluster_null), np.sort(exhaustive), atol=1e-14) + + +def test_statcondfieldtrip_exact_unpaired_clusters_preserve_group_sizes(): + first = np.array([[5.0, 4.0], [6.0, 5.0]]) + second = np.array([[1.0, 2.0], [2.0, 3.0]]) + + result = statcondfieldtrip( + [first, second], + paired="off", + method="permutation", + naccu="all", + mcorrect="cluster", + neighbours=np.array([[0, 1], [1, 0]], dtype=bool), + clustercritval=1.0, + alpha=1 / 3, + ) + + expected_mass = 6 * np.sqrt(2) + np.testing.assert_allclose(result.stat, [3 * np.sqrt(2), 3 * np.sqrt(2)]) + np.testing.assert_allclose(np.sort(result.cluster_null), [0.0] * 4 + [expected_mass] * 2, atol=1e-14) + np.testing.assert_allclose(result.pvalue, [1 / 3, 1 / 3]) + np.testing.assert_array_equal(result.mask, [True, True]) + assert result.surrogate.shape == (2, 6) + + pooled = np.concatenate([first, second], axis=-1) + exhaustive = [] + for selected in combinations(range(4), 2): + remaining = tuple(index for index in range(4) if index not in selected) + statistic = scipy_stats.ttest_ind( + pooled[:, selected], + pooled[:, remaining], + axis=-1, + equal_var=True, + ).statistic + exhaustive.append(np.sum(np.abs(statistic)) if np.all(np.abs(statistic) >= 1.0) else 0.0) + np.testing.assert_allclose(result.cluster_null, exhaustive, atol=1e-14) + + +def test_statcondfieldtrip_exact_one_way_clusters_use_right_tailed_f_mass(): + conditions = [ + np.array([[4.0, 5.0], [5.0, 6.0]]), + np.array([[2.0, 3.0], [2.5, 3.5]]), + np.array([[0.0, 1.0], [0.5, 1.5]]), + ] + result = statcondfieldtrip( + conditions, + paired="off", + method="montecarlo", + naccu="all", + mcorrect="cluster", + neighbours=np.array([[0, 1], [1, 0]]), + clusteralpha=0.2, + ) + + pooled = np.concatenate(conditions, axis=-1) + critical = scipy_stats.f.isf(0.2, dfn=2, dfd=3) + exhaustive = [] + for first_group in combinations(range(6), 2): + after_first = tuple(index for index in range(6) if index not in first_group) + for second_group in combinations(after_first, 2): + third_group = tuple(index for index in after_first if index not in second_group) + statistic = scipy_stats.f_oneway( + pooled[:, first_group], + pooled[:, second_group], + pooled[:, third_group], + axis=-1, + ).statistic + exhaustive.append(np.sum(statistic[statistic >= critical])) + + assert result.surrogate.shape == (2, 90) + assert all(cluster.sign == 1 for cluster in result.clusters) + np.testing.assert_allclose(result.cluster_critical_value, critical) + np.testing.assert_allclose(result.cluster_null, exhaustive, rtol=1e-13, atol=1e-13) + + +def test_statcondfieldtrip_cluster_result_is_explicit_when_no_cluster_forms(): + generator = np.random.default_rng(946) + first = generator.normal(size=(3, 5)) + second = generator.normal(size=(3, 5)) + + result = statcondfieldtrip( + [first, second], + paired="off", + method="montecarlo", + naccu=15, + mcorrect="cluster", + neighbours=np.zeros((3, 3)), + clustercritval=1e6, + rng=22, + ) + + np.testing.assert_array_equal(result.pvalue, np.ones(3)) + np.testing.assert_array_equal(result.mask, np.zeros(3, dtype=bool)) + np.testing.assert_array_equal(result.cluster_labels, np.zeros(3, dtype=int)) + np.testing.assert_array_equal(result.cluster_null, np.zeros(15)) + assert result.clusters == () + + +def test_statcondfieldtrip_cluster_montecarlo_is_seeded_and_uses_plus_one(): + generator = np.random.default_rng(945) + first = generator.normal(size=(4, 8)) + second = first + np.array([0.9, 0.7, -0.8, -0.7])[:, None] + generator.normal(scale=0.3, size=(4, 8)) + adjacency = np.array( + [ + [0, 1, 0, 0], + [1, 0, 1, 0], + [0, 1, 0, 1], + [0, 0, 1, 0], + ] + ) + kwargs = { + "paired": "on", + "method": "montecarlo", + "naccu": 63, + "mcorrect": "cluster", + "neighbours": adjacency, + "clustercritval": 1.5, + } + + result = statcondfieldtrip([first, second], rng=71, **kwargs) + repeated = statcondfieldtrip([first, second], rng=71, **kwargs) + changed_seed = statcondfieldtrip([first, second], rng=72, **kwargs) + + np.testing.assert_array_equal(result.surrogate, repeated.surrogate) + np.testing.assert_array_equal(result.cluster_null, repeated.cluster_null) + assert not np.array_equal(result.cluster_null, changed_seed.cluster_null) + np.testing.assert_allclose(result.pvalue * 64, np.round(result.pvalue * 64), atol=1e-13) + assert result.exact is False + + @pytest.mark.parametrize( ("kwargs", "message"), [ @@ -194,7 +379,7 @@ def test_statcondfieldtrip_montecarlo_max_correction_is_seeded_and_familywise(): ({"naccu": 1.5}, "naccu"), ({"mcorrect": "max"}, "max correction"), ({"mcorrect": "cluster"}, "cluster correction"), - ({"neighbours": [{"label": "Cz"}]}, "spatial-neighbour"), + ({"neighbours": [{"label": "Cz"}]}, "neighbours"), ], ) def test_statcondfieldtrip_rejects_invalid_or_unavailable_inference_options(kwargs, message): @@ -203,6 +388,48 @@ def test_statcondfieldtrip_rejects_invalid_or_unavailable_inference_options(kwar statcondfieldtrip([first, second], paired="on", **kwargs) +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({}, "explicit neighbours"), + ({"neighbours": np.zeros((3, 3))}, "shape"), + ({"neighbours": np.array([[0, 1], [0, 0]])}, "symmetric"), + ({"neighbours": np.ones((2, 2)), "clusterstatistic": "maxsize"}, "maxsum"), + ( + {"neighbours": np.ones((2, 2)), "clusterthreshold": "nonparametric_common"}, + "parametric", + ), + ({"neighbours": np.ones((2, 2)), "clustercritval": 0.0}, "clustercritval"), + ], +) +def test_statcondfieldtrip_cluster_backend_rejects_ambiguous_policies(kwargs, message): + first = np.array([[1.0, 2.0, 3.0], [2.0, 3.0, 4.0]]) + second = np.zeros_like(first) + with pytest.raises((TypeError, ValueError, NotImplementedError), match=message): + statcondfieldtrip( + [first, second], + paired="on", + method="montecarlo", + naccu=4, + mcorrect="cluster", + **kwargs, + ) + + +def test_statcondfieldtrip_exact_randomization_limit_is_explicit(): + first = np.arange(17.0)[None, :] + second = first + np.linspace(0.0, 1.0, 17)[None, :] + with pytest.raises(ValueError, match="131072 permutations"): + statcondfieldtrip( + [first, second], + paired="on", + method="montecarlo", + naccu="all", + mcorrect="cluster", + neighbours=np.zeros((1, 1)), + ) + + def test_statcondfieldtrip_rejects_designs_disabled_by_the_maintained_wrapper(): first, second, third = _reference_conditions() with pytest.raises(NotImplementedError, match="paired one-way"): @@ -223,4 +450,5 @@ def test_statcondfieldtrip_remains_a_package_callable_after_submodule_import(): module = importlib.import_module("eegprep.functions.statistics.statcondfieldtrip") assert statistics.statcondfieldtrip is module.statcondfieldtrip + assert statistics.StatcondFieldtripCluster is module.StatcondFieldtripCluster assert statistics.StatcondFieldtripResult is module.StatcondFieldtripResult From 2f0091981a9941b49df64a2f6e291a62a6567f5c Mon Sep 17 00:00:00 2001 From: Suraj Ranganath Date: Fri, 18 Sep 2026 17:31:58 -0700 Subject: [PATCH 43/45] Complete current EEGLAB test-port coverage (#372) * test: adjudicate remaining EEGLAB methods * test: port current EEGLAB tutorial workflows * test: port face BIDS tutorial workflow * test: add EEGLAB port completeness audit * test: make live MATLAB validation exhaustive --- docs/source/changelog.rst | 11 + docs/source/development.rst | 35 + docs/source/user_guide/ica_rejection.rst | 7 + docs/source/user_guide/study_workflows.rst | 19 + docs/source/user_guide/visual_parity.rst | 19 +- src/eegprep/functions/popfunc/eeg_picard.py | 4 +- src/eegprep/functions/popfunc/pop_runica.py | 29 +- .../functions/studyfunc/std_maketrialinfo.py | 4 +- tests/test_eeg_autocorr.py | 4 +- tests/test_eeg_autocorr_welch.py | 4 +- tests/test_eeg_store_storedisk.py | 8 + tests/test_eeglab.py | 32 + tests/test_eeglab_test_port_audit.py | 240 ++++++ tests/test_envtopo_parity.py | 23 +- tests/test_erpimage.py | 28 + tests/test_guifunc_primitives.py | 10 + tests/test_phase1b_file_edit_pop_functions.py | 16 +- tests/test_phase4_plot_wrappers.py | 5 + tests/test_public_api_examples.py | 9 + tests/test_rejection_workflows.py | 34 + tests/test_study_long_tail_helpers.py | 1 + tests/test_tutorial_eeglab_tests.py | 772 +++++++++++++++++- tools/eeglab_test_port_audit.py | 381 +++++++++ 23 files changed, 1665 insertions(+), 30 deletions(-) create mode 100644 tests/test_eeglab_test_port_audit.py create mode 100644 tools/eeglab_test_port_audit.py diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index cd551b82..55956b04 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,17 @@ the `GitHub Releases `_ page. Unreleased ========== +- Added a source-driven developer audit for the current EEGLAB test-port + project. It discovers all MATLAB wrapper and regression methods from the + pinned suite, collects pytest provenance, resolves same-directory scenario + scripts to their wrapper methods, and reports exact missing or stale ports. +- Ported the current EEGLAB tutorial-wrapper workflows using + deterministic generated EEG and BIDS fixtures. Coverage now follows scalp + movies, grouped ERP and N400-style STUDY analyses, spherical source fitting, + all-channel time-frequency analysis, and full face-recognition and P300 BIDS + preprocessing paths. + Picard now accepts EEGLAB-style positive and negative PCA dimensions, and + multi-dataset ICA grouping handles empty NumPy-backed BIDS session metadata. - STUDY measure designs can now use categorical factors stored per trial. With ``savetrials="on"``, ERP, spectrum, ERSP, and ITC plots select the matching trials within each dataset and then aggregate one case per subject or diff --git a/docs/source/development.rst b/docs/source/development.rst index 1410a462..dd941631 100644 --- a/docs/source/development.rst +++ b/docs/source/development.rst @@ -114,6 +114,41 @@ runtime behavior may be excluded only with a concrete technical rationale. Never replace an applicable assertion with a no-crash smoke test or broaden a numerical tolerance simply to make the port pass. +Before declaring the port complete, clone the current suite at its pinned +commit and run the source-driven audit: + +.. code-block:: bash + + git clone https://github.com/sccn/eeglab_tests.git /tmp/eeglab_tests + git -C /tmp/eeglab_tests checkout ff605546f3f70868916fb8d49c007472b3257b50 + git -C /tmp/eeglab_tests submodule update --init eeglab + uv run python -m tools.eeglab_test_port_audit /tmp/eeglab_tests + +The command discovers wrapper and regression methods directly from MATLAB, +collects ``eeglab_test`` metadata through pytest, and prints every missing or +stale reference. Pass ``--json`` for automation. It deliberately rejects the +old ``eeglab-testcases`` repository, a checkout at another commit, and +provenance that does not exist in the pinned suite. + +Tutorial-wrapper provenance +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The tutorial-wrapper ports additionally pin +``sccn/eeglab-tutorial-scripts`` commit +``58bf12dd53e894dd3ee1285946563cd94999db16``. The four tutorial references +``plot_study_erp``, ``source_reconstruction_advanced``, +``source_reconstruction_eeg``, and ``time_freq_all_elec`` are MATLAB Live +Scripts (``.mlx``) in that commit; they are not missing source files. MATLAB +can execute them, while Octave cannot execute the Live Script format. + +Although ``tutorial2_wrapperTest.test_bids_process_face_experiment`` has an +entirely commented wrapper body, its referenced Live Script is preserved as a +generated-data port rather than an empty test. The face-recognition and active +P300 workflows exercise BIDS import, preprocessing, ICA rejection, epoching, +trial-level STUDY designs, precomputation, and ERP plotting without checking the +upstream tutorial datasets into the package. The full EEGLAB datasets remain +useful for separate MATLAB parity runs. + Test Discovery -------------- diff --git a/docs/source/user_guide/ica_rejection.rst b/docs/source/user_guide/ica_rejection.rst index 0665f80b..fc4807fa 100644 --- a/docs/source/user_guide/ica_rejection.rst +++ b/docs/source/user_guide/ica_rejection.rst @@ -37,6 +37,13 @@ The default GUI offers runica, robust runica, AMICA, and Picard choices. Standalone AMICA requires an AMICA executable configured outside the Python package. +Picard accepts EEGLAB's PCA option through ``options``. A positive value is the +requested component count; a negative value subtracts dimensions from the +selected channel count. For example, ``options={"pca": -1}`` computes one +fewer component than channels, which is useful after average referencing. The +initial unmixing matrix is sized to the reduced decomposition while remaining +deterministic. + ICLabel Workflow ================ diff --git a/docs/source/user_guide/study_workflows.rst b/docs/source/user_guide/study_workflows.rst index 426bcdfb..ca2e495f 100644 --- a/docs/source/user_guide/study_workflows.rst +++ b/docs/source/user_guide/study_workflows.rst @@ -120,6 +120,25 @@ EEGLAB sidecar measure files. ``std_readdata``/``std_erpplot``/``std_erspplot`` cache contract used by scripts, so GUI and console plots slice axes and cached channel groups consistently. +Tutorial-scale workflows +------------------------ + +The current EEGLAB tutorial-wrapper workflows are covered end to end with +deterministic generated signals. This includes grouped ERP statistics and +topographies, component preclustering for an N400-style study, all-channel +time-frequency analysis, scalp movies, and a generated P300 BIDS pipeline from +export/import through filtering, average reference, rank-reduced Picard ICA, +component rejection, epoching, and STUDY plotting. Generated fixtures keep +ordinary CI independent of large tutorial downloads while retaining numerical +assertions at each processing boundary. + +Two optional boundaries remain explicit. ``pop_iclabel`` needs the ICLabel +model runtime; callers that already have compatible seven-class probabilities +can use ``pop_icflag`` and ``pop_subcomp`` independently. Dataset-level +spherical DIPFIT fitting and leadfield construction run standalone, while +MRI/BEM/LORETA workflows that require FieldTrip fail clearly until an external +source-analysis backend is configured. + Store reusable plot and statistics choices on the STUDY before plotting: .. code-block:: python diff --git a/docs/source/user_guide/visual_parity.rst b/docs/source/user_guide/visual_parity.rst index 0db14b19..4574b662 100644 --- a/docs/source/user_guide/visual_parity.rst +++ b/docs/source/user_guide/visual_parity.rst @@ -20,14 +20,17 @@ displayed. EEG Movies and Visual Workflow Boundaries ========================================= -EEGLAB's tutorial movie scripts, such as ``make_eeg_movie.m``, are treated as -visual workflow references for this epic rather than standalone EEGPrep runtime -features. EEGPrep owns static plots, ERP images, time-frequency wrappers, GUI -dialog rendering, and the visual parity capture tooling described here. It does -not currently ship an EEGLAB-compatible EEG movie generator or a persistent -movie-authoring UI. Use the plotting wrappers documented in -:ref:`preprocessing_pipeline` for analysis figures, and use this page's capture -tools for reviewable GUI evidence. +EEGPrep's ``eegmovie``, ``headmovie``, and ``seemovie`` helpers support the +analysis path used by EEGLAB's ``make_eeg_movie.m`` tutorial. ``eegmovie`` +renders replayable 2-D or 3-D scalp frames and returns RGB arrays directly, so +headless tests and notebooks can inspect or encode them without screen capture. +The public ``movieframes`` boundary remains 1-based. EEGPrep does not provide a +persistent movie-authoring UI or bundle a video encoder; pass the RGB frames to +the image or video writer appropriate for the surrounding workflow. + +The visual parity tooling on this page remains focused on interactive dialogs +and desktop state, where fixed rendering conditions are needed for meaningful +side-by-side comparison. Install the optional EEGPREP GUI dependencies before capturing Python dialogs: diff --git a/src/eegprep/functions/popfunc/eeg_picard.py b/src/eegprep/functions/popfunc/eeg_picard.py index 6b526d16..61cd3001 100644 --- a/src/eegprep/functions/popfunc/eeg_picard.py +++ b/src/eegprep/functions/popfunc/eeg_picard.py @@ -52,9 +52,11 @@ def eeg_picard(EEG, engine=None, posact='off', sortcomps='off', **kwargs): 'tol': 1e-7, # Match MATLAB python_defaults 'centering': True, # Center data before ICA 'whiten': True, # Whiten data (PCA) - 'w_init': np.eye(data.shape[0]), # Identity init for reproducibility } params.update(kwargs) + if 'w_init' not in params: + n_components = params.get('n_components') or data.shape[0] + params['w_init'] = np.eye(int(n_components)) # deterministic across full-rank and PCA runs weighting_matrix, unmixing_matrix, sources = picard(data, **params) diff --git a/src/eegprep/functions/popfunc/pop_runica.py b/src/eegprep/functions/popfunc/pop_runica.py index 87af3b1c..047b4b32 100644 --- a/src/eegprep/functions/popfunc/pop_runica.py +++ b/src/eegprep/functions/popfunc/pop_runica.py @@ -13,7 +13,7 @@ from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import CallbackSpec, ControlSpec, DialogSpec from eegprep.functions.popfunc._ica_utils import flatten_ica_data -from eegprep.functions.popfunc._pop_utils import format_history_value, is_on, parse_key_value_args +from eegprep.functions.popfunc._pop_utils import format_history_value, is_empty_value, is_on, parse_key_value_args from eegprep.functions.popfunc.eeg_amica import eeg_amica from eegprep.functions.popfunc.eeg_decodechan import eeg_decodechan from eegprep.functions.popfunc.eeg_picard import eeg_picard @@ -289,7 +289,17 @@ def _runica_by_subject_session(datasets, icatype, options, *, reorder, chanind): def _subject_session_key(EEG): subject = EEG.get("subject") session = EEG.get("session") - return ("" if subject in (None, "") else str(subject), "" if session in (None, "") else session) + return (_metadata_group_value(subject, text=True), _metadata_group_value(session)) + + +def _metadata_group_value(value, *, text=False): + if is_empty_value(value): + return "" + if isinstance(value, np.ndarray): + value = value.item() if value.size == 1 else tuple(value.ravel().tolist()) + elif isinstance(value, list): + value = tuple(value) + return str(value) if text else value def _flatten_dataset_data(EEG): @@ -398,7 +408,11 @@ def _run_ica_backend(EEG, icatype, options, *, reorder): if icatype == "runica": return eeg_runica(EEG, sortcomps=reorder, **options) if icatype == "picard": - return eeg_picard(EEG, sortcomps=reorder, **_picard_options(options)) + return eeg_picard( + EEG, + sortcomps=reorder, + **_picard_options(options, channel_count=int(EEG.get("nbchan", 0) or 0)), + ) if icatype == "runamica15": return eeg_amica(EEG, sortcomps=reorder, **_amica_options(options)) raise NotImplementedError( @@ -407,12 +421,19 @@ def _run_ica_backend(EEG, icatype, options, *, reorder): ) -def _picard_options(options): +def _picard_options(options, *, channel_count): mapped = {} for key, value in options.items(): lower_key = str(key).lower() if lower_key == "maxiter": mapped["max_iter"] = value + elif lower_key == "pca": + component_count = int(value) + if component_count < 0: + component_count += channel_count + if component_count < 1 or component_count > channel_count: + raise ValueError(f"Picard PCA dimension must be within 1..{channel_count}") + mapped["n_components"] = component_count elif lower_key == "seed": mapped["random_state"] = int(value) elif lower_key == "mode": diff --git a/src/eegprep/functions/studyfunc/std_maketrialinfo.py b/src/eegprep/functions/studyfunc/std_maketrialinfo.py index 1af892bc..60421c76 100644 --- a/src/eegprep/functions/studyfunc/std_maketrialinfo.py +++ b/src/eegprep/functions/studyfunc/std_maketrialinfo.py @@ -17,7 +17,7 @@ def std_maketrialinfo( STUDY: dict[str, Any] | None, ALLEEG: list[dict[str, Any]] | None, ) -> tuple[dict[str, Any], list[list[dict[str, Any]]]]: - """Populate trial information from each epoch's time-locking event.""" + """Populate STUDY and EEG trial information from each time-locking event.""" study, datasets = sync_study_datasets(STUDY, ALLEEG) alltrialinfo: list[list[dict[str, Any]]] = [] for index, eeg in enumerate(datasets): @@ -25,6 +25,8 @@ def std_maketrialinfo( if not rows: rows = trialinfo_from_eeg(eeg) alltrialinfo.append(rows) + if rows: + eeg["trialinfo"] = deepcopy(rows) if rows and index < len(study.get("datasetinfo") or []): study["datasetinfo"][index]["trialinfo"] = rows return study, alltrialinfo diff --git a/tests/test_eeg_autocorr.py b/tests/test_eeg_autocorr.py index 08cd0634..f80719d8 100644 --- a/tests/test_eeg_autocorr.py +++ b/tests/test_eeg_autocorr.py @@ -282,7 +282,7 @@ def test_parity_basic_autocorr(self): from eegprep.functions.popfunc.pop_loadset import pop_loadset import os - test_file = os.path.join(os.path.dirname(__file__), '..', 'data', 'eeglab_data_with_ica_tmp.set') + test_file = os.path.join(os.path.dirname(__file__), '..', 'sample_data', 'eeglab_data_with_ica_tmp.set') if not os.path.exists(test_file): self.skipTest(f"Test file not found: {test_file}") @@ -307,7 +307,7 @@ def test_parity_with_real_data(self): from eegprep.functions.popfunc.pop_loadset import pop_loadset import os - test_file = os.path.join(os.path.dirname(__file__), '..', 'data', 'eeglab_data_with_ica_tmp.set') + test_file = os.path.join(os.path.dirname(__file__), '..', 'sample_data', 'eeglab_data_with_ica_tmp.set') if not os.path.exists(test_file): self.skipTest(f"Test file not found: {test_file}") diff --git a/tests/test_eeg_autocorr_welch.py b/tests/test_eeg_autocorr_welch.py index 71c0d209..f30b6279 100644 --- a/tests/test_eeg_autocorr_welch.py +++ b/tests/test_eeg_autocorr_welch.py @@ -350,7 +350,7 @@ def test_parity_basic_autocorr_welch(self): # Load real EEG dataset with ICA from eegprep.functions.popfunc.pop_loadset import pop_loadset - test_file = os.path.join(os.path.dirname(__file__), '..', 'data', 'eeglab_data_with_ica_tmp.set') + test_file = os.path.join(os.path.dirname(__file__), '..', 'sample_data', 'eeglab_data_with_ica_tmp.set') if not os.path.exists(test_file): self.skipTest(f"Test file not found: {test_file}") @@ -378,7 +378,7 @@ def test_parity_with_real_data_welch(self): # Load real EEG dataset with ICA from eegprep.functions.popfunc.pop_loadset import pop_loadset - test_file = os.path.join(os.path.dirname(__file__), '..', 'data', 'eeglab_data_with_ica_tmp.set') + test_file = os.path.join(os.path.dirname(__file__), '..', 'sample_data', 'eeglab_data_with_ica_tmp.set') if not os.path.exists(test_file): self.skipTest(f"Test file not found: {test_file}") diff --git a/tests/test_eeg_store_storedisk.py b/tests/test_eeg_store_storedisk.py index bc38e88f..1f5c5106 100644 --- a/tests/test_eeg_store_storedisk.py +++ b/tests/test_eeg_store_storedisk.py @@ -15,6 +15,10 @@ from eegprep.functions.popfunc.pop_saveset import pop_saveset from eegprep.functions.studyfunc.pop_savestudy import pop_savestudy from eegprep.functions.studyfunc.pop_study import pop_study +from tests.eeglab_tests import eeglab_test + + +POP_STDWARN_WRAPPER = "unittesting_adminfunc/pop_stdwarn/adminfunc_pop_stdwarn_wrapperTest.m" @pytest.fixture(autouse=True) @@ -55,7 +59,11 @@ def _saved_loaded_eeg(tmp_path: Path, name: str, offset: float = 0.0) -> dict: return pop_loadset(set_file) +@eeglab_test(POP_STDWARN_WRAPPER, "test_test_pop_stdwarn") def test_eeg_store_offloads_saved_non_current_and_retrieve_rehydrates(tmp_path: Path): + # The current pop_stdwarn test comments out its modal prompt. Exercise the + # behavior the prompt guards instead: storedisk bounds resident datasets + # while keeping the selected dataset scientifically identical on retrieval. EEG_OPTIONS["option_storedisk"] = 1 alleeg, current, current_set = eeg_store([], _saved_loaded_eeg(tmp_path, "one"), 0) alleeg, current, current_set = eeg_store(alleeg, _saved_loaded_eeg(tmp_path, "two", 10), 0) diff --git a/tests/test_eeglab.py b/tests/test_eeglab.py index 2366a8fa..be549962 100644 --- a/tests/test_eeglab.py +++ b/tests/test_eeglab.py @@ -1,9 +1,16 @@ +import socket +import sys from unittest import mock import eegprep from eegprep.functions.adminfunc import eeglab as eeglab_module from eegprep.functions.guifunc.session import EEGPrepSession +from tests.eeglab_tests import eeglab_test + + +IS_SCCN_WRAPPER = "unittesting_adminfunc/is_sccn/adminfunc_is_sccn_wrapperTest.m" +IS_DEPLOYED_WRAPPER = "unittesting_adminfunc/iseeglabdeployed/adminfunc_iseeglabdeployed_wrapperTest.m" def test_eeglab_versions_and_nogui_entry_points(): @@ -13,6 +20,31 @@ def test_eeglab_versions_and_nogui_entry_points(): assert eeglab_module.eeglab("nogui", session=session, show=False) is session +@eeglab_test(IS_SCCN_WRAPPER, "test_pass_general") +def test_eegprep_startup_does_not_depend_on_an_institutional_hostname(monkeypatch): + # is_sccn only controls behavior tied to an obsolete institutional network, + # and its MATLAB test accepts either result without an assertion. EEGPrep's + # standalone startup must not consult DNS at all. + def fail_hostname_lookup(): + raise AssertionError("portable EEGPrep startup must not inspect the hostname") + + monkeypatch.setattr(socket, "gethostname", fail_hostname_lookup) + monkeypatch.setattr(socket, "getfqdn", fail_hostname_lookup) + + session = EEGPrepSession() + assert eeglab_module.eeglab("nogui", session=session, show=False) is session + + +@eeglab_test(IS_DEPLOYED_WRAPPER, "test_test_iseeglabdeployed") +def test_eegprep_startup_has_one_runtime_path_for_frozen_python(monkeypatch): + # iseeglabdeployed selects MATLAB Compiler behavior. EEGPrep has no MATLAB + # runtime branch, so a frozen Python executable uses the same session path. + monkeypatch.setattr(sys, "frozen", True, raising=False) + + session = EEGPrepSession() + assert eeglab_module.eeglab("nogui", session=session, show=False) is session + + def test_eeglab_full_mode_builds_window_without_showing(): session = EEGPrepSession() window = mock.Mock() diff --git a/tests/test_eeglab_test_port_audit.py b/tests/test_eeglab_test_port_audit.py new file mode 100644 index 00000000..f62d6c0c --- /dev/null +++ b/tests/test_eeglab_test_port_audit.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import subprocess +import textwrap +from pathlib import Path + +import pytest + +from tools.eeglab_test_port_audit import ( + AuditInputError, + CollectedReference, + MatlabTestScenario, + audit_test_ports, + compare_test_ports, + discover_matlab_test_scenarios, + format_report, + validate_suite_checkout, +) + + +def test_discovers_wrapper_regression_and_nonstandard_limo_methods(tmp_path: Path) -> None: + suite_root = _source_fixture(tmp_path) + + scenarios = discover_matlab_test_scenarios(suite_root) + + assert scenarios == { + MatlabTestScenario("regression_tests/t_regression.m", "testRegression"), + MatlabTestScenario("unittesting_limo/limo_wrapperTest.m", "limo_test1"), + MatlabTestScenario("unittesting_limo/limo_wrapperTest.m", "limo_test2"), + MatlabTestScenario("unittesting_miscfunc/example/example_wrapperTest.m", "test_alpha"), + MatlabTestScenario("unittesting_miscfunc/example/example_wrapperTest.m", "test_beta"), + } + + +def test_audit_collects_pytest_provenance_normalizes_leaf_and_reports_exact_gaps(tmp_path: Path) -> None: + suite_root = _source_fixture(tmp_path / "suite") + suite_commit = _initialize_git_checkout(suite_root) + repo_root = tmp_path / "eegprep" + _write_pytest_fixture(repo_root, suite_commit) + + report = audit_test_ports( + suite_root, + repo_root, + expected_suite_commit=suite_commit, + expected_eeglab_commit=None, + ) + + assert len(report.expected) == 5 + assert set(report.covered) == { + MatlabTestScenario("regression_tests/t_regression.m", "testRegression"), + MatlabTestScenario("unittesting_miscfunc/example/example_wrapperTest.m", "test_alpha"), + MatlabTestScenario("unittesting_miscfunc/example/example_wrapperTest.m", "test_beta"), + } + assert set(report.missing) == { + MatlabTestScenario("unittesting_limo/limo_wrapperTest.m", "limo_test1"), + MatlabTestScenario("unittesting_limo/limo_wrapperTest.m", "limo_test2"), + } + assert report.invalid_references == () + assert "Expected: 5; covered: 3; missing: 2" in format_report(report) + assert "unittesting_limo/limo_wrapperTest.m::limo_test1" in format_report(report) + + +def test_suite_validation_rejects_wrong_commit(tmp_path: Path) -> None: + suite_root = _source_fixture(tmp_path) + actual_commit = _initialize_git_checkout(suite_root) + + with pytest.raises(AuditInputError, match=f"at {actual_commit}; expected pinned commit deadbeef"): + validate_suite_checkout( + suite_root, + expected_suite_commit="deadbeef", + expected_eeglab_commit=None, + ) + + +def test_comparison_rejects_missing_stale_and_wrong_commit_provenance(tmp_path: Path) -> None: + suite_root = _source_fixture(tmp_path) + supplemental = suite_root / "supplemental.m" + supplemental.write_text("function supplementalCase(~)\n", encoding="utf-8") + expected = discover_matlab_test_scenarios(suite_root) + references = ( + CollectedReference( + source="supplemental.m", + test="supplementalCase", + suite_commit="current", + eeglab_commit="eeglab", + nodeid="tests/test_ports.py::test_supplemental", + ), + CollectedReference( + source="unittesting_miscfunc/example/removed.m", + test="test_alpha", + suite_commit="current", + eeglab_commit="eeglab", + nodeid="tests/test_ports.py::test_removed", + ), + CollectedReference( + source="unittesting_miscfunc/example/example_wrapperTest.m", + test="test_beta", + suite_commit="stale", + eeglab_commit="eeglab", + nodeid="tests/test_ports.py::test_stale_commit", + ), + ) + + report = compare_test_ports( + suite_root, + expected, + references, + suite_commit="current", + expected_eeglab_commit="eeglab", + ) + + messages = "\n".join(report.invalid_references) + assert "references a source absent from the pinned suite" in messages + assert "pins suite commit stale, expected current" in messages + assert not report.ok + + +def _source_fixture(root: Path) -> Path: + wrapper_root = root / "unittesting_miscfunc/example" + wrapper_root.mkdir(parents=True) + (wrapper_root / "example_wrapperTest.m").write_text( + textwrap.dedent( + """\ + function tests = example_wrapperTest + tests = functiontests(localfunctions); + + function test_alpha(~) + alpha + + function test_beta(~) + beta + + function helper_not_a_test(~) + """ + ), + encoding="utf-8", + ) + (wrapper_root / "alpha.m").write_text("function alpha()\n", encoding="utf-8") + (wrapper_root / "beta.m").write_text("function beta()\n", encoding="utf-8") + + regression_root = root / "regression_tests" + regression_root.mkdir() + (regression_root / "t_regression.m").write_text( + textwrap.dedent( + """\ + classdef t_regression + methods (Test) + function testRegression(~) + end + end + methods + function helper(~) + end + end + end + """ + ), + encoding="utf-8", + ) + + limo_root = root / "unittesting_limo" + limo_root.mkdir() + (limo_root / "limo_wrapperTest.m").write_text( + textwrap.dedent( + """\ + function tests = limo_wrapperTest + tests = functiontests(localfunctions); + + function limo_test1(~) + + function limo_test2(~) + """ + ), + encoding="utf-8", + ) + return root + + +def _initialize_git_checkout(suite_root: Path) -> str: + _git(suite_root, "init") + _git(suite_root, "config", "user.email", "tests@example.com") + _git(suite_root, "config", "user.name", "EEGPrep Tests") + _git(suite_root, "remote", "add", "origin", "https://github.com/sccn/eeglab_tests.git") + _git(suite_root, "add", ".") + _git(suite_root, "commit", "-m", "fixture") + return _git(suite_root, "rev-parse", "HEAD") + + +def _write_pytest_fixture(repo_root: Path, suite_commit: str) -> None: + package_root = repo_root / "src/eegprep" + package_root.mkdir(parents=True) + (package_root / "__init__.py").write_text('AUDIT_SENTINEL = "requested checkout"\n', encoding="utf-8") + tests_root = repo_root / "tests" + tests_root.mkdir(parents=True) + (tests_root / "test_ports.py").write_text( + textwrap.dedent( + f"""\ + from dataclasses import dataclass + import eegprep + + assert eegprep.AUDIT_SENTINEL == "requested checkout" + + @dataclass(frozen=True) + class Reference: + source: str + test: str + suite_commit: str = {suite_commit!r} + eeglab_commit: str = "fixture-eeglab" + + def eeglab_test(source, test): + def decorate(function): + function.__eeglab_test_references__ = (Reference(source, test),) + return function + return decorate + + @eeglab_test("unittesting_miscfunc/example/alpha.m", "test_alpha") + def test_leaf_provenance(): + pass + + @eeglab_test("unittesting_miscfunc/example/example_wrapperTest.m", "test_beta") + def test_wrapper_provenance(): + pass + + @eeglab_test("regression_tests/t_regression.m", "testRegression") + def test_regression_provenance(): + pass + """ + ), + encoding="utf-8", + ) + + +def _git(repository: Path, *arguments: str) -> str: + completed = subprocess.run( + ["git", "-C", str(repository), *arguments], + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() diff --git a/tests/test_envtopo_parity.py b/tests/test_envtopo_parity.py index ed32b292..990528fe 100644 --- a/tests/test_envtopo_parity.py +++ b/tests/test_envtopo_parity.py @@ -32,6 +32,7 @@ os.environ["OPENBLAS_NUM_THREADS"] = "1" os.environ["VECLIB_MAXIMUM_THREADS"] = "1" +import importlib import tempfile import unittest @@ -44,7 +45,7 @@ import scipy.io from eegprep import pop_loadset, pop_saveset -from eegprep.functions.adminfunc.eeglabcompat import get_eeglab +from eegprep.functions.adminfunc.eeglabcompat import _resolve_eeglab_root from eegprep.functions.miscfunc.misc import finite_matmul from eegprep.functions.sigprocfunc.envtopo import envtopo @@ -85,11 +86,25 @@ def _matlab_options(options): class TestEnvtopoParity(unittest.TestCase): """Parity between Python and MATLAB envtopo component-contribution outputs.""" - def setUp(self): + @classmethod + def setUpClass(cls): + engine = None try: - self.eeglab = get_eeglab("MAT", auto_file_roundtrip=False) + matlab_engine = importlib.import_module("matlab.engine") + engine = matlab_engine.start_matlab() + eeglab_root = _resolve_eeglab_root() + engine.addpath(engine.genpath(str(eeglab_root)), nargout=0) except Exception as e: - self.skipTest(f"MATLAB/EEGLAB not available: {e}") + if engine is not None: + engine.quit() + raise unittest.SkipTest(f"MATLAB/EEGLAB not available: {e}") from e + cls.eeglab = engine + + @classmethod + def tearDownClass(cls): + cls.eeglab.quit() + + def setUp(self): # 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) diff --git a/tests/test_erpimage.py b/tests/test_erpimage.py index 4dbbd981..af01e2ee 100644 --- a/tests/test_erpimage.py +++ b/tests/test_erpimage.py @@ -112,3 +112,31 @@ def test_erpimage_expands_eeglab_compact_time_specification() -> None: np.testing.assert_allclose(fig.axes[0].get_xlim(), [0.0, 3000.0]) np.testing.assert_allclose(fig.axes[-1].lines[0].get_xdata(), [0.0, 1000.0, 2000.0, 3000.0]) plt.close(fig) + + +@eeglab_test(_ERPIMAGE_SOURCE, "test_todo_bugzilla_326") +def test_erpimage_sorts_trials_when_event_latency_lines_are_drawn() -> None: + # The upstream TODO reports a silent sorting failure when sort values and + # event lines are combined; its body never executes. Assert both effects + # together so the historical regression cannot return silently. + data = np.asarray( + [ + [10.0, 20.0, 30.0], + [11.0, 21.0, 31.0], + [12.0, 22.0, 32.0], + [13.0, 23.0, 33.0], + ] + ) + + figure, image = erpimage( + data, + times=[-100.0, 0.0, 100.0, 200.0], + sort_values=[30.0, 10.0, 20.0], + vert=[0.0], + cbar=False, + ) + + np.testing.assert_array_equal(image[:, 0], [20.0, 30.0, 10.0]) + image_axis = next(axis for axis in figure.axes if axis.images) + assert any(np.allclose(line.get_xdata(), [0.0, 0.0]) for line in image_axis.lines) + plt.close(figure) diff --git a/tests/test_guifunc_primitives.py b/tests/test_guifunc_primitives.py index 4068adc7..d12194a2 100644 --- a/tests/test_guifunc_primitives.py +++ b/tests/test_guifunc_primitives.py @@ -14,6 +14,8 @@ ERRORDLG2_WRAPPER = "unittesting_guifunc/errordlg2/guifunc_errordlg2_wrapperTest.m" +EEGLAB_ERROR_WRAPPER = "unittesting_adminfunc/eeglab_error/adminfunc_eeglab_error_wrapperTest.m" +GETTEXT_WRAPPER = "unittesting_adminfunc/gettext/adminfunc_gettext_wrapperTest.m" INPUTDLG2_WRAPPER = "unittesting_guifunc/inputdlg2/guifunc_inputdlg2_wrapperTest.m" INPUTGUI_WRAPPER = "unittesting_guifunc/inputgui/guifunc_inputgui_wrapperTest.m" LISTDLG2_WRAPPER = "unittesting_guifunc/listdlg2/guifunc_listdlg2_wrapperTest.m" @@ -267,7 +269,11 @@ def test_inputdlg2_rejects_mismatched_prompts_and_defaults(): @eeglab_test(INPUTDLG2_WRAPPER, "test_i_pass_general") @eeglab_test("unittesting_guifunc/inputdlg2/i_pass_general.m", "test_i_pass_general") +@eeglab_test(GETTEXT_WRAPPER, "test_i_pass_general") def test_inputdlg2_returns_answers_in_prompt_order(): + # The current gettext payload is entirely commented out because it waits on + # a MATLAB global. Its maintained intent is deterministic text entry and + # cancellation, which EEGPrep provides through the renderer-backed dialog. renderer = RecordingRenderer({"answer0": "this", "answer1": "that"}) answer = inputdlg2( @@ -318,7 +324,11 @@ def qt_widgets(): @eeglab_test(ERRORDLG2_WRAPPER, "test_i_pass_general") @eeglab_test("unittesting_guifunc/errordlg2/i_pass_general.m", "test_i_pass_general") +@eeglab_test(EEGLAB_ERROR_WRAPPER, "test_i_pass_general") def test_errordlg2_builds_a_critical_message_with_requested_text(qt_widgets): + # eeglab_error's current test payload comments out the caught-error setup + # and manual OK click. Assert that its maintained endpoint is a modal, + # critical error message with the original explanation intact. _app, dialog = build_errordlg2("Explanation of error", "testcase for errordlg2") assert dialog.text() == "Explanation of error" diff --git a/tests/test_phase1b_file_edit_pop_functions.py b/tests/test_phase1b_file_edit_pop_functions.py index 28c100fc..944849ab 100644 --- a/tests/test_phase1b_file_edit_pop_functions.py +++ b/tests/test_phase1b_file_edit_pop_functions.py @@ -25,14 +25,18 @@ from tests.eeglab_tests import eeglab_test from tests.fixtures import SAMPLE_DATASET_PATH, matlab_engine_available -EEGLAB_REFERENCE_ROOT = Path(__file__).resolve().parents[1] / "src" / "eegprep" / "eeglab" - def eeglab_reference_available() -> bool: - return ( - (EEGLAB_REFERENCE_ROOT / "functions" / "popfunc" / "pop_selectevent.m").exists() - and (EEGLAB_REFERENCE_ROOT / "functions" / "popfunc" / "pop_mergeset.m").exists() - and (EEGLAB_REFERENCE_ROOT / "plugins" / "clean_rawdata" / "private").is_dir() + repo_root = Path(__file__).resolve().parents[1] + candidates = [] + if os.environ.get("EEGPREP_EEGLAB_ROOT"): + candidates.append(Path(os.environ["EEGPREP_EEGLAB_ROOT"]).expanduser()) + candidates.append(repo_root / "src" / "eegprep" / "eeglab") + return any( + (candidate / "functions" / "popfunc" / "pop_selectevent.m").exists() + and (candidate / "functions" / "popfunc" / "pop_mergeset.m").exists() + and (candidate / "plugins" / "clean_rawdata" / "private").is_dir() + for candidate in candidates ) diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index a7874cdd..e8809531 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -2140,6 +2140,11 @@ def _matlab_vector(values: list[float]) -> str: def _eeglab_reference_root() -> Path: repo_root = Path(__file__).resolve().parents[1] + configured_reference = os.environ.get("EEGPREP_EEGLAB_ROOT") + if configured_reference: + configured_root = Path(configured_reference).expanduser() + if (configured_root / "functions" / "popfunc" / "pop_headplot.m").exists(): + return configured_root package_reference = repo_root / "src" / "eegprep" / "eeglab" if (package_reference / "functions" / "popfunc" / "pop_headplot.m").exists(): return package_reference diff --git a/tests/test_public_api_examples.py b/tests/test_public_api_examples.py index aa7ec543..946fcf33 100644 --- a/tests/test_public_api_examples.py +++ b/tests/test_public_api_examples.py @@ -9,6 +9,8 @@ import pytest +from tests.eeglab_tests import eeglab_test + try: import tomllib except ModuleNotFoundError: # pragma: no cover - Python < 3.11 @@ -16,9 +18,16 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +MAKEHTML_OUTPUT_WRAPPER = "unittesting_miscfunc/makehtml/output/miscfunc_makehtml_output_wrapperTest.m" +@eeglab_test(MAKEHTML_OUTPUT_WRAPPER, "test_Contents") +@eeglab_test(MAKEHTML_OUTPUT_WRAPPER, "test_eeglab") def test_public_api_and_plugins_example_runs() -> None: + # These MATLAB methods execute generated documentation stubs: Contents.m is + # comments only and eeglab.m opens callback-string help links. EEGPrep's + # Sphinx replacement keeps its top-level example executable against the + # installed public API, which is the portable behavior worth preserving. example = REPO_ROOT / "docs/source/examples/plot_public_api_and_plugins.py" runpy.run_path(str(example), run_name="__main__") diff --git a/tests/test_rejection_workflows.py b/tests/test_rejection_workflows.py index 8e660e24..bf0c2804 100644 --- a/tests/test_rejection_workflows.py +++ b/tests/test_rejection_workflows.py @@ -33,6 +33,7 @@ from eegprep.functions.popfunc.pop_rejspec import pop_rejspec from eegprep.functions.popfunc.pop_rejtrend import pop_rejtrend from eegprep.functions.popfunc.pop_selectcomps import pop_selectcomps +from eegprep.functions.sigprocfunc.eegplot import eegplot from eegprep.plugins.ICLabel.pop_viewprops import pop_viewprops from tests.eeglab_tests import eeglab_test from tests.fixtures import SAMPLE_DATASET_PATH, create_test_eeg @@ -161,6 +162,39 @@ def test_rejection_statistics_store_data_and_component_marks(): assert "icarejjp" in comp_out["reject"] +@eeglab_test( + "unittesting_sigprocfunc/eegplot/sigprocfunc_eegplot_wrapperTest.m", + "test_todo_bugzilla_354", +) +def test_eegplot_accepts_epoched_data_after_spectral_rejection_marks(): + # The upstream TODO describes eegplot failing after abnormal-spectrum + # rejection, but its payload is fully commented and calls unrelated + # pop_biosig. Preserve the reported workflow as an executable regression. + eeg = _epoched_eeg() + marked, rejected = pop_rejspec( + eeg, + 1, + "method", + "fft", + "elecrange", + [3], + "threshold", + [-10, 10], + "freqlimits", + [20, 30], + "eegplotreject", + 0, + ) + + model = eegplot(marked, show=False) + + assert rejected + assert marked["reject"]["rejfreq"].any() + assert model.data.mode == "epoched" + assert model.data.data.shape == marked["data"].shape + assert model.data.total_samples == marked["pnts"] * marked["trials"] + + @pytest.mark.parametrize( ("runner", "field"), [ diff --git a/tests/test_study_long_tail_helpers.py b/tests/test_study_long_tail_helpers.py index 786245f1..830ba5f4 100644 --- a/tests/test_study_long_tail_helpers.py +++ b/tests/test_study_long_tail_helpers.py @@ -77,6 +77,7 @@ def test_independent_variable_selection_and_trialinfo_queries_are_1_based(): assert rare_trials == [[1, 3], [3], [1, 2]] assert rt_trials == [1, 3] assert rt_values == [[320.0, 370.0]] + assert [eeg["trialinfo"] for eeg in alleeg] == alltrialinfo assert [row["condition"] for row in combined] == ["target", "target", "target"] assert [row["type"] for row in combined] == ["rare", "standard", "rare"] diff --git a/tests/test_tutorial_eeglab_tests.py b/tests/test_tutorial_eeglab_tests.py index d51f2d08..ceecc8ed 100644 --- a/tests/test_tutorial_eeglab_tests.py +++ b/tests/test_tutorial_eeglab_tests.py @@ -1,4 +1,9 @@ -"""Generated-data ports of current EEGLAB tutorial workflows.""" +"""Generated-data ports of current EEGLAB tutorial workflows. + +Upstream suite: sccn/eeglab_tests@ff605546f3f70868916fb8d49c007472b3257b50 +EEGLAB tree: sccn/eeglab@8ac485f654d6bbb1a6acb8dc9ef3f2eaf3d409ba +Tutorial scripts: sccn/eeglab-tutorial-scripts@58bf12dd53e894dd3ee1285946563cd94999db16 +""" from __future__ import annotations @@ -10,26 +15,67 @@ from matplotlib import pyplot as plt import numpy as np +import pytest from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset from eegprep.functions.adminfunc.eeg_store import eeg_store +from eegprep.functions.miscfunc.eegmovie import eegmovie from eegprep.functions.popfunc.pop_comments import pop_comments from eegprep.functions.popfunc.pop_eegfilt import pop_eegfilt from eegprep.functions.popfunc.pop_epoch import pop_epoch from eegprep.functions.popfunc.pop_newset import pop_newset +from eegprep.functions.popfunc.pop_newtimef import pop_newtimef from eegprep.functions.popfunc.pop_resample import pop_resample from eegprep.functions.popfunc.pop_reref import pop_reref from eegprep.functions.popfunc.pop_rmbase import pop_rmbase +from eegprep.functions.popfunc.pop_runica import pop_runica from eegprep.functions.popfunc.pop_saveset import pop_saveset +from eegprep.functions.popfunc.pop_select import pop_select +from eegprep.functions.popfunc.pop_subcomp import pop_subcomp from eegprep.functions.popfunc.pop_topoplot import pop_topoplot +from eegprep.functions.sigprocfunc.plotcurve import plotcurve +from eegprep.functions.sigprocfunc.cart2topo import cart2topo +from eegprep.functions.sigprocfunc.topoplot import topoplot +from eegprep.functions.studyfunc.pop_clust import pop_clust +from eegprep.functions.studyfunc.pop_erpparams import pop_erpparams +from eegprep.functions.studyfunc.pop_statparams import pop_statparams +from eegprep.functions.studyfunc.pop_study import pop_study from eegprep.functions.studyfunc.std_editset import std_editset +from eegprep.functions.studyfunc.std_erpplot import std_erpplot from eegprep.functions.studyfunc.std_makedesign import std_makedesign from eegprep.functions.studyfunc.std_maketrialinfo import std_maketrialinfo +from eegprep.functions.studyfunc.std_preclust import std_preclust +from eegprep.functions.studyfunc.std_precomp import std_precomp +from eegprep.plugins.clean_rawdata.pop_clean_rawdata import pop_clean_rawdata +from eegprep.plugins.dipfit._fitting import leadfield_matrix +from eegprep.plugins.dipfit._utils import DIPFITUnavailableError +from eegprep.plugins.dipfit.pop_dipfit_loreta import pop_dipfit_loreta +from eegprep.plugins.dipfit.pop_dipfit_settings import pop_dipfit_settings +from eegprep.plugins.dipfit.pop_dipplot import pop_dipplot +from eegprep.plugins.dipfit.pop_leadfield import pop_leadfield +from eegprep.plugins.dipfit.pop_multifit import pop_multifit +from eegprep.plugins.EEG_BIDS.pop_exportbids import pop_exportbids +from eegprep.plugins.EEG_BIDS.pop_importbids import pop_importbids +from eegprep.plugins.ICLabel.pop_icflag import pop_icflag from tests.eeglab_tests import eeglab_test -from tests.fixtures import create_test_eeg +from tests.fixtures import create_test_eeg, create_test_eeg_with_ica TUTORIAL_WRAPPER = "unittesting_tutorial/tutorial_wrapperTest.m" +TUTORIAL2_WRAPPER = "unittesting_tutorial/tutorial2_wrapperTest.m" +CONDITIONS = ("standard", "oddball_with_reponse") +FACE_EVENT_TYPES = ("famous_new", "scrambled_new", "unfamiliar_new") +ICLABEL_THRESHOLDS = np.asarray( + [ + [np.nan, np.nan], + [0.9, 1.0], + [0.9, 1.0], + [np.nan, np.nan], + [np.nan, np.nan], + [np.nan, np.nan], + [np.nan, np.nan], + ] +) def _continuous_tutorial_eeg() -> dict: @@ -55,6 +101,230 @@ def _continuous_tutorial_eeg() -> dict: return eeg +def _epoched_tutorial_eeg(subject: str, condition: str, *, subject_index: int) -> dict: + srate = 64.0 + pnts = 80 + trials = 4 + channel_count = 6 + component_count = 3 + seconds = np.arange(pnts, dtype=float) / srate - 0.25 + target_scale = 2.0 if condition == "target" else 0.45 + subject_scale = 1.0 + 0.12 * subject_index + response = np.exp(-0.5 * ((seconds - 0.3) / 0.055) ** 2) + data = np.empty((channel_count, pnts, trials), dtype=float) + activations = np.empty((component_count, pnts, trials), dtype=float) + for trial in range(trials): + phase = trial * np.pi / 10 + for channel in range(channel_count): + oscillation = 0.2 * np.sin(2 * np.pi * (6 + channel) * seconds + phase) + data[channel, :, trial] = oscillation + subject_scale * target_scale * (1 - 0.08 * channel) * response + for component in range(component_count): + activations[component, :, trial] = ( + np.sin(2 * np.pi * (7 + 2 * component) * seconds + phase) + 0.15 * target_scale * response + ) + mixing = np.asarray( + [ + [1.0, 0.2, -0.1], + [0.7, -0.3, 0.4], + [0.5, 0.6, -0.2], + [0.2, 0.8, 0.3], + [-0.2, 0.4, 0.9], + [0.1, -0.5, 0.7], + ] + ) + eeg = create_test_eeg( + n_channels=channel_count, + n_samples=pnts, + n_trials=trials, + srate=srate, + ) + zero_sample = int(round(-seconds[0] * srate)) + 1 + events = [ + { + "type": condition, + "latency": float(trial * pnts + zero_sample), + "epoch": trial + 1, + "urevent": trial + 1, + } + for trial in range(trials) + ] + eeg.update( + { + "setname": f"{subject}_{condition}", + "subject": subject, + "condition": condition, + "group": "control", + "session": 1, + "run": 1, + "data": data, + "xmin": float(seconds[0]), + "xmax": float(seconds[-1]), + "times": seconds * 1000.0, + "event": events, + "urevent": [{key: value for key, value in event.items() if key != "epoch"} for event in events], + "epoch": [{"event": [trial], "eventtype": [condition]} for trial in range(trials)], + "icaact": activations, + "icawinv": mixing, + "icaweights": np.linalg.pinv(mixing), + "icasphere": np.eye(channel_count), + "icachansind": list(range(channel_count)), + } + ) + labels = ("Fz", "Cz", "Pz", "Oz", "F3", "F4") + for channel, label in zip(eeg["chanlocs"], labels): + channel["labels"] = label + channel["Z"] = 0.5 + return eeg + + +def _tutorial_study() -> tuple[dict, list[dict]]: + datasets = [ + _epoched_tutorial_eeg(f"S{subject:02d}", condition, subject_index=subject) + for subject in range(1, 4) + for condition in ("standard", "target") + ] + study, datasets = pop_study(None, datasets, name="Generated N400 study") + study = std_makedesign( + study, + datasets, + 1, + name="Condition", + variable1="condition", + values1=["standard", "target"], + subjselect=["S01", "S02", "S03"], + ) + return study, datasets + + +def _known_source_eeg() -> tuple[dict, np.ndarray]: + eeg = create_test_eeg_with_ica(n_channels=18, n_samples=80, n_components=1, n_trials=4, srate=64.0) + polar = np.linspace(0.35, np.pi - 0.35, 3) + azimuth = np.linspace(0, 2 * np.pi, 6, endpoint=False) + positions = np.asarray( + [ + [ + 85.0 * np.sin(phi) * np.cos(theta), + 85.0 * np.sin(phi) * np.sin(theta), + 85.0 * np.cos(phi), + ] + for phi in polar + for theta in azimuth + ] + ) + theta, radius, _x, _y, _z = cart2topo(positions) + eeg["chanlocs"] = [ + { + "labels": f"E{index + 1}", + "type": "EEG", + "X": float(position[0]), + "Y": float(position[1]), + "Z": float(position[2]), + "theta": float(theta[index]), + "radius": float(radius[index]), + } + for index, position in enumerate(positions) + ] + source = np.asarray([10.0, -20.0, 40.0]) + moment = np.asarray([0.6, -0.2, 0.8]) + topography = leadfield_matrix(positions, source)[0] @ moment + eeg["icawinv"] = topography[:, np.newaxis] + eeg["icaweights"] = np.linalg.pinv(eeg["icawinv"]) + eeg["icasphere"] = np.eye(eeg["nbchan"]) + eeg["icachansind"] = np.arange(eeg["nbchan"]) + times = np.arange(eeg["pnts"], dtype=float) / eeg["srate"] - 0.25 + response = np.exp(-0.5 * ((times - 0.1) / 0.025) ** 2) + eeg["data"] = topography[:, np.newaxis, np.newaxis] * response[np.newaxis, :, np.newaxis] + eeg["data"] = np.repeat(eeg["data"], eeg["trials"], axis=2) + eeg["times"] = times * 1000.0 + eeg["xmin"] = float(times[0]) + eeg["xmax"] = float(times[-1]) + eeg, _command = pop_dipfit_settings(eeg, model="standardBESA", return_com=True) + return eeg, source + + +def _bids_continuous_eeg(subject_index: int) -> dict: + srate = 64.0 + pnts = 768 + eeg = create_test_eeg(n_channels=8, n_samples=pnts, srate=srate) + seconds = np.arange(pnts, dtype=float) / srate + rng = np.random.default_rng(100 + subject_index) + sources = np.stack( + [ + np.sin(2 * np.pi * 6 * seconds), + np.sin(2 * np.pi * 10 * seconds + 0.1), + 0.2 * np.sin(2 * np.pi * 0.2 * seconds), + rng.normal(scale=0.1, size=pnts), + np.sin(2 * np.pi * 14 * seconds), + np.cos(2 * np.pi * 8 * seconds), + ] + ) + mixing = rng.normal(size=(8, 6)) + data = np.sum(mixing[:, :, np.newaxis] * sources[np.newaxis, :, :], axis=1) + event_types = (CONDITIONS * 3)[:6] + events = [] + for index, event_type in enumerate(event_types): + latency = 97 + index * 105 + events.append({"type": event_type, "latency": float(latency), "duration": 0.0, "urevent": index + 1}) + if event_type == "oddball_with_reponse": + center = latency - 1 + round(0.3 * srate) + response = np.exp(-0.5 * ((np.arange(pnts) - center) / (0.06 * srate)) ** 2) + data[0] += 4.0 * response + eeg.update( + { + "data": data, + "subject": f"S{subject_index:02d}", + "setname": f"S{subject_index:02d}_P300", + "event": events, + "urevent": [dict(event) for event in events], + "dipfit": np.asarray([]), + "eventdescription": np.asarray([]), + "epochdescription": np.asarray([]), + "etc": {"generated_fixture": True}, + } + ) + labels = ("Fz", "Cz", "Pz", "Oz", "F3", "F4", "EOG1", "EOG2") + for channel, label in zip(eeg["chanlocs"], labels): + channel["labels"] = label + return eeg + + +def _bids_face_eeg(subject_index: int) -> dict: + eeg = _bids_continuous_eeg(subject_index) + data = np.asarray(eeg["data"]).copy() + event_types = FACE_EVENT_TYPES * 2 + amplitudes = {"famous": 5.0, "scrambled": 0.5, "unfamiliar": 2.5} + events = [] + for index, event_type in enumerate(event_types): + latency = 97 + index * 105 + face_type = event_type.split("_", maxsplit=1)[0] + events.append({"type": event_type, "latency": float(latency), "duration": 0.0, "urevent": index + 1}) + center = latency - 1 + round(0.17 * eeg["srate"]) + response = np.exp(-0.5 * ((np.arange(eeg["pnts"]) - center) / (0.045 * eeg["srate"])) ** 2) + data[0] += amplitudes[face_type] * response + eeg.update( + { + "data": data, + "setname": f"S{subject_index:02d}_FaceRecognition", + "event": events, + "urevent": [dict(event) for event in events], + } + ) + return eeg + + +def _add_face_type_to_bids_events(root: Path) -> None: + for path in root.rglob("*_events.tsv"): + lines = path.read_text(encoding="utf-8").splitlines() + header = lines[0].split("\t") + trial_type_index = header.index("trial_type") + rows = [line.split("\t") for line in lines[1:]] + output = ["\t".join([*header, "face_type"])] + for row in rows: + face_type = row[trial_type_index].split("_", maxsplit=1)[0] + output.append("\t".join([*row, face_type])) + path.write_text("\n".join(output) + "\n", encoding="utf-8") + + @eeglab_test(TUTORIAL_WRAPPER, "test_eeglab_history") def test_eeglab_history_tutorial_runs_as_replayable_generated_data_pipeline(): eeg = _continuous_tutorial_eeg() @@ -196,3 +466,501 @@ def test_event_processing_study_exposes_derived_reaction_time_as_a_design_variab assert study["design"][0]["variable"][0]["label"] == "rt" assert study["design"][0]["variable"][0]["vartype"] == "continuous" assert study["datasetinfo"][0]["trialinfo"] == trialinfo[0] + + +@eeglab_test(TUTORIAL_WRAPPER, "test_make_eeg_movie") +def test_make_eeg_movie_smooths_an_erp_and_renders_2d_and_3d_frames(): + eeg = _epoched_tutorial_eeg("S01", "target", subject_index=1) + window = (np.asarray(eeg["times"]) >= -100) & (np.asarray(eeg["times"]) <= 600) + scalp_erp = np.mean(np.asarray(eeg["data"])[:, window, :], axis=2) + smoothed = np.vstack([np.convolve(channel, np.ones(5) / 5, mode="same") for channel in scalp_erp]) + frames = [1, smoothed.shape[1] // 2, smoothed.shape[1]] + + movie_2d, colormap_2d = eegmovie( + smoothed, + eeg["srate"], + eeg["chanlocs"], + movieframes=frames, + framenum="off", + startsec=-0.1, + vert=[0], + topoplotopt={"numcontour": 0}, + plot="off", + ) + movie_3d, colormap_3d = eegmovie( + smoothed, + eeg["srate"], + eeg["chanlocs"], + movieframes=[frames[1]], + mode="3d", + timecourse="off", + framenum="off", + startsec=-0.1, + headplotopt={"lighting": "off"}, + plot="off", + ) + + assert np.var(np.diff(smoothed, axis=1)) < np.var(np.diff(scalp_erp, axis=1)) + assert movie_2d.shape[0] == 3 and movie_2d.shape[-1] == 3 + assert movie_3d.shape[0] == 1 and movie_3d.shape[-1] == 3 + assert movie_2d.dtype == movie_3d.dtype == np.uint8 + assert not np.array_equal(movie_2d[0], movie_2d[-1]) + np.testing.assert_allclose(colormap_2d, colormap_3d) + plt.close("all") + + +@eeglab_test(TUTORIAL_WRAPPER, "test_plot_study_erp") +def test_plot_study_erp_precomputes_grouped_conditions_statistics_and_topographies(): + study, alleeg = _tutorial_study() + study = pop_erpparams(study, timerange=[-200, 800], plotconditions="together") + study, alleeg = std_precomp( + study, + alleeg, + "channels", + erp="on", + recompute="on", + erpparams={"rmbase": [-250, 0]}, + ) + study = pop_statparams(study, condstats="on", method="param", mcorrect="fdr", alpha=np.nan) + + result = std_erpplot(study, alleeg, channels=["Pz"], return_stats=True, plotstderr="on") + _study, cells, times, _pgroup, pcond, _pinter, figure = result + standard, target = cells + response_window = (times >= 250) & (times <= 350) + difference = np.mean(target[response_window] - standard[response_window], axis=0) + + assert standard.shape == target.shape == (times.size, 3) + assert np.all(difference > 1.0) + assert np.asarray(pcond[0]).shape == times.shape + assert np.isfinite(np.asarray(pcond[0])[response_window]).all() + assert len(figure.axes) == 1 + + stderr = np.std(target, axis=1, ddof=1) / np.sqrt(target.shape[1]) + curve_figure, curve_axis = plt.subplots() + plotcurve( + times, + target.T, + target=curve_axis, + plotmean="on", + plotindiv="off", + plotstderr=stderr, + title="Generated target ERP", + ) + assert len(curve_axis.lines) == 1 + assert curve_axis.collections + + _study, topo_cells, topo_times, topo_figure = std_erpplot( + study, + alleeg, + channels="channels", + condstats="off", + topotime=[250, 350], + ) + assert topo_cells[0].shape == (topo_times.size, 6, 3) + assert any(axis.collections for axis in topo_figure.axes) + plt.close(figure) + plt.close(curve_figure) + plt.close(topo_figure) + + +@eeglab_test(TUTORIAL_WRAPPER, "test_source_reconstruction_eeg") +def test_source_reconstruction_eeg_localizes_an_erp_topography_and_plots_the_result(): + eeg, true_source = _known_source_eeg() + latency_index = int(np.argmin(np.abs(np.asarray(eeg["times"]) - 100.0))) + erp_topography = np.mean(np.asarray(eeg["data"])[:, latency_index, :], axis=1) + correlation = np.corrcoef(erp_topography, np.asarray(eeg["icawinv"])[:, 0])[0, 1] + + fitted, command = pop_multifit(eeg, [1], threshold=100, return_com=True) + model = fitted["dipfit"]["model"][0] + dipole_figures = pop_dipplot(fitted, [1], normlen="on", plot=True) + topo_figures = pop_topoplot(fitted, 0, [1], "ERP 100 ms single-dipole fit", [1, 1], 0, plot="off") + + assert correlation > 0.999999 + assert np.isfinite(model["rv"]) and model["rv"] < 0.05 + assert np.linalg.norm(np.asarray(model["posxyz"])[0] - true_source) < 25.0 + assert np.linalg.norm(np.asarray(model["posxyz"])[0]) < 85.0 + assert len(dipole_figures) == len(topo_figures) == 1 + assert "pop_multifit" in command + plt.close("all") + + +@eeglab_test(TUTORIAL_WRAPPER, "test_source_reconstruction_advanced") +def test_source_reconstruction_advanced_builds_a_forward_model_and_exposes_fieldtrip_boundary(): + eeg, _true_source = _known_source_eeg() + eeg = pop_dipfit_settings(eeg, model="standardBEM") + source_points = [[-25, 0, 35], [10, -20, 40], [20, 20, 30]] + with_leadfield, command = pop_leadfield(eeg, sourcemodel={"pos": source_points}, return_com=True) + leadfields = [np.asarray(value) for value in with_leadfield["dipfit"]["sourcemodel"]["leadfield"]] + moment = np.asarray([0.5, -0.25, 0.75]) + observation = leadfields[1] @ moment + residuals = [] + for leadfield in leadfields: + fitted_moment = np.linalg.lstsq(leadfield, observation, rcond=None)[0] + residuals.append(np.linalg.norm(observation - leadfield @ fitted_moment)) + + assert all(leadfield.shape == (18, 3) for leadfield in leadfields) + np.testing.assert_allclose([np.mean(leadfield, axis=0) for leadfield in leadfields], 0.0, atol=1e-12) + assert int(np.argmin(residuals)) == 1 + assert "pop_leadfield" in command + with pytest.raises(DIPFITUnavailableError, match="FieldTrip"): + pop_dipfit_loreta(with_leadfield, [1], gui=False) + + +@eeglab_test(TUTORIAL_WRAPPER, "test_study_script") +def test_study_script_runs_n400_measure_statistics_and_component_clustering_workflow(): + study, alleeg = _tutorial_study() + study, alleeg, channel_command = std_precomp( + study, + alleeg, + "channels", + erp="on", + spec="on", + recompute="on", + erpparams={"rmbase": [-250, 0]}, + return_com=True, + ) + study, alleeg, component_command = std_precomp( + study, + alleeg, + "components", + erp="on", + spec="on", + scalp="on", + recompute="on", + erpparams={"rmbase": [-250, 0]}, + return_com=True, + ) + study, alleeg, precluster_command = std_preclust( + study, + alleeg, + 1, + ["spec", "npca", 2, "weight", 1, "freqrange", [3, 25]], + ["erp", "npca", 2, "weight", 1, "timewindow", [100, 600]], + ["scalp", "npca", 2, "weight", 1], + return_com=True, + ) + study, cluster_command = pop_clust(study, alleeg, clus_num=2, random_state=17, return_com=True) + study = pop_statparams(study, condstats="on", method="param", mcorrect="fdr", alpha=np.nan) + _study, erpdata, times, _pgroup, pcond, _pinter, figure = std_erpplot( + study, + alleeg, + channels=["Pz"], + timerange=[-200, 800], + return_stats=True, + ) + + assert study["changrp"][0]["measureinfo"]["computed"] == ["erp", "spec"] + assert study["cluster"][0]["measureinfo"]["computed"] == ["erp", "spec"] + assert np.asarray(study["etc"]["preclust"]["preclustdata"]).shape[0] == 18 + assert len(study["cluster"][1:]) == 2 + assert sum(len(cluster["comps"]) for cluster in study["cluster"][1:]) == 18 + assert erpdata[0].shape == erpdata[1].shape == (times.size, 3) + assert np.asarray(pcond[0]).shape == times.shape + for command in (channel_command, component_command, precluster_command, cluster_command): + assert command + plt.close(figure) + + +@eeglab_test(TUTORIAL_WRAPPER, "test_time_freq_all_elec") +def test_time_freq_all_electrodes_preserves_trial_power_and_spatial_axes(): + eeg = _epoched_tutorial_eeg("S01", "target", subject_index=1) + results = [ + pop_newtimef( + eeg, + 1, + channel, + [eeg["xmin"] * 1000, eeg["xmax"] * 1000], + [0], + freqs=[4, 20], + nfreqs=9, + timesout=8, + baseline=np.nan, + scale="abs", + alpha=0.2, + naccu=20, + rng=300 + channel, + plotphase="off", + plotersp="off", + plotitc="off", + plot="off", + ) + for channel in range(1, eeg["nbchan"] + 1) + ] + all_ersp = np.stack([result.ersp for result in results], axis=-1) + all_itc = np.stack([result.itc for result in results], axis=-1) + all_powbase = np.stack([result.powbase for result in results], axis=-1) + all_erspboot = np.stack([result.erspboot for result in results], axis=-1) + all_itcboot = np.stack([result.itcboot for result in results], axis=-1) + + for result in results: + np.testing.assert_allclose(result.ersp, np.mean(np.abs(result.tfdata) ** 2, axis=2), rtol=1e-12) + assert np.max(np.abs(result.itc)) <= 1.0 + 1e-12 + np.testing.assert_allclose(result.times, results[0].times) + np.testing.assert_allclose(result.freqs, results[0].freqs) + assert all_ersp.shape == all_itc.shape == (9, 8, eeg["nbchan"]) + assert all_powbase.shape == (9, eeg["nbchan"]) + assert all_erspboot.shape == (9, 2, eeg["nbchan"]) + assert all_itcboot.shape == (9, eeg["nbchan"]) + assert np.std(all_ersp, axis=-1).max() > 0 + + figure, axis = plt.subplots() + topoplot( + all_ersp[3, 4], + eeg["chanlocs"], + axes=axis, + electrodes="on", + maplimits="absmax", + ) + assert axis.collections + plt.close(figure) + + +@eeglab_test(TUTORIAL2_WRAPPER, "test_bids_process_face_experiment") +def test_bids_face_experiment_runs_import_ica_epoch_and_trial_factor_study(tmp_path: Path): + bids_root = tmp_path / "generated_face_recognition" + export_commands = [] + for subject in (1, 2): + _root, command = pop_exportbids( + _bids_face_eeg(subject), + bids_root, + subject=f"{subject:02d}", + task="FaceRecognition", + return_com=True, + ) + export_commands.append(command) + _add_face_type_to_bids_events(bids_root) + + imported, import_command = pop_importbids( + bids_root, + eventtype="trial_type", + bidsevent="replace", + return_com=True, + ) + assert isinstance(imported, list) + selected, select_command = pop_select(imported, nochannel=["EOG1", "EOG2"], return_com=True) + cleaned, clean_command = pop_clean_rawdata( + selected, + FlatlineCriterion="off", + ChannelCriterion="off", + LineNoiseCriterion="off", + Highpass=[1, 2], + BurstCriterion="off", + WindowCriterion="off", + gui=False, + return_com=True, + ) + referenced, reference_command = pop_reref(cleaned, [], return_com=True) + decomposed, ica_command = pop_runica( + referenced, + icatype="picard", + concatcond="on", + options={"pca": -1, "maxiter": 100, "verbose": False}, + gui=False, + return_com=True, + ) + for eeg in decomposed: + component_count = np.asarray(eeg["icaweights"]).shape[0] + classifications = np.zeros((component_count, 7)) + classifications[:, 0] = 0.99 + classifications[-1] = [0, 0.95, 0, 0, 0, 0, 0.05] + eeg.setdefault("etc", {}).setdefault("ic_classification", {})["ICLabel"] = { + "classes": ["Brain", "Muscle", "Eye", "Heart", "Line Noise", "Channel Noise", "Other"], + "classifications": classifications, + } + flagged, flag_command = pop_icflag(decomposed, ICLABEL_THRESHOLDS, gui=False, return_com=True) + pruned, prune_command = pop_subcomp(flagged, [], 0, 0, gui=False, return_com=True) + + epochs, epoch_command = pop_epoch(pruned, list(FACE_EVENT_TYPES), [-0.25, 0.75], return_com=True) + study, epochs, study_command = pop_study(None, epochs, name="Generated face recognition", return_com=True) + study, trialinfo = std_maketrialinfo(study, epochs) + study, design_command = std_makedesign( + study, + epochs, + 1, + name="Face type", + variable1="face_type", + values1=["famous", "scrambled", "unfamiliar"], + subjselect=["01", "02"], + return_com=True, + ) + study, epochs, precompute_command = std_precomp( + study, + epochs, + "channels", + erp="on", + savetrials="on", + recompute="on", + erpparams={"rmbase": [-250, 0]}, + return_com=True, + ) + _study, erpdata, times, figure = std_erpplot(study, epochs, channels=["Fz"], design=1) + + assert len(imported) == len(selected) == len(cleaned) == len(decomposed) == len(pruned) == 2 + assert all(eeg["nbchan"] == 6 for eeg in selected) + assert all( + {event["face_type"] for event in eeg["event"]} == {"famous", "scrambled", "unfamiliar"} for eeg in imported + ) + assert all(np.asarray(eeg["icaweights"]).shape == (4, 6) for eeg in pruned) + assert all(eeg["trials"] == 6 for eeg in epochs) + assert all([row["face_type"] for row in rows] == ["famous", "scrambled", "unfamiliar"] * 2 for rows in trialinfo) + assert study["design"][0]["variable"][0]["value"] == ["famous", "scrambled", "unfamiliar"] + assert len(erpdata) == 3 and all(values.shape == (times.size, 2) for values in erpdata) + cache = next(entry for entry in study["changrp"] if entry["name"] == "Fz") + for face_type, actual in zip(("famous", "scrambled", "unfamiliar"), erpdata): + expected_cases = [] + for values, rows in zip(cache["erpdatatrials"], cache["erptrialinfo"]): + mask = np.asarray([row["face_type"] == face_type for row in rows]) + expected_cases.append(np.mean(np.asarray(values)[..., mask], axis=-1)) + np.testing.assert_allclose(actual, np.stack(expected_cases, axis=-1), atol=1e-12) + assert not np.allclose(erpdata[0], erpdata[1]) + for command in ( + *export_commands, + import_command, + select_command, + clean_command, + reference_command, + ica_command, + flag_command, + prune_command, + epoch_command, + study_command, + design_command, + precompute_command, + ): + assert command + plt.close(figure) + + +@eeglab_test(TUTORIAL2_WRAPPER, "test_bids_p300") +def test_bids_p300_runs_generated_import_clean_ica_epoch_and_study_pipeline(tmp_path: Path): + bids_root = tmp_path / "generated_p300" + first_root, first_export_command = pop_exportbids( + _bids_continuous_eeg(1), + bids_root, + subject="01", + task="P300", + return_com=True, + ) + second_root, second_export_command = pop_exportbids( + _bids_continuous_eeg(2), + bids_root, + subject="02", + task="P300", + return_com=True, + ) + imported, import_command = pop_importbids(bids_root, return_com=True) + assert isinstance(imported, list) + selected, select_command = pop_select(imported, nochannel=["EOG1", "EOG2"], return_com=True) + referenced, reference_command = pop_reref(selected, [], return_com=True) + original_low_frequency = [] + for eeg in referenced: + frequencies = np.fft.rfftfreq(eeg["pnts"], d=1 / eeg["srate"]) + low_frequency_index = int(np.argmin(np.abs(frequencies - 0.2))) + original_low_frequency.append(np.abs(np.fft.rfft(np.asarray(eeg["data"])[0]))[low_frequency_index]) + cleaned, clean_command = pop_clean_rawdata( + referenced, + FlatlineCriterion="off", + ChannelCriterion="off", + LineNoiseCriterion="off", + Highpass=[1, 2], + BurstCriterion="off", + WindowCriterion="off", + gui=False, + return_com=True, + ) + decomposed, ica_command = pop_runica( + cleaned, + icatype="picard", + concatcond="on", + options={"pca": -1, "maxiter": 100, "verbose": False}, + gui=False, + return_com=True, + ) + + for eeg in decomposed: + component_count = np.asarray(eeg["icaweights"]).shape[0] + classifications = np.zeros((component_count, 7)) + classifications[:, 0] = 0.99 + classifications[-1] = [0, 0.95, 0, 0, 0, 0, 0.05] + eeg.setdefault("etc", {}).setdefault("ic_classification", {})["ICLabel"] = { + "classes": ["Brain", "Muscle", "Eye", "Heart", "Line Noise", "Channel Noise", "Other"], + "classifications": classifications, + } + flagged, flag_command = pop_icflag(decomposed, ICLABEL_THRESHOLDS, gui=False, return_com=True) + before_component_removal = [np.asarray(eeg["data"]).copy() for eeg in flagged] + pruned, prune_command = pop_subcomp(flagged, [], 0, 0, gui=False, return_com=True) + + epochs = [] + epoch_commands = [] + baseline_commands = [] + for eeg in pruned: + for condition in CONDITIONS: + epoched, epoch_command = pop_epoch(eeg, [condition], [-0.25, 0.75], return_com=True) + epoched, baseline_command = pop_rmbase(epoched, [-250, 0], return_com=True) + epoched["condition"] = condition + epochs.append(epoched) + epoch_commands.append(epoch_command) + baseline_commands.append(baseline_command) + + study, epochs, study_command = pop_study(None, epochs, name="Generated Oddball", return_com=True) + study, design_command = std_makedesign( + study, + epochs, + 1, + name="P300 condition", + variable1="condition", + values1=list(CONDITIONS), + subjselect=["01", "02"], + return_com=True, + ) + study, epochs, precompute_command = std_precomp( + study, + epochs, + "channels", + erp="on", + savetrials="on", + recompute="on", + erpparams={"rmbase": [-250, 0]}, + return_com=True, + ) + _study, erpdata, times, figure = std_erpplot(study, epochs, channels=["Fz"], design=1) + + assert first_root == second_root == str(bids_root) + assert len(imported) == len(selected) == len(cleaned) == len(decomposed) == len(pruned) == 2 + assert [eeg["subject"] for eeg in imported] == ["01", "02"] + assert all(eeg["nbchan"] == 6 for eeg in selected) + for eeg in referenced: + np.testing.assert_allclose(np.mean(np.asarray(eeg["data"]), axis=0), 0.0, atol=1e-6) + for index, eeg in enumerate(cleaned): + frequencies = np.fft.rfftfreq(eeg["pnts"], d=1 / eeg["srate"]) + low_frequency_index = int(np.argmin(np.abs(frequencies - 0.2))) + filtered_amplitude = np.abs(np.fft.rfft(np.asarray(eeg["data"])[0]))[low_frequency_index] + assert filtered_amplitude < 0.6 * original_low_frequency[index] + assert all(np.asarray(eeg["icaweights"]).shape == (5, 6) for eeg in decomposed) + assert all(np.count_nonzero(eeg["reject"]["gcompreject"]) == 1 for eeg in flagged) + assert all(np.asarray(eeg["icaweights"]).shape == (4, 6) for eeg in pruned) + assert all(not np.allclose(before, eeg["data"]) for before, eeg in zip(before_component_removal, pruned)) + assert len(epochs) == 4 and all(eeg["trials"] == 3 for eeg in epochs) + assert all(np.asarray(eeg["data"]).shape == (6, 64, 3) for eeg in epochs) + assert study["design"][0]["variable"][0]["value"] == list(CONDITIONS) + assert erpdata[0].shape == erpdata[1].shape == (times.size, 2) + p300_window = (times >= 150) & (times <= 500) + assert np.max(np.mean(erpdata[1][p300_window] - erpdata[0][p300_window], axis=1)) > 0.25 + for command in ( + first_export_command, + second_export_command, + import_command, + select_command, + reference_command, + clean_command, + ica_command, + flag_command, + prune_command, + *epoch_commands, + *baseline_commands, + study_command, + design_command, + precompute_command, + ): + assert command + plt.close(figure) diff --git a/tools/eeglab_test_port_audit.py b/tools/eeglab_test_port_audit.py new file mode 100644 index 00000000..367b96cf --- /dev/null +++ b/tools/eeglab_test_port_audit.py @@ -0,0 +1,381 @@ +"""Audit pytest ports against the pinned current EEGLAB test suite.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Sequence + + +EEGLAB_TESTS_REPOSITORY = "https://github.com/sccn/eeglab_tests.git" +EEGLAB_TESTS_COMMIT = "ff605546f3f70868916fb8d49c007472b3257b50" +EEGLAB_TESTS_EEGLAB_COMMIT = "8ac485f654d6bbb1a6acb8dc9ef3f2eaf3d409ba" +STALE_EEGLAB_TESTS_REPOSITORY = "https://github.com/sccn/eeglab-testcases.git" +REFERENCE_ATTRIBUTE = "__eeglab_test_references__" +PROVENANCE_OPTION = "--eeglab-provenance-output" +LIMO_TEST_NAMES = frozenset({"limo_test1", "limo_test2"}) + +_FUNCTION_RE = re.compile( + r"^\s*function\s+(?:(?:\[[^\]\n]+\]|[A-Za-z]\w*)\s*=\s*)?(?P[A-Za-z]\w*)", + re.MULTILINE, +) + + +class AuditInputError(RuntimeError): + """Raised when an audit input cannot identify the pinned source of truth.""" + + +@dataclass(frozen=True, order=True) +class MatlabTestScenario: + """One MATLAB test method in its defining source file.""" + + source: str + test: str + + def as_text(self) -> str: + return f"{self.source}::{self.test}" + + +@dataclass(frozen=True) +class CollectedReference: + """One provenance reference attached to a collected pytest test.""" + + source: str + test: str + suite_commit: str + eeglab_commit: str + nodeid: str + + +@dataclass(frozen=True) +class AuditReport: + """Completeness result for one suite checkout and EEGPrep checkout.""" + + suite_commit: str + expected: tuple[MatlabTestScenario, ...] + covered: tuple[MatlabTestScenario, ...] + missing: tuple[MatlabTestScenario, ...] + invalid_references: tuple[str, ...] + + @property + def ok(self) -> bool: + return not self.missing and not self.invalid_references + + def to_jsonable(self) -> dict[str, Any]: + return { + "ok": self.ok, + "suite_commit": self.suite_commit, + "expected_count": len(self.expected), + "covered_count": len(self.covered), + "missing_count": len(self.missing), + "missing": [scenario.as_text() for scenario in self.missing], + "invalid_references": list(self.invalid_references), + } + + +def discover_matlab_test_scenarios(suite_root: Path) -> set[MatlabTestScenario]: + """Discover wrapper and regression methods from an EEGLAB test checkout.""" + scenarios: set[MatlabTestScenario] = set() + for path in sorted(suite_root.rglob("*wrapperTest.m")): + names = _matlab_function_names(path) + for name in names[1:]: + if name.startswith("test") or name in LIMO_TEST_NAMES: + scenarios.add(MatlabTestScenario(path.relative_to(suite_root).as_posix(), name)) + + regression_root = suite_root / "regression_tests" + for path in sorted(regression_root.glob("t_*.m")): + for name in _matlab_function_names(path): + if name.startswith("test"): + scenarios.add(MatlabTestScenario(path.relative_to(suite_root).as_posix(), name)) + return scenarios + + +def validate_suite_checkout( + suite_root: Path, + *, + expected_suite_commit: str = EEGLAB_TESTS_COMMIT, + expected_eeglab_commit: str | None = EEGLAB_TESTS_EEGLAB_COMMIT, + expected_repository: str | None = EEGLAB_TESTS_REPOSITORY, +) -> str: + """Validate that ``suite_root`` is the pinned, current source checkout.""" + if not suite_root.is_dir(): + raise AuditInputError(f"EEGLAB test checkout does not exist: {suite_root}") + + actual_commit = _git_output(suite_root, "rev-parse", "HEAD") + if actual_commit != expected_suite_commit: + raise AuditInputError( + f"EEGLAB test checkout is at {actual_commit}; expected pinned commit {expected_suite_commit}" + ) + + if expected_repository is not None: + actual_repository = _git_output(suite_root, "remote", "get-url", "origin") + if _normalized_repository(actual_repository) != _normalized_repository(expected_repository): + stale_note = ( + " (the eeglab-testcases repository is stale)" + if _normalized_repository(actual_repository) == _normalized_repository(STALE_EEGLAB_TESTS_REPOSITORY) + else "" + ) + raise AuditInputError( + f"EEGLAB test checkout origin is {actual_repository!r}, not {expected_repository!r}{stale_note}" + ) + + if expected_eeglab_commit is not None: + eeglab_root = suite_root / "eeglab" + actual_eeglab_commit = _git_output(eeglab_root, "rev-parse", "HEAD") + if actual_eeglab_commit != expected_eeglab_commit: + raise AuditInputError(f"EEGLAB submodule is at {actual_eeglab_commit}; expected {expected_eeglab_commit}") + return actual_commit + + +def collect_pytest_references(repo_root: Path) -> tuple[CollectedReference, ...]: + """Collect provenance from pytest items without executing their test bodies.""" + tests_root = repo_root / "tests" + if not tests_root.is_dir(): + raise AuditInputError(f"pytest test directory does not exist: {tests_root}") + + with tempfile.TemporaryDirectory(prefix="eegprep-provenance-") as temporary_dir: + output_path = Path(temporary_dir) / "references.json" + environment = os.environ.copy() + tool_root = Path(__file__).resolve().parents[1] + python_paths = [str(repo_root / "src"), str(repo_root), str(tool_root)] + if environment.get("PYTHONPATH"): + python_paths.append(environment["PYTHONPATH"]) + environment.update( + { + "EEGPREP_SKIP_MATLAB": "1", + "MPLBACKEND": "Agg", + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTHONPATH": os.pathsep.join(python_paths), + "QT_QPA_PLATFORM": "offscreen", + } + ) + command = [ + sys.executable, + "-m", + "pytest", + "--collect-only", + "--quiet", + "--disable-warnings", + "-p", + "tools.eeglab_test_port_audit", + f"{PROVENANCE_OPTION}={output_path}", + str(tests_root), + ] + completed = subprocess.run( + command, + cwd=repo_root, + env=environment, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + details = (completed.stderr or completed.stdout).strip() + raise AuditInputError(f"pytest collection failed ({completed.returncode}): {details}") + if not output_path.is_file(): + raise AuditInputError("pytest collection did not emit EEGLAB provenance") + payload = json.loads(output_path.read_text(encoding="utf-8")) + + return tuple(CollectedReference(**entry) for entry in payload) + + +def compare_test_ports( + suite_root: Path, + expected: set[MatlabTestScenario], + references: Sequence[CollectedReference], + *, + suite_commit: str, + expected_eeglab_commit: str | None = EEGLAB_TESTS_EEGLAB_COMMIT, +) -> AuditReport: + """Compare collected provenance with source scenarios and report exact gaps.""" + wrapper_lookup = _leaf_wrapper_lookup(expected) + covered: set[MatlabTestScenario] = set() + invalid: set[str] = set() + + for reference in references: + label = f"{reference.nodeid}: {reference.source}::{reference.test}" + if reference.suite_commit != suite_commit: + invalid.add(f"{label} pins suite commit {reference.suite_commit}, expected {suite_commit}") + continue + if expected_eeglab_commit is not None and reference.eeglab_commit != expected_eeglab_commit: + invalid.add(f"{label} pins EEGLAB commit {reference.eeglab_commit}, expected {expected_eeglab_commit}") + continue + + source = PurePosixPath(reference.source) + if source.is_absolute() or ".." in source.parts or source.suffix != ".m": + invalid.add(f"{label} is not a relative MATLAB source path") + continue + if not (suite_root / Path(*source.parts)).is_file(): + invalid.add(f"{label} references a source absent from the pinned suite") + continue + + scenario = MatlabTestScenario(source.as_posix(), reference.test) + if scenario in expected: + covered.add(scenario) + continue + + leaf_key = (source.parent.as_posix(), source.stem, reference.test) + candidates = wrapper_lookup.get(leaf_key, ()) + if len(candidates) == 1: + covered.add(candidates[0]) + elif len(candidates) > 1: + invalid.add(f"{label} ambiguously maps to multiple wrapper methods") + elif reference.test in _matlab_function_names(suite_root / Path(*source.parts)): + # Some Python tests also cite current-suite helper/class methods. + # They are valid provenance, but are outside this wrapper/regression gate. + continue + else: + invalid.add(f"{label} does not identify a MATLAB method in the pinned suite") + + missing = expected - covered + return AuditReport( + suite_commit=suite_commit, + expected=tuple(sorted(expected)), + covered=tuple(sorted(covered)), + missing=tuple(sorted(missing)), + invalid_references=tuple(sorted(invalid)), + ) + + +def audit_test_ports( + suite_root: Path, + repo_root: Path, + *, + expected_suite_commit: str = EEGLAB_TESTS_COMMIT, + expected_eeglab_commit: str | None = EEGLAB_TESTS_EEGLAB_COMMIT, + expected_repository: str | None = EEGLAB_TESTS_REPOSITORY, +) -> AuditReport: + """Run the complete source-discovery and pytest-provenance audit.""" + suite_commit = validate_suite_checkout( + suite_root, + expected_suite_commit=expected_suite_commit, + expected_eeglab_commit=expected_eeglab_commit, + expected_repository=expected_repository, + ) + expected = discover_matlab_test_scenarios(suite_root) + references = collect_pytest_references(repo_root) + return compare_test_ports( + suite_root, + expected, + references, + suite_commit=suite_commit, + expected_eeglab_commit=expected_eeglab_commit, + ) + + +def format_report(report: AuditReport) -> str: + """Format a concise human-readable audit report.""" + state = "PASS" if report.ok else "FAIL" + lines = [ + f"EEGLAB test-port audit: {state}", + f"Suite commit: {report.suite_commit}", + f"Expected: {len(report.expected)}; covered: {len(report.covered)}; missing: {len(report.missing)}", + ] + if report.missing: + lines.append("Missing scenarios:") + lines.extend(f" {scenario.as_text()}" for scenario in report.missing) + if report.invalid_references: + lines.append("Invalid or stale provenance:") + lines.extend(f" {message}" for message in report.invalid_references) + return "\n".join(lines) + + +def pytest_addoption(parser: Any) -> None: + """Register the private output used by the collection subprocess.""" + parser.addoption(PROVENANCE_OPTION, action="store", default=None) + + +def pytest_collection_finish(session: Any) -> None: + """Serialize decorator provenance from collected pytest items.""" + output = session.config.getoption(PROVENANCE_OPTION) + if output is None: + return + + records: list[dict[str, str]] = [] + for item in session.items: + test_object = getattr(item, "obj", None) + for reference in getattr(test_object, REFERENCE_ATTRIBUTE, ()): + records.append( + { + "source": str(reference.source), + "test": str(reference.test), + "suite_commit": str(reference.suite_commit), + "eeglab_commit": str(reference.eeglab_commit), + "nodeid": str(item.nodeid), + } + ) + Path(output).write_text(json.dumps(records, sort_keys=True), encoding="utf-8") + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the audit CLI.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("suite_checkout", type=Path, help="checkout of sccn/eeglab_tests") + parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--json", action="store_true", help="emit a machine-readable report") + arguments = parser.parse_args(argv) + + try: + report = audit_test_ports(arguments.suite_checkout.resolve(), arguments.repo_root.resolve()) + except AuditInputError as error: + if arguments.json: + print(json.dumps({"ok": False, "input_error": str(error)}, indent=2, sort_keys=True)) + else: + print(f"EEGLAB test-port audit input error: {error}", file=sys.stderr) + return 2 + + if arguments.json: + print(json.dumps(report.to_jsonable(), indent=2, sort_keys=True)) + else: + print(format_report(report)) + return 0 if report.ok else 1 + + +def _matlab_function_names(path: Path) -> list[str]: + text = path.read_text(encoding="utf-8", errors="replace") + return [match.group("name") for match in _FUNCTION_RE.finditer(text)] + + +def _leaf_wrapper_lookup( + expected: set[MatlabTestScenario], +) -> dict[tuple[str, str, str], tuple[MatlabTestScenario, ...]]: + mutable: dict[tuple[str, str, str], list[MatlabTestScenario]] = {} + for scenario in expected: + source = PurePosixPath(scenario.source) + if not source.name.endswith("wrapperTest.m") or not scenario.test.startswith("test_"): + continue + leaf_stem = scenario.test.removeprefix("test_") + key = (source.parent.as_posix(), leaf_stem, scenario.test) + mutable.setdefault(key, []).append(scenario) + return {key: tuple(sorted(value)) for key, value in mutable.items()} + + +def _git_output(repository: Path, *arguments: str) -> str: + completed = subprocess.run( + ["git", "-C", str(repository), *arguments], + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + details = (completed.stderr or completed.stdout).strip() + raise AuditInputError(f"cannot inspect git checkout at {repository}: {details}") + return completed.stdout.strip() + + +def _normalized_repository(repository: str) -> str: + normalized = repository.strip().removesuffix(".git").rstrip("/") + if normalized.startswith("git@github.com:"): + normalized = "https://github.com/" + normalized.removeprefix("git@github.com:") + return normalized.lower() + + +if __name__ == "__main__": + raise SystemExit(main()) From e8ac563323bf59ec421964ea58d683b791cbe389 Mon Sep 17 00:00:00 2001 From: "Seyed (Yahya) Shirazi" Date: Sun, 20 Sep 2026 16:41:21 -0700 Subject: [PATCH 44/45] Fix the resampling ratio for non-integer sampling rates, and drop sympy (#397) * Fix the resampling ratio for non-integer sampling rates EEGLAB computes the ratio as rat(freq/EEG.srate, 1e-12), pop_resample.m line 119. This was ported as sympy.nsimplify, which is a different function: it looks for a simple symbolic expression rather than a rational one, and returns an irrational when no simple fraction is close enough. as_numer_denom then hands back a numerator that is not an integer and int() truncates it silently. A 2000 Hz recording resampled to 999.9 Hz was therefore resampled by 5/12 instead of 9999/20000, a 16.7 percent error, while the output was stamped with the rate that was asked for. The event latencies were rescaled by the same wrong ratio, so the result stayed internally consistent and gave the reader nothing to notice. Across realistic downsampling pairs, 24 of 86 came back wrong, all of them with a fractional source rate; the worst produced 117.04 Hz labeled 128 Hz. _rational_within expands the continued fraction one term at a time and stops at the first convergent inside the tolerance, which is what rat does and which returns the smallest denominator that will do. That matters beyond tidiness, because the denominator sets the anti-aliasing filter length: an earlier draft searched by doubling a denominator cap, which can overshoot the minimal answer and buy filter taps for nothing. The ratio tests fail 28 subtests against the previous implementation, and the end-to-end test added here fails against it too, computing 21/84 where the correct ratio is 2560/9999. An earlier draft of that test used whole-number rates only and passed against the defect, which made it worthless as a guard. Also logs a warning when the ratio's denominator is large. 128 Hz from a 512.03 Hz recording is 12800/51203, which builds a filter of about 1.64 million taps and costs seconds per minute of 64-channel data against milliseconds for a whole-number pair. That cost is EEGLAB's, inherited with its tolerance, and correct rather than a defect, but a resample that appears to hang should say why. * Drop sympy, which no longer has a caller The nsimplify the previous commit replaced was the only use of sympy anywhere in src/, so it leaves the base install. Verified by running the suite with sympy absent from the environment rather than merely absent from pyproject.toml, which is the only check that means anything here: eeglabio has no import site in src/ either, and is not removable, because mne imports it when asked to write an EEGLAB file and eeg_mne2eeg exports through mne. The uv.lock edit removes the two lines naming sympy in eegprep's own dependency lists, and is scoped to eegprep's package block. An earlier draft used an unscoped replace and took torch's sympy edge with it, orphaning the package entry: uv lock --check still passed, because the lock stayed self-consistent, and no CI job installs the torch extra with --locked, so nothing would have caught it. --- pyproject.toml | 1 - src/eegprep/functions/popfunc/pop_resample.py | 79 ++++++++++- tests/test_pop_resample_python.py | 123 +++++++++++++++++- uv.lock | 2 - 4 files changed, 197 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ead0098a..8132821f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,6 @@ dependencies = [ "pyyaml>=6.0", "python-picard>=0.8,<0.9", "scipy>=1.7.0", - "sympy>=1.14.0", "threadpoolctl>=3.6.0", ] diff --git a/src/eegprep/functions/popfunc/pop_resample.py b/src/eegprep/functions/popfunc/pop_resample.py index 73de7bd9..5c413d62 100644 --- a/src/eegprep/functions/popfunc/pop_resample.py +++ b/src/eegprep/functions/popfunc/pop_resample.py @@ -6,7 +6,6 @@ from math import ceil, floor, gcd import numpy as np -import sympy as sp from scipy import signal from scipy.signal import resample, resample_poly from scipy.signal.windows import kaiser @@ -167,6 +166,15 @@ def resample_eeg(EEG, freq, method='poly', fc=0.9, df=0.2): logger.info("resampling data %g Hz", float(freq)) p, q = _resample_ratio(freq, EEG["srate"]) + if max(p, q) > _LARGE_RATIO_WARNING_THRESHOLD: + logger.warning( + "resampling %g Hz to %g Hz needs the ratio %d/%d; the anti-aliasing filter " + "scales with that denominator, so this will be slow", + float(EEG["srate"]), + float(freq), + p, + q, + ) ratio = p / q source_data = EEG["data"] data = np.asarray(source_data) @@ -208,10 +216,73 @@ def resample_eeg(EEG, freq, method='poly', fc=0.9, df=0.2): return output +# The tolerance EEGLAB resamples with: pop_resample.m line 119, +# `[p,q] = rat(freq/EEG.srate, 1e-12)`. A looser 1e-4 is written on the line +# above it and commented "not used right now", so this is a deliberate choice +# upstream rather than an accident, and matching it is what keeps a resampled +# recording identical to the one EEGLAB would have produced. +RATIO_TOLERANCE = 1e-12 + +# The continued fraction of a double is finite and short, about forty terms at +# worst. The bound is a guard against a pathological float, not a tuning knob. +_MAX_CONTINUED_FRACTION_TERMS = 64 + +# _resample_poly_segment sets its anti-aliasing filter's transition width to +# df / max(p, q), so the filter length grows with the ratio. An exact ratio for +# a fractional sampling rate can be large: 128 Hz from a 512.03 Hz recording is +# 12800/51203, which builds a filter of about 1.64 million taps and takes some +# seconds per minute of 64-channel data, against milliseconds for a whole-number +# pair of the same magnitude. That cost is EEGLAB's, inherited with its 1e-12 +# tolerance, and it is correct rather than a defect. It is worth a line in the +# log so that a resample which appears to hang has a visible reason. +_LARGE_RATIO_WARNING_THRESHOLD = 10_000 + + +def _rational_within(value, tolerance): + """Approximate `value` by a fraction, within `tolerance`. + + MATLAB's ``rat``, which is what EEGLAB calls here. It matters that the + result is a RATIO: this used to be ``sympy.nsimplify``, which looks for a + simple symbolic expression rather than a rational one and is free to return + an irrational when no simple fraction is close enough. Taking the numerator + of such an expression and calling ``int()`` on it truncates silently, so a + 2000 Hz recording resampled to 999.9 Hz was resampled by 5/12 rather than + 9999/20000, a 16.7 percent error, and then labeled 999.9 Hz regardless. + + The continued fraction for `value` is expanded one term at a time and the + search stops at the first convergent inside `tolerance`, which is what + ``rat`` does and which returns the SMALLEST denominator that will do. That + matters here beyond tidiness: the denominator sets the anti-aliasing filter + length in ``_resample_poly_segment``, so a larger one than necessary is + paid for in filter taps. + + It terminates because the continued fraction of a float is finite: a double + is itself a ratio of integers, so the expansion is exact within about forty + terms and the loop bound is never reached in practice. + """ + numerator, previous_numerator = floor(value), 1 + denominator, previous_denominator = 1, 0 + remainder = value + + for _ in range(_MAX_CONTINUED_FRACTION_TERMS): + if abs(numerator / denominator - value) <= tolerance: + return int(numerator), int(denominator) + fractional_part = remainder - floor(remainder) + if fractional_part == 0: + break + remainder = 1.0 / fractional_part + term = floor(remainder) + numerator, previous_numerator = term * numerator + previous_numerator, numerator + denominator, previous_denominator = ( + term * denominator + previous_denominator, + denominator, + ) + + return int(numerator), int(denominator) + + def _resample_ratio(freq, srate): - rational_approx = sp.nsimplify(float(freq) / float(srate), tolerance=1e-12) - p, q = rational_approx.as_numer_denom() - return int(p), int(q) + return _rational_within(float(freq) / float(srate), RATIO_TOLERANCE) def _segment_bounds(EEG, old_pnts): diff --git a/tests/test_pop_resample_python.py b/tests/test_pop_resample_python.py index c768664b..369767fc 100644 --- a/tests/test_pop_resample_python.py +++ b/tests/test_pop_resample_python.py @@ -1,9 +1,14 @@ +import math import unittest import numpy as np from eegprep.functions.adminfunc.eeg_options import EEG_OPTIONS -from eegprep.functions.popfunc.pop_resample import pop_resample +from eegprep.functions.popfunc.pop_resample import ( + RATIO_TOLERANCE, + _resample_ratio, + pop_resample, +) from tests.eeglab_tests import eeglab_test @@ -167,5 +172,121 @@ def test_pop_resample_current_suite_preserves_half_sample_boundaries(): ) +class ResampleRatioTests(unittest.TestCase): + """The ratio that drives both the resampling and the event latencies. + + EEGLAB computes it as ``rat(freq/EEG.srate, 1e-12)`` (pop_resample.m line + 119). This used to be ported as ``sympy.nsimplify``, which is not the same + function: it looks for a simple symbolic expression, not a rational one, + and returns an irrational when no simple fraction is close enough. The + numerator of such an expression truncates silently under ``int()``, so the + ratio came back small, wrong, and unflagged. + """ + + # Sampling rates that are not integers are the whole point: every pair here + # with a whole-number source rate was already handled correctly, and every + # pair with a fractional one was not. + INTEGER_RATES = { + (250, 1000): (1, 4), + (256, 512): (1, 2), + (500, 2048): (125, 512), + (128, 1000): (16, 125), + (1000, 1024): (125, 128), + (44100, 48000): (147, 160), + (96, 128): (3, 4), + } + FRACTIONAL_RATES = { + (999.9, 2000): (9999, 20000), + (1023.4, 2000): (5117, 10000), + (250, 999.9): (2500, 9999), + (128, 512.03): (12800, 51203), + } + + def test_whole_number_rates_are_unchanged(self): + """The fix must not move the ratios that were already right.""" + for (freq, srate), expected in self.INTEGER_RATES.items(): + with self.subTest(freq=freq, srate=srate): + self.assertEqual(_resample_ratio(freq, srate), expected) + + def test_fractional_rates_are_exact(self): + """Each of these came back truncated, by up to 16.7 percent.""" + for (freq, srate), expected in self.FRACTIONAL_RATES.items(): + with self.subTest(freq=freq, srate=srate): + self.assertEqual(_resample_ratio(freq, srate), expected) + + def test_every_ratio_lands_inside_the_tolerance(self): + """The property the pairs above are examples of. + + Stated as a property rather than a table so that a future change of + method has to hold the invariant for rates nobody wrote down. An + implementation that returns something other than a ratio fails here for + the same reason it failed in the field: the number it returns is not + the number it was asked for. + """ + sources = [250, 256, 500, 512, 1000, 1024, 2000, 2048, 5000, 999.9, 1023.4, 512.03] + targets = [100, 125, 128, 200, 250, 256, 500, 512] + + for srate in sources: + for freq in targets: + if freq >= srate: + continue + with self.subTest(freq=freq, srate=srate): + p, q = _resample_ratio(freq, srate) + self.assertGreater(q, 0) + self.assertAlmostEqual(p / q, freq / srate, delta=RATIO_TOLERANCE) + + def test_a_fractional_rate_resamples_through_the_whole_pipeline(self): + """The end-to-end test that the defect actually fails. + + The test below deliberately uses whole-number rates, and so passes + against the old implementation too: it says nothing about this fix. This + one drives `pop_resample` itself at a fractional source rate, where the + old ratio was 21/84 and the correct one is 2560/9999, and asserts the + sample count that follows from the correct one. + + It runs through the `scipy` engine on purpose. The `poly` engine builds + an anti-aliasing filter whose length scales with the denominator, and an + exact ratio for a fractional rate has a large one, so the faithful + engine would make this test cost seconds. The `scipy` engine consumes + the same p and q through `ceil(n * p / q)`, which is what is being + checked here. + """ + eeg = _continuous_eeg() + eeg["srate"] = 999.9 + eeg["data"] = np.zeros((2, 2000), dtype=np.float32) + eeg["pnts"] = 2000 + eeg["event"] = [] + eeg["urevent"] = [] + eeg["icaact"] = np.zeros((2, 2000, 1), dtype=np.float32) + + output = pop_resample(eeg, 256, engine="scipy") + + p, q = _resample_ratio(256, 999.9) + self.assertEqual((p, q), (2560, 9999)) + self.assertEqual(output["pnts"], math.ceil(2000 * p / q)) + self.assertEqual(output["srate"], 256) + + def test_the_resampled_rate_matches_the_data(self): + """The invariant the defect broke: the label and the samples agree. + + pop_resample stamps ``srate`` with the rate it was asked for whatever + the ratio turned out to be, so a wrong ratio produced a recording whose + declared rate and sample count disagreed, with nothing to notice it. + Whole-number rates keep this cheap; the ratio tests above are where the + fractional ones are covered, because an exact fractional ratio means a + denominator in the thousands and an anti-aliasing filter to match. + """ + eeg = _continuous_eeg() + eeg["event"] = [] + duration = eeg["pnts"] / eeg["srate"] + + for rate in (50, 200, 250): + with self.subTest(rate=rate): + output = pop_resample(dict(eeg), rate) + + self.assertEqual(output["srate"], rate) + self.assertAlmostEqual(output["pnts"] / output["srate"], duration, delta=1 / rate) + + if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index c4068d0d..2d8547ff 100644 --- a/uv.lock +++ b/uv.lock @@ -658,7 +658,6 @@ dependencies = [ { name = "python-picard" }, { name = "pyyaml" }, { name = "scipy" }, - { name = "sympy" }, { name = "threadpoolctl" }, ] @@ -762,7 +761,6 @@ requires-dist = [ { name = "sphinx-gallery", marker = "extra == 'docs'", specifier = ">=0.14.0" }, { name = "sphinx-togglebutton", marker = "extra == 'docs'", specifier = ">=0.3.0" }, { name = "sphinxcontrib-spelling", marker = "extra == 'docs'", specifier = ">=7.1.0" }, - { name = "sympy", specifier = ">=1.14.0" }, { name = "threadpoolctl", specifier = ">=3.6.0" }, { name = "torch", marker = "extra == 'torch'", specifier = ">=2.0" }, ] From aca0a224b98718a840b1da29f91fe318dc4f3aad Mon Sep 17 00:00:00 2001 From: "Seyed (Yahya) Shirazi" Date: Sun, 20 Sep 2026 21:23:11 -0700 Subject: [PATCH 45/45] Require Python 3.12, and bound every dependency floor by what Pyodide ships (#399) * Require Python 3.12 The browser target sets this, in the opposite direction to the obvious one. Pyodide 0.29.5 ships CPython 3.13.2 and its wheels are cp313, so requires-python is a ceiling rather than a floor: 3.14 would make eegprep uninstallable in the browser until Pyodide moves. 3.12 keeps one version of headroom and lets CI cover both 3.12 and the 3.13 the browser actually runs. The matrix drops 3.10 and 3.11 and gains 3.13. tomllib has been in the standard library since 3.11, so the tomli backport and its two try/except shims are dead code and go with it. * Raise dependency floors, bounded by what Pyodide ships Two of these are live bugs rather than tidying. threadpoolctl was declared >=3.6.0 while Pyodide 0.29.5 ships 3.5.0, so the floor is above what the browser can provide and a browser install cannot satisfy it. matplotlib>=3.9.2 would have introduced the same bug, since Pyodide has 3.8.4; 3.8.0 is the newest floor that does not. The rest were fiction in the other direction: numpy>=1.20 ships cp37 through cp39 wheels and cannot install on the Python this package now requires. Floors are now the first release with real 3.12 support, capped by the version Pyodide ships: numpy 2.1.0, scipy 1.14.1, matplotlib 3.8.0, h5py 3.12.1, threadpoolctl 3.5.0. scipy is split by platform. 1.14.1 is exactly what Pyodide has, so the general floor cannot go higher, but scipy built before 1.16.3 fails to dlopen on current macOS with a zero-fill section error, so darwin gets its own floor. * Pin every CI job to Python 3.12 The 3.12 floor landed in the test matrix but not in the jobs that install a single interpreter, so docs, release, ruff/ty, and the Claude workflows all still asked for 3.11 and uv refused: error: The requested interpreter resolved to Python 3.11.16, which is incompatible with the project's Python requirement: `>=3.12` Tested: the failing jobs were "Ruff and ty" and "Build Documentation". * Retire the last 3.10 and 3.11 references The floor moved but the surrounding claims did not: the README badge, the AGENTS notes, four docs pages, and the six example extensions all still advertised 3.10 or 3.11, and ty was still type-checking against 3.10 semantics. pre-commit.py carried a tomli backport for 3.10 and a matching "tomli is not installed; skipping TOML syntax checks" branch. On a 3.12 floor tomllib is always stdlib, so both are dead, and the branch was a silent skip of a check the script claims to run. Tested: ruff check and format clean, ty clean apart from three pre-existing unresolved torch imports (the local venv has no torch extra), tests/test_extension_catalog.py 22 passed, and ./pre-commit.py --all-files reports OK with config syntax checked. * Let CI test 3.13 where MATLAB cannot follow The 3.13 job failed on the MATLAB engine install, not on eegprep: MATLAB Engine for Python supports Python version 3.9, 3.10, 3.11, and 3.12, but your version of Python is 3.13 uv sync had already succeeded, so every dependency floor resolves on 3.13; only MathWorks' engine has no build for it. The workflow already treats MATLAB as optional and falls through to a plain pytest run when the engine will not start, but the install step aborted the job before reaching that check. Tolerate the install failing so the fall-through runs, which also means this starts working by itself once a MATLAB release covers 3.13, rather than encoding a compatibility table in CI. * Fix a parity-harness instruction the new floor broke development.rst told the reader to build the parity harness venv with --python 3.11 and then pip install -e this working tree into it. Under requires-python >=3.12 that install now fails, so the instruction was not merely stale, it no longer worked. Also split the two-sentence line in faq.rst onto its own lines, per the semantic line break convention. * Stop pre-commit reporting OK with YAML unchecked Removing the tomli backport fixed the TOML half of a false green: a missing parser printed one warning, skipped every file of that type, recorded no error, and let the run print OK. The YAML half was left behind with the identical shape. pyyaml is declared in this script's inline dependencies, so uv installs it before the body runs and the only way to reach the fallback is to start the script without uv. That is a misuse, not a degraded environment, so it now exits with the invocation to use instead of quietly checking nothing. Also corrects the header, which still said config parsing happens "when dependencies are available". It is now unconditional. Tested: ./pre-commit.py --all-files reports OK over 1221 files with config syntax checked. * Test that the floors stay installable in the browser Pyodide bundles its own build of every compiled package, so a floor above what a Pyodide release ships cannot be satisfied in the browser. That makes these floors and requires-python a ceiling, which is the opposite of how a floor reads and is invisible from the code. The epic branch's tools/check_pyodide_base_resolution.py was supposed to catch this and does not: it matches package names against the Pyodide lock and never compares versions, so threadpoolctl>=3.6.0 sits on that branch today against a distribution shipping 3.5.0, and the gate calls it ok. Filed as #400. These tests close the gap on develop, offline, against the versions Pyodide 0.29.5 bundles. Verified non-vacuous by reintroducing the real bug: threadpoolctl>=3.6.0 fails two of them by name, and a requires-python of >=3.14 fails the interpreter check, since Pyodide runs CPython 3.13.2. Also records the constraint above the floors it governs, which was documented for scipy alone, and bumps the ruff target to py312, missed when the ty target moved. The catalog fixture's python_requires went back to a value no interpreter bump can disturb; pinning it to the project floor coupled an unrelated happy path to every future bump. The field's real behavior, an unsatisfiable floor and a malformed specifier, now has the two tests its eegprep_requires sibling already had and it did not. --- .github/workflows/claude.yml | 6 +- .github/workflows/docs.yml | 4 +- .github/workflows/release.yml | 12 +- .github/workflows/test.yml | 21 +- .python-version | 2 +- AGENTS.md | 4 +- README.md | 2 +- docs/source/contributing.rst | 2 +- docs/source/development.rst | 6 +- docs/source/faq.rst | 5 +- docs/source/user_guide/extension_curation.rst | 2 +- docs/source/user_guide/installation.rst | 2 +- .../eegprep_ext_file_io/pyproject.toml | 2 +- .../eegprep_ext_gui_dialog/pyproject.toml | 2 +- .../pyproject.toml | 2 +- .../eegprep_ext_plot_browser/pyproject.toml | 2 +- .../pyproject.toml | 2 +- .../eegprep_ext_template/pyproject.toml | 2 +- pre-commit.py | 36 +- pyproject.toml | 27 +- tests/test_browser_dependency_floors.py | 137 ++ tests/test_extension_catalog.py | 21 +- tests/test_guifunc_pophelp_chansel.py | 5 +- tests/test_public_api_examples.py | 5 +- uv.lock | 1196 +++-------------- 25 files changed, 394 insertions(+), 1113 deletions(-) create mode 100644 tests/test_browser_dependency_floors.py diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 9fd01576..ba51f5b4 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -52,7 +52,7 @@ jobs: uses: astral-sh/setup-uv@v5 - name: Install Python - run: uv python install 3.11 + run: uv python install 3.12 - name: Run Claude Code id: claude @@ -98,7 +98,7 @@ jobs: uses: astral-sh/setup-uv@v5 - name: Install Python - run: uv python install 3.11 + run: uv python install 3.12 - name: Triage Issue uses: anthropics/claude-code-action@v1 @@ -193,7 +193,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 - name: Install Python - run: uv python install 3.11 + run: uv python install 3.12 - name: Apply Autofix uses: anthropics/claude-code-action@v1 with: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 706f429f..8fde7b70 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -38,10 +38,10 @@ jobs: - name: Install dependencies run: | - uv python install 3.11 + uv python install 3.12 # The torch extra is required: gallery examples call ICLabel, which # raises ImportError without it rather than degrading to a skip. - uv sync --python 3.11 --extra docs --extra torch + uv sync --python 3.12 --extra docs --extra torch - name: Build HTML documentation run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75b0f2b2..9a6f2c05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,8 +39,8 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Install Python 3.11 - run: uv python install 3.11 + - name: Install Python 3.12 + run: uv python install 3.12 - name: Install Linux GUI runtime libraries # PySide6 needs these to import at all; without them the GUI tests fail @@ -62,7 +62,7 @@ jobs: echo "QT_QPA_PLATFORM=offscreen" >> "$GITHUB_ENV" - name: Install dependencies - run: uv sync --python 3.11 --all-extras --group dev + run: uv sync --python 3.12 --all-extras --group dev - name: Run Ruff lint run: uv run --no-sync ruff check . @@ -95,8 +95,8 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Install Python 3.11 - run: uv python install 3.11 + - name: Install Python 3.12 + run: uv python install 3.12 - name: Build sdist and wheel # `uv build`, not `python -m build`: the repo's build/ output directory @@ -150,7 +150,7 @@ jobs: - name: Smoke test the built wheel run: | set -euo pipefail - uv venv --python 3.11 /tmp/relcheck + uv venv --python 3.12 /tmp/relcheck VIRTUAL_ENV=/tmp/relcheck uv pip install dist/*.whl VIRTUAL_ENV=/tmp/relcheck uv run --no-project python -c " import eegprep diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6d65683e..60afe629 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,13 +55,13 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Install Python 3.11 + - name: Install Python 3.12 run: | - uv python install 3.11 + uv python install 3.12 - name: Install dependencies run: | - uv sync --python 3.11 --all-extras --group dev + uv sync --python 3.12 --all-extras --group dev - name: Run Ruff lint run: | @@ -83,7 +83,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-22.04] - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.12", "3.13"] include: # Test on macOS and Windows for Python 3.12 only - os: macos-latest @@ -172,7 +172,18 @@ jobs: if: steps.matlab-availability.outputs.available == 'true' shell: bash run: | - uv pip install "${MATLAB_ROOT}/extern/engines/python" + # Each MATLAB release ships one engine build covering a fixed set of Python + # versions, and R2024b stops at 3.12: + # "MATLAB Engine for Python supports Python version 3.9, 3.10, 3.11, and + # 3.12, but your version of Python is 3.13" + # A Python version the pinned release does not cover is not a CI failure. It + # means the MATLAB parity tests cannot run, which the availability check below + # already handles by falling through to the plain pytest step. Letting the + # install decide keeps this working automatically once a newer MATLAB release + # covers the version, instead of encoding a compatibility table here. + if ! uv pip install "${MATLAB_ROOT}/extern/engines/python"; then + echo "MATLAB engine does not support Python ${{ matrix.python-version }}; MATLAB parity tests will be skipped" + fi - name: Check MATLAB Engine availability id: matlab-engine-availability diff --git a/.python-version b/.python-version index 2c073331..e4fba218 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.11 +3.12 diff --git a/AGENTS.md b/AGENTS.md index 3659decb..3c8ca38f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,9 +130,9 @@ Primary references: ## Dependencies -- Python support starts at 3.10 per `pyproject.toml`. +- Python support starts at 3.12 per `pyproject.toml`. - `uv` is the default package and environment manager for development, CI, and agent workflows. -- `.python-version` sets the default development interpreter to Python 3.11. +- `.python-version` sets the default development interpreter to Python 3.12. - Use `uv sync --group dev` after cloning or when dependencies change. - Use `uv run python ...` for Python commands so tests run inside the project environment. - Do not add dependencies for tiny helpers. If a dependency is justified, update `pyproject.toml`, docs, and any CI/install notes. diff --git a/README.md b/README.md index f8b874fb..1afb579e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Tests](https://github.com/sccn/eegprep/actions/workflows/test.yml/badge.svg)](https://github.com/sccn/eegprep/actions/workflows/test.yml) [![Documentation Status](https://github.com/sccn/eegprep/actions/workflows/docs.yml/badge.svg)](https://github.com/sccn/eegprep/actions/workflows/docs.yml) -[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/) +[![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/) [![License](https://img.shields.io/badge/license-BSD--3--Clause-blue)](LICENSE) EEGPrep is an EEGLAB-compatible, Python-native toolkit for loading, cleaning, diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index 35bd13c3..708939e3 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -56,7 +56,7 @@ Create the uv-managed development environment: .. code-block:: bash - uv python install 3.11 + uv python install 3.12 uv sync --group dev Install Dependencies diff --git a/docs/source/development.rst b/docs/source/development.rst index dd941631..f65b80ad 100644 --- a/docs/source/development.rst +++ b/docs/source/development.rst @@ -12,7 +12,7 @@ Prerequisites System Requirements ------------------- -- **Python**: 3.11 or higher +- **Python**: 3.12 or higher - **Git**: For version control - **uv**: Default package and environment manager @@ -54,7 +54,7 @@ Install the default development environment: .. code-block:: bash - uv python install 3.11 + uv python install 3.12 uv sync --group dev ``uv sync`` creates ``.venv/`` and installs EEGPrep in editable mode from the @@ -244,7 +244,7 @@ Running it git clone https://github.com/sccn/eegprep_parity_test cd eegprep_parity_test - uv venv --python 3.11 .venv && source .venv/bin/activate + uv venv --python 3.12 .venv && source .venv/bin/activate pip install -r requirements.txt pip install "$MATLABROOT/extern/engines/python" export EEGPREP_EEGLAB_ROOT=/path/to/eeglab diff --git a/docs/source/faq.rst b/docs/source/faq.rst index 98d3c821..75f84a68 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -10,7 +10,8 @@ Installation FAQ What Python versions does EEGPrep support? ------------------------------------------ -EEGPrep supports Python 3.10 and higher. We recommend using Python 3.11 or 3.12 for development. +EEGPrep supports Python 3.12 and higher. +We recommend using Python 3.12 or 3.13 for development. How do I install EEGPrep? ------------------------- @@ -39,7 +40,7 @@ What are the system requirements? --------------------------------- - **Operating System**: Linux, macOS, or Windows -- **Python**: 3.10 or higher +- **Python**: 3.12 or higher - **RAM**: Minimum 4GB (8GB+ recommended for large datasets) - **Disk Space**: 500MB for installation and dependencies diff --git a/docs/source/user_guide/extension_curation.rst b/docs/source/user_guide/extension_curation.rst index fd3ae1b5..b701a349 100644 --- a/docs/source/user_guide/extension_curation.rst +++ b/docs/source/user_guide/extension_curation.rst @@ -83,7 +83,7 @@ JSON file in a directory. "version": "1.0.0", "api_version": "1", "eegprep_requires": ">=0.2", - "python_requires": ">=3.10", + "python_requires": ">=3.12", "license": "BSD-3-Clause", "maintainer": {"name": "SCCN", "email": "maintainers@example.org"}, "docs_url": "https://example.org/eegprep-ext-example", diff --git a/docs/source/user_guide/installation.rst b/docs/source/user_guide/installation.rst index 49a98696..ab4e22ad 100644 --- a/docs/source/user_guide/installation.rst +++ b/docs/source/user_guide/installation.rst @@ -11,7 +11,7 @@ System Requirements Before installing eegprep, ensure your system meets the following requirements: -- **Python**: 3.10 or higher +- **Python**: 3.12 or higher - **uv**: Default package and environment manager for source installs and development - **pip**: Optional fallback for published package installs - **conda**: Optional environment manager when required by a local setup diff --git a/examples/extensions/eegprep_ext_file_io/pyproject.toml b/examples/extensions/eegprep_ext_file_io/pyproject.toml index 7f2bbfea..d877dbc5 100644 --- a/examples/extensions/eegprep_ext_file_io/pyproject.toml +++ b/examples/extensions/eegprep_ext_file_io/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "eegprep-ext-file-io" version = "0.1.0" description = "EEGPrep extension example for file importer and exporter actions" -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = ["eegprep>=0.2.23", "numpy>=1.23"] [project.entry-points."eegprep.extensions"] diff --git a/examples/extensions/eegprep_ext_gui_dialog/pyproject.toml b/examples/extensions/eegprep_ext_gui_dialog/pyproject.toml index 5ff76d35..00803a75 100644 --- a/examples/extensions/eegprep_ext_gui_dialog/pyproject.toml +++ b/examples/extensions/eegprep_ext_gui_dialog/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "eegprep-ext-gui-dialog" version = "0.1.0" description = "EEGPrep extension example for a GUI dialog" -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = ["eegprep>=0.2.23", "numpy>=1.23"] [project.entry-points."eegprep.extensions"] diff --git a/examples/extensions/eegprep_ext_optional_dependency/pyproject.toml b/examples/extensions/eegprep_ext_optional_dependency/pyproject.toml index 7bc2dba6..88176902 100644 --- a/examples/extensions/eegprep_ext_optional_dependency/pyproject.toml +++ b/examples/extensions/eegprep_ext_optional_dependency/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "eegprep-ext-optional-dependency" version = "0.1.0" description = "EEGPrep extension example for optional dependencies and packaged model data" -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = ["eegprep>=0.2.23"] [project.optional-dependencies] diff --git a/examples/extensions/eegprep_ext_plot_browser/pyproject.toml b/examples/extensions/eegprep_ext_plot_browser/pyproject.toml index 3d9613ba..49fad952 100644 --- a/examples/extensions/eegprep_ext_plot_browser/pyproject.toml +++ b/examples/extensions/eegprep_ext_plot_browser/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "eegprep-ext-plot-browser" version = "0.1.0" description = "EEGPrep extension example for plot/browser-style actions" -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = ["eegprep>=0.2.23"] [project.entry-points."eegprep.extensions"] diff --git a/examples/extensions/eegprep_ext_signal_transform/pyproject.toml b/examples/extensions/eegprep_ext_signal_transform/pyproject.toml index 64f018c6..4aa35924 100644 --- a/examples/extensions/eegprep_ext_signal_transform/pyproject.toml +++ b/examples/extensions/eegprep_ext_signal_transform/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "eegprep-ext-signal-transform" version = "0.1.0" description = "EEGPrep extension example for a pure signal transform" -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = ["eegprep>=0.2.23", "numpy>=1.23"] [project.entry-points."eegprep.extensions"] diff --git a/examples/extensions/eegprep_ext_template/pyproject.toml b/examples/extensions/eegprep_ext_template/pyproject.toml index eca7cb23..673d7c0b 100644 --- a/examples/extensions/eegprep_ext_template/pyproject.toml +++ b/examples/extensions/eegprep_ext_template/pyproject.toml @@ -7,7 +7,7 @@ name = "eegprep-ext-template" version = "0.1.0" description = "Minimal EEGPrep extension template package" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = [ "eegprep>=0.2.23", "numpy>=1.23", diff --git a/pre-commit.py b/pre-commit.py index ff8c998c..37d49f05 100755 --- a/pre-commit.py +++ b/pre-commit.py @@ -1,10 +1,9 @@ #!/bin/sh "exec" "uv" "run" "--script" "$0" "$@" # /// script -# requires-python = ">=3.10" +# requires-python = ">=3.12" # dependencies = [ # "pyyaml", -# "tomli; python_version < '3.11'", # ] # /// # @@ -24,7 +23,7 @@ # # Checks currently run: # - Python syntax parsing for *.py files. -# - JSON, TOML, YAML syntax parsing when dependencies are available. +# - JSON, TOML and YAML syntax parsing. # - Jupyter notebooks have no code-cell outputs or execution counts. # - Text files contain no merge conflict markers. # - Text files contain no trailing whitespace. @@ -50,18 +49,19 @@ from collections.abc import Callable from dataclasses import dataclass, field -try: - import tomllib -except ModuleNotFoundError: # pragma: no cover - only used on Python 3.10 - try: - import tomli as tomllib - except ModuleNotFoundError: - tomllib = None # type: ignore[assignment] +import tomllib try: import yaml -except ModuleNotFoundError: - yaml = None # type: ignore[assignment] +except ModuleNotFoundError: # pragma: no cover - only when run outside uv + # pyyaml is declared in this script's inline dependencies, so uv installs it + # before the body runs. Reaching here means the script was started without + # uv, and skipping the YAML checks would let the run report OK having + # checked nothing. Fail loudly instead. + sys.exit( + "PyYAML is missing. Run this script through uv so its declared " + "dependencies are installed: uv run --script pre-commit.py" + ) ROOT_DIR = pathlib.Path(__file__).resolve().parent @@ -309,27 +309,15 @@ def check_merge_conflicts(files: list[pathlib.Path], fix: bool) -> int: def check_config_syntax(files: list[pathlib.Path], fix: bool) -> int: del fix errors = [] - warned_missing_toml = False - warned_missing_yaml = False for file_path in files: suffix = file_path.suffix.lower() try: if suffix == ".json": json.loads(file_path.read_text(encoding="utf-8")) elif suffix == ".toml": - if tomllib is None: - if not warned_missing_toml: - echo(" Warning: tomli is not installed; skipping TOML syntax checks") - warned_missing_toml = True - continue with file_path.open("rb") as handle: tomllib.load(handle) elif suffix in {".yaml", ".yml"}: - if yaml is None: - if not warned_missing_yaml: - echo(" Warning: PyYAML is not installed; skipping YAML syntax checks") - warned_missing_yaml = True - continue yaml.safe_load(file_path.read_text(encoding="utf-8")) except Exception as error: errors.append((file_path, error)) diff --git a/pyproject.toml b/pyproject.toml index 8132821f..0d4ff924 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [ description = "EEG preprocessing pipeline on Python" readme = { file = "README.md", content-type = "text/markdown" } license = { file = "LICENSE" } -requires-python = ">=3.10" +requires-python = ">=3.12" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Science/Research", @@ -25,11 +25,17 @@ keywords = ["eeg", "preprocessing", "EEGLAB"] dependencies = [ "colorama>=0.4.6", "eeglabio>=0.1.2", - "h5py>=3.3.0", - "matplotlib>=3.4.0", + # The h5py, matplotlib, numpy and threadpoolctl floors here, and the scipy + # floors below, are capped by what Pyodide 0.29.5 ships for a browser install. + # Raising one past Pyodide's shipped version breaks eegprep in the browser, so + # check against the Pyodide release before bumping any of them. + # Shipped by 0.29.5: h5py 3.13.0, matplotlib 3.8.4, numpy 2.2.5, scipy 1.14.1, + # threadpoolctl 3.5.0. tests/test_browser_dependency_floors.py enforces this. + "h5py>=3.12.1", + "matplotlib>=3.8.0", "mne>=1.10.0", "neo>=0.14.2", - "numpy>=1.20", + "numpy>=2.1.0", "oct2py>=5.5.0", "packaging>=23.0", "psutil>=7.0.0", @@ -37,8 +43,12 @@ dependencies = [ "pyedflib>=0.1.42", "pyyaml>=6.0", "python-picard>=0.8,<0.9", - "scipy>=1.7.0", - "threadpoolctl>=3.6.0", + # 1.14.1 is what Pyodide 0.29.5 ships, so the floor cannot go above it without + # making a browser install impossible. macOS needs more: scipy built before + # 1.16.3 fails to dlopen on current macOS with a zero-fill section error. + "scipy>=1.14.1; sys_platform != 'darwin'", + "scipy>=1.16.3; sys_platform == 'darwin'", + "threadpoolctl>=3.5.0", ] [project.optional-dependencies] @@ -86,7 +96,6 @@ dev = [ "pyqtgraph>=0.13.7", "PySide6>=6.6", "ruff>=0.15.14", - "tomli>=2.0; python_version < '3.11'", "ty>=0.0.39", ] release = [ @@ -112,7 +121,7 @@ required-environments = [ ] [tool.ruff] -target-version = "py310" +target-version = "py312" line-length = 120 extend-exclude = [ "pre-commit.py", @@ -133,7 +142,7 @@ select = ["E4", "E7", "E9", "F"] "tests/matlab/*.py" = ["E402"] [tool.ty.environment] -python-version = "3.10" +python-version = "3.12" root = ["."] [tool.ty.src] diff --git a/tests/test_browser_dependency_floors.py b/tests/test_browser_dependency_floors.py new file mode 100644 index 00000000..6405c995 --- /dev/null +++ b/tests/test_browser_dependency_floors.py @@ -0,0 +1,137 @@ +"""Keep eegprep installable in the browser. + +Pyodide bundles its own build of every compiled scientific package, so a dependency floor +above the version a Pyodide release ships cannot be satisfied in the browser: micropip has +no WebAssembly build to fall back to. That makes ``requires-python`` and these floors a +*ceiling* for the browser target, which is the opposite of how a floor usually reads, and it +is invisible from the code. A routine bump made for an unrelated reason is enough to break it. + +``epic/324-pyodide-browser`` already carries ``tools/check_pyodide_base_resolution.py``, but +that gate matches package *names* against the Pyodide lock and never compares versions, so a +floor above what Pyodide ships passes it (sccn/eegprep#400). These tests close that gap on +``develop``, offline and without a network fetch. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import pytest +from packaging.markers import Marker +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.version import Version + +PYPROJECT_PATH = Path(__file__).resolve().parents[1] / "pyproject.toml" + +# The Pyodide release the browser work targets, and the versions it bundles. Read from +# https://cdn.jsdelivr.net/pyodide/v0.29.5/full/pyodide-lock.json on 2026-09-20. Update both +# the version and this table together when the target release moves. +PYODIDE_VERSION = "0.29.5" +PYODIDE_PYTHON = "3.13.2" +PYODIDE_SHIPS = { + "h5py": "3.13.0", + "matplotlib": "3.8.4", + "numpy": "2.2.5", + "scipy": "1.14.1", + "threadpoolctl": "3.5.0", +} + +# A packaging marker environment describing Pyodide's interpreter, so a dependency guarded by +# a marker is evaluated the way the browser would evaluate it. Pyodide reports +# ``sys.platform == "emscripten"``, which is what makes the darwin-only scipy floor inactive here. +PYODIDE_ENVIRONMENT = { + "implementation_name": "cpython", + "implementation_version": PYODIDE_PYTHON, + "os_name": "posix", + "platform_machine": "wasm32", + "platform_python_implementation": "CPython", + "platform_release": "", + "platform_system": "Emscripten", + "platform_version": "", + "python_full_version": PYODIDE_PYTHON, + "python_version": ".".join(PYODIDE_PYTHON.split(".")[:2]), + "sys_platform": "emscripten", +} + + +def _project() -> dict: + return tomllib.loads(PYPROJECT_PATH.read_text(encoding="utf-8"))["project"] + + +def _requirements_active_in_pyodide() -> dict[str, Requirement]: + """Base requirements whose markers hold under Pyodide, keyed by normalized name.""" + active: dict[str, Requirement] = {} + for raw in _project()["dependencies"]: + requirement = Requirement(raw) + if requirement.marker is not None and not requirement.marker.evaluate(PYODIDE_ENVIRONMENT): + continue + name = requirement.name.lower().replace("_", "-") + assert name not in active, f"{name} has two requirements active at once under Pyodide: {raw}" + active[name] = requirement + return active + + +def _declared_floor(requirement: Requirement) -> Version: + floors = [Version(spec.version) for spec in requirement.specifier if spec.operator in {">=", "==", "~="}] + assert floors, f"{requirement.name} declares no lower bound, so nothing pins it for the browser" + return max(floors) + + +@pytest.mark.parametrize("package", sorted(PYODIDE_SHIPS)) +def test_floor_does_not_exceed_what_pyodide_ships(package: str) -> None: + requirement = _requirements_active_in_pyodide().get(package) + assert requirement is not None, f"{package} is no longer a base dependency; drop it from PYODIDE_SHIPS" + + floor = _declared_floor(requirement) + shipped = Version(PYODIDE_SHIPS[package]) + assert floor <= shipped, ( + f"{package}>={floor} is above the {shipped} that Pyodide {PYODIDE_VERSION} ships, " + f"so a browser install cannot satisfy it" + ) + + +@pytest.mark.parametrize("package", sorted(PYODIDE_SHIPS)) +def test_pyodide_version_satisfies_the_whole_specifier(package: str) -> None: + """The floor is not the only bound; an upper bound can exclude Pyodide's build too.""" + requirement = _requirements_active_in_pyodide().get(package) + assert requirement is not None, f"{package} is no longer a base dependency; drop it from PYODIDE_SHIPS" + + shipped = PYODIDE_SHIPS[package] + assert requirement.specifier.contains(shipped, prereleases=True), ( + f"Pyodide {PYODIDE_VERSION} ships {package} {shipped}, which {requirement} excludes" + ) + + +def test_requires_python_admits_the_pyodide_interpreter() -> None: + """requires-python is a ceiling here: Pyodide runs one CPython and cannot be told otherwise.""" + requires_python = SpecifierSet(_project()["requires-python"]) + assert requires_python.contains(PYODIDE_PYTHON, prereleases=True), ( + f"requires-python {requires_python} excludes the CPython {PYODIDE_PYTHON} that " + f"Pyodide {PYODIDE_VERSION} runs, so eegprep cannot be installed in the browser" + ) + + +def test_the_darwin_scipy_floor_stays_out_of_the_browser() -> None: + """The macOS-only scipy floor is above what Pyodide ships, and must stay marker-guarded.""" + darwin = dict(PYODIDE_ENVIRONMENT, sys_platform="darwin", platform_system="Darwin") + darwin_floors = [ + _declared_floor(requirement) + for raw in _project()["dependencies"] + if (requirement := Requirement(raw)).name == "scipy" + and requirement.marker is not None + and requirement.marker.evaluate(darwin) + ] + assert darwin_floors, "the darwin-specific scipy floor is gone; update or remove this test" + assert max(darwin_floors) > Version(PYODIDE_SHIPS["scipy"]), ( + "the darwin scipy floor no longer exceeds Pyodide's build, so the marker split may be " + "unnecessary; confirm before removing this test" + ) + assert all( + Marker(str(requirement.marker)).evaluate(PYODIDE_ENVIRONMENT) is False + for raw in _project()["dependencies"] + if (requirement := Requirement(raw)).name == "scipy" + and requirement.marker is not None + and _declared_floor(requirement) > Version(PYODIDE_SHIPS["scipy"]) + ), "a scipy floor above Pyodide's build is active under Pyodide" diff --git a/tests/test_extension_catalog.py b/tests/test_extension_catalog.py index a723abe7..7e38b052 100644 --- a/tests/test_extension_catalog.py +++ b/tests/test_extension_catalog.py @@ -365,6 +365,22 @@ def test_unsupported_eegprep_version_is_reported() -> None: assert "requires EEGPrep >=999.0" in _messages(report) +def test_unsupported_python_version_is_reported() -> None: + report = validate_catalog_entries([_catalog_entry(python_requires=">=99.0")]) + + assert not report.ok + assert "Extension requires Python >=99.0" in _messages(report) + + +def test_malformed_python_requires_reports_single_field_error() -> None: + report = validate_catalog_entries([_catalog_entry(python_requires="not-a-spec")]) + + assert not report.ok + assert [issue.field for issue in report.errors] == ["python_requires"] + assert report.errors[0].message == "Must be a simple version specifier" + assert "Extension requires Python not-a-spec" not in _messages(report) + + def test_malformed_eegprep_requires_reports_single_field_error() -> None: report = validate_catalog_entries([_catalog_entry(eegprep_requires="not-a-spec")]) @@ -406,7 +422,10 @@ def _catalog_entry(**overrides: Any) -> dict[str, Any]: "version": "1.0.0", "api_version": "1", "eegprep_requires": ">=0.2", - "python_requires": ">=3.10", + # Deliberately far below any interpreter this suite runs on. The happy path + # must not depend on the project's Python floor, or every floor bump breaks + # an unrelated fixture. The floor itself is covered by the two tests below. + "python_requires": ">=3.0", "license": "BSD-3-Clause", "maintainer": {"name": "SCCN", "email": "maintainers@example.org"}, "docs_url": "https://example.org/docs", diff --git a/tests/test_guifunc_pophelp_chansel.py b/tests/test_guifunc_pophelp_chansel.py index 358020eb..95b417c7 100644 --- a/tests/test_guifunc_pophelp_chansel.py +++ b/tests/test_guifunc_pophelp_chansel.py @@ -2,10 +2,7 @@ from importlib import resources from pathlib import Path -try: - import tomllib -except ModuleNotFoundError: - import tomli as tomllib +import tomllib from eegprep.functions.guifunc.eeglab_menu import eeglab_menus, menu_actions from eegprep.functions.guifunc.menu_actions import action_kind diff --git a/tests/test_public_api_examples.py b/tests/test_public_api_examples.py index 946fcf33..2880309d 100644 --- a/tests/test_public_api_examples.py +++ b/tests/test_public_api_examples.py @@ -11,10 +11,7 @@ from tests.eeglab_tests import eeglab_test -try: - import tomllib -except ModuleNotFoundError: # pragma: no cover - Python < 3.11 - import tomli as tomllib +import tomllib REPO_ROOT = Path(__file__).resolve().parents[1] diff --git a/uv.lock b/uv.lock index 2d8547ff..56004772 100644 --- a/uv.lock +++ b/uv.lock @@ -1,20 +1,11 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.11'", + "python_full_version >= '3.13' and sys_platform != 'darwin'", + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version < '3.13' and sys_platform != 'darwin'", + "python_full_version < '3.13' and sys_platform == 'darwin'", ] required-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -88,15 +79,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] -[[package]] -name = "backports-tarfile" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, -] - [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -141,11 +123,9 @@ name = "build" version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "os_name == 'nt'" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "colorama", marker = "os_name == 'nt' and sys_platform != 'darwin'" }, { name = "packaging" }, { name = "pyproject-hooks" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } wheels = [ @@ -170,31 +150,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, @@ -249,38 +204,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, @@ -387,26 +310,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, - { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, - { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, - { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, - { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, - { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, - { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, - { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, - { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, @@ -437,12 +340,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, - { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, - { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, - { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, - { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, ] [[package]] @@ -450,48 +347,37 @@ name = "cryptography" version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.11' and platform_python_implementation != 'PyPy' and sys_platform == 'emscripten') or (python_full_version < '3.11' and platform_python_implementation != 'PyPy' and sys_platform == 'win32') or (platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, ] [[package]] @@ -499,13 +385,9 @@ name = "cuda-bindings" version = "13.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "cuda-pathfinder" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/fe/7351d7e586a8b4c9f89731bfe4cf0148223e8f9903ff09571f78b3fb0682/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b395f79cb89ce0cd8effff07c4a1e20101b873c256a1aeb286e8fd7bd0f556", size = 5744254, upload-time = "2026-03-11T00:12:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ef/184aa775e970fc089942cd9ec6302e6e44679d4c14549c6a7ea45bf7f798/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6f3682ec3c4769326aafc67c2ba669d97d688d0b7e63e659d36d2f8b72f32d6", size = 6329075, upload-time = "2026-03-11T00:12:32.319Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" }, - { url = "https://files.pythonhosted.org/packages/e9/94/2748597f47bb1600cd466b20cab4159f1530a3a33fe7f70fee199b3abb9e/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1eba9504ac70667dd48313395fe05157518fd6371b532790e96fbb31bbb5a5e1", size = 6313924, upload-time = "2026-03-11T00:12:39.462Z" }, { url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" }, { url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" }, { url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" }, @@ -534,34 +416,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -579,14 +461,6 @@ version = "1.8.20" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/be/8bd693a0b9d53d48c8978fa5d889e06f3b5b03e45fd1ea1e78267b4887cb/debugpy-1.8.20-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:157e96ffb7f80b3ad36d808646198c90acb46fdcfd8bb1999838f0b6f2b59c64", size = 2099192, upload-time = "2026-01-29T23:03:29.707Z" }, - { url = "https://files.pythonhosted.org/packages/77/1b/85326d07432086a06361d493d2743edd0c4fc2ef62162be7f8618441ac37/debugpy-1.8.20-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c1178ae571aff42e61801a38b007af504ec8e05fde1c5c12e5a7efef21009642", size = 3088568, upload-time = "2026-01-29T23:03:31.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/60/3e08462ee3eccd10998853eb35947c416e446bfe2bc37dbb886b9044586c/debugpy-1.8.20-cp310-cp310-win32.whl", hash = "sha256:c29dd9d656c0fbd77906a6e6a82ae4881514aa3294b94c903ff99303e789b4a2", size = 5284399, upload-time = "2026-01-29T23:03:33.678Z" }, - { url = "https://files.pythonhosted.org/packages/72/43/09d49106e770fe558ced5e80df2e3c2ebee10e576eda155dcc5670473663/debugpy-1.8.20-cp310-cp310-win_amd64.whl", hash = "sha256:3ca85463f63b5dd0aa7aaa933d97cbc47c174896dcae8431695872969f981893", size = 5316388, upload-time = "2026-01-29T23:03:35.095Z" }, - { url = "https://files.pythonhosted.org/packages/51/56/c3baf5cbe4dd77427fd9aef99fcdade259ad128feeb8a786c246adb838e5/debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b", size = 2208318, upload-time = "2026-01-29T23:03:36.481Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7d/4fa79a57a8e69fe0d9763e98d1110320f9ecd7f1f362572e3aafd7417c9d/debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344", size = 3171493, upload-time = "2026-01-29T23:03:37.775Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f2/1e8f8affe51e12a26f3a8a8a4277d6e60aa89d0a66512f63b1e799d424a4/debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec", size = 5209240, upload-time = "2026-01-29T23:03:39.109Z" }, - { url = "https://files.pythonhosted.org/packages/d5/92/1cb532e88560cbee973396254b21bece8c5d7c2ece958a67afa08c9f10dc/debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb", size = 5233481, upload-time = "2026-01-29T23:03:40.659Z" }, { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" }, { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, @@ -632,7 +506,8 @@ version = "0.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, - { name = "scipy" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, + { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/f9/071281281e7ce4cde8f65ba7b919feef3b10fd9739b53369edb13489e5ea/eeglabio-0.1.3.tar.gz", hash = "sha256:0dbb48ba5a5bc1ebe156acf1b1cc793575928f9a6f73457a05eef567967f9bf3", size = 10113, upload-time = "2026-01-20T18:07:18.901Z" } wheels = [ @@ -657,48 +532,39 @@ dependencies = [ { name = "pyedflib" }, { name = "python-picard" }, { name = "pyyaml" }, - { name = "scipy" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, + { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, { name = "threadpoolctl" }, ] [package.optional-dependencies] all = [ - { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipython" }, + { name = "myst-parser" }, { name = "numpydoc" }, { name = "pydata-sphinx-theme" }, { name = "pyqtgraph" }, { name = "pyside6" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, { name = "sphinx-copybutton" }, - { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-design" }, { name = "sphinx-gallery" }, { name = "sphinx-togglebutton" }, { name = "sphinxcontrib-spelling" }, { name = "torch" }, ] console = [ - { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipython" }, { name = "pyqtgraph" }, { name = "pyside6" }, ] docs = [ - { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "myst-parser" }, { name = "numpydoc" }, { name = "pydata-sphinx-theme" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, { name = "sphinx-copybutton" }, - { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-design" }, { name = "sphinx-gallery" }, { name = "sphinx-togglebutton" }, { name = "sphinxcontrib-spelling" }, @@ -713,13 +579,11 @@ torch = [ [package.dev-dependencies] dev = [ - { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipython" }, { name = "pyqtgraph" }, { name = "pyside6" }, { name = "pytest" }, { name = "ruff" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "ty" }, ] release = [ @@ -736,13 +600,13 @@ requires-dist = [ { name = "eegprep", extras = ["gui"], marker = "extra == 'all'" }, { name = "eegprep", extras = ["gui"], marker = "extra == 'console'" }, { name = "eegprep", extras = ["torch"], marker = "extra == 'all'" }, - { name = "h5py", specifier = ">=3.3.0" }, + { name = "h5py", specifier = ">=3.12.1" }, { name = "ipython", marker = "extra == 'console'", specifier = ">=8.0" }, - { name = "matplotlib", specifier = ">=3.4.0" }, + { name = "matplotlib", specifier = ">=3.8.0" }, { name = "mne", specifier = ">=1.10.0" }, { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=1.0.0" }, { name = "neo", specifier = ">=0.14.2" }, - { name = "numpy", specifier = ">=1.20" }, + { name = "numpy", specifier = ">=2.1.0" }, { name = "numpydoc", marker = "extra == 'docs'", specifier = ">=1.6.0" }, { name = "oct2py", specifier = ">=5.5.0" }, { name = "packaging", specifier = ">=23.0" }, @@ -754,14 +618,15 @@ requires-dist = [ { name = "pyside6", marker = "extra == 'gui'", specifier = ">=6.6" }, { name = "python-picard", specifier = ">=0.8,<0.9" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "scipy", specifier = ">=1.7.0" }, + { name = "scipy", marker = "sys_platform != 'darwin'", specifier = ">=1.14.1" }, + { name = "scipy", marker = "sys_platform == 'darwin'", specifier = ">=1.16.3" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0" }, { name = "sphinx-copybutton", marker = "extra == 'docs'", specifier = ">=0.5.0" }, { name = "sphinx-design", marker = "extra == 'docs'", specifier = ">=0.5.0" }, { name = "sphinx-gallery", marker = "extra == 'docs'", specifier = ">=0.14.0" }, { name = "sphinx-togglebutton", marker = "extra == 'docs'", specifier = ">=0.3.0" }, { name = "sphinxcontrib-spelling", marker = "extra == 'docs'", specifier = ">=7.1.0" }, - { name = "threadpoolctl", specifier = ">=3.6.0" }, + { name = "threadpoolctl", specifier = ">=3.5.0" }, { name = "torch", marker = "extra == 'torch'", specifier = ">=2.0" }, ] provides-extras = ["torch", "gui", "console", "docs", "all"] @@ -773,7 +638,6 @@ dev = [ { name = "pyside6", specifier = ">=6.6" }, { name = "pytest", specifier = ">=8.0" }, { name = "ruff", specifier = ">=0.15.14" }, - { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "ty", specifier = ">=0.0.39" }, ] release = [ @@ -781,18 +645,6 @@ release = [ { name = "twine", specifier = ">=5.0" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "executing" version = "2.2.1" @@ -817,22 +669,6 @@ version = "4.62.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" }, - { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" }, - { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" }, - { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" }, - { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" }, - { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" }, - { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, - { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, - { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, - { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, - { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, @@ -877,7 +713,8 @@ dependencies = [ { name = "narwhals" }, { name = "numpy" }, { name = "pandas" }, - { name = "scipy" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, + { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, { name = "typing-extensions" }, { name = "wrapt" }, ] @@ -892,26 +729,6 @@ version = "2.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/90/b2/2a3d1374b7780999d3184e171e25439a8358c47b481f68be883c14086b4c/frozendict-2.4.7.tar.gz", hash = "sha256:e478fb2a1391a56c8a6e10cc97c4a9002b410ecd1ac28c18d780661762e271bd", size = 317082, upload-time = "2025-11-11T22:40:14.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/bd/920b1c5ff1df427a5fc3fd4c2f13b0b0e720c3d57fafd80557094c1fefe0/frozendict-2.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bd37c087a538944652363cfd77fb7abe8100cc1f48afea0b88b38bf0f469c3d2", size = 59848, upload-time = "2025-11-11T22:37:10.964Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9c/e3e186925b1d84f816d458be4e2ea785bbeba15fd2e9e85c5ae7e7a90421/frozendict-2.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b96f224a5431889f04b2bc99c0e9abe285679464273ead83d7d7f2a15907d35", size = 38164, upload-time = "2025-11-11T22:37:12.622Z" }, - { url = "https://files.pythonhosted.org/packages/10/4c/af931d88c51ee2fcbf8c817557dcb975133a188f1b44bfa82caa940beeab/frozendict-2.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c1781f28c4bbb177644b3cb6d5cf7da59be374b02d91cdde68d1d5ef32e046b", size = 38341, upload-time = "2025-11-11T22:37:13.611Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7a/c1fd4f736758cf93939cc3b7c8399fe1db0c121881431d41fcdbae344343/frozendict-2.4.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8a06f6c3d3b8d487226fdde93f621e04a54faecc5bf5d9b16497b8f9ead0ac3e", size = 112882, upload-time = "2025-11-11T22:37:15.098Z" }, - { url = "https://files.pythonhosted.org/packages/bd/b0/304294f7cd099582a98d63e7a9cec34a9905d07f7628b42fc3f9c9a9bc94/frozendict-2.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b809d1c861436a75b2b015dbfd94f6154fa4e7cb0a70e389df1d5f6246b21d1e", size = 120482, upload-time = "2025-11-11T22:37:16.182Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/689212ea4124fcbd097c0ac02c2c6a4e345ccc132d9104d054ff6b43ab64/frozendict-2.4.7-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75eefdf257a84ea73d553eb80d0abbff0af4c9df62529e4600fd3f96ff17eeb3", size = 113527, upload-time = "2025-11-11T22:37:17.389Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9b/38a762f4e76903efd4340454cac2820f583929457822111ef6a00ff1a3f4/frozendict-2.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4d2b27d8156922c9739dd2ff4f3934716e17cfd1cf6fb61aa17af7d378555e9", size = 130068, upload-time = "2025-11-11T22:37:18.494Z" }, - { url = "https://files.pythonhosted.org/packages/cf/41/9751e9ec1a2e810e8f961aea4f8958953157478daff6b868277ab7c5ef8c/frozendict-2.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ebd953c41408acfb8041ff9e6c3519c09988fb7e007df7ab6b56e229029d788", size = 126184, upload-time = "2025-11-11T22:37:19.789Z" }, - { url = "https://files.pythonhosted.org/packages/71/be/b179b5f200cb0f52debeccc63b786cabcc408c4542f47c4245f978ad36e3/frozendict-2.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c64d34b802912ee6d107936e970b90750385a1fdfd38d310098b2918ba4cbf2", size = 120168, upload-time = "2025-11-11T22:37:20.929Z" }, - { url = "https://files.pythonhosted.org/packages/25/c2/1536bc363dbce414e6b632f496aa8219c0db459a99eeafa02eba380e4cfa/frozendict-2.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:294a7d7d51dd979021a8691b46aedf9bd4a594ce3ed33a4bdf0a712d6929d712", size = 114997, upload-time = "2025-11-11T22:37:21.888Z" }, - { url = "https://files.pythonhosted.org/packages/29/63/3e9efb490c00a0bf3c7bbf72fc73c90c4a6ebe30595e0fc44f59182b2ae7/frozendict-2.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f65d1b90e9ddc791ea82ef91a9ae0ab27ef6c0cfa88fadfa0e5ca5a22f8fa22f", size = 117292, upload-time = "2025-11-11T22:37:22.978Z" }, - { url = "https://files.pythonhosted.org/packages/5e/66/d25b1e94f9b0e64025d5cadc77b9b857737ebffd8963ee91de7c5a06415a/frozendict-2.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:82d5272d08451bcef6fb6235a0a04cf1816b6b6815cec76be5ace1de17e0c1a4", size = 110656, upload-time = "2025-11-11T22:38:37.652Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5d/0e7e3294e18bf41d38dbc9ee82539be607c8d26e763ae12d9e41f03f2dae/frozendict-2.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5943c3f683d3f32036f6ca975e920e383d85add1857eee547742de9c1f283716", size = 113225, upload-time = "2025-11-11T22:38:38.631Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fb/b72c9b261ac7a7803528aa63bba776face8ad8d39cc4ca4825ddaa7777a9/frozendict-2.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:88c6bea948da03087035bb9ca9625305d70e084aa33f11e17048cb7dda4ca293", size = 126713, upload-time = "2025-11-11T22:38:39.588Z" }, - { url = "https://files.pythonhosted.org/packages/c7/d9/e13af40bd9ef27b5c9ba10b0e31b03acac9468236b878dab030c75102a47/frozendict-2.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ffd1a9f9babec9119712e76a39397d8aa0d72ef8c4ccad917c6175d7e7f81b74", size = 114166, upload-time = "2025-11-11T22:38:41.073Z" }, - { url = "https://files.pythonhosted.org/packages/40/2b/435583b11f5332cd3eb479d0a67a87bc9247c8b094169b07bd8f0777fc48/frozendict-2.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0ff6f57854cc8aa8b30947ec005f9246d96e795a78b21441614e85d39b708822", size = 121542, upload-time = "2025-11-11T22:38:42.199Z" }, - { url = "https://files.pythonhosted.org/packages/38/25/097f3c0dc916d7c76f782cb65544e683ff3940a0ed997fc32efdb0989c45/frozendict-2.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d774df483c12d6cba896eb9a1337bbc5ad3f564eb18cfaaee3e95fb4402f2a86", size = 118610, upload-time = "2025-11-11T22:38:43.339Z" }, - { url = "https://files.pythonhosted.org/packages/61/d1/6964158524484d7f3410386ff27cbc8f33ef06f8d9ee0e188348efb9a139/frozendict-2.4.7-cp310-cp310-win32.whl", hash = "sha256:a10d38fa300f6bef230fae1fdb4bc98706b78c8a3a2f3140fde748469ef3cfe8", size = 34547, upload-time = "2025-11-11T22:38:44.327Z" }, - { url = "https://files.pythonhosted.org/packages/94/27/c22d614332c61ace4406542787edafaf7df533c6f02d1de8979d35492587/frozendict-2.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:dd518f300e5eb6a8827bee380f2e1a31c01dc0af069b13abdecd4e5769bd8a97", size = 37693, upload-time = "2025-11-11T22:38:45.571Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d8/9d6604357b1816586612e0e89bab6d8a9c029e95e199862dc99ce8ae2ed5/frozendict-2.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:3842cfc2d69df5b9978f2e881b7678a282dbdd6846b11b5159f910bc633cbe4f", size = 35563, upload-time = "2025-11-11T22:38:46.642Z" }, { url = "https://files.pythonhosted.org/packages/38/74/f94141b38a51a553efef7f510fc213894161ae49b88bffd037f8d2a7cb2f/frozendict-2.4.7-py3-none-any.whl", hash = "sha256:972af65924ea25cf5b4d9326d549e69a9a4918d8a76a9d3a7cd174d98b237550", size = 16264, upload-time = "2025-11-11T22:40:12.836Z" }, ] @@ -930,26 +747,9 @@ version = "3.2.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b0/f5/3e9eafb4030588337b2a2ae4df46212956854e9069c07b53aa3caabafd47/greenlet-3.2.5.tar.gz", hash = "sha256:c816554eb33e7ecf9ba4defcb1fd8c994e59be6b4110da15480b3e7447ea4286", size = 191501, upload-time = "2026-02-20T20:08:51.539Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/d6/b3db928fc329b1b19ba32ffe143d2305f3aaafc583f5e1074c74ec445189/greenlet-3.2.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:34cc7cf8ab6f4b85298b01e13e881265ee7b3c1daf6bc10a2944abc15d4f87c3", size = 275803, upload-time = "2026-02-20T20:06:42.541Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ff/ab0ad4ff3d9e1faa266de4f6c79763b33fccd9265995f2940192494cc0ec/greenlet-3.2.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c11fe0cfb0ce33132f0b5d27eeadd1954976a82e5e9b60909ec2c4b884a55382", size = 633556, upload-time = "2026-02-20T20:30:41.594Z" }, - { url = "https://files.pythonhosted.org/packages/da/dd/7b3ac77099a1671af8077ecedb12c9a1be1310e4c35bb69fd34c18ab6093/greenlet-3.2.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a145f4b1c4ed7a2c94561b7f18b4beec3d3fb6f0580db22f7ed1d544e0620b34", size = 644943, upload-time = "2026-02-20T20:37:23.084Z" }, - { url = "https://files.pythonhosted.org/packages/56/f0/bea7e7909ea9045b0c5055dad1ec9b81c82b761b4567e625f4f8349acfa1/greenlet-3.2.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:edbf4ab9a7057ee430a678fe2ef37ea5d69125d6bdc7feb42ed8d871c737e63b", size = 640849, upload-time = "2026-02-20T20:43:57.305Z" }, - { url = "https://files.pythonhosted.org/packages/0f/36/84630e9ff1dfc8b7690957c0f77834a84eabdbd9c4977c3a2d0cbd5325c2/greenlet-3.2.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1d01bdd67db3e5711e6246e451d7a0f75fae7bbf40adde129296a7f9aa7cc9", size = 639841, upload-time = "2026-02-20T20:07:17.473Z" }, - { url = "https://files.pythonhosted.org/packages/12/c4/6a2ee6c676dea7a05a3c3c1291fbc8ea44f26456b0accc891471293825af/greenlet-3.2.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd593db7ee1fa8a513a48a404f8cc4126998a48025e3f5cbbc68d51be0a6bf66", size = 588813, upload-time = "2026-02-20T20:07:56.171Z" }, - { url = "https://files.pythonhosted.org/packages/01/c0/75e75c2c993aa850292561ec80f5c263e3924e5843aa95a38716df69304c/greenlet-3.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ac8db07bced2c39b987bba13a3195f8157b0cfbce54488f86919321444a1cc3c", size = 1117377, upload-time = "2026-02-20T20:32:48.452Z" }, - { url = "https://files.pythonhosted.org/packages/ee/03/e38ebf9024a0873fe8f60f5b7bc36bfb3be5e13efe4d798240f2d1f0fb73/greenlet-3.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4544ab2cfd5912e42458b13516429e029f87d8bbcdc8d5506db772941ae12493", size = 1141246, upload-time = "2026-02-20T20:06:23.576Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7b/c6e1192c795c0c12871e199237909a6bd35757d92c8472c7c019959b8637/greenlet-3.2.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:acabf468466d18017e2ae5fbf1a5a88b86b48983e550e1ae1437b69a83d9f4ac", size = 276916, upload-time = "2026-02-20T20:06:18.166Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b6/9887b559f3e1952d23052ec352e9977e808a2246c7cb8282a38337221e88/greenlet-3.2.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:472841de62d60f2cafd60edd4fd4dd7253eb70e6eaf14b8990dcaf177f4af957", size = 636107, upload-time = "2026-02-20T20:30:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/8a/be/e3e48b63bbc27d660fa1d98aecb64906b90a12e686a436169c1330ef34b2/greenlet-3.2.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d951e7d628a6e8b68af469f0fe4f100ef64c4054abeb9cdafbfaa30a920c950", size = 648240, upload-time = "2026-02-20T20:37:24.608Z" }, - { url = "https://files.pythonhosted.org/packages/17/f6/2cbe999683f759f14f598234f04ae8ba6f22953a624b3a7a630003e6bfff/greenlet-3.2.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:87b791dd0e031a574249af717ac36f7031b18c35329561c1e0368201c18caf1f", size = 644170, upload-time = "2026-02-20T20:43:59.002Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ac/e731ed62576e91e533b36d0d97325adc2786674ab9e48ed8a6a24f4ef4e9/greenlet-3.2.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8317d732e2ae0935d9ed2af2ea876fa714cf6f3b887a31ca150b54329b0a6e9", size = 643313, upload-time = "2026-02-20T20:07:19.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/64/99e5cdceb494bd4c1341c45b93f322601d2c8a5e1e4d1c7a2d24c5ed0570/greenlet-3.2.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce8aed6fdd5e07d3cbb988cbdc188266a4eb9e1a52db9ef5c6526e59962d3933", size = 591295, upload-time = "2026-02-20T20:07:57.286Z" }, - { url = "https://files.pythonhosted.org/packages/ee/e9/968e11f388c2b8792d3b8b40a57984c894a3b4745dae3662dce722653bc5/greenlet-3.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:60c06b502d56d5451f60ca665691da29f79ed95e247bcf8ce5024d7bbe64acb9", size = 1120277, upload-time = "2026-02-20T20:32:50.103Z" }, - { url = "https://files.pythonhosted.org/packages/cb/2c/b5f2c4c68d753dce08218dc5a6b21d82238fdfdc44309032f6fe24d285e6/greenlet-3.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d2a78e6f1bf3f1672df91e212a2f8314e1e7c922f065d14cbad4bc815059467", size = 1145746, upload-time = "2026-02-20T20:06:26.296Z" }, { url = "https://files.pythonhosted.org/packages/ad/32/022b21523eee713e7550162d5ca6aed23f913cc2c6232b154b9fd9badc07/greenlet-3.2.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2acb30e77042f747ca81f0a10cc153296567e92e666c5e1b117f4595afd43352", size = 278412, upload-time = "2026-02-20T20:03:15.02Z" }, { url = "https://files.pythonhosted.org/packages/90/c5/8a3b0ed3cc34d8b988a44349437dfa0941f9c23ac108175f7b4ccea97111/greenlet-3.2.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:393c03c26c865f17f31d8db2f09603fadbe0581ad85a5d5908b131549fc38217", size = 644616, upload-time = "2026-02-20T20:30:44.823Z" }, { url = "https://files.pythonhosted.org/packages/b1/2c/2627bea183554695016af6cae93d7474fa90f61e5a6601a84ae7841cb720/greenlet-3.2.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:04e6a202cde56043fd355fefd1552c4caa5c087528121871d950eb4f1b51fa99", size = 658813, upload-time = "2026-02-20T20:37:26.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/c6/a80fc96f7cca7962dd972875d12c52dfabc94cb02bfeb19f3e7e169fca44/greenlet-3.2.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d5583b2ffa677578a384337ee13125bdf9a427485d689014b39d638a4f3d8dbe", size = 653512, upload-time = "2026-02-20T20:44:00.343Z" }, { url = "https://files.pythonhosted.org/packages/2f/1b/75a5aeff487a26ba427a3837da6372f1fe6f2a9c6b2898e28ac99d491c11/greenlet-3.2.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:45fcea7b697b91290b36eafc12fff479aca6ba6500d98ef6f34d5634c7119cbe", size = 655426, upload-time = "2026-02-20T20:07:20.124Z" }, { url = "https://files.pythonhosted.org/packages/53/91/9b5dfb4f3c88f8247c7a8f4c3759f0740bfa6bb0c59a9f6bf938e913df56/greenlet-3.2.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96e2bb8a56b7e1aed1dbfbbe0050cb2ecca99c7c91892fd1771e3afab63b3e3", size = 611138, upload-time = "2026-02-20T20:07:58.966Z" }, { url = "https://files.pythonhosted.org/packages/b4/8d/d0b086410512d9859c84e9242a9b341de9f5566011ddf3a3f6886b842b61/greenlet-3.2.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d7456e67b0be653dfe643bb37d9566cd30939c80f858e2ce6d2d54951f75b14a", size = 1126896, upload-time = "2026-02-20T20:32:52.198Z" }, @@ -957,7 +757,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/95/d5d332fb73affaf7a1fbe80e49c2c7eae4f17c645af24a3b3fa25736d6f0/greenlet-3.2.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f2cc88b50b9006b324c1b9f5f3552f9d4564c78af57cdfb4c7baf4f0aa089146", size = 277166, upload-time = "2026-02-20T20:03:57.077Z" }, { url = "https://files.pythonhosted.org/packages/6c/77/89458e20db5a4f1c64f9a0191561227e76d809941ca2d7529006d17d3450/greenlet-3.2.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e66872daffa360b2537170b73ad530f14fa31785b1bc78080125d92edf0a6def", size = 644674, upload-time = "2026-02-20T20:30:46.118Z" }, { url = "https://files.pythonhosted.org/packages/90/f8/9962175d2f2eaa629a7fd7545abacc8c4deda3baa4e52c1526d2eb5f5546/greenlet-3.2.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c5445ddb7b586d870dad32ca9fc47c287d6022a528d194efdb8912093c5303ad", size = 658834, upload-time = "2026-02-20T20:37:27.466Z" }, - { url = "https://files.pythonhosted.org/packages/81/71/52c21a7106ce5218aa6fa59ec32825b2655f875a09b69f68bd3e5d01feb3/greenlet-3.2.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd904626b8779810062cb455514594776e3cba3b8c0ba4939894df9f7b384971", size = 653091, upload-time = "2026-02-20T20:44:01.927Z" }, { url = "https://files.pythonhosted.org/packages/f5/d7/826d0e080f0a7ad5ec47c8d143bbd3ca0887657bb806595fe2434d12938a/greenlet-3.2.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:752c896a8c976548faafe8a306d446c6a4c68d4fd24699b84d4393bd9ac69a8e", size = 655760, upload-time = "2026-02-20T20:07:21.551Z" }, { url = "https://files.pythonhosted.org/packages/41/cc/33bd4c2f816be8c8e16f71740c4130adf3a66a3dd2ba29de72b9d8dd1096/greenlet-3.2.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499b809e7738c8af0ff9ac9d5dd821cb93f4293065a9237543217f0b252f950a", size = 614132, upload-time = "2026-02-20T20:08:00.351Z" }, { url = "https://files.pythonhosted.org/packages/48/79/f3891dcfc59097474a53cc3c624f2f2465e431ab493bda043b8c873fb20a/greenlet-3.2.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2c7429f6e9cea7cbf2637d86d3db12806ba970f7f972fcab39d6b54b4457cbaf", size = 1125286, upload-time = "2026-02-20T20:32:54.032Z" }, @@ -965,7 +764,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/9d/4e9b941be05f8da7ba804c6413761d2c11cca05994cbf0a015bd729419f0/greenlet-3.2.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7123b29e6bad2f3f89681be4ef316480fca798ebe8d22fbaced9cc3775007a4f", size = 277627, upload-time = "2026-02-20T20:06:04.798Z" }, { url = "https://files.pythonhosted.org/packages/23/cb/a73625c9a35138330014ecf3740c0d62e0c2b5e7279bb7f2586b1b199fac/greenlet-3.2.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e8fe0c72603201a86b2e038daf9b6c8570715f8779566419cff543b6ace88de", size = 690001, upload-time = "2026-02-20T20:30:47.754Z" }, { url = "https://files.pythonhosted.org/packages/83/49/6d1531109507bce7dfb23acf57a87013627ed3ac058851176e443a6a9134/greenlet-3.2.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:050703a60603db0e817364d69e048c70af299040c13a7e67792b9e62d4571196", size = 702953, upload-time = "2026-02-20T20:37:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/90/ac/6d8fff3b273fc60ad4b46f8411fe91c1e4cca064dfff68d096bc982fa6d0/greenlet-3.2.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:04633da773ae432649a3f092a8e4add390732cc9e1ab52c8ff2c91b8dc86f202", size = 698353, upload-time = "2026-02-20T20:44:03.547Z" }, { url = "https://files.pythonhosted.org/packages/f7/38/f958ee90fab93529b30cc1e4a59b27c1112b640570043a84af84da3b3b98/greenlet-3.2.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6712bfd520530eb67331813f7112d3ee18e206f48b3d026d8a96cd2d2ad20251", size = 698995, upload-time = "2026-02-20T20:07:22.663Z" }, { url = "https://files.pythonhosted.org/packages/51/c1/a603906e79716d61f08afedaf8aed62017661457aef233d62d6e57ecd511/greenlet-3.2.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc06a78fa3ffbe2a75f1ebc7e040eacf6fa1050a9432953ab111fbbbf0d03c1", size = 661175, upload-time = "2026-02-20T20:08:01.477Z" }, ] @@ -979,16 +777,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/5d/57/dfb3c5c3f1bf5f5ef2e59a22dec4ff1f3d7408b55bfcefcfb0ea69ef21c6/h5py-3.14.0.tar.gz", hash = "sha256:2372116b2e0d5d3e5e705b7f663f7c8d96fa79a4052d250484ef91d24d6a08f4", size = 424323, upload-time = "2025-06-06T14:06:15.01Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/89/06cbb421e01dea2e338b3154326523c05d9698f89a01f9d9b65e1ec3fb18/h5py-3.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24df6b2622f426857bda88683b16630014588a0e4155cba44e872eb011c4eaed", size = 3332522, upload-time = "2025-06-06T14:04:13.775Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e7/6c860b002329e408348735bfd0459e7b12f712c83d357abeef3ef404eaa9/h5py-3.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ff2389961ee5872de697054dd5a033b04284afc3fb52dc51d94561ece2c10c6", size = 2831051, upload-time = "2025-06-06T14:04:18.206Z" }, - { url = "https://files.pythonhosted.org/packages/fa/cd/3dd38cdb7cc9266dc4d85f27f0261680cb62f553f1523167ad7454e32b11/h5py-3.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:016e89d3be4c44f8d5e115fab60548e518ecd9efe9fa5c5324505a90773e6f03", size = 4324677, upload-time = "2025-06-06T14:04:23.438Z" }, - { url = "https://files.pythonhosted.org/packages/b1/45/e1a754dc7cd465ba35e438e28557119221ac89b20aaebef48282654e3dc7/h5py-3.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1223b902ef0b5d90bcc8a4778218d6d6cd0f5561861611eda59fa6c52b922f4d", size = 4557272, upload-time = "2025-06-06T14:04:28.863Z" }, - { url = "https://files.pythonhosted.org/packages/5c/06/f9506c1531645829d302c420851b78bb717af808dde11212c113585fae42/h5py-3.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:852b81f71df4bb9e27d407b43071d1da330d6a7094a588efa50ef02553fa7ce4", size = 2866734, upload-time = "2025-06-06T14:04:33.5Z" }, - { url = "https://files.pythonhosted.org/packages/61/1b/ad24a8ce846cf0519695c10491e99969d9d203b9632c4fcd5004b1641c2e/h5py-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f30dbc58f2a0efeec6c8836c97f6c94afd769023f44e2bb0ed7b17a16ec46088", size = 3352382, upload-time = "2025-06-06T14:04:37.95Z" }, - { url = "https://files.pythonhosted.org/packages/36/5b/a066e459ca48b47cc73a5c668e9924d9619da9e3c500d9fb9c29c03858ec/h5py-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:543877d7f3d8f8a9828ed5df6a0b78ca3d8846244b9702e99ed0d53610b583a8", size = 2852492, upload-time = "2025-06-06T14:04:42.092Z" }, - { url = "https://files.pythonhosted.org/packages/08/0c/5e6aaf221557314bc15ba0e0da92e40b24af97ab162076c8ae009320a42b/h5py-3.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c497600c0496548810047257e36360ff551df8b59156d3a4181072eed47d8ad", size = 4298002, upload-time = "2025-06-06T14:04:47.106Z" }, - { url = "https://files.pythonhosted.org/packages/21/d4/d461649cafd5137088fb7f8e78fdc6621bb0c4ff2c090a389f68e8edc136/h5py-3.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:723a40ee6505bd354bfd26385f2dae7bbfa87655f4e61bab175a49d72ebfc06b", size = 4516618, upload-time = "2025-06-06T14:04:52.467Z" }, - { url = "https://files.pythonhosted.org/packages/db/0c/6c3f879a0f8e891625817637fad902da6e764e36919ed091dc77529004ac/h5py-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d2744b520440a996f2dae97f901caa8a953afc055db4673a993f2d87d7f38713", size = 2874888, upload-time = "2025-06-06T14:04:56.95Z" }, { url = "https://files.pythonhosted.org/packages/3e/77/8f651053c1843391e38a189ccf50df7e261ef8cd8bfd8baba0cbe694f7c3/h5py-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e0045115d83272090b0717c555a31398c2c089b87d212ceba800d3dc5d952e23", size = 3312740, upload-time = "2025-06-06T14:05:01.193Z" }, { url = "https://files.pythonhosted.org/packages/ff/10/20436a6cf419b31124e59fefc78d74cb061ccb22213226a583928a65d715/h5py-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6da62509b7e1d71a7d110478aa25d245dd32c8d9a1daee9d2a42dba8717b047a", size = 2829207, upload-time = "2025-06-06T14:05:05.061Z" }, { url = "https://files.pythonhosted.org/packages/3f/19/c8bfe8543bfdd7ccfafd46d8cfd96fce53d6c33e9c7921f375530ee1d39a/h5py-3.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554ef0ced3571366d4d383427c00c966c360e178b5fb5ee5bb31a435c424db0c", size = 4708455, upload-time = "2025-06-06T14:05:11.528Z" }, @@ -1031,27 +819,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] -[[package]] -name = "importlib-metadata" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, -] - -[[package]] -name = "importlib-resources" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -1078,8 +845,7 @@ dependencies = [ { name = "appnope", marker = "sys_platform == 'darwin'" }, { name = "comm" }, { name = "debugpy" }, - { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipython" }, { name = "jupyter-client" }, { name = "jupyter-core" }, { name = "matplotlib-inline" }, @@ -1095,62 +861,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" }, ] -[[package]] -name = "ipython" -version = "8.39.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version < '3.11'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "stack-data", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, -] - [[package]] name = "ipython" version = "9.13.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.11'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "psutil", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } wheels = [ @@ -1162,7 +888,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -1185,9 +911,6 @@ wheels = [ name = "jaraco-context" version = "6.1.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, @@ -1281,7 +1004,6 @@ name = "keyring" version = "25.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, { name = "jaraco-classes" }, { name = "jaraco-context" }, { name = "jaraco-functools" }, @@ -1300,34 +1022,6 @@ version = "1.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, - { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, - { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, - { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, - { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, - { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, - { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, - { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, - { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, - { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, - { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, - { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, - { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, - { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, - { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, - { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, - { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, - { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, - { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, @@ -1406,16 +1100,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, - { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, - { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, - { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, - { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, - { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, - { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] [[package]] @@ -1430,41 +1114,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, ] -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "mdurl", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.11'" }, +dependencies = [ + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -1477,28 +1132,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, @@ -1573,19 +1206,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/6f/340b04986e67aac6f66c5145ce68bf72c64bed30f92c8913499a6e6b8f99/matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217", size = 8296625, upload-time = "2026-04-24T00:11:43.376Z" }, - { url = "https://files.pythonhosted.org/packages/bb/2f/127081eb83162053ebb9678ceac64220b93a663e0167432566e9c7c82aab/matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b", size = 8188790, upload-time = "2026-04-24T00:11:46.556Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b7/d8bcec2626c35f96972bff656299fef4578113ea6193c8fdad324710410c/matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", size = 8769389, upload-time = "2026-04-24T00:11:48.959Z" }, - { url = "https://files.pythonhosted.org/packages/12/49/b78e214a527ea732033b7f4d37f7afb504d74ba9d134bd47938230dfb8b1/matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", size = 9589657, upload-time = "2026-04-24T00:11:51.915Z" }, - { url = "https://files.pythonhosted.org/packages/5f/15/5246f7b43beae19c74dfee651d58d6cc8112e06f77adb4e88cc04f2e3a23/matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", size = 9651983, upload-time = "2026-04-24T00:11:54.766Z" }, - { url = "https://files.pythonhosted.org/packages/75/77/5acecfe672ba0fa1b8c0454f69ce155d1e6fc5852fa7206bf9afaf767121/matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda", size = 8199701, upload-time = "2026-04-24T00:11:58.389Z" }, - { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, - { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, - { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, - { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, - { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, @@ -1621,12 +1241,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, - { url = "https://files.pythonhosted.org/packages/2c/2b/0e92ad0ac446633f928a1563db4aa8add407e1924faf0ded5b95b35afb27/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", size = 8293058, upload-time = "2026-04-24T00:13:56.339Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/74682fd369f5299ceda438fea2a0662e6383b85c9383fb9cdfcf04713e07/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", size = 8186627, upload-time = "2026-04-24T00:13:58.623Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117, upload-time = "2026-04-24T00:14:01.684Z" }, - { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, - { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, ] [[package]] @@ -1646,8 +1260,7 @@ name = "mdit-py-plugins" version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } wheels = [ @@ -1663,49 +1276,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "metakernel" -version = "0.32.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "comm", marker = "python_full_version < '3.11'" }, - { name = "ipykernel", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "jupyter-core", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/18/6f/a68999453116a90e7e8f58a4dd91e40929e039c507828181784e3af00903/metakernel-0.32.0.tar.gz", hash = "sha256:031984b4c0629c129c86162d27bd8df264d6993bf961aec730fdb71facefddbc", size = 460794, upload-time = "2026-03-10T12:12:11.985Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/0a/fc5d09d84543a6c46e4f69dc5639d55c9258dd5bb6f258b1aedf3f32ab77/metakernel-0.32.0-py3-none-any.whl", hash = "sha256:1c033a52feffa46d844d9ef339f16299872d7046897b6f1ec64ced2f30b82159", size = 197940, upload-time = "2026-03-10T12:12:09.539Z" }, -] - [[package]] name = "metakernel" version = "1.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "comm", marker = "python_full_version >= '3.11'" }, - { name = "ipykernel", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "jupyter-core", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11'" }, +dependencies = [ + { name = "comm" }, + { name = "ipykernel" }, + { name = "jedi" }, + { name = "jupyter-core" }, + { name = "pexpect" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/1e/8b236222e93a3cd4962314d094f6a4936567e8a0728461f9d3b07a4f0f8f/metakernel-1.0.0.tar.gz", hash = "sha256:76f2b8926cbad5a089deb6f727399c4e34e8915af97896a8bb7968ebe02da21c", size = 229742, upload-time = "2026-03-24T01:24:13.452Z" } wheels = [ @@ -1724,7 +1304,8 @@ dependencies = [ { name = "numpy" }, { name = "packaging" }, { name = "pooch" }, - { name = "scipy" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, + { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, { name = "tqdm" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/72/24cd8137df5a185fe0856ff9b6f8a8f9d387d6783c51961a1c6300a4efe8/mne-1.12.1.tar.gz", hash = "sha256:244f844057f28a4da2509039dba637832ffb65f678ca76fc667312c493b12044", size = 7211821, upload-time = "2026-04-20T17:16:57.295Z" } @@ -1750,52 +1331,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] -[[package]] -name = "myst-parser" -version = "4.0.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "docutils", marker = "python_full_version < '3.11'" }, - { name = "jinja2", marker = "python_full_version < '3.11'" }, - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "mdit-py-plugins", marker = "python_full_version < '3.11'" }, - { name = "pyyaml", marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, -] - [[package]] name = "myst-parser" version = "5.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "docutils", marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" }, - { name = "pyyaml", marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } wheels = [ @@ -1834,36 +1380,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] -[[package]] -name = "networkx" -version = "3.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, -] - [[package]] name = "networkx" version = "3.6.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, @@ -1908,7 +1428,6 @@ name = "nibabel" version = "5.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-resources", marker = "python_full_version < '3.12'" }, { name = "numpy" }, { name = "packaging" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -1936,26 +1455,6 @@ version = "2.2.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, @@ -1986,10 +1485,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, ] [[package]] @@ -1997,10 +1492,7 @@ name = "numpydoc" version = "1.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/3c/dfccc9e7dee357fb2aa13c3890d952a370dd0ed071e0f7ed62ed0df567c1/numpydoc-1.10.0.tar.gz", hash = "sha256:3f7970f6eee30912260a6b31ac72bba2432830cd6722569ec17ee8d3ef5ffa01", size = 94027, upload-time = "2025-12-02T16:39:12.937Z" } wheels = [ @@ -2048,7 +1540,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -2060,7 +1552,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -2090,9 +1582,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -2104,7 +1596,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -2162,54 +1654,23 @@ version = "5.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, - { name = "octave-kernel", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "octave-kernel", version = "1.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "scipy" }, + { name = "octave-kernel" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, + { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d5/38/5c854e026d406787737b5cb5fa9c2a8b4c71bcc3f5a148825a7fdf25a023/oct2py-5.8.0.tar.gz", hash = "sha256:2b5e86a9de1d62adcc90ae6795f9e33302f7eef649ce3cef671d1be8b4b5804b", size = 442805, upload-time = "2024-12-11T15:09:56.327Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/40/ac/be35f31333cfa0346aca91bdbdc8186e8e6be7364386225122395b1b8029/oct2py-5.8.0-py3-none-any.whl", hash = "sha256:6e47ac977ad56c6051d5388e815e311666e12a0f4883d4f028c1c737b0fc0afe", size = 29257, upload-time = "2024-12-11T15:09:52.97Z" }, ] -[[package]] -name = "octave-kernel" -version = "0.39.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "ipykernel", marker = "python_full_version < '3.11'" }, - { name = "jupyter-client", marker = "python_full_version < '3.11'" }, - { name = "metakernel", version = "0.32.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/f6/c044883eaff44b31984d8a3090c903376c478d409fa07c5a9a0ac7003ea4/octave_kernel-0.39.0.tar.gz", hash = "sha256:384390c28c5911c78aeb746e6e2eeefc6492f4878356c75ef5c0cc99c0e719f4", size = 265903, upload-time = "2026-03-09T12:46:25.352Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/10/e88bfa82e0da89d0a3a2d0fcb9e9f0822ab23713a17b2c74c37dc7068ffd/octave_kernel-0.39.0-py3-none-any.whl", hash = "sha256:693c40c473dbc1f3442b7e3afdb03f6858bcfa5d484a643028fd8ca7231927c2", size = 36537, upload-time = "2026-03-09T12:46:23.526Z" }, -] - [[package]] name = "octave-kernel" version = "1.0.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "ipykernel", marker = "python_full_version >= '3.11'" }, - { name = "jupyter-client", marker = "python_full_version >= '3.11'" }, - { name = "metakernel", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +dependencies = [ + { name = "ipykernel" }, + { name = "jupyter-client" }, + { name = "metakernel" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/23/b09577bdafdcfab2adb417e8b611a1a9e47876192e742722975f067686a7/octave_kernel-1.0.3.tar.gz", hash = "sha256:d4fd1ecd69b413a7c1c6ff8ba4e80d6d8ccad92943eaa74562a0960325877ceb", size = 566566, upload-time = "2026-04-08T18:53:33.911Z" } wheels = [ @@ -2237,20 +1698,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684, upload-time = "2025-08-21T10:28:29.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/16/a8eeb70aad84ccbf14076793f90e0031eded63c1899aeae9fdfbf37881f4/pandas-2.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52bc29a946304c360561974c6542d1dd628ddafa69134a7131fdfd6a5d7a1a35", size = 11539648, upload-time = "2025-08-21T10:26:36.236Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/c5bdaea13bf3708554d93e948b7ea74121ce6e0d59537ca4c4f77731072b/pandas-2.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:220cc5c35ffaa764dd5bb17cf42df283b5cb7fdf49e10a7b053a06c9cb48ee2b", size = 10786923, upload-time = "2025-08-21T10:26:40.518Z" }, - { url = "https://files.pythonhosted.org/packages/bb/10/811fa01476d29ffed692e735825516ad0e56d925961819e6126b4ba32147/pandas-2.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c05e15111221384019897df20c6fe893b2f697d03c811ee67ec9e0bb5a3424", size = 11726241, upload-time = "2025-08-21T10:26:43.175Z" }, - { url = "https://files.pythonhosted.org/packages/c4/6a/40b043b06e08df1ea1b6d20f0e0c2f2c4ec8c4f07d1c92948273d943a50b/pandas-2.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc03acc273c5515ab69f898df99d9d4f12c4d70dbfc24c3acc6203751d0804cf", size = 12349533, upload-time = "2025-08-21T10:26:46.611Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ea/2e081a2302e41a9bca7056659fdd2b85ef94923723e41665b42d65afd347/pandas-2.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d25c20a03e8870f6339bcf67281b946bd20b86f1a544ebbebb87e66a8d642cba", size = 13202407, upload-time = "2025-08-21T10:26:49.068Z" }, - { url = "https://files.pythonhosted.org/packages/f4/12/7ff9f6a79e2ee8869dcf70741ef998b97ea20050fe25f83dc759764c1e32/pandas-2.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21bb612d148bb5860b7eb2c10faacf1a810799245afd342cf297d7551513fbb6", size = 13837212, upload-time = "2025-08-21T10:26:51.832Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/5ab92fcd76455a632b3db34a746e1074d432c0cdbbd28d7cd1daba46a75d/pandas-2.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:b62d586eb25cb8cb70a5746a378fc3194cb7f11ea77170d59f889f5dfe3cec7a", size = 11338099, upload-time = "2025-08-21T10:26:54.382Z" }, - { url = "https://files.pythonhosted.org/packages/7a/59/f3e010879f118c2d400902d2d871c2226cef29b08c09fb8dc41111730400/pandas-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1333e9c299adcbb68ee89a9bb568fc3f20f9cbb419f1dd5225071e6cddb2a743", size = 11563308, upload-time = "2025-08-21T10:26:56.656Z" }, - { url = "https://files.pythonhosted.org/packages/38/18/48f10f1cc5c397af59571d638d211f494dba481f449c19adbd282aa8f4ca/pandas-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:76972bcbd7de8e91ad5f0ca884a9f2c477a2125354af624e022c49e5bd0dfff4", size = 10820319, upload-time = "2025-08-21T10:26:59.162Z" }, - { url = "https://files.pythonhosted.org/packages/95/3b/1e9b69632898b048e223834cd9702052bcf06b15e1ae716eda3196fb972e/pandas-2.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b98bdd7c456a05eef7cd21fd6b29e3ca243591fe531c62be94a2cc987efb5ac2", size = 11790097, upload-time = "2025-08-21T10:27:02.204Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/0e2ffb30b1f7fbc9a588bd01e3c14a0d96854d09a887e15e30cc19961227/pandas-2.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d81573b3f7db40d020983f78721e9bfc425f411e616ef019a10ebf597aedb2e", size = 12397958, upload-time = "2025-08-21T10:27:05.409Z" }, - { url = "https://files.pythonhosted.org/packages/23/82/e6b85f0d92e9afb0e7f705a51d1399b79c7380c19687bfbf3d2837743249/pandas-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e190b738675a73b581736cc8ec71ae113d6c3768d0bd18bffa5b9a0927b0b6ea", size = 13225600, upload-time = "2025-08-21T10:27:07.791Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f1/f682015893d9ed51611948bd83683670842286a8edd4f68c2c1c3b231eef/pandas-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c253828cb08f47488d60f43c5fc95114c771bbfff085da54bfc79cb4f9e3a372", size = 13879433, upload-time = "2025-08-21T10:27:10.347Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e7/ae86261695b6c8a36d6a4c8d5f9b9ede8248510d689a2f379a18354b37d7/pandas-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:9467697b8083f9667b212633ad6aa4ab32436dcbaf4cd57325debb0ddef2012f", size = 11336557, upload-time = "2025-08-21T10:27:12.983Z" }, { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652, upload-time = "2025-08-21T10:27:15.888Z" }, { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686, upload-time = "2025-08-21T10:27:18.486Z" }, { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722, upload-time = "2025-08-21T10:27:21.149Z" }, @@ -2309,28 +1756,6 @@ version = "12.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, - { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, - { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, - { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, - { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, - { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, @@ -2392,13 +1817,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] @@ -2505,7 +1923,8 @@ dependencies = [ { name = "num2words" }, { name = "numpy" }, { name = "pandas" }, - { name = "scipy" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, + { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, { name = "sqlalchemy" }, { name = "universal-pathlib" }, ] @@ -2533,9 +1952,7 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "docutils" }, { name = "pygments" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/f7/c74c7100a7f4c0f77b5dcacb7dfdb8fee774fb70e487dd97acba2b930774/pydata_sphinx_theme-0.17.1.tar.gz", hash = "sha256:2cfc1d926c753c77039b7ee53f0ccebcbee5e81f0db61432b01cbb10ad7fd0af", size = 4991415, upload-time = "2026-04-21T13:00:34.263Z" } @@ -2552,24 +1969,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/8b/29/5080bc311a9f0f3802fb2919f3a7ff790f1ddbf352b21c282a479b895e10/pyedflib-0.1.42.tar.gz", hash = "sha256:39f35c60ce213f23ee954f89117f79b2adb5ef4894500fd9bf0f9298fb240efc", size = 2262626, upload-time = "2025-06-26T18:38:23.931Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/6b/e9258512a7d4706a2779efeb793a657622dae26f226c045eaefde66d0d4f/pyedflib-0.1.42-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf7ee08a47a7648e18c666bc766a930335364c6501d2aad3cfd9420ea0cb7e54", size = 2317284, upload-time = "2025-06-26T18:37:24.305Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a6/257e2bd0b8941dc9b9483502323899092ce7b0b01bea35047282a3856c8a/pyedflib-0.1.42-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b154d682e7d1dc96ef0b670781871ba18183c5b741d30f8c4cf54072f206b007", size = 2307965, upload-time = "2025-06-26T18:37:25.437Z" }, - { url = "https://files.pythonhosted.org/packages/d5/88/92139981c78615a1251bac2109d17d0e7af676eda86a1e4884ff884c2683/pyedflib-0.1.42-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f010ae011471c61f64b5821e45ac930b18e637e03c3809ec9aaf7fecc63d5cb", size = 2834136, upload-time = "2025-06-26T18:37:26.509Z" }, - { url = "https://files.pythonhosted.org/packages/95/21/03b01c5316db827c025defaa43b7d846ef5c1b31700f762fdbedc69be5a5/pyedflib-0.1.42-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fa086362bebf0fdf1f6987a4f935e22cd49afad8c1fd42b810652e6120467e6", size = 2825911, upload-time = "2025-06-26T18:37:27.665Z" }, - { url = "https://files.pythonhosted.org/packages/98/20/49758d0768e894cf4aa21486a742ac79c3c409daab74ffb1e6ff95bc2da2/pyedflib-0.1.42-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b839a06b75ea04a7a99345cf0ed61849c84d03b5dbd7108af5167229d770e356", size = 2788661, upload-time = "2025-06-26T18:37:28.867Z" }, - { url = "https://files.pythonhosted.org/packages/54/8e/b67779edb3f750af82122137a8cd1b8e649ad1b16d078c38cfa7f139b0d1/pyedflib-0.1.42-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:15a920e9544703011c67173c655c08d4632eb2d09f12438f7958bd574e37ef4f", size = 2789970, upload-time = "2025-06-26T18:37:30.138Z" }, - { url = "https://files.pythonhosted.org/packages/f9/2b/fab5b33deb7eb7af671153547bf092f2ae2f57ac864ba7d7db556421de3f/pyedflib-0.1.42-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cfc439fc986fa18f4b24f3e9d196d3c06616d90adc18d82dfde7969827b349a4", size = 2804447, upload-time = "2025-06-26T18:37:31.341Z" }, - { url = "https://files.pythonhosted.org/packages/ce/76/e9793830817e7ea13d94128cd3befbe34fc6ae751a625268af75ec5d48a1/pyedflib-0.1.42-cp310-cp310-win32.whl", hash = "sha256:928d6d68deaaeb20df291bdd5f0489a648f7ec1bebeb43e05f713d860fe6ea96", size = 2285226, upload-time = "2025-06-26T18:37:32.634Z" }, - { url = "https://files.pythonhosted.org/packages/30/e2/39ab13ca9334390427bf5d74645e9f8e75602efd161d46c3c3118f8e51f6/pyedflib-0.1.42-cp310-cp310-win_amd64.whl", hash = "sha256:3467afe4683e87e1619626ae2d001b977056f867cef7669eb0399097c1017ced", size = 2296955, upload-time = "2025-06-26T18:37:36.212Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7c/7f3697b6d9c2799e3455f576834e22c4b1e6b41d50ef74569f22cf9b909f/pyedflib-0.1.42-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c1cbe156c87e4952b21f12dedb8dc7c2e3a68330bbf526f6560c3eaa4458d480", size = 2319344, upload-time = "2025-06-26T18:37:37.385Z" }, - { url = "https://files.pythonhosted.org/packages/14/13/5b03e466964ce1260563c1cf0407014f0f7ec8574f1b76e3734c07bb9cb4/pyedflib-0.1.42-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46778b28e163d7e318651a7883ba35bf1f3898d6e774dd2a22874959e0c9b760", size = 2309770, upload-time = "2025-06-26T18:37:38.493Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ac/64e151ceb1a4ee3437ab212f18ce41905811ce21b556609f2a37d1830c51/pyedflib-0.1.42-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb58686ecb9a175a71fb893910985677cf0e83cf622d032db406cc23e44172ed", size = 2861301, upload-time = "2025-06-26T18:37:39.783Z" }, - { url = "https://files.pythonhosted.org/packages/f0/fc/336258bb410870ffe36f00b6854ff7fb77ea2d36c34e3dc9f9c214bfc9a6/pyedflib-0.1.42-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2c27139608631319101936c16b408a1c0cd2c53c96a9ae0899f8374673d1c75", size = 2857707, upload-time = "2025-06-26T18:37:40.964Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ff/5d10917002af4c811012fcd8534d1f08eb30eabf1402e35692a745d5e793/pyedflib-0.1.42-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86b182d1e748a42e3169cdb67e3bc4b4f9585d3e6abf4a531f14815d312919cb", size = 2814331, upload-time = "2025-06-26T18:37:42.165Z" }, - { url = "https://files.pythonhosted.org/packages/a3/01/7b5fd465c939cbea3c2c3e7faa48aace914efa2ff93471f3665ac6c211ff/pyedflib-0.1.42-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b59bdd6c3c009af9f06026e4533dbefb7a9900c3377e4a9bee1e9fec9dec376d", size = 2818893, upload-time = "2025-06-26T18:37:43.42Z" }, - { url = "https://files.pythonhosted.org/packages/00/47/e17e763e94acef5ff53260f5167408dfbd53f1116b3dde737f04b69b681c/pyedflib-0.1.42-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e2cf53cee3a84428c98fd0f35ac52b2a2dc5e08e05c3ca345a70165892ed98d", size = 2831638, upload-time = "2025-06-26T18:37:44.606Z" }, - { url = "https://files.pythonhosted.org/packages/42/29/451b8af95458fbeac449ac25efa91db168fc48599e10c4bd951147a09355/pyedflib-0.1.42-cp311-cp311-win32.whl", hash = "sha256:50ef1d51759baee8dcf8e886abadecea23594fc4d76a397998e33a0b046857af", size = 2285335, upload-time = "2025-06-26T18:37:46.403Z" }, - { url = "https://files.pythonhosted.org/packages/d3/21/0bb26247f05c939fe26d112f55e7b0f9c3fb2b1e0dfcd95e6012f9f3b7b2/pyedflib-0.1.42-cp311-cp311-win_amd64.whl", hash = "sha256:1d21e7e4af0c93a0e18a200d89a6e96d5bc99804ac7993286ccb821054a5641e", size = 2298353, upload-time = "2025-06-26T18:37:47.515Z" }, { url = "https://files.pythonhosted.org/packages/3f/0d/f846c6f2b1ec383166b500af354ac4a282c416fa1d6279bc9e1b80ff3366/pyedflib-0.1.42-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4e9c52bac8c12d8981a288c203e48929ce8ae8b54ac0a0839060499a25f61073", size = 2319498, upload-time = "2025-06-26T18:37:48.645Z" }, { url = "https://files.pythonhosted.org/packages/4a/b7/a8740dbdb9c6949e4531956cc2e222cbab4a5f49ee7048ecef6a80c2ae24/pyedflib-0.1.42-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:539f8b35717070f0fcefcee5de3475daf717762f539bab61c1804f11fa258a98", size = 2308186, upload-time = "2025-06-26T18:37:49.825Z" }, { url = "https://files.pythonhosted.org/packages/42/77/3793a769b266ff4d01fe4c85ee7e8fcc27e9cc0c000f2f5b1836ea865047/pyedflib-0.1.42-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8284c0607660ed1227efcff54837ef077284957a138075edcdf78afc24fb8cb", size = 2857156, upload-time = "2025-06-26T18:37:52.333Z" }, @@ -2684,12 +2083,10 @@ version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ @@ -2715,7 +2112,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "scikit-learn" }, - { name = "scipy" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, + { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/4b/cd049f1c742cd6005b3c2758b1568149da2f27d5e1b90ecfe662d5e52b82/python_picard-0.8.2.tar.gz", hash = "sha256:42259763f87673837dcc2772cc941139c8ee547093d1b970e95097393c628cde", size = 65272, upload-time = "2026-04-27T14:11:04.17Z" } wheels = [ @@ -2746,24 +2144,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, @@ -2813,28 +2193,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/b1/11/b9213d25230ac18a71b39b3723494e57adebe36e066397b961657b3b41c1/pyzmq-26.4.0.tar.gz", hash = "sha256:4bd13f85f80962f91a651a7356fe0472791a5f7a92f227822b5acf44795c626d", size = 278293, upload-time = "2025-04-04T12:05:44.049Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/b8/af1d814ffc3ff9730f9a970cbf216b6f078e5d251a25ef5201d7bc32a37c/pyzmq-26.4.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:0329bdf83e170ac133f44a233fc651f6ed66ef8e66693b5af7d54f45d1ef5918", size = 1339238, upload-time = "2025-04-04T12:03:07.022Z" }, - { url = "https://files.pythonhosted.org/packages/ee/e4/5aafed4886c264f2ea6064601ad39c5fc4e9b6539c6ebe598a859832eeee/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:398a825d2dea96227cf6460ce0a174cf7657d6f6827807d4d1ae9d0f9ae64315", size = 672848, upload-time = "2025-04-04T12:03:08.591Z" }, - { url = "https://files.pythonhosted.org/packages/79/39/026bf49c721cb42f1ef3ae0ee3d348212a7621d2adb739ba97599b6e4d50/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d52d62edc96787f5c1dfa6c6ccff9b581cfae5a70d94ec4c8da157656c73b5b", size = 911299, upload-time = "2025-04-04T12:03:10Z" }, - { url = "https://files.pythonhosted.org/packages/03/23/b41f936a9403b8f92325c823c0f264c6102a0687a99c820f1aaeb99c1def/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1410c3a3705db68d11eb2424d75894d41cff2f64d948ffe245dd97a9debfebf4", size = 867920, upload-time = "2025-04-04T12:03:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3e/2de5928cdadc2105e7c8f890cc5f404136b41ce5b6eae5902167f1d5641c/pyzmq-26.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:7dacb06a9c83b007cc01e8e5277f94c95c453c5851aac5e83efe93e72226353f", size = 862514, upload-time = "2025-04-04T12:03:13.013Z" }, - { url = "https://files.pythonhosted.org/packages/ce/57/109569514dd32e05a61d4382bc88980c95bfd2f02e58fea47ec0ccd96de1/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6bab961c8c9b3a4dc94d26e9b2cdf84de9918931d01d6ff38c721a83ab3c0ef5", size = 1204494, upload-time = "2025-04-04T12:03:14.795Z" }, - { url = "https://files.pythonhosted.org/packages/aa/02/dc51068ff2ca70350d1151833643a598625feac7b632372d229ceb4de3e1/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a5c09413b924d96af2aa8b57e76b9b0058284d60e2fc3730ce0f979031d162a", size = 1514525, upload-time = "2025-04-04T12:03:16.246Z" }, - { url = "https://files.pythonhosted.org/packages/48/2a/a7d81873fff0645eb60afaec2b7c78a85a377af8f1d911aff045d8955bc7/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7d489ac234d38e57f458fdbd12a996bfe990ac028feaf6f3c1e81ff766513d3b", size = 1414659, upload-time = "2025-04-04T12:03:17.652Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ea/813af9c42ae21845c1ccfe495bd29c067622a621e85d7cda6bc437de8101/pyzmq-26.4.0-cp310-cp310-win32.whl", hash = "sha256:dea1c8db78fb1b4b7dc9f8e213d0af3fc8ecd2c51a1d5a3ca1cde1bda034a980", size = 580348, upload-time = "2025-04-04T12:03:19.384Z" }, - { url = "https://files.pythonhosted.org/packages/20/68/318666a89a565252c81d3fed7f3b4c54bd80fd55c6095988dfa2cd04a62b/pyzmq-26.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:fa59e1f5a224b5e04dc6c101d7186058efa68288c2d714aa12d27603ae93318b", size = 643838, upload-time = "2025-04-04T12:03:20.795Z" }, - { url = "https://files.pythonhosted.org/packages/91/f8/fb1a15b5f4ecd3e588bfde40c17d32ed84b735195b5c7d1d7ce88301a16f/pyzmq-26.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:a651fe2f447672f4a815e22e74630b6b1ec3a1ab670c95e5e5e28dcd4e69bbb5", size = 559565, upload-time = "2025-04-04T12:03:22.676Z" }, - { url = "https://files.pythonhosted.org/packages/32/6d/234e3b0aa82fd0290b1896e9992f56bdddf1f97266110be54d0177a9d2d9/pyzmq-26.4.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:bfcf82644c9b45ddd7cd2a041f3ff8dce4a0904429b74d73a439e8cab1bd9e54", size = 1339723, upload-time = "2025-04-04T12:03:24.358Z" }, - { url = "https://files.pythonhosted.org/packages/4f/11/6d561efe29ad83f7149a7cd48e498e539ed09019c6cd7ecc73f4cc725028/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9bcae3979b2654d5289d3490742378b2f3ce804b0b5fd42036074e2bf35b030", size = 672645, upload-time = "2025-04-04T12:03:25.693Z" }, - { url = "https://files.pythonhosted.org/packages/19/fd/81bfe3e23f418644660bad1a90f0d22f0b3eebe33dd65a79385530bceb3d/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccdff8ac4246b6fb60dcf3982dfaeeff5dd04f36051fe0632748fc0aa0679c01", size = 910133, upload-time = "2025-04-04T12:03:27.625Z" }, - { url = "https://files.pythonhosted.org/packages/97/68/321b9c775595ea3df832a9516252b653fe32818db66fdc8fa31c9b9fce37/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4550af385b442dc2d55ab7717837812799d3674cb12f9a3aa897611839c18e9e", size = 867428, upload-time = "2025-04-04T12:03:29.004Z" }, - { url = "https://files.pythonhosted.org/packages/4e/6e/159cbf2055ef36aa2aa297e01b24523176e5b48ead283c23a94179fb2ba2/pyzmq-26.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:2f9f7ffe9db1187a253fca95191854b3fda24696f086e8789d1d449308a34b88", size = 862409, upload-time = "2025-04-04T12:03:31.032Z" }, - { url = "https://files.pythonhosted.org/packages/05/1c/45fb8db7be5a7d0cadea1070a9cbded5199a2d578de2208197e592f219bd/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3709c9ff7ba61589b7372923fd82b99a81932b592a5c7f1a24147c91da9a68d6", size = 1205007, upload-time = "2025-04-04T12:03:32.687Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fa/658c7f583af6498b463f2fa600f34e298e1b330886f82f1feba0dc2dd6c3/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f8f3c30fb2d26ae5ce36b59768ba60fb72507ea9efc72f8f69fa088450cff1df", size = 1514599, upload-time = "2025-04-04T12:03:34.084Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d7/44d641522353ce0a2bbd150379cb5ec32f7120944e6bfba4846586945658/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:382a4a48c8080e273427fc692037e3f7d2851959ffe40864f2db32646eeb3cef", size = 1414546, upload-time = "2025-04-04T12:03:35.478Z" }, - { url = "https://files.pythonhosted.org/packages/72/76/c8ed7263218b3d1e9bce07b9058502024188bd52cc0b0a267a9513b431fc/pyzmq-26.4.0-cp311-cp311-win32.whl", hash = "sha256:d56aad0517d4c09e3b4f15adebba8f6372c5102c27742a5bdbfc74a7dceb8fca", size = 579247, upload-time = "2025-04-04T12:03:36.846Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d0/2d9abfa2571a0b1a67c0ada79a8aa1ba1cce57992d80f771abcdf99bb32c/pyzmq-26.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:963977ac8baed7058c1e126014f3fe58b3773f45c78cce7af5c26c09b6823896", size = 644727, upload-time = "2025-04-04T12:03:38.578Z" }, - { url = "https://files.pythonhosted.org/packages/0d/d1/c8ad82393be6ccedfc3c9f3adb07f8f3976e3c4802640fe3f71441941e70/pyzmq-26.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:c0c8e8cadc81e44cc5088fcd53b9b3b4ce9344815f6c4a03aec653509296fae3", size = 559942, upload-time = "2025-04-04T12:03:40.143Z" }, { url = "https://files.pythonhosted.org/packages/10/44/a778555ebfdf6c7fc00816aad12d185d10a74d975800341b1bc36bad1187/pyzmq-26.4.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:5227cb8da4b6f68acfd48d20c588197fd67745c278827d5238c707daf579227b", size = 1341586, upload-time = "2025-04-04T12:03:41.954Z" }, { url = "https://files.pythonhosted.org/packages/9c/4f/f3a58dc69ac757e5103be3bd41fb78721a5e17da7cc617ddb56d973a365c/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1c07a7fa7f7ba86554a2b1bef198c9fed570c08ee062fd2fd6a4dcacd45f905", size = 665880, upload-time = "2025-04-04T12:03:43.45Z" }, { url = "https://files.pythonhosted.org/packages/fe/45/50230bcfb3ae5cb98bee683b6edeba1919f2565d7cc1851d3c38e2260795/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae775fa83f52f52de73183f7ef5395186f7105d5ed65b1ae65ba27cb1260de2b", size = 902216, upload-time = "2025-04-04T12:03:45.572Z" }, @@ -2865,16 +2223,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/bc/f88b0bad0f7a7f500547d71e99f10336f2314e525d4ebf576a1ea4a1d903/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b30f862f6768b17040929a68432c8a8be77780317f45a353cb17e423127d250c", size = 1189183, upload-time = "2025-04-04T12:04:27.035Z" }, { url = "https://files.pythonhosted.org/packages/d9/8c/db446a3dd9cf894406dec2e61eeffaa3c07c3abb783deaebb9812c4af6a5/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:c80fcd3504232f13617c6ab501124d373e4895424e65de8b72042333316f64a8", size = 1495501, upload-time = "2025-04-04T12:04:28.833Z" }, { url = "https://files.pythonhosted.org/packages/05/4c/bf3cad0d64c3214ac881299c4562b815f05d503bccc513e3fd4fdc6f67e4/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:26a2a7451606b87f67cdeca2c2789d86f605da08b4bd616b1a9981605ca3a364", size = 1395540, upload-time = "2025-04-04T12:04:30.562Z" }, - { url = "https://files.pythonhosted.org/packages/47/03/96004704a84095f493be8d2b476641f5c967b269390173f85488a53c1c13/pyzmq-26.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:98d948288ce893a2edc5ec3c438fe8de2daa5bbbd6e2e865ec5f966e237084ba", size = 834408, upload-time = "2025-04-04T12:05:04.569Z" }, - { url = "https://files.pythonhosted.org/packages/e4/7f/68d8f3034a20505db7551cb2260248be28ca66d537a1ac9a257913d778e4/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9f34f5c9e0203ece706a1003f1492a56c06c0632d86cb77bcfe77b56aacf27b", size = 569580, upload-time = "2025-04-04T12:05:06.283Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a6/2b0d6801ec33f2b2a19dd8d02e0a1e8701000fec72926e6787363567d30c/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80c9b48aef586ff8b698359ce22f9508937c799cc1d2c9c2f7c95996f2300c94", size = 798250, upload-time = "2025-04-04T12:05:07.88Z" }, - { url = "https://files.pythonhosted.org/packages/96/2a/0322b3437de977dcac8a755d6d7ce6ec5238de78e2e2d9353730b297cf12/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3f2a5b74009fd50b53b26f65daff23e9853e79aa86e0aa08a53a7628d92d44a", size = 756758, upload-time = "2025-04-04T12:05:09.483Z" }, - { url = "https://files.pythonhosted.org/packages/c2/33/43704f066369416d65549ccee366cc19153911bec0154da7c6b41fca7e78/pyzmq-26.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:61c5f93d7622d84cb3092d7f6398ffc77654c346545313a3737e266fc11a3beb", size = 555371, upload-time = "2025-04-04T12:05:11.062Z" }, - { url = "https://files.pythonhosted.org/packages/04/52/a70fcd5592715702248306d8e1729c10742c2eac44529984413b05c68658/pyzmq-26.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4478b14cb54a805088299c25a79f27eaf530564a7a4f72bf432a040042b554eb", size = 834405, upload-time = "2025-04-04T12:05:13.3Z" }, - { url = "https://files.pythonhosted.org/packages/25/f9/1a03f1accff16b3af1a6fa22cbf7ced074776abbf688b2e9cb4629700c62/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a28ac29c60e4ba84b5f58605ace8ad495414a724fe7aceb7cf06cd0598d04e1", size = 569578, upload-time = "2025-04-04T12:05:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/76/0c/3a633acd762aa6655fcb71fa841907eae0ab1e8582ff494b137266de341d/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43b03c1ceea27c6520124f4fb2ba9c647409b9abdf9a62388117148a90419494", size = 798248, upload-time = "2025-04-04T12:05:17.376Z" }, - { url = "https://files.pythonhosted.org/packages/cd/cc/6c99c84aa60ac1cc56747bed6be8ce6305b9b861d7475772e7a25ce019d3/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7731abd23a782851426d4e37deb2057bf9410848a4459b5ede4fe89342e687a9", size = 756757, upload-time = "2025-04-04T12:05:19.19Z" }, - { url = "https://files.pythonhosted.org/packages/13/9c/d8073bd898eb896e94c679abe82e47506e2b750eb261cf6010ced869797c/pyzmq-26.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a222ad02fbe80166b0526c038776e8042cd4e5f0dec1489a006a1df47e9040e0", size = 555371, upload-time = "2025-04-04T12:05:20.702Z" }, ] [[package]] @@ -2944,8 +2292,7 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } @@ -2994,21 +2341,12 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "joblib" }, { name = "numpy" }, - { name = "scipy" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, + { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" }, - { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" }, - { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" }, - { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" }, - { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" }, - { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" }, - { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" }, - { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" }, - { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" }, { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, @@ -3035,51 +2373,25 @@ wheels = [ name = "scipy" version = "1.15.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform != 'darwin'", + "python_full_version < '3.13' and sys_platform != 'darwin'", +] dependencies = [ { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, - { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, - { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, - { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, - { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, - { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, - { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, - { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, - { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, - { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, - { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, - { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, - { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, - { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, - { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, - { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, @@ -3087,13 +2399,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, ] +[[package]] +name = "scipy" +version = "1.16.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version < '3.13' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883, upload-time = "2025-10-28T17:38:54.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/41/5bf55c3f386b1643812f3a5674edf74b26184378ef0f3e7c7a09a7e2ca7f/scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6", size = 36659043, upload-time = "2025-10-28T17:32:40.285Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0f/65582071948cfc45d43e9870bf7ca5f0e0684e165d7c9ef4e50d783073eb/scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07", size = 28898986, upload-time = "2025-10-28T17:32:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/96/5e/36bf3f0ac298187d1ceadde9051177d6a4fe4d507e8f59067dc9dd39e650/scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9", size = 20889814, upload-time = "2025-10-28T17:32:49.277Z" }, + { url = "https://files.pythonhosted.org/packages/80/35/178d9d0c35394d5d5211bbff7ac4f2986c5488b59506fef9e1de13ea28d3/scipy-1.16.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3d4a07a8e785d80289dfe66b7c27d8634a773020742ec7187b85ccc4b0e7b686", size = 23565795, upload-time = "2025-10-28T17:32:53.337Z" }, + { url = "https://files.pythonhosted.org/packages/72/f1/57e8327ab1508272029e27eeef34f2302ffc156b69e7e233e906c2a5c379/scipy-1.16.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d2ec56337675e61b312179a1ad124f5f570c00f920cc75e1000025451b88241c", size = 36617856, upload-time = "2025-10-28T17:33:31.375Z" }, + { url = "https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d", size = 28874306, upload-time = "2025-10-28T17:33:36.516Z" }, + { url = "https://files.pythonhosted.org/packages/15/65/3a9400efd0228a176e6ec3454b1fa998fbbb5a8defa1672c3f65706987db/scipy-1.16.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:5803c5fadd29de0cf27fa08ccbfe7a9e5d741bf63e4ab1085437266f12460ff9", size = 20865371, upload-time = "2025-10-28T17:33:42.094Z" }, + { url = "https://files.pythonhosted.org/packages/33/d7/eda09adf009a9fb81827194d4dd02d2e4bc752cef16737cc4ef065234031/scipy-1.16.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b81c27fc41954319a943d43b20e07c40bdcd3ff7cf013f4fb86286faefe546c4", size = 23524877, upload-time = "2025-10-28T17:33:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a3/9ec205bd49f42d45d77f1730dbad9ccf146244c1647605cf834b3a8c4f36/scipy-1.16.3-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:fb4b29f4cf8cc5a8d628bc8d8e26d12d7278cd1f219f22698a378c3d67db5e4b", size = 37027931, upload-time = "2025-10-28T17:34:31.451Z" }, + { url = "https://files.pythonhosted.org/packages/25/06/ca9fd1f3a4589cbd825b1447e5db3a8ebb969c1eaf22c8579bd286f51b6d/scipy-1.16.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:8d09d72dc92742988b0e7750bddb8060b0c7079606c0d24a8cc8e9c9c11f9079", size = 29400081, upload-time = "2025-10-28T17:34:39.087Z" }, + { url = "https://files.pythonhosted.org/packages/6a/56/933e68210d92657d93fb0e381683bc0e53a965048d7358ff5fbf9e6a1b17/scipy-1.16.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:03192a35e661470197556de24e7cb1330d84b35b94ead65c46ad6f16f6b28f2a", size = 21391244, upload-time = "2025-10-28T17:34:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/779845db03dc1418e215726329674b40576879b91814568757ff0014ad65/scipy-1.16.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:57d01cb6f85e34f0946b33caa66e892aae072b64b034183f3d87c4025802a119", size = 23929753, upload-time = "2025-10-28T17:34:51.793Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/99b10fd70f2d864c1e29a28bbcaa0c6340f9d8518396542d9ea3b4aaae15/scipy-1.16.3-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:875555ce62743e1d54f06cdf22c1e0bc47b91130ac40fe5d783b6dfa114beeb6", size = 36606469, upload-time = "2025-10-28T17:36:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/4d/74/043b54f2319f48ea940dd025779fa28ee360e6b95acb7cd188fad4391c6b/scipy-1.16.3-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bb61878c18a470021fb515a843dc7a76961a8daceaaaa8bad1332f1bf4b54657", size = 28872043, upload-time = "2025-10-28T17:36:16.599Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/24b7e50cc1c4ee6ffbcb1f27fe9f4c8b40e7911675f6d2d20955f41c6348/scipy-1.16.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2622206f5559784fa5c4b53a950c3c7c1cf3e84ca1b9c4b6c03f062f289ca26", size = 20862952, upload-time = "2025-10-28T17:36:22.966Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3a/3e8c01a4d742b730df368e063787c6808597ccb38636ed821d10b39ca51b/scipy-1.16.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7f68154688c515cdb541a31ef8eb66d8cd1050605be9dcd74199cbd22ac739bc", size = 23508512, upload-time = "2025-10-28T17:36:29.731Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6f/69f1e2b682efe9de8fe9f91040f0cd32f13cfccba690512ba4c582b0bc29/scipy-1.16.3-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:e1d27cbcb4602680a49d787d90664fa4974063ac9d4134813332a8c53dbe667c", size = 37028379, upload-time = "2025-10-28T17:37:14.061Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2d/e826f31624a5ebbab1cd93d30fd74349914753076ed0593e1d56a98c4fb4/scipy-1.16.3-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9b9c9c07b6d56a35777a1b4cc8966118fb16cfd8daf6743867d17d36cfad2d40", size = 29400052, upload-time = "2025-10-28T17:37:21.709Z" }, + { url = "https://files.pythonhosted.org/packages/69/27/d24feb80155f41fd1f156bf144e7e049b4e2b9dd06261a242905e3bc7a03/scipy-1.16.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:3a4c460301fb2cffb7f88528f30b3127742cff583603aa7dc964a52c463b385d", size = 21391183, upload-time = "2025-10-28T17:37:29.559Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d3/1b229e433074c5738a24277eca520a2319aac7465eea7310ea6ae0e98ae2/scipy-1.16.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f667a4542cc8917af1db06366d3f78a5c8e83badd56409f94d1eac8d8d9133fa", size = 23930174, upload-time = "2025-10-28T17:37:36.306Z" }, +] + [[package]] name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "jeepney", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -3148,103 +2495,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] -[[package]] -name = "sphinx" -version = "8.1.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "alabaster", marker = "python_full_version < '3.11'" }, - { name = "babel", marker = "python_full_version < '3.11'" }, - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.11'" }, - { name = "imagesize", marker = "python_full_version < '3.11'" }, - { name = "jinja2", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "requests", marker = "python_full_version < '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, -] - -[[package]] -name = "sphinx" -version = "9.0.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "alabaster", marker = "python_full_version == '3.11.*'" }, - { name = "babel", marker = "python_full_version == '3.11.*'" }, - { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version == '3.11.*'" }, - { name = "imagesize", marker = "python_full_version == '3.11.*'" }, - { name = "jinja2", marker = "python_full_version == '3.11.*'" }, - { name = "packaging", marker = "python_full_version == '3.11.*'" }, - { name = "pygments", marker = "python_full_version == '3.11.*'" }, - { name = "requests", marker = "python_full_version == '3.11.*'" }, - { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, - { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, -] - [[package]] name = "sphinx" version = "9.1.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.12'" }, - { name = "babel", marker = "python_full_version >= '3.12'" }, - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.12'" }, - { name = "imagesize", marker = "python_full_version >= '3.12'" }, - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, - { name = "requests", marker = "python_full_version >= '3.12'" }, - { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ @@ -3256,51 +2528,19 @@ name = "sphinx-copybutton" version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, ] -[[package]] -name = "sphinx-design" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, -] - [[package]] name = "sphinx-design" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +dependencies = [ + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } wheels = [ @@ -3313,9 +2553,7 @@ version = "0.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pillow" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/9d/91334ba9370de74564c8a1e0c54ce1bc638b35e00177cc02cb25c9c14348/sphinx_gallery-0.21.0.tar.gz", hash = "sha256:72a7734ad9100878345b8b65c249148cc0f1cd0e274adf3e3900214e4c2c5bee", size = 483616, upload-time = "2026-04-24T03:09:28.173Z" } wheels = [ @@ -3329,9 +2567,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, { name = "setuptools" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, { name = "wheel" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/be/169a0b0a8ad9588e8697c85e1d489aaaca7416073c2fc0267c360af5aae9/sphinx_togglebutton-0.4.5.tar.gz", hash = "sha256:c870dfbd3bc6e119b50ff9a37a64f8991902269e856728931c7d89877e8d4b3d", size = 18101, upload-time = "2026-03-27T13:50:41.984Z" } @@ -3400,9 +2636,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyenchant" }, { name = "requests" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/cd/fa8039cedce6295644ff5f03367742d21e7922da426e8c666b7f5a213682/sphinxcontrib_spelling-8.0.2.tar.gz", hash = "sha256:afbc7b8e93721ab88f12bdd39d848b92017b3763b9ed6226b4b0e54b06664fea", size = 30955, upload-time = "2025-11-28T15:31:50.235Z" } wheels = [ @@ -3419,20 +2653,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/76/f908955139842c362aa877848f42f9249642d5b69e06cee9eae5111da1bd/sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f", size = 2159321, upload-time = "2026-04-03T16:50:11.8Z" }, - { url = "https://files.pythonhosted.org/packages/24/e2/17ba0b7bfbd8de67196889b6d951de269e8a46057d92baca162889beb16d/sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b", size = 3238937, upload-time = "2026-04-03T16:54:45.731Z" }, - { url = "https://files.pythonhosted.org/packages/90/1e/410dd499c039deacff395eec01a9da057125fcd0c97e3badc252c6a2d6a7/sqlalchemy-2.0.49-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1", size = 3237188, upload-time = "2026-04-03T16:56:53.217Z" }, - { url = "https://files.pythonhosted.org/packages/ab/06/e797a8b98a3993ac4bc785309b9b6d005457fc70238ee6cefa7c8867a92e/sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339", size = 3190061, upload-time = "2026-04-03T16:54:47.489Z" }, - { url = "https://files.pythonhosted.org/packages/44/d3/5a9f7ef580af1031184b38235da6ac58c3b571df01c9ec061c44b2b0c5a6/sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d", size = 3211477, upload-time = "2026-04-03T16:56:55.056Z" }, - { url = "https://files.pythonhosted.org/packages/69/ec/7be8c8cb35f038e963a203e4fe5a028989167cc7299927b7cf297c271e37/sqlalchemy-2.0.49-cp310-cp310-win32.whl", hash = "sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3", size = 2119965, upload-time = "2026-04-03T17:00:50.009Z" }, - { url = "https://files.pythonhosted.org/packages/b5/31/0defb93e3a10b0cf7d1271aedd87251a08c3a597ee4f353281769b547b5a/sqlalchemy-2.0.49-cp310-cp310-win_amd64.whl", hash = "sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75", size = 2142935, upload-time = "2026-04-03T17:00:51.675Z" }, - { url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" }, - { url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" }, - { url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" }, - { url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" }, - { url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" }, { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, @@ -3504,60 +2724,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - [[package]] name = "torch" version = "2.12.0" @@ -3568,8 +2734,7 @@ dependencies = [ { name = "filelock" }, { name = "fsspec" }, { name = "jinja2" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "networkx" }, { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, @@ -3581,14 +2746,6 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/b7/53fe0436586716ab7aecff41e26b9302d57c85ded481fd83a2cd741e6b4e/torch-2.12.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:1834bd984f8a2f4f16bdfbeecca9146184b220aa46276bf5756735b5dae12812", size = 87981887, upload-time = "2026-05-13T14:55:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/34/60/d930eac44c30de06ed16f6d1ba4e785e1632532b50d8f0bf9bf699a4d0c7/torch-2.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d4d029801cb7b6df858804a2a21b00cc2aa0bf0ee5d2ab18d343c9e9e5681f35", size = 426355000, upload-time = "2026-05-13T14:54:31.944Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0c/c76b6a087820bab55705b94dfc074e520de9ae91f5ef90da2ecbf2a3ef12/torch-2.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d47e7dee68ac4cd7a068b26bcd6b989935427709fae1c8f7bd0019978f829e15", size = 532144998, upload-time = "2026-05-13T14:56:05.523Z" }, - { url = "https://files.pythonhosted.org/packages/4a/64/8a0d036e166a6aa85ee09bef072f3655d1ba5d5486a68d1b03b6813c01b3/torch-2.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:cf9839790285dd472e7a16aafcb4a4e6bf58ec1b494045044b0eefb0eb4bd1f2", size = 122949877, upload-time = "2026-05-13T14:55:46.841Z" }, - { url = "https://files.pythonhosted.org/packages/18/62/131124fb95df03811b8260d1d43dcc5ee85ea1a344b964613d7efe77fb08/torch-2.12.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:10802fd383bbfed646212e765a72c37d2185205d4f26eb197a254e8ac7ddcb25", size = 87990344, upload-time = "2026-05-13T14:55:42.154Z" }, - { url = "https://files.pythonhosted.org/packages/12/9c/dda0dbd547dc549839824135f223792fd0e725f28ed0715dda366b7acaa2/torch-2.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:c12592630aef72feaf18bd3f197ef587bbfa21131b31c38b23ab2e55fce92e36", size = 426362932, upload-time = "2026-05-13T14:54:15.295Z" }, - { url = "https://files.pythonhosted.org/packages/e2/d2/a7dd5a3f9bdaa7842124e8e2359202b317c48d47d2fc5816fafdf2049adb/torch-2.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:415c1b8d0412f67551c8e89a2daca0fb3e56694af0281ba155eaa9da481f58b4", size = 532170085, upload-time = "2026-05-13T14:55:20.788Z" }, - { url = "https://files.pythonhosted.org/packages/12/1b/a61ce2004f9ab0ea8964a6e6168133a127795667639e2ff4f8f2bdb16a65/torch-2.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:dd37188ea325042cb1f6cafa56822b11ada2520c04791a52629b0af25bdfbfd9", size = 122953128, upload-time = "2026-05-13T14:54:52.744Z" }, { url = "https://files.pythonhosted.org/packages/ef/bb/285d643f254731294c9b595a007eac39db4600a98682d7bca688f42ca164/torch-2.12.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b41339df93d491435e790ff8bcbae1c0ce777175889bfd1281d119862793e6a2", size = 88010197, upload-time = "2026-05-13T14:55:35.414Z" }, { url = "https://files.pythonhosted.org/packages/79/81/76debf1db1343bd929bbb5d74c89fb437c2ed88eb144712557e7bd3eea45/torch-2.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8fbef9f108a863e7722a73740998967e3b074742a834fc5be3a535a2befa7057", size = 426376751, upload-time = "2026-05-13T14:55:03.353Z" }, { url = "https://files.pythonhosted.org/packages/de/f0/80026028b603c4650ff270fc3785bdef4bd6738765a9cc5a0f5a637d65a2/torch-2.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4b4f64c2c2b11f7510d93dd6412b87025ff6eddd6bb61c3b5a3d892ea20c4756", size = 532261691, upload-time = "2026-05-13T14:52:54.453Z" }, @@ -3654,10 +2811,6 @@ name = "triton" version = "3.7.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/97/dcd1f2a0f8336691bff74abc59b2ed9c69a0c0f8f65cd77109c49e05f068/triton-3.7.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223ac302091491436c248a34ee1e6c47a1026486579103c906ffd805be50cb89", size = 188367104, upload-time = "2026-05-07T19:04:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c0/c2ac4fd2d8809b7579d4a820a0f9e5de62a9bc8a757ed4b3abf4f7ee964a/triton-3.7.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c631b65668d4951213b948a413c0564184305b77bb45cc9d686d3e1ecc4701a3", size = 201313191, upload-time = "2026-05-07T18:45:58.444Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c1/5d842314bb6c78442cc60437928781701c6050b8d479bc2a1aed691d37ca/triton-3.7.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9e71fc392675fac364e0ecf4ef3f76f85b7f5433a16f4c3c5fe5f05a52c85fe", size = 188480277, upload-time = "2026-05-07T19:05:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/13/31/8315ea5f8dd18e60970b3022e3a8b93fd37e0b784fbbef86e10c8e6e5ca1/triton-3.7.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22bacffce443f54593dd20f05294d5a40622e0ea9ab632816f87154504356221", size = 201415942, upload-time = "2026-05-07T18:46:06.479Z" }, { url = "https://files.pythonhosted.org/packages/f7/13/ec05adfcd87311d532ba61e3af143e8be59fcd26675884c4682841406a20/triton-3.7.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bf49b00a7a377a68a6da603a876e797614e6455a80e9021669c476a953ad9a", size = 188505104, upload-time = "2026-05-07T19:05:09.843Z" }, { url = "https://files.pythonhosted.org/packages/62/7b/468a576e35beef1426e0828e28e9ba9e65f5474d496f16ee126c15646324/triton-3.7.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f111161d49bf903c0eaedde3962353a3d841c08a836839b7cc1025b8426efcf", size = 201457567, upload-time = "2026-05-07T18:46:13.505Z" }, { url = "https://files.pythonhosted.org/packages/01/e1/a59a583de59b8f62c495d67c80ee3ea97d09e91ac80c4c6e76456ed8d8ac/triton-3.7.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abdf6beaa89b1bcfb9a43cd990536ce66091a997841a4814b260b7bee4c88c3c", size = 188503209, upload-time = "2026-05-07T19:05:17.935Z" }, @@ -3782,28 +2935,6 @@ version = "2.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" }, - { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" }, - { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" }, - { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" }, - { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" }, - { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" }, - { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" }, - { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" }, - { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, - { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, - { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, - { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, - { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, - { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, - { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, - { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, - { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, @@ -3861,12 +2992,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ] - -[[package]] -name = "zipp" -version = "3.23.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, -]