Source code for mne_rt.rt_epochs

"""Event-triggered real-time epoch accumulation.

Thin orchestration layer on top of :class:`mne_lsl.stream.EpochsStream`,
which handles all buffering, baseline correction, and rejection internally.

Typical workflow
----------------
::

    rt = RTEpochs(
        event_id={"target": 1, "standard": 2},
        event_channels="STI 014",
        tmin=-0.2, tmax=0.8,
    )
    rt.connect_to_lsl()
    rt.run(n_trials=80, show_erp=True)

Events may instead arrive on a separate LSL marker stream -- what a PsychoPy
paradigm publishes -- in which case ``event_channels`` names a channel of
*that* stream::

    rt = RTEpochs(event_id={"go": 1}, event_channels="markers")
    rt.connect_to_lsl(stream_name="ANT", event_stream_name="psychopy_markers")

Classes
-------
RTEpochs
    Event-triggered epoch accumulator backed by mne_lsl.EpochsStream.
"""

from __future__ import annotations

import threading
import time
from collections import defaultdict
from typing import Callable, Optional, Sequence, Union

import mne
import numpy as np

try:
    from mne_lsl.player import PlayerLSL
    from mne_lsl.stream import EpochsStream, StreamLSL

    _mne_lsl_available = True
except ImportError:
    _mne_lsl_available = False

from mne_rt._logging import logger, set_log_level


