Source code for mne_denoise.asr._windowing

"""ASR calibration-window helpers."""

from __future__ import annotations

from typing import Any

import numpy as np

from ._distribution import fit_rms_distribution
from ._validation import (
    _check_enough_samples,
    _round_half_up,
    _validate_common_params,
)


def _get_window_starts(n_times: int, win_len: int, overlap: float) -> np.ndarray:
    """Compute sample indices for the start of overlapping sliding windows."""
    if win_len < 2:
        raise ValueError("window length must be at least 2 samples")
    if win_len > n_times:
        raise ValueError(
            f"Window length ({win_len} samples) exceeds data length ({n_times} samples)"
        )
    step = max(1, int(round(win_len * (1 - overlap))))
    starts = list(range(0, n_times - win_len + 1, step))
    last = n_times - win_len
    if starts[-1] != last:
        starts.append(last)
    return np.asarray(starts, dtype=int)


def _get_fractional_window_starts(
    n_times: int,
    win_len: int,
    overlap: float,
) -> np.ndarray:
    """Return window starts using precise fractional overlap step sizes."""
    if win_len < 2:
        raise ValueError("window length must be at least 2 samples")
    if win_len > n_times:
        raise ValueError(
            f"Window length ({win_len} samples) exceeds data length ({n_times} samples)"
        )
    step = win_len * (1.0 - overlap)
    starts_1_based = np.arange(1.0, n_times - win_len + np.finfo(float).eps, step)
    starts = np.asarray(
        [_round_half_up(start) - 1 for start in starts_1_based], dtype=int
    )
    return np.unique(starts)


def _get_window_weights(win_len: int) -> np.ndarray:
    """Generate Hanning weights for smooth cross-fading."""
    if win_len <= 2:
        return np.ones(win_len, dtype=np.float64)
    return np.hanning(win_len + 2)[1:-1].astype(np.float64)


def _compute_window_rms(X: np.ndarray, starts: np.ndarray, win_len: int) -> np.ndarray:
    """Compute per-channel RMS values over a set of windows."""
    windows = starts[:, np.newaxis] + np.arange(win_len, dtype=int)[np.newaxis, :]
    squared = X[:, windows] ** 2
    return np.sqrt(np.sum(squared, axis=2) / win_len)


def _resolve_bad_channel_count(
    max_bad_channels: float | int,
    n_channels: int,
) -> int:
    """Resolve the tolerated bad-channel fraction into an absolute count."""
    if isinstance(max_bad_channels, float) and 0 < max_bad_channels < 1:
        resolved = _round_half_up(n_channels * max_bad_channels)
    else:
        resolved = int(max_bad_channels)
    if resolved < 0:
        raise ValueError("max_bad_channels must be non-negative")
    return min(resolved, n_channels)


def _select_clean_windows(
    X: np.ndarray,
    starts: np.ndarray,
    win_len: int,
    ref_max_bad_channels: float,
    ref_tolerances: tuple[float, float],
    max_dropout_fraction: float,
    min_clean_fraction: float,
) -> tuple[np.ndarray, np.ndarray]:
    """Identify clean calibration windows using Z-scored channel RMS distributions."""
    diagnostics = _compute_window_diagnostics(
        X,
        starts,
        win_len,
        max_bad_channels=ref_max_bad_channels,
        zthresholds=ref_tolerances,
        max_dropout_fraction=max_dropout_fraction,
        min_clean_fraction=min_clean_fraction,
    )
    clean = diagnostics["window_keep_mask"].copy()
    if not np.any(clean):
        zscores = diagnostics["window_rms_zscores"]
        penalty = np.mean(np.maximum(zscores - max(ref_tolerances), 0.0), axis=1)
        clean[np.argmin(penalty)] = True
    return clean, diagnostics["window_rms_zscores"]


