Source code for eegunity.utils.label_channel
"""Utilities for EEGUnity misc (continuous-label) channels.
EEGUnity uses the ``misc:`` channel name prefix together with MNE's built-in
``misc`` channel type to attach per-sample continuous signals to a
:class:`~mne.io.BaseRaw` object alongside the EEG data. Typical use cases
include regression labels such as reaction time, inter-event gap, or any
other scalar value produced by a dataset-specific kernel for each epoch.
Channel naming convention
-------------------------
All EEGUnity label channels follow the ``misc:{task_name}`` pattern, for
example ``misc:reaction_time`` or ``misc:inter_event_gap``. The MNE channel
type is always ``misc``.
This convention is consistent with EEGUnity's locator channel prefix system
(``eeg:``, ``eog:``, ``emg:``, ``ecg:``, ``stim:``; legacy uppercase forms are
also accepted) and is distinct from ``stim:`` channels in the following ways:
- Value type: ``stim`` channels carry integer trigger codes, while
``misc`` label channels carry continuous float values.
- Typical use: ``stim`` is for event onset or TTL-like pulses; ``misc`` is
for per-sample regression targets.
- Resampling: MNE handles ``stim`` with nearest-neighbour logic, but applies
``resample_poly`` to ``misc`` channels unless EEGUnity's wrapper is used.
- ``filter()`` / ``ICA.fit()``: both channel types are excluded by default.
- ``events_from_annotations()`` does not directly consume either channel type.
Because MNE's :meth:`~mne.io.BaseRaw.resample` applies
``scipy.signal.resample_poly`` to *all* channels (misc included), label
channels must be resampled with nearest-neighbour interpolation to preserve
their original float values. Always call :func:`resample_raw_with_labels`
instead of ``raw.resample()`` directly in any EEGUnity code path where
label channels may be present.
See Also
--------
:func:`resample_raw_with_labels` : Drop-in replacement for ``raw.resample()``.
:func:`is_misc_channel` : Predicate for identifying label channels.
:func:`misc_task_name` : Extract the task name from a label channel name.
"""
from typing import List
import numpy as np
import mne
MISC_CH_PREFIX: str = 'misc:'
"""Prefix string that identifies all EEGUnity misc (label) channels.
Every channel whose name starts with this prefix is treated as a continuous
label channel with MNE type ``misc``.
"""
[docs]
def is_misc_channel(ch_name: str) -> bool:
"""Return ``True`` if *ch_name* is an EEGUnity misc (label) channel.
The check is case-sensitive and matches the ``'misc:'`` prefix exactly.
Parameters
----------
ch_name : str
Channel name to test.
Returns
-------
bool
``True`` when *ch_name* starts with ``'misc:'``, ``False`` otherwise.
Examples
--------
>>> is_misc_channel('misc:reaction_time')
True
>>> is_misc_channel('eeg:Fz')
False
"""
return str(ch_name).startswith(MISC_CH_PREFIX)
[docs]
def is_misc_channel_in_raw(raw: mne.io.BaseRaw, ch_idx: int) -> bool:
"""Return ``True`` if channel index points to a misc label channel.
The check primarily uses MNE channel type metadata and falls back to the
EEGUnity ``misc:`` prefix for backward compatibility.
Parameters
----------
raw : mne.io.BaseRaw
Raw object.
ch_idx : int
Channel index.
Returns
-------
bool
``True`` when channel type is ``misc`` or name starts with ``misc:``.
Examples
--------
>>> # is_misc_channel_in_raw(raw, 0) # doctest: +SKIP
"""
try:
if mne.channel_type(raw.info, ch_idx) == 'misc':
return True
except Exception:
pass
return is_misc_channel(raw.ch_names[ch_idx])
[docs]
def is_stim_channel_in_raw(raw: mne.io.BaseRaw, ch_idx: int) -> bool:
"""Return ``True`` if channel index points to a stim channel.
Parameters
----------
raw : mne.io.BaseRaw
Raw object.
ch_idx : int
Channel index.
Returns
-------
bool
``True`` when channel type is ``stim``.
Examples
--------
>>> # is_stim_channel_in_raw(raw, 0) # doctest: +SKIP
"""
try:
return mne.channel_type(raw.info, ch_idx) == 'stim'
except Exception:
return False
[docs]
def misc_channel_indices(raw: mne.io.BaseRaw) -> List[int]:
"""Return indices of misc channels in a raw object.
Parameters
----------
raw : mne.io.BaseRaw
Raw object.
Returns
-------
list of int
Indices of channels treated as misc labels.
Examples
--------
>>> # idx = misc_channel_indices(raw) # doctest: +SKIP
"""
return [idx for idx in range(len(raw.ch_names)) if is_misc_channel_in_raw(raw, idx)]
[docs]
def stim_channel_indices(raw: mne.io.BaseRaw) -> List[int]:
"""Return indices of stim channels in a raw object.
Parameters
----------
raw : mne.io.BaseRaw
Raw object.
Returns
-------
list of int
Indices of channels with type ``stim``.
Examples
--------
>>> # idx = stim_channel_indices(raw) # doctest: +SKIP
"""
return [idx for idx in range(len(raw.ch_names)) if is_stim_channel_in_raw(raw, idx)]
[docs]
def misc_task_name(ch_name: str) -> str:
"""Extract the task name from a misc label channel name.
Parameters
----------
ch_name : str
A channel name of the form ``'misc:{task_name}'``.
Returns
-------
str
The task name portion after the ``'misc:'`` prefix.
Raises
------
ValueError
If *ch_name* does not start with ``'misc:'``.
Examples
--------
>>> misc_task_name('misc:reaction_time')
'reaction_time'
"""
if not is_misc_channel(ch_name):
raise ValueError(f"Not a misc label channel name: {ch_name!r}")
return ch_name[len(MISC_CH_PREFIX):]
[docs]
def resample_raw_with_labels(
raw: mne.io.BaseRaw,
sfreq: float,
**kwargs,
) -> mne.io.BaseRaw:
"""Resample *raw*, applying nearest-neighbour interpolation to misc channels.
MNE's :meth:`~mne.io.BaseRaw.resample` uses
``scipy.signal.resample_poly`` for all channels, which introduces
low-pass filtering artefacts on the step-function signals typically
stored in ``misc:`` label channels. This function wraps the standard
resample call and overwrites the resampled misc channel data with values
obtained by nearest-neighbour interpolation, preserving the original float
values exactly for samples that fall in the interior of constant regions.
EEG, EOG, MEG and all other non-misc channels are resampled with the
standard MNE pipeline and are not affected by this wrapper.
Parameters
----------
raw : mne.io.BaseRaw
The raw object to resample. Will be loaded into memory if not already
preloaded.
sfreq : float
New sampling frequency in Hz.
**kwargs
Additional keyword arguments forwarded to
:meth:`~mne.io.BaseRaw.resample` (e.g. ``npad``, ``window``).
Returns
-------
mne.io.BaseRaw
The resampled raw object (modified in-place).
Notes
-----
If no ``misc:`` channels are present, this function is equivalent to
calling ``raw.resample(sfreq, **kwargs)`` directly.
The nearest-neighbour mapping is computed as::
new_index[i] = round(i * old_n_times / new_n_times)
which guarantees that samples in the interior of a constant label region
are reproduced exactly regardless of the resampling ratio.
Examples
--------
>>> raw = resample_raw_with_labels(raw, sfreq=256)
"""
misc_ch_indices: List[int] = misc_channel_indices(raw)
misc_ch_names: List[str] = [raw.ch_names[idx] for idx in misc_ch_indices]
if not misc_ch_names:
raw.resample(sfreq, **kwargs)
return raw
raw.load_data()
# Record original misc channel data and sample count before resampling.
old_n_times: int = raw.n_times
old_misc_data = raw._data[misc_ch_indices, :].copy() # (n_misc, old_n_times)
# Standard resample: correct for EEG/MEG; misc data will be overwritten.
raw.resample(sfreq, **kwargs)
new_n_times: int = raw.n_times
# Nearest-neighbour index mapping: new sample i -> nearest old sample.
nn_indices = (
np.round(np.linspace(0, old_n_times - 1, new_n_times))
.astype(int)
.clip(0, old_n_times - 1)
)
for i, ch_idx in enumerate(misc_ch_indices):
raw._data[ch_idx] = old_misc_data[i, nn_indices]
return raw