Amplifiers, sampling and recording: native formats, headers, events, timing, and a photodiode-measured trigger offset

nb-0-3-first-load Level 0 · Read the Raw Signal ~3 min Used in L0.3 · Amplifiers, sampling and recording

Downloads from ds-eegbci, ds-lemon when you run it.

Download the notebook (.ipynb) Outputs below are the ones stored when it was executed — you do not need to run anything to read it.

nb-0-3-first-load · Amplifiers, sampling and recording (L0.3)

Lesson L0.3 Amplifiers, sampling and recording · Level 0 · Status draft — drafted for expert review; every scientific statement below is a draft and uncertain points carry TODO(confirm).

What you will do

  1. Load each spine dataset in its native format — EDF+ (ds-eegbci), BrainVision (ds-lemon), and the ds-erpcore stub — print info, read the acquisition metadata the header actually carries (sampling rate, resolution in µV per digital step, hardware filters), count the events, and check the timing of the events and of the samples for gaps.
  2. Round-trip a recording through MNE's own format, FIF.
  3. Exercise: measure the stimulus–trigger offset of a recording from its photodiode channel. No spine or directory dataset documents a photodiode channel (TODO(confirm)), so the recording is synthetic: real ds-eegbci EEG plus a simulated photodiode channel and a trigger channel with a planted offset, generated deterministically from a fixed seed. The notebook says so wherever the recording appears.

Data

  • ds-eegbci — EEGMMIDB, Schalk et al. (2004), PhysioNet v1.0.0, DOI 10.13026/C28G6P, ODC-By 1.0. From the catalog: EDF+ files with a .edf.event annotation file, 64 channels (10-10, Sharbrough labels), 160 Hz, no hardware filters, 60 Hz mains; R01 is the ~1-min eyes-open baseline, R03 a ~2-min motor-execution run with events T0/T1/T2. Subject S001, runs R01 and R03 (two files, 3.6 MB).
  • ds-lemon — LEMON, Babayan et al. (2019), Scientific Data, DOI 10.1038/sdata.2018.308; CC BY 4.0 per the data descriptor (the NITRC page references a PDDL-style dedication; exact terms TODO(confirm)). From the catalog: raw BrainVision files, 62 channels (61 EEG + VEOG) referenced to FCz, 2500 Hz, 0.015–1000 Hz, hardware notch off, 50 Hz mains, 16 alternating one-minute eyes-closed/eyes-open blocks; distributed per subject (~250 MB raw). Subject sub-010002 — only the first 5 minutes of its data file are fetched (about 93 MB) from the public S3 mirror; see section 2 for how and why.
  • ds-erpcore — not in the site's dataset catalog (TODO(confirm), spec §13 item 22): the loader raises, and section 3 shows the message.
In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import subprocess
import sys
import warnings
from pathlib import Path

# 1. Dependencies are pinned in notebooks/requirements.txt.  Nothing is installed
#    when the pinned stack is already present (local runs, CI); a fresh Colab or
#    Binder kernel installs it once.  On Colab, run from a clone of the repository
#    so that notebooks/_shared/ is available (repository URL: TODO(confirm), spec
#    section 13 item 3).
_needed = ("mne", "scipy", "matplotlib", "pooch")
_missing = [p for p in _needed if importlib.util.find_spec(p) is None]
if _missing:
    _req = next((d / "requirements.txt" for d in (Path.cwd(), *Path.cwd().parents)
                 if (d / "requirements.txt").exists()), None)
    _cmd = [sys.executable, "-m", "pip", "install", "-q"]
    _cmd += ["-r", str(_req)] if _req else ["mne==1.10.2", "moabb==1.7.2", "pooch>=1.8", "edfio>=0.4"]
    subprocess.check_call(_cmd)

# 2. Shared helpers (notebooks/_shared/helpers.py), located relative to the working
#    directory -- notebooks/<level>/ or notebooks/ -- never through an absolute path.
_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L0/ (or notebooks/) so that _shared/helpers.py is found")
sys.path.insert(0, str(_shared))
import helpers

# 3. Plotting: Jupyter's default inline backend renders static PNGs through Agg
#    (no windows, nothing blocks); outside Jupyter the helpers select Agg.  Every
#    MNE figure is requested with show=False, and plt.show() renders each cell's
#    figures in place.
import matplotlib.pyplot as plt
import numpy as np
import mne

mne.viz.set_browser_backend("matplotlib", verbose=False)
mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72
print(f"MNE {mne.__version__}; helpers imported from notebooks/_shared; "
      "downloads go to MNE's default data directory unless EEG_COURSE_DATA is set")