def _compute_window_diagnostics(
    X: np.ndarray,
    starts: np.ndarray,
    win_len: int,
    max_bad_channels: float | int,
    zthresholds: tuple[float, float],
    max_dropout_fraction: float,
    min_clean_fraction: float,
    fit_quantiles: tuple[float, float] = (0.022, 0.6),
    beta_grid: np.ndarray | None = None,
) -> dict[str, Any]:
    """Compute detailed window retention diagnostics over a predefined grid."""
    n_channels = X.shape[0]
    rms = _compute_window_rms(X, starts, win_len)

    zscores = np.empty_like(rms)
    mu_values = np.empty(n_channels, dtype=np.float64)
    sigma_values = np.empty(n_channels, dtype=np.float64)
    beta_values = np.empty(n_channels, dtype=np.float64)
    fit_errors = np.empty(n_channels, dtype=np.float64)
    fit_intervals = np.empty((n_channels, 2), dtype=np.float64)
    fit_sample_counts = np.empty(n_channels, dtype=int)
    for ch_idx in range(n_channels):
        mu, sigma, info = fit_rms_distribution(
            rms[ch_idx],
            min_clean_fraction=min_clean_fraction,
            max_dropout_fraction=max_dropout_fraction,
            fit_quantiles=fit_quantiles,
            beta_grid=beta_grid,
            return_info=True,
        )
        mu_values[ch_idx] = mu
        sigma_values[ch_idx] = sigma
        beta_values[ch_idx] = info["beta"]
        fit_errors[ch_idx] = info["fit_error"]
        fit_intervals[ch_idx] = info["fit_interval"]
        fit_sample_counts[ch_idx] = info["n_fit_samples"]
        zscores[ch_idx] = (rms[ch_idx] - mu) / max(sigma, np.finfo(float).eps)

    tolerated_bad_channels = _resolve_bad_channel_count(max_bad_channels, n_channels)
    window_remove_mask = np.zeros(starts.size, dtype=bool)
    if tolerated_bad_channels < n_channels:
        swz = np.sort(zscores, axis=0)
        z_low, z_high = zthresholds
        if z_high > 0:
            window_remove_mask |= swz[-1 - tolerated_bad_channels] > z_high
        if z_low < 0:
            window_remove_mask |= swz[tolerated_bad_channels] < z_low
    window_keep_mask = ~window_remove_mask

    return {
        "window_starts": starts,
        "window_stops": starts + win_len,
        "window_rms": rms.T,
        "window_rms_zscores": zscores.T,
        "window_keep_mask": window_keep_mask,
        "window_remove_mask": window_remove_mask,
        "mu": mu_values,
        "sigma": sigma_values,
        "beta": beta_values,
        "fit_error": fit_errors,
        "fit_interval": fit_intervals,
        "n_fit_samples": fit_sample_counts,
        "n_windows": int(starts.size),
        "n_rejected_windows": int(np.sum(window_remove_mask)),
    }


def _concatenate_windows(
    X: np.ndarray,
    starts: np.ndarray,
    win_len: int,
) -> np.ndarray:
    """Concatenate a subset of windows into a continuous flat array."""
    out = np.empty((X.shape[0], len(starts) * win_len), dtype=np.float64)
    for idx, start in enumerate(starts):
        out[:, idx * win_len : (idx + 1) * win_len] = X[:, start : start + win_len]
    return out


def _create_sample_mask_from_windows(
    n_times: int,
    starts: np.ndarray,
    win_len: int,
    window_remove_mask: np.ndarray,
) -> np.ndarray:
    """Project window-level rejection decisions to a sample-level binary mask."""
    sample_mask = np.ones(n_times, dtype=bool)
    for start in starts[np.asarray(window_remove_mask, dtype=bool)]:
        sample_mask[start : start + win_len] = False
    return sample_mask


def _create_good_sample_mask_from_mne(
    raw: Any, prefixes: tuple[str, ...]
) -> np.ndarray:
    """Create a sample mask identifying data not marked by specific MNE annotations."""
    n_times = raw.n_times
    mask = np.ones(n_times, dtype=bool)
    if not hasattr(raw, "annotations") or len(raw.annotations) == 0:
        return mask
    sfreq = raw.info["sfreq"]
    first_time = (
        raw.first_time
        if hasattr(raw, "first_time")
        else getattr(raw, "_first_time", 0.0)
    )
    for onset, duration, description in zip(
        raw.annotations.onset,
        raw.annotations.duration,
        raw.annotations.description,
    ):
        desc = str(description).lower()
        if not any(desc.startswith(prefix.lower()) for prefix in prefixes):
            continue
        start = max(0, int(np.floor((onset - first_time) * sfreq)))
        stop = min(n_times, int(np.ceil((onset + duration - first_time) * sfreq)))
        mask[start:stop] = False
    return mask


