Wave Detector#

Wave detection#

WaveDetector finds waves (a negative half-wave followed by a positive half-wave) inside a chosen frequency band and reports morphological features of those waves: rate, amplitude, peak-to-peak, duration and slope.

It is a general half-wave detector: with fband=(0.5, 4) it detects delta waves, with (0.5, 0.9) slow oscillations, and any other band works too. The interface follows brainmaze_eeg.features.time_domain_features.TimeDomainFeatureExtractor (and, for the (values, names) return, brainmaze_eeg.features.feature_extraction.SleepSpectralFeatureExtractor): configure it once with fs and segm_size, call it on a 1-D signal or an (n_channels, n_samples) array, and concatenate the wave features with spectral / time-domain features for the same windows.

Two ways to use it#

Windowed feature extraction (__call__), e.g. a 30-minute, 32-channel segment:

from brainmaze_eeg.features.wave_detector import WaveDetector

det = WaveDetector(fs=500, fband=(0.5, 4.0), segm_size=30, datarate=True,
                   features_on='both')
values, names = det(x)        # x: (32, 900000) -> each value (32, 60)
# names: DATA_RATE, WAVE_RATE, WAVE_PK2PK_MEAN, ..., WAVE_SLOPE_MEDIAN,
#        WAVE_PK2PK_MEAN_BAND, ..., ANALYSABLE_RATE

Raw detections, for plotting or custom analysis (detect):

det = WaveDetector(fs=500, fband=(0.5, 4.0))
w = det.detect(x[0], return_signals=True)          # dict (1-D) or list of dicts (2-D)
plt.plot(w['x_amp']); plt.plot(w['x_band'])
plt.plot(w['min_pos'], w['min_val'], 'v')               # unfiltered trough values
plt.plot(w['min_pos_band'], w['min_val_band'], 'v')     # filtered trough values

Consecutive segments (e.g. a night cut into 30-min blocks) can pass a few seconds of the neighbouring blocks as context=(n_before, n_after): it is used for filtering only, so no wave is lost to the edge margin at the block borders and none is counted twice.

Algorithm#

For every signal, independently and on the whole signal at once (window borders never cut a wave):

  1. Gaps. Non-finite samples (NaN, +/-inf) in x or measure_on are gaps. With nan_policy='fill' (default) they are filled by brainmaze_utils.gaps.fill_gaps() with method='spectral' and max_interp_s=0.1 (both passed explicitly): gaps up to 0.1 s are interpolated linearly, longer gaps get noise whose spectrum matches the neighbouring data, with edges conditioned on the neighbouring samples. This only lets the filters run; the fill is never measured: step 6 discards every wave near a gap. With nan_policy='raise' any gap raises ValueError. Clean signals skip this step entirely.

  2. Filtering. The mean is removed, then zero-phase filters produce two signals:

    • the filtered (detection) signal: band-pass to fband;

    • the unfiltered (broadband amplitude) signal: drift removal only (high-pass at 0.5 * fband[0]), or measure_on when given (mean-subtracted, used as-is), or, with trough='paper', the paper’s trace (see Trough placement).

    filter='butter' (default) uses a Butterworth band-pass of order filter_order (default 2) in second-order sections applied forward-backward (scipy.signal.sosfiltfilt): zero phase, magnitude |H(f)|^2 – gain 1 in the band centre, 0.5 (-6 dB) at the band edges, and a skirt of about -24 dB/octave outside the band for order 2 (12 dB/octave per pass, two passes). The drift high-pass is a 4th-order Butterworth at 0.5 * fband[0], forward-backward: gain 0.996 at fband[0], 0.004 at fband[0] / 4. The measured response matches this design to within 0.003 for fs from 200 Hz to 25 kHz (see test_wave_detector.py). filter='fft' uses the ideal brick-wall FFT mask of v1.0.0 (its drift removal is a brick-wall high-pass at fband[0]). The brick-wall filter has a sinc impulse response that rings for many seconds around any transient and invents waves there (one isolated 50 uV, 1 Hz cycle in 30 s of silence: 14 detections in v1.0.0, 5 with 'fft' here, 1 with 'butter'), and it is 10-15x slower on long signals, so it is no longer the default.

  3. Half-waves. The filtered signal (with trough='paper': the paper’s trace) is split at its zero crossings into negative and positive half-waves. A wave is a negative half-wave immediately followed by a positive one. Both half-waves must be bounded by real zero crossings: the truncated half-waves at the start and end of the signal are never paired.

  4. Positions. The trough / peak are placed according to trough (see Trough placement).

  5. Duration gate. Keep waves whose duration lies within half a period of the band edges, up to a relative tolerance of 1 %:

    0.99 / (2 * fband[1])  <=  duration  <=  1.01 / (2 * fband[0])
    

    The duration is trough -> peak ('refine': of the refined positions, as in v1.0.0; 'band': of the band positions), with both extremes located to a fraction of a sample by a parabola through the three samples around them; with 'paper' it is the negative half-wave between its two interpolated zero crossings (the paper’s “zero-crossings separated by 1.1-2 s” for 0.5-0.9 Hz). This gate (more than the filter) defines the effective band of the detected waves, and it is the same at every fs: a sine at exactly fband[1] is detected, one 3 % above it is not, at 128 Hz as at 5 kHz (v1.0.0 lost about a third of the fband[1] sines; the previous one-sample tolerance let more out-of-band waves in at low fs).

  6. Edges and gaps. A wave’s span runs from its interpolated down-going zero crossing (zero_pos_frac) to the end of its positive half-wave (end_pos), widened to include the trough and peak if they lie outside (possible with 'refine'). A wave is discarded when its span starts within edge_margin_s of the first sample or ends within edge_margin_s of the last sample, or overlaps a gap widened by its gap margin on each side. See Edges and gaps.

  7. Morphology. Amplitudes, durations and slopes are read at the wave’s positions on both signals (outputs below).