MNE 1.10.2; helpers imported from notebooks/_shared; downloads go to MNE's default data directory unless EEG_COURSE_DATA is set

1. EDF+ — ds-eegbci S001 R01 and R03

mne.io.read_raw_edf reads the file; raw.info is what MNE kept of its header. The EDF header itself stores, per signal, a physical range and a digital range: their ratio is the resolution in µV per digital step (the "µV per LSB" of the lesson), and a prefiltering text field where hardware filters are recorded if the software wrote them. edfio reads those fields directly; the cell skips that part if it is not installed.

In [2]:
from mne.datasets import eegbci

DATASET, SUBJECT = "ds-eegbci", "S001"
root = helpers.data_dir()                                              # None = MNE's default data directory
paths = eegbci.load_data(1, [1, 3], update_path=False, verbose=False, **({"path": str(root)} if root else {}))
raw_r01 = mne.io.read_raw_edf(paths[0], preload=True, verbose=False)
raw_r03 = mne.io.read_raw_edf(paths[1], preload=True, verbose=False)
for r in (raw_r01, raw_r03):
    eegbci.standardize(r)                                              # Sharbrough labels -> 10-10 names (nb-0-2)
    r.set_montage("standard_1005", on_missing="ignore", verbose=False)
print("R01:", helpers.EEGBCI_RUNS["R01"], "|", Path(paths[0]).name)
print(raw_r01.info)
R01: Baseline: eyes open (~1 min) | S001R01.edf
<Info | 9 non-empty values
 bads: []
 ch_names: FC5, FC3, FC1, FCz, FC2, FC4, FC6, C5, C3, C1, Cz, C2, C4, C6, ...
 chs: 64 EEG
 custom_ref_applied: False
 dig: 67 items (3 Cardinal, 64 EEG)
 highpass: 0.0 Hz
 lowpass: 80.0 Hz
 meas_date: 2009-08-12 16:15:00 UTC
 nchan: 64
 projs: []
 sfreq: 160.0 Hz
 subject_info: <subject_info | his_id: X, sex: 0, last_name: X>
>
In [3]:
try:
    import edfio
except ImportError:
    edfio = None
    print("edfio is not installed (pip install edfio): skipping the EDF header fields")
if edfio is not None:
    edf = edfio.read_edf(paths[0])
    print(f"EDF header of {Path(paths[0]).name}: version {getattr(edf, 'version', '?')}, start {edf.startdate} {edf.starttime}, "
          f"{edf.num_data_records} data records of {edf.data_record_duration} s, {edf.num_signals} signals")
    print(f"{'label':8s} {'unit':5s} {'phys min':>9s} {'phys max':>9s} {'dig min':>8s} {'dig max':>7s} {'uV/step':>8s} {'Hz':>5s}  prefiltering")
    for s in list(edf.signals)[:4]:
        step = (s.physical_max - s.physical_min) / (s.digital_max - s.digital_min)
        print(f"{s.label:8s} {s.physical_dimension:5s} {s.physical_min:9.1f} {s.physical_max:9.1f} {s.digital_min:8d} {s.digital_max:7d} "
              f"{step:8.4f} {s.sampling_frequency:5.0f}  {s.prefiltering!r}")
    print("... (one row per signal); an empty prefiltering field is why MNE reports high-pass 0 Hz and low-pass = Nyquist,")
    print("    consistent with the catalog's 'no hardware filters' for this dataset")
EDF header of S001R01.edf: version 0, start 2009-08-12 16:15:00, 61 data records of 1.0 s, 64 signals
label    unit   phys min  phys max  dig min dig max  uV/step    Hz  prefiltering
Fc5.     uV      -8092.0    8092.0    -8092    8092   1.0000   160  'HP:0Hz LP:0Hz N:0Hz'
Fc3.     uV      -8092.0    8092.0    -8092    8092   1.0000   160  'HP:0Hz LP:0Hz N:0Hz'
Fc1.     uV      -8092.0    8092.0    -8092    8092   1.0000   160  'HP:0Hz LP:0Hz N:0Hz'
Fcz.     uV      -8092.0    8092.0    -8092    8092   1.0000   160  'HP:0Hz LP:0Hz N:0Hz'
... (one row per signal); an empty prefiltering field is why MNE reports high-pass 0 Hz and low-pass = Nyquist,
    consistent with the catalog's 'no hardware filters' for this dataset

Events and timing