[docs] class RTEpochs: """Event-triggered epoch accumulator backed by :class:`mne_lsl.stream.EpochsStream`. Connects a :class:`~mne_lsl.stream.StreamLSL` to an :class:`~mne_lsl.stream.EpochsStream`, polls for new epochs, and optionally drives an :class:`~mne_rt.viz.TopoPlot` that redraws after every new trial. Parameters ---------- event_id : dict[str, int] Condition label → marker integer, e.g. ``{"target": 1, "standard": 2}``. event_channels : str or list of str Channel(s) carrying the event codes. Without an event stream these must be ``"stim"``-type channels of the M/EEG stream itself (e.g. ``"STI 014"``). When :meth:`connect_to_lsl` is given an event stream they name channels of *that* stream instead -- see its Notes. tmin : float, default -0.2 Epoch start in seconds relative to the event. tmax : float, default 0.8 Epoch end in seconds relative to the event. baseline : tuple or None, default (None, 0) Baseline interval passed to :class:`~mne_lsl.stream.EpochsStream`. ``None`` disables correction. picks : str or list or None, default None Channel selection forwarded to :class:`~mne_lsl.stream.EpochsStream`. reject : dict or None, default None Peak-to-peak rejection thresholds, e.g. ``{"eeg": 150e-6}``. bufsize : int, default 200 Number of epochs to keep in the :class:`~mne_lsl.stream.EpochsStream` internal ring buffer. on_trial : callable or None, default None Optional callback fired after every accepted epoch:: def on_trial(n_accepted, data, event_code, condition): ... ``data`` is the single accepted epoch, ``(n_channels, n_times)``; ``event_code`` is its integer marker and ``condition`` the matching ``event_id`` label. verbose : bool or str or None, default None Attributes ---------- epochs_stream_ : mne_lsl.stream.EpochsStream or None The underlying :class:`~mne_lsl.stream.EpochsStream` after :meth:`connect_to_lsl` has been called. event_stream_ : mne_lsl.stream.StreamLSL or None The separate marker stream, when :meth:`connect_to_lsl` was asked for one. ``None`` when the events come from a stim channel of the M/EEG stream. n_accepted_ : int Running count of accepted epochs since :meth:`run` started. See Also -------- mne_rt.viz.TopoPlot : Live scalp-layout ERP display driven by this class. mne_rt.viz.EpochPlot : Scrolling raw viewer with trigger/epoch overlays. mne_rt.RTStream : Continuous sliding-window stream processor. Examples -------- >>> rt = RTEpochs( ... event_id={"auditory": 1, "visual": 2}, ... event_channels="STI 014", ... tmin=-0.2, tmax=0.5, ... ) >>> rt.connect_to_lsl(mock_lsl=True, fname="sample_raw.fif") >>> rt.run(n_trials=20, show_erp=True) .. versionadded:: 1.0.0 """
[docs] def __init__( self, event_id: dict[str, int], event_channels: Union[str, list[str]], tmin: float = -0.2, tmax: float = 0.8, baseline: Optional[tuple] = (None, 0), picks: Optional[Union[str, list]] = None, reject: Optional[dict] = None, bufsize: int = 200, on_trial: Optional[Callable] = None, verbose: Union[bool, str, None] = None, ) -> None: set_log_level(verbose) if not _mne_lsl_available: raise ImportError("mne-lsl is required. Install with: pip install mne-lsl") self.event_id = event_id self.event_channels = event_channels self.tmin = tmin self.tmax = tmax self.baseline = baseline self.picks = picks self.reject = reject self.bufsize = bufsize self.on_trial = on_trial self._stream: Optional[StreamLSL] = None self._player: Optional[PlayerLSL] = None self.epochs_stream_: Optional[EpochsStream] = None self.event_stream_: Optional[StreamLSL] = None self.n_accepted_: int = 0 self._stop_event = threading.Event() self._connected = False # Populated by run() — persists for get_epochs/get_evoked/save self._buf_: Optional[np.ndarray] = None # (n_trials, n_ch, n_t) self._cond_list_: list[str] = [] self._code_list_: list[int] = []
# ------------------------------------------------------------------ # Connection # ------------------------------------------------------------------
[docs] def connect_to_lsl( self, stream_name: Optional[str] = None, mock_lsl: bool = False, fname: Optional[str] = None, timeout: float = 10.0, event_stream_name: Optional[str] = None, event_source_id: Optional[str] = None, event_stream_bufsize: int = 200, processing_flags: Optional[Union[str, Sequence[str]]] = None, verbose: Union[bool, str, None] = None, ) -> "RTEpochs": """Connect to an LSL stream and set up the EpochsStream. Parameters ---------- stream_name : str or None LSL stream name. ``None`` picks the first available stream. mock_lsl : bool Replay ``fname`` via :class:`~mne_lsl.player.PlayerLSL`. fname : str or None Path to a ``.fif`` file (required when ``mock_lsl=True``). timeout : float LSL connection timeout in seconds. event_stream_name : str or None, default None Name of a separate LSL stream carrying the event codes -- the marker outlet of a PsychoPy paradigm, for instance. Leaving both this and ``event_source_id`` as ``None`` keeps the events on a stim channel of the M/EEG stream itself, which is the behaviour of earlier versions. event_source_id : str or None, default None Source ID of the event stream. May be given instead of, or together with, ``event_stream_name``; the two must together identify exactly one stream. event_stream_bufsize : int, default 200 Event-stream buffer size **in samples**. A marker outlet is irregularly sampled, so a duration would carry no meaning. processing_flags : str or sequence of str or None, default None Forwarded to :meth:`mne_lsl.stream.StreamLSL.connect` for every stream opened here. ``None`` selects ``"all"`` when an event stream is requested, and no flags otherwise -- see Notes. verbose : bool or str or None Returns ------- self : RTEpochs Notes ----- **Clock synchronisation.** Two LSL streams share a time base only once their clocks are synchronised, so when an event stream is requested both streams are connected with ``processing_flags="all"`` (``clocksync``, ``dejitter``, ``monotize``). Without it, markers published from a second machine sit at an arbitrary offset from the M/EEG data and every epoch is cut in the wrong place, silently. Pass ``processing_flags`` explicitly to override. **Publishing the markers.** The outlet must satisfy three constraints, each of which otherwise fails confusingly or not at all: - it must be **numeric**. mne-lsl refuses string streams outright, and ``channel_format="string"`` is what most PsychoPy marker examples use -- publish ``"int32"`` instead; - it should **label its channel**, since an outlet that does not is exposed as ``"0"``, ``"1"``, ... and ``event_channels`` has to match; - the codes must be positive integers no greater than 32767, matching the values of ``event_id``. A minimal publisher, which doubles as the reference for the paradigm side:: from mne_lsl.lsl import StreamInfo, StreamOutlet sinfo = StreamInfo( "psychopy_markers", "Markers", 1, 0.0, "int32", "psychopy_uid" ) sinfo.set_channel_names(["markers"]) outlet = StreamOutlet(sinfo) outlet.push_sample([1]) # a "go" trial Examples -------- Events from a stim channel of the M/EEG stream itself:: rt = RTEpochs(event_id={"target": 1}, event_channels="STI 014") rt.connect_to_lsl(mock_lsl=True, fname="sample_raw.fif") Events from a PsychoPy marker outlet:: rt = RTEpochs(event_id={"go": 1, "stop": 2}, event_channels="markers") rt.connect_to_lsl( stream_name="ANT", event_stream_name="psychopy_markers" ) """ if verbose is not None: set_log_level(verbose) # Clear handles from any previous connect: without this a second call # made *without* an event stream would still hand the disconnected one # from the first call to EpochsStream, which refuses it. self.epochs_stream_ = None self.event_stream_ = None self._connected = False if mock_lsl: if fname is None: raise ValueError("fname is required when mock_lsl=True.") logger.info("RTEpochs: starting mock PlayerLSL from %s", fname) self._player = PlayerLSL(fname, name="mne_rt_mock", chunk_size=16).start() time.sleep(1.5) stream_name = "mne_rt_mock" want_event_stream = event_stream_name is not None or event_source_id is not None if want_event_stream: if int(event_stream_bufsize) != event_stream_bufsize or event_stream_bufsize <= 0: raise ValueError( "event_stream_bufsize is a number of samples and must be a " f"positive integer; got {event_stream_bufsize!r}." ) event_stream_bufsize = int(event_stream_bufsize) if processing_flags is None: # Two streams share a time base only once their clocks are synced. processing_flags = "all" logger.info("RTEpochs: connecting StreamLSL …") self._stream = StreamLSL(bufsize=4.0, name=stream_name) try: self._stream.connect( acquisition_delay=0.005, processing_flags=processing_flags, timeout=timeout, ) logger.info( "RTEpochs: stream connected — %d ch @ %.0f Hz", self._stream.info["nchan"], self._stream.info["sfreq"], ) if want_event_stream: self._connect_event_stream( name=event_stream_name, source_id=event_source_id, bufsize=event_stream_bufsize, processing_flags=processing_flags, timeout=timeout, ) logger.info("RTEpochs: setting up EpochsStream …") self.epochs_stream_ = EpochsStream( stream=self._stream, bufsize=self.bufsize, event_id=self.event_id, event_channels=self.event_channels, event_stream=self.event_stream_, tmin=self.tmin, tmax=self.tmax, baseline=self.baseline, picks=self.picks, reject=self.reject, ).connect(acquisition_delay=0.005) except BaseException: # Anything raised past this point leaves a running player and one # or two connected streams behind, with ``_connected`` still False # so the caller has no handle to clean them up with. self.disconnect() raise self._connected = True logger.info("RTEpochs: EpochsStream connected.") return self
def _connect_event_stream( self, *, name: Optional[str], source_id: Optional[str], bufsize: int, processing_flags: Optional[Union[str, Sequence[str]]], timeout: float, ) -> None: """Connect the separate LSL stream carrying the event codes. Kept apart from :meth:`connect_to_lsl` only so its two failure modes can be reported against the *event* stream; mne-lsl raises for both, but from a context that does not say which of the two streams is at fault. """ logger.info( "RTEpochs: connecting event StreamLSL (name=%r, source_id=%r) …", name, source_id, ) # Held from construction onwards, so that a failure anywhere below is # still reachable by disconnect(). StreamLSL.connect() can raise with an # inlet already open and an acquisition thread already running. stream = StreamLSL(bufsize=bufsize, name=name, source_id=source_id) self.event_stream_ = stream try: stream.connect( acquisition_delay=0.005, processing_flags=processing_flags, timeout=timeout, ) except RuntimeError as exc: if "string LSL streams" not in str(exc): raise raise RuntimeError( "The event stream publishes strings, which mne-lsl cannot read. " "Publish the marker codes from a numeric outlet instead, e.g. " "channel_format='int32' — note that PsychoPy's marker examples " "commonly default to channel_format='string'." ) from exc available = list(stream.info["ch_names"]) wanted = ( [self.event_channels] if isinstance(self.event_channels, str) else list(self.event_channels) ) missing = [ch for ch in wanted if ch not in available] if missing: raise ValueError( f"Event channel(s) {missing} are not in the event stream, which " f"publishes {available}. An LSL outlet that does not label its " "channels is exposed by mne-lsl as '0', '1', … — either label " "the channel in the publisher, or pass the name it actually has." ) logger.info( "RTEpochs: event stream connected — %d ch @ %.0f Hz, using %s", stream.info["nchan"], stream.info["sfreq"], wanted, ) # ------------------------------------------------------------------ # Main loop # ------------------------------------------------------------------
[docs] def run( self, n_trials: int = 100, show_erp: bool = False, erp_update_every: int = 1, poll_interval: float = 0.05, verbose: Union[bool, str, None] = None, ) -> "RTEpochs": """Run the epoch accumulation loop. Polls :attr:`~mne_lsl.stream.EpochsStream.n_new_epochs` and retrieves data in batches. Blocks until ``n_trials`` accepted epochs have been collected or :meth:`stop` is called. Parameters ---------- n_trials : int, default 100 Stop after this many accepted epochs. show_erp : bool, default False Open an :class:`~mne_rt.viz.TopoPlot` that redraws every ``erp_update_every`` accepted epochs. erp_update_every : int, default 1 ERP redraw cadence in number of accepted epochs. poll_interval : float, default 0.05 Seconds to sleep between polling :attr:`n_new_epochs`. verbose : bool or str or None Returns ------- self : RTEpochs """ if verbose is not None: set_log_level(verbose) if not self._connected: raise RuntimeError("Call connect_to_lsl() before run().") es = self.epochs_stream_ erp_plot = None if show_erp: from mne_rt.viz.topo_plot import TopoPlot erp_plot = TopoPlot( ch_names=list(es.info["ch_names"]), sfreq=es.info["sfreq"], tmin=self.tmin, tmax=self.tmax, event_id=self.event_id, info=es.info, # pass real Info for accurate layout baseline=self.baseline, ) erp_plot.show() inv_event = {v: k for k, v in self.event_id.items()} # Pre-allocate epoch buffer — avoids O(N²) np.stack per trial n_ch = es.info["nchan"] # es.times is authoritative: mne-lsl builds ceil((tmax - tmin) * sfreq) # samples with endpoint=False, one fewer than round(...) + 1. Sizing the # buffer ourselves left a trailing all-zero sample on every epoch. n_times = es.times.size self._buf_ = np.zeros((n_trials, n_ch, n_times), dtype=np.float32) self._cond_list_ = [] self._code_list_ = [] self._stop_event.clear() self.n_accepted_ = 0 logger.info("RTEpochs: running — target %d trials …", n_trials) while self.n_accepted_ < n_trials and not self._stop_event.is_set(): n_new = self.epochs_stream_.n_new_epochs if n_new == 0: time.sleep(poll_interval) continue # Retrieve all new epochs at once — shape (n_new, n_ch, n_times) data = self.epochs_stream_.get_data(n_epochs=n_new) events = self.epochs_stream_.events[-n_new:] for i in range(data.shape[0]): if self.n_accepted_ >= n_trials: break code = int(events[i]) if events.ndim == 1 else int(events[i, 2]) condition = inv_event.get(code, str(code)) # Write into pre-allocated buffer (O(1) copy) ep = data[i] t = min(ep.shape[-1], n_times) self._buf_[self.n_accepted_, :, :t] = ep[:, :t] self._cond_list_.append(condition) self._code_list_.append(code) self.n_accepted_ += 1 # on_trial now receives event_code + condition directly if self.on_trial is not None: self.on_trial( self.n_accepted_, self._buf_[self.n_accepted_ - 1], # view — no copy code, condition, ) if erp_plot is not None and self.n_accepted_ % erp_update_every == 0: # Pass a view of the filled portion — no copy erp_plot.update(self._buf_[: self.n_accepted_], list(self._cond_list_)) logger.debug("RTEpochs: accepted %d (%s)", self.n_accepted_, condition) logger.info("RTEpochs: finished — %d epochs accepted.", self.n_accepted_) return self
[docs] def stop(self) -> None: """Signal the run loop to stop after the current poll.""" self._stop_event.set()
[docs] def disconnect(self) -> None: """Disconnect EpochsStream, both StreamLSLs, and stop any mock player.""" # The EpochsStream registers itself on the stream(s) it reads and # unregisters on disconnect, so it has to be torn down first. if self.epochs_stream_ is not None: try: self.epochs_stream_.disconnect() except Exception: pass for stream in (self.event_stream_, self._stream): if stream is not None: try: stream.disconnect() except Exception: pass if self._player is not None: try: self._player.stop() except Exception: pass self._connected = False logger.info("RTEpochs: disconnected.")
# ------------------------------------------------------------------ # Offline analysis helpers # ------------------------------------------------------------------
[docs] def get_epochs(self) -> "mne.EpochsArray": """Return accumulated epochs as :class:`mne.EpochsArray`. Can be called mid-run or after :meth:`run` completes. The returned object contains all epochs accepted so far and uses the real :class:`mne.Info` from the underlying stream (including channel positions and digitisation points). Returns ------- epochs : mne.EpochsArray Shape ``(n_accepted, n_channels, n_times)``. Raises ------ RuntimeError If called before :meth:`connect_to_lsl`. Examples -------- >>> rt.run(n_trials=50, show_erp=True) >>> epochs = rt.get_epochs() >>> epochs.plot_image() """ import mne if self.epochs_stream_ is None or self._buf_ is None: raise RuntimeError("No data yet — call connect_to_lsl() then run() first.") n = self.n_accepted_ events = np.column_stack( [ np.arange(n, dtype=int), np.zeros(n, dtype=int), np.array(self._code_list_[:n], dtype=int), ] ) return mne.EpochsArray( self._buf_[:n].astype(np.float64), info=self.epochs_stream_.info, events=events, event_id=self.event_id, tmin=float(self.epochs_stream_.times[0]), verbose=False, )
[docs] def get_evoked(self) -> "dict[str, mne.EvokedArray]": """Return per-condition grand-average as :class:`mne.EvokedArray` objects. Useful for immediate offline analysis, plotting with :func:`mne.viz.plot_evoked`, or source localisation via :meth:`get_source`. Returns ------- evoked : dict[str, mne.EvokedArray] Mapping ``condition_label → EvokedArray``. Conditions with zero accepted epochs are omitted. Examples -------- >>> evoked = rt.get_evoked() >>> mne.viz.plot_evoked(evoked["auditory/left"]) """ epochs = self.get_epochs() result = {} for cond in self.event_id: try: result[cond] = epochs[cond].average() except KeyError: pass return result
[docs] def save(self, path: str, overwrite: bool = False) -> None: """Save accumulated epochs to a ``-epo.fif`` file mid-run. The file can be reloaded offline with ``mne.read_epochs(path)`` and the full MNE analysis pipeline applied. Parameters ---------- path : str Destination path. Should end with ``-epo.fif`` or ``-epo.fif.gz`` to follow MNE naming conventions. overwrite : bool, default False Overwrite an existing file. Examples -------- >>> rt.run(n_trials=30) >>> rt.save("session01-epo.fif", overwrite=True) """ self.get_epochs().save(path, overwrite=overwrite, verbose=False) logger.info("RTEpochs: saved %d epochs to %s", self.n_accepted_, path)
[docs] def get_source( self, inverse_operator, lambda2: float = 1.0 / 9.0, method: str = "dSPM", ) -> "dict[str, mne.SourceEstimate]": """Apply a pre-computed inverse operator to the current grand averages. Wraps :func:`mne.minimum_norm.apply_inverse` — load an existing inverse operator with ``mne.minimum_norm.read_inverse_operator(fname)``. Parameters ---------- inverse_operator : mne.minimum_norm.InverseOperator Pre-computed inverse operator matching the stream's Info (same channels, same channel order). lambda2 : float, default 1/9 Regularisation parameter (``1 / SNR²``). Use ``1/9`` for SNR ≈ 3 (typical ERP), ``1.0`` for noisy single-trial data. method : str, default "dSPM" Inverse method: ``"MNE"``, ``"dSPM"``, ``"sLORETA"``, or ``"eLORETA"``. Returns ------- stc_dict : dict[str, mne.SourceEstimate] Condition label → source estimate (vertex × time). Examples -------- >>> inv_op = mne.minimum_norm.read_inverse_operator("sample-inv.fif") >>> stc = rt.get_source(inv_op) >>> brain = mne_rt.BrainPlot(subject="sample", subjects_dir=sd) >>> brain.update(stc["auditory/left"].data.mean(-1)) """ import mne.minimum_norm evoked = self.get_evoked() return { cond: mne.minimum_norm.apply_inverse( ev, inverse_operator, lambda2=lambda2, method=method, verbose=False, ) for cond, ev in evoked.items() }