"""ModalityMixin — real-time feature extraction for all modalities.
Mixed into :class:`RTStream`. Assumes the following attributes exist on
``self`` at call time:
- ``rec_info`` — MNE :class:`~mne.Info`
- ``data_type`` — ``"eeg"`` | ``"meg"``
- ``_sfreq`` — sampling frequency (Hz)
- ``winsize`` — analysis window length (s)
- ``picks`` — channel selection (may be ``None``)
- ``params`` — current modality parameter dict (set before each prep call)
- ``subject_fs_id`` — FreeSurfer subject identifier
- ``subjects_fs_dir``
- ``subject_dir`` — :class:`~pathlib.Path` to subject data folder
- ``visit`` — visit number
- ``raw_baseline`` — baseline :class:`~mne.io.RawArray` (source modalities)
- ``fwd``, ``noise_cov`` — forward solution / covariance (LCMV)
- ``_prepare_raw_array(data)`` — wraps data in ``RawArray``, applies EEG ref if needed
New modalities added:
- ``erd_ers`` — event-related desynchronisation/synchronisation (%)
- ``laterality`` — inter-hemispheric power asymmetry index
- ``hjorth`` — mean of Hjorth mobility and complexity (no FFT)
- ``spectral_centroid``— frequency-weighted centre-of-mass of the PSD within a band
- ``decode`` — fitted :class:`~mne_rt.RTDecode` classifier queried
once per window (``self.decoder``, set via
:meth:`~mne_rt.RTStream.set_decoder`)
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from warnings import warn
import mne
import numpy as np
from mne import read_labels_from_annot
from mne.beamformer import apply_lcmv_raw, make_lcmv
from mne.minimum_norm import apply_inverse_raw, read_inverse_operator
from mne_connectivity import spectral_connectivity_time
from mne_features.univariate import (
compute_app_entropy,
compute_samp_entropy,
compute_spect_entropy,
compute_svd_entropy,
)
from pactools import Comodulogram
from scipy.optimize import curve_fit
from scipy.signal import butter, sosfiltfilt, welch
from mne_rt._logging import logger
from mne_rt.source import SourceModel, resolve_rois
from mne_rt.tools import (
butter_bandpass,
compute_bandpower,
compute_fft,
estimate_aperiodic_component,
log_degree_barrier,
resolve_connectivity_method,
resolve_n_cycles,
timed,
)
#: Inverse solutions the source-space modalities accept.
_SOURCE_METHODS = frozenset({"MNE", "dSPM", "sLORETA", "eLORETA", "LCMV"})
#: Connectivity metrics that are meaningless on a magnitude (phase-free) source
#: estimate, because they are defined in terms of relative phase.
_PHASE_SENSITIVE_METRICS = frozenset({"imcoh", "cohy", "plv", "ciplv", "pli", "wpli"})
def model_supports_phase(model) -> bool:
"""Whether a source model preserves phase.
A free-orientation estimate combines three orientations by norm, giving a
non-negative magnitude with no phase — so any phase-based connectivity
measure computed on it is meaningless. Fixed-orientation solutions (LCMV
with ``pick_ori="max-power"``, or a surface minimum-norm estimate with
``pick_ori="normal"``) keep the signed time course.
"""
return bool(model.supports_kernel)
def _connectivity_values(con, take_imag: bool) -> np.ndarray:
"""Per-pair connectivity values from a ``spectral_connectivity_time`` result.
Uses the default *raveled* output rather than ``output="dense"``: the dense
form allocates a real-valued matrix and silently discards the imaginary part
(``ComplexWarning``), which would zero out every ``imcoh`` value.
Parameters
----------
con : instance of SpectralConnectivity
Result of :func:`~mne_connectivity.spectral_connectivity_time`,
computed with explicit ``indices`` and ``faverage=True``.
take_imag : bool
Take the imaginary part (imaginary coherence) rather than the value
as returned.
Returns
-------
values : ndarray, shape (n_pairs,)
One real value per requested connection, averaged over epochs and
the (already band-averaged) frequency axis.
"""
values = np.asarray(con.get_data()) # (n_epochs, n_pairs, n_freqs)
values = values.reshape(values.shape[0], values.shape[1], -1).mean(axis=(0, 2))
if take_imag:
return np.imag(values)
if np.iscomplexobj(values):
# e.g. "cohy" requested directly — report the real (co-coherence) part.
return np.real(values)
return values
[docs]
class ModalityMixin:
"""Feature-extraction engine for all MNE-RT NF modalities.
:class:`ModalityMixin` is mixed into :class:`~mne_rt.RTStream` and provides
the **prep / compute** pair for every modality:
* **Prep** (``_<modality>_prep()``) — runs *once* before the main loop.
Returns a ``dict`` of pre-computed artefacts (filter coefficients, index
arrays, connectivity indices, …) that are passed as keyword arguments to
the compute step.
* **Compute** (``_<modality>(data, **prep_kwargs)``) — runs *every window*
inside a thread-pool worker. Decorated with :func:`~ant.tools.timed` so
it returns ``(value, elapsed_seconds)``.
.. rubric:: Supported modalities (21 total)
**Sensor-space power & time-domain**
``sensor_power``, ``band_ratio``, ``erd_ers``, ``laterality``,
``laterality_erd_ers``, ``hjorth``, ``spectral_centroid``, ``argmax_freq``,
``individual_peak_power``, ``entropy``, ``instantaneous_phase``,
``scp``, ``peak_alpha_freq``
**Sensor-space connectivity & graph**
``sensor_connectivity``, ``connectivity_ratio``, ``cfc_sensor``,
``sensor_graph``
**Source-space**
``source_power``, ``source_connectivity``, ``source_graph``
**Decoding**
``decode`` — single-trial classification via a fitted
:class:`~mne_rt.RTDecode` instance (see
:meth:`~mne_rt.RTStream.set_decoder`)
Notes
-----
All methods are private (single-underscore prefix) and are called
internally by :meth:`~mne_rt.RTStream.record_main`. To extend MNE-RT with a
custom modality, sub-class :class:`~mne_rt.RTStream` and add a matching
``_<name>_prep`` / ``_<name>`` pair following the same pattern.
"""
# ------------------------------------------------------------------
# Shared helpers
# ------------------------------------------------------------------
def _acquired_ch_names(self) -> list:
"""Channels present in the array ``stream.get_data()`` returns.
This is **not** ``rec_info["ch_names"]``: ``get_data()`` defaults to
``exclude="bads"``, so a channel marked bad — at connect time, or later
by :class:`~mne_rt.BadChannelDetector` during the baseline — is absent
from the array while still listed in the info.
Everything that indexes the acquired array by position, or wraps it in
a :class:`~mne.io.RawArray`, must use this list. Using
``rec_info["ch_names"]`` instead shifts every position once a channel is
bad, which is what made :meth:`record_baseline` raise
``len(data) does not match len(info["ch_names"])`` and made the
connectivity modalities silently compute on the wrong channel pairs.
Bads are deliberately left in ``rec_info``: the forward model and
beamformer exclude them by themselves, and
:class:`~mne_rt.tools.RTMaxwellFilter` needs them marked in order to
reconstruct them through the SSS expansion.
"""
bads = set(self.rec_info["bads"])
names = [ch for ch in self.rec_info["ch_names"] if ch not in bads]
picks = getattr(self, "picks", None)
if picks is None or isinstance(picks, str):
# A channel-type string is resolved by the stream itself; the
# ordering it returns still follows rec_info.
return names
wanted = set(picks)
return [ch for ch in names if ch in wanted]
def _get_source_model(self, *, method: str, atlas: str):
"""Return a :class:`~mne_rt.SourceModel`, building it at most once.
Memoised on ``(method, atlas)`` so that several source modalities — for
example the same connectivity measure in two frequency bands — share one
forward model and one beamformer instead of each paying to build their own.
"""
cache = getattr(self, "_source_models", None)
if cache is None:
cache = self._source_models = {}
key = (str(method), str(atlas))
if key not in cache:
# `from_stream` needs `src`/`fwd`/`data_cov`, which record_baseline no
# longer builds. Note compute_inv_operator() clears this cache, so it
# has to run before the entry is written, not after.
self._ensure_head_model(f"Source modality {method!r}")
cache = self._source_models
cache[key] = SourceModel.from_stream(self, method=method, atlas=atlas)
logger.info("Built %r for method=%s atlas=%s", cache[key], method, atlas)
return cache[key]
def _get_roi_kernel(self, model, rois, mri_resolution: bool):
"""Return the sensor → ROI operator, building it at most once.
Several instances of one modality — the same ROI pairs in three
frequency bands, say — ask for an identical kernel, and building it is
the expensive part of source preparation for a volume atlas at
``mri_resolution=True``.
The model is keyed by identity rather than by ``id()``: the cache holds
a reference, so an entry cannot outlive its model and be matched by a
later object that happens to reuse the address. :class:`~mne_rt.ROI` is
a frozen dataclass, so keying on the ROIs themselves distinguishes
same-named regions defined over different labels.
"""
cache = getattr(self, "_roi_kernels", None)
if cache is None:
cache = self._roi_kernels = {}
key = (model, tuple(rois), bool(mri_resolution))
if key not in cache:
cache[key] = model.roi_kernel(rois, mri_resolution=mri_resolution)
return cache[key]
def _roi_names(self, *legacy_keys: str) -> list:
"""ROI spec from ``rois``, falling back to the deprecated label keys."""
rois = self.params.get("rois")
if rois:
return list(rois) if not isinstance(rois, (str, dict)) else [rois]
legacy = [self.params[key] for key in legacy_keys if self.params.get(key)]
if not legacy:
raise ValueError(
"No ROIs configured. Set 'rois' for this modality, e.g. "
"rois: ['Broca', 'Wernicke']. Use mne_rt.list_rois() to see what "
"the chosen atlas provides."
)
warn(
f"{legacy_keys} is deprecated; use 'rois' (and 'pairs' where a modality "
"needs specific ROI pairs) instead. Support will be removed in a future "
"release.",
DeprecationWarning,
stacklevel=3,
)
return legacy
def _source_prep_common(self, *, inverse_method: str) -> dict:
"""Resolve ROIs and build the cached sensor → ROI operator."""
# `atlas: null` in the config means "use the session's", which is what
# makes RTStream(source_space="volume") pick a volumetric atlas without
# every modality having to restate it.
atlas = self.params.get("atlas") or getattr(self, "source_atlas", None) or "aparc"
model = self._get_source_model(method=inverse_method, atlas=atlas)
rois = resolve_rois(
self._roi_names("brain_label", "brain_label_1", "brain_label_2"),
atlas=atlas,
subject=self.subject_fs_id,
subjects_dir=self.subjects_fs_dir,
)
mri_resolution = bool(self.params.get("mri_resolution", True))
if model.supports_kernel:
return {
"model": model,
"rois": rois,
"kernel": self._get_roi_kernel(model, rois, mri_resolution),
# The operator's columns follow the forward model's channels, which
# exclude bads and any channel without a digitised position, so the
# data must be selected/reordered to match.
"ch_picks": model.channel_picks(self._acquired_ch_names()),
"label_operator": None,
}
warn(
f"{model!r} cannot use the cached-kernel path, so every window will run a "
"full source estimate — roughly two orders of magnitude slower, and likely "
"too slow to keep up with the acquisition loop. Use method='LCMV', or a "
"surface source space with a minimum-norm method.",
RuntimeWarning,
stacklevel=3,
)
return {
"model": model,
"rois": rois,
"kernel": None,
"ch_picks": None,
"label_operator": model.label_operator(rois, mri_resolution=mri_resolution),
}
@staticmethod
def _roi_time_courses(model, data, kernel, ch_picks, label_operator) -> np.ndarray:
"""ROI time courses ``(n_roi, n_times)`` for one analysis window."""
return model.apply(data, kernel=kernel, ch_picks=ch_picks, label_operator=label_operator)
def _ensure_head_model(self, reason: str, *, need_inverse: bool = False) -> None:
"""Build the forward/inverse model if nothing has built it yet.
:meth:`~mne_rt.RTStream.record_baseline` used to do this
unconditionally, which meant every baseline — including sensor-space
ones that never look at a source — fetched the ``fsaverage`` anatomy
over the network. It is built here instead, on the first request that
actually needs it.
Parameters
----------
reason : str
What asked for it, quoted in the error when no baseline exists.
need_inverse : bool, default False
Require a minimum-norm inverse operator as well as the forward
model. A beamformer needs only the latter, and
``compute_inv_operator(make_inverse=False)`` deliberately produces
no inverse, so callers that dereference :attr:`inv` have to say so
— otherwise this returns satisfied and they fail later with nothing
to explain it.
"""
_have_src = getattr(self, "src", None) is not None
_have_inv = getattr(self, "inv", None) is not None
if _have_src and (_have_inv or not need_inverse):
return
if getattr(self, "raw_baseline", None) is None:
raise RuntimeError(
f"{reason} needs a head model, which needs a baseline recording. "
"Run record_baseline() first, or call compute_inv_operator() "
"yourself if you have a baseline from elsewhere."
)
logger.info("Building the head model for %s …", reason)
self.compute_inv_operator(make_inverse=True)
def _get_inverse_operator(self):
"""Return the fitted inverse operator, from memory or from disk.
:meth:`~mne_rt.RTStream.compute_inv_operator` leaves the operator on the
instance and also writes it under ``inv/``; prefer the in-memory copy and
fall back to the file so a session restarted against an existing subject
directory still works.
"""
inv = getattr(self, "inv", None)
if inv is not None:
return inv
stem = f"sub-{self.subject_id}_ses-{self.session}_task-baseline"
fname = self.subject_dir / "inv" / f"{stem}_inv.fif"
if Path(fname).is_file():
return read_inverse_operator(fname=fname)
# Nothing on disk either, so build it now -- raising here would be a
# failure the caller can do nothing about except run the very step this
# method exists to perform.
self._ensure_head_model("A source-space modality", need_inverse=True)
inv = getattr(self, "inv", None)
if inv is None:
raise RuntimeError(
"No inverse operator available for a source-space modality, and "
"building one produced none. Call compute_inv_operator() with "
f"make_inverse=True. Looked for {fname}."
)
return inv
# ------------------------------------------------------------------
# Prep methods (run once before the main loop, return kwargs dict)
# ------------------------------------------------------------------
def _sensor_power_prep(self) -> dict:
return {
"sfreq": self.rec_info["sfreq"],
"frange": self.params["frange"],
"method": self.params["method"],
"relative": self.params["relative"],
}
def _band_ratio_prep(self) -> dict:
return {
"sfreq": self.rec_info["sfreq"],
"frange_1": self.params["frange_1"],
"frange_2": self.params["frange_2"],
"method": self.params["method"],
}
def _individual_peak_power_prep(self) -> dict:
_, peak_params_ = estimate_aperiodic_component(
raw_baseline=self.raw_baseline,
picks=self.picks,
method=self.params["method"],
)
candidates = [
p[0] for p in peak_params_ if self.params["frange"][0] < p[0] < self.params["frange"][1]
]
if len(candidates) == 1:
cf = candidates[0]
else:
cf = (self.params["frange"][0] + self.params["frange"][1]) / 2.0
warn(
"individual_peak_power: center frequency defaulted to mid-range "
f"({cf:.1f} Hz); found {len(candidates)} peak(s) in band.",
UserWarning,
stacklevel=2,
)
return {"sfreq": self._sfreq, "freq_var": 2.0, "cf": cf}
def _entropy_prep(self) -> dict:
sos = butter_bandpass(
self.params["frange"][0],
self.params["frange"][1],
self._sfreq,
order=5,
)
return {
"sos": sos,
"method": self.params["method"],
"psd_method": self.params["psd_method"],
}
def _argmax_freq_prep(self) -> dict:
if not hasattr(self, "raw_baseline"):
raise RuntimeError("Baseline recording must be completed before using 'argmax_freq'.")
ap_params, _ = estimate_aperiodic_component(
raw_baseline=self.raw_baseline,
picks=self.picks,
method=self.params["method"],
)
n_samples = int(self.winsize * self._sfreq)
fft_window = np.hanning(n_samples)
freqs = np.fft.rfftfreq(n_samples, d=1.0 / self._sfreq)
mask = (freqs >= self.params["frange"][0]) & (freqs <= self.params["frange"][1])
freqs_band = freqs[mask]
ap_model = (10 ** ap_params[0]) / (freqs_band ** ap_params[1])
def _gaussian(x: np.ndarray, a: float, mu: float, sigma: float) -> np.ndarray:
return a * np.exp(-((x - mu) ** 2) / (2 * sigma**2))
return {
"fft_window": fft_window,
"ap_model": ap_model,
"gaussian": _gaussian,
"mask": mask,
"freqs_band": freqs_band,
}
def _source_power_prep(self) -> dict:
method = self.params["method"]
if method not in _SOURCE_METHODS:
raise ValueError(
f"Unknown source method: {method!r}. Expected one of {sorted(_SOURCE_METHODS)}."
)
fft_window, _, freq_band_idxs, _ = compute_fft(
sfreq=self._sfreq,
winsize=self.winsize,
freq_range=self.params["frange"],
)
return {
**self._source_prep_common(inverse_method=method),
"fft_window": fft_window,
"freq_band_idxs": freq_band_idxs,
}
def _sensor_connectivity_prep(self) -> dict:
ch_names = self._acquired_ch_names() # data excludes bads; rec_info does not
chs = self.params["channels"]
# `channels` is a list of [ch_A, ch_B] pairs; build mne-connectivity's
# (seeds, targets) form. The previous zip(chs[0], chs[1]) happened to
# give the same answer for exactly two pairs, but raised IndexError for
# one pair and silently ignored the third onwards.
missing = sorted({c for pair in chs for c in pair} - set(ch_names))
if missing:
raise ValueError(
f"sensor_connectivity: channels {missing} not found in recording. "
f"Available: {ch_names}"
)
seeds = np.array([ch_names.index(pair[0]) for pair in chs])
targets = np.array([ch_names.index(pair[1]) for pair in chs])
indices = (seeds, targets)
fmin, fmax = self.params["frange"]
freqs = np.linspace(fmin, fmax, 6)
method, take_imag = resolve_connectivity_method(self.params["method"])
n_cycles = resolve_n_cycles(freqs, self.params.get("n_cycles", 5), self.winsize)
return {
"indices": indices,
"freqs": freqs,
"fmin": fmin,
"fmax": fmax,
"mode": self.params["mode"],
"method": method,
"take_imag": take_imag,
"n_cycles": n_cycles,
}
def _source_connectivity_prep(self) -> dict:
inverse_method = self.params.get("inverse_method", "dSPM")
if inverse_method not in _SOURCE_METHODS:
raise ValueError(
f"Unknown inverse_method: {inverse_method!r}. "
f"Expected one of {sorted(_SOURCE_METHODS)}."
)
common = self._source_prep_common(inverse_method=inverse_method)
rois = common["rois"]
roi_names = [roi.name for roi in rois]
pairs = self.params.get("pairs")
if not pairs:
if len(roi_names) != 2:
raise ValueError(
f"source_connectivity needs 'pairs' when more than two ROIs are "
f"configured; got {len(roi_names)} ROIs {roi_names} and no pairs. "
'Example: pairs: [["Broca", "Wernicke"]]'
)
pairs = [roi_names] # two ROIs, one obvious connection
seeds, targets = [], []
for pair in pairs:
if len(pair) != 2:
raise ValueError(f"Each entry of 'pairs' must name two ROIs, got {pair!r}.")
for name, bucket in zip(pair, (seeds, targets)):
if name not in roi_names:
raise ValueError(
f"Pair member {name!r} is not among the configured ROIs {roi_names}."
)
bucket.append(roi_names.index(name))
indices = (np.array(seeds), np.array(targets))
if not model_supports_phase(common["model"]):
method_name = self.params["method"]
if method_name in _PHASE_SENSITIVE_METRICS:
raise ValueError(
f"Connectivity metric {method_name!r} depends on phase, but "
f"{common['model']!r} produces a magnitude (a free-orientation "
"estimate combines three orientations by norm). Use "
"inverse_method='LCMV', or a surface source space."
)
fmin, fmax = self.params["frange"]
freqs = np.linspace(fmin, fmax, int(self.params.get("n_freqs", 6)))
method, take_imag = resolve_connectivity_method(self.params["method"])
n_cycles = resolve_n_cycles(freqs, self.params.get("n_cycles", 5), self.winsize)
return {
**common,
"indices": indices,
"freqs": freqs,
"fmin": fmin,
"fmax": fmax,
"mode": self.params["mode"],
"method": method,
"take_imag": take_imag,
"n_cycles": n_cycles,
"signed": bool(self.params.get("signed", False)),
}
def _sensor_graph_prep(self) -> dict:
ch_names = self._acquired_ch_names() # data excludes bads; rec_info does not
chs = self.params["channels"]
indices = tuple(
np.array([ch_names.index(ch1), ch_names.index(ch2)]) for ch1, ch2 in zip(chs[0], chs[1])
)
sos = butter_bandpass(
self.params["frange"][0],
self.params["frange"][1],
self._sfreq,
order=5,
)
return {
"indices": indices,
"sos": sos,
"dist_type": self.params["dist_type"],
"alpha": self.params["alpha"],
"beta": self.params["beta"],
}
def _source_graph_prep(self) -> dict:
inverse_method = self.params.get("inverse_method", "dSPM")
if inverse_method not in _SOURCE_METHODS:
raise ValueError(
f"Unknown inverse_method: {inverse_method!r}. "
f"Expected one of {sorted(_SOURCE_METHODS)}."
)
common = self._source_prep_common(inverse_method=inverse_method)
roi_names = [roi.name for roi in common["rois"]]
if len(roi_names) < 2:
raise ValueError(
f"source_graph learns a graph over ROIs and needs at least two; got {roi_names}."
)
pair = self.params.get("pair")
if pair is None:
pairs = self.params.get("pairs")
pair = pairs[0] if pairs else roi_names[:2]
missing = [name for name in pair if name not in roi_names]
if missing:
raise ValueError(f"Edge member(s) {missing} are not among the ROIs {roi_names}.")
edge = (roi_names.index(pair[0]), roi_names.index(pair[1]))
sos = butter_bandpass(
self.params["frange"][0],
self.params["frange"][1],
self._sfreq,
order=5,
)
return {
**common,
"edge": edge,
"sos": sos,
"dist_type": self.params["dist_type"],
"alpha": self.params["alpha"],
"beta": self.params["beta"],
}
def _cfc_sensor_prep(self) -> dict:
comod = Comodulogram(
fs=self._sfreq,
low_fq_range=np.linspace(self.params["frange_1"][0], self.params["frange_1"][1], 5),
high_fq_range=np.linspace(self.params["frange_2"][0], self.params["frange_2"][1], 5),
method=self.params["method"],
n_surrogates=0,
)
return {"comod": comod}
# ------------------------------------------------------------------
# Feature-extraction methods (decorated with @timed)
# ------------------------------------------------------------------
@timed
def _sensor_power(
self,
data: np.ndarray,
sfreq: float,
frange: tuple,
method: str = "welch",
relative: bool = False,
) -> float:
"""Mean band power across channels at sensor level."""
bp = compute_bandpower(data, sfreq, frange, method=method, relative=relative)
return float(bp.mean())
@timed
def _band_ratio(
self,
data: np.ndarray,
sfreq: float,
frange_1: tuple,
frange_2: tuple,
method: str = "welch",
) -> float:
"""Power ratio between two frequency bands."""
bp1 = compute_bandpower(data, sfreq, tuple(frange_1), method=method, relative=False)
bp2 = compute_bandpower(data, sfreq, tuple(frange_2), method=method, relative=False)
return float(bp1.mean() / (bp2.mean() + 1e-30))
@timed
def _individual_peak_power(
self,
data: np.ndarray,
sfreq: float,
freq_var: float,
cf: float,
) -> float:
"""Band power in a narrow window around the individual peak frequency."""
bp = compute_bandpower(
data,
sfreq,
(cf - freq_var, cf + freq_var),
method="welch",
relative=False,
)
return float(bp.mean())
@timed
def _entropy(
self,
data: np.ndarray,
sos: np.ndarray,
method: str,
psd_method: Optional[str] = None,
) -> float:
"""Entropy of band-filtered M/EEG signals."""
data_filt = sosfiltfilt(sos, data)
if method == "AppEn":
ents = compute_app_entropy(data_filt)
elif method == "SampEn":
ents = compute_samp_entropy(data_filt)
elif method == "Spectral":
ents = compute_spect_entropy(sfreq=self._sfreq, data=data_filt, psd_method=psd_method)
elif method == "SVD":
ents = compute_svd_entropy(data_filt)
else:
raise ValueError(
f"Unknown entropy method: {method!r}. "
"Expected one of 'AppEn', 'SampEn', 'Spectral', 'SVD'."
)
return float(ents.mean() - 2)
@timed
def _argmax_freq(
self,
data: np.ndarray,
fft_window: np.ndarray,
ap_model: np.ndarray,
gaussian,
mask: np.ndarray,
freqs_band: np.ndarray,
) -> float:
"""Individual peak frequency via aperiodic subtraction + Gaussian fit."""
data_win = data * fft_window
fftval = np.abs(np.fft.rfft(data_win, axis=1) / data.shape[-1])
periodic_power = np.mean(np.square(fftval[:, mask]), axis=0) - ap_model
p0 = [periodic_power.max(), freqs_band[np.argmax(periodic_power)], 1.0]
try:
popt, _ = curve_fit(gaussian, freqs_band, periodic_power, p0=p0)
return float(popt[1])
except RuntimeError:
warn(
"argmax_freq: Gaussian fit failed; returning 0 Hz.",
RuntimeWarning,
stacklevel=2,
)
return 0.0
@timed
def _source_power(
self,
data: np.ndarray,
model,
rois: list,
kernel,
ch_picks,
label_operator,
fft_window: np.ndarray,
freq_band_idxs: np.ndarray,
) -> float:
"""Band power averaged over the configured source-space ROIs."""
tcs = self._roi_time_courses(model, data, kernel, ch_picks, label_operator)
tcs = tcs * fft_window
fft_val = np.abs(np.fft.rfft(tcs, axis=1) / tcs.shape[-1])
return float(np.mean(np.square(fft_val[:, freq_band_idxs])))
@timed
def _sensor_connectivity(
self,
data: np.ndarray,
indices: tuple,
freqs: np.ndarray,
fmin: float,
fmax: float,
mode: str,
method: str,
take_imag: bool,
n_cycles: np.ndarray,
) -> float:
"""Sensor-level spectral connectivity between channel pairs."""
con = spectral_connectivity_time(
data=data[np.newaxis, :],
freqs=freqs,
indices=indices,
average=False,
sfreq=self._sfreq,
fmin=fmin,
fmax=fmax,
faverage=True,
mode=mode,
method=method,
n_cycles=n_cycles,
)
return float(_connectivity_values(con, take_imag).mean())
@timed
def _source_connectivity(
self,
data: np.ndarray,
model,
rois: list,
kernel,
ch_picks,
label_operator,
indices: tuple,
freqs: np.ndarray,
fmin: float,
fmax: float,
mode: str,
method: str,
take_imag: bool,
n_cycles: np.ndarray,
signed: bool,
) -> float:
"""Connectivity between the configured source-space ROI pairs."""
tcs = self._roi_time_courses(model, data, kernel, ch_picks, label_operator)
con = spectral_connectivity_time(
data=tcs[np.newaxis],
freqs=freqs,
indices=indices,
average=False,
sfreq=self._sfreq,
fmin=fmin,
fmax=fmax,
faverage=True,
mode=mode,
method=method,
n_cycles=n_cycles,
)
values = _connectivity_values(con, take_imag)
if not signed:
# imcoh and friends are signed: the sign encodes which region leads.
# For a reward signal the magnitude is usually what is wanted.
values = np.abs(values)
return float(values.mean())
@timed
def _sensor_graph(
self,
data: np.ndarray,
indices: tuple,
sos: np.ndarray,
dist_type: str,
alpha: float,
beta: float,
) -> float:
"""Graph-theoretic connectivity from sensor-space M/EEG."""
data_filt = sosfiltfilt(sos, data)
graph_matrix = log_degree_barrier(data_filt, dist_type=dist_type, alpha=alpha, beta=beta)
return float(np.mean([graph_matrix[idxs] for idxs in indices]) - 0.025)
@timed
def _source_graph(
self,
data: np.ndarray,
model,
rois: list,
kernel,
ch_picks,
label_operator,
edge: tuple,
sos: np.ndarray,
dist_type: str,
alpha: float,
beta: float,
) -> float:
"""Learned graph edge weight between two source-space ROIs."""
tcs = self._roi_time_courses(model, data, kernel, ch_picks, label_operator)
tcs_filt = sosfiltfilt(sos, tcs)
graph_matrix = log_degree_barrier(
tcs_filt,
dist_type=dist_type,
alpha=alpha,
beta=beta,
)
return float(graph_matrix[edge[0], edge[1]])
@timed
def _cfc_sensor(self, data: np.ndarray, comod) -> float:
"""Cross-frequency coupling (modulation index) at sensor level."""
comod.fit(data)
return float(comod.comod_.mean())
# ------------------------------------------------------------------
# ERD/ERS
# ------------------------------------------------------------------
def _erd_ers_prep(self) -> dict:
if not hasattr(self, "raw_baseline") or self.raw_baseline is None:
raise RuntimeError(
"erd_ers requires a completed baseline recording. Call record_baseline() first."
)
baseline_power = compute_bandpower(
self.raw_baseline.get_data(),
sfreq=self._sfreq,
band=tuple(self.params["frange"]),
method=self.params["method"],
relative=False,
).mean()
return {
"sfreq": self._sfreq,
"frange": self.params["frange"],
"method": self.params["method"],
"baseline_power": float(baseline_power),
}
@timed
def _erd_ers(
self,
data: np.ndarray,
sfreq: float,
frange: tuple,
method: str,
baseline_power: float,
) -> float:
"""Event-related desynchronisation / synchronisation (%).
Positive values = synchronisation (ERS); negative = desynchronisation (ERD).
"""
current_power = compute_bandpower(
data, sfreq, tuple(frange), method=method, relative=False
).mean()
return float((current_power - baseline_power) / (baseline_power + 1e-300) * 100.0)
# ------------------------------------------------------------------
# Laterality
# ------------------------------------------------------------------
def _laterality_prep(self) -> dict:
ch_names = self._acquired_ch_names() # data excludes bads; rec_info does not
def _is_left(name: str) -> bool:
# 10-20 convention: trailing odd digit → left hemisphere
for i in range(len(name) - 1, -1, -1):
if name[i].isdigit():
return int(name[i]) % 2 == 1
return False
def _is_right(name: str) -> bool:
for i in range(len(name) - 1, -1, -1):
if name[i].isdigit():
return int(name[i]) % 2 == 0
return False
lh_idx = [i for i, ch in enumerate(ch_names) if _is_left(ch)]
rh_idx = [i for i, ch in enumerate(ch_names) if _is_right(ch)]
if not lh_idx or not rh_idx:
warn(
"laterality: could not auto-detect left/right channels from names; "
"splitting by index instead.",
UserWarning,
stacklevel=2,
)
mid = len(ch_names) // 2
lh_idx = list(range(mid))
rh_idx = list(range(mid, len(ch_names)))
return {
"sfreq": self._sfreq,
"frange": self.params["frange"],
"method": self.params["method"],
"lh_idx": lh_idx,
"rh_idx": rh_idx,
}
@timed
def _laterality(
self,
data: np.ndarray,
sfreq: float,
frange: tuple,
method: str,
lh_idx: list,
rh_idx: list,
) -> float:
"""Inter-hemispheric power asymmetry: log(P_right) − log(P_left).
Positive → right dominance; negative → left dominance.
"""
lh_power = compute_bandpower(
data[lh_idx], sfreq, tuple(frange), method=method, relative=False
).mean()
rh_power = compute_bandpower(
data[rh_idx], sfreq, tuple(frange), method=method, relative=False
).mean()
return float(np.log(rh_power + 1e-300) - np.log(lh_power + 1e-300))
# ------------------------------------------------------------------
# Hjorth parameters
# ------------------------------------------------------------------
def _hjorth_prep(self) -> dict:
sos = butter_bandpass(
self.params["frange"][0],
self.params["frange"][1],
self._sfreq,
order=5,
)
return {"sos": sos}
@timed
def _hjorth(self, data: np.ndarray, sos: np.ndarray) -> float:
"""Mean of Hjorth mobility and complexity across channels.
Mobility ≈ dominant frequency proxy; complexity ≈ signal irregularity.
No FFT required — pure time-domain.
"""
x = sosfiltfilt(sos, data) # shape (n_ch, n_samples)
dx = np.diff(x, axis=1)
ddx = np.diff(dx, axis=1)
var_x = np.var(x, axis=1) + 1e-300
var_dx = np.var(dx, axis=1) + 1e-300
var_ddx = np.var(ddx, axis=1) + 1e-300
mobility = np.sqrt(var_dx / var_x)
mobility_d = np.sqrt(var_ddx / var_dx)
complexity = mobility_d / mobility
return float(0.5 * (mobility.mean() + complexity.mean()))
# ------------------------------------------------------------------
# Spectral centroid
# ------------------------------------------------------------------
def _spectral_centroid_prep(self) -> dict:
return {
"sfreq": self._sfreq,
"frange": self.params["frange"],
}
@timed
def _spectral_centroid(
self,
data: np.ndarray,
sfreq: float,
frange: tuple,
) -> float:
"""Frequency-weighted centre-of-mass of the PSD within a band (Hz).
High centroid → activity shifted towards the upper edge of the band
(useful for tracking alpha-peak drift or SMR centre-frequency).
"""
n_samples = data.shape[1]
freqs = np.fft.rfftfreq(n_samples, d=1.0 / sfreq)
mask = (freqs >= frange[0]) & (freqs <= frange[1])
freqs_band = freqs[mask]
psd = np.abs(np.fft.rfft(data, axis=1)) ** 2 # shape (n_ch, n_freqs)
psd_band = psd[:, mask]
total = psd_band.sum(axis=1, keepdims=True) + 1e-300
centroid_per_ch = (psd_band * freqs_band[np.newaxis, :]).sum(axis=1) / total.squeeze()
return float(centroid_per_ch.mean())
# ------------------------------------------------------------------
# ERD/ERS laterality index
# ------------------------------------------------------------------
def _laterality_erd_ers_prep(self) -> dict:
"""Prep: detect hemispheric channel indices + compute baseline powers."""
# requires raw_baseline
if not hasattr(self, "raw_baseline") or self.raw_baseline is None:
raise RuntimeError(
"laterality_erd_ers requires a completed baseline recording. "
"Call record_baseline() first."
)
ch_names = self._acquired_ch_names() # data excludes bads; rec_info does not
def _is_left(name):
for i in range(len(name) - 1, -1, -1):
if name[i].isdigit():
return int(name[i]) % 2 == 1
return False
def _is_right(name):
for i in range(len(name) - 1, -1, -1):
if name[i].isdigit():
return int(name[i]) % 2 == 0
return False
lh_idx = [i for i, ch in enumerate(ch_names) if _is_left(ch)]
rh_idx = [i for i, ch in enumerate(ch_names) if _is_right(ch)]
if not lh_idx or not rh_idx:
warn(
"laterality_erd_ers: hemispheric auto-detection failed; splitting by index.",
UserWarning,
stacklevel=2,
)
mid = len(ch_names) // 2
lh_idx = list(range(mid))
rh_idx = list(range(mid, len(ch_names)))
baseline_data = self.raw_baseline.get_data()
frange = tuple(self.params["frange"])
method = self.params["method"]
baseline_lh = float(
compute_bandpower(
baseline_data[lh_idx], self._sfreq, frange, method=method, relative=False
).mean()
)
baseline_rh = float(
compute_bandpower(
baseline_data[rh_idx], self._sfreq, frange, method=method, relative=False
).mean()
)
return {
"sfreq": self._sfreq,
"frange": frange,
"method": method,
"lh_idx": lh_idx,
"rh_idx": rh_idx,
"baseline_lh": baseline_lh,
"baseline_rh": baseline_rh,
}
@timed
def _laterality_erd_ers(
self,
data: np.ndarray,
sfreq: float,
frange: tuple,
method: str,
lh_idx: list,
rh_idx: list,
baseline_lh: float,
baseline_rh: float,
) -> float:
"""Baseline-normalised inter-hemispheric ERD/ERS asymmetry (%).
Computes the ERD/ERS ratio for each hemisphere separately
(normalised by its own baseline power) and returns the signed
difference:
feature = ERD_ERS_right − ERD_ERS_left
* Positive → right hemisphere more activated (ERS) or less suppressed.
* Negative → left hemisphere more activated (or right more suppressed).
Motor imagery example: right-hand imagery produces left-hemisphere
alpha ERD, so the feature becomes strongly negative during the task
and recovers toward zero at rest.
"""
lh_now = compute_bandpower(
data[lh_idx], sfreq, frange, method=method, relative=False
).mean()
rh_now = compute_bandpower(
data[rh_idx], sfreq, frange, method=method, relative=False
).mean()
erd_lh = (lh_now - baseline_lh) / (baseline_lh + 1e-300) * 100.0
erd_rh = (rh_now - baseline_rh) / (baseline_rh + 1e-300) * 100.0
return float(erd_rh - erd_lh)
# ------------------------------------------------------------------
# Slow Cortical Potentials (SCP)
# ------------------------------------------------------------------
def _scp_prep(self) -> dict:
"""Prep: build SOS low-pass (and optional high-pass) Butterworth filters."""
sfreq = self.rec_info["sfreq"]
lowpass = self.params["lowpass"]
highpass = self.params.get("highpass", 0.0)
reference = self.params.get("reference", "mean")
nyq = sfreq / 2.0
sos_lp = butter(4, lowpass / nyq, btype="low", output="sos")
sos_hp = None
if highpass > 0.0:
sos_hp = butter(4, highpass / nyq, btype="high", output="sos")
return {
"sos_lp": sos_lp,
"sos_hp": sos_hp,
"reference": reference,
}
@timed
def _scp(
self,
data: np.ndarray,
sos_lp: np.ndarray,
sos_hp,
reference: str,
) -> float:
"""Slow Cortical Potential: mean amplitude of the DC-coupled slow signal.
Applies a low-pass (and optional high-pass) zero-phase Butterworth
filter to extract the slow envelope, then collapses channels via
mean or median and returns the temporal mean of the resulting signal.
Positive SCP → cortical deactivation; negative SCP → activation.
"""
sig = data.copy()
# Optional high-pass first (removes very slow drifts if DC not coupled)
if sos_hp is not None:
sig = sosfiltfilt(sos_hp, sig)
# Low-pass to extract the slow cortical potential
sig = sosfiltfilt(sos_lp, sig)
# Collapse channels
if reference == "median":
channel_summary = np.median(sig, axis=0) # shape: (n_samples,)
else:
channel_summary = np.mean(sig, axis=0) # shape: (n_samples,)
return float(channel_summary.mean())
# ------------------------------------------------------------------
# Peak Alpha Frequency (PAF) tracker
# ------------------------------------------------------------------
def _peak_alpha_freq_prep(self) -> dict:
"""Prep: initialise EMA state for the real-time PAF tracker."""
sfreq = self.rec_info["sfreq"]
frange = self.params["frange"]
method = self.params.get("method", "welch")
smoothing = self.params.get("smoothing", 0.85)
# Compute initial PAF from baseline if available; else use band midpoint
if hasattr(self, "raw_baseline") and self.raw_baseline is not None:
baseline_data = self.raw_baseline.get_data() # (n_ch, n_samples)
mean_sig = baseline_data.mean(axis=0) # (n_samples,)
if method == "welch":
freqs_bl, psd_bl = welch(mean_sig, fs=sfreq, nperseg=min(256, mean_sig.shape[-1]))
else:
n = mean_sig.shape[-1]
fft_vals = np.abs(np.fft.rfft(mean_sig)) ** 2
freqs_bl = np.fft.rfftfreq(n, d=1.0 / sfreq)
psd_bl = fft_vals
mask_bl = (freqs_bl >= frange[0]) & (freqs_bl <= frange[1])
if mask_bl.any():
initial_paf = float(freqs_bl[mask_bl][np.argmax(psd_bl[mask_bl])])
else:
initial_paf = float((frange[0] + frange[1]) / 2.0)
else:
initial_paf = float((frange[0] + frange[1]) / 2.0)
return {
"sfreq": float(sfreq),
"frange": list(frange),
"method": method,
"smoothing": float(smoothing),
"_paf_state": [initial_paf], # mutable reference cell for EMA state
}
@timed
def _peak_alpha_freq(
self,
data: np.ndarray,
sfreq: float,
frange: list,
method: str,
smoothing: float,
_paf_state: list,
) -> float:
"""Real-time peak alpha frequency (PAF) with exponential smoothing.
Computes the PSD of the current window (averaged across channels),
finds the dominant peak within *frange*, and updates an exponential
moving average (EMA) to suppress frame-to-frame jitter.
Returns the EMA-smoothed PAF in Hz.
"""
# Average across channels to get a single time series
mean_sig = data.mean(axis=0) # shape: (n_samples,)
# Compute PSD
if method == "welch":
freqs, psd = welch(mean_sig, fs=sfreq, nperseg=min(256, mean_sig.shape[-1]))
else:
n = mean_sig.shape[-1]
psd = np.abs(np.fft.rfft(mean_sig)) ** 2
freqs = np.fft.rfftfreq(n, d=1.0 / sfreq)
# Find peak within frange
mask = (freqs >= frange[0]) & (freqs <= frange[1])
if mask.any():
peak_freq = float(freqs[mask][np.argmax(psd[mask])])
else:
peak_freq = float(_paf_state[0]) # fallback: keep current estimate
# EMA update — mutate the state cell so state persists across windows
new_paf = (1.0 - smoothing) * peak_freq + smoothing * _paf_state[0]
_paf_state[0] = new_paf
return float(new_paf)
# ------------------------------------------------------------------
# Connectivity Ratio
# ------------------------------------------------------------------
def _connectivity_ratio_prep(self) -> dict:
"""Prep: build connectivity indices for numerator and denominator pairs."""
ch_names = self._acquired_ch_names() # data excludes bads; rec_info does not
def _pair_to_indices(pair):
a, b = pair[0], pair[1]
if a not in ch_names or b not in ch_names:
missing = [c for c in [a, b] if c not in ch_names]
raise ValueError(
f"connectivity_ratio: channels {missing} not found in recording. "
f"Available: {ch_names}"
)
return (np.array([ch_names.index(a)]), np.array([ch_names.index(b)]))
indices_num = _pair_to_indices(self.params["channels_num"])
indices_den = _pair_to_indices(self.params["channels_den"])
freqs = np.linspace(self.params["frange"][0], self.params["frange"][1], 6)
method, take_imag = resolve_connectivity_method(self.params["method"])
n_cycles = resolve_n_cycles(freqs, self.params.get("n_cycles", 5), self.winsize)
return {
"indices_num": indices_num,
"indices_den": indices_den,
"freqs": freqs,
"fmin": float(self.params["frange"][0]),
"fmax": float(self.params["frange"][1]),
"mode": self.params["mode"],
"method": method,
"take_imag": take_imag,
"n_cycles": n_cycles,
}
@timed
def _connectivity_ratio(
self,
data: np.ndarray,
indices_num: tuple,
indices_den: tuple,
freqs: np.ndarray,
fmin: float,
fmax: float,
mode: str,
method: str,
take_imag: bool,
n_cycles: np.ndarray,
) -> float:
"""Ratio of functional connectivity between two channel pairs (or groups).
Useful for laterality of connectivity, e.g. ipsilateral / contralateral.
Returns conn_pair1 / conn_pair2.
"""
def _connectivity(indices):
con = spectral_connectivity_time(
data=data[np.newaxis, :],
freqs=freqs,
indices=indices,
average=False,
sfreq=self._sfreq,
fmin=fmin,
fmax=fmax,
faverage=True,
mode=mode,
method=method,
n_cycles=n_cycles,
)
return float(_connectivity_values(con, take_imag).mean())
conn_num = _connectivity(indices_num)
conn_den = _connectivity(indices_den)
return float(conn_num / (conn_den + 1e-30))
# ------------------------------------------------------------------
# Decoding
# ------------------------------------------------------------------
def _decode_prep(self) -> dict:
decoder = getattr(self, "decoder", None)
if decoder is None:
raise RuntimeError(
"The 'decode' modality requires a fitted RTDecode instance. "
"Call set_decoder(RTDecode(...).fit(X, y)) before record_main()."
)
if not decoder.fitted:
raise RuntimeError(
"The RTDecode instance passed to set_decoder() must be "
"fit() on calibration epochs before record_main()."
)
if not decoder._supports_proba:
raise AttributeError(
f"The 'decode' modality requires predict_proba(); the estimator "
f"{type(decoder.estimator).__name__} attached via set_decoder() "
"does not implement it. Pass an estimator that does, e.g. "
"LogisticRegression or SVC(probability=True)."
)
class_index = self.params["class_index"]
n_classes = len(decoder.classes_)
if not (0 <= class_index < n_classes):
raise ValueError(
f"`class_index={class_index}` is out of range for the fitted "
f"decoder, which has {n_classes} classes "
f"(decoder.classes_={list(decoder.classes_)!r})."
)
picks = getattr(self, "picks", None)
n_expected = len(picks) if picks is not None else len(self.rec_info["ch_names"])
if decoder.n_channels_ != n_expected:
raise ValueError(
f"The fitted decoder expects {decoder.n_channels_} channels but "
f"the current session provides {n_expected} (picks={picks!r}). "
"Re-fit RTDecode on calibration data matching this session's "
"channel selection before record_main()."
)
return {"decoder": decoder, "class_index": class_index}
@timed
def _decode(self, data: np.ndarray, decoder, class_index: int) -> float:
"""Probability of ``decoder.classes_[class_index]`` for one window."""
return float(decoder.predict_proba(data)[class_index])