Events in EDF+ live in an annotation channel (ds-eegbci ships them as .edf.event files that MNE folds into raw.annotations). R01 carries a single T0 span; R03 alternates rest (T0) and movement cues (T1, T2). helpers.event_timing_check counts them and checks two things you cannot see in a table: whether consecutive spans overlap or leave gaps, and whether the onset-to-onset intervals are regular. Sample arithmetic is the other timing check: the number of samples divided by the sampling rate is the duration the file claims; a dropped-sample problem shows up as events (or the recording's end) landing earlier than the wall clock says.

In [4]:
events_r03, event_id_r03 = mne.events_from_annotations(raw_r03, verbose=False)
print("R03:", helpers.EEGBCI_RUNS["R03"])
print("event_id:", {str(k): v for k, v in event_id_r03.items()}, "| events array shape:", events_r03.shape, "(sample, previous value, code)")
for label, r in (("R01", raw_r01), ("R03", raw_r03)):
    tc = helpers.event_timing_check(r)
    line = (f"{label}: {tc['n_samples']} samples / {tc['sfreq_hz']:g} Hz = {tc['duration_s']:.2f} s; "
            f"{tc['n_annotations']} annotations {tc['counts']}")
    if "interval_s" in tc:
        line += (f"; onset intervals median {tc['interval_s']['median']:.2f} s (min {tc['interval_s']['min']:.2f}, "
                 f"max {tc['interval_s']['max']:.2f}); overlapping spans {tc.get('n_overlapping_spans', 0)}, "
                 f"gaps between spans {tc.get('n_gaps_between_spans', 0)}, long gaps {tc['n_long_gaps']}")
    print(line)

fig, ax = plt.subplots(figsize=(12, 2.2))
colours = {"T0": "0.75", "T1": "tab:blue", "T2": "tab:orange"}
for onset, dur, desc in zip(raw_r03.annotations.onset, raw_r03.annotations.duration, raw_r03.annotations.description):
    ax.barh(0, dur, left=onset, height=0.6, color=colours.get(desc, "k"), edgecolor="w", lw=0.5)
ax.set(yticks=[], xlabel="Time (s)", xlim=(0, raw_r03.times[-1]),
       title="S001 R03: T0 (rest, grey), T1 (blue) and T2 (orange) spans tile the run without gaps or overlaps")
plt.show()   # render the static figure(s) of this cell inline
R03: Motor execution: open/close left or right fist (~2 min)
event_id: {'T0': 1, 'T1': 2, 'T2': 3} | events array shape: (30, 3) (sample, previous value, code)
R01: 9760 samples / 160 Hz = 61.00 s; 1 annotations {'T0': 1}
R03: 20000 samples / 160 Hz = 125.00 s; 30 annotations {'T0': 15, 'T2': 7, 'T1': 8}; onset intervals median 4.20 s (min 4.10, max 4.20); overlapping spans 0, gaps between spans 0, long gaps 0
Figure 1 of notebook nb-0-3-first-load, an output plot. The text around it states what it shows and the units of every axis.

2. BrainVision — ds-lemon sub-010002

A BrainVision recording is three files: a text header (.vhdr: channels, sampling interval, binary format, resolution per channel), a text marker file (.vmrk: every event with its sample position) and the binary data (.eeg). The header is worth reading with your own eyes once — it is the only place where the amplifier's resolution and the recorder's filter settings are written down.

How the file is fetched, and the guard. The catalog lists the public S3 mirror of the dataset (fcp-indi/data/Projects/INDI/MPI-LEMON). A per-subject archive named by the subject's BIDS-style id (Compressed_tar/…/EEG_Raw_BIDS_ID/sub-010002.tar.gz) does not exist there (HTTP 404 on 2026-09-17): the raw-EEG tree names its folders by a second id scheme (the mirror's name_match.csv maps sub-010002 to sub-032301), and the three BrainVision files of each subject sit uncompressed in that tree. helpers.fetch_lemon_raw therefore resolves the id, checks each file with a HEAD request, verifies the free disk space (it skips below 700 MB), and downloads the header, the markers and — because the data file is a 302 MB multiplexed INT_16 stream of 17 minutes — only its first 5 minutes through an HTTP Range request (LEMON_MAX_MINUTES; set it to None for the whole recording). MNE reads the shortened file as a 5-minute recording and drops the markers beyond its end. If anything cannot be fetched, the helper prints why and this section is skipped.

In [5]:
LEMON_SUBJECT, LEMON_MAX_MINUTES = "sub-010002", 5
print(f"free disk on the download volume: {helpers.free_disk_mb(helpers.download_root()):.0f} MB")
vhdr = helpers.fetch_lemon_raw(LEMON_SUBJECT, max_minutes=LEMON_MAX_MINUTES)
LEMON_OK = vhdr is not None
if not LEMON_OK:
    print("ds-lemon: skipped in this run (see the reason above); sections 2 and the LEMON lines of section 5 are omitted")