Trough placement (trough)#

'refine' (default) – the v1.0.0 placement

The trough / peak are the extremes of the drift-removed x within +/- half a period of fband[1] around the band-pass extremes. As in v1.0.0 this window is not clamped to the wave’s half-wave, the duration gate uses these refined positions, the broadband values are read from measure_on (when given) at these positions, and downslope runs from the first negative sample of the band signal (zero_pos), not the interpolated crossing. A trough that lands at or before that sample gives downslope = NaN, which the slope features ignore. This keeps existing results: on the demo recording (6.8 h Fz-Cz, 500 Hz, NREM epochs, measure_on 0.5-35 Hz, amplitude_threshold=5), the mean downslope is SO 186.8 / delta 250.1 uV/s with filter='fft' (v1.0.0: 186.0 / 250.2; the rest of the difference is the truncated edge half-waves no longer being paired and the fs-independent gate tolerance) and 173.9 / 239.3 with the default Butterworth filter (the ringing fix, -6.5 % / -4.4 %). The search runs on x even when measure_on is given (as in v1.0.0); use 'paper' to measure the negative peak of the measured trace itself. Why these v1.0.0 details matter: clamping the window to the half-wave, or using the interpolated crossing, lets broadband troughs sit a fraction of a sample after the crossing, and -min_val / down_dur then explodes (demo SO mean 316, with measure_on as the search signal 910). In v1.0.0 these cases gave NaN.

'band' – band-limited placement (opt-in)

The trough / peak are those of the band-passed signal; the broadband values are read there. Unbiased under noise (100 uV pk2pk 1 Hz sine + white noise SD 10 uV: pk2pk 101 vs 139 with 'refine') and no heavy slope tail, but it reads the broadband trace at the band trough, which is neither the broadband negative peak nor the band slope, and sharp non-sinusoidal extremes are underestimated. Demo: SO 51.8 / delta 155.0 uV/s (mean), rate 0.309 / 1.336 per s.

'paper' – Carvalho et al. 2024, Methods (opt-in)

