From de70d2fe8e04777cf49adacd371848564523ed8c Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 27 May 2026 09:10:16 -0400 Subject: [PATCH 01/15] ENH: Add JupyterLite GitHub Actions workflow --- .github/workflows/jupyterlite.yml | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/jupyterlite.yml diff --git a/.github/workflows/jupyterlite.yml b/.github/workflows/jupyterlite.yml new file mode 100644 index 00000000000..bce53fc8e7c --- /dev/null +++ b/.github/workflows/jupyterlite.yml @@ -0,0 +1,44 @@ +name: Build JupyterLite + +on: + 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 + + - 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 + + - 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/ From e4e3e5f09e7a4b8d721ac8ac16a44c383aafb3b7 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 27 May 2026 09:13:44 -0400 Subject: [PATCH 02/15] FIX: Install jupyter-server for jupyterlite build --- .github/workflows/jupyterlite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/jupyterlite.yml b/.github/workflows/jupyterlite.yml index bce53fc8e7c..408fd0ac54b 100644 --- a/.github/workflows/jupyterlite.yml +++ b/.github/workflows/jupyterlite.yml @@ -24,7 +24,7 @@ jobs: - name: Install JupyterLite & Build Tools run: | python -m pip install --upgrade pip - pip install jupyterlite-core jupyterlite-pyodide-kernel build + pip install jupyterlite-core jupyterlite-pyodide-kernel build jupyter-server - name: Build MNE-Python Wheel run: | From 9900ee786fa39962f352f93aac1e7630510e69ef Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 27 May 2026 10:45:54 -0400 Subject: [PATCH 03/15] FIX: Add towncrier changelog and fix yamllint truthy error --- .github/workflows/jupyterlite.yml | 2 +- doc/changes/dev/13925.newfeature.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 doc/changes/dev/13925.newfeature.rst diff --git a/.github/workflows/jupyterlite.yml b/.github/workflows/jupyterlite.yml index 408fd0ac54b..d4014b2b11c 100644 --- a/.github/workflows/jupyterlite.yml +++ b/.github/workflows/jupyterlite.yml @@ -1,6 +1,6 @@ name: Build JupyterLite -on: +on: # yamllint disable-line rule:truthy push: branches: - jupyterlite-gh-actions diff --git a/doc/changes/dev/13925.newfeature.rst b/doc/changes/dev/13925.newfeature.rst new file mode 100644 index 00000000000..7044e4cb1fb --- /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 :user:`natinew77-creator`. From 0af3e0a7c3e0148b90c7167e2ff0638ab2c3b2a2 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 27 May 2026 10:53:51 -0400 Subject: [PATCH 04/15] FIX: Address pre-commit style and security checks --- .github/workflows/jupyterlite.yml | 2 ++ doc/changes/dev/13925.newfeature.rst | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/jupyterlite.yml b/.github/workflows/jupyterlite.yml index d4014b2b11c..c06af5601ae 100644 --- a/.github/workflows/jupyterlite.yml +++ b/.github/workflows/jupyterlite.yml @@ -15,6 +15,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup Python uses: actions/setup-python@v5 diff --git a/doc/changes/dev/13925.newfeature.rst b/doc/changes/dev/13925.newfeature.rst index 7044e4cb1fb..bcd3109e8d1 100644 --- a/doc/changes/dev/13925.newfeature.rst +++ b/doc/changes/dev/13925.newfeature.rst @@ -1 +1 @@ -Added a JupyterLite GitHub Actions workflow to automatically build a Wasm-compatible interactive documentation site by :user:`natinew77-creator`. +Added a JupyterLite GitHub Actions workflow to automatically build a Wasm-compatible interactive documentation site by Natneal Belete. From df8656da38fe1b1e3de80bec7f7dae3cbb0c7927 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Thu, 11 Jun 2026 16:23:03 -0400 Subject: [PATCH 05/15] ENH: Integrate jupyterlite-sphinx for examples --- doc/conf.py | 23 +++++++++++++++++++++++ pyproject.toml | 2 ++ 2 files changed, 25 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index e7f740db907..aaf7ce7a051 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -112,6 +112,7 @@ # contrib "matplotlib.sphinxext.plot_directive", "numpydoc", + "jupyterlite_sphinx", "sphinx_copybutton", "sphinx_design", "sphinx_gallery.gen_gallery", @@ -470,6 +471,28 @@ sphinx_gallery_parallel = int(os.getenv("MNE_DOC_BUILD_N_JOBS", "1")) sphinx_gallery_conf = { + "jupyterlite": { + "use_jupyter_lab": True, + }, + "first_notebook_cell": ( + "# 💡 This cell is automatically added to the start of each notebook.\n" + "import micropip\n" + "await micropip.install(['mne', 'pyodide-http'])\n" + "\n" + "# 1. Patch networking so pooch can download datasets\n" + "import pyodide_http\n" + "pyodide_http.patch_all()\n" + "\n" + "# 2. Patch MNEBrowseFigure to auto-display in Pyodide's inline backend\n" + "import mne\n" + "import matplotlib.pyplot as plt\n" + "orig_plt_show = mne.viz.utils.plt_show\n" + "def pyodide_plt_show(fig=None, **kwargs):\n" + " orig_plt_show(fig, **kwargs)\n" + " import IPython.display\n" + " IPython.display.display(plt.gcf())\n" + "mne.viz.utils.plt_show = pyodide_plt_show\n" + ), "doc_module": ("mne",), "reference_url": dict(mne=None), "examples_dirs": examples_dirs, diff --git a/pyproject.toml b/pyproject.toml index ae2120938ee..d756628af56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ doc = [ "graphviz", "intersphinx_registry >= 0.2405.27", "ipython != 8.7.0", # also in "full-no-qt" and "test" + "jupyterlite-pyodide-kernel", + "jupyterlite-sphinx", "memory_profiler >= 0.16", "mne-bids", "mne-connectivity", From 89072afc79dd2617504219c5482fd32d63c9db35 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Thu, 11 Jun 2026 17:33:42 -0400 Subject: [PATCH 06/15] MAINT: explicitly import mne.viz.utils in jupyterlite cell to prevent lazy_loader AttributeError --- doc/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/conf.py b/doc/conf.py index 0b937edce52..fe241b94096 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -487,6 +487,7 @@ "\n" "# 2. Patch MNEBrowseFigure to auto-display in Pyodide's inline backend\n" "import mne\n" + "import mne.viz.utils\n" "import matplotlib.pyplot as plt\n" "orig_plt_show = mne.viz.utils.plt_show\n" "def pyodide_plt_show(fig=None, **kwargs):\n" From 325d5a17bc3ce7592dca8f3cd1e2b58ab7ef74ea Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 12 Jun 2026 12:30:26 -0400 Subject: [PATCH 07/15] MAINT: fix pyodide_plt_show argument signature to accept multiple positional arguments via *args --- doc/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 93d7742e970..81054d2a9ac 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -491,8 +491,8 @@ "import mne.viz.utils\n" "import matplotlib.pyplot as plt\n" "orig_plt_show = mne.viz.utils.plt_show\n" - "def pyodide_plt_show(fig=None, **kwargs):\n" - " orig_plt_show(fig, **kwargs)\n" + "def pyodide_plt_show(*args, **kwargs):\n" + " orig_plt_show(*args, **kwargs)\n" " import IPython.display\n" " IPython.display.display(plt.gcf())\n" "mne.viz.utils.plt_show = pyodide_plt_show\n" From 241e67e040f9130e3423d56c10f083be0963e43d Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 15 Jun 2026 11:41:39 -0400 Subject: [PATCH 08/15] ENH: Patch pooch and MNEBrowseFigure for JupyterLite compatibility --- doc/conf.py | 57 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 81054d2a9ac..91bced4aecf 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -480,22 +480,63 @@ "first_notebook_cell": ( "# 💡 This cell is automatically added to the start of each notebook.\n" "import micropip\n" - "await micropip.install(['mne', 'pyodide-http'])\n" + "await micropip.install(['mne'])\n" + "\n" + "# Mock lzma since it is missing in Pyodide but required by pooch\n" + "import sys\n" + "import types\n" + "if 'lzma' not in sys.modules:\n" + " sys.modules['lzma'] = types.ModuleType('lzma')\n" "\n" "# 1. Patch networking so pooch can download datasets\n" - "import pyodide_http\n" - "pyodide_http.patch_all()\n" + "import pooch\n" + "import requests\n" + "import urllib.request\n" + "import urllib.error\n" + "import io\n" + "\n" + "orig_send = requests.Session.send\n" + "def pyodide_send(self, request, **kwargs):\n" + " try:\n" + " resp = urllib.request.urlopen(request.url)\n" + " content = resp.read()\n" + " status = resp.status\n" + " except urllib.error.HTTPError as e:\n" + " content = e.read()\n" + " status = e.code\n" + " except Exception:\n" + " return orig_send(self, request, **kwargs)\n" + " response = requests.Response()\n" + " response.status_code = status\n" + " response.url = request.url\n" + " response.raw = io.BytesIO(content)\n" + " return response\n" + "requests.Session.send = pyodide_send\n" + "\n" + "# Intercept pooch to provide helpful errors for large OSF datasets\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} natively in JupyterLite due to browser CORS restrictions '\n" + " f'on OSF and memory limits. Please download the dataset locally and upload it '\n" + " f'into the JupyterLite file browser.'\n" + " )\n" + " return orig_pooch_fetch(self, fname, processor=processor, downloader=downloader)\n" + "pooch.Pooch.fetch = pyodide_pooch_fetch\n" "\n" "# 2. Patch MNEBrowseFigure to auto-display in Pyodide's inline backend\n" "import mne\n" - "import mne.viz.utils\n" "import matplotlib.pyplot as plt\n" - "orig_plt_show = mne.viz.utils.plt_show\n" - "def pyodide_plt_show(*args, **kwargs):\n" - " orig_plt_show(*args, **kwargs)\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(fig=None, **kwargs):\n" + " orig_plt_show(fig, **kwargs)\n" " import IPython.display\n" " IPython.display.display(plt.gcf())\n" - "mne.viz.utils.plt_show = pyodide_plt_show\n" + "viz_utils.plt_show = pyodide_plt_show\n" ), "doc_module": ("mne",), "reference_url": dict(mne=None), From 1e40137c2b9b0303c14da6382120407a2b16af73 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 15 Jun 2026 11:51:12 -0400 Subject: [PATCH 09/15] MAINT: Fix ruff E501 line too long in doc/conf.py jupyterlite setup --- doc/conf.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 91bced4aecf..7a0b364bc14 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -519,11 +519,14 @@ " 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} natively in JupyterLite due to browser CORS restrictions '\n" - " f'on OSF and memory limits. Please download the dataset locally and upload it '\n" - " f'into the JupyterLite file browser.'\n" + " f'Cannot download {fname} natively in JupyterLite '\n" + " 'due to browser CORS restrictions on OSF and '\n" + " 'memory limits. Please download the dataset locally '\n" + " 'and upload it into the JupyterLite file browser.'\n" " )\n" - " return orig_pooch_fetch(self, fname, processor=processor, downloader=downloader)\n" + " return orig_pooch_fetch(\n" + " self, fname, processor=processor, downloader=downloader\n" + " )\n" "pooch.Pooch.fetch = pyodide_pooch_fetch\n" "\n" "# 2. Patch MNEBrowseFigure to auto-display in Pyodide's inline backend\n" From 173e1290af0fab2946eccca7629e2902ac8b35af Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 15 Jun 2026 15:54:29 -0400 Subject: [PATCH 10/15] MAINT: Hotfix jupyterlite contents so notebooks are copied to build --- doc/conf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 7a0b364bc14..e6c013466f7 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -473,9 +473,13 @@ compress_images = () sphinx_gallery_parallel = int(os.getenv("MNE_DOC_BUILD_N_JOBS", "1")) +jupyterlite_contents = ["jupyterlite_contents"] +jupyterlite_bind_ipynb_suffix = False + 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" From 25a3818999b0d9ec46fea1dd26553adaddd017ba Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 15 Jun 2026 16:37:51 -0400 Subject: [PATCH 11/15] MAINT: Add .gitkeep to jupyterlite_contents to fix sphinx race condition --- doc/jupyterlite_contents/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 doc/jupyterlite_contents/.gitkeep diff --git a/doc/jupyterlite_contents/.gitkeep b/doc/jupyterlite_contents/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d From f5215715947ec00be5f418eea5131807a1e9ef14 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 15 Jun 2026 17:17:49 -0400 Subject: [PATCH 12/15] FIX: Ensure jupyterlite_sphinx runs after sphinx_gallery in build-finished event --- doc/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index e6c013466f7..626f704d861 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -113,11 +113,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 From e9d6142ab196d277a5bb73e841ebfb4ed53e2e8d Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Thu, 18 Jun 2026 14:11:42 -0400 Subject: [PATCH 13/15] MAINT: Build local wheel for JupyterLite environments to fix PyPI outdated issues --- doc/conf.py | 17 +- mne/_fiff/meas_info.py | 530 +++++++++-------------------------------- 2 files changed, 128 insertions(+), 419 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 626f704d861..6f761b02d74 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -476,6 +476,19 @@ jupyterlite_contents = ["jupyterlite_contents"] jupyterlite_bind_ipynb_suffix = False +import subprocess +import sys +import os + +# 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") +os.makedirs(dist_lite_dir, exist_ok=True) +subprocess.run([sys.executable, "-m", "pip", "wheel", "..", "--no-deps", "-w", dist_lite_dir], check=True) + +jupyterlite_build_command_options = { + "piplite-wheels": dist_lite_dir +} + sphinx_gallery_conf = { "jupyterlite": { "use_jupyter_lab": True, @@ -483,8 +496,8 @@ }, "first_notebook_cell": ( "# 💡 This cell is automatically added to the start of each notebook.\n" - "import micropip\n" - "await micropip.install(['mne'])\n" + "import piplite\n" + "await piplite.install(['mne'])\n" "\n" "# Mock lzma since it is missing in Pyodide but required by pooch\n" "import sys\n" diff --git a/mne/_fiff/meas_info.py b/mne/_fiff/meas_info.py index c76e757fff7..629d9a4b0ce 100644 --- a/mne/_fiff/meas_info.py +++ b/mne/_fiff/meas_info.py @@ -35,7 +35,6 @@ verbose, warn, ) -from ..utils._bunch import NamedFloat, NamedInt from ._digitization import ( DigPoint, _dig_kind_ints, @@ -47,7 +46,7 @@ write_dig, ) from .compensator import get_current_comp -from .constants import FIFF, _ch_unit_mul_named +from .constants import FIFF, _ch_unit_mul_named, _coord_frame_named from .ctf_comp import _read_ctf_comp, write_ctf_comp from .open import fiff_open from .pick import ( @@ -58,12 +57,7 @@ get_channel_type_constants, pick_types, ) -from .proc_history import ( - _read_mf_data, - _read_proc_history, - _write_mf_data, - _write_proc_history, -) +from .proc_history import _read_proc_history, _write_proc_history from .proj import ( Projection, _normalize_proj, @@ -402,7 +396,7 @@ def set_montage( Returns ------- - inst : same type as the input data + inst : instance of Raw | Epochs | Evoked The instance, modified in-place. See Also @@ -450,7 +444,6 @@ def set_montage( FIFF.FIFF_UNIT_NONE: "NA", FIFF.FIFF_UNIT_CEL: "C", FIFF.FIFF_UNIT_S: "S", - FIFF.FIFF_UNIT_SEC: "s", FIFF.FIFF_UNIT_PX: "px", } @@ -462,7 +455,7 @@ def _check_set(ch, projs, ch_type): for proj in projs: if ch["ch_name"] in proj["data"]["col_names"]: raise RuntimeError( - f"Cannot change channel type for channel {ch['ch_name']} in " + f'Cannot change channel type for channel {ch["ch_name"]} in ' f'projector "{proj["desc"]}"' ) ch["kind"] = new_kind @@ -545,7 +538,7 @@ def set_channel_types(self, mapping, *, on_unit_change="warn", verbose=None): Returns ------- - inst : same type as the input data + inst : instance of Raw | Epochs | Evoked The instance (modified in place). .. versionchanged:: 0.20 @@ -633,22 +626,17 @@ def set_channel_types(self, mapping, *, on_unit_change="warn", verbose=None): return self @verbose - def rename_channels( - self, mapping, allow_duplicates=False, *, on_missing="raise", verbose=None - ): + def rename_channels(self, mapping, allow_duplicates=False, *, verbose=None): """Rename channels. Parameters ---------- %(mapping_rename_channels_duplicates)s - %(on_missing_ch_names)s - - .. versionadded:: 1.11.0 %(verbose)s Returns ------- - inst : same type as the input data + inst : instance of Raw | Epochs | Evoked The instance (modified in place). .. versionchanged:: 0.20 @@ -664,7 +652,7 @@ def rename_channels( info = self if isinstance(self, Info) else self.info ch_names_orig = list(info["ch_names"]) - rename_channels(info, mapping, allow_duplicates, on_missing=on_missing) + rename_channels(info, mapping, allow_duplicates) # Update self._orig_units for Raw if isinstance(self, BaseRaw): @@ -793,7 +781,7 @@ def anonymize(self, daysback=None, keep_his=False, verbose=None): Returns ------- - inst : same type as the input data + inst : instance of Raw | Epochs | Evoked The modified instance. Notes @@ -822,8 +810,8 @@ def set_meas_date(self, meas_date): Returns ------- - inst : same type as the input data - The modified instance. Operates in place. + inst : instance of Raw | Epochs | Evoked + The modified raw instance. Operates in place. See Also -------- @@ -839,10 +827,6 @@ def set_meas_date(self, meas_date): """ from ..annotations import _handle_meas_date - _validate_type( - meas_date, (datetime.datetime, "numeric", tuple, None), "meas_date" - ) - info = self if isinstance(self, Info) else self.info meas_date = _handle_meas_date(meas_date) @@ -1027,19 +1011,6 @@ def _check_types(x, *, info, name, types, cast=None): return x -def _check_bday(birthday_input, *, info): - date = _check_types( - birthday_input, - info=info, - name='subject_info["birthday"]', - types=(datetime.date, None), - ) - # test if we have a pd.Timestamp - if hasattr(date, "date"): - date = date.date() - return date - - class SubjectInfo(ValidatedDict): _attributes = { "id": partial(_check_types, name='subject_info["id"]', types=int), @@ -1051,7 +1022,9 @@ class SubjectInfo(ValidatedDict): "middle_name": partial( _check_types, name='subject_info["middle_name"]', types=str ), - "birthday": partial(_check_bday), + "birthday": partial( + _check_types, name='subject_info["birthday"]', types=(datetime.date, None) + ), "sex": partial(_check_types, name='subject_info["sex"]', types=int), "hand": partial(_check_types, name='subject_info["hand"]', types=int), "weight": partial( @@ -1077,12 +1050,6 @@ class HeliumInfo(ValidatedDict): types="numeric", cast=float, ), - "gantry_angle": partial( - _check_types, - name='helium_info["gantry_angle"]', - types="int-like", - cast=int, - ), "helium_level": partial( _check_types, name='helium_info["helium_level"]', @@ -1189,89 +1156,6 @@ def _check_dev_head_t(dev_head_t, *, info): return dev_head_t -def _restore_mne_types(info): - """Restore MNE-specific types after unpickling/deserialization. - - This function handles the restoration of MNE-specific object types - that need to be reconstructed from their serialized representations. - These correspond to the "cast" entries in Info._attributes: bads, - dev_head_t, dig, helium_info, line_freq, proj_id, projs, and - subject_info. However, this function is specifically for types that - need restoration because h5io and other serialization formats cast - them to native Python types (e.g., MNEBadsList -> list, Projection - -> dict, DigPoint -> dict). - - This function should be called in Info.__init__ and Info.__setstate__. - If new MNE-specific types are added to Info._attributes, they - should be handled here if they need type restoration after - deserialization. - - Parameters - ---------- - info : Info - The Info object whose types need to be restored. Modified in-place. - - Notes - ----- - This function restores: - - MNEBadsList for the bads field (see Info._attributes["bads"]) - - DigPoint objects for digitization points (see Info._attributes["dig"]) - - Projection objects for SSP projectors (see Info._attributes["projs"]) - - Transform objects for device/head transformations - - meas_date from tuple to datetime - - helium_info and subject_info with proper casting - - proc_history date field from numpy array to tuple (JSON limitation) - """ - # Restore MNEBadsList (corresponds to Info._attributes["bads"]) - if "bads" in info: - info["bads"] = MNEBadsList(bads=info["bads"], info=info) - - # Format Transform objects - for key in ("dev_head_t", "ctf_head_t", "dev_ctf_t"): - _format_trans(info, key) - for res in info.get("hpi_results", []): - _format_trans(res, "coord_trans") - - # Restore DigPoint objects (corresponds to Info._attributes["dig"]) - if info.get("dig", None) is not None and len(info["dig"]): - if isinstance(info["dig"], dict): # needs to be unpacked - info["dig"] = _dict_unpack(info["dig"], _DIG_CAST) - if not isinstance(info["dig"][0], DigPoint): - info["dig"] = _format_dig_points(info["dig"]) - - # Unpack chs if needed - if isinstance(info.get("chs", None), dict): - info["chs"]["ch_name"] = [ - str(x) for x in np.char.decode(info["chs"]["ch_name"], encoding="utf8") - ] - info["chs"] = _dict_unpack(info["chs"], _CH_CAST) - - # Restore Projection objects (corresponds to Info._attributes["projs"]) - for pi, proj in enumerate(info.get("projs", [])): - if not isinstance(proj, Projection): - info["projs"][pi] = Projection(**proj) - - # Old files could have meas_date as tuple instead of datetime - try: - meas_date = info["meas_date"] - except KeyError: - pass - else: - info["meas_date"] = _ensure_meas_date_none_or_dt(meas_date) - - # with validation and casting - for key in ("helium_info", "subject_info"): - if key in info: - info[key] = info[key] - - # Restore proc_history[*]['date'] as tuple - # JSON converts tuples to lists, so we need to convert back - for entry in info.get("proc_history", []): - if "date" in entry and isinstance(entry["date"], np.ndarray): - # Convert numpy array back to tuple with Python types - entry["date"] = tuple(int(x) for x in entry["date"]) - - # TODO: Add fNIRS convention to loc class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): """Measurement information. @@ -1289,10 +1173,10 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): .. warning:: The only entries that should be manually changed by the user are: - ``info['bads']``, ``info['description']``, ``info['device_info']``, - ``info['proj_id']``, ``info['proj_name']``, ``info['dev_head_t']``, - ``info['experimenter']``, ``info['helium_info']``, - ``info['line_freq']``, ``info['temp']``, and ``info['subject_info']``. + ``info['bads']``, ``info['description']``, ``info['device_info']`` + ``info['dev_head_t']``, ``info['experimenter']``, + ``info['helium_info']``, ``info['line_freq']``, ``info['temp']``, + and ``info['subject_info']``. All other entries should be considered read-only, though they can be modified by various MNE-Python functions or methods (which have @@ -1326,8 +1210,6 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): comps : list of dict CTF software gradient compensation data. See Notes for more information. - cross_talk : dict | None - Cross-talk information added at acquisition time by MEGIN systems. ctf_head_t : Transform | None The transformation from 4D/CTF head coordinates to Neuromag head coordinates. This is only present in 4D/CTF data. @@ -1358,9 +1240,7 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): Name of the person that ran the experiment. file_id : dict | None The FIF globally unique ID. See Notes for more information. - fine_calibration : dict | None - Fine calibration information added at acquisition time by MEGIN systems. - gantry_angle : int | None + gantry_angle : float | None Tilt angle of the gantry in degrees. helium_info : dict | None Information about the device helium. See Notes for details. @@ -1444,7 +1324,6 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): See Also -------- mne.create_info - mne.pick_info Notes ----- @@ -1477,7 +1356,7 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): Eyetrack Element ``[3]`` contains information about which eye was tracked (-1 for left, 1 for right), and element ``[4]`` contains information - about the axis of coordinate data (-1 for x-coordinate data, 1 for + about the the axis of coordinate data (-1 for x-coordinate data, 1 for y-coordinate data). Dipole Elements ``[3:6]`` contain dipole orientation information. @@ -1560,7 +1439,7 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): Helium level (%) after position correction. orig_file_guid : str Original file GUID. - meas_date : datetime.datetime | None + meas_date : datetime.datetime The helium level meas date. .. versionchanged:: 1.8 @@ -1714,7 +1593,6 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): "comps": "comps cannot be set directly. " "Please use method Raw.apply_gradient_compensation() " "instead.", - "cross_talk": "cross_talk cannot be set directly.", "ctf_head_t": "ctf_head_t cannot be set directly.", "custom_ref_applied": "custom_ref_applied cannot be set directly. " "Please use method inst.set_eeg_reference() " @@ -1728,7 +1606,6 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): "events": "events cannot be set directly.", "experimenter": partial(_check_types, name="experimenter", types=(str, None)), "file_id": "file_id cannot be set directly.", - "fine_calibration": "fine_calibration cannot be set directly.", "gantry_angle": "gantry_angle cannot be set directly.", "helium_info": partial( _check_types, name="helium_info", types=(dict, None), cast=HeliumInfo @@ -1756,8 +1633,8 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): "Please use methods inst.add_channels(), " "inst.drop_channels(), and inst.pick() instead.", "proc_history": "proc_history cannot be set directly.", - "proj_id": partial(_check_types, name="proj_id", types=(int, None), cast=int), - "proj_name": partial(_check_types, name="proj_name", types=(str, None)), + "proj_id": "proj_id cannot be set directly.", + "proj_name": "proj_name cannot be set directly.", "projs": "projs cannot be set directly. " "Please use methods inst.add_proj() and inst.del_proj() " "instead.", @@ -1774,8 +1651,39 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - with self._unlock(): - _restore_mne_types(self) + self._unlocked = True + # Deal with h5io writing things as dict + if "bads" in self: + self["bads"] = MNEBadsList(bads=self["bads"], info=self) + for key in ("dev_head_t", "ctf_head_t", "dev_ctf_t"): + _format_trans(self, key) + for res in self.get("hpi_results", []): + _format_trans(res, "coord_trans") + if self.get("dig", None) is not None and len(self["dig"]): + if isinstance(self["dig"], dict): # needs to be unpacked + self["dig"] = _dict_unpack(self["dig"], _DIG_CAST) + if not isinstance(self["dig"][0], DigPoint): + self["dig"] = _format_dig_points(self["dig"]) + if isinstance(self.get("chs", None), dict): + self["chs"]["ch_name"] = [ + str(x) for x in np.char.decode(self["chs"]["ch_name"], encoding="utf8") + ] + self["chs"] = _dict_unpack(self["chs"], _CH_CAST) + for pi, proj in enumerate(self.get("projs", [])): + if not isinstance(proj, Projection): + self["projs"][pi] = Projection(**proj) + # Old files could have meas_date as tuple instead of datetime + try: + meas_date = self["meas_date"] + except KeyError: + pass + else: + self["meas_date"] = _ensure_meas_date_none_or_dt(meas_date) + self._unlocked = False + # with validation and casting + for key in ("helium_info", "subject_info"): + if key in self: + self[key] = self[key] def __setstate__(self, state): """Set state (for pickling).""" @@ -1959,7 +1867,7 @@ def _check_consistency(self, prepend_error=""): ): raise RuntimeError( f'{prepend_error}info["meas_date"] must be a datetime object in UTC' - f" or None, got {repr(self['meas_date'])!r}" + f' or None, got {repr(self["meas_date"])!r}' ) chs = [ch["ch_name"] for ch in self["chs"]] @@ -2021,194 +1929,21 @@ def ch_names(self): return ch_names - @property - def _cals(self): - return np.array([ch["range"] * ch["cal"] for ch in self["chs"]], float) - @repr_html def _repr_html_(self): """Summarize info for HTML representation.""" info_template = _get_html_template("repr", "info.html.jinja") return info_template.render(info=self) - @verbose - def save(self, fname, *, overwrite=False, verbose=None): + def save(self, fname): """Write measurement info in fif file. Parameters ---------- fname : path-like The name of the file. Should end by ``'-info.fif'``. - %(overwrite)s - - .. versionadded:: 1.10 - %(verbose)s - - See Also - -------- - mne.io.write_info - """ - write_info(fname, self, overwrite=overwrite) - - def to_json_dict(self) -> dict: - """Convert Info to a JSON-serializable dictionary. - - This method converts the Info object to a standard Python dictionary - containing only JSON-serializable types (dict, list, str, int, float, - bool, None). Numpy arrays are converted to nested lists, and datetime - objects to ISO format strings. - - Returns - ------- - dict - A JSON-serializable dictionary representation of the Info object. - - See Also - -------- - from_json_dict : Reconstruct Info object from dictionary. - - Notes - ----- - This method is useful for serializing Info objects to JSON or other - formats that don't support numpy arrays or custom objects. - - Examples - -------- - >>> info = mne.create_info(['MEG1', 'MEG2'], 1000., ['mag', 'mag']) - >>> info_dict = info.to_json_dict() - >>> import json - >>> json_str = json.dumps(info_dict) # Save to JSON - """ - return _make_serializable(self) - - @classmethod - def from_json_dict(cls, data_dict) -> "Info": - """Reconstruct Info object from a dictionary. - - Parameters - ---------- - data_dict : dict - A dictionary representation of an Info object, typically - created by the :meth:`to_json_dict` method. - - Returns - ------- - Info - The reconstructed Info object. - - See Also - -------- - to_json_dict : Convert Info to dictionary. - - Examples - -------- - >>> info = mne.create_info(['MEG1', 'MEG2'], 1000., ['mag', 'mag']) - >>> info_dict = info.to_json_dict() - >>> info_restored = mne.Info.from_json_dict(info_dict) """ - data_dict = data_dict.copy() - # Restore all nested objects (Transform, NamedInt, etc.) - restored_dict = _restore_objects(data_dict) - - info = cls() - with info._unlock(): - info.update(restored_dict) - _restore_mne_types(info) - - return info - - -def _make_serializable(obj): - """Recursively convert objects to JSON-serializable types.""" - from ..transforms import Transform - - if obj is None: - return None - elif isinstance(obj, bool): - return obj - elif isinstance(obj, NamedInt): - # Preserve NamedInt with its name - return {"_mne_type": "NamedInt", "value": int(obj), "name": obj._name} - elif isinstance(obj, NamedFloat): - # Preserve NamedFloat with its name - return {"_mne_type": "NamedFloat", "value": float(obj), "name": obj._name} - elif isinstance(obj, (str, int, float)): - return obj - elif isinstance(obj, np.integer): - return int(obj) - elif isinstance(obj, np.floating): - return float(obj) - elif isinstance(obj, np.ndarray): - return obj.tolist() - elif isinstance(obj, datetime.datetime): - # Tag datetime objects for proper reconstruction - return {"_mne_type": "datetime", "value": obj.isoformat()} - elif isinstance(obj, datetime.date): - # Tag date objects for proper reconstruction - return {"_mne_type": "date", "value": obj.isoformat()} - elif isinstance(obj, Transform): - # Tag Transform objects for proper reconstruction - return { - "_mne_type": "Transform", - "from": obj["from"], - "to": obj["to"], - "trans": obj["trans"].tolist(), - } - elif isinstance(obj, (list, tuple)): - return [_make_serializable(item) for item in obj] - elif isinstance(obj, dict): - return {key: _make_serializable(val) for key, val in obj.items()} - else: - # Try to convert to string as fallback - return str(obj) - - -def _restore_objects(obj) -> object: - """Recursively restore objects from JSON-serializable types.""" - if obj is None: - return None - elif isinstance(obj, (bool, int, float)): - return obj - elif isinstance(obj, str): - # Regular strings are returned as-is - return obj - elif isinstance(obj, list): - # Check if all elements are numbers - convert to numpy array - # (JSON doesn't distinguish between tuples and lists, so 1D numeric - # lists that came from numpy arrays should be restored as numpy arrays, - # while tuple fields like proc_history[date] are handled separately) - if len(obj) > 0 and all(isinstance(x, (int, float)) for x in obj): - # 1D numeric arrays should be converted to numpy arrays - return np.array(obj) - elif len(obj) > 0 and all(isinstance(x, list) for x in obj): - # 2D or higher dimensional arrays - return np.array(obj) - else: - # Mixed types - recursively restore elements - return [_restore_objects(item) for item in obj] - elif isinstance(obj, dict): - # Check if this is a tagged MNE type - if "_mne_type" in obj: - if obj["_mne_type"] == "Transform": - # Actually create the Transform object now - from ..transforms import Transform - - return Transform(obj["from"], obj["to"], np.array(obj["trans"])) - elif obj["_mne_type"] == "NamedInt": - return NamedInt(obj["name"], obj["value"]) - elif obj["_mne_type"] == "NamedFloat": - return NamedFloat(obj["name"], obj["value"]) - elif obj["_mne_type"] == "datetime": - # Restore datetime object from ISO format string - return datetime.datetime.fromisoformat(obj["value"]) - elif obj["_mne_type"] == "date": - # Restore date object from ISO format string - return datetime.date.fromisoformat(obj["value"]) - # Add more types here if needed in the future - else: - return {key: _restore_objects(val) for key, val in obj.items()} - else: - return obj + write_info(fname, self) def _simplify_info(info, *, keep=()): @@ -2226,7 +1961,7 @@ def _simplify_info(info, *, keep=()): @verbose -def read_fiducials(fname, *, verbose=None): +def read_fiducials(fname, verbose=None): """Read fiducials from a fiff file. Parameters @@ -2246,8 +1981,26 @@ def read_fiducials(fname, *, verbose=None): fname = _check_fname(fname=fname, overwrite="read", must_exist=True) fid, tree, _ = fiff_open(fname) with fid: - pts = _read_dig_fif(fid, tree) - return pts, pts[0]["coord_frame"] + isotrak = dir_tree_find(tree, FIFF.FIFFB_ISOTRAK) + isotrak = isotrak[0] + pts = [] + coord_frame = FIFF.FIFFV_COORD_HEAD + for k in range(isotrak["nent"]): + kind = isotrak["directory"][k].kind + pos = isotrak["directory"][k].pos + if kind == FIFF.FIFF_DIG_POINT: + tag = read_tag(fid, pos) + pts.append(DigPoint(tag.data)) + elif kind == FIFF.FIFF_MNE_COORD_FRAME: + tag = read_tag(fid, pos) + coord_frame = tag.data[0] + coord_frame = _coord_frame_named.get(coord_frame, coord_frame) + + # coord_frame is not stored in the tag + for pt in pts: + pt["coord_frame"] = coord_frame + + return pts, coord_frame @verbose @@ -2337,7 +2090,7 @@ def _write_bad_channels(fid, bads, ch_names_mapping): ch_names_mapping = {} if ch_names_mapping is None else ch_names_mapping bads = _rename_list(bads, ch_names_mapping) start_block(fid, FIFF.FIFFB_MNE_BAD_CHANNELS) - write_name_list_sanitized(fid, FIFF.FIFF_MNE_CH_NAME_LIST, bads, name="bads") + write_name_list_sanitized(fid, FIFF.FIFF_MNE_CH_NAME_LIST, bads, "bads") end_block(fid, FIFF.FIFFB_MNE_BAD_CHANNELS) @@ -2370,14 +2123,14 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): if len(meas) == 0: raise ValueError("Could not find measurement data") if len(meas) > 1: - raise ValueError("Cannot read more than 1 measurement data") + raise ValueError("Cannot read more that 1 measurement data") meas = meas[0] meas_info = dir_tree_find(meas, FIFF.FIFFB_MEAS_INFO) if len(meas_info) == 0: raise ValueError("Could not find measurement info") if len(meas_info) > 1: - raise ValueError("Cannot read more than 1 measurement info") + raise ValueError("Cannot read more that 1 measurement info") meas_info = meas_info[0] # Read measurement info @@ -2461,7 +2214,7 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): description = tag.data elif kind == FIFF.FIFF_PROJ_ID: tag = read_tag(fid, pos) - proj_id = int(tag.data.item()) + proj_id = tag.data elif kind == FIFF.FIFF_PROJ_NAME: tag = read_tag(fid, pos) proj_name = tag.data @@ -2470,7 +2223,7 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): line_freq = float(tag.data.item()) elif kind == FIFF.FIFF_GANTRY_ANGLE: tag = read_tag(fid, pos) - gantry_angle = int(tag.data.item()) + gantry_angle = float(tag.data.item()) elif kind in [FIFF.FIFF_MNE_CUSTOM_REF, 236]: # 236 used before v0.11 tag = read_tag(fid, pos) custom_ref_applied = int(tag.data.item()) @@ -2605,10 +2358,6 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): for k in range(hpi_meas["nent"]): kind = hpi_meas["directory"][k].kind pos = hpi_meas["directory"][k].pos - if kind == FIFF.FIFF_BLOCK_ID: - hm["block_id"] = read_tag(fid, pos).data - if kind == FIFF.FIFF_PARENT_BLOCK_ID: - hm["parent_id"] = read_tag(fid, pos).data if kind == FIFF.FIFF_CREATOR: hm["creator"] = str(read_tag(fid, pos).data) elif kind == FIFF.FIFF_SFREQ: @@ -2733,9 +2482,6 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): if kind == FIFF.FIFF_HE_LEVEL_RAW: tag = read_tag(fid, pos) hi["he_level_raw"] = float(tag.data.item()) - elif kind == FIFF.FIFF_GANTRY_ANGLE: - tag = read_tag(fid, pos) - hi["gantry_angle"] = int(tag.data.item()) elif kind == FIFF.FIFF_HELIUM_LEVEL: tag = read_tag(fid, pos) hi["helium_level"] = float(tag.data.item()) @@ -2747,8 +2493,6 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): hi["meas_date"] = _ensure_meas_date_none_or_dt( tuple(int(t) for t in tag.data), ) - if "meas_date" not in hi: - hi["meas_date"] = None info["helium_info"] = hi del hi @@ -2780,14 +2524,6 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): hs["hpi_coils"] = hc info["hpi_subsystem"] = hs - # Read cross-talk and fine cal - cross_talk = _read_mf_data(fid, tree, kind="sss_ctc") - if len(cross_talk): - info["cross_talk"] = cross_talk - fine_calibration = _read_mf_data(fid, tree, kind="sss_cal") - if len(fine_calibration): - info["fine_calibration"] = fine_calibration - # Read processing history info["proc_history"] = _read_proc_history(fid, tree) @@ -3003,10 +2739,6 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): # HPI Measurement for hpi_meas in info["hpi_meas"]: start_block(fid, FIFF.FIFFB_HPI_MEAS) - if hpi_meas.get("block_id") is not None: - write_id(fid, FIFF.FIFF_BLOCK_ID, hpi_meas["block_id"]) - if hpi_meas.get("parent_id") is not None: - write_id(fid, FIFF.FIFF_PARENT_BLOCK_ID, hpi_meas["parent_id"]) if hpi_meas.get("creator") is not None: write_string(fid, FIFF.FIFF_CREATOR, hpi_meas["creator"]) if hpi_meas.get("sfreq") is not None: @@ -3060,6 +2792,13 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): if info["dev_ctf_t"] is not None: write_coord_trans(fid, info["dev_ctf_t"]) + # Projectors + ch_names_mapping = _make_ch_names_mapping(info["chs"]) + _write_proj(fid, info["projs"], ch_names_mapping=ch_names_mapping) + + # Bad channels + _write_bad_channels(fid, info["bads"], ch_names_mapping=ch_names_mapping) + # General if info.get("experimenter") is not None: write_string(fid, FIFF.FIFF_EXPERIMENTER, info["experimenter"]) @@ -3081,15 +2820,18 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): write_float(fid, FIFF.FIFF_HIGHPASS, info["highpass"]) if info.get("line_freq") is not None: write_float(fid, FIFF.FIFF_LINE_FREQ, info["line_freq"]) + if info.get("gantry_angle") is not None: + write_float(fid, FIFF.FIFF_GANTRY_ANGLE, info["gantry_angle"]) if data_type is not None: write_int(fid, FIFF.FIFF_DATA_PACK, data_type) - if info.get("gantry_angle") is not None: - write_int(fid, FIFF.FIFF_GANTRY_ANGLE, info["gantry_angle"]) if info.get("custom_ref_applied"): write_int(fid, FIFF.FIFF_MNE_CUSTOM_REF, info["custom_ref_applied"]) if info.get("xplotter_layout"): write_string(fid, FIFF.FIFF_XPLOTTER_LAYOUT, info["xplotter_layout"]) + # Channel information + _write_ch_infos(fid, info["chs"], reset_range, ch_names_mapping) + # Subject information if info.get("subject_info") is not None: start_block(fid, FIFF.FIFFB_SUBJECT) @@ -3117,16 +2859,6 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): end_block(fid, FIFF.FIFFB_SUBJECT) del si - # Projectors - ch_names_mapping = _make_ch_names_mapping(info["chs"]) - _write_proj(fid, info["projs"], ch_names_mapping=ch_names_mapping) - - # Channel information - _write_ch_infos(fid, info["chs"], reset_range, ch_names_mapping) - - _write_mf_data(fid, info, kind="sss_ctc", key="cross_talk") - _write_mf_data(fid, info, kind="sss_cal", key="fine_calibration") - if info.get("device_info") is not None: start_block(fid, FIFF.FIFFB_DEVICE) di = info["device_info"] @@ -3143,14 +2875,11 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): hi = info["helium_info"] if hi.get("he_level_raw") is not None: write_float(fid, FIFF.FIFF_HE_LEVEL_RAW, hi["he_level_raw"]) - if hi.get("gantry_angle") is not None: - write_int(fid, FIFF.FIFF_GANTRY_ANGLE, hi["gantry_angle"]) if hi.get("helium_level") is not None: write_float(fid, FIFF.FIFF_HELIUM_LEVEL, hi["helium_level"]) if hi.get("orig_file_guid") is not None: write_string(fid, FIFF.FIFF_ORIG_FILE_GUID, hi["orig_file_guid"]) - if hi.get("meas_date", None) is not None: - write_int(fid, FIFF.FIFF_MEAS_DATE, _dt_to_stamp(hi["meas_date"])) + write_int(fid, FIFF.FIFF_MEAS_DATE, _dt_to_stamp(hi["meas_date"])) end_block(fid, FIFF.FIFFB_HELIUM) del hi @@ -3170,9 +2899,6 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): end_block(fid, FIFF.FIFFB_HPI_SUBSYSTEM) del hs - # Bad channels - _write_bad_channels(fid, info["bads"], ch_names_mapping=ch_names_mapping) - # CTF compensation info comps = info["comps"] if ch_names_mapping: @@ -3190,10 +2916,8 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): _write_proc_history(fid, info) -@verbose -def write_info( - fname, info, *, data_type=None, reset_range=True, overwrite=False, verbose=None -): +@fill_doc +def write_info(fname, info, data_type=None, reset_range=True): """Write measurement info in fif file. Parameters @@ -3207,10 +2931,8 @@ def write_info( raw data. reset_range : bool If True, info['chs'][k]['range'] will be set to unity. - %(overwrite)s - %(verbose)s """ - with start_and_end_file(fname, overwrite=overwrite) as fid: + with start_and_end_file(fname) as fid: start_block(fid, FIFF.FIFFB_MEAS) write_meas_info(fid, info, data_type, reset_range) end_block(fid, FIFF.FIFFB_MEAS) @@ -3288,21 +3010,6 @@ def _where_isinstance(values, kind): return values[int(idx)] elif len(idx) > 1: raise RuntimeError(msg) - # proj_id - elif _check_isinstance(values, (int, type(None)), all) and key == "proj_id": - unique_values = set(values) - if len(unique_values) != 1: - logger.info("Found multiple proj_ids, using the first one.") - return list(unique_values)[0] - - elif key == "experimenter" or key == "proj_name": - if _check_isinstance(values, (str, type(None)), all): - unique_values = set(values) - unique_values.discard(None) - if len(unique_values) == 1: - return list(unique_values)[0] - else: - return None # other else: unique_values = set(values) @@ -3312,7 +3019,7 @@ def _where_isinstance(values, kind): logger.info("Found multiple StringIO instances. Setting value to `None`") return None elif isinstance(list(unique_values)[0], str): - logger.info(f"Found multiple {key}. Setting value to `None`") + logger.info("Found multiple filenames. Setting value to `None`") return None else: raise RuntimeError(msg) @@ -3594,12 +3301,13 @@ def create_info(ch_names, sfreq, ch_types="misc", verbose=None): def _empty_info(sfreq): """Create an empty info dictionary.""" + from ..transforms import Transform + _none_keys = ( "acq_pars", "acq_stim", "ctf_head_t", "description", - "dev_head_t", "dev_ctf_t", "dig", "experimenter", @@ -3640,6 +3348,7 @@ def _empty_info(sfreq): info["highpass"] = 0.0 info["sfreq"] = float(sfreq) info["lowpass"] = info["sfreq"] / 2.0 + info["dev_head_t"] = Transform("meg", "head") info._update_redundant() info._check_consistency() return info @@ -3710,18 +3419,6 @@ def anonymize_info(info, daysback=None, keep_his=False, verbose=None): """ _validate_type(info, "info", "self") - valid_fields = {"his_id", "sex", "hand"} - if isinstance(keep_his, bool): # True means keep all fields, False means keep none - keep_fields = valid_fields if keep_his else set() - elif isinstance(keep_his, str): - _check_option("keep_his", keep_his, valid_fields) - keep_fields = {keep_his} - else: - _validate_type(keep_his, (list, tuple, set), "keep_his") - keep_fields = set(keep_his) - for field in keep_fields: - _check_option("keep_his", field, valid_fields) - default_anon_dos = datetime.datetime( 2000, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc ) @@ -3772,19 +3469,17 @@ def anonymize_info(info, daysback=None, keep_his=False, verbose=None): if subject_info is not None: if subject_info.get("id") is not None: subject_info["id"] = default_subject_id - if keep_fields: + if keep_his: logger.info( - f"Not fully anonymizing info - keeping {', '.join(sorted(keep_fields))}" - " of subject_info" + "Not fully anonymizing info - keeping his_id, sex, and hand info" ) - if "his_id" not in keep_fields: + else: if subject_info.get("his_id") is not None: subject_info["his_id"] = str(default_subject_id) - if "sex" not in keep_fields: if subject_info.get("sex") is not None: subject_info["sex"] = default_sex - if "hand" not in keep_fields: - subject_info.pop("hand", None) # there's no "unknown" setting + if subject_info.get("hand") is not None: + del subject_info["hand"] # there's no "unknown" setting for key in ("last_name", "first_name", "middle_name"): if subject_info.get(key) is not None: @@ -3804,7 +3499,7 @@ def anonymize_info(info, daysback=None, keep_his=False, verbose=None): info["description"] = default_desc with info._unlock(): if info["proj_id"] is not None: - info["proj_id"] = 0 + info["proj_id"] = np.zeros_like(info["proj_id"]) if info["proj_name"] is not None: info["proj_name"] = default_str if info["utc_offset"] is not None: @@ -3978,7 +3673,8 @@ def _write_ch_infos(fid, chs, reset_range, ch_names_mapping): # only write new-style channel information if necessary if len(ch_names_mapping): logger.info( - " Writing channel names to FIF truncated to 15 characters with remapping" + " Writing channel names to FIF truncated to 15 characters " + "with remapping" ) for ch in chs: start_block(fid, FIFF.FIFFB_CH_INFO) From 6eb96f0493befeb5cd5e3b593a3dfdf44cebfec3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:12:20 +0000 Subject: [PATCH 14/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/conf.py | 15 +++++++++------ mne/_fiff/meas_info.py | 7 +++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 6f761b02d74..e2f8a3021b1 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -476,18 +476,21 @@ jupyterlite_contents = ["jupyterlite_contents"] jupyterlite_bind_ipynb_suffix = False +import os import subprocess import sys -import os # 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") +dist_lite_dir = os.path.join( + os.path.abspath(os.path.dirname(__file__)), "_build", "dist_lite" +) os.makedirs(dist_lite_dir, exist_ok=True) -subprocess.run([sys.executable, "-m", "pip", "wheel", "..", "--no-deps", "-w", dist_lite_dir], check=True) +subprocess.run( + [sys.executable, "-m", "pip", "wheel", "..", "--no-deps", "-w", dist_lite_dir], + check=True, +) -jupyterlite_build_command_options = { - "piplite-wheels": dist_lite_dir -} +jupyterlite_build_command_options = {"piplite-wheels": dist_lite_dir} sphinx_gallery_conf = { "jupyterlite": { diff --git a/mne/_fiff/meas_info.py b/mne/_fiff/meas_info.py index 629d9a4b0ce..e7dfc2fadae 100644 --- a/mne/_fiff/meas_info.py +++ b/mne/_fiff/meas_info.py @@ -455,7 +455,7 @@ def _check_set(ch, projs, ch_type): for proj in projs: if ch["ch_name"] in proj["data"]["col_names"]: raise RuntimeError( - f'Cannot change channel type for channel {ch["ch_name"]} in ' + f"Cannot change channel type for channel {ch['ch_name']} in " f'projector "{proj["desc"]}"' ) ch["kind"] = new_kind @@ -1867,7 +1867,7 @@ def _check_consistency(self, prepend_error=""): ): raise RuntimeError( f'{prepend_error}info["meas_date"] must be a datetime object in UTC' - f' or None, got {repr(self["meas_date"])!r}' + f" or None, got {repr(self['meas_date'])!r}" ) chs = [ch["ch_name"] for ch in self["chs"]] @@ -3673,8 +3673,7 @@ def _write_ch_infos(fid, chs, reset_range, ch_names_mapping): # only write new-style channel information if necessary if len(ch_names_mapping): logger.info( - " Writing channel names to FIF truncated to 15 characters " - "with remapping" + " Writing channel names to FIF truncated to 15 characters with remapping" ) for ch in chs: start_block(fid, FIFF.FIFFB_CH_INFO) From 60ac122019884cac784130b32250b19cfefb8e11 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Thu, 18 Jun 2026 14:15:41 -0400 Subject: [PATCH 15/15] FIX: Restore accidentally broken meas_info.py and fix conf.py ruff linting --- doc/conf.py | 4 - mne/_fiff/meas_info.py | 539 +++++++++++++++++++++++++++++++---------- 2 files changed, 414 insertions(+), 129 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index e2f8a3021b1..b2d974cdad6 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -476,10 +476,6 @@ jupyterlite_contents = ["jupyterlite_contents"] jupyterlite_bind_ipynb_suffix = False -import os -import subprocess -import sys - # 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" diff --git a/mne/_fiff/meas_info.py b/mne/_fiff/meas_info.py index 26dcb2923af..c76e757fff7 100644 --- a/mne/_fiff/meas_info.py +++ b/mne/_fiff/meas_info.py @@ -35,6 +35,7 @@ verbose, warn, ) +from ..utils._bunch import NamedFloat, NamedInt from ._digitization import ( DigPoint, _dig_kind_ints, @@ -46,7 +47,7 @@ write_dig, ) from .compensator import get_current_comp -from .constants import FIFF, _ch_unit_mul_named, _coord_frame_named +from .constants import FIFF, _ch_unit_mul_named from .ctf_comp import _read_ctf_comp, write_ctf_comp from .open import fiff_open from .pick import ( @@ -57,7 +58,12 @@ get_channel_type_constants, pick_types, ) -from .proc_history import _read_proc_history, _write_proc_history +from .proc_history import ( + _read_mf_data, + _read_proc_history, + _write_mf_data, + _write_proc_history, +) from .proj import ( Projection, _normalize_proj, @@ -396,7 +402,7 @@ def set_montage( Returns ------- - inst : instance of Raw | Epochs | Evoked + inst : same type as the input data The instance, modified in-place. See Also @@ -444,6 +450,7 @@ def set_montage( FIFF.FIFF_UNIT_NONE: "NA", FIFF.FIFF_UNIT_CEL: "C", FIFF.FIFF_UNIT_S: "S", + FIFF.FIFF_UNIT_SEC: "s", FIFF.FIFF_UNIT_PX: "px", } @@ -538,7 +545,7 @@ def set_channel_types(self, mapping, *, on_unit_change="warn", verbose=None): Returns ------- - inst : instance of Raw | Epochs | Evoked + inst : same type as the input data The instance (modified in place). .. versionchanged:: 0.20 @@ -626,17 +633,22 @@ def set_channel_types(self, mapping, *, on_unit_change="warn", verbose=None): return self @verbose - def rename_channels(self, mapping, allow_duplicates=False, *, verbose=None): + def rename_channels( + self, mapping, allow_duplicates=False, *, on_missing="raise", verbose=None + ): """Rename channels. Parameters ---------- %(mapping_rename_channels_duplicates)s + %(on_missing_ch_names)s + + .. versionadded:: 1.11.0 %(verbose)s Returns ------- - inst : instance of Raw | Epochs | Evoked + inst : same type as the input data The instance (modified in place). .. versionchanged:: 0.20 @@ -652,7 +664,7 @@ def rename_channels(self, mapping, allow_duplicates=False, *, verbose=None): info = self if isinstance(self, Info) else self.info ch_names_orig = list(info["ch_names"]) - rename_channels(info, mapping, allow_duplicates) + rename_channels(info, mapping, allow_duplicates, on_missing=on_missing) # Update self._orig_units for Raw if isinstance(self, BaseRaw): @@ -781,7 +793,7 @@ def anonymize(self, daysback=None, keep_his=False, verbose=None): Returns ------- - inst : instance of Raw | Epochs | Evoked + inst : same type as the input data The modified instance. Notes @@ -810,8 +822,8 @@ def set_meas_date(self, meas_date): Returns ------- - inst : instance of Raw | Epochs | Evoked - The modified raw instance. Operates in place. + inst : same type as the input data + The modified instance. Operates in place. See Also -------- @@ -827,6 +839,10 @@ def set_meas_date(self, meas_date): """ from ..annotations import _handle_meas_date + _validate_type( + meas_date, (datetime.datetime, "numeric", tuple, None), "meas_date" + ) + info = self if isinstance(self, Info) else self.info meas_date = _handle_meas_date(meas_date) @@ -1011,6 +1027,19 @@ def _check_types(x, *, info, name, types, cast=None): return x +def _check_bday(birthday_input, *, info): + date = _check_types( + birthday_input, + info=info, + name='subject_info["birthday"]', + types=(datetime.date, None), + ) + # test if we have a pd.Timestamp + if hasattr(date, "date"): + date = date.date() + return date + + class SubjectInfo(ValidatedDict): _attributes = { "id": partial(_check_types, name='subject_info["id"]', types=int), @@ -1022,9 +1051,7 @@ class SubjectInfo(ValidatedDict): "middle_name": partial( _check_types, name='subject_info["middle_name"]', types=str ), - "birthday": partial( - _check_types, name='subject_info["birthday"]', types=(datetime.date, None) - ), + "birthday": partial(_check_bday), "sex": partial(_check_types, name='subject_info["sex"]', types=int), "hand": partial(_check_types, name='subject_info["hand"]', types=int), "weight": partial( @@ -1050,6 +1077,12 @@ class HeliumInfo(ValidatedDict): types="numeric", cast=float, ), + "gantry_angle": partial( + _check_types, + name='helium_info["gantry_angle"]', + types="int-like", + cast=int, + ), "helium_level": partial( _check_types, name='helium_info["helium_level"]', @@ -1156,6 +1189,89 @@ def _check_dev_head_t(dev_head_t, *, info): return dev_head_t +def _restore_mne_types(info): + """Restore MNE-specific types after unpickling/deserialization. + + This function handles the restoration of MNE-specific object types + that need to be reconstructed from their serialized representations. + These correspond to the "cast" entries in Info._attributes: bads, + dev_head_t, dig, helium_info, line_freq, proj_id, projs, and + subject_info. However, this function is specifically for types that + need restoration because h5io and other serialization formats cast + them to native Python types (e.g., MNEBadsList -> list, Projection + -> dict, DigPoint -> dict). + + This function should be called in Info.__init__ and Info.__setstate__. + If new MNE-specific types are added to Info._attributes, they + should be handled here if they need type restoration after + deserialization. + + Parameters + ---------- + info : Info + The Info object whose types need to be restored. Modified in-place. + + Notes + ----- + This function restores: + - MNEBadsList for the bads field (see Info._attributes["bads"]) + - DigPoint objects for digitization points (see Info._attributes["dig"]) + - Projection objects for SSP projectors (see Info._attributes["projs"]) + - Transform objects for device/head transformations + - meas_date from tuple to datetime + - helium_info and subject_info with proper casting + - proc_history date field from numpy array to tuple (JSON limitation) + """ + # Restore MNEBadsList (corresponds to Info._attributes["bads"]) + if "bads" in info: + info["bads"] = MNEBadsList(bads=info["bads"], info=info) + + # Format Transform objects + for key in ("dev_head_t", "ctf_head_t", "dev_ctf_t"): + _format_trans(info, key) + for res in info.get("hpi_results", []): + _format_trans(res, "coord_trans") + + # Restore DigPoint objects (corresponds to Info._attributes["dig"]) + if info.get("dig", None) is not None and len(info["dig"]): + if isinstance(info["dig"], dict): # needs to be unpacked + info["dig"] = _dict_unpack(info["dig"], _DIG_CAST) + if not isinstance(info["dig"][0], DigPoint): + info["dig"] = _format_dig_points(info["dig"]) + + # Unpack chs if needed + if isinstance(info.get("chs", None), dict): + info["chs"]["ch_name"] = [ + str(x) for x in np.char.decode(info["chs"]["ch_name"], encoding="utf8") + ] + info["chs"] = _dict_unpack(info["chs"], _CH_CAST) + + # Restore Projection objects (corresponds to Info._attributes["projs"]) + for pi, proj in enumerate(info.get("projs", [])): + if not isinstance(proj, Projection): + info["projs"][pi] = Projection(**proj) + + # Old files could have meas_date as tuple instead of datetime + try: + meas_date = info["meas_date"] + except KeyError: + pass + else: + info["meas_date"] = _ensure_meas_date_none_or_dt(meas_date) + + # with validation and casting + for key in ("helium_info", "subject_info"): + if key in info: + info[key] = info[key] + + # Restore proc_history[*]['date'] as tuple + # JSON converts tuples to lists, so we need to convert back + for entry in info.get("proc_history", []): + if "date" in entry and isinstance(entry["date"], np.ndarray): + # Convert numpy array back to tuple with Python types + entry["date"] = tuple(int(x) for x in entry["date"]) + + # TODO: Add fNIRS convention to loc class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): """Measurement information. @@ -1173,31 +1289,15 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): .. warning:: The only entries that should be manually changed by the user are: - ``info['bads']``, ``info['description']``, ``info['device_info']`` - ``info['dev_head_t']``, ``info['experimenter']``, - ``info['helium_info']``, ``info['line_freq']``, ``info['temp']``, - and ``info['subject_info']``. + ``info['bads']``, ``info['description']``, ``info['device_info']``, + ``info['proj_id']``, ``info['proj_name']``, ``info['dev_head_t']``, + ``info['experimenter']``, ``info['helium_info']``, + ``info['line_freq']``, ``info['temp']``, and ``info['subject_info']``. All other entries should be considered read-only, though they can be modified by various MNE-Python functions or methods (which have safeguards to ensure all fields remain in sync). - Some common methods that safely modify the ``info`` object include: - - * :meth:`mne.io.Raw.add_proj`, :meth:`mne.Epochs.add_proj`, - :meth:`mne.Evoked.add_proj` - * :meth:`mne.io.Raw.del_proj`, :meth:`mne.Epochs.del_proj`, - :meth:`mne.Evoked.del_proj` - * :meth:`mne.io.Raw.rename_channels`, - :meth:`mne.Epochs.rename_channels`, - :meth:`mne.Evoked.rename_channels` - * :meth:`mne.io.Raw.set_channel_types`, - :meth:`mne.Epochs.set_channel_types`, - :meth:`mne.Evoked.set_channel_types` - * :meth:`mne.io.Raw.set_meas_date`, - :meth:`mne.Epochs.set_meas_date`, - :meth:`mne.Evoked.set_meas_date` - Parameters ---------- *args : list @@ -1226,6 +1326,8 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): comps : list of dict CTF software gradient compensation data. See Notes for more information. + cross_talk : dict | None + Cross-talk information added at acquisition time by MEGIN systems. ctf_head_t : Transform | None The transformation from 4D/CTF head coordinates to Neuromag head coordinates. This is only present in 4D/CTF data. @@ -1256,7 +1358,9 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): Name of the person that ran the experiment. file_id : dict | None The FIF globally unique ID. See Notes for more information. - gantry_angle : float | None + fine_calibration : dict | None + Fine calibration information added at acquisition time by MEGIN systems. + gantry_angle : int | None Tilt angle of the gantry in degrees. helium_info : dict | None Information about the device helium. See Notes for details. @@ -1340,6 +1444,7 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): See Also -------- mne.create_info + mne.pick_info Notes ----- @@ -1372,7 +1477,7 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): Eyetrack Element ``[3]`` contains information about which eye was tracked (-1 for left, 1 for right), and element ``[4]`` contains information - about the the axis of coordinate data (-1 for x-coordinate data, 1 for + about the axis of coordinate data (-1 for x-coordinate data, 1 for y-coordinate data). Dipole Elements ``[3:6]`` contain dipole orientation information. @@ -1455,7 +1560,7 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): Helium level (%) after position correction. orig_file_guid : str Original file GUID. - meas_date : datetime.datetime + meas_date : datetime.datetime | None The helium level meas date. .. versionchanged:: 1.8 @@ -1609,6 +1714,7 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): "comps": "comps cannot be set directly. " "Please use method Raw.apply_gradient_compensation() " "instead.", + "cross_talk": "cross_talk cannot be set directly.", "ctf_head_t": "ctf_head_t cannot be set directly.", "custom_ref_applied": "custom_ref_applied cannot be set directly. " "Please use method inst.set_eeg_reference() " @@ -1622,6 +1728,7 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): "events": "events cannot be set directly.", "experimenter": partial(_check_types, name="experimenter", types=(str, None)), "file_id": "file_id cannot be set directly.", + "fine_calibration": "fine_calibration cannot be set directly.", "gantry_angle": "gantry_angle cannot be set directly.", "helium_info": partial( _check_types, name="helium_info", types=(dict, None), cast=HeliumInfo @@ -1649,8 +1756,8 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): "Please use methods inst.add_channels(), " "inst.drop_channels(), and inst.pick() instead.", "proc_history": "proc_history cannot be set directly.", - "proj_id": "proj_id cannot be set directly.", - "proj_name": "proj_name cannot be set directly.", + "proj_id": partial(_check_types, name="proj_id", types=(int, None), cast=int), + "proj_name": partial(_check_types, name="proj_name", types=(str, None)), "projs": "projs cannot be set directly. " "Please use methods inst.add_proj() and inst.del_proj() " "instead.", @@ -1667,39 +1774,8 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._unlocked = True - # Deal with h5io writing things as dict - if "bads" in self: - self["bads"] = MNEBadsList(bads=self["bads"], info=self) - for key in ("dev_head_t", "ctf_head_t", "dev_ctf_t"): - _format_trans(self, key) - for res in self.get("hpi_results", []): - _format_trans(res, "coord_trans") - if self.get("dig", None) is not None and len(self["dig"]): - if isinstance(self["dig"], dict): # needs to be unpacked - self["dig"] = _dict_unpack(self["dig"], _DIG_CAST) - if not isinstance(self["dig"][0], DigPoint): - self["dig"] = _format_dig_points(self["dig"]) - if isinstance(self.get("chs", None), dict): - self["chs"]["ch_name"] = [ - str(x) for x in np.char.decode(self["chs"]["ch_name"], encoding="utf8") - ] - self["chs"] = _dict_unpack(self["chs"], _CH_CAST) - for pi, proj in enumerate(self.get("projs", [])): - if not isinstance(proj, Projection): - self["projs"][pi] = Projection(**proj) - # Old files could have meas_date as tuple instead of datetime - try: - meas_date = self["meas_date"] - except KeyError: - pass - else: - self["meas_date"] = _ensure_meas_date_none_or_dt(meas_date) - self._unlocked = False - # with validation and casting - for key in ("helium_info", "subject_info"): - if key in self: - self[key] = self[key] + with self._unlock(): + _restore_mne_types(self) def __setstate__(self, state): """Set state (for pickling).""" @@ -1945,21 +2021,194 @@ def ch_names(self): return ch_names + @property + def _cals(self): + return np.array([ch["range"] * ch["cal"] for ch in self["chs"]], float) + @repr_html def _repr_html_(self): """Summarize info for HTML representation.""" info_template = _get_html_template("repr", "info.html.jinja") return info_template.render(info=self) - def save(self, fname): + @verbose + def save(self, fname, *, overwrite=False, verbose=None): """Write measurement info in fif file. Parameters ---------- fname : path-like The name of the file. Should end by ``'-info.fif'``. + %(overwrite)s + + .. versionadded:: 1.10 + %(verbose)s + + See Also + -------- + mne.io.write_info + """ + write_info(fname, self, overwrite=overwrite) + + def to_json_dict(self) -> dict: + """Convert Info to a JSON-serializable dictionary. + + This method converts the Info object to a standard Python dictionary + containing only JSON-serializable types (dict, list, str, int, float, + bool, None). Numpy arrays are converted to nested lists, and datetime + objects to ISO format strings. + + Returns + ------- + dict + A JSON-serializable dictionary representation of the Info object. + + See Also + -------- + from_json_dict : Reconstruct Info object from dictionary. + + Notes + ----- + This method is useful for serializing Info objects to JSON or other + formats that don't support numpy arrays or custom objects. + + Examples + -------- + >>> info = mne.create_info(['MEG1', 'MEG2'], 1000., ['mag', 'mag']) + >>> info_dict = info.to_json_dict() + >>> import json + >>> json_str = json.dumps(info_dict) # Save to JSON """ - write_info(fname, self) + return _make_serializable(self) + + @classmethod + def from_json_dict(cls, data_dict) -> "Info": + """Reconstruct Info object from a dictionary. + + Parameters + ---------- + data_dict : dict + A dictionary representation of an Info object, typically + created by the :meth:`to_json_dict` method. + + Returns + ------- + Info + The reconstructed Info object. + + See Also + -------- + to_json_dict : Convert Info to dictionary. + + Examples + -------- + >>> info = mne.create_info(['MEG1', 'MEG2'], 1000., ['mag', 'mag']) + >>> info_dict = info.to_json_dict() + >>> info_restored = mne.Info.from_json_dict(info_dict) + """ + data_dict = data_dict.copy() + # Restore all nested objects (Transform, NamedInt, etc.) + restored_dict = _restore_objects(data_dict) + + info = cls() + with info._unlock(): + info.update(restored_dict) + _restore_mne_types(info) + + return info + + +def _make_serializable(obj): + """Recursively convert objects to JSON-serializable types.""" + from ..transforms import Transform + + if obj is None: + return None + elif isinstance(obj, bool): + return obj + elif isinstance(obj, NamedInt): + # Preserve NamedInt with its name + return {"_mne_type": "NamedInt", "value": int(obj), "name": obj._name} + elif isinstance(obj, NamedFloat): + # Preserve NamedFloat with its name + return {"_mne_type": "NamedFloat", "value": float(obj), "name": obj._name} + elif isinstance(obj, (str, int, float)): + return obj + elif isinstance(obj, np.integer): + return int(obj) + elif isinstance(obj, np.floating): + return float(obj) + elif isinstance(obj, np.ndarray): + return obj.tolist() + elif isinstance(obj, datetime.datetime): + # Tag datetime objects for proper reconstruction + return {"_mne_type": "datetime", "value": obj.isoformat()} + elif isinstance(obj, datetime.date): + # Tag date objects for proper reconstruction + return {"_mne_type": "date", "value": obj.isoformat()} + elif isinstance(obj, Transform): + # Tag Transform objects for proper reconstruction + return { + "_mne_type": "Transform", + "from": obj["from"], + "to": obj["to"], + "trans": obj["trans"].tolist(), + } + elif isinstance(obj, (list, tuple)): + return [_make_serializable(item) for item in obj] + elif isinstance(obj, dict): + return {key: _make_serializable(val) for key, val in obj.items()} + else: + # Try to convert to string as fallback + return str(obj) + + +def _restore_objects(obj) -> object: + """Recursively restore objects from JSON-serializable types.""" + if obj is None: + return None + elif isinstance(obj, (bool, int, float)): + return obj + elif isinstance(obj, str): + # Regular strings are returned as-is + return obj + elif isinstance(obj, list): + # Check if all elements are numbers - convert to numpy array + # (JSON doesn't distinguish between tuples and lists, so 1D numeric + # lists that came from numpy arrays should be restored as numpy arrays, + # while tuple fields like proc_history[date] are handled separately) + if len(obj) > 0 and all(isinstance(x, (int, float)) for x in obj): + # 1D numeric arrays should be converted to numpy arrays + return np.array(obj) + elif len(obj) > 0 and all(isinstance(x, list) for x in obj): + # 2D or higher dimensional arrays + return np.array(obj) + else: + # Mixed types - recursively restore elements + return [_restore_objects(item) for item in obj] + elif isinstance(obj, dict): + # Check if this is a tagged MNE type + if "_mne_type" in obj: + if obj["_mne_type"] == "Transform": + # Actually create the Transform object now + from ..transforms import Transform + + return Transform(obj["from"], obj["to"], np.array(obj["trans"])) + elif obj["_mne_type"] == "NamedInt": + return NamedInt(obj["name"], obj["value"]) + elif obj["_mne_type"] == "NamedFloat": + return NamedFloat(obj["name"], obj["value"]) + elif obj["_mne_type"] == "datetime": + # Restore datetime object from ISO format string + return datetime.datetime.fromisoformat(obj["value"]) + elif obj["_mne_type"] == "date": + # Restore date object from ISO format string + return datetime.date.fromisoformat(obj["value"]) + # Add more types here if needed in the future + else: + return {key: _restore_objects(val) for key, val in obj.items()} + else: + return obj def _simplify_info(info, *, keep=()): @@ -1977,7 +2226,7 @@ def _simplify_info(info, *, keep=()): @verbose -def read_fiducials(fname, verbose=None): +def read_fiducials(fname, *, verbose=None): """Read fiducials from a fiff file. Parameters @@ -1997,26 +2246,8 @@ def read_fiducials(fname, verbose=None): fname = _check_fname(fname=fname, overwrite="read", must_exist=True) fid, tree, _ = fiff_open(fname) with fid: - isotrak = dir_tree_find(tree, FIFF.FIFFB_ISOTRAK) - isotrak = isotrak[0] - pts = [] - coord_frame = FIFF.FIFFV_COORD_HEAD - for k in range(isotrak["nent"]): - kind = isotrak["directory"][k].kind - pos = isotrak["directory"][k].pos - if kind == FIFF.FIFF_DIG_POINT: - tag = read_tag(fid, pos) - pts.append(DigPoint(tag.data)) - elif kind == FIFF.FIFF_MNE_COORD_FRAME: - tag = read_tag(fid, pos) - coord_frame = tag.data[0] - coord_frame = _coord_frame_named.get(coord_frame, coord_frame) - - # coord_frame is not stored in the tag - for pt in pts: - pt["coord_frame"] = coord_frame - - return pts, coord_frame + pts = _read_dig_fif(fid, tree) + return pts, pts[0]["coord_frame"] @verbose @@ -2106,7 +2337,7 @@ def _write_bad_channels(fid, bads, ch_names_mapping): ch_names_mapping = {} if ch_names_mapping is None else ch_names_mapping bads = _rename_list(bads, ch_names_mapping) start_block(fid, FIFF.FIFFB_MNE_BAD_CHANNELS) - write_name_list_sanitized(fid, FIFF.FIFF_MNE_CH_NAME_LIST, bads, "bads") + write_name_list_sanitized(fid, FIFF.FIFF_MNE_CH_NAME_LIST, bads, name="bads") end_block(fid, FIFF.FIFFB_MNE_BAD_CHANNELS) @@ -2139,14 +2370,14 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): if len(meas) == 0: raise ValueError("Could not find measurement data") if len(meas) > 1: - raise ValueError("Cannot read more that 1 measurement data") + raise ValueError("Cannot read more than 1 measurement data") meas = meas[0] meas_info = dir_tree_find(meas, FIFF.FIFFB_MEAS_INFO) if len(meas_info) == 0: raise ValueError("Could not find measurement info") if len(meas_info) > 1: - raise ValueError("Cannot read more that 1 measurement info") + raise ValueError("Cannot read more than 1 measurement info") meas_info = meas_info[0] # Read measurement info @@ -2230,7 +2461,7 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): description = tag.data elif kind == FIFF.FIFF_PROJ_ID: tag = read_tag(fid, pos) - proj_id = tag.data + proj_id = int(tag.data.item()) elif kind == FIFF.FIFF_PROJ_NAME: tag = read_tag(fid, pos) proj_name = tag.data @@ -2239,7 +2470,7 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): line_freq = float(tag.data.item()) elif kind == FIFF.FIFF_GANTRY_ANGLE: tag = read_tag(fid, pos) - gantry_angle = float(tag.data.item()) + gantry_angle = int(tag.data.item()) elif kind in [FIFF.FIFF_MNE_CUSTOM_REF, 236]: # 236 used before v0.11 tag = read_tag(fid, pos) custom_ref_applied = int(tag.data.item()) @@ -2374,6 +2605,10 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): for k in range(hpi_meas["nent"]): kind = hpi_meas["directory"][k].kind pos = hpi_meas["directory"][k].pos + if kind == FIFF.FIFF_BLOCK_ID: + hm["block_id"] = read_tag(fid, pos).data + if kind == FIFF.FIFF_PARENT_BLOCK_ID: + hm["parent_id"] = read_tag(fid, pos).data if kind == FIFF.FIFF_CREATOR: hm["creator"] = str(read_tag(fid, pos).data) elif kind == FIFF.FIFF_SFREQ: @@ -2498,6 +2733,9 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): if kind == FIFF.FIFF_HE_LEVEL_RAW: tag = read_tag(fid, pos) hi["he_level_raw"] = float(tag.data.item()) + elif kind == FIFF.FIFF_GANTRY_ANGLE: + tag = read_tag(fid, pos) + hi["gantry_angle"] = int(tag.data.item()) elif kind == FIFF.FIFF_HELIUM_LEVEL: tag = read_tag(fid, pos) hi["helium_level"] = float(tag.data.item()) @@ -2509,6 +2747,8 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): hi["meas_date"] = _ensure_meas_date_none_or_dt( tuple(int(t) for t in tag.data), ) + if "meas_date" not in hi: + hi["meas_date"] = None info["helium_info"] = hi del hi @@ -2540,6 +2780,14 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None): hs["hpi_coils"] = hc info["hpi_subsystem"] = hs + # Read cross-talk and fine cal + cross_talk = _read_mf_data(fid, tree, kind="sss_ctc") + if len(cross_talk): + info["cross_talk"] = cross_talk + fine_calibration = _read_mf_data(fid, tree, kind="sss_cal") + if len(fine_calibration): + info["fine_calibration"] = fine_calibration + # Read processing history info["proc_history"] = _read_proc_history(fid, tree) @@ -2755,6 +3003,10 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): # HPI Measurement for hpi_meas in info["hpi_meas"]: start_block(fid, FIFF.FIFFB_HPI_MEAS) + if hpi_meas.get("block_id") is not None: + write_id(fid, FIFF.FIFF_BLOCK_ID, hpi_meas["block_id"]) + if hpi_meas.get("parent_id") is not None: + write_id(fid, FIFF.FIFF_PARENT_BLOCK_ID, hpi_meas["parent_id"]) if hpi_meas.get("creator") is not None: write_string(fid, FIFF.FIFF_CREATOR, hpi_meas["creator"]) if hpi_meas.get("sfreq") is not None: @@ -2808,13 +3060,6 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): if info["dev_ctf_t"] is not None: write_coord_trans(fid, info["dev_ctf_t"]) - # Projectors - ch_names_mapping = _make_ch_names_mapping(info["chs"]) - _write_proj(fid, info["projs"], ch_names_mapping=ch_names_mapping) - - # Bad channels - _write_bad_channels(fid, info["bads"], ch_names_mapping=ch_names_mapping) - # General if info.get("experimenter") is not None: write_string(fid, FIFF.FIFF_EXPERIMENTER, info["experimenter"]) @@ -2836,18 +3081,15 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): write_float(fid, FIFF.FIFF_HIGHPASS, info["highpass"]) if info.get("line_freq") is not None: write_float(fid, FIFF.FIFF_LINE_FREQ, info["line_freq"]) - if info.get("gantry_angle") is not None: - write_float(fid, FIFF.FIFF_GANTRY_ANGLE, info["gantry_angle"]) if data_type is not None: write_int(fid, FIFF.FIFF_DATA_PACK, data_type) + if info.get("gantry_angle") is not None: + write_int(fid, FIFF.FIFF_GANTRY_ANGLE, info["gantry_angle"]) if info.get("custom_ref_applied"): write_int(fid, FIFF.FIFF_MNE_CUSTOM_REF, info["custom_ref_applied"]) if info.get("xplotter_layout"): write_string(fid, FIFF.FIFF_XPLOTTER_LAYOUT, info["xplotter_layout"]) - # Channel information - _write_ch_infos(fid, info["chs"], reset_range, ch_names_mapping) - # Subject information if info.get("subject_info") is not None: start_block(fid, FIFF.FIFFB_SUBJECT) @@ -2875,6 +3117,16 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): end_block(fid, FIFF.FIFFB_SUBJECT) del si + # Projectors + ch_names_mapping = _make_ch_names_mapping(info["chs"]) + _write_proj(fid, info["projs"], ch_names_mapping=ch_names_mapping) + + # Channel information + _write_ch_infos(fid, info["chs"], reset_range, ch_names_mapping) + + _write_mf_data(fid, info, kind="sss_ctc", key="cross_talk") + _write_mf_data(fid, info, kind="sss_cal", key="fine_calibration") + if info.get("device_info") is not None: start_block(fid, FIFF.FIFFB_DEVICE) di = info["device_info"] @@ -2891,11 +3143,14 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): hi = info["helium_info"] if hi.get("he_level_raw") is not None: write_float(fid, FIFF.FIFF_HE_LEVEL_RAW, hi["he_level_raw"]) + if hi.get("gantry_angle") is not None: + write_int(fid, FIFF.FIFF_GANTRY_ANGLE, hi["gantry_angle"]) if hi.get("helium_level") is not None: write_float(fid, FIFF.FIFF_HELIUM_LEVEL, hi["helium_level"]) if hi.get("orig_file_guid") is not None: write_string(fid, FIFF.FIFF_ORIG_FILE_GUID, hi["orig_file_guid"]) - write_int(fid, FIFF.FIFF_MEAS_DATE, _dt_to_stamp(hi["meas_date"])) + if hi.get("meas_date", None) is not None: + write_int(fid, FIFF.FIFF_MEAS_DATE, _dt_to_stamp(hi["meas_date"])) end_block(fid, FIFF.FIFFB_HELIUM) del hi @@ -2915,6 +3170,9 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): end_block(fid, FIFF.FIFFB_HPI_SUBSYSTEM) del hs + # Bad channels + _write_bad_channels(fid, info["bads"], ch_names_mapping=ch_names_mapping) + # CTF compensation info comps = info["comps"] if ch_names_mapping: @@ -2932,8 +3190,10 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): _write_proc_history(fid, info) -@fill_doc -def write_info(fname, info, data_type=None, reset_range=True): +@verbose +def write_info( + fname, info, *, data_type=None, reset_range=True, overwrite=False, verbose=None +): """Write measurement info in fif file. Parameters @@ -2947,8 +3207,10 @@ def write_info(fname, info, data_type=None, reset_range=True): raw data. reset_range : bool If True, info['chs'][k]['range'] will be set to unity. + %(overwrite)s + %(verbose)s """ - with start_and_end_file(fname) as fid: + with start_and_end_file(fname, overwrite=overwrite) as fid: start_block(fid, FIFF.FIFFB_MEAS) write_meas_info(fid, info, data_type, reset_range) end_block(fid, FIFF.FIFFB_MEAS) @@ -3026,6 +3288,21 @@ def _where_isinstance(values, kind): return values[int(idx)] elif len(idx) > 1: raise RuntimeError(msg) + # proj_id + elif _check_isinstance(values, (int, type(None)), all) and key == "proj_id": + unique_values = set(values) + if len(unique_values) != 1: + logger.info("Found multiple proj_ids, using the first one.") + return list(unique_values)[0] + + elif key == "experimenter" or key == "proj_name": + if _check_isinstance(values, (str, type(None)), all): + unique_values = set(values) + unique_values.discard(None) + if len(unique_values) == 1: + return list(unique_values)[0] + else: + return None # other else: unique_values = set(values) @@ -3035,7 +3312,7 @@ def _where_isinstance(values, kind): logger.info("Found multiple StringIO instances. Setting value to `None`") return None elif isinstance(list(unique_values)[0], str): - logger.info("Found multiple filenames. Setting value to `None`") + logger.info(f"Found multiple {key}. Setting value to `None`") return None else: raise RuntimeError(msg) @@ -3317,13 +3594,12 @@ def create_info(ch_names, sfreq, ch_types="misc", verbose=None): def _empty_info(sfreq): """Create an empty info dictionary.""" - from ..transforms import Transform - _none_keys = ( "acq_pars", "acq_stim", "ctf_head_t", "description", + "dev_head_t", "dev_ctf_t", "dig", "experimenter", @@ -3364,7 +3640,6 @@ def _empty_info(sfreq): info["highpass"] = 0.0 info["sfreq"] = float(sfreq) info["lowpass"] = info["sfreq"] / 2.0 - info["dev_head_t"] = Transform("meg", "head") info._update_redundant() info._check_consistency() return info @@ -3435,6 +3710,18 @@ def anonymize_info(info, daysback=None, keep_his=False, verbose=None): """ _validate_type(info, "info", "self") + valid_fields = {"his_id", "sex", "hand"} + if isinstance(keep_his, bool): # True means keep all fields, False means keep none + keep_fields = valid_fields if keep_his else set() + elif isinstance(keep_his, str): + _check_option("keep_his", keep_his, valid_fields) + keep_fields = {keep_his} + else: + _validate_type(keep_his, (list, tuple, set), "keep_his") + keep_fields = set(keep_his) + for field in keep_fields: + _check_option("keep_his", field, valid_fields) + default_anon_dos = datetime.datetime( 2000, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc ) @@ -3485,17 +3772,19 @@ def anonymize_info(info, daysback=None, keep_his=False, verbose=None): if subject_info is not None: if subject_info.get("id") is not None: subject_info["id"] = default_subject_id - if keep_his: + if keep_fields: logger.info( - "Not fully anonymizing info - keeping his_id, sex, and hand info" + f"Not fully anonymizing info - keeping {', '.join(sorted(keep_fields))}" + " of subject_info" ) - else: + if "his_id" not in keep_fields: if subject_info.get("his_id") is not None: subject_info["his_id"] = str(default_subject_id) + if "sex" not in keep_fields: if subject_info.get("sex") is not None: subject_info["sex"] = default_sex - if subject_info.get("hand") is not None: - del subject_info["hand"] # there's no "unknown" setting + if "hand" not in keep_fields: + subject_info.pop("hand", None) # there's no "unknown" setting for key in ("last_name", "first_name", "middle_name"): if subject_info.get(key) is not None: @@ -3515,7 +3804,7 @@ def anonymize_info(info, daysback=None, keep_his=False, verbose=None): info["description"] = default_desc with info._unlock(): if info["proj_id"] is not None: - info["proj_id"] = np.zeros_like(info["proj_id"]) + info["proj_id"] = 0 if info["proj_name"] is not None: info["proj_name"] = default_str if info["utc_offset"] is not None: