This document describes the interictal epileptiform discharge (IED) detection pipeline: how to run it, what it does, and what it outputs. The method is based on Janca et al. (2015).
Reference: Janca, R., et al. (2015). Detection of interictal epileptiform discharges using signal envelope distribution modelling. Brain Topography, 28(6), 770–778. https://link.springer.com/article/10.1007/s10548-014-0379-1
Requirements: a bidsified session — an _ieeg.edf with a _channels.tsv next to it, under <bids_data_dir>/sub-<sub_id>/ses-<ses_id>/. The _channels.tsv decides which channels are sEEG; channels typed anything else (EKG, TRIG, DC…) are read but not analysed, and are not carried into the output. Run the bidsify pipeline first if a session does not have one.
-
Copy the example config and edit the session entities:
cp configs/example_configs/example_ied_config.json my_config.json # Edit my_config.json: set bids_data_dir, sub_id, ses_id -
Run the pipeline:
python -m ieeg_prep.ied.analysis --config my_config.json
python -m ieeg_prep.ied.analysis \
--bids-data-dir /path/to/bids \
--sub-id EMOP0649 --ses-id ieeg20260310 \
--dataset-description configs/ied_dataset_description.jsonsbatch batch_script/run_ied_analysis.batchconfig.json can contain:
| Key | Required | Description |
|---|---|---|
bids_data_dir |
Yes | Root of the BIDS dataset. Output goes to its derivatives/ directory |
sub_id |
Yes | Subject label, with or without the sub- prefix |
ses_id |
Yes | Session label, with or without the ses- prefix |
task_name |
Only if the session holds more than one task | task- label |
run_id |
Only if the session holds more than one run | run- index, zero-padded or not |
dataset_description |
Only when the derivatives directory does not exist yet | Path to the dataset_description.json to copy in |
derivatives_name |
No (default: ied-detection) |
Directory under derivatives/ to write to |
channels_above_rate_thresh |
No (default: 6.5) | Spike rate threshold (spikes/min) for the channels_above_rate boolean |
interictal_config |
No | Object with pipeline parameters (see below) |
metadata |
No | Extra keys merged into _metadata.json, for anything the pipeline cannot derive |
notch_freq defaults to the recording's own PowerLineFrequency from its _ieeg.json, not to 60 Hz; setting it in the config or on the command line still wins.
Any option can be overridden from the command line. Example config:
{
"bids_data_dir": "/path/to/bids",
"sub_id": "EMOP0649",
"ses_id": "ieeg20260310",
"task_name": "pilotbattery",
"run_id": "01",
"derivatives_name": "ied-detection",
"dataset_description": "configs/ied_dataset_description.json",
"channels_above_rate_thresh": 6.5,
"interictal_config": {
"sfreq_target": 200,
"bandpass_low": 10,
"bandpass_high": 60,
"notch_method": "custom_biquad",
"notch_freq": 60,
"win_coeff": 5,
"overlap_coeff": 4,
"threshold_k": 3.65,
"polyspike_union_sec": 0.12,
"trim_edges_sec": 1
}
}| Parameter | Default | Description |
|---|---|---|
sfreq_target |
200 | Resampling target (Hz) |
bandpass_low |
10 | Bandpass low cutoff (Hz) |
bandpass_high |
60 | Bandpass high cutoff (Hz) |
bandpass_order |
8 | IIR filter order |
bandpass_ftype |
"cheby2" |
IIR type ("cheby2", etc.) |
bandpass_rs |
60 | Cheby2 stopband ripple (dB) |
notch_freq |
60 | Line-noise notch frequency (Hz) |
notch_radius |
0.985 | Pole radius for custom biquad notch |
notch_method |
"custom_biquad" |
"custom_biquad" or "mne" |
win_coeff |
5 | Envelope segment window (seconds) |
overlap_coeff |
4 | Envelope segment overlap (seconds) |
smooth_window_len |
5 | Moving-average length for μ/σ smoothing |
threshold_k |
3.65 | Threshold multiplier: k × (mode + median) |
polyspike_union_sec |
0.12 | Merge spikes within this window (seconds) |
trim_edges_sec |
1 | Drop first/last N seconds |
Results are written as a BIDS derivatives dataset:
<bids_data_dir>/derivatives/ied-detection/
dataset_description.json
sub-EMOP0004/ses-litieeg20260213a/ieeg/
sub-EMOP0004_ses-litieeg20260213a_task-pilotbattery_run-01_desc-ied_events.tsv
..._desc-ied_events.json
..._desc-ied_channels.tsv
..._desc-ied_ieeg.fif
..._desc-ied_ieeg.json
..._desc-ied_metadata.json
..._desc-ied_results.npz
Every file carries the source recording's entities plus a desc-ied label, so a derivative can always be traced back to what produced it. Re-running overwrites in place.
The derivatives/ied-detection/ directory is created on first use, but only if a dataset_description.json is supplied to copy into it — a derivatives directory without one is not a BIDS dataset, and its contents (dataset name, generating software) cannot be worked out from the data. The run stops with an error if one is neither already present nor provided. See configs/ied_dataset_description.json.
One row per detection, ordered by onset and then by channel:
| Column | Description |
|---|---|
onset |
Seconds from the start of the source recording |
duration |
Always 0 — detections are point events |
trial_type |
Always ied |
channel |
sEEG channel the detection is on |
_desc-ied_events.json is the accompanying column dictionary.
The source recording's _channels.tsv for the sEEG channels only, in pipeline order, with two changes:
sampling_frequency,low_cutoff,high_cutoffandnotchare rewritten to describe the.fifnext to it, not the source EDF.- Three columns are appended:
ied_count,ied_rate(spikes/min), andabove_ied_rate_threshold(true/falseagainstchannels_above_rate_thresh).
The resampled, bandpassed and notched signal the detector actually ran on, sEEG channels only. In pipeline terms:
Raw EDF → resample / bandpass / notch → THIS FILE → envelope → lognormal μ/σ
→ threshold → candidates → polyspike merge → detections (_events.tsv)
So it is the detector's input, not its output — the last point at which the data is still a continuous voltage trace. Load it with mne.io.read_raw_fif to eyeball a detection against the signal without recomputing the filtering.
Two caveats. It is bandpassed to 10–60 Hz, so it is unusable for anything outside IED work. And onsets in _events.tsv sit exactly on this file's 200 Hz sample grid, so raw.time_as_index(onset) is exact here, but only ±2.5 ms against the original 2048 Hz EDF.
The BIDS sidecar for that .fif — required, since BIDS expects an _ieeg.json beside every iEEG data file. Descriptive fields (TaskName, PowerLineFrequency, iEEGReference, Manufacturer, …) are carried over from the source recording's own sidecar; everything the filtering changed is recomputed:
SamplingFrequencyandRecordingDurationdescribe the.fif, not the source.SEEGChannelCountis the channels actually kept; the other count fields are explicitly0, since dropped is different from unknown.SoftwareFiltersrecords the bandpass and notch as a structured object. The source usually says"n/a"; this is the only machine-readable record of how the.fifwas produced.
NumPy archive (load with np.load(...)):
| Key | Shape / type | Description |
|---|---|---|
spike_markers |
(n_channels, n_samples) bool |
1 where a spike was detected |
channel_spike_counts |
(n_channels,) int |
Total spikes per channel |
channel_spike_rates |
(n_channels,) float |
Spikes per minute per channel |
channels_above_rate |
(n_channels,) bool |
Channels with rate > channels_above_rate_thresh |
ch_names |
1D array of str | sEEG channel names (same order as above) |
sfreq |
scalar | Sampling frequency (Hz) after resampling |
What the detection run found, and nothing else:
| Key | Description |
|---|---|
Description |
Fixed text naming what this dataset is |
Sources |
bids:: URIs of the EDF and sidecars that were read |
RecordingDurationMinutes |
Duration the rates are computed over |
SEEGChannelCount |
Channels analysed |
IEDCount |
Total detections across all channels |
IEDRateThreshold |
Rate in spikes/min a channel is flagged above |
ChannelsAboveIEDRateThreshold |
How many channels exceeded it |
Parameters |
Full interictal_config used for the run |
Deliberately small. Anything another file in the run already holds is not repeated: per-channel counts and rates are columns of _channels.tsv, channel names are its name column, the arrays are in the .npz, and the recording's own properties are in _ieeg.json. The two JSON files divide by subject — _ieeg.json describes the recording, _metadata.json describes the detection — and state nothing in common.
Anything the pipeline cannot derive goes in the config's metadata object and is merged in last, so it can also override any key above.
- Locate the session from its BIDS entities, load the EDF and set channel types from its
_channels.tsv(bidsify.query.apply_bids_channel_types). - Resample to
sfreq_target(default 200 Hz). - Bandpass 10–60 Hz (Cheby2 IIR, order 8) on sEEG only.
- Notch 60 Hz line noise (custom biquad or MNE IIR).
- Hilbert envelope of the filtered sEEG.
- Lognormal fit per sliding segment (window
win_coeffs, overlapoverlap_coeffs) → μ, σ per segment. - Smooth μ and σ along segments (moving average).
- Interpolate μ and σ to every sample (cubic spline).
- Threshold = k × (lognormal mode + lognormal median).
- Detection: envelope > threshold → crossings → one peak per crossing → merge nearby spikes (polyspike union) → one local max per merged event → trim first/last N seconds.
- Rates: spike count and rate per channel; boolean
channels_above_rateusing the chosen rate threshold.
import json
from pathlib import Path
import mne
import numpy as np
import pandas as pd
bids = Path("/path/to/bids")
sub, ses = "sub-EMOP0004", "ses-litieeg20260213a"
out = bids / "derivatives/ied-detection" / sub / ses / "ieeg"
stem = f"{sub}_{ses}_task-pilotbattery_run-01_desc-ied"
# Per-channel rates, and which channels are above threshold. pandas may parse
# the true/false column as bool or leave it as text, so normalise before testing.
channels = pd.read_csv(out / f"{stem}_channels.tsv", sep="\t")
above = channels["above_ied_rate_threshold"].astype(str).str.lower() == "true"
high_rate = channels.loc[above, "name"]
# Individual detections
events = pd.read_csv(out / f"{stem}_events.tsv", sep="\t")
print(events.groupby("channel").size())
# The filtered signal the detector ran on
raw = mne.io.read_raw_fif(out / f"{stem}_ieeg.fif")
# Or the arrays, in the layout earlier versions wrote
data = np.load(out / f"{stem}_results.npz", allow_pickle=True)
spike_markers = data["spike_markers"] # (n_channels, n_samples) bool
with open(out / f"{stem}_metadata.json") as f:
meta = json.load(f)
print(meta["IEDCount"], meta["RecordingDurationMinutes"], meta["IEDRateThreshold"])python -m ieeg_prep.ied.analysis [OPTIONS]
Required (if not in config): --bids-data-dir, --sub-id, --ses-id.
Common options:
--config PATH– load options from JSON--bids-data-dir PATH– root of the BIDS dataset--sub-id LABEL,--ses-id LABEL– which session to run on--task-name LABEL,--run-id INDEX– only needed when the session holds more than one recording--dataset-description PATH–dataset_description.jsonto install if the derivatives directory is new--derivatives-name NAME– directory underderivatives/(default:ied-detection)--channels-above-rate-thresh FLOAT– rate threshold for boolean mask (default: 6.5)--notch-method {custom_biquad,mne}– notch filter method--sfreq-target,--bandpass-low,--bandpass-high, etc. – override config
Run with --help for the full list.