Spike detectors#
Interictal epileptiform discharge (spike) detectors: Janca (Hilbert-envelope distribution
modelling; eeg_forge formulation with presets 'spike' and 'ripple' – the latter not
validated on real ripples – and MATLAB-v24 port) and Barkmeier (multichannel half-wave
morphology). The detectors are raw (finite input only);
GapAwareSpikeDetector wraps any of them for data
with NaN/inf gaps. Algorithms, every parameter with its default and source, the filter
verification and the comparison with the reference implementations are documented in
brainmaze_eeg/spikes/README.md and in the module docstrings below.
Interictal epileptiform discharge (spike) detectors.
Detectors#
detect_spikes_janca()– Hilbert-envelope distribution-modelling detector (Janca et al. 2015), eeg_forge formulation with verified filters; presets'spike'(default) and'ripple'(80-250 Hz, not validated on real ripples) of one implementation. Layout(n_samples,)or(n_channels, n_samples).SpikeDetectorHilbert(aliasspike_detector_hilbert_v24) – port of the MATLABspike_detector_hilbert_v24with its full output. Layout(n_samples, n_channels)(MATLAB convention).detect_spikes_barkmeier()– amplitude/slope/ duration half-wave detector (Barkmeier et al. 2012). Layout(n_samples,)or(n_channels, n_samples).
Two layers:
raw detectors (above): the algorithm only; finite input required (NaN/inf raise
ValueError);GapAwareSpikeDetector: wraps a detector object (JancaDetector,BarkmeierDetector,SpikeDetectorHilbertor any object with adetect(x, fs)method), fills NaN/inf gaps, runs it, removes detections in or near gaps and reports the valid time per channel.
The 2-D entry points raise ValueError on an array with more channels than samples
(probably transposed).
See brainmaze_eeg/spikes/README.md for the algorithms, parameters, filter verification
and the comparison with the reference implementations.
Janca#
Janca (Hilbert-envelope) interictal spike detectors#
Interictal epileptiform discharge (IED) detection by modelling the distribution of the band-passed signal’s Hilbert envelope as log-normal and flagging envelope maxima that exceed an adaptive threshold derived from that distribution:
Janca, R., Jezdik, P., Cmejla, R., Tomasek, M., Worrell, G.A., Stead, M., Wagenaar, J., Jefferys, J.G.R., Krsek, P., Komarek, V., Jiruska, P., Marusic, P. (2015). Detection of Interictal Epileptiform Discharges Using Signal Envelope Distribution Modelling: Application to Epileptic and Non-Epileptic Intracranial Recordings. Brain Topography 28(1), 172-183. https://doi.org/10.1007/s10548-014-0379-1
Two implementations are provided:
detect_spikes_janca()(primary)The fast, streamlined formulation of the algorithm from eeg_forge by xnejed07 (
eeg_forge/detection/spike_detection_janca.py, xnejed07/eeg_forge), reproduced faithfully (detection indices are identical on the parity data, see the README) with its numerical defects fixed and every parameter configurable. Multichannel layout(n_channels, n_samples). One implementation serves several frequency bands through named presets (see below).SpikeDetectorHilbert(MATLAB-v24 compatible)A port of the published MATLAB
spike_detector_hilbert_v24.mwith its full output (per-detection CDF/PDF weights, multichannel discharge grouping, ambiguousk2class, segment buffering). Multichannel layout(n_samples, n_channels)(MATLAB’s[samples, channels]).
Presets of detect_spikes_janca() / JancaDetector#
A preset is a complete, named set of parameter values (JANCA_PRESETS); any
parameter passed explicitly overrides the preset’s value, e.g.
detect_spikes_janca(x, fs, preset='ripple', threshold=4). There is one code path; a
preset only supplies defaults. janca_params() returns the resolved, validated values.
'spike'(default)The eeg_forge reference values: band 10-60 Hz, analysis rate
target_fs200 Hz, 50 Hz notch, 5 s window, threshold 3.65, 0.1 s minimum distance. Source: eeg_forgespike_detection_Janca(commit 54c3704) and Janca et al. (2015).'ripple'Ripple band 80-250 Hz (the clinical ripple band, e.g. Zijlmans, M., Jiruska, P., Zelmann, R., Leijten, F.S.S., Jefferys, J.G.R., Gotman, J. (2012). High-frequency oscillations as a new biomarker in epilepsy. Annals of Neurology 71(2), 169-178. https://doi.org/10.1002/ana.22548) analysed at
target_fs= 1000 Hz [ours: four times the band top, so that after decimation the band top sits at half the analysis Nyquist; with the defaultdecimation='integer'the analysis rate isfs / floor(fs / 1000), e.g. 1000 Hz for 2-5 kHz input, 1024 Hz for 2048 Hz input]. Input of 501-1999 Hz is not decimated (integerneedsfs >= 2 * target_fs) and is analysed at its own rate, where 250 Hz can lie close to Nyquist (0.98 of it at 512 Hz, 0.998 at 501 Hz): the realised edges are still -6.02 dB, but there is almost no room above the band. Input of 500 Hz or less raises. Every other value (threshold, window, minimum distance, notch) is the'spike'preset’s, carried over untuned. Applying the Janca envelope model to the ripple band is our choice; this preset has not been validated on real ripples (only its filters, resampling and validation are verified by the test-suite). Note that mains harmonics inside 80-250 Hz are not notched by default (notch_harmonics=1notches onlypowerline); pass e.g.notch_harmonics=5to notch 100-250 Hz (50 Hz mains) as well.
Algorithm of detect_spikes_janca()#
For each channel, independently (all filters zero-phase, sosfiltfilt, at the input
rate fs):
Band-pass Butterworth, order
filter_order(3), edgesband(10, 60) Hz.bandis the design edge, i.e. the -3 dB point of the single-pass filter; the forward-backward (zero-phase) application squares the response, so the realised response is -6.02 dB at both edges (as in eeg_forge and v24, which also run their designs forward-backward).Band-stop Butterworth, order
notch_order(3),powerline +/- notch_width/2(50 +/- 2.5 Hz; -3 dB single pass, -6 dB zero-phase at those edges), optionally also at harmonics (notch_harmonics).Resample with
scipy.signal.resample_poly()(its anti-alias FIR runs on the already band-limited signal).decimation='integer'(default, reference): iftarget_fsis set andfs >= 2 * target_fs, decimate by the integer factorq = floor(fs / target_fs); the analysis ratefs_a = fs / qis then generally nottarget_fs(500 Hz -> 250 Hz; 512 -> 256; 2048 -> 204.8; 256 or 399 Hz -> not decimated).decimation='exact'(ours): resample totarget_fswheneverfs > target_fsby the rational factorup / down(seejanca_resampling(); within a relative 1e-6 oftarget_fs). The resampler’s anti-alias filter attenuates the top ~15 % below the analysis Nyquist, so a resampled configuration is only accepted if that filter loses at mostMAX_RESAMPLER_LOSS_DB(0.1 dB) atband[1](aboutband[1] <= 0.85 * fs_a / 2); the realised high edge is then -6.02 to -6.12 dB (-6.12 dB at the limit,band[1]= 0.858 *fs_a / 2).Envelope
e = |hilbert(x)|. When the analysis length has a prime factor > 1000 the FFT is padded toscipy.fft.next_fast_len()(pocketfft is 3-11x slower on such lengths; measured). Padding changes the envelope near both ends of the record (the zeros wrap the end into the start): on band-passed noise, > 1 % relative change in the first ~0.2-0.27 s and the last ~0.05-0.2 s. Detections there can differ (on a 6.8 h record cut to a prime analysis length: 5 of ~1500, all in the last 1.6 s; none changed on a 1 h prime-length record), as they would for a record a few samples longer. All other lengths are transformed unpadded, exactly as in the reference.Sliding statistics of
L = log(e + eps)over a centred window ofW = int(window_s * fs_a)samples (made odd),mode='reflect'at the ends:mu[n] = mean_{k in win(n)} L[k] sd[n] = sqrt( mean_{k in win(n)} (L[k] - mu[k])**2 )
Note that
sdsubtracts the per-sample local meanmu[k]inside the window, not the window-centre meanmu[n]; this is the reference definition and is kept as is (it is not the textbook moving standard deviation; for a stationary background the two agree to first order).Log-normal mode and median:
mode = exp(mu - sd**2),median = exp(mu); thresholdT = threshold * (mode + median).Detections are the maxima of
efound byscipy.signal.find_peaks()withheight=Tanddistance=int(min_distance_s * fs_a)samples; returned as sample indices of the input signal (index_a * q, resolutionqinput samples; withdecimation='exact':round(index_a * down / up), i.e. mapped back with the realised ratio, so the rational approximation causes no timing drift).
Channels are processed one at a time, so the working memory is a few times one channel, not a few times the whole montage.
Known differences from the eeg_forge reference (all deliberate fixes)#
Filters in ``sos`` form. The reference designs
b, atransfer functions; at high sampling rates these lose precision (max complex response error of the 50 Hz band-stop 5e-7 at 2 kHz, 5e-5 at 5 kHz, 2e-3 at 8 kHz, 7e-3 at 10 kHz, 6e-2 at 16 kHz) and the band-stop becomes unstable at 32 kHz (pole radius 1.0006) -> NaN -> silently no detections. Thesosdesign has the same response where theb, aone is accurate: on the parity data the detection indices are identical at 200-10000 Hz; at 16 kHz one detection of 23 moves by one analysis sample; at 32 kHz the reference finds nothing.Scale-invariant epsilon. The reference adds an absolute
1e-6to the envelope before the log. For data in volts (envelope ~1e-5..1e-8) that constant dominates the background, the threshold no longer adapts, and the detector returns nothing. Hereeps = eps_rel * median(e)(eps_rel = 1e-6), so the result does not depend on the unit; at uV scale detections are unchanged.Validation. Every parameter is checked (type, finiteness, range) when it is resolved (
janca_params(), also atJancaDetectorconstruction); band edges, notch frequencies, the analysis Nyquist, the resampler loss at the band edge, the window length and the record length are checked againstfsat call time (ValueError). A power-line notch that does not fit below Nyquist is skipped with aUserWarning(powerline=Nonedisables it silently).Multichannel input
(n_channels, n_samples)(the reference is 1-D only).Non-finite input raises
ValueError(the reference silently returns zero detections for a channel with a single NaN). For signals with gaps useGapAwareSpikeDetectoraroundJancaDetector.min_distance_s * fs_a < 1is clamped to 1 sample (the reference would raise).Optional rational resampling (
decimation='exact'); the default keeps the reference’s integer decimation so that results are identical to it.Hilbert FFT length padded to a fast length only for lengths with a prime factor > 1000 (step 4); the parity fixtures and the 6.8 h parity recording are not affected.
- brainmaze_eeg.spikes.janca.JANCA_PRESETS = mappingproxy({'spike': mappingproxy({'band': (10.0, 60.0), 'filter_order': 3, 'powerline': 50.0, 'notch_width': 5.0, 'notch_order': 3, 'notch_harmonics': 1, 'target_fs': 200.0, 'decimation': 'integer', 'window_s': 5.0, 'threshold': 3.65, 'min_distance_s': 0.1, 'eps_rel': 1e-06}), 'ripple': mappingproxy({'band': (80.0, 250.0), 'filter_order': 3, 'powerline': 50.0, 'notch_width': 5.0, 'notch_order': 3, 'notch_harmonics': 1, 'target_fs': 1000.0, 'decimation': 'integer', 'window_s': 5.0, 'threshold': 3.65, 'min_distance_s': 0.1, 'eps_rel': 1e-06})})#
Named parameter sets of
detect_spikes_janca()(read-only; see the module docstring for their sources).'ripple'differs from'spike'only inbandandtarget_fsand is not validated on real ripples.
- class brainmaze_eeg.spikes.janca.JancaDetector(preset='spike', **overrides)#
detect_spikes_janca()as a detector object (the protocol ofGapAwareSpikeDetector).JancaDetector(preset, **overrides).detect(x, fs)equalsdetect_spikes_janca(x, fs, preset=preset, **overrides)for 2-Dx(n_channels, n_samples): a list with oneint64array of sample indices per channel. All parameters are resolved and validated at construction (janca_params(): names, types, finiteness, ranges); the checks that need the sampling rate (Nyquist, resampler loss, window and record length) run indetect().Examples:
JancaDetector() # spikes, eeg_forge values JancaDetector(powerline=60) # spikes, North-American mains JancaDetector('ripple') # 80-250 Hz at ~1 kHz (unvalidated) JancaDetector('ripple', notch_harmonics=5, threshold=4.0)
- detect(x, fs)#
Detection sample indices per channel of
x(n_channels, n_samples).
- brainmaze_eeg.spikes.janca.MAX_RESAMPLER_LOSS_DB = 0.1#
Maximum loss (dB) of the resampler’s anti-alias filter allowed at
band[1].
- class brainmaze_eeg.spikes.janca.SpikeDetectorHilbert(**kwargs)#
Janca envelope-distribution IED detector, port of MATLAB
spike_detector_hilbert_v24.Use this when you need v24’s full output (detection CDF/PDF weights, multichannel discharge grouping, the ambiguous
k2class) or comparability with MATLAB v24 results; otherwise preferdetect_spikes_janca(). Input layout is (n_samples, n_channels) (MATLAB convention), the opposite of the rest of the package.Pipeline (v24): resample to
decimationHz -> power-line notch comb -> 1 Hz high-pass (Butterworth order 2) -> per segment: band-passbandwidth-> Hilbert envelope -> per-window (winsize/noverlap) log-normal MLE, smoothed and interpolated -> thresholdk1*(mode+median) - k3*(mean-mode)-> local maxima, poly-spike union -> output.Filters (all zero-phase,
sos; verified by the test-suite):Power-line notch comb: 2nd-order IIR notches at
main_hum_freqand harmonics up to1.1 * bandwidth[1], unit gain away from the notch. v24 uses a fixed pole radius 0.985, which gives a ~1 Hz wide notch at 200 Hz but widens proportionally with the rate (~24 Hz at 5 kHz withdecimation=0). Here the radius is1 - 0.015 * 200 / fsso the width stays ~1 Hz (identical to v24 at 200 Hz).High-pass 1 Hz Butterworth order 2.
Band-pass
f_type:Chebyshev-II (default): minimum-order low-pass and high-pass meeting
cheb_rpdB (6) max loss at the band edges andcheb_rsdB (60) stop-band attenuationcheb_transition_hz= (5, 10) Hz beyond the low/high edge, i.e. the v24 spec (normalised 0.05/0.1 at 200 Hz) expressed in Hz so it is valid at any rate. Effective zero-phase response: about -12 dB at the band edges (v24’s 6 dB single-pass spec), < -120 dB in the stop bands. Fixed defect: the earlier port passed the pass-band edge ascheby2’sWn(which is the stop-band edge) and discarded the order-designWn, shrinking the effective band to ~18-48 Hz (15 Hz attenuated to 0.03, 50 Hz to 0.15 of the input power).Butterworth order 4 high-pass + order 4 low-pass (-6 dB at the edges).
FIR (
firwin,fs/2taps, odd) high-pass + low-pass (-12 dB at the edges).
Change from v24: v24 replaced Chebyshev by Butterworth (with a warning) whenever
decimationwas not 200 Hz, because its Chebyshev spec was normalised to 200 Hz. Here the spec is in Hz and valid at any analysis rate (verified at 200 Hz-32 kHz), sof_typeis always used as given. Passf_type=2to reproduce v24 at other rates.
Resampling (
decimation> 0 and different fromfs): rational polyphase (scipy.signal.resample_poly()) by the smallest-denominator ratioup / downwithin 1e-6 (relative) ofdecimation / fs, so any real input rate works (TDT 24414.0625 Hz, 511.99 Hz). The analysis then runs at the realised ratefs * up / down(filters designed and positions converted at that rate, so there is no timing drift). The band must lie below the input Nyquist as well, and the resampler’s anti-alias filter may lose at mostMAX_RESAMPLER_LOSS_DBatbandwidth[1].Parameters (defaults follow v24)#
- bandwidth(float, float)
Band-pass edges [low, high] in Hz. Default [10, 60].
- k1float
Threshold multiplier for obvious spikes. Default 3.65.
- k2float
Threshold multiplier for ambiguous spikes,
0 < k2 <= k1(MATLAB v24 help: “k1 >= k2”). A local maximum above thek2threshold but not abovek1is reported as ambiguous (con0.5) only if an obvious detection on any channel lies within the preceding 10 ms ([i - 10 ms, i]; v24 tests the single samplei - 10 ms, which we read as a typo for this window). Default equalsk1(ambiguous class disabled). Withk2 < k1the result of a channel depends on the other channels, sochannel_independentis False andGapAwareSpikeDetectorpasses the whole montage. (An earlier version enforcedk2 >= k1, which inverted the v24 constraint; the ambiguous class could then never fire.) Not comparable to MATLAB v24 whenk2 < k1: v23/v25 test the literal single sample, and on real 15-channel iEEG the window used here gives 4-60x more ambiguous detections (a symmetric +-10 ms window 12-50 % more still). See the README (“Ambiguous class is not comparable to v24”). Defaults are unaffected.- k3float
Threshold tilt term. Default 0.
- main_hum_freqfloat or None
Mains frequency to notch (Hz). Default 50 (use 60 for North-American data).
Nonedisables the notch.- decimationfloat
Target analysis sampling rate (Hz). Default 200. Set 0 to keep the input rate.
- bufferingfloat
Core window length for batch processing (seconds). Default 300.
- winsize, noverlapfloat
Envelope-model window and overlap in seconds. Defaults 5 and 4. A
winsizelonger than the analysis record issues aUserWarning(one fit for the whole record).- polyspike_union_timefloat
Poly-spike union interval (seconds). Default 0.12.
- discharge_tolfloat
Grouping tolerance for multichannel events (seconds). Default 0.005.
- f_typeint
Band-pass family: 1 = Chebyshev-II (default), 2 = Butterworth, 3 = FIR.
- cheb_rp, cheb_rsfloat
Chebyshev-II max pass-band loss / min stop-band attenuation, single pass, dB. Defaults 6 and 60 (v24).
- cheb_transition_hz(float, float)
Chebyshev-II transition widths (Hz) below the low and above the high edge. Default (5, 10) (= v24 at 200 Hz).
- betafloat
Low edge (Hz) of beta rejection.
inf(default) disables it; any finite value raisesNotImplementedError.
Input must be finite (NaN/inf raise
ValueError). For data with gaps useGapAwareSpikeDetector(SpikeDetectorHilbert(...)): it callsdetect(), which takes(n_channels, n_samples)and returns detection sample indices per channel.Buffering scheme#
The record is partitioned into contiguous core windows that tile
[0, N)exactly. Each core is analysed inside a block extended bymargin = 3 * winsizesamples on each side, and only detections inside the core are kept, so every detection is produced exactly once (verified by a whole-vs-buffered test).Not implemented: beta/mu-activity rejection and the
ti_switch == 2timing mode.- property channel_independent#
ambiguous detections are accepted only next to an obvious detection on any channel.
- Type:
True unless the ambiguous class is enabled (
k2 < k1)
- design_filters(fs)#
Design the filters applied at the analysis rate
fs(i.e. after decimation).- Returns:
'notches': list of(centre_hz, sos);'highpass': 1 Hzsos;'bandpass': list of filters applied in sequence, eachsos(IIR) or('fir', taps);'f_type': family actually used.- Return type:
dict
- detect(x, fs)#
Detector protocol of
GapAwareSpikeDetector.- Parameters:
x (np.ndarray, shape (n_channels, n_samples)) – Channels first (unlike
run()).fs (float)
- Returns:
Per channel, sorted
int64sample indices (input rate) of the detections (obvious and, withk2 < k1, ambiguous ones),round(pos * fs).- Return type:
list of np.ndarray
- run(d, fs)#
Detect IEDs in
dsampled atfs.- Parameters:
d (np.ndarray) – Signal, shape
(n_samples,)or(n_samples, n_channels).fs (float) – Input sampling frequency (Hz).
- Returns:
out (dict) – Per-detection arrays:
pos(s),dur(s),chan(int),con(1 obvious / 0.5 ambiguous),weight(envelope CDF),pdf.discharges (dict) – Per multichannel event:
MV[n_events, n_chan] spike type,MAmax envelope above background,MPstart position (s),MDduration (s),MWCDF weight,MPDFpdf.d_decim (np.ndarray) – Decimated, hum-notched, 1 Hz high-passed signal
(n_samples_dec, n_chan).envelope (np.ndarray) – Hilbert envelope of the band-passed signal
(n_samples_dec, n_chan).background (np.ndarray) – Threshold curves
(n_samples_dec, n_chan, 2)for k1 and k2.envelope_pdf (np.ndarray) – Log-normal PDF of the envelope
(n_samples_dec, n_chan).
- brainmaze_eeg.spikes.janca.design_janca_filters(fs, band=(10.0, 60.0), filter_order=3, powerline=50.0, notch_width=5.0, notch_order=3, notch_harmonics=1)#
Design the filters
detect_spikes_janca()applies at the input ratefs.Parameters are those of
detect_spikes_janca().bandand the band-stop edges are design edges (-3 dB single pass); the detector applies the filters forward-backward, so the realised response is -6.02 dB there.- Returns:
'bandpass':sosof the band-pass;'notches': list of(centre_hz, sos)band-stops actually applied;'skipped_notches': centres (Hz) that did not fit below Nyquist and were skipped (aUserWarningis issued).- Return type:
dict
- Raises:
ValueError – Invalid band, order, notch width or harmonic count.
- brainmaze_eeg.spikes.janca.detect_spikes_janca(x, fs, *, preset='spike', band=<preset>, filter_order=<preset>, powerline=<preset>, notch_width=<preset>, notch_order=<preset>, notch_harmonics=<preset>, target_fs=<preset>, decimation=<preset>, window_s=<preset>, threshold=<preset>, min_distance_s=<preset>, eps_rel=<preset>, return_details=False)#
Janca envelope-distribution detector (eeg_forge formulation, fixed), for spikes or, with
preset='ripple', the ripple band.See the module docstring for the algorithm, the presets and the differences from the reference. Every parameter left at
<preset>takes the value ofpreset; any value given explicitly overrides it. The defaults below are those ofpreset='spike'.- Parameters:
x (np.ndarray) – Signal,
(n_samples,)or(n_channels, n_samples). Any amplitude unit (the detector is scale-invariant). Must be finite: NaN/inf raiseValueError(wrap the detector inGapAwareSpikeDetectorfor data with gaps).fs (float) – Sampling frequency of
x(Hz).preset ({'spike', 'ripple'}) – Named parameter set (
JANCA_PRESETS).'spike'(default): the eeg_forge reference values.'ripple': band 80-250 Hz,target_fs1000 Hz, everything else as'spike'; not validated on real ripples.band ((float, float)) – Band-pass design edges in Hz (spike: 10, 60; ripple: 80, 250). Single-pass -3 dB; realised (zero-phase) -6.02 dB at both edges. Must satisfy
0 < low < high,high< Nyquist offs, and, when resampling, lose at mostMAX_RESAMPLER_LOSS_DBin the resampler (abouthigh <= 0.85 * fs_a / 2).filter_order (int) – Butterworth prototype order of the band-pass (reference: 3; 1-20).
powerline (float or None) – Power-line frequency in Hz (reference: 50). Set 60 for North-American data.
Nonedisables the notch.notch_width (float) – Total width (Hz) of the band-stop, centred on
powerline(reference: 5, i.e. +/-2.5 Hz design edges; -6 dB at the edges after zero-phase filtering). Must be< 2 * powerline.notch_order (int) – Butterworth prototype order of the band-stop (reference: 3; 1-20).
notch_harmonics (int) – Number of notches at
k * powerline,k = 1..notch_harmonics(reference: 1). Notches not fitting below Nyquist are skipped with a warning.target_fs (float or None) – Decimation target (Hz) (spike: 200; ripple: 1000).
Nonekeeps the input rate.decimation ({'integer', 'exact'}) –
'integer'(default, reference): decimate by the integer factorq = floor(fs / target_fs)only whenfs >= 2 * target_fs; the analysis ratefs / qis then generally nottarget_fs(512 -> 256 Hz, 2048 -> 204.8 Hz, 250..399 Hz -> not decimated).'exact'(ours): rational polyphase resampling totarget_fs(within 1e-6 relative) wheneverfs > target_fs, so the analysis rate (and with it the effective time resolution of the envelope model) is the same for every input rate. Detections are mapped back to input samples by rounding, with the realised ratio.window_s (float) – Length (s) of the sliding window of the log-envelope statistics (reference
w: 5). Must span at least 3 analysis samples. A window longer than the analysis record issues aUserWarning: the statistics then cover the whole (reflected) record, not a local window.threshold (float) – Threshold multiplier on
mode + medianof the local log-normal model (referencethr: 3.65, the paper’sk1). Finite, > 0.min_distance_s (float) – Minimum distance between detections in seconds (reference: 0.1). Of two close maxima the larger is kept. Finite, >= 0.
eps_rel (float) – Envelope offset before the log, relative to the channel’s median envelope (our choice, replaces the reference’s absolute 1e-6; see module docstring). Finite, >= 0.
return_details (bool) – Also return per-channel diagnostics (see Returns).
- Returns:
detections (np.ndarray or list of np.ndarray) – For 1-D input:
int64array of detection sample indices into x (input rate), sorted. For 2-D input: a list with one such array per channel (row ofx). Convert to seconds withdetections / fs.details (dict or list of dict) – Only with
return_details=True(one dict per channel for 2-D input):fs_analysis(Hz),up/downresampling factors (fs_analysis = fs * up / down),envelopeandthreshold(at the analysis rate; sampleicorresponds to input samplei * down / up),filters(output ofdesign_janca_filters()),presetandparams(the resolved parameter values).
- Raises:
ValueError, TypeError – Invalid parameters (see
janca_params()), a band edge at/above the Nyquist frequency or too close to it after resampling, a window shorter than 3 analysis samples, a record too short for the zero-phase filters, a 2-D array with more rows than columns (probably transposed), or a non-finite value (NaN/inf) inx.
- brainmaze_eeg.spikes.janca.janca_decimation_factor(fs, target_fs=200.0)#
Integer decimation factor of the reference rule (
decimation='integer').floor(fs / target_fs)whentarget_fsis set andfs >= 2 * target_fs, otherwise 1 (no decimation). With the defaulttarget_fs=200this is the eeg_forge rule (decimate only whenfs >= 400). The resulting analysis ratefs / qis generally nottarget_fs(512 Hz -> 256 Hz, 2048 Hz -> 204.8 Hz, 399 Hz -> 399 Hz).
- brainmaze_eeg.spikes.janca.janca_params(preset='spike', **overrides)#
Resolved, validated parameters of
detect_spikes_janca().- Parameters:
preset (str) – Name of a preset in
JANCA_PRESETS('spike'or'ripple').**overrides – Any parameter of
detect_spikes_janca(); replaces the preset’s value.
- Returns:
One value per parameter (
band…eps_rel), ready fordetect_spikes_janca(x, fs, **params).- Return type:
dict
- Raises:
ValueError, TypeError – Unknown preset or parameter, or a value of the wrong type / out of range (NaN and inf are rejected everywhere except
target_fs=None/powerline=None, which mean “off”). Checks that need the sampling rate happen indetect_spikes_janca().
- brainmaze_eeg.spikes.janca.janca_resampling(fs, target_fs=200.0, decimation='integer')#
Resampling applied by
detect_spikes_janca()after filtering.- Parameters:
fs (float) – Input sampling rate (Hz).
target_fs (float or None) – Decimation target (Hz);
Nonekeeps the input rate.decimation ({'integer', 'exact'}) –
'integer'(reference): decimate byq = floor(fs / target_fs)whenfs >= 2 * target_fs(seejanca_decimation_factor()).'exact': rational resampling totarget_fswheneverfs > target_fs(never upsamples).up / downis the smallest-denominator continued-fraction convergent oftarget_fs / fswithin a relative 1e-6, so any real rate works (TDT 24414.0625 Hz, rates estimated from timestamps such as 511.99 Hz).
- Returns:
(up, down, fs_analysis) –
scipy.signal.resample_poly()factors and the realised analysis ratefs * up / down(equal totarget_fsto within 1e-6 relative with'exact').- Return type:
(int, int, float)
- brainmaze_eeg.spikes.janca.resampler_gain_db(freq, fs, up, down)#
Gain (dB) at
freqHz of the anti-alias FIR thatscipy.signal.resample_poly()designs by default forup / downat input ratefs(Kaiser window, beta 5, cutoff at the lower of the two Nyquist frequencies,20 * max(up, down) + 1taps; scipy’s own design rule). 0 when no resampling happens.
Barkmeier#
Barkmeier interictal spike detector#
Multichannel amplitude/slope/duration half-wave spike detector after:
Barkmeier, D.T., Shah, A.K., Flanagan, D., Atkinson, M.D., Agarwal, R., Fuerst, D.R., Jafari-Khouzani, K., Loeb, J.A. (2012). High inter-reviewer variability of spike detection on intracranial EEG addressed by an automated multi-channel algorithm. Clinical Neurophysiology 123(6), 1088-1095. https://doi.org/10.1016/j.clinph.2011.09.023 (PMC3277646)
Written from the paper’s Methods (“Spike detection algorithm”); no third-party code. Each step below is marked [paper] when it is specified by the paper and [ours] when the paper leaves it open and this implementation makes a documented choice.
Algorithm#
[paper] One-minute blocks. The record is processed in successive blocks of
block_sseconds (60). The scaling factor, the artifact-channel rule and the candidate threshold are computed per block. [ours] Filtering is done once on the whole record (no block-edge transients); a trailing remainder shorter than half a block is merged into the previous block;block_s=Nonetreats the whole record as one block.[paper] Artifact channels – OFF by default here (opt-in). The paper excludes, in each block, a channel whose average slope is more than 10 standard deviations from the mean slope of the channels. [ours] Average slope = mean
|dx/dt|of the input signal over the (valid samples of the) block. No formulation of this rule we tested keeps every real detection and removes realistic artifacts, so the default isartifact_sd=None, artifact_ratio=None(no channel is ever excluded) and two formulations are offered as opt-in. Measured on 1/f background with 60 s blocks at 256 and 1000 Hz (brainmaze-work/scratch/eeg-spikes/r3/v1_*.py; table in the README):Literal paper rule (mean/SD over all channels): the tested channel is part of the SD, so its z-score is bounded by
sqrt(n_channels - 1); it cannot fire below 102 channels (flagged nothing in any test).Leave-one-out mean/SD (round 1 of this module): the reference SD of similar channels is tiny, so a genuinely spiking channel is excluded (8 channels, 300 uV IEDs at 1/s on one: 120 of 598 kept at 256 Hz; 1000 uV at 3/s: 0 of 1794 kept), and 3-4 channel noise montages have 3-12 % false flags.
Spatial robust rule (
artifact_sd, e.g. 10; round 2 of this module): centre = median slope of the usable channels, spread =max(1.4826 * MAD, artifact_rel_floor * median), flagged when|slope - median| > artifact_sd * spread. With the floor 0.2 a channel is flagged whenever its slope exceeds 3x the median channel’s, whatever the cause: in a montage of 12 contacts at 1x and 4 at 3.5-6x amplitude (grey vs white matter, no artifact) the 4 large channels are flagged in every block and a spiking large channel loses all its detections (0 of 221, 181, 154 and 145 kept at 3.5x, 4x, 5x, 6x). It also removes strong IED bursts (2000 uV at 3/s: 541 -> 4 at 256 Hz). Use it only for montages of similar contacts.Self-referenced rule (
artifact_ratio, e.g. 3; ours): each channel’s slope is divided by its own median slope over all blocks, and a channel-block is flagged when that ratio exceedsartifact_ratiotimes the median ratio of the montage in that block. Heterogeneous montages and steady spiking are never flagged (all cases above kept), but it needs >= 3 blocks, misses an artifact present in more than half of the blocks, and also removes strong IED bursts confined to a few blocks (2000 uV at 3/s in 3 of 10 blocks at 256 Hz: 541 -> 4).
What either rule catches: broadband noise (white noise at 3x the background rms: slope ratio 5.5-6.2) and mains pick-up (300 uV, 60 Hz). What neither catches (mean slope barely changes, ratio 0.03-1.9): slow drifts, EMG bursts, electrode pops, flat stretches with jumps – although EMG, pops and jumps cause false detections (17-94 in a 3-minute stretch). On real 15-channel iEEG (2 x 1 h, 256 Hz, contact slopes 0.14-4.1x the median) no rule flagged anything. Only usable channels count (>= 3 needed, positive median); excluded channel-blocks get no detections and do not enter the scaling, and a
UserWarningnames them. Both rules may be combined (a channel-block is excluded if either flags it).[paper] Candidates. Band-pass 20-50 Hz (
narrow_band); candidates are local maxima of the rectified narrow-band signal above a threshold ofstd_coeff(4) standard deviations. [ours: interpretation] The paper’s wording (“four standard deviations of the channel mean amplitude”) is ambiguous; the threshold here ismean + std_coeff * stdof the rectified narrow-band signal in the block (on Gaussian noise about 3.2 SD of the narrow-band signal itself). [ours] Filter type/order are not given in the paper: Butterworth, prototype ordernarrow_order= 2 (the paper’s broad-band design), zero-phase.[paper] Morphology band and block scaling. Band-pass 1-35 Hz, 2nd-order Butterworth (
broad_band,broad_order). All channels of a block are multiplied by one factor that brings the median (across channels) of the channel mean rectified amplitudes toscale(70 uV). [ours] zero-phase application (sosfiltfilt): the paper only says “second order digital Butterworth”. The band edges are design edges (-3 dB single pass); applied forward-backward the attenuation doubles, so the realised response is -6 dB at 1 and 35 Hz.[paper] For each candidate, the broad-band peak (largest
|x|within +/-2 ms [ours]) and the flanking opposite extrema withintrough_search(50 ms [ours]) define two half-waves. A spike is accepted iff total amplitude of both half-waves> 600, each half-wave slope> 7 uV/msand each half-wave duration> 10 ms, in the block-scaled domain (DEFAULT_THRESHOLDS).[ours] Accepted detections on a channel closer than
trough_searchare merged (largest total amplitude kept; one discharge produces several narrow-band maxima); then the optionalrefractory(s) is applied to the merged list (default 0, the paper defines none).
Filters (verified by the test-suite at 200-32000 Hz)#
signal |
type |
order |
zero-phase response |
|---|---|---|---|
narrow |
Butterworth band-pass |
2 |
-6 dB at 20 / 50 Hz, ~0 dB at 30-35 Hz, -15 dB at 60 Hz |
broad |
Butterworth band-pass |
2 |
-6 dB at 1 / 35 Hz, ~0 dB at 5-10 Hz |
Differences from the earlier version of this module#
Broad band was 1-80 Hz (2nd-order high-pass + 4th-order low-pass) and its docstring claimed “per the paper”; the paper specifies 1-35 Hz 2nd-order Butterworth. The 80 Hz band admits sharper noise transients: on 1/f noise the false-positive rate drops ~5x with the paper’s band (see the README for numbers).
No blocks: one scaling factor and one threshold per channel for the whole record. Now per
block_s(paper: one minute).No artifact-channel rule. Now available as two opt-in formulations (see step 1); off by default because none keeps every real detection.
The refractory period was applied before the 50 ms merge, so a merge could pick a detection the refractory had already used to suppress its neighbour. Now merge first.
A single NaN anywhere silently made the median scaling factor NaN for every channel. Non-finite input now raises
ValueError; data with gaps go throughGapAwareSpikeDetector(withBarkmeierDetector), which fills the gaps, passes the gap mask asvalidso that block statistics use only real samples, and drops detections in/near gaps.A transposed
(n_samples, n_channels)array was silently accepted. A 2-D input with more rows than columns, or shorter than 1 s, now raisesValueError.
Note on false positives#
Block scaling normalises the median channel to scale whatever the channel count, so the
fixed thresholds are relative to the typical channel of the montage. On pure 1/f
background the detector fires at a low but non-zero rate (0.015-0.027/s per channel with
the paper’s 1-35 Hz band; 8 channels of 30 uV 1/f noise) independently of the number of
channels; the multichannel design
makes the result comparable between channels, it does not by itself remove noise
detections. The earlier statement that this was a single-channel artefact was wrong.
- class brainmaze_eeg.spikes.barkmeier.BarkmeierDetector(**params)#
detect_spikes_barkmeier()as a detector object (the protocol ofGapAwareSpikeDetector).BarkmeierDetector(**params).detect(x, fs)runsdetect_spikes_barkmeier()on the whole montagex(n_channels, n_samples)and returns a list with one list of detection dicts per channel (same dicts asdetect_spikes_barkmeier(), sorted bypeak_index). Parameters are those ofdetect_spikes_barkmeier()exceptvalidandreturn_info; they are validated at construction (names, types, finiteness, ranges); the checks that needfs(band vs Nyquist,trough_searchof at least 2 samples) run indetect().- detect(x, fs, valid=None)#
Per-channel lists of detection dicts for
x(n_channels, n_samples).
- brainmaze_eeg.spikes.barkmeier.design_barkmeier_filters(fs, narrow_band=(20.0, 50.0), broad_band=(1.0, 35.0), narrow_order=2, broad_order=2)#
Design the band-pass filters used by
detect_spikes_barkmeier().- Returns:
{'narrow': sos, 'broad': sos}(Butterworth band-passes; applied zero-phase).- Return type:
dict
- Raises:
ValueError – Unless
0 < low < high < fs/2for both bands and the orders are positive integers.
- brainmaze_eeg.spikes.barkmeier.detect_spikes_barkmeier(sig, fs, scale=70.0, std_coeff=4.0, trough_search=0.05, thresholds=None, narrow_band=(20.0, 50.0), broad_band=(1.0, 35.0), refractory=0.0, *, narrow_order=2, broad_order=2, block_s=60.0, artifact_sd=None, artifact_rel_floor=0.2, artifact_ratio=None, valid=None, return_info=False)#
Detect interictal spikes with the Barkmeier (2012) multichannel half-wave criteria.
See the module docstring for the algorithm and which choices are the paper’s.
- Parameters:
sig (np.ndarray) – iEEG in uV,
(n_samples,)or(n_channels, n_samples)– pass the whole montage: scaling and the artifact rule are across channels. Must be finite: NaN/inf raiseValueError(useGapAwareSpikeDetectorfor data with gaps).fs (float) – Sampling frequency in Hz.
scale (float) – Block-scaling target for the median channel mean rectified amplitude (paper: 70 uV). Finite, > 0.
std_coeff (float) – Candidate threshold in SDs of the rectified narrow-band signal (paper: 4). Finite, >= 0.
trough_search (float) – Half-window (s) each side of the peak in which to find the flanking troughs, and merge distance of detections (ours: 0.05). Finite, > 0 and at least 2 samples.
thresholds (dict, optional) –
{'total_amp', 'slope', 'half_dur'}in the block-scaled domain; partial dicts are completed fromDEFAULT_THRESHOLDS(paper: 600 uV, 7 uV/ms = 7000 uV/s, 10 ms). Each finite, >= 0.narrow_band ((float, float)) – Candidate band design edges (paper: 20-50 Hz); -6 dB realised (zero-phase).
broad_band ((float, float)) – Morphology/scaling band design edges (paper: 1-35 Hz); -6 dB realised.
refractory (float) – Minimum time (s) between accepted spikes on a channel, after merging (default 0). Finite, >= 0.
narrow_order (int) – Butterworth prototype orders (broad: paper 2; narrow: ours 2). 1-20.
broad_order (int) – Butterworth prototype orders (broad: paper 2; narrow: ours 2). 1-20.
block_s (float or None) – Block length in seconds (paper: 60), finite and >= 1.
None: one block for the whole record.artifact_sd (float or None) – Opt-in spatial robust artifact-channel rule (paper: 10 SD; default
None= off), finite and > 0. Flags any channel whose block slope exceeds1 + artifact_sd * artifact_rel_floor(3x) times the median channel’s whatever the cause, so large normal channels of a heterogeneous montage lose all detections, and frequent large spikes alone can exceed the limit (the slope ratio depends on the sampling rate: 1000 uV IEDs at 3/s, 256 Hz: 510 of 1235 kept); use only for montages of similar contacts. See the module docstring, step 1.artifact_rel_floor (float) – Floor of the spatial rule’s spread, relative to the median channel slope (ours: 0.2). Finite, >= 0.
artifact_ratio (float or None) – Opt-in self-referenced artifact rule (ours; e.g. 3; default
None= off), finite and > 1: flags a channel-block whose slope, relative to the channel’s own median over blocks, exceedsartifact_ratiotimes the montage’s median relative slope in that block. Safe for heterogeneous montages; needs >= 3 blocks; misses artifacts present in most blocks and removes strong IED bursts confined to a few blocks. See the module docstring, step 1.valid (np.ndarray of bool, optional) – Same shape as
sig; samples that are real data (default: all). Only valid samples enter the per-block statistics (scaling factor, candidate threshold, artifact slope); a channel with no valid sample in a block is excluded from that block. Used byGapAwareSpikeDetectorso that filled gaps do not bias the statistics; detections are not filtered by it.return_info (bool) – Also return a dict with per-block diagnostics (see Returns).
- Returns:
detections (list of dict) – One dict per spike, sorted by (channel, time):
channel, peak_index, peak_time, block, peak_amp, left_amp, left_dur, left_slope, right_amp, right_dur, right_slope, total_amp. Index/time refer tosig(samples / seconds). Amplitudes and slopes are in the block-scaled domain (multiply by1/info['scale_factor'][block]for input units); durations are seconds.info (dict) – Only with
return_info=True:blocks(n_blocks, 2)[start, stop)samples;scale_factor(n_blocks,);artifact(n_blocks, n_channels)bool;channel_slopeandcandidate_threshold(n_blocks, n_channels)in input units;filters.
- Raises:
ValueError, TypeError – Invalid parameter (type, NaN/inf, range), band at/above Nyquist, input not 1-D/2-D, a 2-D input with more rows than columns (probably transposed), a record shorter than 1 s, a non-finite value, or a
validmask of the wrong shape.
Gap handling#
Gap-aware wrapper for the spike detectors#
The detectors in this package are raw: they run their algorithm on a finite signal and
raise ValueError on NaN/inf. GapAwareSpikeDetector is the layer for signals with
missing data. Around any detector that follows the protocol below, it
finds the gaps of every channel of the original signal: runs of NaN/inf and, by default, runs of an exactly constant value lasting at least
flat_as_gap_s(0.1 s; see “Missing data stored as a constant” below);fills them – gaps up to
short_gap_s(0.02 s) by linear interpolation, longer ones withfill('mirror','pink'or'linear', seebrainmaze_eeg.spikes._gaps) – so that filters and background estimates run on a signal without steps or silent stretches;runs the detector on the filled signal (channels that are entirely missing are left out, so they cannot bias cross-channel statistics such as Barkmeier’s median scaling; detectors that declare
channel_independent = Trueare fed one channel at a time to bound memory);removes every detection inside a gap or within
edge_margin_s(0.2 s) of it – for short, interpolated gaps as well as long ones;reports the gaps and the valid time per channel, so rates can be normalised by the time in which a detection could have been reported (a
RuntimeWarningnames channels with less than half of the record valid).
On a gap-free signal the result is identical to calling the detector directly.
Missing data stored as a constant#
Many recordings do not store missing data as NaN: MEF/EDF exports and acquisition
systems often write a constant (0, the last value, or a fixed code) during dropouts. A
raw detector sees steps at both ends of such a flat run and fires trains of false
detections there (on the 6.8 h eeg_forge parity recording, constant runs inside its
data_present == 0 intervals produced ~70-85 of the 1494 detections). Convert missing
data to NaN when you know where it is (e.g. x[~data_present] = np.nan). As a safety net
the wrapper treats any run of exactly equal consecutive samples lasting at least
flat_as_gap_s seconds as a gap (default 0.1 s; None disables it). Evidence for the
default: in that recording every constant run of >= 0.1 s is missing data, and constant
runs within real signal last at most 4 ms (2 samples). Saturated (clipped) stretches are
caught too, which is intended. A channel that is constant throughout becomes an
all-missing channel (flagged, no detections).
Short gaps and the margin (trade-off)#
Linear interpolation is right for very short dropouts but carries no band power, so with
many longer interpolated gaps a background-modelling detector (Janca) sees a lower
background and a lower threshold everywhere: on 30 min of real Fz-Cz with 100 ms
dropouts every 1 s, the old default short_gap_s=0.1 gave 173 detections in valid time
vs 108 without gaps (+67). With short_gap_s=0.02 (gaps > 20 ms mirrored), the same
probe gives 110 vs 108, and every tested dropout pattern (4-200 ms, every 0.1-2 s) stays
within a few detections of the gap-free run (brainmaze-work/scratch/eeg-spikes/r2/).
Even 1-2 sample gaps move nearby detections by > 20 ms, so the margin applies to every gap.
The price is valid time: with edge_margin_s=0.2 a gap every 0.4 s or less leaves no
valid time at all. That is reported (info['valid_s'], warning) rather than hidden;
lower edge_margin_s only if you accept detections influenced by the fill.
Detector protocol#
An object with a method detect(x, fs) where x is a finite float64 array of shape
(n_channels, n_samples), returning a list of length n_channels; element c is
either
a 1-D integer array of detection sample indices into
x[c], ora list of dicts, each with an integer
'peak_index'(sample index); a'channel'key, if present, is rewritten to the channel index of the caller’s array. Such detectors set the class attributeoutput = 'records'so that empty channels keep the list type (output = 'indices'otherwise).
If the object has accepts_valid = True, detect is called as
detect(x, fs, valid=mask) with a boolean (n_channels, n_samples) mask that is False
on filled samples, so the detector can exclude them from its statistics. If it has
channel_independent = True (its result for one channel never depends on the others),
the wrapper calls it once per channel with a (1, n_samples) array.
Implementations: JancaDetector (also with
preset='ripple'), BarkmeierDetector and
SpikeDetectorHilbert (its detect method).
Example
>>> from brainmaze_eeg.spikes import GapAwareSpikeDetector, JancaDetector
>>> det = GapAwareSpikeDetector(JancaDetector(powerline=60))
>>> spikes, info = det.detect(x, fs, return_info=True) # x: (n_channels, n_samples)
>>> rate_per_min = [len(s) / (v / 60) for s, v in zip(spikes, info['valid_s'])]
- class brainmaze_eeg.spikes.gap_aware.GapAwareSpikeDetector(detector, detector_kwargs=None, *, short_gap_s=0.02, fill='mirror', edge_margin_s=0.2, flat_as_gap_s=0.1, seed=0, fill_kwargs=None)#
Run a spike detector on a signal with gaps (NaN/inf, and constant runs); see the module docstring.
- Parameters:
detector (object or class) – A detector instance following the protocol (e.g.
JancaDetector(),JancaDetector('ripple'),BarkmeierDetector()), or a detector class, instantiated withdetector_kwargs.detector_kwargs (dict, optional) – Constructor arguments when
detectoris a class.short_gap_s (float) – Gaps up to this length (s) are linearly interpolated (default 0.02; see the module docstring for why not longer). Finite, >= 0.
fill ({'mirror', 'pink', 'linear'}) – Fill of longer gaps (default
'mirror'; see the README for the measurements behind this choice). Always passed explicitly to the fill function.edge_margin_s (float) – Detections within this distance (s) of a gap, or inside it, are removed (default 0.2: covers the mirror fill’s influence on peak selection, measured at 0.10-0.16 s from the edge, and
min_distance_s+ half a spike). Finite, >= 0.flat_as_gap_s (float or None) – Runs of exactly equal consecutive samples lasting at least this long (s) are treated as gaps (default 0.1;
Nonedisables). See “Missing data stored as a constant”.seed (None, int, sequence of int, np.random.SeedSequence or np.random.Generator) – Seed of the
'pink'fill noise (default 0: reproducible). Validated at construction.fill_kwargs (dict, optional) – Further options of
brainmaze_eeg.spikes._gaps.fill_gaps()(context_s> 0,taper_s>= 0,beta; all finite). Validated at construction. The wrapper’scontext_sdefault is 10 s (passed explicitly;_gaps.fill_gapsitself defaults to 30 s likebrainmaze_utils.gaps).
- detect(x, fs, return_info=False, return_mask=False)#
Detect spikes in
xwith gaps handled.- Parameters:
x (np.ndarray) –
(n_samples,)or(n_channels, n_samples); NaN/inf (and constant runs of at leastflat_as_gap_s) mark missing data. Not modified; any real dtype (float32 is converted one channel at a time).fs (float) – Sampling frequency (Hz).
return_info (bool) – Also return a dict (see Returns).
return_mask (bool) – Include
info['gap_mask'](bool, shape ofx; True on missing samples).
- Returns:
detections – The detector’s output with in/near-gap detections removed: for 2-D input a list with one entry per channel (int64 sample-index array, or list of dicts), for 1-D input that single entry. Entirely missing channels give an empty entry.
info (dict) – Only with
return_info=True. Per channel:gaps((n_gaps, 2)[start, stop)samples, NaN/inf and flat runs merged),flat_runs(the constant runs treated as gaps),gap_intervals_s(gaps in seconds),all_nan(bool: no usable sample),n_removed(detections dropped in/near gaps),valid_s(seconds in which a detection can be reported: record length minus gaps widened byedge_margin_s; normalise rates by this),valid_fraction; andgap_maskwithreturn_mask=True.
Gap (NaN/inf) helpers used by GapAwareSpikeDetector.
Three steps around an unchanged detector:
find_gaps()– runs of non-finite samples (NaN or +/-inf) of the original signal, as[start, stop)sample indices.fill_gaps()– make the signal finite so filters/FFT/Hilbert can run:gaps up to
max_interp_s(default 0.1 s here; the wrapper passes its ownshort_gap_s, 0.02 s) are linearly interpolated;longer gaps,
method='pink': 1/f noise scaled to the robust amplitude (MAD) of the neighbouringcontext_sseconds, offset to the local level (bridged linearly between the two sides) and cross-faded with a raised-cosine taper oftaper_sseconds into the mirror image of the neighbouring signal at each edge (continuous at the edges);longer gaps,
method='mirror': the neighbouring signal mirrored into the gap from both sides, the two images cross-faded over the whole gap (keeps the local spectrum). Real events next to the gap are copied into it. Detections inside the gap are removed by the wrapper, but the mirrored copies still compete with real maxima in peak selection (find_peaks(distance=...)) just outside the gap: on spike-dense real data the extra/missing detections sit 0.10-0.16 s from the edge, which is why the wrapper’sedge_margin_sdefault is 0.2 s (independent review of PR #67, R7);method='linear': straight line for every gap (not recommended for gaps > ~0.1 s before a background-modelling detector such as Janca).
Each gap draws its noise from its own generator, seeded from
seedand the gap’s position and neighbouring data, so fills are reproducible, independent across channels and independent of the other gaps.mask_in_gaps()/drop_in_gaps()– remove detections inside a gap or withinmargin_sof it. Detections on filled samples are never real, whatever the fill.
Relation to brainmaze_utils.gaps#
The canonical gap helpers of the BrainMaze family are being added to brainmaze-utils
(brainmaze_utils.gaps, PR bnelair/brainmaze-utils#26, to ship in brainmaze-utils 3.0.0).
This module is a deliberately thin, self-contained stand-in with the same names, signatures
and semantics as that module’s final API: find_gaps, gap_intervals,
fill_gaps(x, fs, *, max_interp_s, method, context_s, taper_s, beta, seed, axis, all_nan,
copy), pink_noise, and mask_in_gaps/drop_in_gaps(det, gaps, fs, *, units,
gap_units, margin_s=0.1, end=None) with explicit, separate units for the detections and the
gaps (both required). The one difference: there is no
'spectral' fill here (the default there), so this module’s default method is
'mirror'. GapAwareSpikeDetector always passes
method explicitly, so switching to from brainmaze_utils.gaps import ... is a one-line
change (follow-up, once brainmaze-utils 3.0.0 is released; then benchmark 'spectral' as the wrapper
default).
- brainmaze_eeg.spikes._gaps.drop_in_gaps(det, gaps, fs, *, units, gap_units, margin_s=0.1, end=None)#
detwithout the detections flagged bymask_in_gaps()(original dtype), or(det, end)whenendis given.
- brainmaze_eeg.spikes._gaps.fill_gaps(x, fs, *, max_interp_s=0.1, method='mirror', context_s=30.0, taper_s=0.5, beta=1.0, seed=0, axis=-1, all_nan='keep', copy=True)#
Fill the non-finite gaps of a signal (see module docstring).
- Parameters:
x (array_like) – Signal, 1-D
(n_samples,)or N-D with time alongaxis; every other index is filled independently (a row of an N-D array is filled exactly as the same 1-D signal).fs (float) – Sampling frequency (Hz).
max_interp_s (float) – Gaps up to this length (s) are linearly interpolated (default 0.1 s).
method ({'mirror', 'pink', 'linear'}) – Fill of longer gaps (default
'mirror';brainmaze_utils.gapsadditionally has'spectral', its default).context_s (float) – Seconds of valid data on each side used for amplitude, level and mirroring (default 30, as in
brainmaze_utils.gaps; the wrapper passes 10 explicitly, its tuned value).taper_s (float) –
'pink': raised-cosine cross-fade length (s) into the mirrored signal at each edge, capped at half the gap (default 0.5).beta (float) –
'pink': spectral exponent,P(f) ~ 1/f**beta(default 1).seed (None, int, sequence of int, np.random.SeedSequence or np.random.Generator) – Noise seed (default 0: reproducible). Each gap gets its own stream derived from the seed, the gap’s position and a hash of its neighbouring data, so channels with the same seed still get independent noise.
axis (int) – Time axis (default -1).
all_nan ({'keep', 'zero', 'raise'}) – A channel without any finite sample: left unchanged (
'keep'), set to zeros ('zero') – both with one warning listing the channels – orValueError('raise').copy (bool) –
True(default):xis not modified.False: a writable floating-point ndarray is filled in place and returned.
- Returns:
Same shape as
x; floating input keeps its dtype, anything else becomes float64.- Return type:
np.ndarray
- brainmaze_eeg.spikes._gaps.find_gaps(x)#
Runs of non-finite samples (NaN, +inf, -inf) in a 1-D signal.
- Returns:
[start, stop)sample indices of each run (stopexclusive), in order.- Return type:
np.ndarray, shape (n_gaps, 2), int64
- brainmaze_eeg.spikes._gaps.gap_intervals(x, fs)#
Gaps of a 1-D signal as
[start, stop)times in seconds, shape (n_gaps, 2).
- brainmaze_eeg.spikes._gaps.mask_in_gaps(det, gaps, fs, *, units, gap_units, margin_s=0.1, end=None)#
Boolean mask of detections overlapping a gap widened by
margin_son each side.- Parameters:
det (array_like) – Detection positions, or interval starts, in
units.gaps (array_like, shape (n_gaps, 2)) –
[start, stop)of each gap ingap_units:find_gaps()gives samples,gap_intervals()gives seconds.fs (float) – Sampling frequency (Hz), required.
units ({'seconds', 'samples'}) – Units of
det(andend); required, never inferred.gap_units ({'seconds', 'samples'}) – Units of
gaps; required, never inferred. Integer-typed values with'seconds'raiseValueError(pass whole seconds as floats); with'samples', float values must be integral within 1e-6 (t * fsis fine) orValueErroris raised (a units mix-up must not silently mask nothing).margin_s (float) – Exclusion margin in seconds (default 0.1).
end (array_like, optional) – Interval ends (same shape as
det,end >= det).
- Returns:
True where
[det, end]overlaps[gap_start - margin, gap_stop + margin).- Return type:
np.ndarray of bool
- brainmaze_eeg.spikes._gaps.pink_noise(n, beta=1.0, fmin_bins=1, rng=None)#
Zero-mean, unit-variance
1/f**betanoise of lengthn(spectral synthesis). Frequency bins belowfmin_binsare zeroed.
Filter design#
Shared filter design and verification helpers for the spike detectors.
Every filter used by a detector is designed here, in second-order-section (sos) form,
from parameters given in Hz (never normalised frequencies), and validated against the
Nyquist frequency of the rate at which it will run. The sos form is used throughout
because the transfer-function (b, a) form of narrow or low-cutoff IIR filters becomes
numerically unstable at high sampling rates (e.g. a 3rd-order 47.5-52.5 Hz Butterworth
band-stop has a pole outside the unit circle at 32 kHz in b, a form, and its frequency
response is already wrong by >1e-3 at 10 kHz), which silently turns the output into NaN or
garbage.
All detectors apply their filters forward-backward (scipy.signal.sosfiltfilt()), so
the effective magnitude response is |H(f)|**2 (attenuation in dB doubles, phase is
zero). zero_phase_response_db() returns exactly that and is what the test-suite uses
to verify that every filter does what its parameters say.
- brainmaze_eeg.spikes._filters.butter_bandpass(band, fs, order)#
Butterworth band-pass,
sos.orderis the prototype order (as scipy/MATLAB).Single-pass response is -3 dB at both edges; forward-backward (zero-phase) -6 dB.
- brainmaze_eeg.spikes._filters.butter_bandstop(band, fs, order)#
Butterworth band-stop,
sos; -3 dB single-pass (-6 dB zero-phase) at the edges.
- brainmaze_eeg.spikes._filters.butter_highpass(cutoff, fs, order)#
Butterworth high-pass,
sos; -3 dB single-pass (-6 dB zero-phase) atcutoff.
- brainmaze_eeg.spikes._filters.butter_lowpass(cutoff, fs, order)#
Butterworth low-pass,
sos; -3 dB single-pass (-6 dB zero-phase) atcutoff.
- brainmaze_eeg.spikes._filters.cheby2_highpass(passband_edge, stopband_edge, fs, rp, rs)#
Chebyshev-II high-pass counterpart of
cheby2_lowpass()(stopband < passband).
- brainmaze_eeg.spikes._filters.cheby2_lowpass(passband_edge, stopband_edge, fs, rp, rs)#
Minimum-order Chebyshev-II low-pass meeting: at most
rpdB loss atpassband_edgeand at leastrsdB attenuation fromstopband_edge(single pass).The order and the stop-band edge passed to
scipy.signal.cheby2()are the ones returned byscipy.signal.cheb2ord()(cheby2’sWnis the stop-band edge, not the pass-band edge – passing the pass-band edge shifts the whole filter inwards).
- brainmaze_eeg.spikes._filters.check_band(band, fs, name='band')#
Validate a
(low, high)band in Hz against the Nyquist frequency offs.- Raises:
ValueError – Unless
0 < low < high < fs/2.
- brainmaze_eeg.spikes._filters.iir_notch_sos(freq, fs, pole_radius)#
Second-order notch with zeros on the unit circle at
freqand poles at radiuspole_radius(same angle), as onesosrow. The -3 dB width (single pass) is about(1 - pole_radius) * fs / piHz.
- brainmaze_eeg.spikes._filters.zero_phase_response_db(sos, freqs, fs)#
Effective magnitude response (dB) of
sosapplied forward-backward (scipy.signal.sosfiltfilt()), i.e.20*log10(|H(f)|**2), atfreqs(Hz).Several cascaded filters can be passed as a list of
sosarrays; their responses multiply.