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):
Gaps. Non-finite samples (NaN, +/-inf) in
xormeasure_onare gaps. Withnan_policy='fill'(default) they are filled bybrainmaze_utils.gaps.fill_gaps()withmethod='spectral'andmax_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. Withnan_policy='raise'any gap raisesValueError. Clean signals skip this step entirely.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]), ormeasure_onwhen given (mean-subtracted, used as-is), or, withtrough='paper', the paper’s trace (see Trough placement).
filter='butter'(default) uses a Butterworth band-pass of orderfilter_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 at0.5 * fband[0], forward-backward: gain 0.996 atfband[0], 0.004 atfband[0] / 4. The measured response matches this design to within 0.003 for fs from 200 Hz to 25 kHz (seetest_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 atfband[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.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.Positions. The trough / peak are placed according to
trough(see Trough placement).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 everyfs: a sine at exactlyfband[1]is detected, one 3 % above it is not, at 128 Hz as at 5 kHz (v1.0.0 lost about a third of thefband[1]sines; the previous one-sample tolerance let more out-of-band waves in at lowfs).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 withinedge_margin_sof the first sample or ends withinedge_margin_sof the last sample, or overlaps a gap widened by its gap margin on each side. See Edges and gaps.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 placementThe trough / peak are the extremes of the drift-removed
xwithin +/- half a period offband[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 frommeasure_on(when given) at these positions, anddownsloperuns from the first negative sample of the band signal (zero_pos), not the interpolated crossing. A trough that lands at or before that sample givesdownslope = NaN, which the slope features ignore. This keeps existing results: on the demo recording (6.8 h Fz-Cz, 500 Hz, NREM epochs,measure_on0.5-35 Hz,amplitude_threshold=5), the mean downslope is SO 186.8 / delta 250.1 uV/s withfilter='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 onxeven whenmeasure_onis 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_durthen explodes (demo SO mean 316, withmeasure_onas 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.fbandonly sets the gate;measure_onmust be None andfeatures_onmust be'broadband'. The_bandoutputs are the band-passedxread at the same positions. Defaultedge_margin_sand 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-backwardfiltfilt(|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 withfiltfilt), so thefiltfiltreading 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 originalSlowWaveDetectsource 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(default4 / 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. Usecontextfor 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 fixed3 / 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, largergap_margin_sif 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; setgap_margin_sexplicitly to trade that off. With'paper'the margin is capped at the trace’s filter support. A fixedgap_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 ofmeasure_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)|^2is 0.5 at the band edges, so the filtered amplitude of a wave near a band edge is attenuated (a pure sine atfband[0]orfband[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_posFirst negative sample after the down-going zero crossing (int sample index).
zero_pos_fracThe same crossing linearly interpolated between the two samples that bracket it (fractional sample index).
end_posLast 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_posTrough / peak sample index (int).
min_val,max_valSignal value at the trough / peak (input units, e.g. uV).
pk2pkmax_val - min_val(input units).delta_t(max_pos - min_pos) / fs(s).down_dur(min_pos - zero) / fs(s), wherezeroiszero_posfor the broadband outputs oftrough='refine'(v1.0.0) andzero_pos_fracotherwise. Can be <= 0 with'refine'.upslopepk2pk / delta_t(input units / s).downslope-min_val / down_dur(input units / s); NaN whendown_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_thresholdremoves such waves.*_bandThe nine keys
min_pos…downslopeagain, 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_ampOnly 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. Seedemo/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 withfsandsegm_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, likebrainmaze_utils.signal.buffer()andTimeDomainFeatureExtractor); 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_MEDIANreport (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. use5(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_RATEandWAVE_RATEappear once. Must be'broadband'withtrough='paper'.datarate (bool) – If True, add
DATA_RATE(first; fraction of finite samples per window) andANALYSABLE_RATE(last; fraction of the window thatWAVE_RATEis computed on;WAVE_RATEis 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 orderfilter_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 withbrainmaze_utils.gaps.fill_gaps()(method='spectral'), discard waves overlapping a gap (plus its margin), and normaliseWAVE_RATEby the analysable time of each window.'raise': raiseValueError.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]. Seedetect_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]. Seedetect_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 oredge_margin_sof 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 givesWAVE_RATE = 0; shape features are NaN when there is no wave;WAVE_*_MEANare means,WAVE_SLOPE_MEDIANthe 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 firstn_beforeand lastn_aftersamples 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 (andgaps,x_band,x_amp) relative to the core start;zero_pos/end_posof 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 (
Noneat 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
Nonefor 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 ofx. Mean-subtracted, otherwise used as-is. Detection (zero crossings, half-waves) always runs onx; withtrough='refine'the broadband trough / peak are still searched on the drift-removedx(as in v1.0.0) and only their values are read frommeasure_on; withtrough='band'the values are read at the band-pass extremes. Defaults to the drift-removedx. The*_bandoutputs always come from the band-passedx. Not allowed withtrough='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
xormeasure_on:'fill'(default) fills them for filtering and discards every wave that overlaps them (plus the gap margin);'raise'raisesValueError.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 to4 / fband[0]for gaps of2 / fband[0]and longer; module docstring, Edges and gaps).edge_margin_s (float or None) – A wave whose span starts within
edge_margin_sof the first sample or ends within it of the last sample is discarded (the zero-phase filters’ edge transient).None(default):4 / fband[0](withtrough='paper': the support of the paper trace’s filters, ~2 s).0keeps 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-removedxwithin half a period offband[1]around the band-pass extreme; the window is not clamped to the wave’s half-wave, so a trough at or beforezero_posgivesdownslope = 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 ofx.return_signals (bool) – Also return the band-passed signal (
'x_band') and the broadband amplitude signal ('x_amp'; withtrough='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_posand, 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_bandfor the band-passed signal. Plusgaps:(n_gaps, 2)[start, stop)sample indices of the non-finite runs (union overxandmeasure_on). Waves are ordered by time.- Return type:
dict