def _merge_sample_spans(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
    """Merge overlapping or adjacent half-open sample spans."""
    if not spans:
        return []
    spans = sorted(spans)
    merged = [spans[0]]
    for start, stop in spans[1:]:
        last_start, last_stop = merged[-1]
        if start <= last_stop:
            merged[-1] = (last_start, max(last_stop, stop))
        else:
            merged.append((start, stop))
    return merged


def _mask_to_sample_spans(mask: np.ndarray) -> list[tuple[int, int]]:
    """Convert a 1-D boolean mask to half-open sample spans."""
    mask = np.asarray(mask, dtype=bool).ravel()
    if mask.size == 0:
        return []
    edges = np.diff(np.concatenate(([False], mask, [False])).astype(int))
    starts = np.flatnonzero(edges == 1)
    stops = np.flatnonzero(edges == -1)
    return list(zip(starts.tolist(), stops.tolist()))


[docs] def compute_clean_window_mask( X: np.ndarray, sfreq: float, *, max_bad_channels: float | int = 0.2, zthresholds: tuple[float, float] = (-3.5, 5.0), window_length: float = 1.0, window_overlap: float = 0.66, max_dropout_fraction: float = 0.1, min_clean_fraction: float = 0.25, fit_quantiles: tuple[float, float] = (0.022, 0.6), beta_grid: np.ndarray | None = None, ) -> tuple[np.ndarray, dict[str, Any]]: """Compute a retained-sample mask from ASR window statistics. Parameters ---------- X : ndarray, shape (n_channels, n_times) Continuous data. sfreq : float Sampling frequency in Hz. max_bad_channels : float or int, default=0.2 Maximum bad-channel fraction or count per window. zthresholds : tuple of float, default=(-3.5, 5.0) Lower and upper channel-RMS z-score limits. window_length : float, default=1.0 Window length in seconds. window_overlap : float, default=0.66 Window overlap fraction. max_dropout_fraction : float, default=0.1 Low-tail fraction ignored during RMS fitting. min_clean_fraction : float, default=0.25 Minimum clean fraction used for RMS fitting. fit_quantiles : tuple of float, default=(0.022, 0.6) Quantile interval for the RMS fit. beta_grid : ndarray or None, default=None Optional generalized-Gaussian shape grid. Returns ------- sample_mask : ndarray, shape (n_times,) Boolean mask of retained samples. diagnostics : dict Window-level RMS, z-score, and mask diagnostics. """ _validate_common_params( sfreq=sfreq, cutoff=1.0, window_length=window_length, window_overlap=window_overlap, max_dropout_fraction=max_dropout_fraction, min_clean_fraction=min_clean_fraction, regularization=1e-8, ) X = np.asarray(X, dtype=np.float64) if X.ndim != 2: raise ValueError( f"ASR window rejection expects a 2D array (n_channels, n_times), got {X.shape}" ) if X.shape[0] < 1: raise ValueError("ASR window rejection requires at least one channel") if not np.all(np.isfinite(X)): X = np.nan_to_num(X, copy=True) n_channels, n_times = X.shape _check_enough_samples(n_times, sfreq, window_length) window_length_samples = _round_half_up(window_length * sfreq) starts = _get_fractional_window_starts( n_times, window_length_samples, window_overlap ) diagnostics = _compute_window_diagnostics( X, starts, window_length_samples, max_bad_channels=max_bad_channels, zthresholds=zthresholds, max_dropout_fraction=max_dropout_fraction, min_clean_fraction=min_clean_fraction, fit_quantiles=fit_quantiles, beta_grid=beta_grid, ) window_remove_mask = diagnostics["window_remove_mask"] sample_mask = np.ones(n_times, dtype=bool) for start in starts[window_remove_mask]: sample_mask[start : start + window_length_samples] = False diagnostics = dict(diagnostics) diagnostics["sample_mask"] = sample_mask diagnostics["fraction_retained_samples"] = float(np.mean(sample_mask)) diagnostics["fraction_rejected_samples"] = float(1.0 - np.mean(sample_mask)) return sample_mask, diagnostics
def _extract_clean_calibration_samples( X: np.ndarray, sfreq: float, window_length: float, window_overlap: float, max_bad_channels: float, zthresholds: tuple[float, float], max_dropout_fraction: float, min_clean_fraction: float, beta_grid: np.ndarray | None = None, ) -> tuple[np.ndarray, np.ndarray, dict[str, Any]]: """Extract a subset of continuous clean calibration data using robust diagnostics.""" win_len = _round_half_up(window_length * sfreq) starts = _get_fractional_window_starts(X.shape[1], win_len, window_overlap) diagnostics = _compute_window_diagnostics( X, starts, win_len, max_bad_channels=max_bad_channels, zthresholds=zthresholds, max_dropout_fraction=max_dropout_fraction, min_clean_fraction=min_clean_fraction, fit_quantiles=(0.022, 0.6), beta_grid=beta_grid, ) if not np.any(diagnostics["window_keep_mask"]): scores = diagnostics["window_rms_zscores"] penalty = np.mean(np.maximum(scores - max(zthresholds), 0.0), axis=1) diagnostics["window_keep_mask"][int(np.argmin(penalty))] = True diagnostics["window_remove_mask"] = ~diagnostics["window_keep_mask"] sample_mask = _create_sample_mask_from_windows( X.shape[1], starts, win_len, diagnostics["window_remove_mask"], ) return X[:, sample_mask], sample_mask, diagnostics