The detector builds the study’s trace from x: a 0.5-35 Hz zero-phase FIR (Hamming window, 4 s; 1999 taps at the study’s 500 Hz, applied once with its delay removed, DC gain set to exactly 0), then a centred 50 ms moving average (25 samples at 500 Hz). Zero crossings, half-waves, the duration gate (step 5) and the trough – “the negative peak after each zero-crossing” – are all taken on that trace, and the slope is “the amplitude difference (zero-crossing to negative peak) divided by their interval” on the same trace. fband only sets the gate; measure_on must be None and features_on must be 'broadband'. The _band outputs are the band-passed x read at the same positions. Default edge_margin_s and the gap-margin cap are the support of the two filters (2.02 s at 500 Hz): beyond it the fill or the signal end has exactly no effect. Interpretation. The Methods text says “zero phase shift” but not how it was achieved. This mode reads it as a single pass of the FIR with its group delay removed (magnitude |H|), not as forward-backward filtfilt (|H|^2); the DC-gain nulling is this module’s own addition (it moves the demo trace by up to 11 uV and the SO mean slope by +1 %), and the duration gate uses the interpolated crossings (step 5). With this reading the mode is wave for wave identical to an independent loop implementation (demo: 2088 / 2088 SO and 27817 / 27817 delta waves, slopes within 1e-9). Sensitivity: the 4 s Hamming FIR’s transition band lies on the SO band (|H| 0.50 / 0.87 at 0.5 / 0.7 Hz single pass, 0.25 / 0.75 with filtfilt), so the filtfilt reading finds 0.76 instead of 1.29 SO waves per NREM epoch on the demo, with median SO slope 95.3 instead of 104.2 uV/s (mean of epoch means about the same, 172.9 vs 173.1; that reading as literally written uses an integer-sample gate, with the interpolated gate 0.84 waves and 96.7 uV/s); delta is unaffected (195.1-195.5, about 40 waves per epoch). Demo: SO 173.1 / delta 195.3 uV/s (mean of epoch means), median wave 104.2 / 152.0; only 1.3 SO waves per NREM epoch (half-waves of 0.55-1 s are rare on a 0.5-35 Hz trace). The paper reports 95.1 +/- 28.9 (SO) and 130.8 +/- 34.8 (delta) across subjects; this is one subject, and the original SlowWaveDetect source is not available, so this mode reproduces the published method, not verified numbers.

Edges and gaps#

