Source code for mne_denoise.dss.denoisers.artifact

"""Event-locked bias functions for DSS."""

from __future__ import annotations

from collections.abc import Sequence
from typing import Literal

import numpy as np

from ..._validation import (
    check_positive_integer,
    check_positive_real,
    resolve_sample_window,
)
from .base import LinearDenoiser


def _as_int64_coordinates(
    values: object, name: str, *, ndim: tuple[int, ...]
) -> np.ndarray:
    """Return exact integer coordinates without signed overflow."""
    array = np.asarray(values, dtype=object)
    if array.ndim not in ndim:
        dimensions = " or ".join(f"{value}-D" for value in ndim)
        raise ValueError(f"{name} must be a {dimensions} integer array.")
    if array.ndim == 2 and array.shape[1] != 2:
        raise ValueError(
            f"{name} must have shape (n_events, 2) for per-epoch coordinates."
        )

    bounds = np.iinfo(np.int64)
    converted = []
    for value in array.ravel().tolist():
        if isinstance(value, (bool, np.bool_)) or not isinstance(
            value, (int, np.integer)
        ):
            raise ValueError(f"{name} must contain only integer coordinates.")
        integer = int(value)
        if integer < int(bounds.min) or integer > int(bounds.max):
            raise ValueError(f"{name} values must fit in signed 64-bit samples.")
        converted.append(integer)
    return np.asarray(converted, dtype=np.int64).reshape(array.shape)


def _prepare_events(
    event_samples: object, event_origin: str, first_samp: int | None
) -> tuple[np.ndarray, int | None, np.ndarray]:
    """Validate, deduplicate, and map public event coordinates."""
    if event_origin not in {"data", "raw"}:
        raise ValueError(f"event_origin must be 'data' or 'raw', got {event_origin!r}.")
    if event_origin == "raw" and first_samp is None:
        raise ValueError("first_samp is required when event_origin='raw'.")
    if event_origin == "data" and first_samp is not None:
        raise ValueError(
            "first_samp must be omitted when event_origin='data'; events are "
            "already relative to the supplied data."
        )

    events = np.unique(
        _as_int64_coordinates(event_samples, "event_samples", ndim=(1, 2)), axis=0
    )
    if events.ndim == 2:
        if np.any(events[:, 0] < 0):
            raise ValueError("epoch indices in event_samples must be non-negative.")
        if event_origin == "raw":
            raise ValueError(
                "event_origin='raw' is not defined for per-epoch coordinates; "
                "use data-relative (epoch_index, sample_index) pairs."
            )
        return events, None, events.copy()

    if first_samp is None:
        return events, None, events.copy()
    first_samp = int(_as_int64_coordinates([first_samp], "first_samp", ndim=(1,))[0])
    mapped = _as_int64_coordinates(
        [int(event) - first_samp for event in events],
        "event_samples after applying first_samp",
        ndim=(1,),
    )
    return events, first_samp, mapped


def _as_float_data(data: np.ndarray) -> np.ndarray:
    """Validate numerical input and convert it without mutating the caller."""
    array = np.asarray(data)
    if array.ndim not in (2, 3):
        raise ValueError(f"Data must be 2D or 3D, got {array.ndim}D.")
    if not np.issubdtype(array.dtype, np.number):
        raise TypeError("Data must contain real numerical values.")
    if np.iscomplexobj(array):
        raise TypeError("Data must be real-valued, not complex.")
    if not np.all(np.isfinite(array)):
        raise ValueError("Data must contain only finite values.")
    dtype = np.float32 if array.dtype == np.float32 else np.float64
    return np.asarray(array, dtype=dtype)


