Source code for mne_rt.tools.simulation

"""Simulated M/EEG data generation for offline testing and demos.

This module provides two simulation functions:

* :func:`simulate_raw` — physics-based EEG/MEG via the MNE fsaverage source
  space and forward model (slow; requires the MNE sample dataset).
* :func:`simulate_nf_session` — fast parametric EEG with realistic 1/f
  background, alpha oscillations, eye blinks, muscle bursts, and slow drift
  (no MRI data required; useful for unit tests and NF algorithm development).

Functions
---------
simulate_raw
    Generate a synthetic EEG or MEG Raw object with a dipolar source.
simulate_nf_session
    Generate a realistic multi-artifact EEG simulation for NF testing.
"""
from __future__ import annotations

import os
from pathlib import Path
from typing import Optional, Union

import numpy as np

import mne
from mne.datasets import fetch_fsaverage
from mne.label import select_sources
from mne.simulation import (
    SourceSimulator,
    simulate_raw as _mne_simulate_raw,
    add_noise,
    add_eog,
)

_MEG_AMPLITUDE_SCALE = 1e-12   # 1 pT — typical MEG dipole projection amplitude
_EEG_AMPLITUDE_SCALE = 10e-9   # 10 nV scaling factor (matches original code)


[docs] def simulate_raw( brain_label: str, frequency: float, amplitude: float, duration: float, gap_duration: float, n_repetition: int, start: float, data_type: str = "eeg", sfreq: float = 256.0, n_eeg_channels: int = 64, iir_filter: list = [0.2, -0.2, 0.04], add_eog_artifacts: bool = True, fname_save: Optional[Union[str, Path]] = None, verbose: Union[bool, str, None] = None, ) -> mne.io.RawArray: """Generate a synthetic EEG or MEG recording with a sinusoidal source. A forward solution is created for the ``fsaverage`` template brain and a sinusoidal dipole is injected into the specified cortical label. The signal is projected to sensor space, optionally repeated with inter-epoch gaps, sensor noise is added, and the result is returned (and optionally saved) as an :class:`mne.io.RawArray`. Parameters ---------- brain_label : str Regexp matching a cortical label in the fsaverage parcellation (e.g. ``"bankssts-lh"`` for alpha, ``"precentral-lh"`` for motor). frequency : float Frequency of the simulated sine wave (Hz). amplitude : float Amplitude scaling factor. Multiplied by ``10e-9`` for EEG or ``1e-12`` for MEG; pass ``1.0`` for a standard physiological signal. duration : float Duration of each signal epoch (seconds). gap_duration : float Silence gap between consecutive epochs (seconds). n_repetition : int Number of epochs to simulate. start : float Start time of the first epoch (seconds from recording start). data_type : {"eeg", "meg"}, default "eeg" Sensor modality to simulate. ``"meg"`` creates a magnetometer (gradiometer-free) sensor layout using the ``Vectorview-all`` template. sfreq : float, default 256.0 Sampling frequency (Hz). Used only when ``data_type="eeg"`` to build the synthetic sensor layout. n_eeg_channels : int, default 64 Number of EEG channels. Must be one of the standard MNE montage channel counts (32, 64, 128, or 256 channels of ``biosemi*``/``easycap*`` montages). Ignored when ``data_type="meg"``. iir_filter : array_like, default [0.2, -0.2, 0.04] IIR denominator coefficients passed to :func:`mne.simulation.add_noise`. add_eog_artifacts : bool, default True If ``True``, add simulated EOG blink artefacts. fname_save : str | Path | None, default None Path to write the output ``.fif`` file. If ``None``, the file is saved to ``data/simulated/<label>_<freq>Hz_<data_type>-raw.fif`` relative to the repository root (or current working directory). verbose : bool | str | int | None, default None MNE verbosity level. Returns ------- raw : mne.io.Raw The simulated raw recording. Raises ------ ValueError If ``data_type`` is not ``"eeg"`` or ``"meg"``. Notes ----- The function requires the MNE ``fsaverage`` dataset which is downloaded automatically on first call via :func:`mne.datasets.fetch_fsaverage`. For MEG, a Vectorview-all info template is used (magnetometers + planar gradiometers). For EEG, a ``biosemi64`` (or ``biosemi32``/``biosemi128`` for other channel counts) standard layout is created programmatically. Examples -------- Simulate alpha-band EEG in the left parieto-occipital region:: from mne_rt.tools.simulation import simulate_raw raw = simulate_raw( brain_label="bankssts-lh", frequency=10.0, amplitude=1.0, duration=2.0, gap_duration=1.0, n_repetition=5, start=0.0, data_type="eeg", ) Simulate beta-band MEG over left motor cortex:: raw = simulate_raw( brain_label="precentral-lh", frequency=20.0, amplitude=1.0, duration=2.0, gap_duration=1.0, n_repetition=5, start=0.0, data_type="meg", ) """ if data_type not in ("eeg", "meg"): raise ValueError(f"data_type must be 'eeg' or 'meg', got {data_type!r}") mne.set_log_level(verbose=verbose) # ------------------------------------------------------------------ # Build sensor info # ------------------------------------------------------------------ raw_info = _make_sensor_info(data_type, sfreq, n_eeg_channels) # ------------------------------------------------------------------ # fsaverage source space + forward solution # ------------------------------------------------------------------ fs_dir = fetch_fsaverage(verbose=verbose) subjects_dir = os.path.dirname(fs_dir) subject = "fsaverage" trans = "fsaverage" src_fif = os.path.join(fs_dir, "bem", "fsaverage-ico-5-src.fif") bem_fif = os.path.join(fs_dir, "bem", "fsaverage-5120-5120-5120-bem-sol.fif") fwd = mne.make_forward_solution( raw_info, trans=trans, src=src_fif, bem=bem_fif, meg=(data_type == "meg"), eeg=(data_type == "eeg"), verbose=verbose, ) src = fwd["src"] # ------------------------------------------------------------------ # Source time series # ------------------------------------------------------------------ tstep = 1.0 / raw_info["sfreq"] n_samples = int(duration * raw_info["sfreq"]) t = np.arange(n_samples) * tstep amp_scale = _MEG_AMPLITUDE_SCALE if data_type == "meg" else _EEG_AMPLITUDE_SCALE source_time_series = np.sin(2.0 * np.pi * frequency * t) * amp_scale * amplitude # Pick a single central vertex in the target label selected_label = mne.read_labels_from_annot( subject, regexp=brain_label, subjects_dir=subjects_dir, verbose=verbose )[0] label = select_sources( subject, selected_label, location="center", extent=1, grow_outside=True, subjects_dir=subjects_dir, ) # Build events: one onset per epoch, separated by gap_duration samples gap_samples = int(gap_duration * raw_info["sfreq"]) start_samples = int(start * raw_info["sfreq"]) events = np.zeros((n_repetition, 3), dtype=int) events[:, 0] = start_samples + gap_samples * np.arange(n_repetition) events[:, 2] = 1 source_sim = SourceSimulator(src, tstep=tstep) source_sim.add_data(label, source_time_series, events) # ------------------------------------------------------------------ # Project to sensors and add noise # ------------------------------------------------------------------ raw = _mne_simulate_raw(raw_info, source_sim, forward=fwd, verbose=verbose) cov = mne.make_ad_hoc_cov(raw.info, verbose=verbose) add_noise(raw, cov, iir_filter=iir_filter, verbose=verbose) if add_eog_artifacts: add_eog(raw, verbose=verbose) # ------------------------------------------------------------------ # Save # ------------------------------------------------------------------ if fname_save is None: sim_dir = Path.cwd() / "data" / "simulated" sim_dir.mkdir(parents=True, exist_ok=True) fname_save = sim_dir / f"{brain_label}_{frequency}Hz_{data_type}-raw.fif" raw.save(fname=Path(fname_save), overwrite=True) return raw
[docs] def simulate_nf_session( duration: float = 120.0, sfreq: float = 256.0, n_channels: int = 64, alpha_frange: tuple = (8.0, 13.0), alpha_amplitude: float = 15e-6, background_amplitude: float = 5e-6, n_blinks: int = 15, blink_amplitude: float = 150e-6, muscle_rate: float = 0.05, muscle_amplitude: float = 30e-6, drift_amplitude: float = 20e-6, alpha_reactivity: bool = True, nf_epoch_fraction: float = 0.5, rng_seed: Optional[Union[int, None]] = None, verbose: Union[bool, str, None] = None, ) -> tuple: """Generate a realistic multi-artifact EEG simulation for NF testing. Produces synthetic EEG with: * **1/f background noise** (pink noise) on all channels * **Alpha-band oscillations** with realistic spatial topography (occipital channels have strongest alpha) * **Alpha reactivity**: alpha power is reduced during simulated NF "active" epochs (mimics ERD during cognitive effort) * **Eye-blink artefacts** at realistic rate (≈15/min) with frontal topography * **Muscle noise bursts** as short high-frequency transients * **Slow electrode drift** (low-frequency random walk) Parameters ---------- duration : float, default 120.0 Total recording duration in seconds. sfreq : float, default 256.0 Sampling rate in Hz. n_channels : int, default 64 Number of EEG channels (must be 32, 64, 128, or 256 for standard biosemi/easycap montages). alpha_frange : tuple, default (8.0, 13.0) Alpha-band frequency range (Hz). alpha_amplitude : float, default 15e-6 Peak alpha oscillation amplitude at occipital electrodes (V). background_amplitude : float, default 5e-6 Broadband 1/f noise amplitude (V). n_blinks : int, default 15 Number of blink artefacts to inject. blink_amplitude : float, default 150e-6 Peak blink amplitude (V) at Fp1/Fp2. muscle_rate : float, default 0.05 Fraction of windows contaminated with muscle bursts (0–1). muscle_amplitude : float, default 30e-6 Peak muscle burst amplitude (V). drift_amplitude : float, default 20e-6 Peak slow-drift amplitude (V). alpha_reactivity : bool, default True If True, alpha power is reduced by ~50% during NF epochs. nf_epoch_fraction : float, default 0.5 Fraction of total duration where NF-state (reduced alpha) applies. rng_seed : int | None, default None Random seed for reproducibility. verbose : bool | str | None, default None MNE verbosity. Returns ------- raw : mne.io.RawArray Simulated raw EEG. nf_state : ndarray, shape (n_samples,) Boolean mask: True where simulated NF-state (reduced alpha) is active. Notes ----- The function uses a standard biosemi64 montage channel layout. Channels are sorted into occipital (O1, Oz, O2, POz, PO3, PO4, PO7, PO8), frontal (Fp1, Fp2, AF7, AF8), and remaining groups to assign spatially realistic source weights. References ---------- Niedermeyer, E., & da Silva, F. L. (2005). Electroencephalography: Basic Principles, Clinical Applications, and Related Fields. LWW. Pfurtscheller, G., & Aranibar, A. (1977). Event-related cortical desynchronisation detected by power measurements of scalp EEG. Electroencephalography and clinical Neurophysiology, 42(6), 817–826. """ from scipy.signal import butter, sosfiltfilt mne.set_log_level(verbose=verbose) rng = np.random.default_rng(rng_seed) n_samples = int(duration * sfreq) info = _make_sensor_info("eeg", sfreq, n_channels) ch_names = info["ch_names"] # ── Spatial weight vectors ───────────────────────────────────────────── occipital_names = {"O1", "Oz", "O2", "POz", "PO3", "PO4", "PO7", "PO8"} frontal_names = {"Fp1", "Fp2", "AF7", "AF8"} alpha_weights = np.zeros(n_channels) blink_weights = np.zeros(n_channels) for i, ch in enumerate(ch_names): if ch in occipital_names: alpha_weights[i] = 1.0 elif ch in frontal_names: alpha_weights[i] = 0.1 blink_weights[i] = 1.0 else: alpha_weights[i] = 0.3 blink_weights[i] = 0.05 # Normalise weights so they sum to 1 (max-normalise for spatial realism) if alpha_weights.max() > 0: alpha_weights /= alpha_weights.max() if blink_weights.max() > 0: blink_weights /= blink_weights.max() # ── NF state mask (consecutive block in the middle of the recording) ── nf_state = np.zeros(n_samples, dtype=bool) nf_n = int(nf_epoch_fraction * n_samples) nf_start = (n_samples - nf_n) // 2 nf_state[nf_start: nf_start + nf_n] = True # ── 1. Pink noise (all channels) ────────────────────────────────────── def _pink_noise(n: int) -> np.ndarray: white = rng.standard_normal(n) fft_vals = np.fft.rfft(white) freqs = np.fft.rfftfreq(n) # Avoid division by zero at DC; set DC to 0. with np.errstate(divide="ignore", invalid="ignore"): scale = np.where(freqs == 0, 0.0, 1.0 / np.sqrt(freqs)) fft_vals *= scale pink = np.fft.irfft(fft_vals, n=n) # Normalise to unit RMS rms = np.sqrt(np.mean(pink ** 2)) return pink / (rms + 1e-30) data = np.zeros((n_channels, n_samples)) for c in range(n_channels): data[c] = _pink_noise(n_samples) * background_amplitude # ── 2. Alpha oscillations ────────────────────────────────────────────── iaf = (alpha_frange[0] + alpha_frange[1]) / 2.0 # individual alpha frequency t = np.arange(n_samples) / sfreq # Random phase offset per channel to avoid perfectly synchronous oscillations phase_offsets = rng.uniform(0, 2 * np.pi, n_channels) for c in range(n_channels): alpha_signal = np.sin(2.0 * np.pi * iaf * t + phase_offsets[c]) if alpha_reactivity: # Reduce alpha amplitude by 50% during NF state amplitude_mod = np.where(nf_state, 0.5, 1.0) alpha_signal = alpha_signal * amplitude_mod data[c] += alpha_weights[c] * alpha_amplitude * alpha_signal # ── 3. Eye blink artefacts ──────────────────────────────────────────── if n_blinks > 0: blink_duration_s = 0.2 # 200 ms Gaussian envelope blink_sigma_samples = int(blink_duration_s / 2 * sfreq) blink_half_width = 3 * blink_sigma_samples blink_t = np.arange(-blink_half_width, blink_half_width + 1) blink_template = np.exp(-(blink_t ** 2) / (2 * blink_sigma_samples ** 2)) blink_times = rng.integers( blink_half_width, n_samples - blink_half_width, size=n_blinks ) for bt in blink_times: start = bt - blink_half_width end = bt + blink_half_width + 1 actual_len = min(end, n_samples) - max(start, 0) tmpl_start = max(0, -start) tmpl_end = tmpl_start + actual_len data_start = max(0, start) for c in range(n_channels): data[c, data_start: data_start + actual_len] += ( blink_weights[c] * blink_amplitude * blink_template[tmpl_start:tmpl_end] ) # ── 4. Muscle bursts ────────────────────────────────────────────────── window_size = int(sfreq) # 1-second windows n_windows = n_samples // window_size n_muscle_windows = int(muscle_rate * n_windows) muscle_window_indices = rng.choice(n_windows, size=n_muscle_windows, replace=False) for wi in muscle_window_indices: ws = wi * window_size we = ws + window_size # Band-limited high-frequency noise (30–150 Hz) burst = rng.standard_normal((n_channels, window_size)) sos = butter( 4, [30.0 / (sfreq / 2), min(150.0, sfreq / 2 - 1) / (sfreq / 2)], btype="bandpass", output="sos", ) for c in range(n_channels): burst[c] = sosfiltfilt(sos, burst[c]) # Normalise burst to unit RMS then scale rms_burst = np.sqrt(np.mean(burst ** 2, axis=1, keepdims=True)) burst /= (rms_burst + 1e-30) data[:, ws:we] += muscle_amplitude * burst # ── 5. Slow electrode drift ─────────────────────────────────────────── drift_raw = rng.standard_normal((n_channels, n_samples)) sos_drift = butter( 2, 0.5 / (sfreq / 2), btype="low", output="sos", ) for c in range(n_channels): drift_filtered = sosfiltfilt(sos_drift, drift_raw[c]) max_drift = np.abs(drift_filtered).max() if max_drift > 0: drift_filtered /= max_drift data[c] += drift_amplitude * drift_filtered raw = mne.io.RawArray(data, info, verbose=verbose) return raw, nf_state
# --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _make_sensor_info(data_type: str, sfreq: float, n_eeg_channels: int) -> mne.Info: """Build an :class:`mne.Info` object for the requested modality. For EEG a standard montage is used; for MEG the MNE sample dataset Vectorview info is used as template (magnetometers + gradiometers). """ if data_type == "meg": try: sample_dir = mne.datasets.sample.data_path(verbose=False) except Exception as exc: raise RuntimeError( "MEG simulation requires the MNE sample dataset. " "Install it with: mne.datasets.sample.data_path()" ) from exc raw_fname = os.path.join(sample_dir, "MEG", "sample", "sample_audvis_raw.fif") info = mne.io.read_info(raw_fname, verbose=False) # Keep only MEG channels; resample info sfreq if different meg_picks = mne.pick_types(info, meg=True, eeg=False, stim=False, exclude="bads") info = mne.pick_info(info, sel=meg_picks) info["sfreq"] = float(sfreq) return info # EEG — use a standard biosemi/easycap montage montage_map = { 32: "easycap-M10", 64: "biosemi64", 128: "biosemi128", 256: "biosemi256", } montage_name = montage_map.get(n_eeg_channels, "biosemi64") montage = mne.channels.make_standard_montage(montage_name) info = mne.create_info( ch_names=montage.ch_names, sfreq=float(sfreq), ch_types="eeg", ) info.set_montage(montage) return info