Both margins exist because a zero-phase filter needs time to forget a signal end or a gap fill. They were measured on 1/f EEG with slow oscillations and delta bursts (fs 250 and 1000 Hz; bands 0.5-4, 0.5-0.9 and 1-3.9 Hz; 'refine' and 'band'; probes listed in PR #68) by comparing every wave with the same wave in the uncut, gap-free recording, against the distance between the wave’s span and the cut or gap, in periods of fband[0]: value error = broadband trough or peak value changed by more than 1 uV; lost / gained = no wave with a zero crossing within 20 ms; switched = trough or peak moved by more than 2 samples (1 nV of white noise alone switches 0.1-1 %, between near-equal extremes).

  • edge_margin_s (default 4 / fband[0], 8 s for a 0.5 Hz edge; 'paper': the trace’s filter support). At 3-3.5 periods from a signal end 1.5 % of waves still had a value error (0.5-4 Hz), none beyond 3.5 periods. Use context for consecutive segments so this costs nothing at the segment borders.

  • gap margin (default: depends on the gap length L):

    margin = 0                                        if L <= 25 ms
    margin = min(4, 4 * (L * fband[0]) ** 0.25) / fband[0]   otherwise
    

    gap length L

    <= 25 ms

    50 ms

    0.1 s

    0.5 s

    1 s

    >= 2 s

    margin at 0.5 Hz (s)

    0

    3.2

    3.8

    5.7

    6.7

    8.0

    With this rule (spectral fill of brainmaze-utils >= 3.0.0, utils#26 round 3) and the default trough='refine', among the waves within 6 periods of gaps of 4 ms to 5 s, at most 0.8 % had a value error (0.5-0.9 Hz band; 0 % in the others), 0.1 % were lost or gained and 0.5 % switched; the same at 1 kHz (an independent re-check with another generator found 0.00 % value errors at 250 Hz and 1 kHz, <= 0.22 % lost). Gaps up to 20 ms changed nothing even with no margin (a 1-sample dropout costs only the wave it falls in). Without margins long gaps gave up to 18 % value errors; the previous fixed 3 / fband[0] up to 1.1 %. trough='band' is more sensitive to the fill: it reads the broadband value at the exact band-pass argmin, which on a flat 0.5-0.9 Hz band trace can move by a sample, so 0.1-1.8 % (250 Hz) and 0.9-5.9 % (1 kHz) of its SO waves had a value error beyond the margin; use a fixed, larger gap_margin_s if that matters. The step from 0 to about 3 periods at 25 ms is conservative: with no margin at all, 30-100 ms gaps gave 0-0.3 % ('refine', 0.5-0.9 Hz; up to 0.9 % for 1-3.9 Hz) and 1.4-4.9 % ('band') affected waves, so data with frequent short dropouts loses more time than strictly necessary; set gap_margin_s explicitly to trade that off. With 'paper' the margin is capped at the trace’s filter support. A fixed gap_margin_s (seconds, all gaps) overrides the rule.

WAVE_RATE counts waves per analysable second: the time outside the margins and gaps, with every clean run shortened by the mean zero-crossing -> trough time at its start and the mean trough -> span-end time at its end – the same rule that decides whether a wave is kept. So the rate does not depend on how many gaps there are (1 Hz sine with 20 ms dropouts every 10 s or 50 ms every 30 s: pooled rate 1.000 within 1 %; before this, -6 to -11 %). ANALYSABLE_RATE (with datarate=True) is that time as a fraction of the window: gate WAVE_RATE on it, not on DATA_RATE. The shrink uses mean durations, which no longer represent the few short runs left when gaps are dense: with ANALYSABLE_RATE below about 5 % WAVE_RATE is unreliable and biased low (30 ms dropouts every 5 s, ANALYSABLE_RATE 0.6-1.3 %: 0.82-0.83 of the gap-free rate; 0.976-1.028 whenever ANALYSABLE_RATE >= 5 %). A window with no analysable time (ANALYSABLE_RATE == 0, WAVE_RATE NaN) reports NaN shape features too, even if a kept wave’s trough lies in it.

Two signals: filtered and unfiltered#

Every wave is measured twice, on the same set of detected waves:

  • unfiltered (broadband; keys without suffix, features WAVE_*): values of the drift-removed input (or of measure_on, or the paper trace) – what the EEG really looked like, including faster activity riding on the wave;

  • filtered (band-passed; keys suffixed _band, features suffixed _BAND): values of the band-passed detection signal at its own extremes – the band-limited component only. Note the filter gain: |H(f)|^2 is 0.5 at the band edges, so the filtered amplitude of a wave near a band edge is attenuated (a pure sine at fband[0] or fband[1] comes out at half amplitude).

WaveDetector(features_on=...) selects which set the windowed features use; detect() always returns both.

Outputs (per wave, from detect / detect_waves())#

zero_pos

First negative sample after the down-going zero crossing (int sample index).

zero_pos_frac

The same crossing linearly interpolated between the two samples that bracket it (fractional sample index).

end_pos

Last sample of the positive half-wave (int). [zero_pos_frac, end_pos] is the wave’s span used for edge / gap exclusion.

min_pos, max_pos

Trough / peak sample index (int).

min_val, max_val

Signal value at the trough / peak (input units, e.g. uV).

pk2pk

max_val - min_val (input units).

delta_t

(max_pos - min_pos) / fs (s).

down_dur

(min_pos - zero) / fs (s), where zero is zero_pos for the broadband outputs of trough='refine' (v1.0.0) and zero_pos_frac otherwise. Can be <= 0 with 'refine'.

upslope

pk2pk / delta_t (input units / s).

downslope

-min_val / down_dur (input units / s); NaN when down_dur <= 0. Positive for a trough below zero (the normal case). It can be negative if the unfiltered value at the trough is above zero; amplitude_threshold removes such waves.

*_band

The nine keys min_pos … downslope again, measured on the filtered signal (always with the interpolated crossing).

gaps

(n_gaps, 2) [start, stop) sample indices of the non-finite runs.

x_band, x_amp

Only with return_signals=True: the filtered and unfiltered signals (NaN in gaps).

Windowed features (__call__)#

See WaveDetector. In order: DATA_RATE (only with datarate=True), WAVE_RATE, the per-window means of pk2pk, the selected slope, delta_t, min_val and max_val (the v1.0.0 names, at their v1.0.0 positions), then WAVE_SLOPE_MEDIAN (median of the selected slope: robust to the few very steep waves that dominate the mean), the same for _BAND when requested, and last ANALYSABLE_RATE (only with datarate=True).

DATA_RATE here is the fraction of samples that are finite in x and in measure_on; TimeDomainFeatureExtractor’s DATA_RATE counts only NaN in its own input. They agree for NaN-only gaps without measure_on; with +/-inf samples or gaps in measure_on this one is lower.

Slope conventions#

slope='downslope' (default)

Zero-crossing -> negative-trough rate, -min_val / (t_trough - t_zero_cross), the slow-wave slope of Carvalho et al. 2024. The study detected on the narrow band, measured on a broadband 0.5-35 Hz trace and kept waves with a negative peak of at least 5 uV (amplitude_threshold=5); trough='paper' implements its Methods text, the default reproduces this package’s v1.0.0 numbers. See demo/eeg_wave_detection/example_one_file.py.

slope='upslope'

Trough -> peak rate, (max_val - min_val) / (t_peak - t_trough).

For A sin(2 pi f t) both equal 4 A f.

Performance#

Everything is vectorised (no Python loop over samples, waves or windows); time and memory are O(n) per signal, dominated by the sosfiltfilt passes. 2-D input is converted to float64 one channel at a time; the working set is about five float64 copies of one channel (~1.8 GB for a 30-min channel at 25 kHz), times n_processes. CPU time per 30-minute channel (single thread, fband=(0.5, 4), segm_size=30, synthetic 1/f EEG with delta bursts, on a loaded 12-core workstation): 0.054 s at 250 Hz, 0.20 s at 1 kHz (0.32 s with 20 gaps), 1.0 s at 5 kHz, 5.3 s at 25 kHz; trough='paper' adds 10-45 % (its FIR). v1.0.0 needed 0.40 / 1.56 / 8.7 / 48.4 s (7.5-9x slower) and returned WAVE_RATE = 0 for any input with a gap.

Changes from v1.0.0 (brainmaze-eeg 2.0.0; class version 2.0.0 -> 2.1.0)#

Same default trough placement as v1.0.0 (trough='refine'). Butterworth instead of brick-wall FFT filter (demo SO / delta downslope 186.0 / 250.2 -> 173.9 / 239.3 uV/s); NaN gaps no longer zero the whole signal; truncated edge half-waves are no longer paired; waves near the signal edges and gaps are excluded and WAVE_RATE is normalised by analysable time; fs-independent duration gate; trough='band' and trough='paper'; WAVE_SLOPE_MEDIAN, ANALYSABLE_RATE, *_band outputs and features_on; return_signals; context; numpy scalar fs accepted.

References

Carvalho D.Z. et al. (2024), Non-rapid eye movement sleep slow-wave activity features are associated with amyloid accumulation in older adults with obstructive sleep apnoea, Brain Communications 6(5): fcae354. https://doi.org/10.1093/braincomms/fcae354. Methods, slow-wave detection: “bandpass finite impulse response filter with zero phase shift … in the 0.5-35 Hz band” (length 2000, Hamming window), then a “50-ms moving average filter”; “negative peaks (troughs) of SWs after each zero-crossing”; “zero-crossings were separated by 1.1-2 s for SO and 0.25-1.0 s for delta waves”; “SW amplitude threshold set at -5 uV”; slope = “the amplitude difference (zero-crossing to negative peak in uV) by their interval”.

Lineage: this detector is the successor of the SlowWaveDetect routine used in the study above, generalised to an arbitrary band. The original SlowWaveDetect source is not part of this repository, so numerical identity with the published values cannot be verified here.

class brainmaze_eeg.features.wave_detector.WaveDetector(fs, fband=(0.5, 4.0), segm_size=None, overlap=0.0, slope='downslope', amplitude_threshold=None, datarate=False, n_processes=1, cutoff_low=None, cutoff_high=None, filter='butter', filter_order=2, nan_policy='fill', gap_margin_s=None, edge_margin_s=None, trough='refine', features_on='broadband')#

Band-limited wave detector and windowed feature extractor.

Same calling convention as brainmaze_eeg.features.time_domain_features.TimeDomainFeatureExtractor: configure once with fs and segm_size, then call it on a 1-D signal or an (n_channels, n_samples) array (e.g. a 30-minute multichannel segment) and get (values, names) back, one value per feature window. See the module docstring for the algorithm and the exact definition of every output.

Parameters:
  • fs (float) – Sampling frequency (Hz). Python or numpy real scalar.

  • fband ((float, float)) – Single (low, high) band in Hz, 0 < low < high < fs/2. One detector detects in one band; run several detectors for several bands. Default (0.5, 4.0) (delta).

  • segm_size (float, optional) – Feature window length in seconds. None (default) treats the whole signal as one window. Only complete windows are returned (a trailing partial window is dropped, like brainmaze_utils.signal.buffer() and TimeDomainFeatureExtractor); a signal shorter than one window gives zero windows.

  • overlap (float) – Feature window overlap in seconds. Default 0.0.

  • slope ({'downslope', 'upslope'}) – Which slope WAVE_SLOPE_MEAN / WAVE_SLOPE_MEDIAN report (see module docstring). Default 'downslope'.

  • amplitude_threshold (float, optional) – Keep only waves whose broadband negative trough is at least this deep (-min_val >= amplitude_threshold, input units). None (default) keeps all waves. Carvalho et al. use 5 (uV). The same set of waves feeds the broadband and the band features.

  • features_on ({'broadband', 'band', 'both'}) – Which signal the windowed shape features are measured on (module docstring, Two signals). 'broadband' (default): WAVE_* names as in v1.0.0. 'band': the band-passed signal, names suffixed _BAND. 'both': both sets, broadband first. DATA_RATE and WAVE_RATE appear once. Must be 'broadband' with trough='paper'.

  • datarate (bool) – If True, add DATA_RATE (first; fraction of finite samples per window) and ANALYSABLE_RATE (last; fraction of the window that WAVE_RATE is computed on; WAVE_RATE is unreliable below about 5 %, and windows with 0 report NaN for every wave feature).

  • n_processes (int) – Parallelise detection across signals for 2-D / list input. Default 1.

  • filter ({'butter', 'fft'}) – Band-pass implementation. 'butter' (default): zero-phase Butterworth of order filter_order. 'fft': ideal brick-wall FFT mask (the v1.0.0 filter; rings around transients and invents waves – see module docstring).

  • filter_order (int) – Butterworth order. Default 2.

  • nan_policy ({'fill', 'raise'}) – Handling of non-finite samples. 'fill' (default): fill with brainmaze_utils.gaps.fill_gaps() (method='spectral'), discard waves overlapping a gap (plus its margin), and normalise WAVE_RATE by the analysable time of each window. 'raise': raise ValueError.

  • gap_margin_s (float or None) – Exclusion margins in seconds around gaps / at the two ends of each signal. None (default): gap-length-dependent / 4 / fband[0]. See detect_waves() and the module docstring, Edges and gaps.

  • edge_margin_s (float or None) – Exclusion margins in seconds around gaps / at the two ends of each signal. None (default): gap-length-dependent / 4 / fband[0]. See detect_waves() and the module docstring, Edges and gaps.

  • trough ({'refine', 'band', 'paper'}) – Where the broadband trough / peak are placed (module docstring, Trough placement). Default 'refine' (the v1.0.0 placement).

Notes

Windowed features (__call__):

  • detection runs once on the whole signal (so window borders do not cut waves and do not add filter transients); a wave then belongs to the window containing its trough (min_pos);

  • WAVE_RATE = number of waves / analysable seconds in the window (Hz). Analysable: finite, not within the gap margin of a gap or edge_margin_s of either end, and – because a wave is only kept when its whole span is clean – every clean run is shortened by the mean zero-crossing->trough time at its start and the mean trough->span-end time at its end (means over the signal’s counted waves). So the expected rate does not depend on how many gaps there are;

  • a window with no analysable time gives WAVE_RATE = NaN; a window with analysable time but no wave gives WAVE_RATE = 0; shape features are NaN when there is no wave;

  • WAVE_*_MEAN are means, WAVE_SLOPE_MEDIAN the median, over the waves in the window, ignoring non-finite per-wave values (e.g. a NaN downslope).

property cutoff_high#
property cutoff_low#
detect(x, measure_on=None, return_signals=False, context=None)#

Raw per-signal detections.

Parameters:
  • x (np.ndarray or list) – 1-D (n_samples,), 2-D (n_signals, n_samples), or list of 1-D arrays.

  • measure_on (np.ndarray or list, optional) – Broadband amplitude signal(s), same shape as x.

  • return_signals (bool) – Also return the band-passed ('x_band') and broadband amplitude ('x_amp') signals per input signal (NaN in gaps). Default False.

  • context ((int, int), optional) – (n_before, n_after): the first n_before and last n_after samples of every signal are context (e.g. the end of the previous and the start of the next 30-min segment), used for filtering only. Only waves whose trough lies in the core [n_before, n - n_after) are returned, with all positions (and gaps, x_band, x_amp) relative to the core start; zero_pos / end_pos of a wave that straddles the core border can be negative / beyond the core. Consecutive segments then count every wave exactly once and lose nothing to the edge margin. Default: no context.

Returns:

A single detection dict for 1-D input, otherwise one dict per signal (keys: see detect_waves(); broadband keys unsuffixed, band-passed keys suffixed _band). Signals are detected independently – positions index into that signal.

Return type:

dict or list of dict

edge_margin_s#

resolved edge margin in seconds (None at construction -> the default)

property feature_names#

Names returned by __call__, in order (v1.0.0 names keep their positions).

gap_margin_s#

fixed gap margin in seconds, or None for the gap-length-dependent default

brainmaze_eeg.features.wave_detector.detect_waves(x, fs, fband=(0.5, 4.0), measure_on=None, filter='butter', filter_order=2, nan_policy='fill', gap_margin_s=None, edge_margin_s=None, trough='refine', return_signals=False)#

Detect waves in a single 1-D signal and return their positions and morphology.

Parameters:
  • x (np.ndarray) – 1-D signal (any amplitude unit; outputs use the same unit, e.g. uV). Any real dtype; it is converted to float64 here (one signal at a time).

  • fs (float) – Sampling frequency (Hz). Python or numpy real scalar.

  • fband ((float, float)) – (low, high) band in Hz, 0 < low < high < fs/2.

  • measure_on (np.ndarray, optional) – Signal the broadband amplitudes/slopes are read from (same length as x), e.g. a 0.5-35 Hz trace of x. Mean-subtracted, otherwise used as-is. Detection (zero crossings, half-waves) always runs on x; with trough='refine' the broadband trough / peak are still searched on the drift-removed x (as in v1.0.0) and only their values are read from measure_on; with trough='band' the values are read at the band-pass extremes. Defaults to the drift-removed x. The *_band outputs always come from the band-passed x. Not allowed with trough='paper' (which builds its own trace).

  • filter ({'butter', 'fft'}) – Band-pass implementation (module docstring, step 2). Default 'butter'.

  • filter_order (int) – Butterworth order (ignored for 'fft'). The filter is applied forward-backward, so the magnitude response is squared. Default 2.

  • nan_policy ({'fill', 'raise'}) – Non-finite samples in x or measure_on: 'fill' (default) fills them for filtering and discards every wave that overlaps them (plus the gap margin); 'raise' raises ValueError.

  • gap_margin_s (float or None) – Seconds added on both sides of every gap; a wave whose span overlaps a widened gap is discarded. None (default): depends on the gap length (0 up to 25 ms, rising to 4 / fband[0] for gaps of 2 / fband[0] and longer; module docstring, Edges and gaps).

  • edge_margin_s (float or None) – A wave whose span starts within edge_margin_s of the first sample or ends within it of the last sample is discarded (the zero-phase filters’ edge transient). None (default): 4 / fband[0] (with trough='paper': the support of the paper trace’s filters, ~2 s). 0 keeps every complete wave.

  • trough ({'refine', 'band', 'paper'}) – Where the broadband (unsuffixed) trough / peak are placed (module docstring, Trough placement). 'refine' (default, v1.0.0 placement): the extreme of the drift-removed x within half a period of fband[1] around the band-pass extreme; the window is not clamped to the wave’s half-wave, so a trough at or before zero_pos gives downslope = NaN. 'band': the band-pass extreme. 'paper': Carvalho et al. 2024 – zero crossings, half-waves and the negative peak all on a 0.5-35 Hz FIR + 50 ms moving-average trace of x.

  • return_signals (bool) – Also return the band-passed signal ('x_band') and the broadband amplitude signal ('x_amp'; with trough='paper', the paper trace), both float arrays of the input length. Gaps are NaN in both (the fill is never returned). Default False.

Returns:

Per-wave arrays (see Outputs in the module docstring): zero_pos, zero_pos_frac, end_pos and, for the broadband signal, min_pos, min_val, max_pos, max_val, pk2pk, delta_t, upslope, down_dur, downslope, and the same nine keys with suffix _band for the band-passed signal. Plus gaps: (n_gaps, 2) [start, stop) sample indices of the non-finite runs (union over x and measure_on). Waves are ordered by time.

Return type:

dict