Source code for mne_rt.tools.bids_io

"""BIDS-format I/O utilities for MNE-RT session data.

Functions
---------
save_as_bids
    Save a raw EEG recording and NF feature time-series in BIDS layout.

Notes
-----
``mne_bids`` is used when installed (``pip install mne-bids``).  When it is
absent, a minimal BIDS-compliant layout is written manually: the raw recording
as ``.fif``, NF data as ``_beh.tsv``, and stub
``dataset_description.json`` / ``participants.tsv`` files.

References
----------
Appelhoff, S., et al. (2019). MNE-BIDS: Organizing electrophysiological data
into the BIDS format and facilitating their analysis.
Journal of Open Source Software, 4(44), 1896.
"""

from __future__ import annotations

import csv
import json
import logging
from pathlib import Path
from typing import Optional, Union

import mne
import numpy as np

logger = logging.getLogger(__name__)


[docs] def save_as_bids( raw: mne.io.BaseRaw, nf_data: dict, output_dir: Union[str, Path], subject: str, session: Optional[str] = None, task: str = "neurofeedback", run: Optional[str] = None, overwrite: bool = False, verbose: Union[bool, str, None] = None, ) -> Path: """Save a neurofeedback session in BIDS format. Writes the raw EEG recording as a BIDS EEG dataset and exports the per-window NF feature time-series as a ``*_beh.tsv`` behavioural side-car file. Parameters ---------- raw : mne.io.BaseRaw The raw EEG (or MEG) recording to export. nf_data : dict Dictionary mapping modality name → list of per-window values (as returned by :meth:`~mne_rt.RTStream.record_main`). output_dir : str | Path Root BIDS directory (created if it does not exist). subject : str BIDS subject label (without the ``sub-`` prefix). session : str | None, default None BIDS session label (without the ``ses-`` prefix). ``None`` omits the session level. task : str, default "neurofeedback" BIDS task label. run : str | None, default None BIDS run label (without the ``run-`` prefix). overwrite : bool, default False Overwrite existing files. verbose : bool | str | None, default None Verbosity passed to mne-bids. Returns ------- bids_path : Path Root BIDS output directory. Raises ------ FileExistsError If ``overwrite=False`` and output files already exist. Notes ----- If ``mne_bids`` is installed (``pip install mne-bids``) it is used for writing the EEG data with full BIDS compliance. Otherwise a minimal BIDS-like layout is written manually (EEG as ``.fif``, NF data as ``_beh.tsv``, and stub ``dataset_description.json`` / ``participants.tsv`` files). .. versionadded:: 1.0.0 """ output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) _write_dataset_description(output_dir, overwrite=overwrite) _update_participants_tsv(output_dir, subject=subject) try: import mne_bids # noqa: F401 _save_with_mne_bids( raw=raw, nf_data=nf_data, output_dir=output_dir, subject=subject, session=session, task=task, run=run, overwrite=overwrite, verbose=verbose, ) except ImportError: logger.info( "mne-bids is not installed; falling back to minimal BIDS layout. " "Install with: pip install mne-bids" ) _save_minimal_bids( raw=raw, nf_data=nf_data, output_dir=output_dir, subject=subject, session=session, task=task, run=run, overwrite=overwrite, ) return output_dir
# --------------------------------------------------------------------------- # mne-bids backend # --------------------------------------------------------------------------- def _save_with_mne_bids( raw: mne.io.BaseRaw, nf_data: dict, output_dir: Path, subject: str, session: Optional[str], task: str, run: Optional[str], overwrite: bool, verbose: Union[bool, str, None], ) -> None: """Write raw data via mne_bids.write_raw_bids and NF data as _beh.tsv.""" import mne_bids bids_path = mne_bids.BIDSPath( subject=subject, session=session, task=task, run=run, root=output_dir, datatype="eeg", ) mne_bids.write_raw_bids( raw, bids_path=bids_path, overwrite=overwrite, verbose=verbose, ) _write_nf_beh_tsv( nf_data=nf_data, output_dir=output_dir, subject=subject, session=session, task=task, run=run, overwrite=overwrite, ) logger.info("Session saved via mne-bids to %s", output_dir) # --------------------------------------------------------------------------- # Minimal (manual) BIDS backend # --------------------------------------------------------------------------- def _save_minimal_bids( raw: mne.io.BaseRaw, nf_data: dict, output_dir: Path, subject: str, session: Optional[str], task: str, run: Optional[str], overwrite: bool, ) -> None: """Write raw as .fif and NF data as _beh.tsv in a BIDS folder tree.""" entity_dir = _build_entity_dir(output_dir=output_dir, subject=subject, session=session) eeg_dir = entity_dir / "eeg" eeg_dir.mkdir(parents=True, exist_ok=True) stem = _build_bids_stem(subject=subject, session=session, task=task, run=run) fif_path = eeg_dir / f"{stem}_eeg.fif" if fif_path.exists() and not overwrite: raise FileExistsError( f"Output file already exists: {fif_path}. Set overwrite=True to overwrite." ) raw.save(fif_path, overwrite=overwrite, verbose=False) logger.info("Raw saved to %s", fif_path) _write_nf_beh_tsv( nf_data=nf_data, output_dir=output_dir, subject=subject, session=session, task=task, run=run, overwrite=overwrite, ) # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- def _build_entity_dir(output_dir: Path, subject: str, session: Optional[str]) -> Path: parts = [f"sub-{subject}"] if session is not None: parts.append(f"ses-{session}") return output_dir.joinpath(*parts) def _build_bids_stem( subject: str, session: Optional[str], task: str, run: Optional[str], ) -> str: parts = [f"sub-{subject}"] if session is not None: parts.append(f"ses-{session}") parts.append(f"task-{task}") if run is not None: parts.append(f"run-{run}") return "_".join(parts) def _write_nf_beh_tsv( nf_data: dict, output_dir: Path, subject: str, session: Optional[str], task: str, run: Optional[str], overwrite: bool, windows: Optional[dict] = None, reward: Optional[dict] = None, snr: Optional[list] = None, ) -> None: """Write per-window NF feature values to a _beh.tsv side-car file.""" entity_dir = _build_entity_dir(output_dir=output_dir, subject=subject, session=session) beh_dir = entity_dir / "beh" beh_dir.mkdir(parents=True, exist_ok=True) stem = _build_bids_stem(subject=subject, session=session, task=task, run=run) tsv_path = beh_dir / f"{stem}_beh.tsv" if tsv_path.exists() and not overwrite: raise FileExistsError( f"Behavioural TSV already exists: {tsv_path}. Set overwrite=True to overwrite." ) if not nf_data: logger.info("nf_data is empty; skipping _beh.tsv") return write_nf_beh_tsv( tsv_path, nf_data=nf_data, windows=windows, reward=reward, snr=snr, sidecar=True ) logger.info("NF behavioural data saved to %s", tsv_path) #: Columns that carry a time in seconds. These need fixed-point formatting: at a #: session-relative onset of ~1234 s, ``%.6g`` rounds to 10 ms, which is far too #: coarse to align a window against a stimulus log. _TIME_COLUMNS = ("onset", "duration") #: Trial-level columns, written next to the timing they describe and ahead of #: the features. Each appears only when the session recorded it, so a run with #: no marker stream produces the same table as before. _MARKER_COLUMNS = ("condition", "n_markers", "gated") def _nf_columns(*, nf_data, windows=None, reward=None, snr=None): """Assemble the ordered column names and their per-window series. BIDS requires ``onset`` first and ``duration`` second, so the timing columns lead; features, rewards and SNR follow. """ columns: list[str] = [] combined: dict[str, list] = {} for name in _TIME_COLUMNS + _MARKER_COLUMNS: if windows and windows.get(name): columns.append(name) combined[name] = list(windows[name]) for name, vals in nf_data.items(): columns.append(name) combined[name] = list(vals) for name, vals in (reward or {}).items(): if vals: columns.append(f"reward_{name}") combined[f"reward_{name}"] = list(vals) if snr: columns.append("snr_db") combined["snr_db"] = list(snr) return columns, combined def write_nf_beh_tsv( tsv_path: Union[str, Path], *, nf_data: dict, windows: Optional[dict] = None, reward: Optional[dict] = None, snr: Optional[list] = None, sidecar: bool = False, meta: Optional[dict] = None, ) -> Path: """Write per-window neurofeedback values as a BIDS ``_beh.tsv``. The single writer for both :meth:`~mne_rt.RTStream.save` and :func:`save_as_bids`, which previously carried separate implementations that had already drifted apart on which columns they emit and how they format floats. Parameters ---------- tsv_path : str | path-like Destination ``.tsv`` file. nf_data : dict ``{modality: [value per window]}``. Ragged lists are padded with ``n/a``. windows : dict | None Optional per-window timing, ``{"onset": [...], "duration": [...]}``. BIDS requires ``onset`` first and ``duration`` second, so these are emitted ahead of every feature column. reward : dict | None ``{modality: [magnitude per window]}``, written as ``reward_<modality>``. Empty series are skipped. snr : list | None Per-window SNR in dB, written as ``snr_db``. sidecar : bool, default False Also write the ``_beh.json`` sidecar describing the columns. BIDS wants one whenever column names are not part of the standard vocabulary, which for a neurofeedback file is all of them. meta : dict | None Session metadata for the sidecar (``sfreq_hz``, ``winsize_s`` …). Returns ------- path : Path The file written. Notes ----- Feature values are written to six significant figures, matching what the BIDS writer has always emitted. That is a readable table, not the archival record: the session JSON keeps full float precision, so prefer it when re-analysing. Time columns are fixed-point instead, because six significant figures on an onset of ~1e3 s would quantise it to 10 ms. """ tsv_path = Path(tsv_path) tsv_path.parent.mkdir(parents=True, exist_ok=True) columns, combined = _nf_columns(nf_data=nf_data, windows=windows, reward=reward, snr=snr) n_rows = max((len(v) for v in combined.values()), default=0) with open(tsv_path, "w", newline="", encoding="utf-8") as fh: writer = csv.writer(fh, delimiter="\t") writer.writerow(columns) for i in range(n_rows): row = [] for col in columns: vals = combined[col] if i >= len(vals): row.append("n/a") continue val = vals[i] if val is None: # BIDS spells a missing value "n/a"; str(None) would write # the literal word "None" into the table. row.append("n/a") continue if isinstance(val, (int, float, np.floating, np.integer)): fmt = "%.6f" if col in _TIME_COLUMNS else "%.6g" row.append(fmt % float(val)) else: row.append(str(val)) writer.writerow(row) if sidecar: _write_beh_sidecar(tsv_path.with_suffix(".json"), columns=columns, meta=meta or {}) return tsv_path def describe_nf_columns( *, nf_data: dict, windows: Optional[dict] = None, reward: Optional[dict] = None, snr: Optional[list] = None, meta: Optional[dict] = None, ) -> dict: """Describe the ``_beh.tsv`` columns, in BIDS sidecar form. Exposed separately because :meth:`~mne_rt.RTStream.save` cannot write a standalone sidecar: BIDS would name it ``<stem>_beh.json``, which is already the session payload's filename. That payload embeds this dict instead. """ columns = _nf_columns(nf_data=nf_data, windows=windows, reward=reward, snr=snr)[0] return _column_descriptions(columns, meta or {}) def _write_beh_sidecar(path: Path, *, columns: list, meta: dict) -> None: """Write the ``_beh.json`` sidecar describing each TSV column.""" with open(path, "w", encoding="utf-8") as fh: json.dump(_column_descriptions(columns, meta), fh, indent=2) def _column_descriptions(columns: list, meta: dict) -> dict: """Describe each ``_beh.tsv`` column, as BIDS asks for non-standard names.""" descriptions: dict[str, dict] = {} for col in columns: if col == "onset": descriptions[col] = { "LongName": "Analysis window onset", "Description": ( "Onset of the analysis window, relative to the first window of " "the run. Absolute onsets on the LSL clock are in the session " "JSON, for alignment against a stimulus log." ), "Units": "s", } elif col == "duration": descriptions[col] = { "LongName": "Analysis window duration", "Description": "Length of the analysis window.", "Units": "s", } elif col == "condition": descriptions[col] = { "LongName": "Experimental condition", "Description": ( "Label of the most recent marker at or before the end of this " "window, from the marker stream's marker_id map. Latched, so it " "is defined for every window after the first marker." ), } _levels = {k: k for k in sorted(meta.get("marker_id", {}) or {})} if _levels: # Omitted rather than written as null: a reader doing # ``Levels.items()`` would get an AttributeError, and codes with # no label are recorded as the code itself and never appear here. descriptions[col]["Levels"] = _levels elif col == "n_markers": descriptions[col] = { "LongName": "Markers received for this window", "Description": ( "Number of markers that arrived since the previous window ended. " "Windows overlap by half, so counting markers *inside* each window " "would count most of them twice; this partitions the run, and the " "column sums to the number of markers received." ), } elif col == "gated": descriptions[col] = { "LongName": "Feedback withheld", "Description": ( "1 when the window fell outside gate_conditions, so no protocol " "was evaluated and nothing was sent to the stimulus program. The " "feature value is still recorded." ), "Levels": {"0": "feedback delivered", "1": "feedback withheld"}, } elif col == "snr_db": descriptions[col] = { "LongName": "Signal-to-noise ratio", "Description": "In-band power over out-of-band power for the window.", "Units": "dB", } elif col.startswith("reward_"): descriptions[col] = { "LongName": f"Reward magnitude for {col[len('reward_') :]}", "Description": ("Protocol magnitude when the reward criterion was met, else 0.0."), } else: descriptions[col] = { "LongName": f"Neurofeedback value for {col}", "Description": ( "Feature value for this analysis window, after any z-scoring and smoothing." ), "Units": "z-score" if meta.get("zscore_normalize") else "arbitrary", } for key in ("sfreq_hz", "winsize_s", "hop_s"): if meta.get(key) is not None: descriptions[key] = meta[key] return descriptions def _write_dataset_description(output_dir: Path, overwrite: bool) -> None: """Write a minimal dataset_description.json if it does not exist.""" desc_path = output_dir / "dataset_description.json" if desc_path.exists() and not overwrite: return payload = { "Name": "MNE-RT Dataset", "BIDSVersion": "1.9.0", "GeneratedBy": [{"Name": "MNE-RT", "Version": "1.0.0"}], "DatasetType": "raw", } with open(desc_path, "w", encoding="utf-8") as fh: json.dump(payload, fh, indent=2) logger.info("dataset_description.json written to %s", desc_path) def _update_participants_tsv(output_dir: Path, subject: str) -> None: """Create or append to participants.tsv.""" tsv_path = output_dir / "participants.tsv" sub_label = f"sub-{subject}" if tsv_path.exists(): with open(tsv_path, "r", encoding="utf-8") as fh: reader = csv.DictReader(fh, delimiter="\t") rows = list(reader) fieldnames = reader.fieldnames or ["participant_id"] existing_ids = {r.get("participant_id") for r in rows} if sub_label in existing_ids: return rows.append({"participant_id": sub_label}) else: fieldnames = ["participant_id"] rows = [{"participant_id": sub_label}] with open(tsv_path, "w", newline="", encoding="utf-8") as fh: writer = csv.DictWriter(fh, fieldnames=fieldnames, delimiter="\t") writer.writeheader() writer.writerows(rows) logger.info("participants.tsv updated at %s", tsv_path)