else:
    text = vhdr.read_text(encoding="utf-8", errors="replace").splitlines()
    print(f"--- {vhdr.name}: [Common Infos], [Binary Infos] and the first channel entries")
    for line in text[:16] + [l for l in text if l.startswith(("Ch1=", "Ch2=", "Ch17=", "Ch25="))]:
        print("   ", line)
    vmrk = vhdr.with_suffix(".vmrk")
    marks = [l for l in vmrk.read_text(encoding="utf-8", errors="replace").splitlines() if l.startswith("Mk")]
    print(f"--- {vmrk.name}: {len(marks)} markers in the file (the whole 17-min recording); the first eight:")
    for line in marks[:8]:
        print("   ", line)
    info_path = helpers.download_root() / "MNE-lemon-data" / "EEG_Info"
    if info_path.exists() or helpers.download_url(helpers.LEMON_EEG_INFO, info_path, quiet=True):
        coding = [l.strip() for l in info_path.read_text(errors="replace").splitlines() if l.strip().startswith(("S200", "S210"))]
        print("--- marker coding from the dataset's EEG_Info file:", "; ".join(coding))
free disk on the download volume: 3918 MB
ds-lemon sub-010002: using the cached BrainVision files (sub-010002.eeg: 93 MB, first 5 min)
--- sub-010002.vhdr: [Common Infos], [Binary Infos] and the first channel entries
    Brain Vision Data Exchange Header File Version 1.0
    ; Data created by the Vision Recorder
    
    [Common Infos]
    Codepage=UTF-8
    DataFile=sub-010002.eeg
    MarkerFile=sub-010002.vmrk
    DataFormat=BINARY
    ; Data orientation: MULTIPLEXED=ch1,pt1, ch2,pt1 ...
    DataOrientation=MULTIPLEXED
    NumberOfChannels=62
    ; Sampling interval in microseconds
    SamplingInterval=400
    
    [Binary Infos]
    BinaryFormat=INT_16
    Ch1=Fp1,,0.1,µV
    Ch2=Fp2,,0.1,µV
    Ch17=VEOG,,0.1,µV
    Ch25=Pz,,0.1,µV
--- sub-010002.vmrk: 499 markers in the file (the whole 17-min recording); the first eight:
    Mk1=New Segment,,1,1,0,20131111134740346211
    Mk2=Comment,no USB Connection to actiCAP,1,1,0
    Mk3=Stimulus,S  1,9980,1,0
    Mk4=Stimulus,S  1,15939,1,0
    Mk5=Stimulus,S210,15963,1,0
    Mk6=Stimulus,S210,20963,1,0
    Mk7=Stimulus,S210,25963,1,0
    Mk8=Stimulus,S210,30963,1,0
--- marker coding from the dataset's EEG_Info file: S200 - eyes open rest; S210 - eyes closed rest
In [6]:
if LEMON_OK:
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        raw_lemon = mne.io.read_raw_brainvision(vhdr, preload=False, verbose=False)
    for c in caught:
        if "annotation" in str(c.message).lower():
            print("MNE says:", str(c.message)[:120])
    raw_lemon.set_channel_types({"VEOG": "eog"})                      # the header types every line as EEG; VEOG is not
    raw_lemon.set_montage("standard_1005", on_missing="ignore", verbose=False)
    print(raw_lemon.info)
    types = raw_lemon.get_channel_types()
    print(f"channel types: {{t: types.count(t) for t in sorted(set(types))}}; FCz (the documented online reference) stored as a channel? "
          f"{'FCz' in raw_lemon.ch_names}")
    events_lemon, event_id_lemon = mne.events_from_annotations(raw_lemon, verbose=False)
    tc = helpers.event_timing_check(raw_lemon)
    print(f"{tc['n_samples']} samples / {tc['sfreq_hz']:g} Hz = {tc['duration_s']:.1f} s in the fetched prefix; "
          f"{tc['n_annotations']} markers: {tc['counts']}")
    print(f"onset intervals: median {tc['interval_s']['median']:.3f} s, min {tc['interval_s']['min']:.4f} s, "
          f"max {tc['interval_s']['max']:.3f} s; intervals > 2 x median: {tc['n_long_gaps']}")
    print("measurement start (from the 'New Segment' marker):", raw_lemon.info["meas_date"])
