diff --git a/.circleci/config.yml b/.circleci/config.yml index a8943cc0094..3088d9b7c5c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -249,6 +249,23 @@ jobs: cp junit-results.xml doc/_build/test-results/test-doc/junit.xml; cp coverage.xml doc/_build/test-results/test-doc/coverage.xml; fi; + # Ensure the data the JupyterLite notebooks need is on disk so conf.py can + # copy the required subset for the build. lite_data fetches only the + # curated files the browser notebooks use (from the MNE-lite-data OSF + # project) instead of the full sample/kiloword/erp_core/mtrf/eegbci + # datasets, which slims the build. The curated files share hashes with the + # full datasets, so on a full build that already downloaded them nothing + # extra is fetched. + - run: + name: Ensure MNE data for JupyterLite + command: | + python -c "import mne; mne.datasets.lite_data.data_path(update_path=True)" + # Build the dev MNE wheel the JupyterLite browser kernel installs, once, + # before Sphinx runs. conf.py reuses it and only builds it itself if this + # step did not run (e.g. a local build). + - run: + name: Build MNE wheel for JupyterLite + command: python doc/sphinxext/build_lite_wheel.py # Build docs - run: name: make html diff --git a/.github/workflows/jupyterlite.yml b/.github/workflows/jupyterlite.yml new file mode 100644 index 00000000000..c06af5601ae --- /dev/null +++ b/.github/workflows/jupyterlite.yml @@ -0,0 +1,46 @@ +name: Build JupyterLite + +on: # yamllint disable-line rule:truthy + push: + branches: + - jupyterlite-gh-actions + +permissions: + contents: read + +jobs: + build: + name: Build JupyterLite Site + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install JupyterLite & Build Tools + run: | + python -m pip install --upgrade pip + pip install jupyterlite-core jupyterlite-pyodide-kernel build jupyter-server + + - name: Build MNE-Python Wheel + run: | + python -m build --wheel + mkdir -p lite-wheels + cp dist/*.whl lite-wheels/ + + - name: Build JupyterLite Site + # We pass the local wheel to JupyterLite so the browser environment uses the exact code from this branch! + run: | + jupyter lite build --contents examples/ --output-dir dist_lite/ --piplite-wheel lite-wheels/*.whl + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: jupyterlite-build + path: dist_lite/ diff --git a/.gitignore b/.gitignore index 21275b21c0b..3df771d63aa 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ mne/viz/_brain/tests/.ipynb_checkpoints dist/ doc/_build/ +doc/pypi/ doc/generated/ doc/auto_examples/ doc/auto_tutorials/ @@ -103,4 +104,6 @@ venv/ .hypothesis/ .ruff_cache/ .ipynb_checkpoints/ -/.claude/ \ No newline at end of file +jupyterlite_contents/auto_tutorials +jupyterlite_contents/mne_data +/.claude/ diff --git a/doc/api/datasets.rst b/doc/api/datasets.rst index 87730fbd717..e19bf7f8dfb 100644 --- a/doc/api/datasets.rst +++ b/doc/api/datasets.rst @@ -30,6 +30,7 @@ Datasets hf_sef.data_path kiloword.data_path limo.load_data + lite_data.data_path misc.data_path mtrf.data_path multimodal.data_path diff --git a/doc/changes/dev/13925.newfeature.rst b/doc/changes/dev/13925.newfeature.rst new file mode 100644 index 00000000000..d28ef6d6b57 --- /dev/null +++ b/doc/changes/dev/13925.newfeature.rst @@ -0,0 +1,3 @@ +Added a JupyterLite GitHub Actions workflow to automatically build a Wasm-compatible interactive documentation site by Natneal Belete. + +Added :func:`mne.datasets.lite_data.data_path` to fetch the curated subset of dataset files used by the interactive JupyterLite documentation by Natneal Belete. diff --git a/doc/conf.py b/doc/conf.py index 6cec4f97214..410801b19d1 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -11,10 +11,10 @@ import faulthandler import os +import shutil import subprocess import sys from datetime import datetime, timezone -from importlib.metadata import metadata from pathlib import Path import matplotlib @@ -52,7 +52,9 @@ curpath = Path(__file__).parent.resolve(strict=True) sys.path.append(str(curpath / "sphinxext")) +from build_lite_wheel import build_wheel, find_wheels # noqa: E402 from credit_tools import generate_credit_rst # noqa: E402 +from jupyterlite_lite_renderer import LITE_RENDERER_CELL # noqa: E402 from mne_doc_utils import report_scraper, reset_warnings, sphinx_logger # noqa: E402 # -- Project information ----------------------------------------------------- @@ -116,6 +118,7 @@ "sphinx_copybutton", "sphinx_design", "sphinx_gallery.gen_gallery", + "jupyterlite_sphinx", "sphinxcontrib.bibtex", "sphinxcontrib.youtube", "sphinxcontrib.towncrier.ext", @@ -139,7 +142,14 @@ # This pattern also affects html_static_path and html_extra_path. # NB: changes here should also be made to the linkcheck target in the Makefile -exclude_patterns = ["_includes", "changes/dev"] +exclude_patterns = [ + "_includes", + "changes/dev", + "jupyterlite_contents", + "lite_extra", + "pypi", + "corrupt_*", +] # The suffix of source filenames. source_suffix = ".rst" @@ -481,7 +491,1168 @@ compress_images = () sphinx_gallery_parallel = int(os.getenv("MNE_DOC_BUILD_N_JOBS", "1")) +jupyterlite_contents = ["jupyterlite_contents"] +jupyterlite_bind_ipynb_suffix = False + +# Inject the required subset of MNE-sample-data for JupyterLite. The data is +# placed under doc/lite_extra/mne_data and served at the docs root via +# html_extra_path (added below). The JupyterLite setup cell fetches these +# files over HTTP into the Pyodide kernel — the /drive virtual-filesystem +# bridge needs cross-origin-isolation (COOP/COEP) headers that static +# artifact servers (e.g. CircleCI) do not send, so it is unusable there. +# lite_data (mne.datasets.lite_data) extracts the curated subset here, with the +# files under their original dataset folders (MNE-sample-data/, ...). +mne_data_base = Path(os.path.expanduser("~/mne_data")) +lite_root = mne_data_base / "MNE-lite-data" +src_sample_data = lite_root / "MNE-sample-data" +lite_extra_base = ( + Path(os.path.abspath(os.path.dirname(__file__))) / "lite_extra" / "mne_data" +) +dst_sample_data = lite_extra_base / "MNE-sample-data" +dst_sample_data.mkdir(parents=True, exist_ok=True) + + +def _lite_src(folder, rel): + """Return where a dataset file can be read from, or None if nowhere. + + The curated lite_data archive only carries the files it was published with, + so look in whatever CI restored of the real dataset first and fall back to + the archive. Sourcing from the archive alone means anything added since it + was last uploaded goes missing without the build failing. + """ + for root in (mne_data_base / folder, lite_root / folder): + candidate = root / rel + if candidate.exists(): + return candidate + return None + + +print( + f"[JupyterLite] Sample data: real dataset=" + f"{(mne_data_base / 'MNE-sample-data').exists()}, " + f"curated archive={src_sample_data.exists()}" +) +if (mne_data_base / "MNE-sample-data").exists() or src_sample_data.exists(): + required_files = [ + "version.txt", + "MEG/sample/sample_audvis_raw.fif", + "MEG/sample/sample_audvis_filt-0-40_raw.fif", + "MEG/sample/sample_audvis_raw-eve.fif", + "MEG/sample/sample_audvis_filt-0-40_raw-eve.fif", + "MEG/sample/sample_audvis_ecg-proj.fif", + "MEG/sample/sample_audvis-ave.fif", + "MEG/sample/sample_audvis-cov.fif", + "MEG/sample/sample_audvis-meg-eeg-oct-6-fwd.fif", + "MEG/sample/sample_audvis-meg-oct-6-meg-inv.fif", + "MEG/sample/sample_audvis-meg-oct-6-fwd.fif", + "MEG/sample/sample_audvis-meg-oct-6-meg-fixed-inv.fif", + "MEG/sample/ernoise_raw.fif", + "MEG/sample/sample_audvis-no-filter-ave.fif", + "MEG/sample/sample_audvis_raw-trans.fif", + "MEG/sample/sample_audvis-shrunk-cov.fif", + "MEG/sample/sample_audvis-meg-lh.stc", + "MEG/sample/sample_audvis-meg-rh.stc", + "MEG/sample/sample_audvis-meg-eeg-lh.stc", + "MEG/sample/sample_audvis-meg-eeg-rh.stc", + "MEG/sample/sample_audvis_ecg-eve.fif", + # Maxwell-filter calibration pair, read from inside maxwell_filter + # rather than through a shimmable reader (86 KB, so fetched eagerly) + "SSS/sss_cal_mgh.dat", + "SSS/ct_sparse_mgh.fif", + "subjects/sample/mri/T1.mgz", + "subjects/sample/mri/aseg.mgz", + "subjects/sample/bem/sample-oct-6-src.fif", + # Head and skull surfaces for plot_alignment. outer_skin.surf is what + # MNE picks first, so serving it makes the browser figure match the + # rendered docs; sample-head.fif is the later fallback. There is no + # sample-head-dense.fif in the dataset -- lh.seghead is the documented + # second candidate for the dense surface. (These three .surf paths are + # symlinks into bem/flash/, and copy2 follows them.) + "subjects/sample/bem/outer_skin.surf", + "subjects/sample/bem/outer_skull.surf", + "subjects/sample/bem/inner_skull.surf", + "subjects/sample/bem/sample-head.fif", + "subjects/sample/surf/lh.seghead", + # single-layer BEM solution (the 3-layer one is 237 MB, so notebooks + # needing that are excluded instead) + "subjects/sample/bem/sample-5120-bem-sol.fif", + # fsaverage source space, used by the morphing and cluster-stats + # notebooks; it ships inside MNE-sample-data + "subjects/fsaverage/bem/fsaverage-ico-5-src.fif", + "subjects/sample/surf/rh.pial", + "subjects/sample/surf/lh.pial", + "subjects/sample/surf/rh.white", + "subjects/sample/surf/lh.white", + "subjects/sample/surf/rh.inflated", + "subjects/sample/surf/lh.inflated", + "subjects/sample/surf/rh.curv", + "subjects/sample/surf/lh.curv", + "subjects/sample/label/lh.aparc.annot", + "subjects/sample/label/rh.aparc.annot", + ] + for req in required_files: + s = _lite_src("MNE-sample-data", req) + d = dst_sample_data / req + if s is not None: + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(s, d) + print(f"[JupyterLite] Copied: {req}") + else: + print(f"[JupyterLite] MISSING: {req}") + + +# Also inject SSVEP and EEGLAB testing datasets for JupyterLite +lite_data_base = lite_extra_base +lite_data_base.mkdir(parents=True, exist_ok=True) + +src_ssvep = mne_data_base / "ssvep-example-data" +dst_ssvep = lite_data_base / "ssvep-example-data" +print(f"[JupyterLite] SSVEP data source exists: {src_ssvep.exists()}") +if src_ssvep.exists() and not dst_ssvep.exists(): + shutil.copytree(src_ssvep, dst_ssvep, dirs_exist_ok=True) + print("[JupyterLite] Copied ssvep-example-data") + +src_eeglab = mne_data_base / "MNE-testing-data" / "EEGLAB" +dst_eeglab = lite_data_base / "MNE-testing-data" / "EEGLAB" +print(f"[JupyterLite] EEGLAB data source exists: {src_eeglab.exists()}") +if src_eeglab.exists() and not dst_eeglab.exists(): + shutil.copytree(src_eeglab, dst_eeglab, dirs_exist_ok=True) + print("[JupyterLite] Copied MNE-testing-data/EEGLAB") + +# The head-position and Maxwell-filtering tutorials read one continuous +# movement recording out of the testing dataset. CI already restores it from +# data-cache-testing, so only these two files are copied, not the 1.6 GB set. +testing_files = [ + "SSS/test_move_anon_raw.fif", + "SSS/test_move_anon_raw.pos", +] +for testing_file in testing_files: + s = _lite_src("MNE-testing-data", testing_file) + d = lite_data_base / "MNE-testing-data" / testing_file + if s is not None and not d.exists(): + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(s, d) + _mb = s.stat().st_size / 1e6 + print(f"[JupyterLite] Copied {testing_file} ({_mb:.1f} MB)") + elif s is None: + print(f"[JupyterLite] MISSING {testing_file}") + +# The remaining datasets are each used by one or two notebooks that read only a +# couple of files out of them. CI already downloads all of these in +# tools/circleci_download.sh, so copying is free -- but their sizes vary a lot, +# so refuse anything past this limit rather than bloat the artifact. For scale, +# the largest file already served (sample_audvis_raw.fif) is 128 MB. +LITE_MAX_FILE_MB = 150 + + +def _lite_copy(folder, rel_paths): + """Copy selected files of a dataset into the served tree.""" + for rel in rel_paths: + s = _lite_src(folder, rel) + if s is None: + print(f"[JupyterLite] MISSING {folder}/{rel}") + continue + size_mb = s.stat().st_size / 1e6 + if size_mb > LITE_MAX_FILE_MB: + print(f"[JupyterLite] SKIPPED {folder}/{rel} ({size_mb:.1f} MB)") + continue + d = lite_data_base / folder / rel + if not d.exists(): + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(s, d) + print(f"[JupyterLite] Copied {folder}/{rel} ({size_mb:.1f} MB)") + + +def _lite_copy_tree(folder, rel_dir): + """Copy a directory-shaped recording, leaving a manifest for the browser. + + read_raw_nirx and read_raw_egi are handed a folder rather than a file, so + the setup cell has no way to know what to fetch without a listing. + """ + src = mne_data_base / folder / rel_dir + if not src.is_dir(): + print(f"[JupyterLite] MISSING {folder}/{rel_dir}") + return + names, total_mb = [], 0.0 + for f in sorted(src.rglob("*")): + if not f.is_file(): + continue + size_mb = f.stat().st_size / 1e6 + if size_mb > LITE_MAX_FILE_MB: + print(f"[JupyterLite] SKIPPED {folder}/{rel_dir} ({size_mb:.1f} MB)") + return + names.append(str(f.relative_to(src))) + total_mb += size_mb + dst = lite_data_base / folder / rel_dir + dst.mkdir(parents=True, exist_ok=True) + for name in names: + d = dst / name + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src / name, d) + (dst / "_lite_manifest.txt").write_text("\n".join(names)) + print( + f"[JupyterLite] Copied {folder}/{rel_dir} " + f"({len(names)} files, {total_mb:.1f} MB)" + ) + + +_lite_copy( + "MNE-misc-data", + [ + "xdf/sub-P001_ses-S004_task-Default_run-001_eeg_a2.xdf", + "movement/simulated_quats.pos", + "movement/simulated_movement_raw.fif", + "movement/simulated_stationary_raw.fif", + "eyetracking/eyelink/px_textpage_ws.asc", + "eyetracking/eyelink/HREF_textpage_ws.asc", + ], +) +_lite_copy( + "MNE-eyelink-data", + [ + "freeviewing/sub-01_task-freeview_eyetrack.asc", + "freeviewing/stim/naturalistic.png", + "eeg-et/sub-01_task-plr_eyetrack.asc", + ], +) +_lite_copy_tree("MNE-eyelink-data", "eeg-et/sub-01_task-plr_eeg.mff") +_lite_copy_tree("MNE-fNIRS-motor-data", "Participant-1") + +# The logging tutorial reads a KIT file that lives inside the package itself, +# under mne/io/kit/tests/. pyproject excludes "/mne/**/tests" from the wheel, so +# it is absent from the browser kernel -- serve it and let the setup cell stage +# it back into the path the tutorial builds. +_kit_src = Path(mne.__file__).parent / "io" / "kit" / "tests" / "data" / "test.sqd" +_kit_dst = lite_data_base / "MNE-kit-testdata" / "test.sqd" +if _kit_src.exists(): + if not _kit_dst.exists(): + _kit_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(_kit_src, _kit_dst) + print( + f"[JupyterLite] Copied MNE-kit-testdata/test.sqd " + f"({_kit_src.stat().st_size / 1e6:.1f} MB)" + ) +else: + print("[JupyterLite] MISSING MNE-kit-testdata/test.sqd") + +_lite_copy("MNE-phantom-kernel-data", ["phantom_32_100nam_raw.fif"]) +_lite_copy("MNE-multimodal-data", ["multimodal_raw.fif"]) +_lite_copy("MNE-refmeg-noise-data", ["sample_reference_MEG_noise-raw.fif"]) + +# somato is deliberately not served: its raw alone is 344 MB and the six +# notebooks that read it are on the exclude list instead. + +# Inject the single needed file(s) from extra datasets used by the Epochs and +# decoding examples. Sizes are all within what we already serve +# (sample_audvis_raw.fif is 128.5 MB): kiloword 28.7 MB, erp_core 123.6 MB, +# mtrf speech_data.mat 17.2 MB, eegbci 3x2.6 MB. The CI "Ensure ... data" step +# downloads them so the sources exist here. +for _folder, _ds_files in ( + ("MNE-kiloword-data", ["kword_metadata-epo.fif"]), + ("MNE-ERP-CORE-data", ["ERP-CORE_Subject-001_Task-Flankers_eeg.fif"]), + ("mTRF_1.5", ["speech_data.mat"]), + ( + "MNE-eegbci-data", + [ + "files/eegmmidb/1.0.0/S001/S001R06.edf", + "files/eegmmidb/1.0.0/S001/S001R10.edf", + "files/eegmmidb/1.0.0/S001/S001R14.edf", + ], + ), +): + _dst_ds = lite_data_base / _folder + for _ds_file in _ds_files: + s = _lite_src(_folder, _ds_file) + d = _dst_ds / _ds_file + if s is not None: + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(s, d) + print(f"[JupyterLite] Copied: {_folder}/{_ds_file}") + else: + print(f"[JupyterLite] MISSING: {_folder}/{_ds_file}") + + +# Provide the development MNE wheel so JupyterLite installs the current version +# rather than the older release from PyPI. ``doc/sphinxext/build_lite_wheel.py`` +# builds it into ``doc/pypi``, where the jupyterlite-pyodide-kernel PipliteAddon +# discovers and indexes it. Running that script before the docs build (in CI or +# locally) means Sphinx reuses the wheel instead of rebuilding it on every +# invocation; if none is present we build it here, so the docs build never +# depends on the pre-step having run. +_lite_wheels = find_wheels() or build_wheel() +sphinx_logger.info(f"[JupyterLite] MNE wheel for the browser kernel: {_lite_wheels}") + sphinx_gallery_conf = { + "jupyterlite": { + "use_jupyter_lab": True, + "jupyterlite_contents": "jupyterlite_contents", + }, + "first_notebook_cell": ( + "# 💡 This cell is automatically added to the start of each notebook.\n" + "# It installs MNE and patches the browser environment for Pyodide.\n" + "import piplite\n" + "# Use piplite (not micropip) so the locally-built development MNE wheel\n" + "# bundled into the JupyterLite build is preferred over the older PyPI\n" + "# release;\n" + "# piplite checks the local index first and falls back to PyPI for deps.\n" + "# keep_going=True lets it install even if Pyodide's bundled\n" + "# matplotlib/scipy/numpy are older than MNE's declared minimums.\n" + "await piplite.install(\n" + " ['mne', 'scikit-learn', 'joblib', 'pandas', 'seaborn', " + "'mne-connectivity', 'nibabel', 'pyvista-js', 'pyxdf'],\n" + " keep_going=True,\n" + ")\n" + "\n" + "import sys\n" + "import os\n" + "import io\n" + "\n" + "# lzma: try real stdlib first (Pyodide ships it); only mock if absent\n" + "try:\n" + " import lzma\n" + "except ImportError:\n" + " class _LZMAFile:\n" + " def __init__(self, *a, **kw): pass\n" + " def __enter__(self): return self\n" + " def __exit__(self, *a): pass\n" + " def write(self, d): pass\n" + " def read(self, n=-1): return b''\n" + " def close(self): pass\n" + " class _MockLZMA:\n" + " LZMAError = Exception\n" + " LZMAFile = _LZMAFile\n" + " FORMAT_XZ = 1\n" + " FORMAT_ALONE = 2\n" + " def __getattr__(self, name): return object\n" + " import sys as _sys\n" + " _sys.modules['lzma'] = _MockLZMA()\n" + "\n" + "# Mock multiprocessing — missing in Pyodide but imported by joblib\n" + "from unittest.mock import MagicMock\n" + "if 'multiprocessing' not in sys.modules:\n" + " m = MagicMock()\n" + " m.cpu_count.return_value = 1\n" + " sys.modules['multiprocessing'] = m\n" + " sys.modules['multiprocessing.util'] = m.util\n" + " sys.modules['multiprocessing.pool'] = m.pool\n" + "\n" + "# Patch requests so pooch can fetch files already on /drive/mne_data.\n" + "# open_url works for both text and binary in Pyodide >= 0.21.\n" + "import requests\n" + "import pyodide\n" + "orig_send = requests.Session.send\n" + "def pyodide_send(self, request, **kwargs):\n" + " try:\n" + " buf = pyodide.http.open_url(request.url)\n" + " content = buf.getvalue() if hasattr(buf, 'getvalue') else buf.read()\n" + " if isinstance(content, str):\n" + " content = content.encode('utf-8')\n" + " except Exception as e:\n" + " print(f'open_url failed for {request.url}: {e}')\n" + " return orig_send(self, request, **kwargs)\n" + " response = requests.Response()\n" + " response.status_code = 200\n" + " response.url = request.url\n" + " response.raw = io.BytesIO(content)\n" + " return response\n" + "requests.Session.send = pyodide_send\n" + "\n" + "# /drive/ in Pyodide requires Cross-Origin-Isolation headers\n" + "# (COOP/COEP) which many static servers (e.g. CircleCI artifacts)\n" + "# do not send. Fetch the data over HTTP into /tmp/mne_data instead\n" + "# — same-origin, no CORS. The data is served at the docs root\n" + "# (/mne_data/...) via Sphinx html_extra_path.\n" + "# Pyodide may run in a web worker (no `window`); `location` exists\n" + "# in both the main thread and workers, so use it to find the docs\n" + "# root by splitting on '/lite/'.\n" + "import pyodide.http as _phttp\n" + "import js as _js\n" + "try:\n" + " _page = str(_js.location.href)\n" + "except Exception:\n" + " _page = str(_js.window.location.href)\n" + "_base = _page.split('/lite/')[0] + '/mne_data/'\n" + "mne_data_path = '/tmp/mne_data'\n" + "_sample_dir = mne_data_path + '/MNE-sample-data'\n" + "# Eager 'core': small, commonly-used sample files fetched once at\n" + "# notebook start. The heavy files (raw / filt raw / ernoise / fwd /\n" + "# inv / src, ~360 MB total) are intentionally omitted here -- they are\n" + "# fetched lazily on first read via the reader shims below, so each\n" + "# notebook only downloads the sample files it actually uses.\n" + "_sample_files = [\n" + " 'version.txt',\n" + " 'MEG/sample/sample_audvis_raw-eve.fif',\n" + " 'MEG/sample/sample_audvis_filt-0-40_raw-eve.fif',\n" + " 'MEG/sample/sample_audvis_ecg-proj.fif',\n" + " 'MEG/sample/sample_audvis-cov.fif',\n" + " 'MEG/sample/sample_audvis-ave.fif',\n" + " 'MEG/sample/sample_audvis-no-filter-ave.fif',\n" + " 'MEG/sample/sample_audvis_raw-trans.fif',\n" + " 'MEG/sample/sample_audvis-shrunk-cov.fif',\n" + " 'MEG/sample/sample_audvis-meg-lh.stc',\n" + " 'MEG/sample/sample_audvis-meg-rh.stc',\n" + " 'subjects/sample/mri/T1.mgz',\n" + " 'subjects/sample/surf/rh.pial',\n" + " 'subjects/sample/surf/lh.pial',\n" + " 'subjects/sample/surf/rh.white',\n" + " 'subjects/sample/surf/lh.white',\n" + " 'subjects/sample/label/lh.aparc.annot',\n" + " 'subjects/sample/label/rh.aparc.annot',\n" + " 'SSS/sss_cal_mgh.dat',\n" + " 'SSS/ct_sparse_mgh.fif',\n" + "]\n" + "print('Fetching MNE sample data (once per session)...')\n" + "for _f in _sample_files:\n" + " _dst = _sample_dir + '/' + _f\n" + " if os.path.exists(_dst):\n" + " continue\n" + " _url = _base + 'MNE-sample-data/' + _f\n" + " try:\n" + " _r = await _phttp.pyfetch(_url)\n" + " if _r.status != 200:\n" + " print(f' HTTP {_r.status} for {_url}')\n" + " continue\n" + " _d = await _r.bytes()\n" + " if _d[:4] == b''\n" + " ))\n" + " except Exception as _e:\n" + " print('(report preview unavailable: ' + repr(_e) + ')')\n" + " return fname\n" + "mne.Report.save = _lite_report_save\n" + "# the logging tutorial reads a KIT file from inside the installed\n" + "# package; the wheel excludes mne/**/tests, so stage the served copy\n" + "# into the path the tutorial builds rather than editing the tutorial\n" + "import shutil as _shutil\n" + "_orig_read_raw_kit = mne.io.read_raw_kit\n" + "def _lite_read_raw_kit(input_fname, *_a, **_kw):\n" + " _p = str(input_fname)\n" + " if _p.endswith('test.sqd') and not os.path.exists(_p):\n" + " try:\n" + " _staged = _lite_fetch_rel('MNE-kit-testdata/test.sqd')\n" + " os.makedirs(os.path.dirname(_p), exist_ok=True)\n" + " _shutil.copyfile(_staged, _p)\n" + " except Exception as _e:\n" + " print('[JupyterLite] could not stage test.sqd: ' + repr(_e))\n" + " return _orig_read_raw_kit(input_fname, *_a, **_kw)\n" + "mne.io.read_raw_kit = _lite_read_raw_kit\n" + "# a BrainVision .vhdr is a text header pointing at a .eeg and a .vmrk\n" + "_orig_read_raw_brainvision = mne.io.read_raw_brainvision\n" + "def _lite_read_raw_brainvision(vhdr_fname, *_a, **_kw):\n" + " _p = str(vhdr_fname)\n" + " if _p.startswith(mne_data_path + '/'):\n" + " _stem = _p[:-5] if _p.endswith('.vhdr') else _p\n" + " for _cand in (_p, _stem + '.eeg', _stem + '.vmrk'):\n" + " try:\n" + " _lite_fetch_rel(_cand[len(mne_data_path) + 1:])\n" + " except Exception:\n" + " pass\n" + " return _orig_read_raw_brainvision(vhdr_fname, *_a, **_kw)\n" + "mne.io.read_raw_brainvision = _lite_read_raw_brainvision\n" + "# eyelink .asc recordings are single files\n" + "_orig_read_raw_eyelink = mne.io.read_raw_eyelink\n" + "def _lite_read_raw_eyelink(fname, *_a, **_kw):\n" + " return _orig_read_raw_eyelink(\n" + " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" + " )\n" + "mne.io.read_raw_eyelink = _lite_read_raw_eyelink\n" + "# the heatmap example draws its stimulus straight through pyplot, and\n" + "# read_xdf goes through pyxdf -- neither is an MNE reader, so shim the\n" + "# two entry points as well\n" + "import matplotlib.pyplot as _plt\n" + "_orig_imread = _plt.imread\n" + "def _lite_imread(fname, *_a, **_kw):\n" + " return _orig_imread(_lite_fetch_if_under_mne_data(fname), *_a, **_kw)\n" + "_plt.imread = _lite_imread\n" + "try:\n" + " import pyxdf as _pyxdf\n" + " _orig_load_xdf = _pyxdf.load_xdf\n" + " def _lite_load_xdf(fname, *_a, **_kw):\n" + " return _orig_load_xdf(\n" + " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" + " )\n" + " _pyxdf.load_xdf = _lite_load_xdf\n" + "except Exception:\n" + " pass\n" + "import mne.chpi as _mne_chpi\n" + "_orig_read_head_pos = _mne_chpi.read_head_pos\n" + "def _lite_read_head_pos(fname, *_a, **_kw):\n" + " return _orig_read_head_pos(\n" + " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" + " )\n" + "_mne_chpi.read_head_pos = _lite_read_head_pos\n" + "mne.chpi.read_head_pos = _lite_read_head_pos\n" + "# read_source_estimate is handed the stem of a .stc pair, so fetch\n" + "# both hemispheres before letting MNE resolve the name itself.\n" + "_orig_read_source_estimate = mne.read_source_estimate\n" + "def _lite_read_source_estimate(fname, *_a, **_kw):\n" + " _p = str(fname)\n" + " if _p.startswith(mne_data_path + '/'):\n" + " for _suf in ('', '-lh.stc', '-rh.stc'):\n" + " try:\n" + " _lite_fetch_rel(_p[len(mne_data_path) + 1:] + _suf)\n" + " except Exception:\n" + " pass\n" + " return _orig_read_source_estimate(fname, *_a, **_kw)\n" + "mne.read_source_estimate = _lite_read_source_estimate\n" + "# plot_alignment locates its head surface by probing the filesystem\n" + "# with os.path.exists before any reader runs, so a reader shim never\n" + "# fires. Fetch the candidates first and let MNE choose as it normally\n" + "# would. mne.viz._3d binds the function at import time, so patch both.\n" + "import mne._freesurfer as _mne_fs\n" + "_orig_get_head_surface = _mne_fs._get_head_surface\n" + "def _lite_get_head_surface(surf, subject, subjects_dir, bem=None,\n" + " verbose=None):\n" + " _sd = str(subjects_dir) if subjects_dir is not None else ''\n" + " if subject and _sd.startswith(mne_data_path + '/'):\n" + " _rel = _sd[len(mne_data_path) + 1:] + '/' + str(subject)\n" + " if surf in ('head-dense', 'seghead'):\n" + " _cands = ['bem/' + str(subject) + '-head-dense.fif',\n" + " 'surf/lh.seghead']\n" + " else:\n" + " # same order MNE tries, so the browser picks the same\n" + " # surface the rendered docs did\n" + " _cands = ['bem/outer_skin.surf',\n" + " 'bem/' + str(subject) + '-head.fif']\n" + " for _c in _cands:\n" + " try:\n" + " _lite_fetch_rel(_rel + '/' + _c)\n" + " except Exception:\n" + " pass\n" + " return _orig_get_head_surface(\n" + " surf, subject, subjects_dir, bem=bem, verbose=verbose\n" + " )\n" + "_mne_fs._get_head_surface = _lite_get_head_surface\n" + "import mne.viz._3d as _mne_viz3d\n" + "_mne_viz3d._get_head_surface = _lite_get_head_surface\n" + "# same story for the skull surfaces, which _check_fname insists\n" + "# already exist on disk\n" + "_orig_get_skull_surface = _mne_fs._get_skull_surface\n" + "def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None,\n" + " verbose=None):\n" + " _sd = str(subjects_dir) if subjects_dir is not None else ''\n" + " if subject and _sd.startswith(mne_data_path + '/'):\n" + " try:\n" + " _lite_fetch_rel(\n" + " _sd[len(mne_data_path) + 1:] + '/' + str(subject)\n" + " + '/bem/' + surf + '_skull.surf'\n" + " )\n" + " except Exception:\n" + " pass\n" + " return _orig_get_skull_surface(\n" + " surf, subject, subjects_dir, bem=bem, verbose=verbose\n" + " )\n" + "_mne_fs._get_skull_surface = _lite_get_skull_surface\n" + "_mne_viz3d._get_skull_surface = _lite_get_skull_surface\n" + "# plot_bem globs bem/*.surf and requires the bem directory to exist,\n" + "# so pull its three contours (plus the MRI it draws them on) down\n" + "# first; fetching creates the directory as a side effect.\n" + "_orig_plot_bem = mne.viz.plot_bem\n" + "def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw):\n" + " _sd = str(subjects_dir) if subjects_dir is not None else ''\n" + " if subject and _sd.startswith(mne_data_path + '/'):\n" + " _rel = _sd[len(mne_data_path) + 1:] + '/' + str(subject)\n" + " _want = ['bem/inner_skull.surf', 'bem/outer_skull.surf',\n" + " 'bem/outer_skin.surf',\n" + " 'mri/' + str(_kw.get('mri', 'T1.mgz'))]\n" + " _bs = _kw.get('brain_surfaces')\n" + " if _bs is not None:\n" + " _bs = [_bs] if isinstance(_bs, str) else list(_bs)\n" + " for _b in _bs:\n" + " _want += ['surf/lh.' + _b, 'surf/rh.' + _b]\n" + " for _c in _want:\n" + " try:\n" + " _lite_fetch_rel(_rel + '/' + _c)\n" + " except Exception:\n" + " pass\n" + " return _orig_plot_bem(subject, subjects_dir, *_a, **_kw)\n" + "mne.viz.plot_bem = _lite_plot_bem\n" + "\n" + "# EXPERIMENTAL 3D: MNE's normal Brain/VTK stack can't load in WASM, so\n" + "# route SourceEstimate.plot() through pyvista-js (vtk.js) instead.\n" + "# pyvista-js (0.15) has no scalar colormap in its renderer, so we\n" + "# approximate MNE's Brain look with solid-colored meshes: a two-tone\n" + "# curvature base (light gyri + dark sulci) plus many thin 'hot' bands\n" + "# for the activation, on a black background with even scene lighting.\n" + "# Static, one time point, no time slider yet. Fully guarded — any\n" + "# failure prints a message so the notebook completes. Returns a stub\n" + "# 'brain' whose methods (add_foci/add_text/show_view/...) are safe\n" + "# no-ops, so tutorials that call brain.add_foci(...) after plot() work.\n" + "class _LiteBrain:\n" + " def screenshot(self, *_a, **_kw):\n" + " import numpy as _np\n" + " return _np.zeros((2, 2, 3), dtype='uint8')\n" + " def __getattr__(self, _name):\n" + " return lambda *_a, **_kw: None\n" + "def _lite_stc_plot(self, *_a, **_kw):\n" + " try:\n" + " import numpy as _np\n" + " import nibabel as _nib\n" + " from scipy.spatial import cKDTree as _KDTree\n" + " from matplotlib import colormaps as _cmaps\n" + " import pyvista_js as _pv\n" + " _subj = (_kw.get('subject')\n" + " or (_a[0] if _a and isinstance(_a[0], str) else None)\n" + " or 'sample')\n" + " _sdir = _kw.get('subjects_dir')\n" + " _sdir = (str(_sdir) if _sdir is not None else\n" + " mne_data_path + '/MNE-sample-data/subjects')\n" + " # surfaces are fetched relative to the served mne_data root, so\n" + " # derive that from subjects_dir rather than assuming sample --\n" + " # a dataset may keep its FreeSurfer subjects under its own folder.\n" + " _rel_sdir = (_sdir[len(mne_data_path) + 1:]\n" + " if _sdir.startswith(mne_data_path + '/')\n" + " else 'MNE-sample-data/subjects')\n" + " _init = _kw.get('initial_time', None)\n" + " if _init is None:\n" + " _ti = int(_np.argmax(_np.abs(self.data).mean(0)))\n" + " else:\n" + " _ti = int(_np.argmin(_np.abs(self.times - _init)))\n" + " _hot = _cmaps['hot']\n" + " _N = 10\n" + " def _flat(_t):\n" + " return _np.hstack([\n" + " _np.full((len(_t), 1), 3, dtype=_np.int64),\n" + " _t.astype(_np.int64)]).ravel()\n" + " def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None):\n" + " _sel = _tris[_mask]\n" + " if len(_sel) == 0:\n" + " return None\n" + " _u, _iv = _np.unique(_sel, return_inverse=True)\n" + " _p = _pts[_u]\n" + " if _lift and _cen is not None:\n" + " _p = _cen + (_p - _cen) * (1.0 + _lift)\n" + " return _p, _iv.reshape(-1, 3)\n" + " _plotter = _pv.Plotter()\n" + " _plotter.background_color = 'black'\n" + " # even lighting so the surface isn't black when rotated\n" + " for _lp in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),\n" + " (0, -1, 0), (0, 0, 1), (0, 0, -1)):\n" + " _plotter.add_light(_pv.Light(\n" + " position=(300.0 * _lp[0], 300.0 * _lp[1],\n" + " 300.0 * _lp[2]),\n" + " focal_point=(0.0, 0.0, 0.0), intensity=0.4))\n" + " _nlh = len(self.vertices[0])\n" + " _hemis = (('lh', 0, self.vertices[0]),\n" + " ('rh', 1, self.vertices[1]))\n" + " for _h, _hi, _vno in _hemis:\n" + " if len(_vno) == 0:\n" + " continue\n" + " _pre = _rel_sdir + '/' + _subj + '/surf/' + _h\n" + " _lite_fetch_rel(_pre + '.inflated')\n" + " _lite_fetch_rel(_pre + '.curv')\n" + " _bpath = _sdir + '/' + _subj + '/surf/' + _h\n" + " _rr, _tris = mne.read_surface(_bpath + '.inflated')\n" + " _cv = _nib.freesurfer.read_morph_data(_bpath + '.curv')\n" + " _hdata = self.data[:_nlh] if _hi == 0 else self.data[_nlh:]\n" + " # color each surface vertex from the nearest ACTIVE source\n" + " # within a small radius, so single-vertex (point) sources\n" + " # show as visible blobs and dense sources fill in as usual\n" + " _sv = _hdata[:, _ti].astype(float)\n" + " _act = _sv != 0\n" + " _scal = _np.zeros(len(_rr))\n" + " if _act.any():\n" + " _atree = _KDTree(_rr[_vno][_act])\n" + " _ad, _ai = _atree.query(_rr)\n" + " _scal = _np.where(_ad <= 12.0, _sv[_act][_ai], 0.0)\n" + " # offset hemispheres along x so they do not overlap\n" + " _off = -60.0 if _h == 'lh' else 60.0\n" + " _pts = _np.round(_rr, 2)\n" + " _pts[:, 0] = _pts[:, 0] + _off\n" + " _cen = _pts.mean(0)\n" + " # curvature base: light gyri (curv<0) + dark sulci (curv>=0)\n" + " _fc = _cv[_tris].mean(1)\n" + " for _cm, _col in (\n" + " (_fc < 0, (0.68, 0.68, 0.68)),\n" + " (_fc >= 0, (0.38, 0.38, 0.38))):\n" + " _s = _sub(_pts, _tris, _cm)\n" + " if _s is not None:\n" + " _plotter.add_mesh(\n" + " _pv.PolyData(points=_s[0], faces=_flat(_s[1])),\n" + " color=_col, smooth_shading=True)\n" + " # activation as a smooth hot gradient in N value bands,\n" + " # each lifted 2% off the surface to avoid z-fighting\n" + " _fv = _scal[_tris].mean(1)\n" + " _p90 = _np.percentile(_scal, 90.0)\n" + " _fmax = float(_scal.max())\n" + " # keep the background gray: for sparse point sources the\n" + " # 90th pct is ~0 (most of the brain is zero), which would\n" + " # paint everything, so fall back to a fraction of the max.\n" + " _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4\n" + " if _fmax > _fmin:\n" + " _edges = _np.linspace(_fmin, _fmax, _N + 1)\n" + " for _i in range(_N):\n" + " if _i < _N - 1:\n" + " _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1])\n" + " else:\n" + " _m = _fv >= _edges[_i]\n" + " if int(_m.sum()) == 0:\n" + " continue\n" + " _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1)))\n" + " _col = (float(_rgb[0]), float(_rgb[1]),\n" + " float(_rgb[2]))\n" + " _s = _sub(_pts, _tris, _m, 0.02, _cen)\n" + " if _s is not None:\n" + " _plotter.add_mesh(\n" + " _pv.PolyData(points=_s[0],\n" + " faces=_flat(_s[1])),\n" + " color=_col, smooth_shading=True)\n" + " # Open on the lateral profile (camera along the medial-lateral\n" + " # X axis, superior up), like native MNE, instead of vtk.js's\n" + " # default anterior/face-on view. Guarded so a missing\n" + " # view_vector never costs us the render.\n" + " try:\n" + " _plotter.view_vector((-1.0, 0.0, 0.0),\n" + " viewup=(0.0, 0.0, 1.0))\n" + " except Exception:\n" + " pass\n" + " _plotter.show()\n" + " except Exception as _e:\n" + " print('[JupyterLite] pyvista-js 3D render unavailable: '\n" + " + repr(_e))\n" + " return _LiteBrain()\n" + "mne.SourceEstimate.plot = _lite_stc_plot\n" + "\n" + "# Pyodide/WASM has no OS threads, so MNE's ProgressBar background\n" + "# updater thread (used by the ProgressBar context manager, e.g. in\n" + "# permutation cluster tests) crashes with 'can't start new thread'.\n" + "# That thread only animates a cosmetic bar — the computation runs on\n" + "# the main thread and __exit__ writes the final state — so no-op its\n" + "# start/join. Only affects notebooks that use it; results are unchanged.\n" + "try:\n" + " from mne.utils import progressbar as _mpb\n" + " _mpb._UpdateThread.start = lambda self: None\n" + " _mpb._UpdateThread.join = lambda self, *_a, **_kw: None\n" + "except Exception:\n" + " pass\n" + "# tqdm also spawns its own monitor thread, which likewise can't start in\n" + "# WASM and emits a TqdmMonitorWarning. Setting monitor_interval=0 before\n" + "# any bar is created skips that thread entirely (bars still display).\n" + "try:\n" + " import tqdm as _tqdm\n" + " _tqdm.tqdm.monitor_interval = 0\n" + "except Exception:\n" + " pass\n" + "\n" + "# Switch matplotlib to inline so figures render in the notebook.\n" + "import IPython\n" + "IPython.get_ipython().run_line_magic('matplotlib', 'inline')\n" + "import matplotlib.pyplot as plt\n" + "# Silence the spurious 'FigureCanvasAgg is non-interactive' warning\n" + "# at its source. MNE's plt_show calls fig.show() (the inline backend\n" + "# isn't detected as 'agg'), and the inline Agg canvas warns. Patching\n" + "# viz.utils.plt_show is not enough: other modules did\n" + "# `from .utils import plt_show` and hold their own reference. Every\n" + "# path resolves fig.show on the class at call time, so a no-op here\n" + "# silences it everywhere. Figures still render via the inline backend.\n" + "import matplotlib.figure as _mfig\n" + "_mfig.Figure.show = lambda self, *a, **k: None\n" + "import importlib\n" + "viz_utils = importlib.import_module('mne.viz.utils')\n" + "# Also display+close via IPython for paths that call plt_show\n" + "# directly, so figures render exactly once.\n" + "def pyodide_plt_show(show=True, fig=None, **kwargs):\n" + " if not show:\n" + " return\n" + " import IPython.display\n" + " _f = fig if fig is not None else plt.gcf()\n" + " IPython.display.display(_f)\n" + " plt.close(_f)\n" + "viz_utils.plt_show = pyodide_plt_show\n" + "\n" + "# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer\n" + "# BEFORE the time-course figure, so in WASM the whole call dies and the\n" + "# notebook loses both halves. Rebuild it here: the same glass brain from\n" + "# the source space and a marker per active dipole via pyvista-js, plus\n" + "# the matplotlib time courses (which are the quantitative half). Same\n" + "# approach as the SourceEstimate.plot shim above.\n" + "def _lite_plot_sparse_source_estimates(\n" + " src, stcs, colors=None, linewidth=2, fontsize=18,\n" + " bgcolor=(0.05, 0, 0.1), opacity=0.2, brain_color=(0.7,) * 3,\n" + " show=True, high_resolution=False, fig_name=None,\n" + " fig_number=None, labels=None, modes=('cone', 'sphere'),\n" + " scale_factors=(1, 0.6), **kwargs):\n" + " import numpy as _np\n" + " from itertools import cycle as _cycle\n" + " from matplotlib.colors import to_rgb as _to_rgb\n" + " if not isinstance(stcs, list):\n" + " stcs = [stcs]\n" + " _lhp = src[0]['rr']\n" + " _pts = _np.r_[_lhp, src[1]['rr']] * 170\n" + " _nrm = _np.r_[src[0]['nn'], src[1]['nn']]\n" + " # use_tris is the decimated mesh and can be None on some source\n" + " # spaces; fall back to the full tris in that case.\n" + " _lt = src[0]['tris'] if high_resolution else src[0]['use_tris']\n" + " _rt = src[1]['tris'] if high_resolution else src[1]['use_tris']\n" + " if _lt is None or _rt is None:\n" + " _lt, _rt = src[0]['tris'], src[1]['tris']\n" + " _faces = _np.r_[_lt, len(_lhp) + _rt]\n" + " _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno]\n" + " for _s in stcs]\n" + " _uniq = _np.unique(_np.concatenate(_vertnos).ravel())\n" + " # --- time courses -------------------------------------------------\n" + " _fig = plt.figure(fig_number, layout='constrained')\n" + " _fig.clf()\n" + " _ax = _fig.add_subplot(111)\n" + " _cyc = _cycle(colors if colors is not None else\n" + " plt.rcParams['axes.prop_cycle'].by_key()['color'])\n" + " _marks = []\n" + " for _v in _uniq:\n" + " _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn]\n" + " _c = next(_cyc)\n" + " _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1))\n" + " for _k in _ind:\n" + " _m = _vertnos[_k] == _v\n" + " _ax.plot(1e3 * stcs[_k].times,\n" + " 1e9 * stcs[_k].data[_m].ravel(),\n" + " c=_c, linewidth=linewidth)\n" + " _ax.set_xlabel('Time (ms)', fontsize=fontsize)\n" + " _ax.set_ylabel('Source amplitude (nAm)', fontsize=fontsize)\n" + " if fig_name is not None:\n" + " _ax.set_title(fig_name)\n" + " pyodide_plt_show(show)\n" + " # --- glass brain + dipole markers ---------------------------------\n" + " try:\n" + " import pyvista_js as _pv\n" + " _plotter = _pv.Plotter()\n" + " _plotter.background_color = tuple(\n" + " float(min(max(_x, 0.0), 1.0)) for _x in bgcolor)\n" + " for _lp in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),\n" + " (0, -1, 0), (0, 0, 1), (0, 0, -1)):\n" + " _plotter.add_light(_pv.Light(\n" + " position=(300.0 * _lp[0], 300.0 * _lp[1],\n" + " 300.0 * _lp[2]),\n" + " focal_point=(0.0, 0.0, 0.0), intensity=0.4))\n" + " _flat_faces = _np.hstack([\n" + " _np.full((len(_faces), 1), 3, dtype=_np.int32),\n" + " _faces.astype(_np.int32)]).ravel()\n" + " _plotter.add_mesh(\n" + " _pv.PolyData(points=_pts.astype(_np.float32),\n" + " faces=_flat_faces),\n" + " color=tuple(float(_x) for _x in brain_color),\n" + " opacity=float(opacity), smooth_shading=True)\n" + " for _v, _col, _common in _marks:\n" + " _sf = float(scale_factors[1] if _common\n" + " else scale_factors[0])\n" + " _mode = modes[1] if _common else modes[0]\n" + " _xyz = tuple(float(_q) for _q in _pts[_v])\n" + " if _mode == 'sphere':\n" + " _glyph = _pv.Sphere(radius=_sf, center=_xyz)\n" + " else:\n" + " _glyph = _pv.Cone(\n" + " center=_xyz,\n" + " direction=tuple(float(_q) for _q in _nrm[_v]),\n" + " height=2.0 * _sf, radius=_sf)\n" + " _plotter.add_mesh(_glyph, color=_col, smooth_shading=True)\n" + " try:\n" + " _plotter.view_vector((-1.0, 0.0, 0.0),\n" + " viewup=(0.0, 0.0, 1.0))\n" + " except Exception:\n" + " pass\n" + " _plotter.show()\n" + " except Exception as _e:\n" + " print('[JupyterLite] pyvista-js glass brain unavailable: '\n" + " + repr(_e))\n" + "mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates\n" + "\n" + "# Each MNE plot is rendered once by pyodide_plt_show above (display()).\n" + "# When a plot call is also a cell's last expression, the method returns\n" + "# the Figure, which Jupyter echoes a SECOND time as the Out[] result\n" + "# (the duplicate seen below inline plots). Drop that redundant echo for\n" + "# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each\n" + "# plot appears exactly once. Non-figure results (numbers, DataFrames,\n" + "# reprs) are untouched, and raw matplotlib figures never shown still\n" + "# render via the inline backend's end-of-cell flush, so nothing hides.\n" + "# Wrapped in try/except (like the patches below): if anything about\n" + "# the displayhook is unexpected, silently keep the current behavior\n" + "# (harmless double render) rather than breaking the setup cell.\n" + "try:\n" + " _lite_dh = type(IPython.get_ipython().displayhook)\n" + " if not getattr(_lite_dh, '_lite_no_fig_echo', False):\n" + " _lite_dh_call = _lite_dh.__call__\n" + " def _lite_displayhook(self, result=None):\n" + " if isinstance(result, _mfig.Figure):\n" + " result = None\n" + " elif (isinstance(result, (list, tuple)) and result\n" + " and all(isinstance(_x, _mfig.Figure) for _x in result)):\n" + " result = None\n" + " return _lite_dh_call(self, result)\n" + " _lite_dh.__call__ = _lite_displayhook\n" + " _lite_dh._lite_no_fig_echo = True\n" + "except Exception:\n" + " pass\n" + "\n" + "# Real fix (not a warnings filter) for the threadpoolctl Pyodide\n" + "# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest\n" + "# release) still calls the deprecated Pyodide JsProxy.as_object_map().\n" + "# Pyodide's own message says to use as_py_json() instead; both yield the\n" + "# same library filepaths, so we swap the call at its source. This removes\n" + "# the deprecated API usage entirely, so the warning is never emitted.\n" + "# The upstream fix is already merged (joblib/threadpoolctl#201) but\n" + "# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH\n" + "# once threadpoolctl 3.7.0 is released and Pyodide bundles it.\n" + "try:\n" + " import os as _os\n" + " import threadpoolctl as _tpc\n" + " def _find_libraries_pyodide(self):\n" + " from pyodide_js._module import LDSO\n" + " for _fp in LDSO.loadedLibsByName.as_py_json():\n" + " if _os.path.exists(_fp):\n" + " self._make_controller_from_path(_fp)\n" + " _tpc.ThreadpoolController._find_libraries_pyodide = (\n" + " _find_libraries_pyodide\n" + " )\n" + "except Exception:\n" + " pass\n" + LITE_RENDERER_CELL + # Draw MNE's 3D figures with pyvista-js. Appended last so MNE is + # already imported; see doc/sphinxext/jupyterlite_lite_renderer.py. + ), "doc_module": ("mne",), "reference_url": dict(mne=None), "examples_dirs": examples_dirs, @@ -583,6 +1754,131 @@ "parallel": sphinx_gallery_parallel, } assert is_serializable(sphinx_gallery_conf) + +# --------------------------------------------------------------------------- +# Drop the "Open in JupyterLite" launch badge from gallery pages whose +# notebooks cannot run in the browser kernel at all: they need the R runtime +# (rpy2), a compiled package Pyodide does not ship (antio), or multi-GB +# datasets that cannot be bundled/slimmed. sphinx-gallery adds the badge to +# every example unconditionally, so we wrap its badge generator and return an +# empty string for these files. This only removes the badge/link — the +# notebook source is untouched (no in-code guard). Files that merely need data +# bundled, a pure-Python package installed, or pyvista 3D are NOT listed here +# (they are fixable, not impossible). +JUPYTERLITE_EXCLUDE = ( + # Tier 1 — impossible: R runtime / compiled package / huge single dataset + "examples/stats/r_interop.py", # rpy2 -> needs the R runtime + "examples/io/read_impedances.py", # antio (compiled, not in Pyodide) + "examples/decoding/decoding_rsa_sgskip.py", # visual_92_categories ~6 GB + "examples/decoding/decoding_spoc_CMC.py", # fieldtrip_cmc ~700 MB + "examples/decoding/ssd_spatial_filters.py", # fieldtrip_cmc ~700 MB + # Tier 2 — multi-GB datasets (brainstorm / spm_face / opm / hf_sef) + "examples/datasets/brainstorm_data.py", + "examples/datasets/hf_sef_data.py", + "examples/datasets/opm_data.py", + "examples/datasets/spm_faces_dataset.py", + "examples/preprocessing/movement_detection.py", + "examples/preprocessing/muscle_detection.py", + "examples/preprocessing/otp.py", + "examples/time_frequency/source_power_spectrum_opm.py", + "examples/visualization/evoked_arrowmap.py", + "examples/visualization/meg_sensors.py", + "tutorials/inverse/80_brainstorm_phantom_elekta.py", + "tutorials/inverse/85_brainstorm_phantom_ctf.py", + "tutorials/io/60_ctf_bst_auditory.py", + "tutorials/preprocessing/80_opm_processing.py", + # Tier 3 — several blockers each, none of them worth clearing on its own + # the volume inverse is ~178 MB and volume source estimates are not + # rendered in the browser + "examples/inverse/compute_mne_inverse_volume.py", + # needs aseg.mgz and the mixed source space, and calls src.plot(), which + # is the 3D SourceSpaces view + "examples/inverse/mixed_source_space_inverse.py", + # nilearn.datasets.load_mni152_template() downloads a template at runtime, + # which the browser blocks (CORS); the surrounding try only catches + # TypeError, so the failure is not survivable + "tutorials/inverse/20_dipole_fit.py", + # make_field_map(upsampling=2) subdivides the helmet mesh through VTK, and + # plot_field needs the interactive viewer that the browser renderer skips + "examples/visualization/mne_helmet.py", + # Tier 4 — mne.viz.Brain. The browser renderer draws static meshes; Brain + # additionally wants dock widgets, a toolbar and a time slider, so these + # are blocked on the interactive layer rather than on data. + "examples/visualization/brain.py", + "examples/visualization/parcellation.py", + "tutorials/clinical/20_seeg.py", + "tutorials/forward/10_background_freesurfer.py", + "tutorials/forward/50_background_freesurfer_mne.py", + "tutorials/inverse/60_visualize_stc.py", + "tutorials/io/30_reading_fnirs_data.py", + "tutorials/preprocessing/70_fnirs_processing.py", + # Tier 5 — one-off blockers with no browser path + # plot_field needs the interactive viewer + "tutorials/evoked/20_visualize_evoked.py", + # the three-layer BEM solution alone is 237 MB + "examples/inverse/multi_dipole_model.py", + # openneuro fetches the recording at runtime, which the browser blocks + "examples/preprocessing/esg_rm_heart_artefact_pcaobs.py", + # physionet.org is not CORS-enabled and the dataset is not on the CI box + "tutorials/clinical/60_sleep.py", + # the 4D/BTi phantom dataset is not among the ones CI downloads + "tutorials/inverse/90_phantom_4DBTi.py", + # needs mne_bids as well as the epilepsy_ecog dataset and 3D sensor views + "tutorials/clinical/30_ecog.py", + # Tier 6 — fetch_fsaverage. _manifest_check_download only skips the + # download when every one of its ~190 manifest entries is already present, + # so fsaverage cannot be part-bundled, and MNE-sample-data ships no + # fsaverage/bem at all. The volume forward and inverse these two want are + # 187 MB and 360 MB on top of that. + "examples/inverse/morph_volume_stc.py", + "tutorials/inverse/50_beamformer_lcmv.py", + "examples/visualization/montage_sgskip.py", + # same, plus fetch_infant_template downloads a second template + "tutorials/forward/35_eeg_no_mri.py", + # snapshot_brain_montage needs a real 3D window to read pixels back from + "examples/visualization/3d_to_2d.py", + # the three-layer BEM solution is 237 MB, and T1_electrodes.mgz would pull + # in the misc dataset's MRI as well + "tutorials/inverse/70_eeg_mri_coords.py", + # mne_bids is not installable in the browser kernel + "tutorials/inverse/95_phantom_KIT.py", + # Tier 7 — somato. Serving it costs 404 MB (the raw alone is 344 MB) on + # every docs deploy, which is more than these six pages are worth; the + # dataset is not copied at all. Restoring them means putting the somato + # block back in the copy step above. + "examples/inverse/dics_epochs.py", + "examples/inverse/dics_source_power.py", + "examples/inverse/evoked_ers_source_power.py", + "examples/inverse/multidict_reweighted_tfmxne.py", + "examples/time_frequency/time_frequency_global_field_power.py", + "tutorials/time-freq/20_sensors_time_frequency.py", + # Single recordings well past LITE_MAX_FILE_MB, confirmed against the full + # build: 379 MB and 251 MB for one example each, so they are skipped by the + # copy step and the badge would have nothing to load. + "examples/datasets/kernel_phantom.py", + "examples/io/elekta_epochs.py", + # Report renders its forward/inverse/source-estimate sections through the + # Brain screenshot path, which the browser renderer has no equivalent for, + # and three sections round-trip the report through HDF5. The parts that do + # work are not worth a badge that half-renders, so the whole page is hidden + # (mne.Report.save itself still previews inline -- see the setup cell). + "tutorials/intro/70_report.py", +) + +import sphinx_gallery.gen_rst as _sg_gen_rst # noqa: E402 + +_orig_gen_jupyterlite_rst = _sg_gen_rst.gen_jupyterlite_rst + + +def _lite_badge_filtered(fpath, gallery_conf): + """Return the JupyterLite badge reST, or "" for excluded notebooks.""" + _p = str(fpath).replace(os.sep, "/") + if any(_p.endswith(_ex) for _ex in JUPYTERLITE_EXCLUDE): + return "" + return _orig_gen_jupyterlite_rst(fpath, gallery_conf) + + +_sg_gen_rst.gen_jupyterlite_rst = _lite_badge_filtered # Files were renamed from plot_* with: # find . -type f -name 'plot_*.py' -exec sh -c 'x="{}"; xn=`basename "${x}"`; git mv "$x" `dirname "${x}"`/${xn:5}' \; # noqa @@ -887,6 +2183,9 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): "documentation.html", "getting_started.html", "install_mne_python.html", + # Serve the pre-bundled JupyterLite sample data at the docs root + # (e.g. /mne_data/...). The lite setup cell fetches it over HTTP. + "lite_extra", ] # Custom sidebar templates, maps document names to template names. @@ -1109,11 +2408,10 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): # -- Dependency info ---------------------------------------------------------- -min_py = metadata("mne")["Requires-Python"].lstrip(" =<>") +min_py = "3.10" +min_py_minor = "10" rst_prolog += f"\n.. |min_python_version| replace:: {min_py}\n" -# -- website redirects -------------------------------------------------------- - # Static list created 2021/04/13 based on what we needed to redirect, # since we don't need to add redirects for examples added after this date. needed_plot_redirects = { @@ -1516,8 +2814,25 @@ def rstjinja(app, docname, source): # -- Connect our handlers to the main Sphinx app --------------------------- +def _mark_jupyterlite_parallel_safe(app): + """Declare jupyterlite_sphinx safe for Sphinx's parallel (-j) read phase. + + The jupyterlite-sphinx version pinned by our JupyterLite/Pyodide stack + (0.9.3) predates the ``parallel_read_safe`` metadata that newer releases + declare, so the doc build's ``-j auto`` emits a "does not declare if it is + safe for parallel reading" warning that ``-W`` turns into a build error. + Newer jupyterlite-sphinx marks it read-safe; set the same flag here rather + than bumping the pin (which would drag the whole pinned Pyodide stack + forward and risk the browser build). + """ + ext = app.extensions.get("jupyterlite_sphinx") + if ext is not None and ext.parallel_read_safe is None: + ext.parallel_read_safe = True + + def setup(app): """Set up the Sphinx app.""" + app.connect("builder-inited", _mark_jupyterlite_parallel_safe, priority=1) app.connect("autodoc-process-docstring", append_attr_meth_examples) app.connect("autodoc-process-docstring", fix_sklearn_inherited_docstrings) # High prio, will happen before SG diff --git a/doc/documentation/datasets.rst b/doc/documentation/datasets.rst index 2ec98664e74..2d3e28ca994 100644 --- a/doc/documentation/datasets.rst +++ b/doc/documentation/datasets.rst @@ -541,6 +541,33 @@ the people in the scene were unrecognizable. * :ref:`tut-eyetrack-heatmap` +.. _lite-data: + +JupyterLite data +================ +:func:`mne.datasets.lite_data.data_path` + +A small curated archive holding only the files the browser notebooks read, taken +from the ``sample``, ``kiloword``, ``erp_core``, ``mtrf`` and ``eegbci`` datasets +(same files, same checksums). Those ship as separate multi-GB archives, so without +it the documentation build would download several gigabytes to serve a handful of +files. It extracts to ``MNE-lite-data/``, keeping each file under its original +dataset folder (``MNE-sample-data/``, ``MNE-kiloword-data/``, ...). + +This exists for the documentation build; for analysis, use the individual dataset +fetchers above. + +.. note:: Not every tutorial and example can run in the browser, so the + "Open in JupyterLite" badge is only shown on the pages that work there. A page + is left without a badge when it needs a non-Python runtime (for example the R + interoperability example, via ``rpy2``), a compiled reader with no WebAssembly + build (such as ``antio``), or a dataset too large to serve to a browser + (brainstorm, spm_face, opm, hf_sef, and similar). + + 3D is also limited: source estimates are rendered with vtk.js, but the + coregistration and sensor-plotting views that rely on the full VTK stack are + not available. + References ========== diff --git a/doc/jupyterlite_contents/.gitkeep b/doc/jupyterlite_contents/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/doc/sphinxext/build_lite_wheel.py b/doc/sphinxext/build_lite_wheel.py new file mode 100644 index 00000000000..0fdced0dc70 --- /dev/null +++ b/doc/sphinxext/build_lite_wheel.py @@ -0,0 +1,109 @@ +"""Build the development MNE wheel for the JupyterLite browser kernel. + +Run this once before building the docs, either in CI or locally:: + + python doc/sphinxext/build_lite_wheel.py + +The wheel is written to ``doc/pypi``, where the jupyterlite-pyodide-kernel +PipliteAddon discovers, copies and indexes it (adding it to ``pipliteUrls`` in +``jupyter-lite.json``), so the browser kernel installs the current development +MNE rather than the older release from PyPI. See +https://jupyterlite.readthedocs.io/en/latest/howto/pyodide/wheels.html + +``doc/conf.py`` reuses a wheel that is already there and only falls back to +building one inline when it is missing, so running this ahead of the docs build +means Sphinx does not rebuild the wheel on every invocation. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import glob +import os +import re +import shutil +import subprocess +import sys + +REPO_ROOT = os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) +) +PYPI_WHEELS_DIR = os.path.join(REPO_ROOT, "doc", "pypi") +PYPROJECT_PATH = os.path.join(REPO_ROOT, "pyproject.toml") + + +def find_wheels(): + """Return the MNE wheels already present in ``doc/pypi``. + + Returns + ------- + wheels : list of str + Paths of the MNE wheels found, empty if there are none. + """ + return glob.glob(os.path.join(PYPI_WHEELS_DIR, "mne-*.whl")) + + +def build_wheel(): + """Build the development MNE wheel into ``doc/pypi``. + + Returns + ------- + wheels : list of str + Paths of the MNE wheels that were built. + """ + # Clean first so stale wheels from previous runs do not accumulate and + # pollute the piplite all.json index. + shutil.rmtree(PYPI_WHEELS_DIR, ignore_errors=True) + os.makedirs(PYPI_WHEELS_DIR, exist_ok=True) + + with open(PYPROJECT_PATH, encoding="utf-8") as f: + orig_pyproject = f.read() + + # Relax constraints for Pyodide, which often lags behind PyPI. piplite's + # keep_going=True means these bounds would not block the install anyway, but + # relax them here too so the wheel metadata is accurate for inspection. + patched = re.sub(r'"scipy\s*>=\s*1\.1[0-9]"', '"scipy >= 1.7"', orig_pyproject) + patched = re.sub(r'"matplotlib\s*>=\s*3\.[5-9]"', '"matplotlib >= 3.5"', patched) + patched = re.sub(r'"numpy\s*>=\s*1\.\d+,\s*<\s*3"', '"numpy >= 1.20, < 3"', patched) + os.environ["SETUPTOOLS_SCM_PRETEND_VERSION"] = "9999.0.1" + try: + with open(PYPROJECT_PATH, "w", encoding="utf-8") as f: + f.write(patched) + # NB: build isolation is left ON (the default). MNE uses the hatchling + # build backend, so pip must create an isolated build env to install + # hatchling/hatch-vcs; --no-build-isolation fails with "Cannot import + # 'hatchling.build'" on CI, where those build deps are not in the base + # environment. Isolation also builds from a fresh copy that reads the + # patched pyproject.toml above, so the relaxed bounds are picked up. + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + REPO_ROOT, + "--no-deps", + "-w", + PYPI_WHEELS_DIR, + ], + check=True, + ) + finally: + with open(PYPROJECT_PATH, "w", encoding="utf-8") as f: + f.write(orig_pyproject) + + # Fail loudly rather than silently letting the browser kernel fall back to + # the older released MNE from PyPI. + wheels = find_wheels() + if not wheels: + raise RuntimeError( + f"JupyterLite: no MNE wheel was built into {PYPI_WHEELS_DIR!r}; the " + "browser kernel would fall back to the released PyPI version. Check " + "the 'pip wheel' output above." + ) + return wheels + + +if __name__ == "__main__": + print(f"[JupyterLite] Built MNE wheel(s) for the browser kernel: {build_wheel()}") diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py new file mode 100644 index 00000000000..f8359cb17c0 --- /dev/null +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -0,0 +1,362 @@ +"""A pyvista-js drawing backend for MNE's 3D renderer, for JupyterLite. + +MNE's 3D functions (``plot_alignment``, ``plot_bem``, ``plot_sparse_source_estimates``, +``SourceSpaces.plot``, ...) all build their figure the same way: they do their own +geometry and coordinate-frame work in numpy, then hand the result to a renderer +obtained from ``mne.viz.backends.renderer._get_renderer``. Only that last step needs +VTK, and VTK cannot load in WebAssembly. + +So instead of reimplementing those functions one by one, this module supplies a +renderer that draws with pyvista-js (vtk.js) and patches the factory, along with the +``renderer.backend`` global that ``set_3d_view`` and the other scene-level helpers +read directly. MNE then does all of the transform math itself -- which matters, +because getting a head/MRI/device transform subtly wrong produces a +plausible-looking picture with the sensors in the wrong place, and several of these +tutorials are specifically *about* coordinate alignment. + +What is supported: meshes, surfaces, spheres, tubes and glyphs -- enough for the +static figures the docs render. What is not: the interactive ``Brain`` time viewer, +which additionally needs dock widgets and toolbars, and scalar colormaps, which +pyvista-js 0.15 does not have (scalars fall back to a solid color). + +The source is kept as a string because it has to run inside the browser kernel; see +``first_notebook_cell`` in ``conf.py``. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +LITE_RENDERER_CELL = r''' +# --- pyvista-js drawing backend for MNE's 3D renderer ----------------------- +# Patches mne.viz.backends.renderer._get_renderer so MNE keeps doing its own +# geometry and coordinate-frame work and only the drawing is replaced. +def _lite_view_vector(azimuth): + """Map an MNE azimuth in degrees onto the nearest pyvista-js view vector.""" + _a = float(azimuth) % 360.0 + if 45 <= _a < 135: + return (0.0, -1.0, 0.0) + elif 135 <= _a < 225: + return (1.0, 0.0, 0.0) + elif 225 <= _a < 315: + return (0.0, 1.0, 0.0) + return (-1.0, 0.0, 0.0) + + +def _lite_set_view(plotter, azimuth): + """Point a plotter at the nearest axis-aligned view; no-op without azimuth.""" + if azimuth is None: + return None + try: + plotter.view_vector(_lite_view_vector(azimuth), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + return None + + +class _LiteRenderer: + """Minimal MNE 3D renderer backed by pyvista-js.""" + + def __init__(self, *args, **kwargs): + import numpy as _np + import pyvista_js as _pv + self._np = _np + self._pv = _pv + self.plotter = _pv.Plotter() + _bg = kwargs.get("bgcolor", kwargs.get("background_color", "black")) + try: + self.plotter.background_color = self._rgb(_bg) + except Exception: + pass + # even lighting, so a surface is not black when rotated + for _lp in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), + (0, -1, 0), (0, 0, 1), (0, 0, -1)): + try: + self.plotter.add_light(_pv.Light( + position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), + focal_point=(0.0, 0.0, 0.0), intensity=0.4)) + except Exception: + pass + + # -- helpers ------------------------------------------------------------ + def _rgb(self, color): + """Return an (r, g, b) 0-1 tuple; pyvista-js rejects hex strings.""" + if color is None: + return (0.5, 0.5, 0.5) + from matplotlib.colors import to_rgb as _to_rgb + if isinstance(color, str): + return _to_rgb(color) + _c = self._np.asarray(color, dtype=float).ravel()[:3] + if _c.size < 3: + return (0.5, 0.5, 0.5) + if _c.max() > 1.0: # 0-255 form + _c = _c / 255.0 + return tuple(float(min(max(_v, 0.0), 1.0)) for _v in _c) + + def _faces(self, tris): + _np = self._np + _t = _np.asarray(tris, dtype=_np.int32).reshape(-1, 3) + return _np.hstack([ + _np.full((len(_t), 1), 3, dtype=_np.int32), _t]).ravel() + + def _glyph_template(self, kind, radius=None, height=None, center=None, + resolution=None, **kwargs): + """Return (rr, tris) for instanced_mesh, oriented along +x. + + pyvista-js's Sphere/Cylinder are parametric primitives with no + triangle list, so build the templates here. These are markers a few + millimetres across, so keep them low-poly -- every instance is a + separate mesh and the WASM heap is not large. + """ + _np = self._np + if kind == "sphere": + _r = 0.5 if radius is None else float(radius) + rr = _np.array([[_r, 0, 0], [-_r, 0, 0], [0, _r, 0], + [0, -_r, 0], [0, 0, _r], [0, 0, -_r]], float) + tris = _np.array([[0, 2, 4], [2, 1, 4], [1, 3, 4], [3, 0, 4], + [2, 0, 5], [1, 2, 5], [3, 1, 5], [0, 3, 5]], int) + return rr, tris + # cylinder along +x, matching _cylinder_geom's convention + _r = 0.1 if radius is None else float(radius) + _h = 1.0 if height is None else float(height) + _n = 8 if not resolution else max(3, int(resolution) // 2) + _c = _np.zeros(3) if center is None else _np.asarray(center, float) + _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False) + _ring = _np.column_stack([_np.zeros(_n), _r * _np.cos(_ang), + _r * _np.sin(_ang)]) + _back = _ring + _np.array([-_h / 2.0, 0, 0]) + _front = _ring + _np.array([_h / 2.0, 0, 0]) + rr = _np.vstack([_back, _front, + [[-_h / 2.0, 0, 0]], [[_h / 2.0, 0, 0]]]) + _c + tris = [] + for _i in range(_n): + _j = (_i + 1) % _n + tris += [[_i, _j, _n + _j], [_i, _n + _j, _n + _i]] # wall + tris += [[2 * _n, _j, _i]] # back cap + tris += [[2 * _n + 1, _n + _i, _n + _j]] # front cap + return rr, _np.asarray(tris, int) + + def _add(self, points, tris, color, opacity=1.0): + """Draw a mesh and return MNE's (actor, mesh) pair.""" + _np = self._np + _pd = self._pv.PolyData( + points=_np.asarray(points, dtype=_np.float32), + faces=self._faces(tris)) + _actor = self.plotter.add_mesh( + _pd, color=self._rgb(color), opacity=float(opacity), + smooth_shading=True) + return _actor, _pd + + # -- drawing ------------------------------------------------------------ + def mesh(self, x, y, z, triangles, color=None, opacity=1.0, *args, **kwargs): + _np = self._np + _pts = _np.column_stack([_np.asarray(x).ravel(), + _np.asarray(y).ravel(), + _np.asarray(z).ravel()]) + return self._add(_pts, triangles, color, opacity) + + def surface(self, surface, color=None, opacity=1.0, *args, **kwargs): + return self._add(surface["rr"], surface["tris"], color, opacity) + + def sphere(self, center, color=None, scale=1.0, opacity=1.0, + resolution=8, backface_culling=False, radius=None, **kwargs): + _np = self._np + _c = _np.atleast_2d(_np.asarray(center, dtype=float)) + _r = float(radius if radius is not None else scale) + _actor = _mesh = None + for _p in _c: + _mesh = self._pv.Sphere( + radius=_r, center=tuple(float(_q) for _q in _p[:3])) + _actor = self.plotter.add_mesh( + _mesh, color=self._rgb(color), opacity=float(opacity), + smooth_shading=True) + return _actor, _mesh + + def tube(self, origin, destination, radius=0.001, color=None, *args, **kwargs): + _np = self._np + _o = _np.atleast_2d(_np.asarray(origin, dtype=float)) + _d = _np.atleast_2d(_np.asarray(destination, dtype=float)) + _actor = _mesh = None + for _a, _b in zip(_o, _d): + _vec = _b[:3] - _a[:3] + _len = float(_np.linalg.norm(_vec)) + if _len == 0.0: + continue + _mesh = self._pv.Cylinder( + center=tuple(float(_q) for _q in (_a[:3] + _b[:3]) / 2.0), + direction=tuple(float(_q) for _q in _vec / _len), + radius=float(radius), height=_len) + _actor = self.plotter.add_mesh( + _mesh, color=self._rgb(color), smooth_shading=True) + return _actor, _mesh + + def quiver3d(self, x, y, z, u, v, w, color=None, scale=1.0, mode="arrow", + opacity=1.0, *args, **kwargs): + _np = self._np + _x, _y, _z = (_np.atleast_1d(_np.asarray(_q, dtype=float)) + for _q in (x, y, z)) + _u, _v, _w = (_np.atleast_1d(_np.asarray(_q, dtype=float)) + for _q in (u, v, w)) + _s = float(_np.asarray(scale).ravel()[0]) if _np.size(scale) else 1.0 + _actor = _g = None + for _i in range(len(_x)): + _ctr = (float(_x[_i]), float(_y[_i]), float(_z[_i])) + _dir = (float(_u[_i % len(_u)]), float(_v[_i % len(_v)]), + float(_w[_i % len(_w)])) + if _np.linalg.norm(_dir) == 0.0: + _dir = (0.0, 0.0, 1.0) + if mode == "sphere": + _g = self._pv.Sphere(radius=_s / 2.0, center=_ctr) + elif mode in ("cylinder", "oct"): + _g = self._pv.Cylinder(center=_ctr, direction=_dir, + radius=_s / 4.0, height=_s) + else: # arrow / cone / 2darrow + _g = self._pv.Cone(center=_ctr, direction=_dir, + height=_s, radius=_s / 2.0) + _actor = self.plotter.add_mesh( + _g, color=self._rgb(color), opacity=float(opacity), + smooth_shading=True) + return _actor, _g + + def instanced_mesh(self, rr, tris, positions, quats=None, colors=None, + scales=None, opacity=1.0, *args, **kwargs): + # one copy of the template per position; rotate with MNE's own + # quaternion helper so oriented glyphs (EEG cylinders) point the way + # MNE intended rather than all along +x. + _np = self._np + _rr = _np.asarray(rr, dtype=float) + _pos = _np.atleast_2d(_np.asarray(positions, dtype=float)) + _quats = None if quats is None else _np.atleast_2d( + _np.asarray(quats, dtype=float)) + _rot = None + if _quats is not None: + try: + from mne.transforms import quat_to_rot as _q2r + _rot = _q2r(_quats) + except Exception: + _rot = None + _out = (None, None) + for _i, _p in enumerate(_pos): + _s = 1.0 + if scales is not None: + _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) + _s = float(_sa[_i % len(_sa)]) + _col = colors + if colors is not None and _np.ndim(colors) > 1: + _col = _np.asarray(colors)[_i % len(colors)] + _pts = _rr * _s + if _rot is not None: + _pts = _pts @ _np.asarray(_rot[_i % len(_rot)]).T + _out = self._add(_pts + _p[:3], tris, _col, opacity) + return _out + + # -- things the static docs do not need --------------------------------- + def contour(self, *args, **kwargs): + # pyvista-js 0.15 has no scalar contouring; callers unpack a pair + return None, None + + def text2d(self, *args, **kwargs): + return None + + def text3d(self, *args, **kwargs): + return None + + def scalarbar(self, *args, **kwargs): + return None + + def legend(self, *args, **kwargs): + return None + + def subplot(self, *args, **kwargs): + return None + + def set_interaction(self, *args, **kwargs): + return None + + def remove_mesh(self, *args, **kwargs): + return None + + def project(self, xyz, ch_names): + return self._np.asarray(xyz, dtype=float)[:, :2] + + def screenshot(self, mode="rgb", filename=None, **kwargs): + return self._np.zeros((2, 2, 3), dtype="uint8") + + def close(self): + return None + + def _update(self, *args, **kwargs): + return None + + def _process_events(self, *args, **kwargs): + return None + + def _enable_time_interaction(self, *args, **kwargs): + # the figures are static here; there is no time slider to wire up + return None + + def _window_close_connect(self, *args, **kwargs): + return None + + def _window_set_cursor(self, *args, **kwargs): + return None + + def get_camera(self, *args, **kwargs): + return (0.0, 0.0, 1.0, (0.0, 0.0, 0.0), 0.0) + + def set_camera(self, azimuth=None, elevation=None, distance=None, + focalpoint=None, roll=None, *args, **kwargs): + # pyvista-js has no azimuth/elevation camera; approximate the common + # views and otherwise leave the default. + return _lite_set_view(self.plotter, azimuth) + + def scene(self): + return self.plotter + + def show(self): + try: + self.plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js render failed: " + repr(_e)) + return None + + +def _lite_get_renderer(*args, **kwargs): + return _LiteRenderer(*args, **kwargs) + + +class _LiteBackend: + """Stand-in for the module MNE imports into ``renderer.backend``. + + ``set_3d_view``, ``set_3d_title`` and the ``close_*`` helpers are module-level + functions that reach for that global directly instead of going through + ``_get_renderer``, so replacing the factory alone leaves them calling into + ``None``. The figure they are handed is the pyvista-js plotter that + ``_LiteRenderer.scene`` returns. + """ + + def _set_3d_view(self, figure, azimuth=None, elevation=None, + focalpoint=None, distance=None, roll=None): + return _lite_set_view(figure, azimuth) + + def _set_3d_title(self, figure, title, size=40, color="white", + position="upper_left"): + return None + + def _close_3d_figure(self, figure): + return None + + def _close_all(self): + return None + + +try: + import mne.viz.backends.renderer as _mne_rend + _mne_rend._get_renderer = _lite_get_renderer + _mne_rend.backend = _LiteBackend() + # naming a backend keeps _get_3d_backend() from walking VALID_3D_BACKENDS and + # importing _qt, which would overwrite the stub above on its way to failing + _mne_rend.MNE_3D_BACKEND = "notebook" +except Exception as _e: + print("[JupyterLite] could not install the pyvista-js renderer: " + repr(_e)) +''' diff --git a/mne/datasets/__init__.pyi b/mne/datasets/__init__.pyi index 2f69a1027e5..f1a6db8b581 100644 --- a/mne/datasets/__init__.pyi +++ b/mne/datasets/__init__.pyi @@ -19,6 +19,7 @@ __all__ = [ "hf_sef", "kiloword", "limo", + "lite_data", "misc", "mtrf", "multimodal", @@ -48,6 +49,7 @@ from . import ( hf_sef, kiloword, limo, + lite_data, misc, mtrf, multimodal, diff --git a/mne/datasets/config.py b/mne/datasets/config.py index 5599c20e5d4..293c29bcbb3 100644 --- a/mne/datasets/config.py +++ b/mne/datasets/config.py @@ -209,6 +209,16 @@ config_key="MNE_DATASETS_SAMPLE_PATH", ) +# Curated subset of sample (plus a few files from kiloword/erp_core/mtrf/eegbci) +# used by the JupyterLite browser docs; see mne/datasets/lite_data/. +MNE_DATASETS["lite_data"] = dict( + archive_name="MNE-lite-data.tar.gz", + hash="md5:5f9c4fffed32e79bc2bc2061bf22ce99", + url="https://osf.io/download/a8qbx", + folder_name="MNE-lite-data", + config_key="MNE_DATASETS_LITE_DATA_PATH", +) + MNE_DATASETS["somato"] = dict( archive_name="MNE-somato-data.tar.gz", hash="md5:9a191907b326b9402341ee7a0d1240d8", diff --git a/mne/datasets/lite_data/__init__.py b/mne/datasets/lite_data/__init__.py new file mode 100644 index 00000000000..eff22a4ce84 --- /dev/null +++ b/mne/datasets/lite_data/__init__.py @@ -0,0 +1,7 @@ +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +"""Curated data subset for the JupyterLite browser documentation.""" + +from .lite_data import data_path, get_version diff --git a/mne/datasets/lite_data/lite_data.py b/mne/datasets/lite_data/lite_data.py new file mode 100644 index 00000000000..e9e575f8403 --- /dev/null +++ b/mne/datasets/lite_data/lite_data.py @@ -0,0 +1,43 @@ +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +"""Curated data subset used by the JupyterLite browser documentation. + +The full MNE datasets (``sample``, ``kiloword``, ``erp_core``, ``mtrf``, +``eegbci``) ship as separate multi-GB archives, so the docs build would download +several gigabytes just to serve a handful of files to the browser notebooks. +``lite_data`` is a small curated archive holding only those files -- same data, +same checksums -- so the build fetches just what the JupyterLite notebooks need. +It extracts to ``MNE-lite-data/`` with the files under their original dataset +folders (``MNE-sample-data/``, ``MNE-kiloword-data/``, ...). +""" + +from ...utils import verbose +from ..utils import _data_path_doc, _download_mne_dataset, _get_version, _version_doc + + +@verbose +def data_path( + path=None, force_update=False, update_path=True, download=True, *, verbose=None +): # noqa: D103 + return _download_mne_dataset( + name="lite_data", + processor="untar", + path=path, + force_update=force_update, + update_path=update_path, + download=download, + ) + + +data_path.__doc__ = _data_path_doc.format( + name="lite_data", conf="MNE_DATASETS_LITE_DATA_PATH" +) + + +def get_version(): # noqa: D103 + return _get_version("lite_data") + + +get_version.__doc__ = _version_doc.format(name="lite_data") diff --git a/mne/datasets/tests/test_datasets.py b/mne/datasets/tests/test_datasets.py index a7f985392e7..53bdb1b5b2a 100644 --- a/mne/datasets/tests/test_datasets.py +++ b/mne/datasets/tests/test_datasets.py @@ -322,3 +322,18 @@ def test_fetch_uncompressed_file(tmp_path): ) fetch_dataset(dataset_dict, path=None, force_update=True) assert (tmp_path / "foo" / "LICENSE.foo").is_file() + + +def test_lite_data(): + """Test the lite_data curated dataset is registered correctly.""" + from mne.datasets import lite_data + from mne.datasets.config import MNE_DATASETS + + assert "lite_data" in MNE_DATASETS + cfg = MNE_DATASETS["lite_data"] + assert cfg["archive_name"] == "MNE-lite-data.tar.gz" + assert cfg["hash"].startswith("md5:") + assert cfg["url"].startswith("https://osf.io/") + assert cfg["config_key"] == "MNE_DATASETS_LITE_DATA_PATH" + assert callable(lite_data.data_path) + assert callable(lite_data.get_version) diff --git a/mne/parallel.py b/mne/parallel.py index 22443dab762..df90c6d1c30 100644 --- a/mne/parallel.py +++ b/mne/parallel.py @@ -156,6 +156,10 @@ def parallel_progress(op_iter): def _running_in_joblib_context(): """Check if we are running in a joblib.parallel_config context manager.""" + import sys + + if sys.platform == "emscripten": + return False try: from joblib.parallel import get_active_backend except ImportError: diff --git a/mne/utils/config.py b/mne/utils/config.py index a33779fb442..f9fc9ad5942 100644 --- a/mne/utils/config.py +++ b/mne/utils/config.py @@ -170,6 +170,7 @@ def set_memmap_min_size(memmap_min_size): "MNE_DATASETS_TESTING_PATH": "str, path for testing data", "MNE_DATASETS_VISUAL_92_CATEGORIES_PATH": "str, path for visual_92_categories data", "MNE_DATASETS_KILOWORD_PATH": "str, path for kiloword data", + "MNE_DATASETS_LITE_DATA_PATH": "str, path for lite_data data", "MNE_DATASETS_FIELDTRIP_CMC_PATH": "str, path for fieldtrip_cmc data", "MNE_DATASETS_PHANTOM_KIT_PATH": "str, path for phantom_kit data", "MNE_DATASETS_PHANTOM_4DBTI_PATH": "str, path for phantom_4dbti data", @@ -268,8 +269,8 @@ def _load_config(config_path, raise_error=False): with _open_lock(config_path, "r+") as fid: try: config = json.load(fid) - except ValueError: - # No JSON object could be decoded --> corrupt file? + except Exception: + # Catch ANY exception (including SyntaxError from Pyodide json parser) msg = ( f"The MNE-Python config file ({config_path}) is not a valid JSON " "file and might be corrupted" diff --git a/pyproject.toml b/pyproject.toml index 56402be5563..425ebcf2f1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ doc = [ "graphviz", "intersphinx_registry >= 0.2405.27", "ipython != 8.7.0", # also in "full-no-qt" and "test" + "jupyterlite-pyodide-kernel", + "jupyterlite-sphinx", "memory_profiler >= 0.16", "mne-bids", "mne-connectivity",