def _event_segments(
    data: np.ndarray,
    events: np.ndarray,
    window: tuple[int, int],
    min_events: int,
) -> list[tuple[slice, ...]]:
    """Validate coordinates and return safe indexing segments."""
    if data.ndim == 2 and events.ndim != 1:
        raise ValueError("2-D data requires one-dimensional event sample coordinates.")
    if data.ndim == 3 and events.ndim != 2:
        raise ValueError(
            "3-D data requires (epoch_index, sample_index) event coordinates; "
            "flat global sample indices are not accepted."
        )
    if len(events) < min_events:
        raise ValueError(
            f"CycleAverageBias requires at least {min_events} unique complete "
            f"events; received {len(events)} after deduplication."
        )

    n_times = data.shape[1]
    n_epochs = None if data.ndim == 2 else data.shape[2]
    pre, post = window
    segments = []
    for coordinate in events.tolist():
        if events.ndim == 1:
            sample = int(coordinate)
            label = f"event sample {sample}"
            segment = (slice(sample + pre, sample + post),)
        else:
            epoch, sample = (int(value) for value in coordinate)
            label = f"event ({epoch}, {sample})"
            assert n_epochs is not None
            if epoch >= n_epochs:
                raise ValueError(
                    f"{label} is out of range for data with {n_epochs} epochs."
                )
            segment = (slice(sample + pre, sample + post), epoch)
        if sample < 0 or sample >= n_times:
            raise ValueError(
                f"{label} is out of range for the sample coordinates [0, {n_times})."
            )
        if sample + pre < 0 or sample + post > n_times:
            raise ValueError(
                f"{label} with half-open window [{sample + pre}, {sample + post}) "
                f"crosses the data boundary [0, {n_times}); adjust events or window."
            )
        segments.append(segment)
    return segments


[docs] class CycleAverageBias(LinearDenoiser): """Fixed-window event-locked averaging bias. Parameters ---------- event_samples : array-like of int, shape (n_events,) or (n_events, 2) One-dimensional sample coordinates for 2D input, or ``(epoch_index, sample_index)`` pairs for 3D channel-first input. window : tuple of int or float, default=(-100, 200) Half-open interval ``[event + start, event + stop)``. window_unit : {"samples", "seconds"}, default="samples" Unit for ``window``; seconds require ``sfreq``. sfreq : float or None, default=None Sampling frequency in Hz for second-valued windows. event_origin : {"data", "raw"}, default="data" Origin for one-dimensional coordinates. ``"raw"`` requires ``first_samp``. first_samp : int or None, default=None Acquisition sample offset used with ``event_origin="raw"``. min_events : int, default=2 Minimum number of unique complete events. Notes ----- Windows are half-open. Boundary-crossing or incomplete events raise an error; overlapping contributions are averaged and duplicate coordinates are removed. This fixed-window bias is not the complete variable-period quasiperiodic procedure described in the original DSS work. """ def __init__( self, event_samples: Sequence[int] | Sequence[tuple[int, int]], window: tuple[int | float, int | float] = (-100, 200), *, window_unit: Literal["samples", "seconds"] = "samples", sfreq: float | None = None, event_origin: Literal["data", "raw"] = "data", first_samp: int | None = None, min_events: int = 2, ) -> None: self.window = resolve_sample_window( window, unit=window_unit, sfreq=sfreq, name="window" ) self.window_input = tuple(window) self.window_unit = window_unit self.sfreq = ( check_positive_real(sfreq, name="sfreq") if sfreq is not None else None ) self.event_origin = event_origin self.event_samples, self.first_samp, self.event_samples_data_ = _prepare_events( event_samples, event_origin, first_samp ) self.min_events = check_positive_integer(min_events, name="min_events") if self.min_events < 2: raise ValueError("min_events must be greater than or equal to 2") self._window_length = self.window[1] - self.window[0]
[docs] def apply(self, data: np.ndarray) -> np.ndarray: """Apply fixed-window event-locked averaging. Parameters ---------- data : ndarray, shape (n_channels, n_times) or (n_channels, n_times, n_epochs) Continuous or channel-first epoched data. Returns ------- ndarray Floating-point event-locked estimate with the input shape. """ data = _as_float_data(data) segments = _event_segments( data, self.event_samples_data_, self.window, self.min_events ) windows = [data[(slice(None), *segment)] for segment in segments] cycle_average = np.mean(np.stack(windows), axis=0, dtype=data.dtype) biased = np.zeros_like(data) counts = np.zeros(data.shape[1:], dtype=np.int64) for segment in segments: biased[(slice(None), *segment)] += cycle_average counts[segment] += 1 np.divide( biased, counts[np.newaxis, ...], out=biased, where=counts[np.newaxis, ...] > 0, ) return biased