MNE says: Omitted 348 annotation(s) that were outside data range.
<Info | 8 non-empty values
 bads: []
 ch_names: Fp1, Fp2, F7, F3, Fz, F4, F8, FC5, FC1, FC2, FC6, T7, C3, Cz, ...
 chs: 61 EEG, 1 EOG
 custom_ref_applied: False
 dig: 64 items (3 Cardinal, 61 EEG)
 highpass: 0.0 Hz
 lowpass: 1000.0 Hz
 meas_date: 2013-11-11 13:47:40 UTC
 nchan: 62
 projs: []
 sfreq: 2500.0 Hz
>
channel types: {t: types.count(t) for t in sorted(set(types))}; FCz (the documented online reference) stored as a channel? False
750000 samples / 2500 Hz = 300.0 s in the fetched prefix; 150 markers: {'Comment/no USB Connection to actiCAP': 1, 'Stimulus/S  1': 6, 'Stimulus/S210': 83, 'Stimulus/S200': 60}
onset intervals: median 2.000 s, min 0.0088 s, max 4.353 s; intervals > 2 x median: 1
measurement start (from the 'New Segment' marker): 2013-11-11 13:47:40.346211+00:00

The markers tell the structure of the session: S200 (eyes open) and S210 (eyes closed) are written every 2 s throughout a block, so the condition of each minute can be read from the marker stream — the alternation the catalog describes. The single New Segment marker at sample 1 means the recorder never paused: one continuous segment, no gap to worry about. The Comment marker is the recorder's own note about the cap connection; it is data, not an event.

In [7]:
if LEMON_OK:
    onsets, descs = raw_lemon.annotations.onset, raw_lemon.annotations.description
    fig, ax = plt.subplots(figsize=(12, 2.4))
    for code, y, colour, label in (("Stimulus/S210", 0, "tab:blue", "S210 eyes closed"), ("Stimulus/S200", 1, "tab:orange", "S200 eyes open")):
        t = onsets[descs == code]
        ax.plot(t, np.full_like(t, y), "|", ms=14, color=colour, label=f"{label} ({len(t)} markers)")
    other = onsets[~np.isin(descs, ["Stimulus/S210", "Stimulus/S200"])]
    ax.plot(other, np.full_like(other, 0.5), "x", color="k", label=f"other markers ({len(other)})")
    ax.set(yticks=[0, 0.5, 1], yticklabels=["EC", "other", "EO"], xlabel="Time (s)", xlim=(0, raw_lemon.times[-1]),
           title=f"ds-lemon {LEMON_SUBJECT}, first {LEMON_MAX_MINUTES} min: a marker every 2 s names the condition of each one-minute block")
    ax.legend(fontsize=8, loc="center right")
    plt.show()   # render the static figure(s) of this cell inline
Figure 2 of notebook nb-0-3-first-load, an output plot. The text around it states what it shows and the units of every axis.

3. ds-erpcore — not held (TODO(confirm))

ERP CORE is the third spine dataset, but it is not in the site's dataset catalog: its license and per-subject downloadability are TODO(confirm) (spec §13 item 22), so no file is fetched and no fact about it is stated here. The loader raises a clear message instead of guessing; the cell shows it.

In [8]:
try:
    helpers.load_spine("ds-erpcore", 1)
except NotImplementedError as e:
    print("NotImplementedError:", e)
NotImplementedError: ds-erpcore is not in the site's dataset catalog; per-subject OSF download is TODO(confirm) (§13 item 22). Use ds-brain-invaders for the P300 demonstration meanwhile.

4. Exercise: the stimulus–trigger offset, measured from a photodiode channel

The recording is synthetic. Its EEG is real (ds-eegbci S001 R01, resampled from 160 to 1000 Hz so that the auxiliary channels get millisecond resolution); its PHOTODIODE (misc) and TRIG (stim) channels are simulated: 36 stimuli, a 10-ms TTL pulse on TRIG when the software issues each stimulus, and a 200-ms light step on PHOTODIODE when the stimulus actually appears — a fixed number of milliseconds later, plus a per-trial jitter of up to ±1 ms, both fixed by the seed. The measurement recipe is the one you would use on a real recording: find the trigger onsets, find the light onsets, subtract, and look at the distribution across trials rather than at one trial.

In [9]:
SEED = 20260917          # fixed: the recording, and therefore the answer, is reproducible
OFFSET_MS = 33.0         # planted: each stimulus appears this long after its trigger (plus the jitter below)
JITTER_MS = 1.0          # per-trial uniform jitter in [-1, +1] ms (a refresh-timing wobble)
N_STIM = 36
SFREQ_SYN = 1000.0


