Source code for meeg_utils.epochs
"""Combining the epochs of several runs.
Each run is preprocessed and epoched on its own (bad channels, line noise
and ICA differ between runs); :func:`combine` then checks that the runs are
compatible and concatenates them.
"""
from __future__ import annotations
from collections.abc import Sequence
import mne
import numpy as np
from loguru import logger
from mne.epochs import BaseEpochs
BAD_CHANNEL_POLICIES = ("union", "interpolate")
[docs]
def combine(
epochs_list: Sequence[BaseEpochs],
*,
bads: str = "union",
max_head_shift_mm: float = 2.0,
origin: tuple[float, float, float] = (0.0, 0.0, 0.04),
) -> BaseEpochs:
"""Concatenate the epochs of several runs of one session.
The runs must have the same channels (names and order), sampling rate
and epoch times. Events are matched by name: if runs gave a condition
different codes (MNE numbers the annotations of each run), the codes of
the first run that has it are used.
Parameters
----------
epochs_list : sequence of Epochs
The runs. They are not modified.
bads : {"union", "interpolate"}
``"union"``: every channel bad in any run is marked bad in the
result. ``"interpolate"``: those channels are interpolated in every
run (needs sensor positions) and no channel is marked bad.
max_head_shift_mm : float
For MEG, the largest head displacement relative to the sensors
allowed between runs. Larger differences raise: map the runs to a
common head position first with :class:`~meeg_utils.steps.HeadAlign`
and :func:`meeg_utils.io.average_dev_head_t`. Within the limit, the
first run's head position is kept.
origin : tuple of float
Point (head frame, m) whose displacement is measured.
Returns
-------
Epochs
The concatenated epochs. The runs' annotations are not kept (MNE
cannot concatenate them); events and metadata are.
Raises
------
ValueError
If the runs are not compatible.
"""
from .io import get_datatypes
if bads not in BAD_CHANNEL_POLICIES:
raise ValueError(f"bads must be one of {BAD_CHANNEL_POLICIES}, got {bads!r}")
runs = [e.copy() for e in epochs_list]
if not runs:
raise ValueError("No epochs to combine.")
if not all(isinstance(e, BaseEpochs) for e in runs):
raise TypeError("combine needs Epochs.")
_check_compatible(runs)
if "meg" in get_datatypes(runs[0]):
_check_head_positions(runs, max_head_shift_mm, np.asarray(origin))
first = runs[0]
_unify_event_codes(runs)
union = sorted({ch for run in runs for ch in run.info["bads"]}, key=first.ch_names.index)
for run in runs:
run.info["bads"] = list(union)
if bads == "interpolate" and union:
for run in runs:
run.interpolate_bads(reset_bads=True, mode="accurate", verbose=False)
for run in runs: # MNE cannot concatenate the annotations of epochs; drop them explicitly
run.set_annotations(None)
logger.info(
f"Combining {len(runs)} runs ({sum(len(r) for r in runs)} epochs); "
f"bad channels ({bads}): {union}"
)
return mne.concatenate_epochs(runs, add_offset=True, on_mismatch="ignore", verbose=False)
def _check_compatible(runs: list[BaseEpochs]) -> None:
first = runs[0]
for i, run in enumerate(runs[1:], start=2):
if run.ch_names != first.ch_names:
only_first = sorted(set(first.ch_names) - set(run.ch_names))
only_run = sorted(set(run.ch_names) - set(first.ch_names))
raise ValueError(
f"Run {i} has different channels than run 1 (only in run 1: {only_first}; "
f"only in run {i}: {only_run}; or a different order)."
)
same_times = run.times.shape == first.times.shape and np.allclose(run.times, first.times)
if run.info["sfreq"] != first.info["sfreq"] or not same_times:
raise ValueError(f"Run {i} has a different sampling rate or epoch times than run 1.")
def _check_head_positions(runs: list[BaseEpochs], max_shift_mm: float, origin: np.ndarray) -> None:
from .steps.head import _movement
reference = runs[0].info["dev_head_t"]
for i, run in enumerate(runs[1:], start=2):
shift, rotation = _movement(reference, run.info["dev_head_t"], origin)
if shift * 1000 > max_shift_mm:
raise ValueError(
f"The head position of run {i} differs from run 1 by {shift * 1000:.1f} mm "
f"({rotation:.1f}°), more than max_head_shift_mm={max_shift_mm:g}. Map the "
"runs to a common position first: S.HeadAlign(meu.io.average_dev_head_t(runs))."
)
def _unify_event_codes(runs: list[BaseEpochs]) -> None:
"""Give every event name one code across runs (in place)."""
mapping: dict[str, int] = {}
next_code = 1 + max(int(c) for run in runs for c in run.event_id.values())
for run in runs:
for name, code in run.event_id.items():
if name not in mapping:
if code in mapping.values(): # taken by another name
mapping[name] = next_code
next_code += 1
else:
mapping[name] = int(code)
for run in runs:
events = run.events.copy()
for name, code in run.event_id.items():
events[run.events[:, 2] == code, 2] = mapping[name]
run.events = events
run.event_id = {name: mapping[name] for name in run.event_id}