diff --git a/benchmarks/benchmark_decompose.py b/benchmarks/benchmark_decompose.py index 2ecc848..da7c21d 100644 --- a/benchmarks/benchmark_decompose.py +++ b/benchmarks/benchmark_decompose.py @@ -318,6 +318,9 @@ def _compare(dirs, figure=None, montage=None, topo_figure=None, n_topo=6, data=N { "label": str(z["label"]), "channels": int(z["channels"]), + "channel_indices": z["channel_indices"] + if "channel_indices" in z + else np.arange(int(z["channels"])), "frames": int(z["frames"]) if "frames" in z else -1, "time": float(z["time"]), "final_ll": float(z["final_ll"]), @@ -395,16 +398,25 @@ def _compare(dirs, figure=None, montage=None, topo_figure=None, n_topo=6, data=N if matrix_figure: _plot(labels, M, target, matrix_figure) if topo_figure and montage: - # de-sphere must recompute the sphere from the SAME frames the group was fit - # on (#90 --frames can truncate); pass the matching slice, not the full array. + # de-sphere must recompute the sphere from the SAME frames AND channels the + # group was fit on (#90 --frames truncates frames; #91 selects distributed + # channels), so slice full to both, not the whole array. + sel = group[0]["channel_indices"] nf = target_cf[1] - topo_data = full[:, :nf] if (full is not None and nf > 0) else full - _plot_topomaps(group, montage, target, topo_figure, n_topo, topo_data) + if full is None: + topo_data = None + else: + topo_data = full[sel][:, :nf] if nf > 0 else full[sel] + _plot_topomaps(group, montage, sel, topo_figure, n_topo, topo_data) -def _load_info(montage_tsv, n_channels): +def _load_info(montage_tsv, channel_indices): """Build an MNE Info with a scalp montage from a BIDS electrodes.tsv. + ``channel_indices`` are the 0-based data-channel indices actually used (issue + #91 selects a distributed subset, so they need not be contiguous); the + electrode name for data channel ``i`` is ``EEG{i+1:03d}``. + NOTE: the tsv coordinates may need a rotation to MNE's head frame (nose +y) for the absolute orientation to be correct -- to be checked/fixed later. A global rotation does NOT affect the cross-backend equivalence (every backend @@ -423,7 +435,7 @@ def _load_info(montage_tsv, n_channels): ) except ValueError: continue - ch_names = [f"EEG{i:03d}" for i in range(1, n_channels + 1)] + ch_names = [f"EEG{i + 1:03d}" for i in channel_indices] # some channels can be unlocalized ('n/a'); topomap only the located subset located = np.array([i for i, n in enumerate(ch_names) if n in pos]) used = [ch_names[i] for i in located] @@ -435,12 +447,14 @@ def _load_info(montage_tsv, n_channels): return info, located -def _plot_topomaps(group, montage_tsv, channels, path, n_comps, data=None): +def _plot_topomaps(group, montage_tsv, channel_indices, path, n_comps, data=None): """Grid of IC scalp maps: rows = backends, cols = components. Columns are ordered by the reference's back-projected variance (EEGLAB convention, IC1 = highest variance) when ``data`` is given, else by cross-backend match. Each cell is that backend's Hungarian-matched, sign-aligned map; identical columns - down the rows mean every backend recovered the same IC.""" + down the rows mean every backend recovered the same IC. + + ``channel_indices`` are the 0-based data channels used (issue #91).""" import matplotlib matplotlib.use("Agg") @@ -448,7 +462,8 @@ def _plot_topomaps(group, montage_tsv, channels, path, n_comps, data=None): import mne from scipy.optimize import linear_sum_assignment - info, located = _load_info(montage_tsv, channels) + info, located = _load_info(montage_tsv, channel_indices) + n_ch = len(channel_indices) ref = group[0] # reference backend: the first result loaded (glob order) Wref = ref["W"] / (np.linalg.norm(ref["W"], axis=1, keepdims=True) + 1e-12) n_ref = Wref.shape[0] @@ -467,7 +482,7 @@ def _plot_topomaps(group, montage_tsv, channels, path, n_comps, data=None): # column order: EEGLAB back-projected variance if data available, else match if data is not None: - order = _variance_order(ref["W"], data, channels)[:n_comps] + order = _variance_order(ref["W"], data, n_ch)[:n_comps] else: order = np.argsort(-score)[: min(n_comps, n_ref)] @@ -481,7 +496,7 @@ def _plot_topomaps(group, montage_tsv, channels, path, n_comps, data=None): col, corr = matches[bi] # de-sphered mixing = true sensor-space IC scalp maps (EEGLAB); the saved # A is whitened-space. Fall back to A only if no data to recompute the sphere. - ades = _desphere(r["W"], data, channels)[0] if data is not None else r["A"] + ades = _desphere(r["W"], data, n_ch)[0] if data is not None else r["A"] for ci, i in enumerate(order): j = col[i] sign = 1.0 if corr[i, j] >= 0 else -1.0 @@ -502,7 +517,7 @@ def _plot_topomaps(group, montage_tsv, channels, path, n_comps, data=None): transform=axes[bi][0].transAxes, ) fig.suptitle( - f"AMICA IC scalp maps @ {channels} channels -- matched across backends " + f"AMICA IC scalp maps @ {n_ch} channels -- matched across backends " "(rows), same IC (columns)", fontsize=10, ) @@ -615,10 +630,24 @@ def main() -> int: else [full.shape[1]] ) for nc in channels: + # #91: with a montage, pick spatially-distributed (whole-head) channels + # rather than the first nc electrodes in file order (a spatial cluster); + # the selected data-channel indices are saved so the topomaps use them. + if args.montage and nc < full.shape[0]: + from channel_selection import ( + positions_for_channels, + select_distributed_channels, + ) + + sel = select_distributed_channels( + positions_for_channels(args.montage, full.shape[0]), nc + ) + else: + sel = np.arange(nc) for nf in frame_list: - data = np.ascontiguousarray(full[:nc, :nf]) - k = nf / nc**2 - print(f"\n{nc}ch, {nf} frames (k={k:.1f})") + data = np.ascontiguousarray(full[sel][:, :nf]) + k = nf / len(sel) ** 2 + print(f"\n{len(sel)}ch, {nf} frames (k={k:.1f})") for b in backends: label = f"{b}@{tag}" try: @@ -629,7 +658,8 @@ def main() -> int: fname, label=label, backend=b, - channels=nc, + channels=len(sel), + channel_indices=sel, frames=nf, iters=args.iters, time=res["time"], diff --git a/benchmarks/benchmark_dimsweep.py b/benchmarks/benchmark_dimsweep.py index c97d3e0..05161dd 100644 --- a/benchmarks/benchmark_dimsweep.py +++ b/benchmarks/benchmark_dimsweep.py @@ -508,6 +508,11 @@ def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--data", help="path to the (70, n_samples) real-EEG .npy") ap.add_argument("--channels", default="16,32,48,70") + ap.add_argument( + "--montage", + help="BIDS electrodes.tsv; when given, reduced channel counts use " + "spatially-distributed (whole-head) subsets instead of the first N (#91)", + ) ap.add_argument("--samples", type=int, default=30000) ap.add_argument("--iters", type=int, default=30) ap.add_argument("--repeats", type=int, default=3) @@ -608,7 +613,24 @@ def _thread_points(b): rows = [] for nc in channels: - data = np.ascontiguousarray(full[:nc, : args.samples]) + # #91: with a montage, time whole-head distributed subsets rather than the + # first nc electrodes (a spatial cluster). Channel count is unchanged, so the + # timing is unaffected; this only makes the reduced montages physical. + if args.montage and nc < full.shape[0]: + from channel_selection import ( + positions_for_channels, + select_distributed_channels, + ) + + sel = select_distributed_channels( + positions_for_channels(args.montage, full.shape[0]), nc + ) + else: + sel = np.arange(nc) + # a montage with fewer localized electrodes than requested yields fewer + # channels than nc; record/print the actual count so the sweep is honest. + n_sel = len(sel) + data = np.ascontiguousarray(full[sel][:, : args.samples]) for b in backends: for t in _thread_points(b): tag = f"@{t}t" if t is not None else "" @@ -626,7 +648,7 @@ def _thread_points(b): rows.append( { "config": config, - "channels": nc, + "channels": n_sel, "backend": b, "threads": t, "ms_per_iter": ms, @@ -634,18 +656,18 @@ def _thread_points(b): } ) print( - f" [{config}] {nc:3d}ch {b:18s}{tag:5s} " + f" [{config}] {n_sel:3d}ch {b:18s}{tag:5s} " f"{ms:9.2f} ms/it LL={ll:+.5f}" ) except Exception as exc: # noqa: BLE001 - report and continue print( - f" [{config}] {nc:3d}ch {b:18s}{tag:5s} " + f" [{config}] {n_sel:3d}ch {b:18s}{tag:5s} " f"FAILED: {type(exc).__name__}: {str(exc)[:60]}" ) rows.append( { "config": config, - "channels": nc, + "channels": n_sel, "backend": b, "threads": t, "error": str(exc)[:120], diff --git a/benchmarks/channel_selection.py b/benchmarks/channel_selection.py new file mode 100644 index 0000000..16cc965 --- /dev/null +++ b/benchmarks/channel_selection.py @@ -0,0 +1,116 @@ +"""Spatially-distributed channel subset selection (issue #91). + +The dimension sweeps reduce channel count by slicing ``full[:nc]`` -- the *first* +nc electrodes in file order. For a montage like ds002718 (EEG001..EEG070) the +first 16/32/48 electrodes are a spatial cluster (one scalp region), not a +whole-head cap, so the reduced-channel decompositions and their IC scalp maps are +not physically meaningful reduced montages. + +This module picks nc electrodes **evenly distributed across the scalp** via greedy +farthest-point (k-center) sampling over the real 3D electrode positions from a +BIDS ``electrodes.tsv``, so every subset is a proper whole-head montage. Selection +is deterministic (seeded from the electrode nearest the montage centroid). +""" + +from __future__ import annotations + +import csv + +import numpy as np + + +def read_electrode_positions(tsv_path): + """Parse a BIDS ``electrodes.tsv`` into ordered names and 3D positions. + + Rows whose ``x``/``y``/``z`` are non-numeric (e.g. ``n/a`` for unlocalized or + non-EEG channels) are skipped. + + Parameters + ---------- + tsv_path : str or path-like + Path to a BIDS ``electrodes.tsv`` (tab-separated, columns ``name x y z``). + + Returns + ------- + names : list of str + Electrode names, in file order, for the localized channels. + positions : ndarray of shape (n_localized, 3) + Corresponding 3D coordinates. + """ + names, pos = [], [] + with open(tsv_path) as f: + for row in csv.DictReader(f, delimiter="\t"): + try: + xyz = [float(row["x"]), float(row["y"]), float(row["z"])] + except (ValueError, KeyError, TypeError): + continue + names.append(row["name"]) + pos.append(xyz) + return names, np.asarray(pos, dtype=float) + + +def positions_for_channels(tsv_path, n_channels, name_fmt="EEG{:03d}"): + """Positions aligned to data-channel index for the first ``n_channels``. + + Data channel ``i`` (0-based) is assumed to be electrode ``name_fmt.format(i+1)`` + (``EEG001``, ``EEG002``, ...), matching the benchmark data layout. Channels + without a localized position are filled with NaN. + + Returns + ------- + ndarray of shape (n_channels, 3) + Row ``i`` is the position of data channel ``i`` (NaN if unlocalized). + """ + names, pos = read_electrode_positions(tsv_path) + lookup = dict(zip(names, pos)) + out = np.full((n_channels, 3), np.nan, dtype=float) + for i in range(n_channels): + p = lookup.get(name_fmt.format(i + 1)) + if p is not None: + out[i] = p + return out + + +def select_distributed_channels(positions, n): + """Greedy farthest-point (k-center) selection of ``n`` spread-out channels. + + Starting from the channel nearest the centroid, repeatedly add the channel + that is farthest (in the max-min sense) from the already-selected set. This + yields a spatially distributed, whole-head subset rather than a cluster. + + Parameters + ---------- + positions : ndarray of shape (n_channels, 3) + Positions aligned to data-channel index; rows may be NaN (unlocalized), + which are excluded from selection. + n : int + Number of channels to select. + + Returns + ------- + ndarray of int + Sorted data-channel indices of the selected channels. If ``n`` is at least + the number of localized channels, all localized indices are returned. + """ + positions = np.asarray(positions, dtype=float) + localized = np.where(np.isfinite(positions).all(axis=1))[0] + pts = positions[localized] + if n <= 0: + return localized[:0] + if n >= len(localized): + return localized + centroid = pts.mean(axis=0) + first = int(np.argmin(((pts - centroid) ** 2).sum(axis=1))) + selected = [first] + # running min distance from every point to the selected set + dist = np.sqrt(((pts - pts[first]) ** 2).sum(axis=1)) + # exclude already-selected points from argmax so coincident coordinates + # (dist ties at 0) can never be picked twice -- keeps the subset unique. + avail = np.ones(len(pts), dtype=bool) + avail[first] = False + while len(selected) < n: + nxt = int(np.argmax(np.where(avail, dist, -np.inf))) + selected.append(nxt) + avail[nxt] = False + dist = np.minimum(dist, np.sqrt(((pts - pts[nxt]) ** 2).sum(axis=1))) + return np.sort(localized[np.asarray(selected, dtype=int)]) diff --git a/pyAMICA/tests/fixtures/ds002718_sub-002_electrodes.tsv b/pyAMICA/tests/fixtures/ds002718_sub-002_electrodes.tsv new file mode 100644 index 0000000..660f684 --- /dev/null +++ b/pyAMICA/tests/fixtures/ds002718_sub-002_electrodes.tsv @@ -0,0 +1,75 @@ +name x y z +EEG001 -6.62 4.17 1.68 +EEG002 10.83 -0.46 3.59 +EEG003 -7.31 -4.20 1.86 +EEG004 8.61 4.67 3.37 +EEG005 9.79 2.51 6.06 +EEG006 10.16 -0.67 7.18 +EEG007 9.21 -3.86 6.14 +EEG008 7.94 -5.64 3.24 +EEG009 6.46 5.75 3.92 +EEG010 7.19 5.04 6.42 +EEG011 7.59 3.60 8.18 +EEG012 7.70 1.60 9.66 +EEG013 7.92 -0.57 10.03 +EEG014 7.28 -3.18 9.54 +EEG015 7.10 -5.00 7.76 +EEG016 6.48 -6.19 5.82 +EEG017 5.54 -6.80 3.41 +EEG018 3.26 6.75 1.14 +EEG019 3.87 6.68 4.33 +EEG020 4.40 6.08 7.08 +EEG021 4.61 4.63 9.42 +EEG022 4.74 2.49 10.98 +EEG023 4.85 -0.40 11.55 +EEG024 4.47 -3.40 10.89 +EEG025 3.68 -5.64 9.40 +EEG026 3.45 -7.16 6.96 +EEG027 2.78 -7.79 4.35 +EEG028 2.45 -7.67 0.78 +EEG029 1.30 6.73 1.17 +EEG030 0.92 7.23 4.38 +EEG031 1.35 6.65 7.71 +EEG032 1.28 5.23 10.10 +EEG033 1.16 2.81 11.80 +EEG034 1.09 -0.05 12.76 +EEG035 0.65 -3.18 11.84 +EEG036 0.66 -5.70 10.38 +EEG037 0.42 -7.61 7.71 +EEG038 0.04 -8.25 4.44 +EEG039 0.58 -7.67 1.03 +EEG040 -2.74 6.58 1.10 +EEG041 -1.93 7.03 4.35 +EEG042 -1.94 6.51 7.64 +EEG043 -1.97 5.06 10.07 +EEG044 -2.05 2.61 11.71 +EEG045 -2.87 -0.22 12.30 +EEG046 -2.51 -3.07 11.82 +EEG047 -2.80 -5.54 10.19 +EEG048 -2.88 -7.12 7.68 +EEG049 -3.09 -7.57 4.58 +EEG050 -3.69 -7.51 1.33 +EEG051 -4.93 5.77 1.48 +EEG052 -4.29 6.09 4.72 +EEG053 -4.48 5.74 7.21 +EEG054 -4.84 4.50 9.25 +EEG055 -5.08 2.20 10.63 +EEG056 -5.35 -0.03 11.00 +EEG057 -5.03 -2.57 10.76 +EEG058 -5.42 -4.44 9.48 +EEG059 -5.40 -5.89 7.31 +EEG060 -5.46 -6.33 5.00 +EEG061 n/a n/a n/a +EEG062 n/a n/a n/a +EEG063 n/a n/a n/a +EEG064 n/a n/a n/a +EEG065 -5.58 -6.28 1.72 +EEG066 -6.36 4.75 4.96 +EEG067 -6.98 3.08 7.89 +EEG068 -7.46 0.14 8.63 +EEG069 -7.53 -3.13 8.10 +EEG070 -7.52 -4.51 5.54 +EEG071 -7.85 2.96 5.47 +EEG072 -8.72 0.23 5.59 +EEG073 -8.69 -2.25 5.71 +EEG074 -8.30 -0.29 1.82 diff --git a/pyAMICA/tests/test_channel_selection.py b/pyAMICA/tests/test_channel_selection.py new file mode 100644 index 0000000..87f5bb5 --- /dev/null +++ b/pyAMICA/tests/test_channel_selection.py @@ -0,0 +1,119 @@ +"""Tests for spatially-distributed channel selection (issue #91). + +Uses real electrode coordinates (ds002718 sub-002 BIDS electrodes.tsv, committed +as a fixture) -- never synthetic geometry. +""" + +import importlib.util +from itertools import combinations +from pathlib import Path + +import numpy as np + +_REPO = Path(__file__).resolve().parents[2] +_FIXTURE = ( + Path(__file__).resolve().parent / "fixtures" / "ds002718_sub-002_electrodes.tsv" +) + + +def _load_channel_selection(): + path = _REPO / "benchmarks" / "channel_selection.py" + spec = importlib.util.spec_from_file_location("channel_selection", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +cs = _load_channel_selection() + + +def _min_pairwise(pts): + return min( + float(np.linalg.norm(pts[i] - pts[j])) + for i, j in combinations(range(len(pts)), 2) + ) + + +def test_read_electrode_positions(): + names, pos = cs.read_electrode_positions(_FIXTURE) + assert len(names) == pos.shape[0] + assert pos.shape[1] == 3 + assert "EEG001" in names + assert np.isfinite(pos).all() + # ds002718 sub-002 fixture: 74 electrode rows, 70 with a localized position; + # the 4 external channels (n/a coords) are skipped. + assert len(names) == 70 + + +def test_positions_for_channels_alignment(): + pos = cs.positions_for_channels(_FIXTURE, 70) + assert pos.shape == (70, 3) + # ds002718 sub-002: EEG061-064 are external channels with no scalp position + # (n/a), so 66 of the 70 are localized. + localized = np.isfinite(pos).all(axis=1) + assert localized.sum() == 66 + assert not localized[[60, 61, 62, 63]].any() # EEG061-064 unlocalized + + +def test_selection_deterministic_unique_sorted(): + pos = cs.positions_for_channels(_FIXTURE, 70) + idx1 = cs.select_distributed_channels(pos, 16) + idx2 = cs.select_distributed_channels(pos, 16) + assert len(idx1) == 16 + assert len(set(idx1.tolist())) == 16 # unique + assert np.array_equal(idx1, idx2) # deterministic + assert np.array_equal(idx1, np.sort(idx1)) # sorted ascending + assert idx1.max() < 70 # valid channel indices + + +def test_distributed_more_spread_than_first_n(): + """The whole point of #91: a distributed subset covers the head better than + the first-N cluster, i.e. its nearest-pair distance is larger.""" + pos = cs.positions_for_channels(_FIXTURE, 70) + for n in (16, 32, 48): + idx = cs.select_distributed_channels(pos, n) + first_n = np.arange(n) + assert _min_pairwise(pos[idx]) > _min_pairwise(pos[first_n]) + + +def test_n_ge_localized_returns_all_localized(): + pos = cs.positions_for_channels(_FIXTURE, 70) + # only 66 of 70 are localized; asking for >= that returns the localized set + idx = cs.select_distributed_channels(pos, 70) + assert len(idx) == 66 + # the 4 unlocalized external channels are excluded + assert not (set(idx.tolist()) & {60, 61, 62, 63}) + + +def test_unlocalized_rows_excluded(): + pos = cs.positions_for_channels(_FIXTURE, 70) + pos[5] = np.nan # mark channel 5 as unlocalized + idx = cs.select_distributed_channels(pos, 16) + assert 5 not in idx.tolist() + assert len(idx) == 16 + + +def test_n_zero_returns_empty(): + pos = cs.positions_for_channels(_FIXTURE, 70) + idx = cs.select_distributed_channels(pos, 0) + assert len(idx) == 0 + + +def test_seed_is_centroid_nearest(): + """The greedy selection seeds from the localized channel nearest the montage + centroid; that seed must appear in any non-trivial subset.""" + pos = cs.positions_for_channels(_FIXTURE, 70) + localized = np.where(np.isfinite(pos).all(axis=1))[0] + pts = pos[localized] + centroid = pts.mean(axis=0) + seed = int(localized[np.argmin(((pts - centroid) ** 2).sum(axis=1))]) + assert seed in cs.select_distributed_channels(pos, 8).tolist() + + +def test_coincident_coordinates_stay_unique(): + """Two channels at the exact same position must not both collapse onto one + index: the subset stays unique even when candidates tie at distance 0.""" + pos = cs.positions_for_channels(_FIXTURE, 70) + pos[1] = pos[0] # duplicate a real electrode position onto another channel + idx = cs.select_distributed_channels(pos, 16) + assert len(set(idx.tolist())) == 16 diff --git a/pyproject.toml b/pyproject.toml index 7c7e579..7028989 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,9 @@ docs = [ "mkdocstrings[python]>=0.26", "mkdocs-git-revision-date-localized-plugin>=1.2", ] +# Visualization for the benchmark sweeps: MNE builds the scalp-map montage for +# the IC topomaps and (issue #91) the distributed channel-subset positions. +viz = ["mne>=1.6"] [project.urls] Homepage = "http://github.com/neuromechanist/pyAMICA" diff --git a/uv.lock b/uv.lock index de9f8ab..58df7eb 100644 --- a/uv.lock +++ b/uv.lock @@ -325,6 +325,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + [[package]] name = "execnet" version = "2.1.2" @@ -563,6 +572,18 @@ wheels = [ { 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" }, ] +[[package]] +name = "lazy-loader" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } +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" version = "3.10.2" @@ -873,6 +894,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/59/65d32520175379df33f107749193aa94ea9db069167a36a1a100ff689f62/mlx_metal-0.32.0-py3-none-macosx_26_0_arm64.whl", hash = "sha256:3af76a498d84804f66119800499f9d143d7dffb0878a0dd0d7c2846e58565fd7", size = 56511379, upload-time = "2026-07-07T17:55:36.045Z" }, ] +[[package]] +name = "mne" +version = "1.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "jinja2" }, + { name = "lazy-loader" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pooch" }, + { name = "scipy" }, + { 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" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/da/a3280dbd8f0024b287b625ef97f8ef79ae853ed852e7732641d0ec3c2160/mne-1.12.1-py3-none-any.whl", hash = "sha256:7823bd276d570e9bed2e63e8d86fdbe74d5ee7817b6d01a8e4dc9510ef9e3a91", size = 7509566, upload-time = "2026-04-20T17:16:54.447Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -1210,6 +1251,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pooch" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, +] + [[package]] name = "pyamica" version = "0.1.dev0" @@ -1233,6 +1288,9 @@ docs = [ mlx = [ { name = "mlx" }, ] +viz = [ + { name = "mne" }, +] [package.dev-dependencies] dev = [ @@ -1250,13 +1308,14 @@ requires-dist = [ { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.26" }, { name = "mlx", marker = "extra == 'mlx'", specifier = ">=0.32" }, + { name = "mne", marker = "extra == 'viz'", specifier = ">=1.6" }, { name = "numpy" }, { name = "scipy" }, { name = "threadpoolctl", specifier = ">=3" }, { name = "torch", specifier = ">=2.12.1" }, { name = "tqdm" }, ] -provides-extras = ["mlx", "docs"] +provides-extras = ["mlx", "docs", "viz"] [package.metadata.requires-dev] dev = [