def make_synthetic_recording(raw_eeg, *, seed, offset_ms, jitter_ms, n_stim, sfreq, stim_s=0.2, first_s=2.0, isi_s=(1.0, 1.6)):
    """Real EEG (resampled to `sfreq`) + a simulated photodiode (misc) and trigger (stim) channel with a planted offset."""
    rng = np.random.default_rng(seed)
    rec = raw_eeg.copy().resample(sfreq, verbose=False)
    sf, n = rec.info["sfreq"], rec.n_times
    t_trig = first_s + np.cumsum(np.r_[0.0, rng.uniform(*isi_s, size=n_stim - 1)])
    t_trig = np.round(t_trig * sf) / sf                                   # the recorder samples the TTL line on its grid
    offsets = offset_ms + rng.uniform(-jitter_ms, jitter_ms, size=n_stim)
    t_stim = np.round((t_trig + offsets / 1000.0) * sf) / sf
    assert t_stim[-1] + stim_s + 0.1 < rec.times[-1], "the stimulus schedule must fit inside the recording"
    trig = np.zeros(n)
    diode = rng.normal(0.0, 0.02, n)                                       # dark level with sensor noise (arbitrary units)
    k, tail = int(stim_s * sf), int(0.02 * sf)
    tau = 0.001 * sf                                                       # 1-ms rise time constant
    for a, b in zip(t_trig, t_stim):
        i, j = int(round(a * sf)), int(round(b * sf))
        trig[i:i + int(0.01 * sf)] = 1.0                                  # 10-ms TTL pulse
        diode[j:j + k] += 1.0 - np.exp(-(np.arange(k) + 1) / tau)         # light on: exponential rise to 1
        diode[j + k:j + k + tail] += np.exp(-(np.arange(tail) + 1) / tau) # light off
    aux = mne.io.RawArray(np.vstack([diode, trig]), mne.create_info(["PHOTODIODE", "TRIG"], sf, ["misc", "stim"]), verbose=False)
    rec.add_channels([aux], force_update_info=True)
    rec.info["description"] = (f"SYNTHETIC recording for the L0.3 exercise: ds-eegbci S001 R01 EEG resampled to {sf:g} Hz + simulated "
                               f"PHOTODIODE and TRIG channels; seed {seed}; planted stimulus-trigger offset {offset_ms:g} ms +/- {jitter_ms:g} ms jitter")
    return rec, t_trig, t_stim, offsets


syn, t_trig, t_stim, planted = make_synthetic_recording(raw_r01, seed=SEED, offset_ms=OFFSET_MS, jitter_ms=JITTER_MS,
                                                        n_stim=N_STIM, sfreq=SFREQ_SYN)
print(syn)
print("channel types:", {t: syn.get_channel_types().count(t) for t in sorted(set(syn.get_channel_types()))})
print(f"{N_STIM} stimuli between {t_trig[0]:.2f} s and {t_trig[-1]:.2f} s; planted offsets: mean {planted.mean():.2f} ms, "
      f"range {planted.min():.2f}-{planted.max():.2f} ms (the exact values are hidden from the measurement below)")
<RawEDF | S001R01.edf, 66 x 61000 (61.0 s), ~30.8 MiB, data loaded>
channel types: {'eeg': 64, 'misc': 1, 'stim': 1}
36 stimuli between 2.00 s and 46.39 s; planted offsets: mean 33.06 ms, range 32.00-33.96 ms (the exact values are hidden from the measurement below)

FIF round trip

MNE's own format keeps everything in info — channel types, the montage, the description, the annotations — which EDF cannot (EDF has no channel type, no positions, and the export would have to drop the stim channel). The synthetic recording is saved as FIF into a temporary folder that is deleted at the end of the cell, then read back.

In [10]:
import tempfile

with tempfile.TemporaryDirectory() as tmp:
    fif = Path(tmp) / "synthetic-eegbci-S001R01-photodiode_raw.fif"
    syn.save(fif, overwrite=True, verbose=False)
    back = mne.io.read_raw_fif(fif, preload=False, verbose=False)
    print(f"{fif.name}: {fif.stat().st_size / 1e6:.1f} MB on disk; read back -> {back}")
    print("types preserved:", {t: back.get_channel_types().count(t) for t in sorted(set(back.get_channel_types()))},
          "| montage:", back.get_montage() is not None, "| annotations:", back.annotations,
          "| description starts:", back.info["description"][:40], "...")
