from __future__ import annotations
from dataclasses import dataclass
import numpy as np
[docs]
@dataclass(frozen=True)
class FeatureConfig:
common_mode: str = "median"
log_compress: bool = True
include_waveform_length: bool = False
[docs]
@dataclass(frozen=True)
class SignalQuality:
channel_rms: np.ndarray
flat_channels: tuple[int, ...]
saturated_channels: tuple[int, ...]
noisy_channels: tuple[int, ...]
@property
def usable_fraction(self) -> float:
count = int(self.channel_rms.size)
bad = set(self.flat_channels) | set(self.saturated_channels) | set(self.noisy_channels)
return 0.0 if count == 0 else (count - len(bad)) / count
def _channels_by_samples(emg: np.ndarray) -> np.ndarray:
values = np.asarray(emg, dtype=np.float64)
if values.ndim != 2:
raise ValueError("EMG window must have shape (channels, samples)")
if values.shape[0] < 1 or values.shape[1] < 2:
raise ValueError("EMG window must contain at least one channel and two samples")
if not np.all(np.isfinite(values)):
raise ValueError("EMG window contains non-finite values")
return values
[docs]
def assess_signal_quality(emg: np.ndarray) -> SignalQuality:
values = _channels_by_samples(emg)
rms = np.sqrt(np.mean(values * values, axis=1) + 1e-12)
spread = np.std(values, axis=1)
peak = np.max(np.abs(values), axis=1)
median_rms = max(float(np.median(rms)), 1e-12)
flat = tuple(np.flatnonzero(spread < max(1e-9, median_rms * 1e-4)).tolist())
noisy = tuple(np.flatnonzero(rms > median_rms * 8.0).tolist())
# Repeated extrema are a device-independent clipping proxy; an ADC-specific
# threshold can be added by a stream adapter when its scale is known.
saturated = []
for channel in range(values.shape[0]):
at_peak = np.isclose(np.abs(values[channel]), peak[channel], rtol=0.0, atol=1e-12)
if peak[channel] > 0 and np.mean(at_peak) >= 0.02:
saturated.append(channel)
return SignalQuality(rms, flat, tuple(saturated), noisy)