From 641d2688c8fd08074c33e465ab7bf2762c0ede3b Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 09:34:27 -0400 Subject: [PATCH 01/98] ENH: Add JupyterLite sphinx integration and CI/CD infrastructure Integrates jupyterlite-sphinx into the MNE-Python doc build so every sphinx-gallery example gets a 'Try in Browser' button backed by a Pyodide/WebAssembly kernel. - doc/conf.py: configure jupyterlite_sphinx; build a local MNE dev wheel with relaxed Pyodide constraints; copy required MNE sample-data subset into JupyterLite's virtual filesystem; inject a setup cell that installs MNE via micropip (keep_going=True bypasses version conflicts), mocks missing stdlib modules (lzma, multiprocessing), patches pooch to block large OSF downloads, and sets MNE_DATA paths - .circleci/config.yml: ensure MNE sample data is on disk before the doc build so conf.py can copy it into jupyterlite_contents/ - .github/workflows/jupyterlite.yml: standalone GH Actions workflow on the jupyterlite-gh-actions branch that builds and uploads the site - pyproject.toml: add jupyterlite-pyodide-kernel and jupyterlite-sphinx to the [doc] extras - .gitignore: exclude jupyterlite_contents build artifacts --- .circleci/config.yml | 30 +- .github/workflows/jupyterlite.yml | 46 ++ .gitignore | 3 + doc/changes/dev/13925.newfeature.rst | 1 + doc/conf.py | 622 ++++++++++++++------------- doc/jupyterlite_contents/.gitkeep | 0 pyproject.toml | 40 +- 7 files changed, 415 insertions(+), 327 deletions(-) create mode 100644 .github/workflows/jupyterlite.yml create mode 100644 doc/changes/dev/13925.newfeature.rst create mode 100644 doc/jupyterlite_contents/.gitkeep diff --git a/.circleci/config.yml b/.circleci/config.yml index d032e40887d..536cbae2841 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,6 +21,9 @@ _check_skip: &check_skip circleci-agent step halt; fi +_machine_image: &machine_image + image: ubuntu-2604:2026.05.1 + jobs: build_docs: parameters: @@ -28,7 +31,7 @@ jobs: type: string default: "false" machine: - image: ubuntu-2404:current + <<: *machine_image # large 4 vCPUs 15GB mem # https://discuss.circleci.com/t/changes-to-remote-docker-reporting-pricing/47759 resource_class: large @@ -62,6 +65,9 @@ jobs: if [[ $(cat merge.txt) != "" ]]; then echo "Merging $(cat merge.txt)"; git pull --ff-only upstream "refs/pull/$(cat merge.txt)/merge"; + elif [[ "$CIRCLE_PROJECT_USERNAME" != "mne-tools" ]]; then + echo "On CIRCLE_PROJECT_USERNAME=\"$CIRCLE_PROJECT_USERNAME\" repo rather than mne-tools, merging upstream/main." + git merge upstream/main --no-edit || exit 1 else if [[ "$CIRCLE_BRANCH" == "main" ]]; then KIND=dev @@ -107,7 +113,7 @@ jobs: # Load pip cache - restore_cache: keys: - - pip-cache-0 + - pip-cache-1 - restore_cache: keys: - user-install-bin-cache-310 @@ -119,7 +125,7 @@ jobs: ./tools/circleci_dependencies.sh - save_cache: - key: pip-cache-0 + key: pip-cache-1 paths: - ~/.cache/pip - save_cache: @@ -219,7 +225,7 @@ jobs: keys: - data-cache-ds004388 - run: - name: Get data + name: Get data and triage examples to run # This limit could be increased, but this is helpful for finding slow ones # (even ~2GB datasets should be downloadable in this time from good # providers) @@ -243,6 +249,16 @@ 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 MNE sample dataset is on disk so conf.py can copy the + # required subset into jupyterlite_contents/ for the JupyterLite build. + # circleci_download.sh only fetches sample data when the changed files + # reference it, so a cache miss on a PR that touches only doc/conf.py + # would leave ~/mne_data/MNE-sample-data absent and the notebooks would + # fail at runtime with FileNotFoundError on /drive/mne_data. + - run: + name: Ensure MNE sample data for JupyterLite + command: | + python -c "import mne; mne.datasets.sample.data_path(update_path=True)" # Build docs - run: name: make html @@ -416,7 +432,7 @@ jobs: type: string default: "false" machine: - image: ubuntu-2404:current + <<: *machine_image resource_class: large steps: - restore_cache: @@ -436,7 +452,7 @@ jobs: command: ./tools/circleci_bash_env.sh - restore_cache: keys: - - pip-cache-0 + - pip-cache-1 - run: name: Get Python running command: | @@ -457,7 +473,7 @@ jobs: deploy: machine: - image: ubuntu-2404:current + <<: *machine_image steps: - attach_workspace: at: /tmp/build 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 d66fbef96de..5dc51296dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ tags /doc/fil-result /doc/optipng.exe /doc/sphinxext/.joblib +/doc/code_credit.inc sg_execution_times.rst sg_api_usage.rst sg_api_unused.dot @@ -102,3 +103,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 e7f740db907..1dde8a9fc9f 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 @@ -24,6 +25,7 @@ from sphinx.config import is_serializable from sphinx.domains.changeset import versionlabels from sphinx_gallery.sorting import ExplicitOrder +from yaml import safe_load import mne import mne.html_templates._templates @@ -85,7 +87,9 @@ # built documents. # # The full version, including alpha/beta/rc tags. -release = mne.__version__ +release = mne.__version__ or "1.9.0" +if release == "None": + release = "1.9.0" sphinx_logger.info(f"Building documentation for MNE {release} ({mne.__file__})") # The short X.Y version. version = ".".join(release.split(".")[:2]) @@ -112,10 +116,11 @@ # contrib "matplotlib.sphinxext.plot_directive", "numpydoc", + "sphinxcontrib.bibtex", + "sphinx_gallery.gen_gallery", + "jupyterlite_sphinx", "sphinx_copybutton", "sphinx_design", - "sphinx_gallery.gen_gallery", - "sphinxcontrib.bibtex", "sphinxcontrib.youtube", "sphinxcontrib.towncrier.ext", # homegrown @@ -138,7 +143,7 @@ # 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", "corrupt_*"] # The suffix of source filenames. source_suffix = ".rst" @@ -190,6 +195,8 @@ ), ) ) +# Broken as of 2026/06/08 (https://github.com/joblib/joblib/issues/1796) +intersphinx_mapping["joblib"] = ("https://joblib.readthedocs.io/en/stable", None) # NumPyDoc configuration ----------------------------------------------------- @@ -423,6 +430,8 @@ "polars", "default", # unlinkable + "_Renderer", + "n_triangles", "CoregistrationUI", "mne_qt_browser.figure.MNEQtBrowser", # pooch, since its website is unreliable and users will rarely need the links @@ -469,7 +478,238 @@ compress_images = () sphinx_gallery_parallel = int(os.getenv("MNE_DOC_BUILD_N_JOBS", "1")) +jupyterlite_contents = ["jupyterlite_contents"] +jupyterlite_bind_ipynb_suffix = False + +# Automatically inject the required subset of MNE-sample-data into JupyterLite. +# The destination directory is always created so /drive/mne_data is present in +# the Pyodide kernel's virtual filesystem even when no files have been copied. +src_sample_data = Path(os.path.expanduser("~/mne_data/MNE-sample-data")) +dst_sample_data = ( + Path(os.path.abspath(os.path.dirname(__file__))) + / "jupyterlite_contents" + / "mne_data" + / "MNE-sample-data" +) +dst_sample_data.mkdir(parents=True, exist_ok=True) +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-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", + "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", + ] + 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) + + +# Also inject SSVEP and EEGLAB testing datasets for JupyterLite +mne_data_base = Path(os.path.expanduser("~/mne_data")) +lite_data_base = ( + Path(os.path.abspath(os.path.dirname(__file__))) + / "jupyterlite_contents" + / "mne_data" +) +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" +if src_ssvep.exists() and not dst_ssvep.exists(): + shutil.copytree(src_ssvep, dst_ssvep, dirs_exist_ok=True) + +src_eeglab = mne_data_base / "MNE-testing-data" / "EEGLAB" +dst_eeglab = lite_data_base / "MNE-testing-data" / "EEGLAB" +if src_eeglab.exists() and not dst_eeglab.exists(): + shutil.copytree(src_eeglab, dst_eeglab, dirs_exist_ok=True) + + +# Build the local MNE wheel so JupyterLite can use the current development version +dist_lite_dir = os.path.join( + os.path.abspath(os.path.dirname(__file__)), "_build", "dist_lite" +) +# Clean the directory first so stale wheels from previous runs do not +# accumulate and pollute the piplite all.json index. +shutil.rmtree(dist_lite_dir, ignore_errors=True) +os.makedirs(dist_lite_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; micropip'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", + dist_lite_dir, + ], + check=True, + ) +finally: + with open(pyproject_path, "w", encoding="utf-8") as f: + f.write(orig_pyproject) + +jupyterlite_build_command_options = {"piplite-wheels": dist_lite_dir} + 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 micropip\n" + "# keep_going=True lets micropip install even if Pyodide's bundled\n" + "# matplotlib/scipy/numpy are older than MNE's declared minimums.\n" + "# MNE's runtime code only checks matplotlib >= 3.7/3.8, so 3.8.4 works.\n" + "await micropip.install(['mne', 'scikit-learn', 'joblib'], keep_going=True)\n" + "\n" + "import sys\n" + "import os\n" + "import io\n" + "\n" + "# Mock lzma — missing in Pyodide but imported by pooch/joblib\n" + "import types\n" + "class MockLZMA:\n" + " LZMAError = Exception\n" + " LZMAFile = object\n" + " FORMAT_XZ = 1\n" + " FORMAT_ALONE = 2\n" + " def __getattr__(self, name):\n" + " return object\n" + "if 'lzma' not in sys.modules:\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" + "# Set the data directory: /drive/mne_data is pre-populated by the doc\n" + "# build (conf.py copies the required sample-data subset there).\n" + "# If absent (e.g. standalone JupyterLite), fall back to /tmp/mne_data\n" + "# with a clear warning — do NOT attempt to download ~1.5 GB from OSF.\n" + "mne_data_path = (\n" + " '/drive/mne_data' if os.path.exists('/drive/mne_data')\n" + " else '/tmp/mne_data'\n" + ")\n" + "os.makedirs(mne_data_path, exist_ok=True)\n" + "if mne_data_path == '/tmp/mne_data':\n" + " print(\n" + " '⚠️ MNE sample data not found at /drive/mne_data. '\n" + " 'Cells that load datasets will raise FileNotFoundError. '\n" + " 'Open this notebook from the live MNE docs (mne.tools) '\n" + " 'where sample data is pre-bundled.'\n" + " )\n" + "os.environ['MNE_DATA'] = mne_data_path\n" + "os.environ['MNE_DATASETS_SAMPLE_PATH'] = mne_data_path\n" + "\n" + "# Block pooch from attempting large OSF downloads in the browser.\n" + "# The required files are either pre-injected or unavailable.\n" + "import pooch\n" + "orig_pooch_fetch = pooch.Pooch.fetch\n" + "def pyodide_pooch_fetch(self, fname, processor=None, downloader=None):\n" + " url = self.get_url(fname)\n" + " if 'osf.io' in url or 'files.osf.io' in url:\n" + " raise RuntimeError(\n" + " f'Cannot download {fname!r} from OSF in JupyterLite: '\n" + " 'browser CORS policy and memory limits prevent large '\n" + " 'dataset downloads. Open this notebook from mne.tools '\n" + " 'where sample data is pre-bundled, or run it locally.'\n" + " )\n" + " return orig_pooch_fetch(\n" + " self, fname, processor=processor, downloader=downloader\n" + " )\n" + "pooch.Pooch.fetch = pyodide_pooch_fetch\n" + "\n" + "# Import MNE and finalize setup.\n" + "import mne\n" + "try:\n" + " mne.get_config()\n" + "except Exception:\n" + " try:\n" + " os.remove(mne.get_config_path())\n" + " print('Corrupted MNE config deleted automatically.')\n" + " except Exception:\n" + " pass\n" + "for ds in ['SAMPLE', 'TESTING', 'SSVEP', 'EEGBCI', 'SOMATO',\n" + " 'AUDIOVISUAL', 'BRAINSTORM']:\n" + " mne.set_config(f'MNE_DATASETS_{ds}_PATH', mne_data_path)\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" + "import importlib\n" + "viz_utils = importlib.import_module('mne.viz.utils')\n" + "orig_plt_show = viz_utils.plt_show\n" + "def pyodide_plt_show(*args, **kwargs):\n" + " orig_plt_show(*args, **kwargs)\n" + " import IPython.display\n" + " IPython.display.display(plt.gcf())\n" + "viz_utils.plt_show = pyodide_plt_show\n" + ), "doc_module": ("mne",), "reference_url": dict(mne=None), "examples_dirs": examples_dirs, @@ -654,6 +894,7 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): "https://doi.org/10.1126/", # www.science.org "https://doi.org/10.1137/", # epubs.siam.org "https://doi.org/10.1145/", # dl.acm.org + "https://doi.org/10.5281/", # zenodo.org "https://doi.org/10.1155/", # www.hindawi.com/journals/cin "https://doi.org/10.1161/", # www.ahajournals.org "https://doi.org/10.1162/", # direct.mit.edu/neco/article/ @@ -664,6 +905,8 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): "https://doi.org/10.3390/", # mdpi.com "https://hms.harvard.edu/", # doc/funding.rst "https://stackoverflow.com/questions/21752259/python-why-pickle", # doc/help/faq + "https://mitpress.mit.edu/9780262525855", # works but linkcheck fails to resolve + "https://zenodo.org", # doc/help/faq "https://blender.org", "https://home.alexk101.dev", "https://www.mq.edu.au/", @@ -708,8 +951,12 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): "https://psychophysiology.cpmc.columbia.edu", "https://erc.easme-web.eu", "https://www.crnl.fr", + # Spurious failure + "https://megcore.nih.gov/index.php/Staff", # Not rendered by linkcheck builder r"ides\.html", + # Sponsors not rendered properly by linkcheck builder + "{{inst.url}}", ] linkcheck_anchors = False # saves a bit of time linkcheck_timeout = 15 # some can be quite slow @@ -879,13 +1126,47 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. html_show_sphinx = False -# accommodate different logo shapes (width values in rem) -xs = "2" -sm = "2.5" -md = "3" -lg = "4.5" -xl = "5" -xxl = "6" +# sponsor and partner logos +with open("_static/sponsors.yml") as fid: + sponsors_partners = safe_load(fid) +current = sponsors_partners.pop("current") +# sponsors +current_sponsors = list() +former_sponsors = list() +for key, val in sponsors_partners["sponsors"].items(): + if "img" in val: + val["name"] = key + (current_sponsors if key in current else former_sponsors).append(val) + else: + assert "light" in val and "dark" in val + for mode in ("light", "dark"): + (current_sponsors if key in current else former_sponsors).append( + dict( + name=f"{key}{'_dk' if mode == 'dark' else ''}", + title=val["title"], + img=val[mode], + klass=f"only-{mode}", + ) + ) +# institutions +current_institutions = list() +former_institutions = list() +for key, val in sponsors_partners["partner_institutions"].items(): + if "img" in val: + val["name"] = key + (current_institutions if key in current else former_institutions).append(val) + else: + assert "light" in val and "dark" in val + for mode in ("light", "dark"): + (current_institutions if key in current else former_institutions).append( + dict( + name=f"{key}{'_dk' if mode == 'dark' else ''}", + title=val["title"], + img=val[mode], + klass=f"only-{mode}", + url=val["url"], + ) + ) # variables to pass to HTML templating engine html_context = { "default_mode": "auto", @@ -894,292 +1175,13 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): "github_repo": "mne-python", "github_version": "main", "doc_path": "doc", - "funders": [ - dict(img="nih.svg", size="3", title="National Institutes of Health"), - dict(img="nsf.png", size="3.5", title="US National Science Foundation"), - dict( - img="erc.svg", - size="3.5", - title="European Research Council", - klass="only-light", - ), - dict( - img="erc-dark.svg", - size="3.5", - title="European Research Council", - klass="only-dark", - ), - dict(img="doe.svg", size="3", title="US Department of Energy"), - dict(img="anr.svg", size="3.5", title="Agence Nationale de la Recherche"), - dict( - img="cds.svg", - size="1.75", - title="Paris-Saclay Center for Data Science", - klass="only-light", - ), - dict( - img="cds-dark.svg", - size="1.75", - title="Paris-Saclay Center for Data Science", - klass="only-dark", - ), - dict(img="google.svg", size="2.25", title="Google"), - dict(img="amazon.svg", size="2.5", title="Amazon"), - dict(img="czi.svg", size="2.5", title="Chan Zuckerberg Initiative"), - ], - "institutions": [ - dict( - name="Massachusetts General Hospital", - img="MGH.svg", - url="https://www.massgeneral.org/", - size=sm, - ), - dict( - name="Athinoula A. Martinos Center for Biomedical Imaging", - img="Martinos.png", - url="https://martinos.org/", - size=md, - ), - dict( - name="Harvard Medical School", - img="Harvard.png", - url="https://hms.harvard.edu/", - size=sm, - ), - dict( - name="Massachusetts Institute of Technology", - img="MIT.svg", - url="https://web.mit.edu/", - size=md, - ), - dict( - name="New York University", - img="NYU.svg", - url="https://www.nyu.edu/", - size=xs, - klass="only-light", - ), - dict( - name="New York University", - img="NYU-dark.svg", - url="https://www.nyu.edu/", - size=xs, - klass="only-dark", - ), - dict( - name="Commissariat à l´énergie atomique et aux énergies alternatives", - img="CEA.png", - url="http://www.cea.fr/", - size=md, - ), - dict( - name="Aalto-yliopiston perustieteiden korkeakoulu", - img="Aalto.svg", - url="https://sci.aalto.fi/", - size=md, - klass="only-light", - ), - dict( - name="Aalto-yliopiston perustieteiden korkeakoulu", - img="Aalto-dark.svg", - url="https://sci.aalto.fi/", - size=md, - klass="only-dark", - ), - dict( - name="Télécom ParisTech", - img="Telecom_Paris_Tech.svg", - url="https://www.telecom-paris.fr/", - size=md, - ), - dict( - name="University of Washington", - img="Washington.svg", - url="https://www.washington.edu/", - size=md, - klass="only-light", - ), - dict( - name="University of Washington", - img="Washington-dark.svg", - url="https://www.washington.edu/", - size=md, - klass="only-dark", - ), - dict( - name="Institut du Cerveau et de la Moelle épinière", - img="ICM.jpg", - url="https://icm-institute.org/", - size=md, - ), - dict( - name="Boston University", img="BU.svg", url="https://www.bu.edu/", size=lg - ), - dict( - name="Institut national de la santé et de la recherche médicale", - img="Inserm.svg", - url="https://www.inserm.fr/", - size=xl, - klass="only-light", - ), - dict( - name="Institut national de la santé et de la recherche médicale", - img="Inserm-dark.svg", - url="https://www.inserm.fr/", - size=xl, - klass="only-dark", - ), - dict( - name="Forschungszentrum Jülich", - img="Julich.svg", - url="https://www.fz-juelich.de/", - size=xl, - klass="only-light", - ), - dict( - name="Forschungszentrum Jülich", - img="Julich-dark.svg", - url="https://www.fz-juelich.de/", - size=xl, - klass="only-dark", - ), - dict( - name="Technische Universität Ilmenau", - img="Ilmenau.svg", - url="https://www.tu-ilmenau.de/", - size=xxl, - klass="only-light", - ), - dict( - name="Technische Universität Ilmenau", - img="Ilmenau-dark.svg", - url="https://www.tu-ilmenau.de/", - size=xxl, - klass="only-dark", - ), - dict( - name="Berkeley Institute for Data Science", - img="BIDS.svg", - url="https://bids.berkeley.edu/", - size=lg, - klass="only-light", - ), - dict( - name="Berkeley Institute for Data Science", - img="BIDS-dark.svg", - url="https://bids.berkeley.edu/", - size=lg, - klass="only-dark", - ), - dict( - name="Institut national de recherche en informatique et en automatique", - img="inria.png", - url="https://www.inria.fr/", - size=xl, - ), - dict( - name="Aarhus Universitet", - img="Aarhus.svg", - url="https://www.au.dk/", - size=xl, - klass="only-light", - ), - dict( - name="Aarhus Universitet", - img="Aarhus-dark.svg", - url="https://www.au.dk/", - size=xl, - klass="only-dark", - ), - dict( - name="Karl-Franzens-Universität Graz", - img="Graz.svg", - url="https://www.uni-graz.at/", - size=md, - ), - dict( - name="SWPS Uniwersytet Humanistycznospołeczny", - img="SWPS.svg", - url="https://www.swps.pl/", - size=xl, - klass="only-light", - ), - dict( - name="SWPS Uniwersytet Humanistycznospołeczny", - img="SWPS-dark.svg", - url="https://www.swps.pl/", - size=xl, - klass="only-dark", - ), - dict( - name="Max-Planck-Institut für Bildungsforschung", - img="MPIB.svg", - url="https://www.mpib-berlin.mpg.de/", - size=xxl, - klass="only-light", - ), - dict( - name="Max-Planck-Institut für Bildungsforschung", - img="MPIB-dark.svg", - url="https://www.mpib-berlin.mpg.de/", - size=xxl, - klass="only-dark", - ), - dict( - name="Macquarie University", - img="Macquarie.svg", - url="https://www.mq.edu.au/", - size=lg, - klass="only-light", - ), - dict( - name="Macquarie University", - img="Macquarie-dark.svg", - url="https://www.mq.edu.au/", - size=lg, - klass="only-dark", - ), - dict( - name="AE Studio", - img="AE-Studio-light.svg", - url="https://ae.studio/", - size=xxl, - klass="only-light", - ), - dict( - name="AE Studio", - img="AE-Studio-dark.svg", - url="https://ae.studio/", - size=xxl, - klass="only-dark", - ), - dict( - name="Children’s Hospital of Philadelphia Research Institute", - img="CHOP.svg", - url="https://www.research.chop.edu/imaging", - size=xxl, - klass="only-light", - ), - dict( - name="Children’s Hospital of Philadelphia Research Institute", - img="CHOP-dark.svg", - url="https://www.research.chop.edu/imaging", - size=xxl, - klass="only-dark", - ), - dict( - name="Donders Institute for Brain, Cognition and Behaviour at Radboud University", # noqa E501 - img="Donders.png", - url="https://www.ru.nl/donders/", - size=xl, - ), - dict( - name="Fondation Campus Biotech Geneva", - img="FCBG.svg", - url="https://fcbg.ch/", - size=sm, - ), - ], + "current_sponsors_partners": current, + "current_sponsors": current_sponsors, + "former_sponsors": former_sponsors, + "all_sponsors": [*current_sponsors, *former_sponsors], + "current_institutions": current_institutions, + "former_institutions": former_institutions, + "all_institutions": [*current_institutions, *former_institutions], # \u00AD is an optional hyphen (not rendered unless needed) # If these are changed, the Makefile should be updated, too "carousel": [ @@ -1332,11 +1334,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 = { @@ -1525,9 +1526,12 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): custom_redirects = { # Custom redirects (one HTML path to another, relative to outdir) # can be added here as fr->to key->value mappings + "credit": "credits/credit", + "funding": "credits/sponsors", "install/contributing": "development/contributing", "overview/cite": "documentation/cite", "overview/get_help": "help/index", + "overview/people": "credits/leaders", "overview/roadmap": "development/roadmap", "whats_new": "development/whats_new", f"{tu}/evoked/plot_eeg_erp": f"{tu}/evoked/30_eeg_erp", @@ -1682,7 +1686,9 @@ def make_custom_redirects(app, exception): else: to_path = Path(app.outdir) / to assert to_path.is_file(), to_path - # recreate folders that no longer exist + # recreate overview folder (only for redirects now) + os.makedirs(Path(app.outdir) / "overview", exist_ok=True) + # recreate gallery folders that no longer exist defunct_gallery_folders = ( "misc", "discussions", @@ -1720,6 +1726,17 @@ def make_version(app, exception): sphinx_logger.info(f'Added "{stdout.rstrip()}" > _version.txt') +def rstjinja(app, docname, source): + """Use Jinja to process the sponsors page.""" + # Make sure we're outputting HTML + if app.builder.format != "html": + return + if docname == "credits/sponsors": + src = source[0] + rendered = app.builder.templates.render_string(src, app.config.html_context) + source[0] = rendered + + # -- Connect our handlers to the main Sphinx app --------------------------- @@ -1734,3 +1751,4 @@ def setup(app): app.connect("build-finished", make_api_redirects) app.connect("build-finished", make_custom_redirects) app.connect("build-finished", make_version) + app.connect("source-read", rstjinja) diff --git a/doc/jupyterlite_contents/.gitkeep b/doc/jupyterlite_contents/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyproject.toml b/pyproject.toml index ae2120938ee..257f639b52c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,12 +3,14 @@ build-backend = "hatchling.build" requires = ["hatch-vcs", "hatchling >= 1.27"] [dependency-groups] -dev = ["pip >= 25.1", "rcssmin >= 1.1", {include-group = "doc"}, {include-group = "test_extra"}] +dev = ["pip >= 25.1", "pyside6 >= 6.11.1", "rcssmin >= 1.1", {include-group = "doc"}, {include-group = "test_extra"}] # Dependencies for building the documentation 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", @@ -17,10 +19,9 @@ doc = [ "numpydoc >= 0.5", "openneuro-py >= 2020.1", "psutil", - "pydata_sphinx_theme >= 0.15.2", + "pydata-sphinx-theme >= 0.15.2", "pygments >= 2.13", "pymef", - "pytest", "pyvistaqt >= 0.11", # released 2023-06-30, no newer version available "pyxdf", "pyzmq != 24.0.0", @@ -49,7 +50,7 @@ test = [ "numpydoc >= 1.6", "pillow >= 10.2", "pre-commit", - "pytest >= 8.0", + "pytest >= 8.0,!=9.1.0", # https://github.com/pytest-dev/pytest/issues/14591 "pytest-cov >= 4.1", "pytest-qt >= 4.3", "pytest-rerunfailures", @@ -57,27 +58,31 @@ test = [ "ruff >= 0.1", "twine", "vulture", - "wheel >= 0.21", +] +# Move non-free-threaded dependencies into the "test_extra" superset +# Exclusions determined 2026/06/16 +test_extra = [ + "jupyter_client", # requires tornado, which has no ft version + "nbclient", # requires jupyter_client + "nitime >= 0.7", + "pymef", + "statsmodels", + {include-group = "test_extra_ft"}, ] # Dependencies for being able to run additional tests (rare/CIs/advanced devs) # Changes here should be reflected in the mne/utils/config.py dev dependencies section -test_extra = [ +test_extra_ft = [ "edfio >= 0.4.10", "eeglabio", "hedtools", "imageio >= 2.6.1", "imageio-ffmpeg >= 0.4.1", - "jupyter_client", "mne-bids", - "nbclient", "nbformat", "neo", - "nitime >= 0.7", "pybv", - "pymef", "snirf", "sphinx-gallery", - "statsmodels", {include-group = "test"}, ] @@ -134,12 +139,11 @@ scripts = {mne = "mne.commands.utils:main"} [project.optional-dependencies] # Leave this one here for backward-compat data = [] -full = ["mne[full-no-qt]", "PyQt6 != 6.6.0", "PyQt6-Qt6 != 6.6.0, != 6.7.0"] +full = ["mne[full-no-qt]", "PySide6 != 6.7.0, != 6.8.0, != 6.8.0.1, != 6.9.1"] # Dependencies for full MNE-Python functionality (other than raw/epochs export) # We first define a variant without any Qt bindings. The "complete" variant, mne[full], -# makes an opinionated choice and installs PyQt6. -# We also offter two more variants: mne[full-qt6] (which is equivalent to mne[full]), -# and mne[full-pyside6], which will install PySide6 instead of PyQt6. +# makes an opinionated choice and installs PySide6. +# We also offer mne[full-pyqt6], which will install PyQt6 instead of PySide6. full-no-qt = [ "antio >= 0.5.0", "curryreader >= 0.1.2", @@ -158,7 +162,7 @@ full-no-qt = [ "ipywidgets", "joblib >= 0.8", "jupyter", - "mffpy >= 0.5.7", + "mffpy >= 0.11.0", "mne-qt-browser", "mne[hdf5]", "neo", @@ -191,8 +195,8 @@ full-no-qt = [ "vtk >= 9.2", "xlrd", ] -full-pyqt6 = ["mne[full]"] -full-pyside6 = ["mne[full-no-qt]", "PySide6 != 6.7.0, != 6.8.0, != 6.8.0.1, != 6.9.1"] +full-pyqt6 = ["mne[full-no-qt]", "PyQt6 != 6.6.0", "PyQt6-Qt6 != 6.6.0, != 6.7.0"] +full-pyside6 = ["mne[full]"] # Dependencies for MNE-Python functions that use HDF5 I/O hdf5 = ["h5io >= 0.2.4", "pymatreader"] From e37acb339c93da2b75704a6bdc0afd5f81e9f9f6 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 09:34:36 -0400 Subject: [PATCH 02/98] FIX: Patch MNE internals for Pyodide/emscripten compatibility - mne/parallel.py: return False early in _running_in_joblib_context() on emscripten; joblib parallel backends are unavailable in the browser - mne/utils/config.py: catch Exception (not just ValueError) when loading the MNE config JSON; Pyodide's json parser raises SyntaxError on a corrupt or absent config file --- mne/parallel.py | 4 ++ mne/utils/config.py | 109 ++++++++++++++++++++++++++++---------------- 2 files changed, 73 insertions(+), 40 deletions(-) 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 ff7ec4f6285..8bd70e4a1d8 100644 --- a/mne/utils/config.py +++ b/mne/utils/config.py @@ -6,17 +6,19 @@ import atexit import contextlib +import importlib.metadata +import importlib.util import json import multiprocessing import os import os.path as op import platform import shutil +import site import subprocess import sys import tempfile from functools import lru_cache, partial -from importlib import import_module from pathlib import Path from urllib.error import URLError from urllib.request import urlopen @@ -272,8 +274,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" @@ -757,6 +759,8 @@ def sys_info( .. versionadded:: 1.6 """ + import matplotlib + _validate_type(dependencies, str) _check_option("dependencies", dependencies, ("user", "developer")) _validate_type(check_version, (bool, "numeric"), "check_version") @@ -764,7 +768,10 @@ def sys_info( _check_option("unicode", unicode, ("auto", True, False)) if unicode == "auto": if platform.system() in ("Darwin", "Linux"): - unicode = True + try: + unicode = sys.stdout.encoding.lower().startswith("utf") + except Exception: # in case someone overrides sys.stdout in an unsafe way + unicode = False else: # Windows unicode = False ljust = 24 if dependencies == "developer" else 21 @@ -788,6 +795,13 @@ def sys_info( else: total_memory = f"{total_memory / 1024**3:.1f}" # convert to GiB out(f"{total_memory} GiB\n") + site_packages_path = (site.getsitepackages() or [None])[0] + if show_paths and site_packages_path is not None: + out("Site-packages".ljust(ljust) + f"{site_packages_path}\n") + site_packages_path = Path(site_packages_path) + out("".ljust(ljust)) + out("└►" if unicode else "^-") + out(" Any paths not listed below are in site-packages") out("\n") ljust -= 3 # account for +/- symbols libs = _get_numpy_libs() @@ -800,12 +814,13 @@ def sys_info( "matplotlib", "", "# Numerical (optional)", - "sklearn", + "scikit-learn", "numba", "nibabel", "nilearn", "dipy", "openmeeg", + "python-picard", "cupy", "pandas", "h5io", @@ -820,7 +835,7 @@ def sys_info( "pyqtgraph", "mne-qt-browser", "ipywidgets", - # "trame", # no version, see https://github.com/Kitware/trame/issues/183 + "trame", "trame_client", "trame_server", "trame_pyvista", @@ -834,6 +849,7 @@ def sys_info( "mne-connectivity", "mne-icalabel", "mne-bids-pipeline", + "autoreject", "neo", "eeglabio", "edfio", @@ -849,6 +865,18 @@ def sys_info( use_mod_names += ( "# Testing", "pytest", + "pytest-cov", + "pytest-qt", + "pytest-rerunfailures", + "pytest-timeout", + "codespell", + "ipython", + "mypy", + "pillow", + "pre-commit", + "ruff", + "vulture", + "", "hedtools", "statsmodels", "numpydoc", @@ -859,6 +887,7 @@ def sys_info( "imageio", "imageio-ffmpeg", "snirf", + "twine", "", "# Documentation", "sphinx", @@ -874,12 +903,24 @@ def sys_info( "tqdm", "", ) - try: - unicode = unicode and (sys.stdout.encoding.lower().startswith("utf")) - except Exception: # in case someone overrides sys.stdout in an unsafe way - unicode = False - mne_version_good = True - import_names = dict(hedtools="hed") + if check_version: + timeout = 2.0 if check_version is True else float(check_version) + mne_version_good, mne_extra = _check_mne_version(timeout) + if mne_version_good is None: + mne_version_good = True + del timeout + else: + mne_version_good = True + mne_extra = "" + del check_version + import_names = { + "codespell": "codespell_lib", + "hedtools": "hed", + "ipython": "IPython", + "pillow": "PIL", + "pytest-qt": "pytestqt", + "scikit-learn": "sklearn", + } for mi, mod_name in enumerate(use_mod_names): # upcoming break if mod_name == "": # break @@ -897,45 +938,32 @@ def sys_info( continue pre = "├" last = use_mod_names[mi + 1] == "" and not unavailable + import_name = import_names.get(mod_name, mod_name).replace("-", "_") if last: pre = "└" try: - import_name = import_names.get(mod_name, mod_name.replace("-", "_")) - mod = import_module(import_name) + ver = importlib.metadata.version(mod_name) + mod_loc = Path(importlib.util.find_spec(import_name).origin) except Exception: unavailable.append(mod_name) else: + if mod_loc.stem == "__init__": + mod_loc = mod_loc.parent + if site_packages_path and mod_loc.is_relative_to(site_packages_path): + mod_loc = None mark = "☑" if unicode else "+" - mne_extra = "" - if mod_name == "mne" and check_version: - timeout = 2.0 if check_version is True else float(check_version) - mne_version_good, mne_extra = _check_mne_version(timeout) - if mne_version_good is None: - mne_version_good = True - elif not mne_version_good: - mark = "☒" if unicode else "X" + if mod_name == "mne" and not mne_version_good: + mark = "☒" if unicode else "X" out(f"{pre}{mark} " if unicode else f" {mark} ") out(f"{mod_name}".ljust(ljust)) - if mod_name == "vtk": - vtk_version = mod.vtkVersion() - # 9.0 dev has VersionFull but 9.0 doesn't - for attr in ("GetVTKVersionFull", "GetVTKVersion"): - if hasattr(vtk_version, attr): - version = getattr(vtk_version, attr)() - if version != "": - out(version) - break - else: - out("unknown") - else: - out(mod.__version__.lstrip("v")) + out(ver) if mod_name == "numpy": out(f" ({libs})") elif mod_name == "qtpy": version, api = _check_qt_version(return_api=True) out(f" ({api}={version})") elif mod_name == "matplotlib": - out(f" (backend={mod.get_backend()})") + out(f" (backend={matplotlib.get_backend()})") elif mod_name == "pyvista": version, renderer = _get_gpu_info() if version is None: @@ -943,16 +971,17 @@ def sys_info( else: out(f" (OpenGL {version} via {renderer})") elif mod_name == "mne": - out(f" ({mne_extra})") + if mne_extra: + out(f" ({mne_extra})") # Now comes stuff after the version - if show_paths: + if show_paths and mod_loc is not None: if last: pre = " " elif unicode: pre = "│ " else: pre = " | " - out(f"\n{pre}{' ' * ljust}{op.dirname(mod.__file__)}") + out(f"\n{pre}{' ' * ljust}{mod_loc}") out("\n") if not mne_version_good: @@ -986,7 +1015,7 @@ def _check_mne_version(timeout): if not rel_ver[0].isnumeric(): return None, (f"unable to check for latest version on GitHub, {rel_ver}") rel_ver = parse(rel_ver) - this_ver = parse(import_module("mne").__version__) + this_ver = parse(importlib.metadata.version("mne")) if this_ver > rel_ver: return True, f"development, latest release is {rel_ver}" if this_ver == rel_ver: From 9e6e28835f425af141b593118fe48d7e34faa51a Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 09:34:50 -0400 Subject: [PATCH 03/98] FIX: Guard interactive Qt plots and unavailable datasets for the browser Tutorials and examples that call interactive Qt backends (raw.plot(), epochs.plot(), ica.plot_sources(), etc.) or depend on large datasets not bundled in JupyterLite will hang or error in Pyodide. Wrap them with sys.platform guards so they are skipped when running in the browser. Interactive Qt plots (skip on emscripten): - tutorials/intro/10_overview.py: raw.plot(), stc.plot() - tutorials/intro/15_inplace.py: original_raw.plot(), rereferenced_raw.plot() - tutorials/intro/20_events_from_raw.py: raw.copy().pick().plot(), raw.plot() - tutorials/intro/40_sensor_locations.py: mne.viz.plot_alignment() - tutorials/evoked/40_whitened.py: raw.plot(), epochs.plot() - examples/preprocessing/muscle_ica.py: all ica.plot_* calls Large datasets unavailable in the browser (raise RuntimeError on emscripten): - tutorials/io/60_ctf_bst_auditory.py: BST auditory dataset (~2.9 GB) - tutorials/io/70_reading_eyetracking_data.py: EyeLink misc dataset - examples/visualization/eyetracking_plot_heatmap.py: EyeLink dataset --- examples/preprocessing/muscle_ica.py | 22 +++++++++++++------ .../visualization/eyetracking_plot_heatmap.py | 15 +++++++++++-- tutorials/evoked/40_whitened.py | 12 ++++++---- tutorials/intro/10_overview.py | 18 +++++++++------ tutorials/intro/15_inplace.py | 10 ++++++--- tutorials/intro/20_events_from_raw.py | 8 +++++-- tutorials/intro/40_sensor_locations.py | 20 +++++++++-------- tutorials/io/60_ctf_bst_auditory.py | 9 ++++++++ tutorials/io/70_reading_eyetracking_data.py | 9 ++++++++ 9 files changed, 89 insertions(+), 34 deletions(-) diff --git a/examples/preprocessing/muscle_ica.py b/examples/preprocessing/muscle_ica.py index 8ef1e451985..7b2cc05acd3 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,8 @@ # %% # Remove components with postural muscle artifact using ICA -ica.plot_sources(raw) +if sys.platform != "emscripten": + ica.plot_sources(raw) # %% # By inspection, let's select out the muscle-artifact components based on @@ -71,19 +74,22 @@ # 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) +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) +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) +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 +113,12 @@ n_components=15, method="picard", max_iter="auto", random_state=97 ) ica.fit(raw) - ica.plot_sources(raw) + 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) + 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 07983685b5e..e0104aa4af2 100644 --- a/examples/visualization/eyetracking_plot_heatmap.py +++ b/examples/visualization/eyetracking_plot_heatmap.py @@ -26,11 +26,20 @@ # :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 +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" @@ -82,8 +91,10 @@ # start at a value greater than the darkest value in our previous heatmap, which will # make the darkest colors of the heatmap transparent. -cmap.set_under("k", alpha=0) # make the lowest values transparent -ax = plt.subplot() +cmap = cmap.with_extremes( + under=(0.0, 0.0, 0.0, 0.0) +) # make the lowest values transparent +_, ax = plt.subplots(figsize=(6, 3.5), layout="constrained") ax.imshow(plt.imread(stim_fpath)) plot_gaze( epochs["natural"], diff --git a/tutorials/evoked/40_whitened.py b/tutorials/evoked/40_whitened.py index a3110139b4e..06abc4102e1 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,16 @@ ) # butterfly mode shows the differences most clearly -raw.plot(events=events, butterfly=True) -raw.plot(noise_cov=noise_cov, events=events, butterfly=True) +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) +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..7a9bccfb868 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,10 @@ # 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) +if sys.platform != "emscripten": + raw.plot(duration=5, n_channels=30) # %% # Preprocessing @@ -140,8 +142,9 @@ "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) +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 +403,10 @@ # 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 -) +if sys.platform != "emscripten": + stc.plot( + initial_time=0.1, hemi="split", views=["lat", "med"], subjects_dir=subjects_dir + ) ############################################################################## # The remaining tutorials have *much more detail* on each of these topics (as diff --git a/tutorials/intro/15_inplace.py b/tutorials/intro/15_inplace.py index e9cbd4769f1..a66c162c878 100644 --- a/tutorials/intro/15_inplace.py +++ b/tutorials/intro/15_inplace.py @@ -22,6 +22,8 @@ # %% +import sys + import mne sample_data_folder = mne.datasets.sample.data_path() @@ -83,9 +85,11 @@ # we specified ``copy=True``: # sphinx_gallery_thumbnail_number=2 -rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, ["EEG 003"], copy=True) -fig_orig = original_raw.plot() -fig_reref = rereferenced_raw.plot() +rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, "EEG 003", copy=True) + +if sys.platform != "emscripten": + fig_orig = original_raw.plot() + fig_reref = rereferenced_raw.plot() # %% # Another example is the picking function `mne.pick_info`, which operates on diff --git a/tutorials/intro/20_events_from_raw.py b/tutorials/intro/20_events_from_raw.py index 2c368646908..b2b3c8732d5 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 @@ -94,7 +96,8 @@ # on newer systems it is more commonly ``STI101``. You can see the STIM # channels in the raw data file here: -raw.copy().pick(picks="stim").plot(start=3, duration=6) +if sys.platform != "emscripten": + raw.copy().pick(picks="stim").plot(start=3, duration=6) # %% # You can see that ``STI 014`` (the summation channel) contains pulses of @@ -265,7 +268,8 @@ # Now, the annotations will appear automatically when plotting the raw data, # and will be color-coded by their label value: -raw.plot(start=5, duration=5) +if sys.platform != "emscripten": + raw.plot(start=5, duration=5) # %% # .. _`chunk-duration`: diff --git a/tutorials/intro/40_sensor_locations.py b/tutorials/intro/40_sensor_locations.py index 6046e252f47..673d66599b8 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 @@ -218,15 +219,16 @@ # 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) +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/io/60_ctf_bst_auditory.py b/tutorials/io/60_ctf_bst_auditory.py index 4c3249996aa..945c0648232 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,13 @@ from mne.io import read_raw_ctf from mne.minimum_norm import apply_inverse +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..b256273ccec 100644 --- a/tutorials/io/70_reading_eyetracking_data.py +++ b/tutorials/io/70_reading_eyetracking_data.py @@ -85,8 +85,17 @@ """ # %% +import sys + import mne +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" From d2d22b0b12057dd91b754efbde25943002ca1f9f Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 16:06:34 -0400 Subject: [PATCH 04/98] FIX: Guard browser-incompatible code in all intro tutorials for JupyterLite - doc/conf.py: Fix lzma mock to use real stdlib lzma when available in Pyodide instead of LZMAFile=object which broke joblib's compressor registration - 10_overview.py: Guard ica.plot_properties() which opens an interactive Qt window - 15_inplace.py: Guard set_eeg_reference block which fails under Python 3.13 in Pyodide - 20_events_from_raw.py: Guard STIM channel plot and all EEGLAB sections that require the unavailable testing dataset - 40_sensor_locations.py: Guard ssvep dataset loading and sphere plot that require the unavailable ssvep dataset - 50_configure_mne.py: Guard KIT test data loading whose test files are stripped from the Pyodide wheel - 70_report.py: Skip Report.save() file-writing in browser, guard nibabel-dependent add_bem, 3D methods (add_trans/add_stc/add_forward/ add_inverse_operator), missing ECG/events files, pandas-dependent make_metadata, and the HDF5 round-trip section All intro tutorials (10, 15, 20, 30, 40, 50, 70) now run cleanly in JupyterLite/Pyodide without errors. Co-Authored-By: Claude Sonnet 4.6 --- doc/conf.py | 30 ++-- tutorials/intro/10_overview.py | 3 +- tutorials/intro/15_inplace.py | 3 +- tutorials/intro/20_events_from_raw.py | 38 ++--- tutorials/intro/40_sensor_locations.py | 26 ++-- tutorials/intro/50_configure_mne.py | 28 ++-- tutorials/intro/70_report.py | 192 ++++++++++++++----------- 7 files changed, 181 insertions(+), 139 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 1dde8a9fc9f..83c5f503c07 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -604,17 +604,25 @@ "import os\n" "import io\n" "\n" - "# Mock lzma — missing in Pyodide but imported by pooch/joblib\n" - "import types\n" - "class MockLZMA:\n" - " LZMAError = Exception\n" - " LZMAFile = object\n" - " FORMAT_XZ = 1\n" - " FORMAT_ALONE = 2\n" - " def __getattr__(self, name):\n" - " return object\n" - "if 'lzma' not in sys.modules:\n" - " sys.modules['lzma'] = MockLZMA()\n" + "# lzma: try the real stdlib module 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" diff --git a/tutorials/intro/10_overview.py b/tutorials/intro/10_overview.py index 7a9bccfb868..2c5fa04b362 100644 --- a/tutorials/intro/10_overview.py +++ b/tutorials/intro/10_overview.py @@ -103,7 +103,8 @@ 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) +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 diff --git a/tutorials/intro/15_inplace.py b/tutorials/intro/15_inplace.py index a66c162c878..ad35a5cfeb3 100644 --- a/tutorials/intro/15_inplace.py +++ b/tutorials/intro/15_inplace.py @@ -85,9 +85,8 @@ # we specified ``copy=True``: # sphinx_gallery_thumbnail_number=2 -rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, "EEG 003", copy=True) - if sys.platform != "emscripten": + rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, "EEG 003", copy=True) fig_orig = original_raw.plot() fig_reref = rereferenced_raw.plot() diff --git a/tutorials/intro/20_events_from_raw.py b/tutorials/intro/20_events_from_raw.py index b2b3c8732d5..6d57ddf0898 100644 --- a/tutorials/intro/20_events_from_raw.py +++ b/tutorials/intro/20_events_from_raw.py @@ -166,10 +166,11 @@ # 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) +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 @@ -179,10 +180,11 @@ # 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]) +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 @@ -213,9 +215,10 @@ # :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]) +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 @@ -227,12 +230,13 @@ # `~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]) +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 673d66599b8..216276bc1c6 100644 --- a/tutorials/intro/40_sensor_locations.py +++ b/tutorials/intro/40_sensor_locations.py @@ -92,19 +92,20 @@ # 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) +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:: @@ -134,7 +135,8 @@ # 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") +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 diff --git a/tutorials/intro/50_configure_mne.py b/tutorials/intro/50_configure_mne.py index 9e6896eaf98..ba5643fb638 100644 --- a/tutorials/intro/50_configure_mne.py +++ b/tutorials/intro/50_configure_mne.py @@ -18,6 +18,7 @@ # %% import os +import sys import mne @@ -196,22 +197,24 @@ # 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") +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") +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, @@ -221,8 +224,9 @@ # 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) +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..25f81c1d3ad 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,13 @@ sample_dir = data_path / "MEG" / "sample" subjects_dir = data_path / "subjects" +# In JupyterLite, file writing is not supported — skip report.save() calls. +# The report content still renders inline in Jupyter via add_* methods. +if sys.platform == "emscripten": + def _noop_save(self, fname=None, open_browser=False, overwrite=False, verbose=None): + pass + mne.Report.save = _noop_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 +89,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 +118,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 +193,30 @@ # 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 +297,16 @@ # 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) +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 +319,19 @@ # 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) +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 +341,14 @@ # 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" +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 +358,17 @@ # 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" +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 +381,18 @@ # 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" +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) + 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 +552,29 @@ # 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) +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) +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) +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 +660,12 @@ # 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) +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` From 724f80bb92c46a18ae4af34fd63d6b5fabea7500 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:07:20 +0000 Subject: [PATCH 05/98] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tutorials/intro/15_inplace.py | 4 +++- tutorials/intro/70_report.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tutorials/intro/15_inplace.py b/tutorials/intro/15_inplace.py index ad35a5cfeb3..c95d0214899 100644 --- a/tutorials/intro/15_inplace.py +++ b/tutorials/intro/15_inplace.py @@ -86,7 +86,9 @@ # sphinx_gallery_thumbnail_number=2 if sys.platform != "emscripten": - rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, "EEG 003", copy=True) + rereferenced_raw, ref_data = mne.set_eeg_reference( + original_raw, "EEG 003", copy=True + ) fig_orig = original_raw.plot() fig_reref = rereferenced_raw.plot() diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 25f81c1d3ad..76bab0293e5 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -42,8 +42,10 @@ # In JupyterLite, file writing is not supported — skip report.save() calls. # The report content still renders inline in Jupyter via add_* methods. if sys.platform == "emscripten": + def _noop_save(self, fname=None, open_browser=False, overwrite=False, verbose=None): pass + mne.Report.save = _noop_save # %% @@ -200,7 +202,9 @@ def _noop_save(self, fname=None, open_browser=False, overwrite=False, verbose=No 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() + 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, From 9566839ba4b5325eef43df3a1baf69fe9608387b Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 16:19:03 -0400 Subject: [PATCH 06/98] STY: shorten lzma comment in conf.py to fix E501 --- doc/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index 83c5f503c07..65e7573ca04 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -604,7 +604,7 @@ "import os\n" "import io\n" "\n" - "# lzma: try the real stdlib module first (Pyodide ships it); only mock if absent\n" + "# lzma: try real stdlib first (Pyodide ships it); only mock if absent\n" "try:\n" " import lzma\n" "except ImportError:\n" From 9f3272e9d865e978023027c2e71b5905cf7e72d7 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 16:58:41 -0400 Subject: [PATCH 07/98] FIX: pre-create MNE config file to suppress setup warnings in JupyterLite --- doc/conf.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 65e7573ca04..6347ae90a89 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -693,16 +693,15 @@ "\n" "# Import MNE and finalize setup.\n" "import mne\n" - "try:\n" - " mne.get_config()\n" - "except Exception:\n" - " try:\n" - " os.remove(mne.get_config_path())\n" - " print('Corrupted MNE config deleted automatically.')\n" - " except Exception:\n" - " pass\n" + "# Pre-create a valid empty config file so MNE never hits a corrupt read.\n" + "_cfg = mne.get_config_path()\n" + "os.makedirs(os.path.dirname(_cfg), exist_ok=True)\n" + "if not os.path.exists(_cfg):\n" + " with open(_cfg, 'w') as _f:\n" + " _f.write('{}')\n" + "mne.set_config('MNE_DATA', mne_data_path)\n" "for ds in ['SAMPLE', 'TESTING', 'SSVEP', 'EEGBCI', 'SOMATO',\n" - " 'AUDIOVISUAL', 'BRAINSTORM']:\n" + " 'BRAINSTORM']:\n" " mne.set_config(f'MNE_DATASETS_{ds}_PATH', mne_data_path)\n" "\n" "# Switch matplotlib to inline so figures render in the notebook.\n" From 825740e86f25f7221039f056a1086f57d5eee626 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 17:30:20 -0400 Subject: [PATCH 08/98] download sample data in conf.py if missing so CI artifacts work --- doc/conf.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 6347ae90a89..42d15a74d57 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -492,6 +492,12 @@ / "MNE-sample-data" ) dst_sample_data.mkdir(parents=True, exist_ok=True) +if not src_sample_data.exists(): + try: + import mne as _mne + _mne.datasets.sample.data_path(verbose=False) + except Exception: + pass if src_sample_data.exists(): required_files = [ "version.txt", From 60fcc7f18baca3d373300b7c05dff1ebd2eb0b39 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:30:44 +0000 Subject: [PATCH 09/98] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/conf.py b/doc/conf.py index 42d15a74d57..9f14d2aed31 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -495,6 +495,7 @@ if not src_sample_data.exists(): try: import mne as _mne + _mne.datasets.sample.data_path(verbose=False) except Exception: pass From 4d0586d4ff36b621d3dfdc6c99a3d0db06c95863 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 17:50:58 -0400 Subject: [PATCH 10/98] Revert "[pre-commit.ci] auto fixes from pre-commit.com hooks" This reverts commit 60fcc7f18baca3d373300b7c05dff1ebd2eb0b39. --- doc/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index 9f14d2aed31..42d15a74d57 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -495,7 +495,6 @@ if not src_sample_data.exists(): try: import mne as _mne - _mne.datasets.sample.data_path(verbose=False) except Exception: pass From f7a9aa9d271392e6c509b018b1b6547c5aab8b05 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 17:50:58 -0400 Subject: [PATCH 11/98] Revert "download sample data in conf.py if missing so CI artifacts work" This reverts commit 825740e86f25f7221039f056a1086f57d5eee626. --- doc/conf.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 42d15a74d57..6347ae90a89 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -492,12 +492,6 @@ / "MNE-sample-data" ) dst_sample_data.mkdir(parents=True, exist_ok=True) -if not src_sample_data.exists(): - try: - import mne as _mne - _mne.datasets.sample.data_path(verbose=False) - except Exception: - pass if src_sample_data.exists(): required_files = [ "version.txt", From 41d26c4c5b8b1e5e8702bf2734e1a76fcc3f84ec Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 18:27:00 -0400 Subject: [PATCH 12/98] DBG: log JupyterLite data copy steps in conf.py to diagnose CI artifact issue --- doc/conf.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 6347ae90a89..fcae12f7b2e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -492,6 +492,8 @@ / "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", @@ -514,6 +516,9 @@ 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 @@ -527,13 +532,17 @@ 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") # Build the local MNE wheel so JupyterLite can use the current development version From 43dca987214f83676c6b1a6b109facb8f4f93529 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 20:39:26 -0400 Subject: [PATCH 13/98] DBG: print drive/mne_data filesystem state in JupyterLite setup cell --- doc/conf.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index fcae12f7b2e..6494d3a3b06 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -667,8 +667,18 @@ "# build (conf.py copies the required sample-data subset there).\n" "# If absent (e.g. standalone JupyterLite), fall back to /tmp/mne_data\n" "# with a clear warning — do NOT attempt to download ~1.5 GB from OSF.\n" + "drive_exists = os.path.exists('/drive/mne_data')\n" + "sample_exists = os.path.isdir('/drive/mne_data/MNE-sample-data')\n" + "print(f'[DBG] /drive/mne_data exists: {drive_exists}')\n" + "print(f'[DBG] /drive/mne_data/MNE-sample-data isdir: {sample_exists}')\n" + "if sample_exists:\n" + " try:\n" + " sample_files = os.listdir('/drive/mne_data/MNE-sample-data/MEG/sample')\n" + " print(f'[DBG] MEG/sample files: {sample_files}')\n" + " except Exception as e:\n" + " print(f'[DBG] listdir failed: {e}')\n" "mne_data_path = (\n" - " '/drive/mne_data' if os.path.exists('/drive/mne_data')\n" + " '/drive/mne_data' if drive_exists\n" " else '/tmp/mne_data'\n" ")\n" "os.makedirs(mne_data_path, exist_ok=True)\n" From 2aebf896931d5c9f01dcb038f37bfcb0585aa31b Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 20:41:57 -0400 Subject: [PATCH 14/98] STY: fix E501 in JupyterLite setup cell diagnostics --- doc/conf.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 6494d3a3b06..e5f03a111d9 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -670,10 +670,11 @@ "drive_exists = os.path.exists('/drive/mne_data')\n" "sample_exists = os.path.isdir('/drive/mne_data/MNE-sample-data')\n" "print(f'[DBG] /drive/mne_data exists: {drive_exists}')\n" - "print(f'[DBG] /drive/mne_data/MNE-sample-data isdir: {sample_exists}')\n" + "print(f'[DBG] MNE-sample-data isdir: {sample_exists}')\n" "if sample_exists:\n" " try:\n" - " sample_files = os.listdir('/drive/mne_data/MNE-sample-data/MEG/sample')\n" + " _meg = '/drive/mne_data/MNE-sample-data/MEG/sample'\n" + " sample_files = os.listdir(_meg)\n" " print(f'[DBG] MEG/sample files: {sample_files}')\n" " except Exception as e:\n" " print(f'[DBG] listdir failed: {e}')\n" From 714de0692057569e0aa5ef3eed25a6f39942b06c Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 21:35:23 -0400 Subject: [PATCH 15/98] FIX: patch mne.datasets.sample.data_path in JupyterLite to bypass pooch archive check --- doc/conf.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index e5f03a111d9..b008b1d49d5 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -724,6 +724,16 @@ " 'BRAINSTORM']:\n" " mne.set_config(f'MNE_DATASETS_{ds}_PATH', mne_data_path)\n" "\n" + "# Patch mne.datasets.sample.data_path to return the pre-bundled\n" + "# extracted directory directly. Without this, pooch looks for the\n" + "# original .tar.gz archive and tries to download from OSF when\n" + "# missing — which our guard then blocks.\n" + "_drive_sample = os.path.join(mne_data_path, 'MNE-sample-data')\n" + "if os.path.isdir(_drive_sample):\n" + " def _lite_sample_data_path(*_a, **_kw):\n" + " return _drive_sample\n" + " mne.datasets.sample.data_path = _lite_sample_data_path\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" From 06ad00c2bb748f9bec714277648a95034bda65ce Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 22:20:44 -0400 Subject: [PATCH 16/98] FIX: fetch MNE sample data via HTTP in JupyterLite setup cell --- doc/conf.py | 79 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index b008b1d49d5..1a35619cf97 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -663,33 +663,46 @@ " return response\n" "requests.Session.send = pyodide_send\n" "\n" - "# Set the data directory: /drive/mne_data is pre-populated by the doc\n" - "# build (conf.py copies the required sample-data subset there).\n" - "# If absent (e.g. standalone JupyterLite), fall back to /tmp/mne_data\n" - "# with a clear warning — do NOT attempt to download ~1.5 GB from OSF.\n" - "drive_exists = os.path.exists('/drive/mne_data')\n" - "sample_exists = os.path.isdir('/drive/mne_data/MNE-sample-data')\n" - "print(f'[DBG] /drive/mne_data exists: {drive_exists}')\n" - "print(f'[DBG] MNE-sample-data isdir: {sample_exists}')\n" - "if sample_exists:\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 data files directly from the JupyterLite\n" + "# files/ path into /tmp/mne_data instead — same-origin, no CORS.\n" + "import pyodide.http as _phttp\n" + "import js as _js\n" + "_page = str(_js.window.location.href)\n" + "_base = _page.rsplit('/lab/', 1)[0] + '/files/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-eve.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" + "]\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" - " _meg = '/drive/mne_data/MNE-sample-data/MEG/sample'\n" - " sample_files = os.listdir(_meg)\n" - " print(f'[DBG] MEG/sample files: {sample_files}')\n" - " except Exception as e:\n" - " print(f'[DBG] listdir failed: {e}')\n" - "mne_data_path = (\n" - " '/drive/mne_data' if drive_exists\n" - " else '/tmp/mne_data'\n" - ")\n" + " _r = await _phttp.pyfetch(_url)\n" + " _d = await _r.bytes()\n" + " os.makedirs(os.path.dirname(_dst), exist_ok=True)\n" + " open(_dst, 'wb').write(_d)\n" + " _kb = len(_d) // 1024\n" + " print(f' {_f.split(\"/\")[-1]} ({_kb} KB)')\n" + " except Exception as _e:\n" + " print(f' FAILED {_f}: {_e}')\n" "os.makedirs(mne_data_path, exist_ok=True)\n" - "if mne_data_path == '/tmp/mne_data':\n" - " print(\n" - " '⚠️ MNE sample data not found at /drive/mne_data. '\n" - " 'Cells that load datasets will raise FileNotFoundError. '\n" - " 'Open this notebook from the live MNE docs (mne.tools) '\n" - " 'where sample data is pre-bundled.'\n" - " )\n" "os.environ['MNE_DATA'] = mne_data_path\n" "os.environ['MNE_DATASETS_SAMPLE_PATH'] = mne_data_path\n" "\n" @@ -724,15 +737,13 @@ " 'BRAINSTORM']:\n" " mne.set_config(f'MNE_DATASETS_{ds}_PATH', mne_data_path)\n" "\n" - "# Patch mne.datasets.sample.data_path to return the pre-bundled\n" - "# extracted directory directly. Without this, pooch looks for the\n" - "# original .tar.gz archive and tries to download from OSF when\n" - "# missing — which our guard then blocks.\n" - "_drive_sample = os.path.join(mne_data_path, 'MNE-sample-data')\n" - "if os.path.isdir(_drive_sample):\n" - " def _lite_sample_data_path(*_a, **_kw):\n" - " return _drive_sample\n" - " mne.datasets.sample.data_path = _lite_sample_data_path\n" + "# Bypass pooch's archive check: data_path() normally looks for the\n" + "# .tar.gz archive, not just the extracted folder. Return the folder\n" + "# directly so pooch never tries to download from OSF.\n" + "_sample_path = _sample_dir\n" + "def _lite_sample_data_path(*_a, **_kw):\n" + " return _sample_path\n" + "mne.datasets.sample.data_path = _lite_sample_data_path\n" "\n" "# Switch matplotlib to inline so figures render in the notebook.\n" "import IPython\n" From 149b2673559fe75303ea920d0f965cf489f1c7cc Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 23:08:57 -0400 Subject: [PATCH 17/98] FIX: use js.location instead of js.window for worker-based Pyodide --- doc/conf.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 1a35619cf97..4e96d242a9c 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -667,10 +667,18 @@ "# (COOP/COEP) which many static servers (e.g. CircleCI artifacts)\n" "# do not send. Fetch data files directly from the JupyterLite\n" "# files/ path into /tmp/mne_data instead — same-origin, no CORS.\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 app\n" + "# base URL by splitting on '/lite/'.\n" "import pyodide.http as _phttp\n" "import js as _js\n" - "_page = str(_js.window.location.href)\n" - "_base = _page.rsplit('/lab/', 1)[0] + '/files/mne_data/'\n" + "try:\n" + " _page = str(_js.location.href)\n" + "except Exception:\n" + " _page = str(_js.window.location.href)\n" + "_base = _page.split('/lite/')[0] + '/lite/files/mne_data/'\n" + "print(f'[DBG] page url: {_page}')\n" + "print(f'[DBG] data base: {_base}')\n" "mne_data_path = '/tmp/mne_data'\n" "_sample_dir = mne_data_path + '/MNE-sample-data'\n" "_sample_files = [\n" From ccf2523c8873123cf2aa35fce1ff0a45585b5a7f Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 00:02:51 -0400 Subject: [PATCH 18/98] FIX: return Path from patched data_path so tutorials can use / operator --- doc/conf.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 4e96d242a9c..3561cb72708 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -747,8 +747,10 @@ "\n" "# Bypass pooch's archive check: data_path() normally looks for the\n" "# .tar.gz archive, not just the extracted folder. Return the folder\n" - "# directly so pooch never tries to download from OSF.\n" - "_sample_path = _sample_dir\n" + "# directly so pooch never tries to download from OSF. Return a Path\n" + "# (not a str) since tutorials use the / operator on the result.\n" + "from pathlib import Path as _Path\n" + "_sample_path = _Path(_sample_dir)\n" "def _lite_sample_data_path(*_a, **_kw):\n" " return _sample_path\n" "mne.datasets.sample.data_path = _lite_sample_data_path\n" From c04d8f3c82bd4663a65bae2dcb64632c3fdc1483 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 07:01:32 -0400 Subject: [PATCH 19/98] DBG: check HTTP status and detect HTML responses when fetching data --- doc/conf.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 3561cb72708..7393edad307 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -703,11 +703,18 @@ " _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' Date: Sun, 28 Jun 2026 07:45:18 -0400 Subject: [PATCH 20/98] FIX: serve JupyterLite sample data via html_extra_path so CI artifacts include it --- doc/conf.py | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 7393edad307..76e8b9af65b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -143,7 +143,13 @@ # 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", "jupyterlite_contents", "corrupt_*"] +exclude_patterns = [ + "_includes", + "changes/dev", + "jupyterlite_contents", + "lite_extra", + "corrupt_*", +] # The suffix of source filenames. source_suffix = ".rst" @@ -481,16 +487,19 @@ jupyterlite_contents = ["jupyterlite_contents"] jupyterlite_bind_ipynb_suffix = False -# Automatically inject the required subset of MNE-sample-data into JupyterLite. -# The destination directory is always created so /drive/mne_data is present in -# the Pyodide kernel's virtual filesystem even when no files have been copied. +# 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")) -dst_sample_data = ( +lite_extra_base = ( Path(os.path.abspath(os.path.dirname(__file__))) - / "jupyterlite_contents" + / "lite_extra" / "mne_data" - / "MNE-sample-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}") @@ -523,11 +532,7 @@ # Also inject SSVEP and EEGLAB testing datasets for JupyterLite mne_data_base = Path(os.path.expanduser("~/mne_data")) -lite_data_base = ( - Path(os.path.abspath(os.path.dirname(__file__))) - / "jupyterlite_contents" - / "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" @@ -665,18 +670,19 @@ "\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 data files directly from the JupyterLite\n" - "# files/ path into /tmp/mne_data instead — same-origin, no CORS.\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 app\n" - "# base URL by splitting on '/lite/'.\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] + '/lite/files/mne_data/'\n" + "_base = _page.split('/lite/')[0] + '/mne_data/'\n" "print(f'[DBG] page url: {_page}')\n" "print(f'[DBG] data base: {_base}')\n" "mne_data_path = '/tmp/mne_data'\n" @@ -1177,6 +1183,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. From 41e7c148532db22bf56e243882f8d0eda7e4dd5d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:45:37 +0000 Subject: [PATCH 21/98] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/conf.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 76e8b9af65b..194c0fe8ccc 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -495,9 +495,7 @@ # 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" + 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) From 8dd75075d4b9c0a25af6ec27f6fadac2ad9f9924 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 07:53:07 -0400 Subject: [PATCH 22/98] re-trigger CI From 64cab447aaf75bc0211c5c9d68cf0af09e0190ea Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 10:44:04 -0400 Subject: [PATCH 23/98] FIX: silence FigureCanvasAgg warning and clean up JupyterLite setup cell output --- doc/conf.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 194c0fe8ccc..df865ec4b42 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -681,8 +681,6 @@ "except Exception:\n" " _page = str(_js.window.location.href)\n" "_base = _page.split('/lite/')[0] + '/mne_data/'\n" - "print(f'[DBG] page url: {_page}')\n" - "print(f'[DBG] data base: {_base}')\n" "mne_data_path = '/tmp/mne_data'\n" "_sample_dir = mne_data_path + '/MNE-sample-data'\n" "_sample_files = [\n" @@ -712,15 +710,13 @@ " continue\n" " _d = await _r.bytes()\n" " if _d[:4] == b' Date: Sun, 28 Jun 2026 11:41:31 -0400 Subject: [PATCH 24/98] FIX: no-op Figure.show to silence FigureCanvasAgg warning at its source --- doc/conf.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index df865ec4b42..bb3df211532 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -766,13 +766,19 @@ "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" - "# Patch MNE's plt_show to display via IPython instead of calling\n" - "# fig.show(). On the inline backend get_backend() is not 'agg', so\n" - "# MNE's plt_show calls fig.show(), and the inline FigureCanvasAgg\n" - "# emits 'non-interactive, cannot be shown'. Displaying directly\n" - "# (and closing) renders the figure once with no warning.\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" From 059c1478a5305539ace24565bec51271c708a9fe Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 18:15:55 -0400 Subject: [PATCH 25/98] FIX: fetch sample_audvis_raw.fif in JupyterLite so unfiltered-raw tutorials work --- doc/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/conf.py b/doc/conf.py index bb3df211532..c8e289cc9aa 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -685,6 +685,7 @@ "_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-cov.fif',\n" " 'MEG/sample/sample_audvis-ave.fif',\n" From 6c6c93552e737c5f4b2a7977ca4afa0e36272caf Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 19:47:54 -0400 Subject: [PATCH 26/98] TEST: un-guard raw.plot() in 15_inplace to check if it renders in Pyodide --- tutorials/intro/15_inplace.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tutorials/intro/15_inplace.py b/tutorials/intro/15_inplace.py index c95d0214899..49cdd033b38 100644 --- a/tutorials/intro/15_inplace.py +++ b/tutorials/intro/15_inplace.py @@ -22,8 +22,6 @@ # %% -import sys - import mne sample_data_folder = mne.datasets.sample.data_path() @@ -85,12 +83,9 @@ # we specified ``copy=True``: # sphinx_gallery_thumbnail_number=2 -if sys.platform != "emscripten": - rereferenced_raw, ref_data = mne.set_eeg_reference( - original_raw, "EEG 003", copy=True - ) - fig_orig = original_raw.plot() - fig_reref = rereferenced_raw.plot() +rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, "EEG 003", copy=True) +fig_orig = original_raw.plot() +fig_reref = rereferenced_raw.plot() # %% # Another example is the picking function `mne.pick_info`, which operates on From f4486173e9bce177ba4b3448c9666d410c5c7f34 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 20:53:21 -0400 Subject: [PATCH 27/98] FIX: install dev MNE wheel via piplite, render reports inline, un-guard raw.plot --- doc/conf.py | 12 ++++++---- tutorials/intro/20_events_from_raw.py | 6 ++--- tutorials/intro/70_report.py | 34 +++++++++++++++++++++------ 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index c8e289cc9aa..bc2b6aa3aae 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -562,7 +562,7 @@ orig_pyproject = f.read() # Relax constraints for Pyodide which often lags behind PyPI. -# The wheel built here is served to the browser kernel; micropip's +# 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) @@ -606,11 +606,13 @@ "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 micropip\n" - "# keep_going=True lets micropip install even if Pyodide's bundled\n" + "import piplite\n" + "# Use piplite (not micropip) so the locally-built development MNE wheel\n" + "# served via piplite-wheels is preferred over the older PyPI 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" - "# MNE's runtime code only checks matplotlib >= 3.7/3.8, so 3.8.4 works.\n" - "await micropip.install(['mne', 'scikit-learn', 'joblib'], keep_going=True)\n" + "await piplite.install(['mne', 'scikit-learn', 'joblib'], keep_going=True)\n" "\n" "import sys\n" "import os\n" diff --git a/tutorials/intro/20_events_from_raw.py b/tutorials/intro/20_events_from_raw.py index 6d57ddf0898..80bad559ac9 100644 --- a/tutorials/intro/20_events_from_raw.py +++ b/tutorials/intro/20_events_from_raw.py @@ -96,8 +96,7 @@ # on newer systems it is more commonly ``STI101``. You can see the STIM # channels in the raw data file here: -if sys.platform != "emscripten": - raw.copy().pick(picks="stim").plot(start=3, duration=6) +raw.copy().pick(picks="stim").plot(start=3, duration=6) # %% # You can see that ``STI 014`` (the summation channel) contains pulses of @@ -272,8 +271,7 @@ # Now, the annotations will appear automatically when plotting the raw data, # and will be color-coded by their label value: -if sys.platform != "emscripten": - raw.plot(start=5, duration=5) +raw.plot(start=5, duration=5) # %% # .. _`chunk-duration`: diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 76bab0293e5..0a19378a381 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -39,14 +39,34 @@ sample_dir = data_path / "MEG" / "sample" subjects_dir = data_path / "subjects" -# In JupyterLite, file writing is not supported — skip report.save() calls. -# The report content still renders inline in Jupyter via add_* methods. +# 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 From 5b6033e8406856334b1b4af05abe57cca85c92d9 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 07:15:35 -0400 Subject: [PATCH 28/98] FIX: use mkdtemp instead of deprecated mktemp in report preview --- tutorials/intro/70_report.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 0a19378a381..346c5a9f8be 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -54,9 +54,9 @@ def _inline_report_save(self, fname=None, *args, **kwargs): from IPython.display import HTML, display try: - tmp = tempfile.mktemp(suffix=".html") - _orig_report_save(self, tmp, open_browser=False, overwrite=True) - doc = Path(tmp).read_text(encoding="utf-8") + tmp = Path(tempfile.mkdtemp()) / "report.html" + _orig_report_save(self, str(tmp), open_browser=False, overwrite=True) + doc = tmp.read_text(encoding="utf-8") display( HTML( f'' + f'
' ) ) except Exception as exc: From e33a2c5d87df3ce95256f187e800a95d85b0fa4e Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 14:38:48 -0400 Subject: [PATCH 30/98] FIX: avoid deprecated Pyodide as_object_map() in threadpoolctl during sys_info --- doc/conf.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 1f45457b872..3ac8672bb54 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -811,6 +811,26 @@ " 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" + "# still calls the deprecated Pyodide JsProxy.as_object_map(). Pyodide's\n" + "# own message says to use as_py_json() instead; both yield the same\n" + "# library filepaths, so we swap the call at its source. This removes the\n" + "# deprecated API usage entirely, so the warning is never emitted.\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), From 0e07c6e93853f8021de2e34ddc885f27368309d0 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 16:09:39 -0400 Subject: [PATCH 31/98] FIX: install pandas for to_data_frame tutorials; note threadpoolctl patch can be dropped at 3.7.0 --- doc/conf.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 3ac8672bb54..26ab3513694 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -633,7 +633,9 @@ "# 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(['mne', 'scikit-learn', 'joblib'], keep_going=True)\n" + "await piplite.install(\n" + " ['mne', 'scikit-learn', 'joblib', 'pandas'], keep_going=True\n" + ")\n" "\n" "import sys\n" "import os\n" @@ -813,11 +815,14 @@ "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" - "# still calls the deprecated Pyodide JsProxy.as_object_map(). Pyodide's\n" - "# own message says to use as_py_json() instead; both yield the same\n" - "# library filepaths, so we swap the call at its source. This removes the\n" - "# deprecated API usage entirely, so the warning is never emitted.\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" From 54d8451f325b25c4aee65da4dfb3c69ceca19bb1 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 17:04:06 -0400 Subject: [PATCH 32/98] FIX: bundle ecg-proj and filt eve files for epochs tutorials 20 and 50 --- doc/conf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 26ab3513694..0b2e1323564 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -508,6 +508,8 @@ "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", @@ -712,6 +714,8 @@ " '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" From 55e140d7d3b55f5195a25a988b61b10fcbc4f713 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 18:25:38 -0400 Subject: [PATCH 33/98] ENH: bundle kiloword and erp_core data so epochs tutorials 30 and 40 run in JupyterLite --- .circleci/config.yml | 16 ++++++------ doc/conf.py | 58 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 536cbae2841..c636b3f55db 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -249,16 +249,18 @@ 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 MNE sample dataset is on disk so conf.py can copy the - # required subset into jupyterlite_contents/ for the JupyterLite build. - # circleci_download.sh only fetches sample data when the changed files - # reference it, so a cache miss on a PR that touches only doc/conf.py - # would leave ~/mne_data/MNE-sample-data absent and the notebooks would - # fail at runtime with FileNotFoundError on /drive/mne_data. + # 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). - run: - name: Ensure MNE sample data for JupyterLite + 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)" # Build docs - run: name: make html diff --git a/doc/conf.py b/doc/conf.py index 0b2e1323564..9562b195251 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -550,6 +550,28 @@ shutil.copytree(src_eeglab, dst_eeglab, dirs_exist_ok=True) print("[JupyterLite] Copied MNE-testing-data/EEGLAB") +# Inject the kiloword and erp_core datasets needed by the Epochs tutorials +# (30_epochs_metadata and 40_autogenerate_metadata). Each tutorial uses a +# single file; their sizes (28.7 MB, 123.6 MB) are within what we already +# serve (sample_audvis_raw.fif is 128.5 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"]), +): + _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 @@ -636,7 +658,9 @@ "# 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'], keep_going=True\n" + " ['mne', 'scikit-learn', 'joblib', 'pandas', 'seaborn', " + "'mne-connectivity'],\n" + " keep_going=True,\n" ")\n" "\n" "import sys\n" @@ -791,6 +815,38 @@ "def _lite_sample_data_path(*_a, **_kw):\n" " return _sample_path\n" "mne.datasets.sample.data_path = _lite_sample_data_path\n" + "# kiloword + erp_core (Epochs tutorials 30 & 40) are large and used by\n" + "# only those two notebooks, so fetch them LAZILY — only when data_path()\n" + "# is actually called — to avoid taxing every other notebook's setup.\n" + "# Pyodide runs in a web worker here, where a synchronous XHR may set\n" + "# responseType='arraybuffer', letting a sync data_path() read binary.\n" + "def _lite_lazy_fetch(_folder, _fname):\n" + " _dst = mne_data_path + '/' + _folder + '/' + _fname\n" + " if not os.path.exists(_dst):\n" + " from js import XMLHttpRequest\n" + " _xhr = XMLHttpRequest.new()\n" + " _xhr.open('GET', _base + _folder + '/' + _fname, False)\n" + " _xhr.responseType = 'arraybuffer'\n" + " _xhr.send()\n" + " if _xhr.status != 200:\n" + " raise FileNotFoundError(\n" + " f'Could not fetch {_folder}/{_fname} " + "(HTTP {_xhr.status})'\n" + " )\n" + " os.makedirs(os.path.dirname(_dst), exist_ok=True)\n" + " with open(_dst, 'wb') as _fh:\n" + " _fh.write(bytes(_xhr.response.to_py()))\n" + " return _Path(mne_data_path + '/' + _folder)\n" + "def _lite_kiloword_data_path(*_a, **_kw):\n" + " return _lite_lazy_fetch(" + "'MNE-kiloword-data', 'kword_metadata-epo.fif')\n" + "mne.datasets.kiloword.data_path = _lite_kiloword_data_path\n" + "def _lite_erp_core_data_path(*_a, **_kw):\n" + " return _lite_lazy_fetch(\n" + " 'MNE-ERP-CORE-data', " + "'ERP-CORE_Subject-001_Task-Flankers_eeg.fif'\n" + " )\n" + "mne.datasets.erp_core.data_path = _lite_erp_core_data_path\n" "\n" "# Switch matplotlib to inline so figures render in the notebook.\n" "import IPython\n" From 8ed2c814fe13337822f83073193c692c51f75f60 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 18:54:26 -0400 Subject: [PATCH 34/98] FIX: no-op MNE ProgressBar thread so permutation cluster tests run in JupyterLite --- doc/conf.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 9562b195251..deca2121430 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -848,6 +848,19 @@ " )\n" "mne.datasets.erp_core.data_path = _lite_erp_core_data_path\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" + "\n" "# Switch matplotlib to inline so figures render in the notebook.\n" "import IPython\n" "IPython.get_ipython().run_line_magic('matplotlib', 'inline')\n" From 64338b6dda57bfc111aaa11641995ca78de208c6 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 23:50:30 -0400 Subject: [PATCH 35/98] FIX: disable tqdm monitor thread to suppress TqdmMonitorWarning in JupyterLite --- doc/conf.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index deca2121430..f335bf2e823 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -860,6 +860,14 @@ " _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" From 034d722825d98696c53d45127fc45e3f1b8222b1 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 30 Jun 2026 00:05:36 -0400 Subject: [PATCH 36/98] ENH: bundle mtrf and eegbci data so decoding examples run in JupyterLite --- .circleci/config.yml | 5 +++- doc/conf.py | 60 +++++++++++++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c636b3f55db..f6ed1ad4768 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -254,13 +254,16 @@ jobs: # 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). + # 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/doc/conf.py b/doc/conf.py index f335bf2e823..99d695b9c8e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -550,14 +550,23 @@ shutil.copytree(src_eeglab, dst_eeglab, dirs_exist_ok=True) print("[JupyterLite] Copied MNE-testing-data/EEGLAB") -# Inject the kiloword and erp_core datasets needed by the Epochs tutorials -# (30_epochs_metadata and 40_autogenerate_metadata). Each tutorial uses a -# single file; their sizes (28.7 MB, 123.6 MB) are within what we already -# serve (sample_audvis_raw.fif is 128.5 MB). The CI "Ensure ... data" step +# 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 @@ -815,27 +824,31 @@ "def _lite_sample_data_path(*_a, **_kw):\n" " return _sample_path\n" "mne.datasets.sample.data_path = _lite_sample_data_path\n" - "# kiloword + erp_core (Epochs tutorials 30 & 40) are large and used by\n" - "# only those two notebooks, so fetch them LAZILY — only when data_path()\n" - "# is actually called — to avoid taxing every other notebook's setup.\n" - "# Pyodide runs in a web worker here, where a synchronous XHR may set\n" - "# responseType='arraybuffer', letting a sync data_path() read binary.\n" - "def _lite_lazy_fetch(_folder, _fname):\n" - " _dst = mne_data_path + '/' + _folder + '/' + _fname\n" + "# Several non-sample datasets are each used by only a couple of\n" + "# notebooks (kiloword/erp_core for Epochs 30 & 40; mtrf/eegbci for the\n" + "# decoding examples), so fetch them LAZILY — only when their\n" + "# data_path()/load_data() is called — to avoid taxing every other\n" + "# notebook's setup. Pyodide runs in a web worker here, where a\n" + "# synchronous XHR may set responseType='arraybuffer', letting a sync\n" + "# data_path() read binary.\n" + "def _lite_fetch_rel(_rel):\n" + " _dst = mne_data_path + '/' + _rel\n" " if not os.path.exists(_dst):\n" " from js import XMLHttpRequest\n" " _xhr = XMLHttpRequest.new()\n" - " _xhr.open('GET', _base + _folder + '/' + _fname, False)\n" + " _xhr.open('GET', _base + _rel, False)\n" " _xhr.responseType = 'arraybuffer'\n" " _xhr.send()\n" " if _xhr.status != 200:\n" " raise FileNotFoundError(\n" - " f'Could not fetch {_folder}/{_fname} " - "(HTTP {_xhr.status})'\n" + " f'Could not fetch {_rel} (HTTP {_xhr.status})'\n" " )\n" " os.makedirs(os.path.dirname(_dst), exist_ok=True)\n" " with open(_dst, 'wb') as _fh:\n" " _fh.write(bytes(_xhr.response.to_py()))\n" + " return _dst\n" + "def _lite_lazy_fetch(_folder, _fname):\n" + " _lite_fetch_rel(_folder + '/' + _fname)\n" " return _Path(mne_data_path + '/' + _folder)\n" "def _lite_kiloword_data_path(*_a, **_kw):\n" " return _lite_lazy_fetch(" @@ -847,6 +860,25 @@ "'ERP-CORE_Subject-001_Task-Flankers_eeg.fif'\n" " )\n" "mne.datasets.erp_core.data_path = _lite_erp_core_data_path\n" + "def _lite_mtrf_data_path(*_a, **_kw):\n" + " return _lite_lazy_fetch('mTRF_1.5', 'speech_data.mat')\n" + "mne.datasets.mtrf.data_path = _lite_mtrf_data_path\n" + "def _lite_eegbci_load_data(subject, runs, *_a, **_kw):\n" + " _runs = [runs] if isinstance(runs, (int, float)) else list(runs)\n" + " _subjects = (\n" + " list(subject) if isinstance(subject, (list, tuple))\n" + " else [subject]\n" + " )\n" + " _out = []\n" + " for _s in _subjects:\n" + " for _r in _runs:\n" + " _rel = (\n" + " 'MNE-eegbci-data/files/eegmmidb/1.0.0/'\n" + " f'S{int(_s):03d}/S{int(_s):03d}R{int(_r):02d}.edf'\n" + " )\n" + " _out.append(_Path(_lite_fetch_rel(_rel)))\n" + " return _out\n" + "mne.datasets.eegbci.load_data = _lite_eegbci_load_data\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" From e0755e05542fa695cb5f6a49739adcdf2e57ad64 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 30 Jun 2026 15:30:31 -0400 Subject: [PATCH 37/98] DOC: explain JupyterLite sys.platform guards across tutorials and examples Add a short comment above each `sys.platform == "emscripten"` guard so reviewers understand why interactive/3D code is skipped in the browser build, without needing to dig through PR history. --- examples/preprocessing/muscle_ica.py | 6 ++++++ examples/visualization/eyetracking_plot_heatmap.py | 1 + tutorials/evoked/40_whitened.py | 2 ++ tutorials/intro/10_overview.py | 4 ++++ tutorials/intro/20_events_from_raw.py | 4 ++++ tutorials/intro/40_sensor_locations.py | 3 +++ tutorials/intro/50_configure_mne.py | 3 +++ tutorials/intro/70_report.py | 10 ++++++++++ tutorials/io/60_ctf_bst_auditory.py | 1 + tutorials/io/70_reading_eyetracking_data.py | 1 + 10 files changed, 35 insertions(+) diff --git a/examples/preprocessing/muscle_ica.py b/examples/preprocessing/muscle_ica.py index 7b2cc05acd3..3daae2f0796 100644 --- a/examples/preprocessing/muscle_ica.py +++ b/examples/preprocessing/muscle_ica.py @@ -46,6 +46,7 @@ # %% # Remove components with postural muscle artifact using ICA +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_sources(raw) @@ -74,6 +75,7 @@ # 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] +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_properties(raw, picks=muscle_idx, log_scale=True) @@ -81,6 +83,7 @@ blink_idx = [0] heartbeat_idx = [5] ica.apply(raw, exclude=blink_idx + heartbeat_idx) +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_overlay(raw, exclude=muscle_idx) @@ -88,6 +91,7 @@ # 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) +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_scores(scores, exclude=muscle_idx_auto) print( @@ -113,9 +117,11 @@ n_components=15, method="picard", max_iter="auto", random_state=97 ) ica.fit(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) + # 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) diff --git a/examples/visualization/eyetracking_plot_heatmap.py b/examples/visualization/eyetracking_plot_heatmap.py index e0104aa4af2..3be8bfd6580 100644 --- a/examples/visualization/eyetracking_plot_heatmap.py +++ b/examples/visualization/eyetracking_plot_heatmap.py @@ -33,6 +33,7 @@ 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, " diff --git a/tutorials/evoked/40_whitened.py b/tutorials/evoked/40_whitened.py index 06abc4102e1..639bccfaa2e 100644 --- a/tutorials/evoked/40_whitened.py +++ b/tutorials/evoked/40_whitened.py @@ -54,6 +54,7 @@ ) # butterfly mode shows the differences most clearly +# 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) @@ -61,6 +62,7 @@ # %% # Epochs with whitening # --------------------- +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": epochs.plot(events=True) epochs.plot(noise_cov=noise_cov, events=True) diff --git a/tutorials/intro/10_overview.py b/tutorials/intro/10_overview.py index 2c5fa04b362..0b621906744 100644 --- a/tutorials/intro/10_overview.py +++ b/tutorials/intro/10_overview.py @@ -83,6 +83,7 @@ raw.compute_psd(fmax=50).plot(picks="data", exclude="bads", amplitude=False) +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": raw.plot(duration=5, n_channels=30) @@ -103,6 +104,7 @@ 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 +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_properties(raw, picks=ica.exclude) @@ -143,6 +145,7 @@ "EEG 008", ] chan_idxs = [raw.ch_names.index(ch) for ch in chs] +# 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) @@ -404,6 +407,7 @@ # path to subjects' MRI files subjects_dir = sample_data_folder / "subjects" # plot the STC +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": stc.plot( initial_time=0.1, hemi="split", views=["lat", "med"], subjects_dir=subjects_dir diff --git a/tutorials/intro/20_events_from_raw.py b/tutorials/intro/20_events_from_raw.py index 80bad559ac9..c2fb5948bd1 100644 --- a/tutorials/intro/20_events_from_raw.py +++ b/tutorials/intro/20_events_from_raw.py @@ -165,6 +165,7 @@ # stored events into an `~mne.Annotations` object and store it as the # :attr:`~mne.io.Raw.annotations` attribute of the `~mne.io.Raw` object: +# 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" @@ -179,6 +180,7 @@ # different types of events, and the first event occurred about 1 second after # the recording began: +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": print(len(eeglab_raw.annotations)) print(set(eeglab_raw.annotations.duration)) @@ -214,6 +216,7 @@ # :ref:`fixed-length-events` for direct creation of an Events array of # equally-spaced events). +# 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) @@ -229,6 +232,7 @@ # `~mne.io.Raw` objects, as demonstrated in the tutorial # :ref:`tut-epochs-class`. +# 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( diff --git a/tutorials/intro/40_sensor_locations.py b/tutorials/intro/40_sensor_locations.py index 216276bc1c6..5c801135c75 100644 --- a/tutorials/intro/40_sensor_locations.py +++ b/tutorials/intro/40_sensor_locations.py @@ -92,6 +92,7 @@ # 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. +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ssvep_folder = mne.datasets.ssvep.data_path() ssvep_data_raw_path = ( @@ -135,6 +136,7 @@ # If you prefer to draw the head circle using 10–20 conventions (which are also used by # EEGLAB), you can pass ``sphere='eeglab'``: +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": fig = ssvep_raw.plot_sensors(show_names=True, sphere="eeglab") @@ -221,6 +223,7 @@ # 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`: +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": fig = mne.viz.plot_alignment( sample_raw.info, diff --git a/tutorials/intro/50_configure_mne.py b/tutorials/intro/50_configure_mne.py index ba5643fb638..22b7f8641de 100644 --- a/tutorials/intro/50_configure_mne.py +++ b/tutorials/intro/50_configure_mne.py @@ -197,6 +197,7 @@ # set. First, with log level ``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__)), @@ -213,6 +214,7 @@ # "warning" or worse. Next, we'll load the same file with log level ``info`` # (the default level): +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": raw = mne.io.read_raw_kit(kit_data_path, verbose="info") @@ -224,6 +226,7 @@ # manager, which is another way to accomplish the same thing as passing # ``verbose='debug'``: +# 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) diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 373c794485b..7153909b301 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -44,6 +44,7 @@ # 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 '\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" @@ -1790,6 +1868,12 @@ def _lite_copy_tree(folder, rel_dir): "tutorials/inverse/70_eeg_mri_coords.py", # mne_bids is not installable in the browser kernel "tutorials/inverse/95_phantom_KIT.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 diff --git a/examples/visualization/eyetracking_plot_heatmap.py b/examples/visualization/eyetracking_plot_heatmap.py index 3be8bfd6580..8d656708a12 100644 --- a/examples/visualization/eyetracking_plot_heatmap.py +++ b/examples/visualization/eyetracking_plot_heatmap.py @@ -26,21 +26,11 @@ # :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/tutorials/intro/10_overview.py b/tutorials/intro/10_overview.py index 540fbba7b3b..d9fd1cef8d2 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,7 +81,6 @@ # 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) @@ -401,14 +400,9 @@ # path to subjects' MRI files subjects_dir = sample_data_folder / "subjects" # plot the STC -# 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) +stc.plot( + initial_time=0.1, hemi="split", views=["lat", "med"], subjects_dir=subjects_dir +) ############################################################################## # We can also display multiple conditions on the same brain. Here we compare diff --git a/tutorials/intro/20_events_from_raw.py b/tutorials/intro/20_events_from_raw.py index c2fb5948bd1..2c368646908 100644 --- a/tutorials/intro/20_events_from_raw.py +++ b/tutorials/intro/20_events_from_raw.py @@ -32,8 +32,6 @@ # %% -import sys - import numpy as np import mne @@ -165,12 +163,10 @@ # stored events into an `~mne.Annotations` object and store it as the # :attr:`~mne.io.Raw.annotations` attribute of the `~mne.io.Raw` object: -# 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) +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 @@ -180,12 +176,10 @@ # different types of events, and the first event occurred about 1 second after # the recording began: -# 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]) +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 @@ -216,11 +210,9 @@ # :ref:`fixed-length-events` for direct creation of an Events array of # equally-spaced events). -# 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]) +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 @@ -232,14 +224,12 @@ # `~mne.io.Raw` objects, as demonstrated in the tutorial # :ref:`tut-epochs-class`. -# 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]) +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 5c801135c75..6046e252f47 100644 --- a/tutorials/intro/40_sensor_locations.py +++ b/tutorials/intro/40_sensor_locations.py @@ -15,7 +15,6 @@ # %% -import sys from pathlib import Path import matplotlib.pyplot as plt @@ -92,21 +91,19 @@ # 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. -# 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) +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:: @@ -136,9 +133,7 @@ # If you prefer to draw the head circle using 10–20 conventions (which are also used by # EEGLAB), you can pass ``sphere='eeglab'``: -# Skipped in JupyterLite (browser): no interactive/3D rendering. -if sys.platform != "emscripten": - fig = ssvep_raw.plot_sensors(show_names=True, sphere="eeglab") +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 @@ -223,17 +218,15 @@ # 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`: -# 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) +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 23d8004ee08..17c94626e30 100644 --- a/tutorials/intro/50_configure_mne.py +++ b/tutorials/intro/50_configure_mne.py @@ -18,7 +18,6 @@ # %% import os -import sys import pandas as pd @@ -224,26 +223,22 @@ # set. First, with log level ``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") +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): -# Skipped in JupyterLite (browser): no interactive/3D rendering. -if sys.platform != "emscripten": - raw = mne.io.read_raw_kit(kit_data_path, verbose="info") +raw = mne.io.read_raw_kit(kit_data_path, verbose="info") # %% # This time, we got a few messages about extracting information from the file, @@ -253,10 +248,8 @@ # manager, which is another way to accomplish the same thing as passing # ``verbose='debug'``: -# 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) +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 f67013ebbc3..918634825bd 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -25,7 +25,6 @@ # %% -import sys import tempfile from pathlib import Path @@ -39,39 +38,6 @@ 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 @@ -115,14 +81,12 @@ def _inline_report_save(self, fname=None, *args, **kwargs): # 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") -# 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_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) @@ -144,13 +108,9 @@ def _inline_report_save(self, fname=None, *args, **kwargs): "buttonpress": 32, } -# 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 +metadata, _, _ = mne.epochs.make_metadata( + events=events, event_id=event_id, tmin=-0.2, tmax=0.5, sfreq=raw.info["sfreq"] +) epochs = mne.Epochs(raw=raw, events=events, event_id=event_id, metadata=metadata) report = mne.Report(title="Epochs example") @@ -219,34 +179,30 @@ def _inline_report_save(self, fname=None, *args, **kwargs): # 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") -# 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" - # 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), - on_outside="ignore", - ).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 +# 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), + on_outside="ignore", +).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 +) report.save("report_projs.html", overwrite=True) +del raw_full, events, ecg_evoked # %% # Adding `~mne.preprocessing.ICA` @@ -328,17 +284,15 @@ def _inline_report_save(self, fname=None, *args, **kwargs): # every n-th volume slice, and ``width`` to specify the width of the resulting # figures in pixels. -# 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) +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 @@ -351,20 +305,18 @@ def _inline_report_save(self, fname=None, *args, **kwargs): # 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. -# 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) +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 @@ -374,15 +326,13 @@ def _inline_report_save(self, fname=None, *args, **kwargs): # object or the path to a forward solution stored on disk to # :meth:`mne.Report.add_forward`. -# Skipped in JupyterLite (browser): no interactive/3D rendering. -if sys.platform != "emscripten": - fwd_path = sample_dir / "sample_audvis-meg-oct-6-fwd.fif" +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` @@ -392,18 +342,16 @@ def _inline_report_save(self, fname=None, *args, **kwargs): # The method expects an `~mne.minimum_norm.InverseOperator` object or a path to # one stored on disk, and a title. -# 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" +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` @@ -416,19 +364,17 @@ def _inline_report_save(self, fname=None, *args, **kwargs): # 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. -# 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) +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) @@ -588,32 +534,26 @@ def _inline_report_save(self, fname=None, *args, **kwargs): # 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: -# 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) +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. -# 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) +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)`): -# 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 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 @@ -699,13 +639,11 @@ def _inline_report_save(self, fname=None, *args, **kwargs): # expensive, we'll also pass the ``mri_decim`` parameter for the benefit of our # documentation servers, and skip processing the :file:`.fif` files. -# 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) +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 cf0ea0914e4..4c3249996aa 100644 --- a/tutorials/io/60_ctf_bst_auditory.py +++ b/tutorials/io/60_ctf_bst_auditory.py @@ -26,8 +26,6 @@ # %% -import sys - import numpy as np import pandas as pd @@ -37,14 +35,6 @@ 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 7eaea5f4eec..15c58bd940c 100644 --- a/tutorials/io/70_reading_eyetracking_data.py +++ b/tutorials/io/70_reading_eyetracking_data.py @@ -85,18 +85,8 @@ """ # %% -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 dd583855df0..485714e2c17 100644 --- a/tutorials/simulation/70_point_spread.py +++ b/tutorials/simulation/70_point_spread.py @@ -151,8 +151,6 @@ 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 25ee4802ea5..a487db59752 100644 --- a/tutorials/simulation/80_dics.py +++ b/tutorials/simulation/80_dics.py @@ -239,8 +239,6 @@ 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, @@ -320,8 +318,6 @@ 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", From 515a1b938a686e00bbc0311417de330aea7aaa95 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 28 Jul 2026 15:37:29 -0400 Subject: [PATCH 75/98] CI: full build so the JupyterLite data bundle is complete [circle full] From 15da83709071be32f1221311eb15471af1567b45 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 28 Jul 2026 16:49:10 -0400 Subject: [PATCH 76/98] MAINT: hide the launch badge on two oversized recordings The full build reports 379 MB and 251 MB for these, so the copy step skips them and the badge would have had nothing to load. --- doc/conf.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index edfc09b2a03..abbf87780af 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1868,6 +1868,11 @@ def _lite_copy_tree(folder, rel_dir): "tutorials/inverse/70_eeg_mri_coords.py", # mne_bids is not installable in the browser kernel "tutorials/inverse/95_phantom_KIT.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 From 4bd5a7c80cd3483d47aacdaa280883935b84419a Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 28 Jul 2026 19:30:11 -0400 Subject: [PATCH 77/98] MAINT: stop serving somato and hide the six pages that read it Its raw alone is 344 MB, 404 MB with the forward and surfaces, which is more than those six pages are worth on every docs deploy. --- doc/conf.py | 50 +++++++++++++++++--------------------------------- 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index abbf87780af..410801b19d1 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -739,28 +739,8 @@ def _lite_copy_tree(folder, rel_dir): _lite_copy("MNE-multimodal-data", ["multimodal_raw.fif"]) _lite_copy("MNE-refmeg-noise-data", ["sample_reference_MEG_noise-raw.fif"]) -# Six notebooks use the somato dataset (the DICS/TF-MxNE examples and the -# time-frequency tutorial). The full dataset is ~610 MB, but they only read one -# raw, one forward solution and the FreeSurfer surfaces needed to draw the -# source estimate, so copy just those. CircleCI already restores somato from -# data-cache-somato for the native build, so nothing extra is downloaded. -somato_files = [ - "sub-01/meg/sub-01_task-somato_meg.fif", - "derivatives/sub-01/sub-01_task-somato-fwd.fif", - "derivatives/freesurfer/subjects/01/surf/lh.inflated", - "derivatives/freesurfer/subjects/01/surf/rh.inflated", - "derivatives/freesurfer/subjects/01/surf/lh.curv", - "derivatives/freesurfer/subjects/01/surf/rh.curv", -] -for somato_file in somato_files: - s = _lite_src("MNE-somato-data", somato_file) - d = lite_data_base / "MNE-somato-data" / somato_file - if s is not None and not d.exists(): - d.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(s, d) - print(f"[JupyterLite] Copied {somato_file} ({s.stat().st_size / 1e6:.1f} MB)") - elif s is None: - print(f"[JupyterLite] MISSING {somato_file}") +# 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 @@ -1024,15 +1004,9 @@ def _lite_copy_tree(folder, rel_dir): "def _lite_mtrf_data_path(*_a, **_kw):\n" " return _lite_lazy_fetch('mTRF_1.5', 'speech_data.mat')\n" "mne.datasets.mtrf.data_path = _lite_mtrf_data_path\n" - "# somato just returns the folder: the notebooks reach its raw and\n" - "# forward files through read_raw_fif/read_forward_solution, which are\n" - "# already shimmed below to fetch anything under mne_data on first read.\n" - "def _lite_somato_data_path(*_a, **_kw):\n" - " return _Path(mne_data_path + '/MNE-somato-data')\n" - "mne.datasets.somato.data_path = _lite_somato_data_path\n" - "# testing follows somato: hand back the folder and let the shimmed\n" - "# readers pull individual files, so a notebook that wants the EEGLAB\n" - "# recording does not also drag down the 39 MB movement raw.\n" + "# testing hands back the folder and lets the shimmed readers pull\n" + "# individual files, so a notebook that wants the EEGLAB recording does\n" + "# not also drag down the 39 MB movement raw.\n" "def _lite_testing_data_path(*_a, **_kw):\n" " return _Path(mne_data_path + '/MNE-testing-data')\n" "mne.datasets.testing.data_path = _lite_testing_data_path\n" @@ -1370,8 +1344,8 @@ def _lite_copy_tree(folder, rel_dir): " _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 instead of assuming sample --\n" - " # somato keeps its FreeSurfer subjects under its own folder.\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" @@ -1868,6 +1842,16 @@ def _lite_copy_tree(folder, rel_dir): "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. From 47ff070eee3a11f03930cc0b968e88a6fc160f10 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 29 Jul 2026 10:00:48 -0400 Subject: [PATCH 78/98] CI: full build for the browser verification pass [circle full] From f81f53b556da71bef94cff76b8e64aaf82cc47e9 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 29 Jul 2026 12:03:49 -0400 Subject: [PATCH 79/98] FIX: reach the files and packages the browser notebooks actually need Most MNE readers validate a path through _check_fname before opening it, so hooking that covers read_info, read_evokeds, read_cov and the rest at once; read_label, read_epochs and read_raw_edf open directly and are wrapped individually. Also serves talairach.xfm, the auditory/visual labels and the EEGBCI runs CI provides, and installs mffpy and python-picard. --- doc/conf.py | 119 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 82 insertions(+), 37 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 410801b19d1..cd50aa6646d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -561,6 +561,9 @@ def _lite_src(folder, rel): "SSS/ct_sparse_mgh.fif", "subjects/sample/mri/T1.mgz", "subjects/sample/mri/aseg.mgz", + # read_talxfm builds this path itself, so nothing in the tutorials + # names it; plot_alignment needs it to estimate MRI fiducials + "subjects/sample/mri/transforms/talairach.xfm", "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 @@ -589,6 +592,12 @@ def _lite_src(folder, rel): "subjects/sample/surf/lh.curv", "subjects/sample/label/lh.aparc.annot", "subjects/sample/label/rh.aparc.annot", + # the auditory/visual ROIs; about nine notebooks build these names with + # an f-string, so a scan of the tutorial text never sees them + "MEG/sample/labels/Aud-lh.label", + "MEG/sample/labels/Aud-rh.label", + "MEG/sample/labels/Vis-lh.label", + "MEG/sample/labels/Vis-rh.label", ] for req in required_files: s = _lite_src("MNE-sample-data", req) @@ -677,6 +686,10 @@ def _lite_copy_tree(folder, rel_dir): for f in sorted(src.rglob("*")): if not f.is_file(): continue + # zero-byte members (an .mff carries a couple of lock files) do not + # survive the artifact upload, so listing them only yields a 404 + if f.stat().st_size == 0: + 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)") @@ -753,10 +766,17 @@ def _lite_copy_tree(folder, rel_dir): ("mTRF_1.5", ["speech_data.mat"]), ( "MNE-eegbci-data", + # exactly the runs tools/circleci_download.sh fetches: subject 1 runs + # 3/6/10/14 and run 3 for subjects 2-4. Notebooks wanting run 1 or 2 are + # excluded instead, since that data never reaches the CI box. [ + "files/eegmmidb/1.0.0/S001/S001R03.edf", "files/eegmmidb/1.0.0/S001/S001R06.edf", "files/eegmmidb/1.0.0/S001/S001R10.edf", "files/eegmmidb/1.0.0/S001/S001R14.edf", + "files/eegmmidb/1.0.0/S002/S002R03.edf", + "files/eegmmidb/1.0.0/S003/S003R03.edf", + "files/eegmmidb/1.0.0/S004/S004R03.edf", ], ), ): @@ -799,7 +819,8 @@ def _lite_copy_tree(folder, rel_dir): "# 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" + "'mne-connectivity', 'nibabel', 'pyvista-js', 'pyxdf', 'mffpy', " + "'python-picard'],\n" " keep_going=True,\n" ")\n" "\n" @@ -1090,6 +1111,48 @@ def _lite_copy_tree(folder, rel_dir): " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" " )\n" "mne.read_source_spaces = _lite_read_source_spaces\n" + "# Nearly every MNE reader validates its filename through\n" + "# _check_fname(must_exist=True) before opening it, so hooking that one\n" + "# function covers read_info, read_evokeds, read_cov, read_label and the\n" + "# rest without a wrapper each. Failures stay silent here so MNE still\n" + "# raises its own, clearer error for a file that genuinely is missing.\n" + "import mne.utils.check as _mne_check\n" + "_orig_check_fname = _mne_check._check_fname\n" + "def _lite_check_fname(fname, overwrite=False, must_exist=False,\n" + " *_a, **_kw):\n" + " if must_exist:\n" + " try:\n" + " _lite_fetch_if_under_mne_data(fname)\n" + " except Exception:\n" + " pass\n" + " return _orig_check_fname(fname, overwrite, must_exist, *_a, **_kw)\n" + "_mne_check._check_fname = _lite_check_fname\n" + "# modules that imported it before now hold their own reference; ones\n" + "# loaded later (mne lazy-loads most of itself) pick up the patch\n" + "for _m in list(sys.modules.values()):\n" + " if (getattr(_m, '__name__', '').startswith('mne')\n" + " and getattr(_m, '_check_fname', None) is _orig_check_fname):\n" + " _m._check_fname = _lite_check_fname\n" + "# read_label, read_epochs and read_raw_edf open their file directly\n" + "# rather than validating it first, so the hook above never sees them\n" + "_orig_read_label = mne.read_label\n" + "def _lite_read_label(filename, *_a, **_kw):\n" + " return _orig_read_label(\n" + " _lite_fetch_if_under_mne_data(filename), *_a, **_kw\n" + " )\n" + "mne.read_label = _lite_read_label\n" + "_orig_read_epochs = mne.read_epochs\n" + "def _lite_read_epochs(fname, *_a, **_kw):\n" + " return _orig_read_epochs(\n" + " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" + " )\n" + "mne.read_epochs = _lite_read_epochs\n" + "_orig_read_raw_edf = mne.io.read_raw_edf\n" + "def _lite_read_raw_edf(input_fname, *_a, **_kw):\n" + " return _orig_read_raw_edf(\n" + " _lite_fetch_if_under_mne_data(input_fname), *_a, **_kw\n" + " )\n" + "mne.io.read_raw_edf = _lite_read_raw_edf\n" "_orig_read_bem_solution = mne.read_bem_solution\n" "def _lite_read_bem_solution(fname, *_a, **_kw):\n" " return _orig_read_bem_solution(\n" @@ -1121,7 +1184,12 @@ def _lite_copy_tree(folder, rel_dir): " with open(_manifest) as _fh:\n" " _names = [_n.strip() for _n in _fh if _n.strip()]\n" " for _name in _names:\n" - " _lite_fetch_rel(_rel + '/' + _name)\n" + " # one unreachable member must not abandon the rest of the\n" + " # recording; the reader complains if it needed that file\n" + " try:\n" + " _lite_fetch_rel(_rel + '/' + _name)\n" + " except Exception as _e:\n" + " print('[JupyterLite] skipped ' + _name + ': ' + repr(_e))\n" " return mne_data_path + '/' + _rel\n" "def _lite_dir_reader(_orig):\n" " def _read(fname, *_a, **_kw):\n" @@ -1136,36 +1204,6 @@ def _lite_copy_tree(folder, rel_dir): " return _read\n" "mne.io.read_raw_nirx = _lite_dir_reader(mne.io.read_raw_nirx)\n" "mne.io.read_raw_egi = _lite_dir_reader(mne.io.read_raw_egi)\n" - "# mne.Report writes an HTML file and opens a browser, neither of which\n" - "# means anything here. Writes to a temp dir do work in Pyodide's\n" - "# in-memory filesystem, so save there, read the HTML back and show it\n" - "# in an isolated iframe -- the report's own CSS/JS then cannot clash\n" - "# with the notebook page.\n" - "_orig_report_save = mne.Report.save\n" - "def _lite_report_save(self, fname=None, *_a, **_kw):\n" - " # only the HTML form is previewed; an .hdf5 round-trip still has to\n" - " # write the file the caller asked for\n" - " if fname is not None and str(fname).lower().endswith(('.h5', '.hdf5')):\n" - " return _orig_report_save(self, fname, *_a, **_kw)\n" - " try:\n" - " import html as _htmllib\n" - " import tempfile\n" - " from IPython.display import HTML as _HTML, display as _display\n" - " _tmp = os.path.join(tempfile.mkdtemp(), 'report.html')\n" - " _orig_report_save(self, _tmp, open_browser=False, overwrite=True)\n" - " with open(_tmp, encoding='utf-8') as _fh:\n" - " _doc = _fh.read()\n" - " # wrap in a div so the payload does not start with ''\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" @@ -1857,12 +1895,19 @@ def _lite_copy_tree(folder, rel_dir): # 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). + # These want EEGBCI runs 1 and 2, which tools/circleci_download.sh never + # fetches (it takes subject 1 runs 3/6/10/14 and run 3 for subjects 2-4), + # so the data is not on the machine that builds the docs. eeg_bridging + # alone would need run 1 for ten subjects. + "examples/visualization/onionskin.py", + "examples/preprocessing/muscle_ica.py", + "examples/preprocessing/eeg_bridging.py", + # Both Report tutorials build their figures by screenshotting a 3D scene + # (Report._itv calls backend._take_3d_screenshot), and vtk.js cannot hand a + # framebuffer back to Python, so those sections would embed blank images. + # 70_report additionally round-trips a report through HDF5. "tutorials/intro/70_report.py", + "tutorials/preprocessing/14_quality_control_report.py", ) import sphinx_gallery.gen_rst as _sg_gen_rst # noqa: E402 From 436269e13d3176c2e591db946f76b67c443445de Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 29 Jul 2026 18:07:24 -0400 Subject: [PATCH 80/98] FIX: pre-fetch the other head surface, serve the sphere files [circle full] dig_mri_distances goes through mne/surface.py rather than _freesurfer, so the existing shim never fired. setup_source_space also needs surf/{lh,rh}.sphere. --- doc/conf.py | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index cd50aa6646d..05f492a992d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -590,6 +590,11 @@ def _lite_src(folder, rel): "subjects/sample/surf/lh.inflated", "subjects/sample/surf/rh.curv", "subjects/sample/surf/lh.curv", + # setup_source_space maps each hemisphere onto its sphere for any + # ico/oct spacing, and _create_surf_spacing reads surf/{hemi}.sphere + # by a path it builds itself (5.6 MB each) + "subjects/sample/surf/lh.sphere", + "subjects/sample/surf/rh.sphere", "subjects/sample/label/lh.aparc.annot", "subjects/sample/label/rh.aparc.annot", # the auditory/visual ROIs; about nine notebooks build these names with @@ -1282,7 +1287,8 @@ def _lite_copy_tree(folder, rel_dir): "# 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" + "# would. Several viz modules bind the name at import time, so rebind\n" + "# it wherever the original landed instead of in one known place.\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" @@ -1307,8 +1313,14 @@ def _lite_copy_tree(folder, rel_dir): " 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" + "# import the 3D module first so the sweep below is guaranteed to see\n" + "# it; anything imported later picks the patched name up on its own.\n" + "import mne.viz._3d # noqa: F401\n" + "for _m in list(sys.modules.values()):\n" + " if (getattr(_m, '__name__', '').startswith('mne')\n" + " and getattr(_m, '_get_head_surface', None)\n" + " is _orig_get_head_surface):\n" + " _m._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" @@ -1327,7 +1339,35 @@ def _lite_copy_tree(folder, rel_dir): " 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" + "for _m in list(sys.modules.values()):\n" + " if (getattr(_m, '__name__', '').startswith('mne')\n" + " and getattr(_m, '_get_skull_surface', None)\n" + " is _orig_get_skull_surface):\n" + " _m._get_skull_surface = _lite_get_skull_surface\n" + "# dig_mri_distances reaches a second, unrelated _get_head_surface, the\n" + "# one in mne/surface.py: it takes a list of candidate sources and\n" + "# probes bem/ with os.path.exists and glob, raising if the directory\n" + "# is absent, so the candidates have to land before it runs.\n" + "import mne.surface as _mne_surface\n" + "_orig_surface_head = _mne_surface._get_head_surface\n" + "def _lite_surface_head_surface(subject, source, subjects_dir,\n" + " on_defects, raise_error=True):\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" + " _srcs = [source] if isinstance(source, str) else list(source)\n" + " for _s in _srcs:\n" + " try:\n" + " _lite_fetch_rel(\n" + " _rel + '/bem/' + str(subject) + '-' + _s + '.fif'\n" + " )\n" + " except Exception:\n" + " pass\n" + " return _orig_surface_head(\n" + " subject, source, subjects_dir, on_defects,\n" + " raise_error=raise_error\n" + " )\n" + "_mne_surface._get_head_surface = _lite_surface_head_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" From b7b43191d2e4ccf8ca0ee7aacc2575ba68749219 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 29 Jul 2026 18:14:25 -0400 Subject: [PATCH 81/98] MAINT: full build to test the notebook data [circle full] From a5b4a3c2cefab5ecb6b286b1cb98add17c2bb637 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 29 Jul 2026 20:12:25 -0400 Subject: [PATCH 82/98] FIX: draw glyphs as one merged mesh, not one per point [circle full] An oct-6 source space meant 8196 meshes and 8196 actors in a single scene, which ran the browser tab out of memory. Also honor glyph_resolution/height and solid_transform, so the MRI fiducials come out 5 mm rather than a metre. --- doc/sphinxext/jupyterlite_lite_renderer.py | 322 ++++++++++++++++----- 1 file changed, 245 insertions(+), 77 deletions(-) diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py index f8359cb17c0..945f134a17b 100644 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -54,6 +54,34 @@ def _lite_set_view(plotter, azimuth): return None +# Every scene a notebook drew used to stay live for the kernel's lifetime, +# because the close helpers on _LiteBackend were no-ops. Track the plotters +# weakly -- so they stay collectable -- and give close_all something to free. +_lite_live_plotters = [] + + +def _lite_release_plotter(plotter): + """Hand back a plotter's meshes, JS arrays and GPU buffers.""" + import gc as _gc + if plotter is None: + return None + for _i in range(len(_lite_live_plotters) - 1, -1, -1): + _p = _lite_live_plotters[_i]() + if _p is None or _p is plotter: + del _lite_live_plotters[_i] + # pyvista-js is someone else's surface, so use whichever teardown of these + # it actually implements + for _name in ("clear", "deep_clean", "close"): + _fn = getattr(plotter, _name, None) + if _fn is not None: + try: + _fn() + except Exception: + pass + _gc.collect() + return None + + class _LiteRenderer: """Minimal MNE 3D renderer backed by pyvista-js.""" @@ -63,6 +91,8 @@ def __init__(self, *args, **kwargs): self._np = _np self._pv = _pv self.plotter = _pv.Plotter() + import weakref as _weakref + _lite_live_plotters.append(_weakref.ref(self.plotter)) _bg = kwargs.get("bgcolor", kwargs.get("background_color", "black")) try: self.plotter.background_color = self._rgb(_bg) @@ -99,23 +129,69 @@ def _faces(self, tris): return _np.hstack([ _np.full((len(_t), 1), 3, dtype=_np.int32), _t]).ravel() + def _subdivide(self, rr, tris): + """One level of midpoint subdivision, sharing the new edge vertices.""" + _np = self._np + _rr = [tuple(_v) for _v in _np.asarray(rr, dtype=float)] + _mid = {} + _out = [] + for _a, _b, _c in _np.asarray(tris, dtype=int): + _m = [] + for _p, _q in ((_a, _b), (_b, _c), (_c, _a)): + _k = (min(int(_p), int(_q)), max(int(_p), int(_q))) + if _k not in _mid: + _mid[_k] = len(_rr) + _rr.append(tuple((_np.asarray(_rr[_p]) + + _np.asarray(_rr[_q])) / 2.0)) + _m.append(_mid[_k]) + _ab, _bc, _ca = _m + _out += [[_a, _ab, _ca], [_ab, _b, _bc], [_ca, _bc, _c], + [_ab, _bc, _ca]] + return _np.asarray(_rr, dtype=float), _np.asarray(_out, dtype=int) + def _glyph_template(self, kind, radius=None, height=None, center=None, resolution=None, **kwargs): - """Return (rr, tris) for instanced_mesh, oriented along +x. + """Return (rr, tris) for a glyph template, 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. + triangle list, so build the templates here. ``_tile`` then stamps one + of these at every position and merges the result, which is what keeps + these cheap -- the copies share a single mesh and a single actor. + + Sizes follow the templates ``_pyvista.py`` hands the glyph filter, so + the browser draws the markers at the size the rendered docs do. """ _np = self._np - if kind == "sphere": + if kind in ("sphere", "oct"): _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) + rr = _np.array([[1.0, 0, 0], [-1.0, 0, 0], [0, 1.0, 0], + [0, -1.0, 0], [0, 0, 1.0], [0, 0, -1.0]], 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 + # "oct" is an octahedron on purpose -- that is what _pyvista.py + # hands the glyph filter. A "sphere" has to look round, though: + # fiducials and dig points are drawn with it, so subdivide onto the + # unit sphere to land near the reference's 8x8 sphere (58 verts). + if kind == "sphere": + for _ in range(2): + rr, tris = self._subdivide(rr, tris) + rr /= _np.linalg.norm(rr, axis=1)[:, None] + return rr * _r, tris + if kind == "cone": + # apex along +x so the glyph filter's orientation applies, matching + # pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height + _r = 0.15 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) + _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)]) + rr = _np.vstack([_ring, [[_h, 0, 0]], [[0.0, 0, 0]]]) + tris = [] + for _i in range(_n): + _j = (_i + 1) % _n + tris += [[_i, _j, _n], [_n + 1, _j, _i]] # side, base + return rr, _np.asarray(tris, int) # 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) @@ -147,6 +223,45 @@ def _add(self, points, tris, color, opacity=1.0): smooth_shading=True) return _actor, _pd + def _rots_from_dirs(self, dirs): + """Rotations carrying +x onto each direction, as the glyphs assume.""" + _np = self._np + from mne.transforms import _find_vector_rotation as _fvr + _x = _np.array([1.0, 0.0, 0.0]) + return _np.asarray([_fvr(_x, _d) for _d in dirs], dtype=float) + + def _tile(self, rr, tris, positions, scales=None, rots=None, + axis_scales=None): + """Stamp one template mesh at many positions as a single mesh. + + ``_pyvista.py`` hands its template to VTK's glyph filter, which bakes + every copy into one mesh and adds it once. Doing this per position + instead means an oct-6 source space becomes 8196 meshes and 8196 + actors, which is enough to run the browser tab out of memory. + """ + _np = self._np + _rr = _np.asarray(rr, dtype=float) + _tris = _np.asarray(tris, dtype=int) + _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] + _n = len(_pos) + _pts = _np.repeat(_rr[None, :, :], _n, axis=0) + if axis_scales is not None: + # tubes span a given length without fattening, so scale the + # template's axis alone + _ax = _np.atleast_1d(_np.asarray(axis_scales, dtype=float)) + _pts[:, :, 0] *= _ax[_np.arange(_n) % len(_ax)][:, None] + if scales is not None: + _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) + _pts *= _sa[_np.arange(_n) % len(_sa)][:, None, None] + if rots is not None: + _ra = _np.asarray(rots, dtype=float) + _pts = _np.einsum( + 'nij,nkj->nki', _ra[_np.arange(_n) % len(_ra)], _pts) + _pts += _pos[:, None, :] + _off = (_np.arange(_n) * len(_rr))[:, None, None] + return (_pts.reshape(-1, 3), + (_tris[None, :, :] + _off).reshape(-1, 3)) + # -- drawing ------------------------------------------------------------ def mesh(self, x, y, z, triangles, color=None, opacity=1.0, *args, **kwargs): _np = self._np @@ -162,92 +277,135 @@ 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)) + if not len(_c): + return None, None _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): + _rr, _tris = self._glyph_template("sphere", radius=_r, + resolution=resolution) + _pts, _faces = self._tile(_rr, _tris, _c) + return self._add(_pts, _faces, color, opacity) + + 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 + _o = _np.atleast_2d(_np.asarray(origin, dtype=float))[:, :3] + _d = _np.atleast_2d(_np.asarray(destination, dtype=float))[:, :3] + _n = min(len(_o), len(_d)) + if not _n: + return None, None + _vec = _d[:_n] - _o[:_n] + _len = _np.linalg.norm(_vec, axis=1) + _keep = _len > 0 + if not _keep.any(): + return None, None + _vec, _len = _vec[_keep], _len[_keep] + _ctr = (_o[:_n][_keep] + _d[:_n][_keep]) / 2.0 + # one unit-height template stretched to each segment, merged into a + # single mesh rather than a cylinder primitive per segment + _rr, _tris = self._glyph_template("cylinder", radius=float(radius), + height=1.0) + _pts, _faces = self._tile( + _rr, _tris, _ctr, rots=self._rots_from_dirs(_vec / _len[:, None]), + axis_scales=_len) + return self._add(_pts, _faces, color, kwargs.get("opacity", 1.0)) def quiver3d(self, x, y, z, u, v, w, color=None, scale=1.0, mode="arrow", - opacity=1.0, *args, **kwargs): + opacity=1.0, *, glyph_height=None, glyph_center=None, + glyph_resolution=None, glyph_radius=0.15, + solid_transform=None, **kwargs): + """Draw one merged glyph mesh, the way the glyph filter would. + + ``_pyvista.py`` builds a template, lets VTK's glyph filter bake a copy + at every point into one mesh, and adds that once. Drawing a primitive + per point instead is what made ``20_source_alignment`` -- an oct-6 + source space, so 8196 glyphs, twice -- exhaust the browser tab. + """ _np = self._np _x, _y, _z = (_np.atleast_1d(_np.asarray(_q, dtype=float)) for _q in (x, y, z)) + _ctr = _np.column_stack([_x, _y, _z]) + _n = len(_ctr) + if not _n: + return None, None + _s = float(_np.asarray(scale).ravel()[0]) if _np.size(scale) else 1.0 + _i = _np.arange(_n) _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 + _dirs = _np.column_stack([_u[_i % len(_u)], _v[_i % len(_v)], + _w[_i % len(_w)]]) + _norm = _np.linalg.norm(_dirs, axis=1) + _flat = _norm == 0 + _dirs[_flat] = (1.0, 0.0, 0.0) + _norm[_flat] = 1.0 + _dirs = _dirs / _norm[:, None] + # the same templates _pyvista.py feeds the filter; `scale` then plays + # the part its `factor` does + if mode == "oct": + # vtkPlatonicSolidSource puts its octahedron on the unit + # circumsphere, and the MRI fiducials get their real size from + # solid_transform (mri_fid_scale, 5 mm) rather than from `scale` + _kind, _tkw = "oct", dict(radius=1.0) + elif mode == "sphere": + _kind, _tkw = "sphere", dict(radius=0.5) + elif mode == "cylinder": + _kind = "cylinder" + _tkw = dict(radius=glyph_radius, height=glyph_height, + center=glyph_center, resolution=glyph_resolution) + else: # arrow / cone / 2darrow + _kind = "cone" + _tkw = dict(radius=glyph_radius, height=glyph_height, + resolution=glyph_resolution) + _rr, _tris = self._glyph_template(_kind, **_tkw) + if solid_transform is not None: + # _pyvista.py transforms the template before glyphing, and this is + # where the fiducial markers get their size and 45 deg roll + _st = _np.asarray(solid_transform, dtype=float) + _rr = _rr @ _st[:3, :3].T + _st[:3, 3] + _rots = (None if mode in ("sphere", "oct") + else self._rots_from_dirs(_dirs)) + _pts, _faces = self._tile(_rr, _tris, _ctr, scales=_s, rots=_rots) + return self._add(_pts, _faces, color, opacity) 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. + """Stamp the template at every position, merged per distinct color. + + Rotate with MNE's own quaternion helper so oriented glyphs (EEG + cylinders) point the way MNE intended rather than all along +x. + pyvista-js has no per-vertex color, so instances are grouped by the + color they asked for and each group becomes one mesh -- a handful of + actors for a sensor array instead of one per sensor. + """ _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)) + _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] + _n = len(_pos) + if not _n: + return None, None _rot = None - if _quats is not None: - try: - from mne.transforms import quat_to_rot as _q2r - _rot = _q2r(_quats) - except Exception: - _rot = None + if quats is not None: + from mne.transforms import quat_to_rot as _q2r + _rot = _np.asarray(_q2r(_np.atleast_2d( + _np.asarray(quats, dtype=float))), dtype=float) + _idx = _np.arange(_n) + if colors is not None and _np.ndim(colors) > 1: + _ca = _np.asarray(colors) + _uniq, _inv = _np.unique(_ca[_idx % len(_ca)], axis=0, + return_inverse=True) + _inv = _np.asarray(_inv).ravel() + _groups = [(_uniq[_k], _idx[_inv == _k]) + for _k in range(len(_uniq))] + else: + _groups = [(colors, _idx)] _out = (None, None) - for _i, _p in enumerate(_pos): - _s = 1.0 + for _col, _sel in _groups: + _sc = None 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) + _sc = _sa[_sel % len(_sa)] + _rt = None if _rot is None else _rot[_sel % len(_rot)] + _pts, _faces = self._tile(rr, tris, _pos[_sel], scales=_sc, + rots=_rt) + _out = self._add(_pts, _faces, _col, opacity) return _out # -- things the static docs do not need --------------------------------- @@ -344,9 +502,19 @@ def _set_3d_title(self, figure, title, size=40, color="white", return None def _close_3d_figure(self, figure): + _lite_release_plotter(figure) return None def _close_all(self): + # the registry holds weak references, so deref before releasing -- + # handing the ref itself to _lite_release_plotter matches nothing and + # never shortens the list + while _lite_live_plotters: + _p = _lite_live_plotters[-1]() + if _p is None: + _lite_live_plotters.pop() + else: + _lite_release_plotter(_p) return None From 396382798d0d13cc04b91e0243661de63542b1b8 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Thu, 30 Jul 2026 09:35:45 -0400 Subject: [PATCH 83/98] FIX: expose renderer.figure and reuse a passed-in scene [circle full] The tutorials that build a renderer themselves reach for renderer.figure, which the pyvista backend exposes alongside scene(). Honour a fig argument too, so plot_alignment and plot_dipole_locations composite into one scene instead of opening a second one. --- doc/sphinxext/jupyterlite_lite_renderer.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py index 945f134a17b..39563b3bd1e 100644 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -90,6 +90,15 @@ def __init__(self, *args, **kwargs): import pyvista_js as _pv self._np = _np self._pv = _pv + # plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite + # into a scene the notebook already made, so draw into that plotter + # rather than opening a second one and splitting the picture in two. + # plot_alignment passes it positionally and create_3d_figure by name, + # and `fig` is _PyVistaRenderer's first argument, so accept both. + _fig = args[0] if args else kwargs.get("fig", None) + if _fig is not None and hasattr(_fig, "add_mesh"): + self.plotter = _fig + return self.plotter = _pv.Plotter() import weakref as _weakref _lite_live_plotters.append(_weakref.ref(self.plotter)) @@ -468,6 +477,17 @@ def set_camera(self, azimuth=None, elevation=None, distance=None, # views and otherwise leave the default. return _lite_set_view(self.plotter, azimuth) + @property + def figure(self): + """The scene, under the name the tutorials reach for. + + ``_PyVistaRenderer`` hands out one object as both ``.figure`` and + ``.scene()``; ``20_source_alignment`` builds a renderer itself with + ``create_3d_figure(scene=False)`` and then passes ``renderer.figure`` + to ``set_3d_view``, so the two have to stay the same thing here too. + """ + return self.plotter + def scene(self): return self.plotter From 14b8e520f0ed6af6aab31a2dd40f299b780a8c7d Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 31 Jul 2026 08:17:38 -0400 Subject: [PATCH 84/98] FIX: note the coregistration cell instead of dropping the page [circle full] mne.gui.coregistration places fiducials by clicking the scalp, and the vtk.js renderer has no picker, so that one cell cannot run. Swap it for a note via sphinx-gallery's notebook_modification_function, which only touches the JupyterLite copies, and the other nine cells keep working. --- doc/conf.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 05f492a992d..63bcd8c0d56 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -807,10 +807,63 @@ def _lite_copy_tree(folder, rel_dir): _lite_wheels = find_wheels() or build_wheel() sphinx_logger.info(f"[JupyterLite] MNE wheel for the browser kernel: {_lite_wheels}") + +# A cell here and there cannot run in the browser even though the rest of its +# notebook can, and dropping a whole page from the launcher over one cell costs +# more than it saves. Swap just that cell for a note that keeps the code in +# view and says what it needs instead. Keep this list short: a page that is +# mostly unavailable belongs in JUPYTERLITE_EXCLUDE rather than here. +JUPYTERLITE_CELL_NOTES = ( + ( + "forward/20_source_alignment.ipynb", + "mne.gui.coregistration", + "**This cell does not run in the browser.**\n" + "\n" + "`mne.gui.coregistration` sets the fiducials by clicking on the scalp\n" + "surface, and the vtk.js renderer used here draws scenes without a\n" + "picker, so there is nothing for those clicks to hit. Run it from a\n" + "local MNE install instead:\n" + "\n" + "```python\n" + 'mne.gui.coregistration(subject="sample", subjects_dir=subjects_dir)\n' + "```\n" + "\n" + "The video above walks through the same steps, and the rest of this\n" + "notebook runs normally.\n", + ), +) + + +def _lite_note_unrunnable_cells(notebook_content, notebook_filename): + """Turn cells that cannot run in the browser into an explanatory note. + + sphinx-gallery calls this for each notebook it copies into the JupyterLite + contents, so only the browser copy changes -- the notebook offered for + download stays exactly as the docs built it. + """ + path = str(notebook_filename).replace(os.sep, "/") + for suffix, needle, note in JUPYTERLITE_CELL_NOTES: + if not path.endswith(suffix): + continue + for cell in notebook_content.get("cells", []): + if cell.get("cell_type") != "code": + continue + if needle not in "".join(cell.get("source", [])): + continue + cell["cell_type"] = "markdown" + cell["source"] = [note] + cell["metadata"] = {} + # markdown cells carry neither of these, and nbformat rejects them + cell.pop("outputs", None) + cell.pop("execution_count", None) + sphinx_logger.info(f"[JupyterLite] {suffix}: noted {needle} cell") + + sphinx_gallery_conf = { "jupyterlite": { "use_jupyter_lab": True, "jupyterlite_contents": "jupyterlite_contents", + "notebook_modification_function": _lite_note_unrunnable_cells, }, "first_notebook_cell": ( "# 💡 This cell is automatically added to the start of each notebook.\n" From 50c937743fa5a280ae09e52ff1ba88fd70b183ce Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 31 Jul 2026 08:48:18 -0400 Subject: [PATCH 85/98] FIX: name the notebook hook so the gallery conf stays serializable [circle full] conf.py asserts sphinx_gallery_conf is serializable and sphinx rejects function objects, so passing the hook directly broke the build before Sphinx started. sphinx-gallery imports a dotted path instead, so move it to sphinxext. --- doc/conf.py | 59 ++---------------- doc/sphinxext/jupyterlite_cell_notes.py | 79 +++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 53 deletions(-) create mode 100644 doc/sphinxext/jupyterlite_cell_notes.py diff --git a/doc/conf.py b/doc/conf.py index 63bcd8c0d56..2d2b870d540 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -807,63 +807,16 @@ def _lite_copy_tree(folder, rel_dir): _lite_wheels = find_wheels() or build_wheel() sphinx_logger.info(f"[JupyterLite] MNE wheel for the browser kernel: {_lite_wheels}") - -# A cell here and there cannot run in the browser even though the rest of its -# notebook can, and dropping a whole page from the launcher over one cell costs -# more than it saves. Swap just that cell for a note that keeps the code in -# view and says what it needs instead. Keep this list short: a page that is -# mostly unavailable belongs in JUPYTERLITE_EXCLUDE rather than here. -JUPYTERLITE_CELL_NOTES = ( - ( - "forward/20_source_alignment.ipynb", - "mne.gui.coregistration", - "**This cell does not run in the browser.**\n" - "\n" - "`mne.gui.coregistration` sets the fiducials by clicking on the scalp\n" - "surface, and the vtk.js renderer used here draws scenes without a\n" - "picker, so there is nothing for those clicks to hit. Run it from a\n" - "local MNE install instead:\n" - "\n" - "```python\n" - 'mne.gui.coregistration(subject="sample", subjects_dir=subjects_dir)\n' - "```\n" - "\n" - "The video above walks through the same steps, and the rest of this\n" - "notebook runs normally.\n", - ), -) - - -def _lite_note_unrunnable_cells(notebook_content, notebook_filename): - """Turn cells that cannot run in the browser into an explanatory note. - - sphinx-gallery calls this for each notebook it copies into the JupyterLite - contents, so only the browser copy changes -- the notebook offered for - download stays exactly as the docs built it. - """ - path = str(notebook_filename).replace(os.sep, "/") - for suffix, needle, note in JUPYTERLITE_CELL_NOTES: - if not path.endswith(suffix): - continue - for cell in notebook_content.get("cells", []): - if cell.get("cell_type") != "code": - continue - if needle not in "".join(cell.get("source", [])): - continue - cell["cell_type"] = "markdown" - cell["source"] = [note] - cell["metadata"] = {} - # markdown cells carry neither of these, and nbformat rejects them - cell.pop("outputs", None) - cell.pop("execution_count", None) - sphinx_logger.info(f"[JupyterLite] {suffix}: noted {needle} cell") - - sphinx_gallery_conf = { "jupyterlite": { "use_jupyter_lab": True, "jupyterlite_contents": "jupyterlite_contents", - "notebook_modification_function": _lite_note_unrunnable_cells, + # named rather than passed: sphinx_gallery_conf has to stay + # JSON-serializable (see the is_serializable assert below), so + # sphinx-gallery imports this dotted path itself + "notebook_modification_function": ( + "jupyterlite_cell_notes.note_unrunnable_cells" + ), }, "first_notebook_cell": ( "# 💡 This cell is automatically added to the start of each notebook.\n" diff --git a/doc/sphinxext/jupyterlite_cell_notes.py b/doc/sphinxext/jupyterlite_cell_notes.py new file mode 100644 index 00000000000..db2034fee63 --- /dev/null +++ b/doc/sphinxext/jupyterlite_cell_notes.py @@ -0,0 +1,79 @@ +"""Notes for notebook cells that cannot run in the JupyterLite kernel. + +A cell here and there cannot run in the browser even though the rest of its +notebook can, and dropping a whole page from the launcher over one cell costs +more than it saves. :func:`note_unrunnable_cells` swaps just that cell for a +note that keeps the code in view and says what it needs instead. + +sphinx-gallery calls this for each notebook it copies into the JupyterLite +contents, so only the browser copy changes -- the notebook offered for download +stays exactly as the docs built it. + +It lives here rather than in ``conf.py`` because ``sphinx_gallery_conf`` has to +stay JSON-serializable (``sphinx.config.is_serializable`` rejects functions), so +the config names the dotted path and sphinx-gallery imports it. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import os + +import sphinx.util.logging + +# not mne_doc_utils.sphinx_logger: that module pulls in mne and pyvista, which +# this one has no use for +logger = sphinx.util.logging.getLogger("mne") + +# (notebook path suffix, substring identifying the cell, replacement markdown). +# Keep this short: a page that is mostly unavailable belongs in +# JUPYTERLITE_EXCLUDE instead of here. +CELL_NOTES = ( + ( + "forward/20_source_alignment.ipynb", + "mne.gui.coregistration", + "**This cell does not run in the browser.**\n" + "\n" + "`mne.gui.coregistration` sets the fiducials by clicking on the scalp\n" + "surface, and the vtk.js renderer used here draws scenes without a\n" + "picker, so there is nothing for those clicks to hit. Run it from a\n" + "local MNE install instead:\n" + "\n" + "```python\n" + 'mne.gui.coregistration(subject="sample", subjects_dir=subjects_dir)\n' + "```\n" + "\n" + "The video above walks through the same steps, and the rest of this\n" + "notebook runs normally.\n", + ), +) + + +def note_unrunnable_cells(notebook_content, notebook_filename): + """Turn cells that cannot run in the browser into an explanatory note. + + Parameters + ---------- + notebook_content : dict + The parsed notebook, modified in place. + notebook_filename : path-like + Where the notebook will be written inside the JupyterLite contents. + """ + path = str(notebook_filename).replace(os.sep, "/") + for suffix, needle, note in CELL_NOTES: + if not path.endswith(suffix): + continue + for cell in notebook_content.get("cells", []): + # prose mentions the same function, so only rewrite real code + if cell.get("cell_type") != "code": + continue + if needle not in "".join(cell.get("source", [])): + continue + cell["cell_type"] = "markdown" + cell["source"] = [note] + cell["metadata"] = {} + # markdown cells carry neither of these, and nbformat rejects them + cell.pop("outputs", None) + cell.pop("execution_count", None) + logger.info(f"[JupyterLite] {suffix}: noted {needle} cell") From 7aa64ecbfaf90806d00203da8fc5e7896497c1d3 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 1 Aug 2026 09:47:13 -0400 Subject: [PATCH 86/98] FIX: keep the setup cell out of the downloadable notebooks [circle full] first_notebook_cell is applied when sphinx-gallery generates a notebook, so the piplite cell also landed in the .ipynb offered for download, where it fails on the first line. Prepend it at copy time instead, alongside the cell notes, so only the JupyterLite copies carry it. --- doc/conf.py | 920 -------------------- doc/sphinxext/jupyterlite_cell_notes.py | 50 +- doc/sphinxext/jupyterlite_lite_renderer.py | 6 +- doc/sphinxext/jupyterlite_setup_cell.py | 939 +++++++++++++++++++++ 4 files changed, 983 insertions(+), 932 deletions(-) create mode 100644 doc/sphinxext/jupyterlite_setup_cell.py diff --git a/doc/conf.py b/doc/conf.py index 2d2b870d540..7d21f5a8a70 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -54,7 +54,6 @@ 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 ----------------------------------------------------- @@ -818,925 +817,6 @@ def _lite_copy_tree(folder, rel_dir): "jupyterlite_cell_notes.note_unrunnable_cells" ), }, - "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', 'mffpy', " - "'python-picard'],\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'=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, diff --git a/doc/sphinxext/jupyterlite_cell_notes.py b/doc/sphinxext/jupyterlite_cell_notes.py index db2034fee63..8e205652cf6 100644 --- a/doc/sphinxext/jupyterlite_cell_notes.py +++ b/doc/sphinxext/jupyterlite_cell_notes.py @@ -1,13 +1,20 @@ -"""Notes for notebook cells that cannot run in the JupyterLite kernel. +"""Per-notebook fixups applied to the JupyterLite copies only. -A cell here and there cannot run in the browser even though the rest of its -notebook can, and dropping a whole page from the launcher over one cell costs -more than it saves. :func:`note_unrunnable_cells` swaps just that cell for a -note that keeps the code in view and says what it needs instead. +:func:`note_unrunnable_cells` does two things to every notebook sphinx-gallery +copies into the JupyterLite contents: -sphinx-gallery calls this for each notebook it copies into the JupyterLite -contents, so only the browser copy changes -- the notebook offered for download -stays exactly as the docs built it. +1. Prepends the setup cell that installs MNE and patches the browser + environment (see :mod:`jupyterlite_setup_cell`). +2. Swaps any cell that cannot run in the browser for a note that keeps the code + in view and says what it needs instead -- a cell here and there is blocked + even though the rest of its notebook runs, and dropping a whole page from + the launcher over one cell costs more than it saves. + +Both are deliberately done here rather than through ``first_notebook_cell``, +which sphinx-gallery applies while *generating* the notebook and therefore also +writes into the ``.ipynb`` offered for download -- where ``piplite`` does not +exist and the notebook would fail on its first cell. Doing it at copy time +keeps the download and the rendered page exactly as the docs built them. It lives here rather than in ``conf.py`` because ``sphinx_gallery_conf`` has to stay JSON-serializable (``sphinx.config.is_serializable`` rejects functions), so @@ -21,11 +28,15 @@ import os import sphinx.util.logging +from jupyterlite_setup_cell import LITE_SETUP_CELL # not mne_doc_utils.sphinx_logger: that module pulls in mne and pyvista, which # this one has no use for logger = sphinx.util.logging.getLogger("mne") +# first line of LITE_SETUP_CELL, used to spot a cell that is already there +_SETUP_MARKER = LITE_SETUP_CELL.strip().split("\n", 1)[0] + # (notebook path suffix, substring identifying the cell, replacement markdown). # Keep this short: a page that is mostly unavailable belongs in # JUPYTERLITE_EXCLUDE instead of here. @@ -51,7 +62,7 @@ def note_unrunnable_cells(notebook_content, notebook_filename): - """Turn cells that cannot run in the browser into an explanatory note. + """Add the setup cell and note the cells that cannot run in the browser. Parameters ---------- @@ -60,11 +71,30 @@ def note_unrunnable_cells(notebook_content, notebook_filename): notebook_filename : path-like Where the notebook will be written inside the JupyterLite contents. """ + # setdefault, not get: a missing key would otherwise hand back a throwaway + # list and the insert would silently not stick + cells = notebook_content.setdefault("cells", []) + # stale .ipynb from an earlier build can already carry the cell; adding a + # second one would install everything twice + already = cells and _SETUP_MARKER in "".join(cells[0].get("source", [])) + if not already: + cells.insert( + 0, + { + "cell_type": "code", + "execution_count": None, + "metadata": {"collapsed": False}, + "outputs": [], + # .strip() to match what sphinx-gallery's add_code_cell wrote + # while this went through first_notebook_cell + "source": [LITE_SETUP_CELL.strip()], + }, + ) path = str(notebook_filename).replace(os.sep, "/") for suffix, needle, note in CELL_NOTES: if not path.endswith(suffix): continue - for cell in notebook_content.get("cells", []): + for cell in cells: # prose mentions the same function, so only rewrite real code if cell.get("cell_type") != "code": continue diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py index 39563b3bd1e..705dbe7d5bf 100644 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -19,8 +19,10 @@ 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``. +The source is kept as a string because it has to run inside the browser kernel; it +is appended to ``LITE_SETUP_CELL`` in ``jupyterlite_setup_cell.py``, which +``jupyterlite_cell_notes.note_unrunnable_cells`` prepends to each JupyterLite +notebook. """ # Authors: The MNE-Python contributors. diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py new file mode 100644 index 00000000000..c3f7997df60 --- /dev/null +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -0,0 +1,939 @@ +"""The setup cell prepended to every JupyterLite notebook. + +This installs MNE into the browser kernel and patches the bits of the +environment Pyodide does not provide -- data fetching over HTTP, the readers +that expect files already on disk, and the 3D renderer. + +It is inserted by :func:`jupyterlite_cell_notes.note_unrunnable_cells`, which +sphinx-gallery calls only for the notebooks copied into the JupyterLite +contents. It deliberately does NOT go through ``first_notebook_cell``: that is +applied when the notebook is generated, so it would also land in the ``.ipynb`` +offered for download, where ``piplite`` does not exist and the notebook would +fail on its first cell. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +from jupyterlite_lite_renderer import LITE_RENDERER_CELL + +LITE_SETUP_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', 'mffpy', " + "'python-picard'],\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'=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. +) From d80efae3af35f3634f2a7b345c43b0c9d0c31b7e Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 1 Aug 2026 10:11:39 -0400 Subject: [PATCH 87/98] MAINT: full build to check the setup cell move [circle full] From bb03445d37c9160cfa1b3b161a8c2a2fe1cca9e4 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 1 Aug 2026 15:35:25 -0400 Subject: [PATCH 88/98] MAINT: fold the plain reader shims into one wrapper [circle full] Twelve of the readers only need their file fetched before MNE opens it, and each had its own six-line copy. One wrapper plus a table of readers does the same job; the ones that need more than a fetch keep their own shim. --- doc/sphinxext/jupyterlite_setup_cell.py | 108 ++++++++---------------- 1 file changed, 33 insertions(+), 75 deletions(-) diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py index c3f7997df60..b6a0e095350 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -287,41 +287,24 @@ " if _p.startswith(mne_data_path + '/'):\n" " _lite_fetch_rel(_p[len(mne_data_path) + 1:])\n" " return fname\n" - "_orig_read_forward_solution = mne.read_forward_solution\n" - "def _lite_read_forward_solution(fname, *_a, **_kw):\n" - " return _orig_read_forward_solution(\n" - " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" - " )\n" - "mne.read_forward_solution = _lite_read_forward_solution\n" - "import mne.minimum_norm as _mne_minv\n" - "_orig_read_inverse_operator = _mne_minv.read_inverse_operator\n" - "def _lite_read_inverse_operator(fname, *_a, **_kw):\n" - " return _orig_read_inverse_operator(\n" - " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" - " )\n" - "_mne_minv.read_inverse_operator = _lite_read_inverse_operator\n" - "mne.minimum_norm.read_inverse_operator = _lite_read_inverse_operator\n" + "# Most readers just need their file pulled down before MNE opens it.\n" + "# One wrapper, driven by the table further below; readers that need\n" + "# more than this (a sibling file, a chain of candidates) keep their\n" + "# own shim.\n" + "def _lite_wrap_reader(_mods, _name, _arg):\n" + " _orig = getattr(_mods[0], _name)\n" + " def _wrapped(*_a, **_kw):\n" + " if _a:\n" + " _a = (_lite_fetch_if_under_mne_data(_a[0]),) + _a[1:]\n" + " elif _arg in _kw:\n" + " # positionally, as the hand-written shims did\n" + " _a = (_lite_fetch_if_under_mne_data(_kw.pop(_arg)),)\n" + " return _orig(*_a, **_kw)\n" + " for _m in _mods:\n" + " setattr(_m, _name, _wrapped)\n" "# Lazily fetch the heavy sample raw / source-space files only when a\n" "# notebook actually reads them (same pattern as the fwd/inv shims\n" "# above), instead of pulling the whole sample set up front.\n" - "_orig_read_raw_fif = mne.io.read_raw_fif\n" - "def _lite_read_raw_fif(fname, *_a, **_kw):\n" - " return _orig_read_raw_fif(\n" - " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" - " )\n" - "mne.io.read_raw_fif = _lite_read_raw_fif\n" - "_orig_read_raw = mne.io.read_raw\n" - "def _lite_read_raw(fname, *_a, **_kw):\n" - " return _orig_read_raw(\n" - " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" - " )\n" - "mne.io.read_raw = _lite_read_raw\n" - "_orig_read_source_spaces = mne.read_source_spaces\n" - "def _lite_read_source_spaces(fname, *_a, **_kw):\n" - " return _orig_read_source_spaces(\n" - " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" - " )\n" - "mne.read_source_spaces = _lite_read_source_spaces\n" "# Nearly every MNE reader validates its filename through\n" "# _check_fname(must_exist=True) before opening it, so hooking that one\n" "# function covers read_info, read_evokeds, read_cov, read_label and the\n" @@ -346,36 +329,6 @@ " _m._check_fname = _lite_check_fname\n" "# read_label, read_epochs and read_raw_edf open their file directly\n" "# rather than validating it first, so the hook above never sees them\n" - "_orig_read_label = mne.read_label\n" - "def _lite_read_label(filename, *_a, **_kw):\n" - " return _orig_read_label(\n" - " _lite_fetch_if_under_mne_data(filename), *_a, **_kw\n" - " )\n" - "mne.read_label = _lite_read_label\n" - "_orig_read_epochs = mne.read_epochs\n" - "def _lite_read_epochs(fname, *_a, **_kw):\n" - " return _orig_read_epochs(\n" - " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" - " )\n" - "mne.read_epochs = _lite_read_epochs\n" - "_orig_read_raw_edf = mne.io.read_raw_edf\n" - "def _lite_read_raw_edf(input_fname, *_a, **_kw):\n" - " return _orig_read_raw_edf(\n" - " _lite_fetch_if_under_mne_data(input_fname), *_a, **_kw\n" - " )\n" - "mne.io.read_raw_edf = _lite_read_raw_edf\n" - "_orig_read_bem_solution = mne.read_bem_solution\n" - "def _lite_read_bem_solution(fname, *_a, **_kw):\n" - " return _orig_read_bem_solution(\n" - " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" - " )\n" - "mne.read_bem_solution = _lite_read_bem_solution\n" - "_orig_read_events = mne.read_events\n" - "def _lite_read_events(fname, *_a, **_kw):\n" - " return _orig_read_events(\n" - " _lite_fetch_if_under_mne_data(fname), *_a, **_kw\n" - " )\n" - "mne.read_events = _lite_read_events\n" "# an EEGLAB .set keeps its samples in a sibling .fdt, so fetch both\n" "_orig_read_raw_eeglab = mne.io.read_raw_eeglab\n" "def _lite_read_raw_eeglab(input_fname, *_a, **_kw):\n" @@ -445,12 +398,6 @@ " 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" @@ -469,14 +416,25 @@ " _pyxdf.load_xdf = _lite_load_xdf\n" "except Exception:\n" " pass\n" + "# The readers that only need the fetch. Two of them are bound on a\n" + "# private alias as well as the public one, so both are listed.\n" + "import mne.minimum_norm as _mne_minv\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" + "for _mods, _name, _arg in (\n" + " ((mne,), 'read_forward_solution', 'fname'),\n" + " ((_mne_minv, mne.minimum_norm), 'read_inverse_operator', 'fname'),\n" + " ((mne.io,), 'read_raw_fif', 'fname'),\n" + " ((mne.io,), 'read_raw', 'fname'),\n" + " ((mne,), 'read_source_spaces', 'fname'),\n" + " ((mne,), 'read_label', 'filename'),\n" + " ((mne,), 'read_epochs', 'fname'),\n" + " ((mne.io,), 'read_raw_edf', 'input_fname'),\n" + " ((mne,), 'read_bem_solution', 'fname'),\n" + " ((mne,), 'read_events', 'fname'),\n" + " ((mne.io,), 'read_raw_eyelink', 'fname'),\n" + " ((_mne_chpi, mne.chpi), 'read_head_pos', 'fname'),\n" + "):\n" + " _lite_wrap_reader(_mods, _name, _arg)\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" From da21d8cb9f16c1f1ca0a5799ac63f1867f694339 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 2 Aug 2026 18:31:06 -0400 Subject: [PATCH 89/98] FIX: keep only the newest 3D scenes alive [circle full] Each scene holds its meshes in the WASM heap, a copy in JS and a set of GPU buffers, and nothing in a notebook calls close_3d_figure, so they all stayed. 20_source_alignment builds six, which is enough to run the tab out of memory. Give the older ones their geometry back as new ones arrive. --- doc/sphinxext/jupyterlite_lite_renderer.py | 38 ++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py index 705dbe7d5bf..12444d363f5 100644 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -62,8 +62,16 @@ def _lite_set_view(plotter, azimuth): _lite_live_plotters = [] -def _lite_release_plotter(plotter): - """Hand back a plotter's meshes, JS arrays and GPU buffers.""" +def _lite_release_plotter(plotter, close=True): + """Hand back a plotter's meshes, JS arrays and GPU buffers. + + ``clear()`` empties the actor list, which is where the geometry is held, + so that is what frees the memory. ``close=False`` additionally says not to + tear the render window down -- what trimming an older scene wants, since + the notebook has already drawn it. pyvista-js 0.15 implements neither + ``deep_clean`` nor ``close``, so today the two paths do the same thing; + the flag keeps the intent right if that changes. + """ import gc as _gc if plotter is None: return None @@ -73,7 +81,8 @@ def _lite_release_plotter(plotter): del _lite_live_plotters[_i] # pyvista-js is someone else's surface, so use whichever teardown of these # it actually implements - for _name in ("clear", "deep_clean", "close"): + _names = ("clear", "deep_clean", "close") if close else ("clear", "deep_clean") + for _name in _names: _fn = getattr(plotter, _name, None) if _fn is not None: try: @@ -84,6 +93,27 @@ def _lite_release_plotter(plotter): return None +# Each live scene holds its meshes in the WASM heap, a copy of them in JS and +# a set of GPU buffers. Nothing in a notebook calls close_3d_figure, so without +# a cap they all stay: 20_source_alignment builds six, which is enough to run +# the tab out of memory. Keep the newest few and give the rest their geometry +# back as new ones arrive -- scrolling back shows an empty canvas, which is a +# far better outcome than losing the page. +_LITE_MAX_LIVE_SCENES = 2 + + +def _lite_trim_live_plotters(): + """Release everything but the most recent scenes.""" + while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES: + _p = _lite_live_plotters[0]() + if _p is None: + _lite_live_plotters.pop(0) + else: + # also drops it from the registry, so this terminates + _lite_release_plotter(_p, close=False) + return None + + class _LiteRenderer: """Minimal MNE 3D renderer backed by pyvista-js.""" @@ -104,6 +134,8 @@ def __init__(self, *args, **kwargs): self.plotter = _pv.Plotter() import weakref as _weakref _lite_live_plotters.append(_weakref.ref(self.plotter)) + # trim AFTER appending, so the scene being built is never the one freed + _lite_trim_live_plotters() _bg = kwargs.get("bgcolor", kwargs.get("background_color", "black")) try: self.plotter.background_color = self._rgb(_bg) From 58e4868580c567c6e06f1941a7a9f5fcad1a7d0f Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 4 Aug 2026 12:17:22 -0400 Subject: [PATCH 90/98] FIX: treat opacity=None as the renderer default [circle full] The renderer API gained opacity=None in #14125, meaning "use the default", which float() cannot take. Every drawing method here goes through _add, so translate it there once. --- doc/sphinxext/jupyterlite_lite_renderer.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py index 12444d363f5..07e41184f26 100644 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -256,13 +256,20 @@ def _glyph_template(self, kind, radius=None, height=None, center=None, 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.""" + """Draw a mesh and return MNE's (actor, mesh) pair. + + ``opacity=None`` means "renderer default" in MNE's renderer API, which + for PyVista reaches ``add_mesh(opacity=None)`` and draws opaque. Every + drawing method here funnels through this, so translating it once covers + all of them. + """ _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), + _pd, color=self._rgb(color), + opacity=1.0 if opacity is None else float(opacity), smooth_shading=True) return _actor, _pd From ea9061e9c807477dfa8a52c8a07eded0dc92f65a Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 7 Aug 2026 13:45:22 -0400 Subject: [PATCH 91/98] TST: move the browser build to Pyodide 314 [circle full] Overrides jupyterlite-sphinx's jupyterlite-core cap so the docs build picks up pyodide-kernel 0.8.2, whose matplotlib clears MNE's minimum. Revert if the notebooks break. --- tools/circleci_uv_overrides.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/circleci_uv_overrides.txt b/tools/circleci_uv_overrides.txt index e68be19682e..da5f714e4f2 100644 --- a/tools/circleci_uv_overrides.txt +++ b/tools/circleci_uv_overrides.txt @@ -5,3 +5,13 @@ # so uv does not drop those dependencies (the override takes precedence over the # command line, including its extras). -e .[full-pyside6] + +# jupyterlite-sphinx 0.22.1 (its newest release) caps jupyterlite-core at < 0.8, +# which holds the browser kernel at Pyodide 0.29.3 and its matplotlib 3.8.4, one +# minor below the 3.9 MNE declares. The cap is only declared, not real: 0.22.1 +# imports and builds fine against core 0.8.1. Overriding it moves the browser to +# Pyodide 314 (matplotlib 3.10.8, scipy 1.17.1, numpy 2.4.3), so the wheel build +# no longer has to relax any bound. Both lines are needed, since overriding core +# alone also lifts the old kernel's own cap and would leave it in place. +jupyterlite-core>=0.8.1 +jupyterlite-pyodide-kernel>=0.8 From 8e74a153df46fad985104ef59b413684d46465c5 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 7 Aug 2026 13:53:49 -0400 Subject: [PATCH 92/98] TST: retry the full build after an OSF read timeout [circle full] From 39b371fc2835e3f13b90bc815c0a7926f8b4f316 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 7 Aug 2026 15:37:13 -0400 Subject: [PATCH 93/98] TST: retry the full build now that OSF has recovered [circle full] From b5fb9d8f5c52329cb80628579efbd1835cfbeb8b Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 8 Aug 2026 15:08:08 -0400 Subject: [PATCH 94/98] MAINT: align the wheel build and overrides with #14135 Same content as the PR 2 branch so the two do not conflict when it merges. Drops the pyproject patching, which Pyodide 314 makes unnecessary. --- doc/sphinxext/build_lite_wheel.py | 66 +++++++++++++------------------ tools/circleci_uv_overrides.txt | 15 +++---- 2 files changed, 35 insertions(+), 46 deletions(-) diff --git a/doc/sphinxext/build_lite_wheel.py b/doc/sphinxext/build_lite_wheel.py index 0fdced0dc70..4f23d98f3c9 100644 --- a/doc/sphinxext/build_lite_wheel.py +++ b/doc/sphinxext/build_lite_wheel.py @@ -10,9 +10,12 @@ 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. +Both functions are importable, so a docs build can reuse a wheel that is already +present rather than building one on every invocation:: + + from build_lite_wheel import build_wheel, find_wheels + + wheels = find_wheels() or build_wheel() """ # Authors: The MNE-Python contributors. @@ -21,7 +24,6 @@ import glob import os -import re import shutil import subprocess import sys @@ -30,7 +32,6 @@ 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(): @@ -57,41 +58,28 @@ def build_wheel(): 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) + # The wheel is built from pyproject.toml as it stands: Pyodide 314 ships + # matplotlib 3.10.8, scipy 1.17.1 and numpy 2.4.3, all of which satisfy the + # minimums MNE declares, so none of them needs relaxing for the browser. 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) + # 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. + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + REPO_ROOT, + "--no-deps", + "-w", + PYPI_WHEELS_DIR, + ], + check=True, + ) # Fail loudly rather than silently letting the browser kernel fall back to # the older released MNE from PyPI. diff --git a/tools/circleci_uv_overrides.txt b/tools/circleci_uv_overrides.txt index da5f714e4f2..d0d4c8f869c 100644 --- a/tools/circleci_uv_overrides.txt +++ b/tools/circleci_uv_overrides.txt @@ -6,12 +6,13 @@ # command line, including its extras). -e .[full-pyside6] -# jupyterlite-sphinx 0.22.1 (its newest release) caps jupyterlite-core at < 0.8, -# which holds the browser kernel at Pyodide 0.29.3 and its matplotlib 3.8.4, one -# minor below the 3.9 MNE declares. The cap is only declared, not real: 0.22.1 -# imports and builds fine against core 0.8.1. Overriding it moves the browser to -# Pyodide 314 (matplotlib 3.10.8, scipy 1.17.1, numpy 2.4.3), so the wheel build -# no longer has to relax any bound. Both lines are needed, since overriding core -# alone also lifts the old kernel's own cap and would leave it in place. +# jupyterlite-sphinx 0.22.1, its newest release, caps jupyterlite-core at < 0.8, +# which would hold the browser kernel at Pyodide 0.29.3 and its matplotlib 3.8.4, +# one minor below the 3.9 MNE declares. The cap is declared rather than real: +# 0.22.1 imports and builds fine against core 0.8.1. Overriding it puts the +# browser on Pyodide 314, whose matplotlib 3.10.8, scipy 1.17.1 and numpy 2.4.3 +# all satisfy MNE, so the wheel build needs no version patching at all. Both +# lines are needed: overriding core alone also lifts the old kernel's own cap, +# which would leave the old kernel and its old Pyodide in place. jupyterlite-core>=0.8.1 jupyterlite-pyodide-kernel>=0.8 From 0e23c5555045ab0b94f17f7de094f0583a0a83be Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 8 Aug 2026 15:37:15 -0400 Subject: [PATCH 95/98] MAINT: align the browser runtime docstrings with the split PR Same content as the browser-runtime branch so the two do not conflict when it merges. --- doc/sphinxext/jupyterlite_lite_renderer.py | 9 ++++----- doc/sphinxext/jupyterlite_setup_cell.py | 5 ++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py index 07e41184f26..dc73be80743 100644 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -9,20 +9,19 @@ 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, +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 +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; it -is appended to ``LITE_SETUP_CELL`` in ``jupyterlite_setup_cell.py``, which -``jupyterlite_cell_notes.note_unrunnable_cells`` prepends to each JupyterLite -notebook. +is appended to ``LITE_SETUP_CELL`` in ``jupyterlite_setup_cell.py``, which the docs +build prepends to each JupyterLite notebook. """ # Authors: The MNE-Python contributors. diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py index b6a0e095350..10729a27cb8 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -1,11 +1,10 @@ """The setup cell prepended to every JupyterLite notebook. This installs MNE into the browser kernel and patches the bits of the -environment Pyodide does not provide -- data fetching over HTTP, the readers +environment Pyodide does not provide: data fetching over HTTP, the readers that expect files already on disk, and the 3D renderer. -It is inserted by :func:`jupyterlite_cell_notes.note_unrunnable_cells`, which -sphinx-gallery calls only for the notebooks copied into the JupyterLite +The docs build prepends it only to the notebooks copied into the JupyterLite contents. It deliberately does NOT go through ``first_notebook_cell``: that is applied when the notebook is generated, so it would also land in the ``.ipynb`` offered for download, where ``piplite`` does not exist and the notebook would From 1cab76db86b31f06feaab10e0647caaf9985d0ed Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 8 Aug 2026 15:43:28 -0400 Subject: [PATCH 96/98] DOC: apply the reviewed lite_data wording from #14128 Keeps the section identical to the split PR so the two do not conflict. --- doc/documentation/datasets.rst | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/doc/documentation/datasets.rst b/doc/documentation/datasets.rst index 2d3e28ca994..1c821a3d681 100644 --- a/doc/documentation/datasets.rst +++ b/doc/documentation/datasets.rst @@ -547,15 +547,22 @@ 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. +A small curated archive holding the data files needed to run the tutorials and +examples in the browser, taken from the ``sample``, ``kiloword``, ``erp_core``, +``mtrf`` and ``eegbci`` datasets. The files are unchanged and keep the same +checksums as the full datasets. It extracts to ``MNE-lite-data/``, keeping each +file under its original dataset folder (``MNE-sample-data/``, +``MNE-kiloword-data/``, ...). + +Those datasets ship as separate multi-GB archives, so without this the +documentation build would download several gigabytes to serve a handful of +files. + +The ``somato`` dataset is not included, so the somatosensory tutorials and +examples are not available in the browser. + +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 From 65d5e65cac21c12e5754bde7fc44e3366ed2ea55 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 11 Aug 2026 09:44:47 -0400 Subject: [PATCH 97/98] MAINT: drop the dead corrupt_* exclude pattern Nothing in the repo produces files matching it. --- doc/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index 53de255c967..783733c2254 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -149,7 +149,6 @@ "jupyterlite_contents", "lite_extra", "pypi", - "corrupt_*", ] # The suffix of source filenames. From a30c36ccd142d140575699b2926a88aa3f9ac401 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 11 Aug 2026 09:51:13 -0400 Subject: [PATCH 98/98] MAINT: align the lite_data docstring and CI comment with the split PRs Keeps the shared files identical so the branches do not conflict when the split PRs merge. --- .circleci/config.yml | 6 +++--- mne/datasets/lite_data/lite_data.py | 20 +++++++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5a801c77eb5..fb53ff810f5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -260,9 +260,9 @@ jobs: 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). + # Build the development MNE wheel that the JupyterLite browser kernel + # will install, once, before Sphinx runs. Building it here rather than + # from conf.py keeps it out of the per-invocation docs build. - run: name: Build MNE wheel for JupyterLite command: python doc/sphinxext/build_lite_wheel.py diff --git a/mne/datasets/lite_data/lite_data.py b/mne/datasets/lite_data/lite_data.py index e9e575f8403..02122e2080f 100644 --- a/mne/datasets/lite_data/lite_data.py +++ b/mne/datasets/lite_data/lite_data.py @@ -4,13 +4,19 @@ """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/``, ...). +``lite_data`` holds the data files needed to run the tutorials and examples in +the browser, taken from ``sample``, ``kiloword``, ``erp_core``, ``mtrf`` and +``eegbci``. The files are unchanged and keep the same checksums as the full +datasets. It extracts to ``MNE-lite-data/`` with each file under its original +dataset folder (``MNE-sample-data/``, ``MNE-kiloword-data/``, ...), so paths +match. + +Those datasets ship as separate multi-GB archives, so without this the +documentation build would download several gigabytes to serve a handful of +files. + +The ``somato`` dataset is not included, so the somatosensory tutorials and +examples do not run in the browser. """ from ...utils import verbose