synthetic-eegbci-S001R01-photodiode_raw.fif: 16.1 MB on disk; read back -> <Raw | synthetic-eegbci-S001R01-photodiode_raw.fif, 66 x 61000 (61.0 s), ~81 KiB, data not loaded>
types preserved: {'eeg': 64, 'misc': 1, 'stim': 1} | montage: True | annotations: <Annotations | 1 segment: T0 (1)> | description starts: SYNTHETIC recording for the L0.3 exercis ...
In [11]:
sf = syn.info["sfreq"]
events_syn = mne.find_events(syn, stim_channel="TRIG", verbose=False)         # rising edges of the TTL line
trig_samples = events_syn[:, 0]
diode = syn.get_data(picks="PHOTODIODE")[0]
baseline_sd = diode[: int(1.5 * sf)].std()                                    # dark-level noise before the first stimulus
threshold = 5 * baseline_sd
diode_samples = np.array([i + int(np.argmax(diode[i:i + int(0.5 * sf)] > threshold)) for i in trig_samples])
measured_ms = (diode_samples - trig_samples) / sf * 1000.0
print(f"{len(events_syn)} triggers found; photodiode onset = first sample above 5 x the dark-level noise ({threshold:.3f} a.u.)")
print(f"stimulus - trigger offset: mean {measured_ms.mean():.2f} ms, median {np.median(measured_ms):.1f} ms, "
      f"SD {measured_ms.std(ddof=1):.2f} ms, range {measured_ms.min():.0f}-{measured_ms.max():.0f} ms (n = {len(measured_ms)})")

k = 4                                                                          # one stimulus, close up
t0 = trig_samples[k] / sf
seg = syn.copy().crop(t0 - 0.05, t0 + 0.35)
fig, axes = plt.subplots(3, 1, figsize=(11, 6.5), sharex=True)
t = seg.times + (t0 - 0.05)
axes[0].plot(t, seg.get_data(picks="PHOTODIODE")[0], "k", lw=0.9)
axes[0].axhline(threshold, color="tab:red", lw=0.8, ls="--", label="detection threshold")
axes[0].set(ylabel="photodiode (a.u.)", title=f"Stimulus {k + 1}: trigger pulse, light onset and the EEG at Oz (synthetic recording)")
axes[1].plot(t, seg.get_data(picks="TRIG")[0], "k", lw=0.9)
axes[1].set(ylabel="TRIG (TTL)")
axes[2].plot(t, seg.get_data(picks="Oz")[0] * 1e6, "k", lw=0.9)
axes[2].set(ylabel="Oz (uV)", xlabel="Time (s)")
for ax in axes:
    ax.axvline(trig_samples[k] / sf, color="tab:blue", lw=1, label="trigger onset")
    ax.axvline(diode_samples[k] / sf, color="tab:orange", lw=1, label="light onset")
    ax.grid(alpha=0.3)
axes[0].legend(fontsize=8, loc="upper right")
fig.tight_layout()

fig, axes = plt.subplots(1, 2, figsize=(11, 3.2), gridspec_kw=dict(width_ratios=[2, 1]))
axes[0].plot(np.arange(1, len(measured_ms) + 1), measured_ms, "o-", ms=4, lw=0.8)
axes[0].axhline(measured_ms.mean(), color="tab:red", lw=0.8, label=f"mean {measured_ms.mean():.2f} ms")
axes[0].set(xlabel="stimulus number", ylabel="offset (ms)", title="Stimulus - trigger offset per trial (ms)")
axes[0].legend(fontsize=8)
axes[0].grid(alpha=0.3)
axes[1].hist(measured_ms, bins=np.arange(measured_ms.min() - 0.5, measured_ms.max() + 1.5, 1.0), color="tab:blue")
axes[1].set(xlabel="offset (ms)", ylabel="trials", title="Distribution across trials (ms)")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
36 triggers found; photodiode onset = first sample above 5 x the dark-level noise (0.101 a.u.)
stimulus - trigger offset: mean 33.06 ms, median 33.0 ms, SD 0.75 ms, range 32-34 ms (n = 36)
Figure 3 of notebook nb-0-3-first-load, an output plot. The text around it states what it shows and the units of every axis.
Figure 4 of notebook nb-0-3-first-load, an output plot. The text around it states what it shows and the units of every axis.

What the numbers mean for an analysis: an ERP time-locked to TRIG would place every component about 33 ms too late; the fix is to shift the events by the measured offset (or to time-lock to the photodiode) — and the trial-to-trial spread tells you how much of the remaining latency jitter is the display's, not the brain's (pf-trigger-offsets). A one-frame wobble of a 60 Hz display would be ±8 ms; the ±1 ms planted here is the optimistic case.

5. The numbers

