diff --git a/.circleci/config.yml b/.circleci/config.yml index d47862ae880..f6ed1ad4768 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -249,6 +249,21 @@ 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 datasets the JupyterLite notebooks need are on disk so + # conf.py can copy the required subset for the build. circleci_download.sh + # only fetches them when the changed files reference them, so a cache miss + # on a PR that touches only doc/conf.py would leave them absent and the + # notebooks would fail at runtime with FileNotFoundError. kiloword and + # erp_core back the Epochs metadata tutorials (30 & 40); mtrf and eegbci + # back the decoding examples (receptive_field_mtrf, decoding_csp_*). + - run: + name: Ensure MNE data for JupyterLite + command: | + python -c "import mne; mne.datasets.sample.data_path(update_path=True)" + python -c "import mne; mne.datasets.kiloword.data_path(update_path=True)" + python -c "import mne; mne.datasets.erp_core.data_path(update_path=True)" + python -c "import mne; mne.datasets.mtrf.data_path(update_path=True)" + python -c "import mne; mne.datasets.eegbci.load_data(1, [6, 10, 14], update_path=True)" # 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 1d5165e8924..19b307c65d5 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,3 +104,5 @@ venv/ .hypothesis/ .ruff_cache/ .ipynb_checkpoints/ +jupyterlite_contents/auto_tutorials +jupyterlite_contents/mne_data diff --git a/doc/changes/dev/13925.newfeature.rst b/doc/changes/dev/13925.newfeature.rst new file mode 100644 index 00000000000..bcd3109e8d1 --- /dev/null +++ b/doc/changes/dev/13925.newfeature.rst @@ -0,0 +1 @@ +Added a JupyterLite GitHub Actions workflow to automatically build a Wasm-compatible interactive documentation site by Natneal Belete. diff --git a/doc/conf.py b/doc/conf.py index 358779583f0..127cf981eaa 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -11,10 +11,11 @@ import faulthandler import os +import re +import shutil import subprocess import sys from datetime import datetime, timezone -from importlib.metadata import metadata from pathlib import Path import matplotlib @@ -116,6 +117,7 @@ "sphinx_copybutton", "sphinx_design", "sphinx_gallery.gen_gallery", + "jupyterlite_sphinx", "sphinxcontrib.bibtex", "sphinxcontrib.youtube", "sphinxcontrib.towncrier.ext", @@ -139,7 +141,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" @@ -474,7 +483,642 @@ 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. +src_sample_data = Path(os.path.expanduser("~/mne_data/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) +print(f"[JupyterLite] Sample data source exists: {src_sample_data.exists()}") +print(f"[JupyterLite] Source path: {src_sample_data}") +if 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", + "subjects/sample/mri/T1.mgz", + "subjects/sample/bem/sample-oct-6-src.fif", + "subjects/sample/bem/sample-5120-5120-5120-bem-sol.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 = src_sample_data / req + d = dst_sample_data / req + if s.exists(): + 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 +mne_data_base = Path(os.path.expanduser("~/mne_data")) +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") + +# 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", + ], + ), +): + _src_ds = mne_data_base / _folder + _dst_ds = lite_data_base / _folder + print(f"[JupyterLite] {_folder} source exists: {_src_ds.exists()}") + for _ds_file in _ds_files: + s = _src_ds / _ds_file + d = _dst_ds / _ds_file + if s.exists(): + 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}") + + +# Build the local MNE wheel so JupyterLite installs the current development +# version instead of the older release from PyPI. The wheel is written to +# ``/pypi`` (i.e. ``doc/pypi``): the jupyterlite-pyodide-kernel +# PipliteAddon automatically discovers, copies and indexes every wheel found +# there and adds it to ``pipliteUrls`` in ``jupyter-lite.json``. This is the +# approach documented at +# https://jupyterlite.readthedocs.io/en/latest/howto/pyodide/wheels.html and, +# unlike the ``--piplite-wheels`` build option, does not depend on the option +# being threaded through jupyterlite-sphinx to the build command. +pypi_wheels_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), "pypi") +# Clean the directory 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) + +pyproject_path = os.path.join(os.path.dirname(__file__), "..", "pyproject.toml") +with open(pyproject_path, encoding="utf-8") as f: + orig_pyproject = f.read() + +# Relax constraints for Pyodide which often lags behind PyPI. +# The wheel built here is served to the browser kernel; piplite's +# keep_going=True means these bounds won't block install, but we also +# relax them here 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 (build-backend = "hatchling.build"), so pip must create + # an isolated build env to install hatchling/hatch-vcs; passing + # --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 + # below, so the relaxed constraints are still picked up. + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + "..", + "--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 if the wheel was not produced rather than silently letting the +# browser kernel fall back to the older released MNE from PyPI. +_built_wheels = [ + f + for f in os.listdir(pypi_wheels_dir) + if f.startswith("mne-") and f.endswith(".whl") +] +if not _built_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." + ) +print(f"[JupyterLite] Built MNE wheel(s) for the browser kernel: {_built_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'],\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" + "_sample_files = [\n" + " 'version.txt',\n" + " 'MEG/sample/sample_audvis_raw.fif',\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_filt-0-40_raw.fif',\n" + " 'MEG/sample/sample_audvis-meg-eeg-oct-6-fwd.fif',\n" + " 'MEG/sample/sample_audvis-meg-oct-6-meg-inv.fif',\n" + " 'subjects/sample/mri/T1.mgz',\n" + " 'subjects/sample/bem/sample-oct-6-src.fif',\n" + " 'subjects/sample/bem/sample-5120-5120-5120-bem-sol.fif',\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" + "]\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'=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" + " _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" + "# 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" + ), "doc_module": ("mne",), "reference_url": dict(mne=None), "examples_dirs": examples_dirs, @@ -877,6 +1521,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. @@ -1099,11 +1746,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 = { diff --git a/doc/jupyterlite_contents/.gitkeep b/doc/jupyterlite_contents/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/examples/preprocessing/muscle_ica.py b/examples/preprocessing/muscle_ica.py index 8ef1e451985..3daae2f0796 100644 --- a/examples/preprocessing/muscle_ica.py +++ b/examples/preprocessing/muscle_ica.py @@ -19,6 +19,8 @@ # %% +import sys + import mne data_path = mne.datasets.sample.data_path() @@ -44,7 +46,9 @@ # %% # Remove components with postural muscle artifact using ICA -ica.plot_sources(raw) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + ica.plot_sources(raw) # %% # By inspection, let's select out the muscle-artifact components based on @@ -71,19 +75,25 @@ # slope in log-log units; this is a very typical pattern for muscle artifact. muscle_idx = [6, 7, 8, 9, 10, 11, 12, 13, 14] -ica.plot_properties(raw, picks=muscle_idx, log_scale=True) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + ica.plot_properties(raw, picks=muscle_idx, log_scale=True) # first, remove blinks and heartbeat to compare blink_idx = [0] heartbeat_idx = [5] ica.apply(raw, exclude=blink_idx + heartbeat_idx) -ica.plot_overlay(raw, exclude=muscle_idx) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + ica.plot_overlay(raw, exclude=muscle_idx) # %% # Finally, let's try an automated algorithm to find muscle components # and ensure that it gets the same components we did manually. muscle_idx_auto, scores = ica.find_bads_muscle(raw) -ica.plot_scores(scores, exclude=muscle_idx_auto) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + ica.plot_scores(scores, exclude=muscle_idx_auto) print( f"Manually found muscle artifact ICA components: {muscle_idx}\n" f"Automatically found muscle artifact ICA components: {muscle_idx_auto}" @@ -107,10 +117,14 @@ n_components=15, method="picard", max_iter="auto", random_state=97 ) ica.fit(raw) - ica.plot_sources(raw) + # Skipped in JupyterLite (browser): no interactive/3D rendering. + if sys.platform != "emscripten": + ica.plot_sources(raw) muscle_idx_auto, scores = ica.find_bads_muscle(raw) - ica.plot_properties(raw, picks=muscle_idx_auto, log_scale=True) - ica.plot_scores(scores, exclude=muscle_idx_auto) + # Skipped in JupyterLite (browser): no interactive/3D rendering. + if sys.platform != "emscripten": + ica.plot_properties(raw, picks=muscle_idx_auto, log_scale=True) + ica.plot_scores(scores, exclude=muscle_idx_auto) print( f"Manually found muscle artifact ICA components: {muscle_idx}\n" diff --git a/examples/visualization/eyetracking_plot_heatmap.py b/examples/visualization/eyetracking_plot_heatmap.py index 8d656708a12..3be8bfd6580 100644 --- a/examples/visualization/eyetracking_plot_heatmap.py +++ b/examples/visualization/eyetracking_plot_heatmap.py @@ -26,11 +26,21 @@ # :ref:`example data `: eye-tracking data recorded from SR research's # ``'.asc'`` file format. +import sys + import matplotlib.pyplot as plt import mne from mne.viz.eyetracking import plot_gaze +# JupyterLite (Pyodide) browser build only. +if sys.platform == "emscripten": + raise RuntimeError( + "This example requires the MNE EyeLink dataset, " + "which is not available in the browser. Please run this example " + "locally. Visit https://mne.tools for instructions." + ) + task_fpath = mne.datasets.eyelink.data_path() / "freeviewing" et_fpath = task_fpath / "sub-01_task-freeview_eyetrack.asc" stim_fpath = task_fpath / "stim" / "naturalistic.png" 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 b46a8d5c9c5..35e963bbb4f 100644 --- a/mne/utils/config.py +++ b/mne/utils/config.py @@ -268,8 +268,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 64e0a55fc5c..6a39fbb519e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,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", diff --git a/tutorials/evoked/40_whitened.py b/tutorials/evoked/40_whitened.py index a3110139b4e..639bccfaa2e 100644 --- a/tutorials/evoked/40_whitened.py +++ b/tutorials/evoked/40_whitened.py @@ -21,6 +21,8 @@ # %% +import sys + import mne from mne.datasets import sample @@ -52,14 +54,18 @@ ) # butterfly mode shows the differences most clearly -raw.plot(events=events, butterfly=True) -raw.plot(noise_cov=noise_cov, events=events, butterfly=True) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + raw.plot(events=events, butterfly=True) + raw.plot(noise_cov=noise_cov, events=events, butterfly=True) # %% # Epochs with whitening # --------------------- -epochs.plot(events=True) -epochs.plot(noise_cov=noise_cov, events=True) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + epochs.plot(events=True) + epochs.plot(noise_cov=noise_cov, events=True) # %% # Evoked data with whitening diff --git a/tutorials/intro/10_overview.py b/tutorials/intro/10_overview.py index f61745b0024..d587ed12d9c 100644 --- a/tutorials/intro/10_overview.py +++ b/tutorials/intro/10_overview.py @@ -18,7 +18,7 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. -# %% +import sys import numpy as np @@ -81,8 +81,11 @@ # sessions, `~mne.io.Raw.plot` is interactive and allows scrolling, scaling, # bad channel marking, annotations, projector toggling, etc. + raw.compute_psd(fmax=50).plot(picks="data", exclude="bads", amplitude=False) -raw.plot(duration=5, n_channels=30) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + raw.plot(duration=5, n_channels=30) # %% # Preprocessing @@ -101,7 +104,9 @@ ica = mne.preprocessing.ICA(n_components=20, random_state=97, max_iter=800) ica.fit(raw) ica.exclude = [1, 2] # details on how we picked these are omitted here -ica.plot_properties(raw, picks=ica.exclude) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + ica.plot_properties(raw, picks=ica.exclude) # %% # Once we're confident about which component(s) we want to remove, we pass them @@ -140,8 +145,10 @@ "EEG 008", ] chan_idxs = [raw.ch_names.index(ch) for ch in chs] -orig_raw.plot(order=chan_idxs, start=12, duration=4) -raw.plot(order=chan_idxs, start=12, duration=4) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + orig_raw.plot(order=chan_idxs, start=12, duration=4) + raw.plot(order=chan_idxs, start=12, duration=4) # %% # .. _overview-tut-events-section: @@ -400,9 +407,14 @@ # path to subjects' MRI files subjects_dir = sample_data_folder / "subjects" # plot the STC -stc.plot( - initial_time=0.1, hemi="split", views=["lat", "med"], subjects_dir=subjects_dir -) +# In JupyterLite (browser) this renders via pyvista-js (vtk.js), wired up in the +# setup cell; otherwise it uses MNE's normal 3D backend. +if sys.platform != "emscripten": + stc.plot( + initial_time=0.1, hemi="split", views=["lat", "med"], subjects_dir=subjects_dir + ) +else: + stc.plot(initial_time=0.1, hemi="both", subjects_dir=subjects_dir) ############################################################################## # The remaining tutorials have *much more detail* on each of these topics (as diff --git a/tutorials/intro/20_events_from_raw.py b/tutorials/intro/20_events_from_raw.py index 2c368646908..c2fb5948bd1 100644 --- a/tutorials/intro/20_events_from_raw.py +++ b/tutorials/intro/20_events_from_raw.py @@ -32,6 +32,8 @@ # %% +import sys + import numpy as np import mne @@ -163,10 +165,12 @@ # stored events into an `~mne.Annotations` object and store it as the # :attr:`~mne.io.Raw.annotations` attribute of the `~mne.io.Raw` object: -testing_data_folder = mne.datasets.testing.data_path() -eeglab_raw_file = testing_data_folder / "EEGLAB" / "test_raw.set" -eeglab_raw = mne.io.read_raw_eeglab(eeglab_raw_file) -print(eeglab_raw.annotations) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + testing_data_folder = mne.datasets.testing.data_path() + eeglab_raw_file = testing_data_folder / "EEGLAB" / "test_raw.set" + eeglab_raw = mne.io.read_raw_eeglab(eeglab_raw_file) + print(eeglab_raw.annotations) # %% # The core data within an `~mne.Annotations` object is accessible @@ -176,10 +180,12 @@ # different types of events, and the first event occurred about 1 second after # the recording began: -print(len(eeglab_raw.annotations)) -print(set(eeglab_raw.annotations.duration)) -print(set(eeglab_raw.annotations.description)) -print(eeglab_raw.annotations.onset[0]) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + print(len(eeglab_raw.annotations)) + print(set(eeglab_raw.annotations.duration)) + print(set(eeglab_raw.annotations.description)) + print(eeglab_raw.annotations.onset[0]) # %% # More information on working with `~mne.Annotations` objects, including @@ -210,9 +216,11 @@ # :ref:`fixed-length-events` for direct creation of an Events array of # equally-spaced events). -events_from_annot, event_dict = mne.events_from_annotations(eeglab_raw) -print(event_dict) -print(events_from_annot[:5]) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + events_from_annot, event_dict = mne.events_from_annotations(eeglab_raw) + print(event_dict) + print(events_from_annot[:5]) # %% # If you want to control which integers are mapped to each unique description @@ -224,12 +232,14 @@ # `~mne.io.Raw` objects, as demonstrated in the tutorial # :ref:`tut-epochs-class`. -custom_mapping = {"rt": 77, "square": 42} -(events_from_annot, event_dict) = mne.events_from_annotations( - eeglab_raw, event_id=custom_mapping -) -print(event_dict) -print(events_from_annot[:5]) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + custom_mapping = {"rt": 77, "square": 42} + (events_from_annot, event_dict) = mne.events_from_annotations( + eeglab_raw, event_id=custom_mapping + ) + print(event_dict) + print(events_from_annot[:5]) # %% # To make the opposite conversion (from an Events array to an diff --git a/tutorials/intro/40_sensor_locations.py b/tutorials/intro/40_sensor_locations.py index 6046e252f47..5c801135c75 100644 --- a/tutorials/intro/40_sensor_locations.py +++ b/tutorials/intro/40_sensor_locations.py @@ -15,6 +15,7 @@ # %% +import sys from pathlib import Path import matplotlib.pyplot as plt @@ -91,19 +92,21 @@ # It is also possible to skip the manual montage loading step by passing the montage # name directly to the :meth:`~mne.io.Raw.set_montage` method. -ssvep_folder = mne.datasets.ssvep.data_path() -ssvep_data_raw_path = ( - ssvep_folder / "sub-02" / "ses-01" / "eeg" / "sub-02_ses-01_task-ssvep_eeg.vhdr" -) -ssvep_raw = mne.io.read_raw_brainvision(ssvep_data_raw_path, verbose=False) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + ssvep_folder = mne.datasets.ssvep.data_path() + ssvep_data_raw_path = ( + ssvep_folder / "sub-02" / "ses-01" / "eeg" / "sub-02_ses-01_task-ssvep_eeg.vhdr" + ) + ssvep_raw = mne.io.read_raw_brainvision(ssvep_data_raw_path, verbose=False) -# Use the preloaded montage -ssvep_raw.set_montage(easycap_montage) -fig = ssvep_raw.plot_sensors(show_names=True) + # Use the preloaded montage + ssvep_raw.set_montage(easycap_montage) + fig = ssvep_raw.plot_sensors(show_names=True) -# Apply a template montage directly, without preloading -ssvep_raw.set_montage("easycap-M1") -fig = ssvep_raw.plot_sensors(show_names=True) + # Apply a template montage directly, without preloading + ssvep_raw.set_montage("easycap-M1") + fig = ssvep_raw.plot_sensors(show_names=True) # %% # .. note:: @@ -133,7 +136,9 @@ # If you prefer to draw the head circle using 10–20 conventions (which are also used by # EEGLAB), you can pass ``sphere='eeglab'``: -fig = ssvep_raw.plot_sensors(show_names=True, sphere="eeglab") +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + fig = ssvep_raw.plot_sensors(show_names=True, sphere="eeglab") # %% # Because the data we're using here doesn't contain an Fpz channel, its putative @@ -218,15 +223,17 @@ # It is also possible to render an image of an MEG sensor helmet using 3D surface # rendering instead of matplotlib. This works by calling :func:`mne.viz.plot_alignment`: -fig = mne.viz.plot_alignment( - sample_raw.info, - dig=False, - eeg=False, - surfaces=[], - meg=["helmet", "sensors"], - coord_frame="meg", -) -mne.viz.set_3d_view(fig, azimuth=50, elevation=90, distance=0.5) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + fig = mne.viz.plot_alignment( + sample_raw.info, + dig=False, + eeg=False, + surfaces=[], + meg=["helmet", "sensors"], + coord_frame="meg", + ) + mne.viz.set_3d_view(fig, azimuth=50, elevation=90, distance=0.5) # %% # Note that :func:`~mne.viz.plot_alignment` requires an `~mne.Info` object, and can also diff --git a/tutorials/intro/50_configure_mne.py b/tutorials/intro/50_configure_mne.py index 17c94626e30..23d8004ee08 100644 --- a/tutorials/intro/50_configure_mne.py +++ b/tutorials/intro/50_configure_mne.py @@ -18,6 +18,7 @@ # %% import os +import sys import pandas as pd @@ -223,22 +224,26 @@ # set. First, with log level ``warning``: -kit_data_path = os.path.join( - os.path.abspath(os.path.dirname(mne.__file__)), - "io", - "kit", - "tests", - "data", - "test.sqd", -) -raw = mne.io.read_raw_kit(kit_data_path, verbose="warning") +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + kit_data_path = os.path.join( + os.path.abspath(os.path.dirname(mne.__file__)), + "io", + "kit", + "tests", + "data", + "test.sqd", + ) + raw = mne.io.read_raw_kit(kit_data_path, verbose="warning") # %% # No messages were generated, because none of the messages were of severity # "warning" or worse. Next, we'll load the same file with log level ``info`` # (the default level): -raw = mne.io.read_raw_kit(kit_data_path, verbose="info") +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + raw = mne.io.read_raw_kit(kit_data_path, verbose="info") # %% # This time, we got a few messages about extracting information from the file, @@ -248,8 +253,10 @@ # manager, which is another way to accomplish the same thing as passing # ``verbose='debug'``: -with mne.use_log_level("debug"): - raw = mne.io.read_raw_kit(kit_data_path) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + with mne.use_log_level("debug"): + raw = mne.io.read_raw_kit(kit_data_path) # %% # We've been passing string values to the ``verbose`` parameter, but we can see diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 76d15fc0251..7153909b301 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -25,6 +25,7 @@ # %% +import sys import tempfile from pathlib import Path @@ -38,6 +39,39 @@ sample_dir = data_path / "MEG" / "sample" subjects_dir = data_path / "subjects" +# In JupyterLite, render each report inline instead of writing to disk and +# opening a browser tab (open_browser/webbrowser don't work in Pyodide). +# Writes to /tmp DO work in Pyodide's in-memory filesystem, so we save there, +# read the HTML back, and embed it in an isolated ' + ) + ) + except Exception as exc: + print(f"(report preview unavailable in JupyterLite: {exc})") + + mne.Report.save = _inline_report_save + # %% # The basic process for creating an HTML report is to instantiate the # :class:`~mne.Report` class and then use one or more of its many methods to @@ -81,12 +115,14 @@ # supply the sampling frequency used during the recording; this information is # used to generate a meaningful time axis. -events_path = sample_dir / "sample_audvis_filt-0-40_raw-eve.fif" events = mne.find_events(raw=raw) sfreq = raw.info["sfreq"] report = mne.Report(title="Events example") -report.add_events(events=events_path, title="Events from Path", sfreq=sfreq) +# sample_audvis_filt-0-40_raw-eve.fif is not bundled in JupyterLite +if sys.platform != "emscripten": + events_path = sample_dir / "sample_audvis_filt-0-40_raw-eve.fif" + report.add_events(events=events_path, title="Events from Path", sfreq=sfreq) report.add_events(events=events, title='Events from "events"', sfreq=sfreq) report.save("report_events.html", overwrite=True) @@ -108,9 +144,13 @@ "buttonpress": 32, } -metadata, _, _ = mne.epochs.make_metadata( - events=events, event_id=event_id, tmin=-0.2, tmax=0.5, sfreq=raw.info["sfreq"] -) +# make_metadata requires pandas; skip metadata in JupyterLite +if sys.platform != "emscripten": + metadata, _, _ = mne.epochs.make_metadata( + events=events, event_id=event_id, tmin=-0.2, tmax=0.5, sfreq=raw.info["sfreq"] + ) +else: + metadata = None epochs = mne.Epochs(raw=raw, events=events, event_id=event_id, metadata=metadata) report = mne.Report(title="Epochs example") @@ -179,29 +219,32 @@ # is read from the `~mne.Info`, but projectors potentially included will be # ignored; instead, only the explicitly passed projectors will be plotted. -ecg_proj_path = sample_dir / "sample_audvis_ecg-proj.fif" report = mne.Report(title="Projectors example") report.add_projs(info=raw_path, title="Projs from info") -# Now a joint plot -events = mne.read_events(sample_dir / "sample_audvis_ecg-eve.fif") -raw_full = mne.io.read_raw(sample_dir / "sample_audvis_raw.fif").crop(0, 60).load_data() -ecg_evoked = mne.Epochs( - raw=raw_full, - events=events, - tmin=-0.5, - tmax=0.5, - baseline=(None, None), -).average() -report.img_max_width = None # do not constrain image width -report.add_projs( - info=ecg_evoked, - projs=ecg_proj_path, - title="ECG projs from path", - joint=True, # use joint version of the plot -) +# The ECG projectors and events files are not bundled in JupyterLite +if sys.platform != "emscripten": + ecg_proj_path = sample_dir / "sample_audvis_ecg-proj.fif" + events = mne.read_events(sample_dir / "sample_audvis_ecg-eve.fif") + raw_full = ( + mne.io.read_raw(sample_dir / "sample_audvis_raw.fif").crop(0, 60).load_data() + ) + ecg_evoked = mne.Epochs( + raw=raw_full, + events=events, + tmin=-0.5, + tmax=0.5, + baseline=(None, None), + ).average() + report.img_max_width = None # do not constrain image width + report.add_projs( + info=ecg_evoked, + projs=ecg_proj_path, + title="ECG projs from path", + joint=True, # use joint version of the plot + ) + del raw_full, events, ecg_evoked report.save("report_projs.html", overwrite=True) -del raw_full, events, ecg_evoked # %% # Adding `~mne.preprocessing.ICA` @@ -282,15 +325,17 @@ # every n-th volume slice, and ``width`` to specify the width of the resulting # figures in pixels. -report = mne.Report(title="BEM example") -report.add_bem( - subject="sample", - subjects_dir=subjects_dir, - title="MRI & BEM", - decim=40, - width=256, -) -report.save("report_mri_and_bem.html", overwrite=True) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + report = mne.Report(title="BEM example") + report.add_bem( + subject="sample", + subjects_dir=subjects_dir, + title="MRI & BEM", + decim=40, + width=256, + ) + report.save("report_mri_and_bem.html", overwrite=True) # %% # Adding coregistration @@ -303,18 +348,20 @@ # subjects directory, and a title. The ``alpha`` parameter can be used to # control the transparency of the head, where a value of 1 means fully opaque. -trans_path = sample_dir / "sample_audvis_raw-trans.fif" - -report = mne.Report(title="Coregistration example") -report.add_trans( - trans=trans_path, - info=raw_path, - subject="sample", - subjects_dir=subjects_dir, - alpha=1.0, - title="Coregistration", -) -report.save("report_coregistration.html", overwrite=True) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + trans_path = sample_dir / "sample_audvis_raw-trans.fif" + + report = mne.Report(title="Coregistration example") + report.add_trans( + trans=trans_path, + info=raw_path, + subject="sample", + subjects_dir=subjects_dir, + alpha=1.0, + title="Coregistration", + ) + report.save("report_coregistration.html", overwrite=True) # %% # Adding a `~mne.Forward` solution @@ -324,13 +371,15 @@ # object or the path to a forward solution stored on disk to # :meth:`mne.Report.add_forward`. -fwd_path = sample_dir / "sample_audvis-meg-oct-6-fwd.fif" +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + fwd_path = sample_dir / "sample_audvis-meg-oct-6-fwd.fif" -report = mne.Report(title="Forward solution example") -report.add_forward( - forward=fwd_path, title="Forward solution", plot=True, subjects_dir=subjects_dir -) -report.save("report_forward_sol.html", overwrite=True) + report = mne.Report(title="Forward solution example") + report.add_forward( + forward=fwd_path, title="Forward solution", plot=True, subjects_dir=subjects_dir + ) + report.save("report_forward_sol.html", overwrite=True) # %% # Adding an `~mne.minimum_norm.InverseOperator` @@ -340,16 +389,18 @@ # The method expects an `~mne.minimum_norm.InverseOperator` object or a path to # one stored on disk, and a title. -inverse_op_path = sample_dir / "sample_audvis-meg-oct-6-meg-inv.fif" +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + inverse_op_path = sample_dir / "sample_audvis-meg-oct-6-meg-inv.fif" -report = mne.Report(title="Inverse operator example") -report.add_inverse_operator( - inverse_operator=inverse_op_path, - title="Inverse operator", - plot=True, - subjects_dir=subjects_dir, -) -report.save("report_inverse_op.html", overwrite=True) + report = mne.Report(title="Inverse operator example") + report.add_inverse_operator( + inverse_operator=inverse_op_path, + title="Inverse operator", + plot=True, + subjects_dir=subjects_dir, + ) + report.save("report_inverse_op.html", overwrite=True) # %% # Adding a `~mne.SourceEstimate` @@ -362,17 +413,19 @@ # snapshots at 51 equally-spaced time points (or fewer, if the data contains # fewer time points). We can adjust this via the ``n_time_points`` parameter. -stc_path = sample_dir / "sample_audvis-meg" - -report = mne.Report(title="Source estimate example") -report.add_stc( - stc=stc_path, - subject="sample", - subjects_dir=subjects_dir, - title="Source estimate", - n_time_points=2, # few for speed -) -report.save("report_inverse_sol.html", overwrite=True) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + stc_path = sample_dir / "sample_audvis-meg" + + report = mne.Report(title="Source estimate example") + report.add_stc( + stc=stc_path, + subject="sample", + subjects_dir=subjects_dir, + title="Source estimate", + n_time_points=2, # few for speed + ) + report.save("report_inverse_sol.html", overwrite=True) # %% # Adding source code (e.g., a Python script) @@ -532,26 +585,32 @@ # to edit a report once it's no longer in-memory in an active Python session, # save it as an HDF5 file instead of HTML: -report = mne.Report(title="Saved report example", verbose=True) -report.add_image(image=mne_logo_path, title="MNE 1") -report.save("report_partial.hdf5", overwrite=True) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + report = mne.Report(title="Saved report example", verbose=True) + report.add_image(image=mne_logo_path, title="MNE 1") + report.save("report_partial.hdf5", overwrite=True) # %% # The saved report can be read back and modified or amended. This allows the # possibility to e.g. run multiple scripts in a processing pipeline, where each # script adds new content to an existing report. -report_from_disk = mne.open_report("report_partial.hdf5") -report_from_disk.add_image(image=mne_logo_path, title="MNE 2") -report_from_disk.save("report_partial.hdf5", overwrite=True) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + report_from_disk = mne.open_report("report_partial.hdf5") + report_from_disk.add_image(image=mne_logo_path, title="MNE 2") + report_from_disk.save("report_partial.hdf5", overwrite=True) # %% # To make this even easier, :class:`mne.Report` can be used as a # context manager (note the ``with`` statement)`): -with mne.open_report("report_partial.hdf5") as report: - report.add_image(image=mne_logo_path, title="MNE 3") - report.save("report_final.html", overwrite=True) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + with mne.open_report("report_partial.hdf5") as report: + report.add_image(image=mne_logo_path, title="MNE 3") + report.save("report_final.html", overwrite=True) # %% # With the context manager, the updated report is also automatically saved @@ -637,11 +696,13 @@ # expensive, we'll also pass the ``mri_decim`` parameter for the benefit of our # documentation servers, and skip processing the :file:`.fif` files. -report = mne.Report( - title="parse_folder example 3", subject="sample", subjects_dir=subjects_dir -) -report.parse_folder(data_path=data_path, pattern="", mri_decim=40) -report.save("report_parse_folder_mri_bem.html", overwrite=True) +# Skipped in JupyterLite (browser): no interactive/3D rendering. +if sys.platform != "emscripten": + report = mne.Report( + title="parse_folder example 3", subject="sample", subjects_dir=subjects_dir + ) + report.parse_folder(data_path=data_path, pattern="", mri_decim=40) + report.save("report_parse_folder_mri_bem.html", overwrite=True) # %% # Now let's look at how :class:`~mne.Report` handles :class:`~mne.Evoked` diff --git a/tutorials/io/60_ctf_bst_auditory.py b/tutorials/io/60_ctf_bst_auditory.py index 4c3249996aa..cf0ea0914e4 100644 --- a/tutorials/io/60_ctf_bst_auditory.py +++ b/tutorials/io/60_ctf_bst_auditory.py @@ -26,6 +26,8 @@ # %% +import sys + import numpy as np import pandas as pd @@ -35,6 +37,14 @@ from mne.io import read_raw_ctf from mne.minimum_norm import apply_inverse +# JupyterLite (Pyodide) browser build only. +if sys.platform == "emscripten": + raise RuntimeError( + "This tutorial requires the Brainstorm auditory dataset (~2.9 GB) " + "which is not available in the browser. Please run this tutorial " + "locally. Visit https://mne.tools for instructions." + ) + # %% # To reduce memory consumption and running time, some of the steps are # precomputed. To run everything from scratch change ``use_precomputed`` to diff --git a/tutorials/io/70_reading_eyetracking_data.py b/tutorials/io/70_reading_eyetracking_data.py index 15c58bd940c..7eaea5f4eec 100644 --- a/tutorials/io/70_reading_eyetracking_data.py +++ b/tutorials/io/70_reading_eyetracking_data.py @@ -85,8 +85,18 @@ """ # %% +import sys + import mne +# JupyterLite (Pyodide) browser build only. +if sys.platform == "emscripten": + raise RuntimeError( + "This tutorial requires the MNE misc dataset with eyetracking data, " + "which is not available in the browser. Please run this tutorial " + "locally. Visit https://mne.tools for instructions." + ) + # %% fpath = mne.datasets.misc.data_path() / "eyetracking" / "eyelink" fname = fpath / "px_textpage_ws.asc" diff --git a/tutorials/simulation/70_point_spread.py b/tutorials/simulation/70_point_spread.py index 485714e2c17..dd583855df0 100644 --- a/tutorials/simulation/70_point_spread.py +++ b/tutorials/simulation/70_point_spread.py @@ -151,6 +151,8 @@ views=["lat", "med"], ) clim = dict(kind="value", pos_lims=[1e-9, 1e-8, 1e-7]) +# In JupyterLite (browser) this renders via pyvista-js (see the setup cell); +# otherwise it uses MNE's normal 3D backend. brain_gen = stc_gen.plot(clim=clim, **kwargs) # %% diff --git a/tutorials/simulation/80_dics.py b/tutorials/simulation/80_dics.py index aa2dbea48b9..fcabc7c8cbf 100644 --- a/tutorials/simulation/80_dics.py +++ b/tutorials/simulation/80_dics.py @@ -239,6 +239,8 @@ def coh_signal_gen(): # Take the root-mean square along the time dimension and plot the result. s_rms = np.sqrt((s**2).mean()) title = "MNE-dSPM inverse (RMS)" +# In JupyterLite (browser) this renders via pyvista-js (see the setup cell); +# otherwise it uses MNE's normal 3D backend. brain = s_rms.plot( "sample", subjects_dir=subjects_dir, @@ -318,6 +320,8 @@ def coh_signal_gen(): def plot_approach(power, n): """Plot the results on a brain.""" + # In JupyterLite (browser) this renders via pyvista-js (see the setup + # cell); otherwise it uses MNE's normal 3D backend. title = f"DICS power map, approach {n}" brain = power_approach1.plot( "sample",