Source code for mne_denoise.asr._reconstruction

"""ASR processing: reconstruct artifact subspaces in incoming data.

``process_asr`` applies the fitted calibration window by window, rejecting and
reconstructing the principal subspaces whose variance exceeds the
per-direction thresholds (raised-cosine blended, with lookahead). The
Riemannian variants implement the experimental SPD-geometry backends.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

import numpy as np

from ..blending import raised_cosine_ramp
from ._covariance import (
    _ChunkedMovingCovariances,
    _covariance_chunk_blocks,
    _covariance_stack_bytes,
    _iter_moving_covariances_at,
    _max_mem_bytes,
    _moving_average_padded,
    _process_memory_info,
)
from ._filters import (
    _append_streaming_tail,
    _apply_statistics_filter_streaming,
    _lfilter_channels,
    _prepend_streaming_carry,
)
from ._spd import _regularize_spd, _riemannian_nonlinear_eigenspace
from ._types import ASRState, _copy_process_state
from ._validation import (
    _resolve_max_dims_padded,
    _round_half_up,
    _validate_array_2d,
    _validate_common_params,
)


@dataclass(frozen=True)
class _PreparedASRStream:
    """Validated arrays and indices shared by windowed ASR processors."""

    data: np.ndarray
    n_channels: int
    n_times: int
    win_len: int
    lookahead_samples: int
    stepsize: int
    max_bad: int
    data_stream: np.ndarray | None
    n_stream_input: int
    statistics: np.ndarray | None
    update_at: np.ndarray | None
    use_rolling_covariance: bool


def _prepare_asr_stream(
    X: np.ndarray,
    sfreq: float,
    state: ASRState,
    *,
    window_length: float,
    window_overlap: float,
    max_dims: float | int,
    regularization: float,
    max_mem_mb: int | None,
    lookahead: float | None,
    stepsize: int | None,
) -> _PreparedASRStream:
    """Validate input and prepare the common streaming ASR representation."""
    _validate_common_params(
        sfreq=sfreq,
        cutoff=1.0,
        window_length=window_length,
        window_overlap=window_overlap,
        max_dropout_fraction=0.1,
        min_clean_fraction=0.25,
        regularization=regularization,
    )
    X = _validate_array_2d(X)
    n_channels, n_times = X.shape
    if state.M.shape != (n_channels, n_channels):
        raise ValueError(
            "ASR state channel count does not match data: "
            f"{state.M.shape[0]} vs {n_channels}"
        )

    win_len = max(
        _round_half_up(window_length * sfreq), _round_half_up(1.5 * n_channels)
    )
    if n_times < win_len:
        raise ValueError(
            f"Window length ({win_len} samples) exceeds data length ({n_times} samples)"
        )
    lookahead = (win_len / sfreq) / 2.0 if lookahead is None else float(lookahead)
    if lookahead < 0:
        raise ValueError("lookahead must be non-negative")
    lookahead_samples = _round_half_up(lookahead * sfreq)
    if lookahead_samples >= n_times:
        raise ValueError("lookahead is too long for the data length")
    if stepsize is None:
        stepsize = max(1, win_len // 2)
    else:
        stepsize = int(stepsize)
    if stepsize < 1:
        raise ValueError("stepsize must be at least 1 sample")
    if stepsize > win_len:
        raise ValueError("stepsize must not exceed window_length in samples")

    max_bad = _resolve_max_dims_padded(max_dims, n_channels)
    if max_bad <= 0:
        return _PreparedASRStream(
            data=X,
            n_channels=n_channels,
            n_times=n_times,
            win_len=win_len,
            lookahead_samples=lookahead_samples,
            stepsize=stepsize,
            max_bad=max_bad,
            data_stream=None,
            n_stream_input=n_times,
            statistics=None,
            update_at=None,
            use_rolling_covariance=False,
        )

    X_proc = _append_streaming_tail(X, lookahead_samples)
    data_stream = _prepend_streaming_carry(X_proc, lookahead_samples)
    data_stream[~np.isfinite(data_stream)] = 0.0
    n_stream_input = X_proc.shape[1]
    statistics_input = data_stream[
        :, lookahead_samples : lookahead_samples + n_stream_input
    ]
    if state.filter_zi is None:
        statistics = _apply_statistics_filter_streaming(
            statistics_input,
            state.filter_b,
            state.filter_a,
        )
    else:
        statistics, _ = _lfilter_channels(
            statistics_input,
            state.filter_b,
            state.filter_a,
            zi=state.filter_zi,
        )
    update_at = np.minimum(
        np.arange(stepsize, n_stream_input + stepsize, stepsize, dtype=int),
        n_stream_input,
    )
    if update_at.size == 0 or update_at[-1] != n_stream_input:
        update_at = np.append(update_at, n_stream_input)
    update_at = np.concatenate(([1], np.unique(update_at)))
    max_mem_bytes = _max_mem_bytes(max_mem_mb)
    use_rolling_covariance = max_mem_bytes is not None and (
        _covariance_stack_bytes(n_stream_input, n_channels) > max_mem_bytes
    )
    return _PreparedASRStream(
        data=X,
        n_channels=n_channels,
        n_times=n_times,
        win_len=win_len,
        lookahead_samples=lookahead_samples,
        stepsize=stepsize,
        max_bad=max_bad,
        data_stream=data_stream,
        n_stream_input=n_stream_input,
        statistics=statistics,
        update_at=update_at,
        use_rolling_covariance=use_rolling_covariance,
    )


[docs] def process_asr( X: np.ndarray, sfreq: float, state: ASRState, *, window_length: float = 0.5, window_overlap: float = 0.66, max_dims: float | int = 0.66, regularization: float = 1e-8, store_reconstruction_matrices: bool = False, max_mem_mb: int | None = 512, lookahead: float | None = None, stepsize: int | None = None, method: str | None = None, ) -> tuple[np.ndarray, dict[str, Any]]: """Apply a calibrated ASR model to continuous data. Parameters ---------- X : ndarray, shape (n_channels, n_times) Continuous data in the same channel order and units used for calibration. sfreq : float Sampling frequency in Hz. state : ASRState Fitted calibration state from :func:`calibrate_asr`. window_length : float Processing window length in seconds. window_overlap : float Calibration threshold-window overlap. Processing follows the standard streaming ASR algorithm and uses ``stepsize`` for reconstruction-matrix updates. max_dims : float | int Maximum number of dimensions reconstructed per window. Floats in ``[0, 1]`` are interpreted as a fraction of channels. regularization : float Relative eigenvalue floor for window covariances. store_reconstruction_matrices : bool If True, store all window reconstruction matrices in diagnostics. max_mem_mb : int | None Reserved memory limit for future chunking. Present for API stability. lookahead : float | None Processing lookahead in seconds. If None, use ``window_length / 2``. stepsize : int | None Number of samples between reconstruction-matrix updates. If None, use ``floor(sfreq * window_length / 2)``, matching the standard algorithm defaults. method : {'standard', 'riemannian'} | None Covariance geometry for processing. If ``None``, use ``state.method``. Returns ------- X_clean : ndarray, shape (n_channels, n_times) Cleaned data. diagnostics : dict Processing diagnostics. Examples -------- Process a new array of task data using a previously calibrated ASR state: >>> import numpy as np >>> from mne_denoise.asr import process_asr >>> rng = np.random.default_rng(42) >>> task_data = rng.standard_normal((10, 2000)) >>> # Assuming 'state' is an ASRState returned by calibrate_asr >>> cleaned_data, diagnostics = process_asr(task_data, sfreq=250.0, state=state) >>> print(f"Cleaned data shape: {cleaned_data.shape}") Cleaned data shape: (10, 2000) """ if method is None: method = state.method if method not in ("standard", "riemannian", "riemannian_windowed"): raise ValueError( "method must be 'standard', 'riemannian', or 'riemannian_windowed'" ) prepared = _prepare_asr_stream( X, sfreq, state, window_length=window_length, window_overlap=window_overlap, max_dims=max_dims, regularization=regularization, max_mem_mb=max_mem_mb, lookahead=lookahead, stepsize=stepsize, ) X = prepared.data n_channels = prepared.n_channels n_times = prepared.n_times win_len = prepared.win_len lookahead_samples = prepared.lookahead_samples stepsize = prepared.stepsize max_bad = prepared.max_bad if max_bad <= 0: diagnostics = _empty_process_diagnostics(n_times) diagnostics.update( _process_memory_info( n_channels=n_channels, n_stream_input=n_times, max_mem_mb=max_mem_mb, memory_mode="identity", peak_cov_buffer_bytes=0, chunk_samples=0, used_memory_bound=False, ) ) return X.copy(), diagnostics assert prepared.data_stream is not None assert prepared.statistics is not None assert prepared.update_at is not None data_stream = prepared.data_stream n_stream_input = prepared.n_stream_input X_stats = prepared.statistics update_at = prepared.update_at use_rolling_covariance = prepared.use_rolling_covariance if method == "riemannian": X_clean, diagnostics = _process_asr_riemannian( data_stream, X_stats, state, n_times=n_times, n_stream_input=n_stream_input, lookahead_samples=lookahead_samples, update_at=update_at, max_bad=max_bad, stepsize=stepsize, win_len=win_len, regularization=regularization, store_reconstruction_matrices=store_reconstruction_matrices, ) diagnostics.update( _process_memory_info( n_channels=n_channels, n_stream_input=n_stream_input, max_mem_mb=max_mem_mb, memory_mode="riemannian", peak_cov_buffer_bytes=_covariance_stack_bytes(1, n_channels), chunk_samples=n_stream_input, used_memory_bound=False, ) ) return X_clean, diagnostics if method == "riemannian_windowed": X_clean, diagnostics = _process_asr_riemannian_windowed( data_stream, X_stats, state, n_times=n_times, n_stream_input=n_stream_input, lookahead_samples=lookahead_samples, update_at=update_at, max_bad=max_bad, stepsize=stepsize, win_len=win_len, store_reconstruction_matrices=store_reconstruction_matrices, use_rolling_covariance=use_rolling_covariance, ) diagnostics.update( _process_memory_info( n_channels=n_channels, n_stream_input=n_stream_input, max_mem_mb=max_mem_mb, memory_mode=( "riemannian_windowed_rolling" if use_rolling_covariance else "riemannian_windowed" ), peak_cov_buffer_bytes=_covariance_stack_bytes(1, n_channels), chunk_samples=win_len if use_rolling_covariance else n_stream_input, used_memory_bound=use_rolling_covariance, ) ) return X_clean, diagnostics if use_rolling_covariance: covariance_iter = _iter_moving_covariances_at(X_stats, update_at, win_len) Xcov_flat = None else: outer = np.einsum("it,jt->ijt", X_stats, X_stats, optimize=True) Xcov_flat = outer.reshape(n_channels * n_channels, n_stream_input, order="F") Xcov_flat, _ = _moving_average_padded(win_len, Xcov_flat) covariance_iter = None sample_mask = np.zeros(n_times, dtype=bool) n_reconstructed: list[int] = [] component_variances: list[np.ndarray] = [] component_thresholds: list[np.ndarray] = [] reconstruction_matrices: list[np.ndarray] = [] window_starts: list[int] = [] window_stops: list[int] = [] eye = np.eye(n_channels) last_R = eye last_trivial = True last_n = 0 for n in update_at: if covariance_iter is None: assert Xcov_flat is not None Cw = Xcov_flat[:, n - 1].reshape(n_channels, n_channels, order="F") else: Cw = next(covariance_iter) Cw = (Cw + Cw.T) / 2.0 D, V = np.linalg.eigh(Cw) order = np.argsort(D) D = D[order] V = V[:, order] theta2 = np.sum((state.T @ V) ** 2, axis=0) keep = (theta2 > D) | (np.arange(1, n_channels + 1) < (n_channels - max_bad)) trivial = bool(np.all(keep)) n_bad = int(n_channels - np.count_nonzero(keep)) if trivial: R = eye else: basis = keep[:, np.newaxis].astype(np.float64) * (V.T @ state.M) R = state.M @ np.linalg.pinv(basis) @ V.T R = np.real_if_close(R).astype(np.float64) applied = (not trivial) or (not last_trivial) if applied and n > last_n: subrange = slice(last_n, n) width = n - last_n blend = raised_cosine_ramp(width) segment = data_stream[:, subrange] data_stream[:, subrange] = (R @ segment) * blend[np.newaxis, :] + ( last_R @ segment ) * (1.0 - blend[np.newaxis, :]) start_out = max(last_n, lookahead_samples) - lookahead_samples stop_out = min(n, lookahead_samples + n_times) - lookahead_samples if stop_out > start_out: window_starts.append(int(start_out)) window_stops.append(int(stop_out)) n_reconstructed.append(n_bad) component_variances.append(D.copy()) component_thresholds.append(theta2.copy()) if applied: sample_mask[start_out:stop_out] = True if store_reconstruction_matrices: reconstruction_matrices.append(R.copy()) last_n = int(n) last_R = R last_trivial = trivial X_clean = data_stream[:, lookahead_samples : lookahead_samples + n_times].copy() n_reconstructed_arr = np.asarray(n_reconstructed, dtype=int) diagnostics = { "window_starts": np.asarray(window_starts, dtype=int), "window_stops": np.asarray(window_stops, dtype=int), "sample_mask": sample_mask, "n_components_reconstructed": n_reconstructed_arr, "component_variances": np.asarray(component_variances, dtype=np.float64), "component_thresholds": np.asarray(component_thresholds, dtype=np.float64), "n_windows": int(len(n_reconstructed_arr)), "fraction_reconstructed_windows": float( np.mean(n_reconstructed_arr > 0) if n_reconstructed_arr.size else 0.0 ), "fraction_reconstructed_samples": float(np.mean(sample_mask)), "max_components_reconstructed": int(n_reconstructed_arr.max(initial=0)), "lookahead_samples": int(lookahead_samples), "stepsize_samples": int(stepsize), "window_length_samples": int(win_len), "covariance_geometry": method, } if use_rolling_covariance: diagnostics.update( _process_memory_info( n_channels=n_channels, n_stream_input=n_stream_input, max_mem_mb=max_mem_mb, memory_mode="rolling", peak_cov_buffer_bytes=_covariance_stack_bytes(1, n_channels), chunk_samples=win_len, used_memory_bound=True, ) ) else: diagnostics.update( _process_memory_info( n_channels=n_channels, n_stream_input=n_stream_input, max_mem_mb=max_mem_mb, memory_mode="full", peak_cov_buffer_bytes=_covariance_stack_bytes( n_stream_input, n_channels ), chunk_samples=n_stream_input, used_memory_bound=False, ) ) if store_reconstruction_matrices: diagnostics["reconstruction_matrices"] = np.asarray(reconstruction_matrices) return X_clean, diagnostics
def _empty_process_diagnostics(n_times: int) -> dict[str, Any]: """Return identity-processing diagnostics. Parameters ---------- n_times : int Number of time samples in the processed block. Returns ------- diagnostics : dict A diagnostic dictionary describing a trivial identity reconstruction where no components were removed. """ return { "window_starts": np.array([0], dtype=int), "window_stops": np.array([n_times], dtype=int), "sample_mask": np.zeros(n_times, dtype=bool), "n_components_reconstructed": np.array([0], dtype=int), "component_variances": np.empty((1, 0), dtype=np.float64), "component_thresholds": np.empty((1, 0), dtype=np.float64), "n_windows": 1, "fraction_reconstructed_windows": 0.0, "fraction_reconstructed_samples": 0.0, "max_components_reconstructed": 0, "lookahead_samples": 0, "stepsize_samples": 0, "window_length_samples": 0, } def _process_asr_riemannian( data_stream: np.ndarray, X_stats: np.ndarray, state: ASRState, *, n_times: int, n_stream_input: int, lookahead_samples: int, update_at: np.ndarray, max_bad: int, stepsize: int, win_len: int, regularization: float, store_reconstruction_matrices: bool, ) -> tuple[np.ndarray, dict[str, Any]]: """Apply the standard Riemannian chunk covariance backend. Parameters ---------- data_stream : ndarray The padded, lookahead-applied data stream. X_stats : ndarray The filtered subset of the data stream used for covariance calculations. state : ASRState Fitted calibration state containing mixing/threshold matrices. n_times : int Original number of time samples before padding. n_stream_input : int Number of input samples in the data stream. lookahead_samples : int Number of samples used for window lookahead padding. update_at : ndarray Array of sample indices at which to update reconstruction matrices. max_bad : int Maximum allowable number of rejected components per window. stepsize : int Sample step size between consecutive window matrix updates. win_len : int Length of the running processing window in samples. regularization : float Regularization applied to the symmetric positive definite covariance. store_reconstruction_matrices : bool If True, diagnostic dictionaries will contain per-window reconstruction matrices. Returns ------- X_clean : ndarray The cleaned original data block. diagnostics : dict A dictionary containing processing diagnostic metadata. """ n_channels = data_stream.shape[0] eye = np.eye(n_channels) Cw = (X_stats @ X_stats.T) / n_stream_input Cw = _regularize_spd(Cw, regularization) D, V = _riemannian_nonlinear_eigenspace(Cw, regularization) theta2 = np.sum((state.T @ V) ** 2, axis=0) keep = (theta2 > D) | (np.arange(1, n_channels + 1) < (n_channels - max_bad)) trivial = bool(np.all(keep)) n_bad = int(n_channels - np.count_nonzero(keep)) if trivial: R = eye else: basis = keep[:, np.newaxis].astype(np.float64) * (V.T @ state.M) R = state.M @ np.linalg.pinv(basis) @ V.T R = np.real_if_close(R).astype(np.float64) sample_mask = np.zeros(n_times, dtype=bool) n_reconstructed: list[int] = [] component_variances: list[np.ndarray] = [] component_thresholds: list[np.ndarray] = [] reconstruction_matrices: list[np.ndarray] = [] window_starts: list[int] = [] window_stops: list[int] = [] last_R = eye last_trivial = True last_n = 0 for n in update_at: applied = (not trivial) or (not last_trivial) if applied and n > last_n: subrange = slice(last_n, n) width = n - last_n blend = raised_cosine_ramp(width) segment = data_stream[:, subrange] data_stream[:, subrange] = (R @ segment) * blend[np.newaxis, :] + ( last_R @ segment ) * (1.0 - blend[np.newaxis, :]) start_out = max(last_n, lookahead_samples) - lookahead_samples stop_out = min(n, lookahead_samples + n_times) - lookahead_samples if stop_out > start_out: window_starts.append(int(start_out)) window_stops.append(int(stop_out)) n_reconstructed.append(n_bad) component_variances.append(D.copy()) component_thresholds.append(theta2.copy()) if applied: sample_mask[start_out:stop_out] = True if store_reconstruction_matrices: reconstruction_matrices.append(R.copy()) last_n = int(n) last_R = R last_trivial = trivial X_clean = data_stream[:, lookahead_samples : lookahead_samples + n_times].copy() n_reconstructed_arr = np.asarray(n_reconstructed, dtype=int) diagnostics = { "window_starts": np.asarray(window_starts, dtype=int), "window_stops": np.asarray(window_stops, dtype=int), "sample_mask": sample_mask, "n_components_reconstructed": n_reconstructed_arr, "component_variances": np.asarray(component_variances, dtype=np.float64), "component_thresholds": np.asarray(component_thresholds, dtype=np.float64), "n_windows": int(len(n_reconstructed_arr)), "fraction_reconstructed_windows": float( np.mean(n_reconstructed_arr > 0) if n_reconstructed_arr.size else 0.0 ), "fraction_reconstructed_samples": float(np.mean(sample_mask)), "max_components_reconstructed": int(n_reconstructed_arr.max(initial=0)), "lookahead_samples": int(lookahead_samples), "stepsize_samples": int(stepsize), "window_length_samples": int(win_len), "covariance_geometry": "riemannian", "riemannian_solver": "nonlinear_eigenspace", "riemannian_mean_iterations": np.zeros( len(n_reconstructed_arr), dtype=int, ), "riemannian_mean_converged": np.ones( len(n_reconstructed_arr), dtype=bool, ), "riemannian_mean_update_norm": np.zeros( len(n_reconstructed_arr), dtype=np.float64, ), } if store_reconstruction_matrices: diagnostics["reconstruction_matrices"] = np.asarray(reconstruction_matrices) return X_clean, diagnostics def _process_asr_windowed( data_stream: np.ndarray, X_stats: np.ndarray, state: ASRState, *, n_times: int, n_stream_input: int, lookahead_samples: int, update_at: np.ndarray, max_bad: int, stepsize: int, win_len: int, store_reconstruction_matrices: bool, use_rolling_covariance: bool, component_weight_function: Callable[ [np.ndarray, np.ndarray, np.ndarray, np.ndarray], np.ndarray ] | None = None, return_component_weights: bool = False, ) -> tuple[np.ndarray, dict[str, Any]]: """Process windowed ASR with binary or custom soft component weights. Standard ``riemannian_windowed`` ASR uses the binary keep mask when ``component_weight_function`` is None. Experimental extensions can provide a callable that returns continuous keep weights without duplicating the covariance, eigendecomposition, blending, and diagnostics loop. Parameters ---------- data_stream : ndarray The padded, lookahead-applied data stream. X_stats : ndarray The filtered subset of the data stream used for covariance calculations. state : ASRState Fitted calibration state containing mixing/threshold matrices. n_times : int Original number of time samples before padding. n_stream_input : int Number of input samples in the data stream. lookahead_samples : int Number of samples used for window lookahead padding. update_at : ndarray Array of sample indices at which to update reconstruction matrices. max_bad : int Maximum allowable number of rejected components per window. stepsize : int Sample step size between consecutive window matrix updates. win_len : int Length of the running processing window in samples. store_reconstruction_matrices : bool If True, diagnostic dictionaries will contain per-window reconstruction matrices. use_rolling_covariance : bool Whether to optimize performance by iteratively updating the window covariance. component_weight_function : callable | None Optional callable receiving ``(variances, eigenvectors, thresholds, forced_keep)`` and returning one keep weight in ``[0, 1]`` per component. ``None`` selects standard binary ASR reconstruction. return_component_weights : bool If True, include binary or soft component weights in diagnostics. Returns ------- X_clean : ndarray The cleaned original data block. diagnostics : dict A dictionary containing processing diagnostic metadata. """ n_channels = data_stream.shape[0] if use_rolling_covariance: covariance_iter = _iter_moving_covariances_at(X_stats, update_at, win_len) Xcov_flat = None else: outer = np.einsum("it,jt->ijt", X_stats, X_stats, optimize=True) Xcov_flat = outer.reshape(n_channels * n_channels, n_stream_input, order="F") Xcov_flat, _ = _moving_average_padded(win_len, Xcov_flat) covariance_iter = None sample_mask = np.zeros(n_times, dtype=bool) n_reconstructed: list[int] = [] component_variances: list[np.ndarray] = [] component_thresholds: list[np.ndarray] = [] component_weights: list[np.ndarray] = [] reconstruction_matrices: list[np.ndarray] = [] window_starts: list[int] = [] window_stops: list[int] = [] eye = np.eye(n_channels) last_R = eye last_trivial = True last_n = 0 for n in update_at: if covariance_iter is None: assert Xcov_flat is not None Cw = Xcov_flat[:, n - 1].reshape(n_channels, n_channels, order="F") else: Cw = next(covariance_iter) Cw = (Cw + Cw.T) / 2.0 # Standard SPD eigendecomposition (matches _process_asr_standard). # See the docstring for why we do NOT use _riemannian_nonlinear_eigenspace # at processing time. D, V = np.linalg.eigh(Cw) order = np.argsort(D) D = D[order] V = V[:, order] theta2 = np.sum((state.T @ V) ** 2, axis=0) forced_keep = np.arange(1, n_channels + 1) < (n_channels - max_bad) if component_weight_function is None: keep = (theta2 > D) | forced_keep weights = keep.astype(np.float64) trivial = bool(np.all(keep)) n_bad = int(n_channels - np.count_nonzero(keep)) if trivial: R = eye else: basis = keep[:, np.newaxis].astype(np.float64) * (V.T @ state.M) R = state.M @ np.linalg.pinv(basis) @ V.T R = np.real_if_close(R).astype(np.float64) else: weights = np.asarray( component_weight_function(D, V, theta2, forced_keep), dtype=np.float64, ) if weights.shape != (n_channels,): raise ValueError( "component_weight_function must return shape " f"({n_channels},), got {weights.shape}" ) weights = np.clip(weights, 0.0, 1.0) trivial = bool(np.all(weights >= 1.0 - 1e-12)) n_bad = int(np.count_nonzero(weights < 0.5)) keep = (weights >= 0.5) | forced_keep if np.all(keep): hard_reconstruction = eye else: basis = keep[:, np.newaxis].astype(np.float64) * (V.T @ state.M) hard_reconstruction = state.M @ np.linalg.pinv(basis) @ V.T R = (V * weights) @ V.T + ( (V * (1.0 - weights)) @ V.T @ hard_reconstruction ) R = np.real_if_close(R).astype(np.float64) applied = (not trivial) or (not last_trivial) if applied and n > last_n: subrange = slice(last_n, n) width = n - last_n blend = raised_cosine_ramp(width) segment = data_stream[:, subrange] data_stream[:, subrange] = (R @ segment) * blend[np.newaxis, :] + ( last_R @ segment ) * (1.0 - blend[np.newaxis, :]) start_out = max(last_n, lookahead_samples) - lookahead_samples stop_out = min(n, lookahead_samples + n_times) - lookahead_samples if stop_out > start_out: window_starts.append(int(start_out)) window_stops.append(int(stop_out)) n_reconstructed.append(n_bad) component_variances.append(D.copy()) component_thresholds.append(theta2.copy()) if component_weight_function is not None or return_component_weights: component_weights.append(weights.copy()) if applied: sample_mask[start_out:stop_out] = True if store_reconstruction_matrices: reconstruction_matrices.append(R.copy()) last_n = int(n) last_R = R last_trivial = trivial X_clean = data_stream[:, lookahead_samples : lookahead_samples + n_times].copy() n_reconstructed_arr = np.asarray(n_reconstructed, dtype=int) diagnostics = { "window_starts": np.asarray(window_starts, dtype=int), "window_stops": np.asarray(window_stops, dtype=int), "sample_mask": sample_mask, "n_components_reconstructed": n_reconstructed_arr, "component_variances": np.asarray(component_variances, dtype=np.float64), "component_thresholds": np.asarray(component_thresholds, dtype=np.float64), "n_windows": int(len(n_reconstructed_arr)), "fraction_reconstructed_windows": float( np.mean(n_reconstructed_arr > 0) if n_reconstructed_arr.size else 0.0 ), "fraction_reconstructed_samples": float(np.mean(sample_mask)), "max_components_reconstructed": int(n_reconstructed_arr.max(initial=0)), "lookahead_samples": int(lookahead_samples), "stepsize_samples": int(stepsize), "window_length_samples": int(win_len), "covariance_geometry": "riemannian_windowed", "riemannian_solver": "nonlinear_eigenspace", } if component_weight_function is not None or return_component_weights: diagnostics["component_weights"] = np.asarray( component_weights, dtype=np.float64 ) if store_reconstruction_matrices: diagnostics["reconstruction_matrices"] = np.asarray(reconstruction_matrices) return X_clean, diagnostics def _process_asr_riemannian_windowed( data_stream: np.ndarray, X_stats: np.ndarray, state: ASRState, *, n_times: int, n_stream_input: int, lookahead_samples: int, update_at: np.ndarray, max_bad: int, stepsize: int, win_len: int, store_reconstruction_matrices: bool, use_rolling_covariance: bool, ) -> tuple[np.ndarray, dict[str, Any]]: """Run standard cutoff-sensitive per-window Riemannian ASR.""" return _process_asr_windowed( data_stream, X_stats, state, n_times=n_times, n_stream_input=n_stream_input, lookahead_samples=lookahead_samples, update_at=update_at, max_bad=max_bad, stepsize=stepsize, win_len=win_len, store_reconstruction_matrices=store_reconstruction_matrices, use_rolling_covariance=use_rolling_covariance, ) def _process_adaptive_chunk( X: np.ndarray, sfreq: float, state: ASRState, process_state: dict[str, Any], window_length: float, lookahead: float | None, stepsize: int | None, max_dims: float | int, store_reconstruction_matrices: bool, adaptive_variant: str, max_mem_mb: int | float | None, ) -> tuple[np.ndarray, dict[str, Any], dict[str, Any]]: """Mirror the AASR ``reconstruct()`` wrapper around ``asr_process``. Parameters ---------- X : ndarray, shape (n_channels, n_times) The input data chunk to be reconstructed. sfreq : float The sampling frequency of the data. state : ASRState The current calibration state of the ASR algorithm. process_state : dict A dictionary containing stateful values carried over from the previous chunk (e.g., streaming carry, filter states, and the adaptive learner). window_length : float The length of the moving window in seconds. lookahead : float | None The lookahead duration in seconds. If None, defaults to half the window length. stepsize : int | None The processing step size in samples. max_dims : float | int The maximum number of dimensions to reconstruct. store_reconstruction_matrices : bool If True, returns the mixing matrices used for reconstruction. adaptive_variant : str The variant of the adaptive updating rule used (e.g., 'psw'). max_mem_mb : int | float | None The maximum memory allowed for block computations. Returns ------- X_clean : ndarray The cleaned (reconstructed) data chunk. diagnostics : dict Diagnostic information about the chunk processing. next_process_state : dict The updated process state dictionary to carry forward to the next chunk. """ X = _validate_array_2d(X) n_channels, n_times = X.shape lookahead_samples = ( _round_half_up((window_length / 2.0) * sfreq) if lookahead is None else _round_half_up(lookahead * sfreq) ) if lookahead_samples >= n_times: raise ValueError("lookahead is too long for the data length") win_len = max( _round_half_up(window_length * sfreq), _round_half_up(1.5 * n_channels) ) stepsize = 32 if stepsize is None else int(stepsize) if stepsize < 1: raise ValueError("stepsize must be at least 1 sample") sig = _append_streaming_tail(X, lookahead_samples) # Convert parametric max_dims limits into fixed rank tolerances max_bad = _resolve_max_dims_padded(max_dims, n_channels) win_len = int(_round_half_up(window_length * sfreq)) n_stream_input = sig.shape[1] if max_bad <= 0: diagnostics = _empty_process_diagnostics(n_times) diagnostics.update( _process_memory_info( n_channels=n_channels, n_stream_input=n_stream_input, max_mem_mb=max_mem_mb, memory_mode="identity", peak_cov_buffer_bytes=0, chunk_samples=0, used_memory_bound=False, ) ) return X.copy(), diagnostics, _copy_process_state(process_state) carry = process_state.get("carry") if carry is None: data_stream = np.concatenate( [ 2.0 * sig[:, [0]] - sig[:, lookahead_samples:0:-1], sig, ], axis=1, ) else: data_stream = np.concatenate([carry, sig], axis=1) data_stream = np.asarray(data_stream, dtype=np.float64) data_stream[~np.isfinite(data_stream)] = 0.0 X_stats, iir_state = _lfilter_channels( data_stream[:, lookahead_samples : lookahead_samples + n_stream_input], state.filter_b, state.filter_a, zi=process_state.get("iir"), ) update_at = np.minimum( np.arange(stepsize, n_stream_input + stepsize, stepsize, dtype=int), n_stream_input, ) if update_at.size == 0 or update_at[-1] != n_stream_input: update_at = np.append(update_at, n_stream_input) update_at = np.unique(update_at) last_R = process_state.get("last_R") last_trivial = bool(process_state.get("last_trivial", True)) if last_R is None: last_R = np.eye(n_channels, dtype=np.float64) last_trivial = True update_at = np.concatenate(([1], update_at)) estimated_cov_bytes = _covariance_stack_bytes(n_stream_input, n_channels) max_mem_bytes = _max_mem_bytes(max_mem_mb) use_chunked_covariance = ( max_mem_bytes is not None and estimated_cov_bytes > max_mem_bytes ) cov_state_in = process_state.get("cov") cov_source = None if use_chunked_covariance: chunk_samples = _covariance_chunk_blocks(n_channels, max_mem_bytes) cov_source = _ChunkedMovingCovariances( X_stats, update_at, win_len, chunk_samples=chunk_samples, zi=cov_state_in, ) covariance_iter = iter(cov_source) Xcov_flat = None cov_state = None peak_cov_buffer_bytes = _covariance_stack_bytes(chunk_samples, n_channels) else: chunk_samples = n_stream_input outer = np.einsum("it,jt->ijt", X_stats, X_stats, optimize=True) Xcov_flat = outer.reshape(n_channels * n_channels, n_stream_input, order="F") Xcov_flat, cov_state = _moving_average_padded( win_len, Xcov_flat, zi=cov_state_in, ) covariance_iter = None peak_cov_buffer_bytes = estimated_cov_bytes sample_mask = np.zeros(n_times, dtype=bool) n_reconstructed: list[int] = [] component_variances: list[np.ndarray] = [] component_thresholds: list[np.ndarray] = [] reconstruction_matrices: list[np.ndarray] = [] window_starts: list[int] = [] window_stops: list[int] = [] last_n = 0 for n in update_at: if covariance_iter is None: assert Xcov_flat is not None Cw = Xcov_flat[:, n - 1].reshape(n_channels, n_channels, order="F") else: Cw = next(covariance_iter) Cw = (Cw + Cw.T) / 2.0 D, V = np.linalg.eigh(Cw) order = np.argsort(D) D = D[order] V = V[:, order] theta2 = np.sum((state.T @ V) ** 2, axis=0) keep = (theta2 > D) | (np.arange(1, n_channels + 1) < (n_channels - max_bad)) trivial = bool(np.all(keep)) n_bad = int(n_channels - np.count_nonzero(keep)) if trivial: R = np.eye(n_channels, dtype=np.float64) else: basis = keep[:, np.newaxis].astype(np.float64) * (V.T @ state.M) R = state.M @ np.linalg.pinv(basis) @ V.T R = np.real_if_close(R).astype(np.float64) applied = (not trivial) or (not last_trivial) if applied and n > last_n: subrange = slice(last_n, n) width = n - last_n blend = raised_cosine_ramp(width) segment = data_stream[:, subrange] data_stream[:, subrange] = (R @ segment) * blend[np.newaxis, :] + ( last_R @ segment ) * (1.0 - blend[np.newaxis, :]) start_out = max(last_n, lookahead_samples) - lookahead_samples stop_out = min(n, lookahead_samples + n_times) - lookahead_samples if stop_out > start_out: window_starts.append(int(start_out)) window_stops.append(int(stop_out)) n_reconstructed.append(n_bad) component_variances.append(D.copy()) component_thresholds.append(theta2.copy()) if applied: sample_mask[start_out:stop_out] = True if store_reconstruction_matrices: reconstruction_matrices.append(R.copy()) last_n = int(n) last_R = R last_trivial = trivial carry_out = ( data_stream[:, -lookahead_samples:].copy() if lookahead_samples > 0 else None ) delayed = data_stream[:, :n_stream_input].copy() X_clean = delayed[:, lookahead_samples:] if cov_source is not None: cov_state = cov_source.cov_state n_reconstructed_arr = np.asarray(n_reconstructed, dtype=int) diagnostics = { "window_starts": np.asarray(window_starts, dtype=int), "window_stops": np.asarray(window_stops, dtype=int), "sample_mask": sample_mask, "n_components_reconstructed": n_reconstructed_arr, "component_variances": np.asarray(component_variances, dtype=np.float64), "component_thresholds": np.asarray(component_thresholds, dtype=np.float64), "n_windows": int(len(n_reconstructed_arr)), "fraction_reconstructed_windows": float( np.mean(n_reconstructed_arr > 0) if n_reconstructed_arr.size else 0.0 ), "fraction_reconstructed_samples": float(np.mean(sample_mask)), "max_components_reconstructed": int(n_reconstructed_arr.max(initial=0)), "lookahead_samples": int(lookahead_samples), "stepsize_samples": int(stepsize), "window_length_samples": int(win_len), "covariance_geometry": "standard", "adaptive_variant": adaptive_variant, } diagnostics.update( _process_memory_info( n_channels=n_channels, n_stream_input=n_stream_input, max_mem_mb=max_mem_mb, memory_mode="chunked" if use_chunked_covariance else "full", peak_cov_buffer_bytes=peak_cov_buffer_bytes, chunk_samples=chunk_samples, used_memory_bound=use_chunked_covariance, ) ) if store_reconstruction_matrices: diagnostics["reconstruction_matrices"] = np.asarray(reconstruction_matrices) next_state = { "cov": cov_state.copy() if cov_state is not None else None, "carry": carry_out, "iir": iir_state.copy() if iir_state is not None else None, "last_R": last_R.copy(), "last_trivial": bool(last_trivial), } return X_clean, diagnostics, next_state