In [12]:
tc1, tc3 = helpers.event_timing_check(raw_r01), helpers.event_timing_check(raw_r03)
print(f"nb-0-3-first-load -- {DATASET} (EEGMMIDB; PhysioNet DOI 10.13026/C28G6P; ODC-By 1.0), subject {SUBJECT}")
print(f"EDF+ S001 R01: {tc1['sfreq_hz']:g} Hz | {len(mne.pick_types(raw_r01.info, eeg=True))} EEG channels | {tc1['duration_s']:.2f} s | "
      f"annotations {tc1['counts']}")
print(f"EDF+ S001 R03: {tc3['sfreq_hz']:g} Hz | {tc3['duration_s']:.2f} s | annotations {tc3['counts']} | onset intervals median "
      f"{tc3['interval_s']['median']:.2f} s (min {tc3['interval_s']['min']:.2f}, max {tc3['interval_s']['max']:.2f}) | "
      f"overlaps {tc3.get('n_overlapping_spans', 0)}, gaps {tc3.get('n_gaps_between_spans', 0)}")
if LEMON_OK:
    tcl = helpers.event_timing_check(raw_lemon)
    print(f"BrainVision ds-lemon {LEMON_SUBJECT} (first {LEMON_MAX_MINUTES} min): {tcl['sfreq_hz']:g} Hz | "
          f"{len(mne.pick_types(raw_lemon.info, eeg=True))} EEG + {len(mne.pick_types(raw_lemon.info, eog=True))} EOG channels | "
          f"{tcl['duration_s']:.1f} s | markers {tcl['counts']} | onset intervals median {tcl['interval_s']['median']:.3f} s | "
          f"FCz stored: {'FCz' in raw_lemon.ch_names}")
else:
    print("BrainVision ds-lemon: skipped in this run (not fetched)")
print("ds-erpcore: TODO(confirm) -- not in the catalog (spec 13.22); loader raises NotImplementedError")
print()
print("L0.3 exercise (SYNTHETIC recording: ds-eegbci S001 R01 EEG resampled to 1000 Hz + simulated PHOTODIODE and TRIG; "
      f"seed {SEED}; n = {N_STIM} stimuli)")
print(f"  planted stimulus-trigger offset : {OFFSET_MS:.1f} ms constant, +/- {JITTER_MS:g} ms uniform jitter per trial "
      f"(planted mean {planted.mean():.2f} ms)")
print(f"  measured from the photodiode    : mean {measured_ms.mean():.2f} ms | median {np.median(measured_ms):.1f} ms | "
      f"SD {measured_ms.std(ddof=1):.2f} ms | range {measured_ms.min():.0f}-{measured_ms.max():.0f} ms"
      "   <-- the number this notebook reports (answer key, tolerance +/- 2 ms)")
print("  the stimulus lags the trigger: events time-locked to TRIG must be shifted later by this amount")
print("TODO(confirm): draft values until the author reviews them; the recording is synthetic because no catalog dataset")
print("documents a photodiode channel.")
nb-0-3-first-load -- ds-eegbci (EEGMMIDB; PhysioNet DOI 10.13026/C28G6P; ODC-By 1.0), subject S001
EDF+ S001 R01: 160 Hz | 64 EEG channels | 61.00 s | annotations {'T0': 1}
EDF+ S001 R03: 160 Hz | 125.00 s | annotations {'T0': 15, 'T2': 7, 'T1': 8} | onset intervals median 4.20 s (min 4.10, max 4.20) | overlaps 0, gaps 0
BrainVision ds-lemon sub-010002 (first 5 min): 2500 Hz | 61 EEG + 1 EOG channels | 300.0 s | markers {'Comment/no USB Connection to actiCAP': 1, 'Stimulus/S  1': 6, 'Stimulus/S210': 83, 'Stimulus/S200': 60} | onset intervals median 2.000 s | FCz stored: False
ds-erpcore: TODO(confirm) -- not in the catalog (spec 13.22); loader raises NotImplementedError

L0.3 exercise (SYNTHETIC recording: ds-eegbci S001 R01 EEG resampled to 1000 Hz + simulated PHOTODIODE and TRIG; seed 20260917; n = 36 stimuli)
  planted stimulus-trigger offset : 33.0 ms constant, +/- 1 ms uniform jitter per trial (planted mean 33.06 ms)
  measured from the photodiode    : mean 33.06 ms | median 33.0 ms | SD 0.75 ms | range 32-34 ms   <-- the number this notebook reports (answer key, tolerance +/- 2 ms)
  the stimulus lags the trigger: events time-locked to TRIG must be shifted later by this amount
TODO(confirm): draft values until the author reviews them; the recording is synthetic because no catalog dataset
documents a photodiode channel.