Is it really an oscillation? A spectral peak, bursts whose count falls elevenfold on the threshold alone, and cycle-by-cycle waveform shape

nb-4-6-bursts Level 4 · Time-Frequency and Oscillations ~5 min Used in L4.6 · Is it really an oscillation?

Downloads from 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-4-6-bursts · Is it really an oscillation? (L4.6)

Lesson L4.6 · Level 4 · Status draft — for expert review; uncertain points carry TODO(confirm).

"Sustained alpha" is a description of an average, not of a signal. This notebook takes one minute of real eyes-closed EEG and asks three questions in order:

  1. Is there a spectral peak at all? Band power can be reported for any band of any recording; it only means "the rhythm" if a peak exists over the aperiodic background. specparam answers it.
  2. Is the rhythm sustained or bursty? Threshold the amplitude envelope and count. The answer depends on the threshold to a degree that is the whole lesson: the burst rate falls elevenfold and the duty cycle sixteenfold across a range one sentence of a methods section would cover.
  3. Is each cycle sinusoidal? Cycle-by-cycle analysis measures rise/decay and peak/trough asymmetry directly, which is what tells a non-sinusoidal rhythm from a coupled pair of rhythms (pf-harmonics-as-pac).

Data. ds-lemon — LEMON (MPI-Leipzig Mind-Brain-Body), Babayan, Erbey, Kumral et al. (2019), Scientific Data 6, 180308, DOI 10.1038/sdata.2018.308. From data/directory.yaml: Brain Products BrainAmp MR plus with a 62-channel actiCAP (61 EEG + VEOG), 2500 Hz in the raw release, online band 0.015–1000 Hz with no notch at any stage, reference FCz (absent as a channel), 50 Hz mains, 227 healthy adults with age released in 5-year bins, access: open. Licence CC BY 4.0 per the data descriptor; the NITRC/INDI page references an Open Data (PDDL-style) dedication, so the exact dataset terms are a TODO(confirm) (spec §13 item 5) — recorded that way in the directory and repeated here rather than resolved.

Downloading 20 MB instead of 317. One raw LEMON recording is a 317 MB BrainVision .eeg file. The data are multiplexed 16-bit integers, so sample n starts at byte n × 62 × 2 and any time window is a contiguous byte range. helpers_l4.fetch_lemon_window issues an HTTP range request for exactly the window it needs and writes a valid BrainVision triplet for it — header repointed, markers shifted, out-of-range markers dropped. Every file is deleted in a finally; free disk is printed before and after.

bycycle is optional. Where it is absent the notebook falls back to a documented cycle segmentation implemented in the cell itself and prints which path it took.

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', 'specparam')
_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", "pooch>=1.8"]
    subprocess.check_call(_cmd)

# 2. Shared helpers, 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_l4.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L4/ (or notebooks/) so that "
                            "_shared/helpers_l4.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l1
import helpers_l4 as L4
from scipy import signal as sps

try:
    from bycycle.features import compute_features as _bycycle_features
    HAVE_BYCYCLE = True
except ImportError:  # optional dependency; section 4 falls back and says so
    _bycycle_features = None
    HAVE_BYCYCLE = False

# 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 each figure cell ends with plt.show().
import matplotlib.pyplot as plt
import numpy as np
import mne

mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72

# 4. Quiet the downloader.  pooch, which MNE uses to fetch datasets, logs
#    "Downloading file '...' from '...' to '<cache directory>'" at INFO, and that last field is an
#    ABSOLUTE PATH from whichever machine executed the notebook.  Absolute paths are not allowed in a
#    stored notebook (scripts/scrub-notebooks.py is a CI gate) and re-executing would put them straight
#    back, so the message is suppressed at the source rather than cleaned up afterwards.  Nothing is
#    hidden by this: every cell below prints the file NAMES it fetched and helpers_l4.Downloads prints
#    free disk before and after.  Please do not delete this as noise.
try:
    import pooch

    pooch.get_logger().setLevel("WARNING")
except Exception:  # pooch absent or its API moved: the scrub script is the backstop
    pass
print(f"MNE {mne.__version__}; helpers_l4 imported from notebooks/_shared")
print(f"downloads go to {helpers_l1.download_dir().name}/ (resolved relative to the working directory, "
      "or $EEG_COURSE_DOWNLOADS) and are deleted at the end of this notebook")
print(f"bycycle available: {HAVE_BYCYCLE}" if HAVE_BYCYCLE else "bycycle not installed -- section 4 uses the documented NumPy fallback")
MNE 1.10.2; helpers_l4 imported from notebooks/_shared
downloads go to eeg-course/ (resolved relative to the working directory, or $EEG_COURSE_DOWNLOADS) and are deleted at the end of this notebook
bycycle available: True

1 · One eyes-closed minute, fetched as a byte range

LEMON's resting protocol alternates two marker codes in one-minute blocks. What the two codes mean is not recorded in data/directory.yaml, so this notebook does not assume: it probes twelve seconds from inside the first block of each code and calls the one with the larger relative occipital alpha power the eyes-closed condition. That label is label_source: algorithmic and a TODO(confirm) against the dataset's own protocol documentation.

The segment analysed is the one the w-burst-detector asset ships — subject sub-010002, block 6, channel O2 — so every number below can be checked against the widget a learner will see.

In [2]:
REF_SUBJECT = "sub-010002"
REF_BLOCK = 6
DURATION_S = 60.0
TARGET_SFREQ = 250.0
OCCIPITAL = ["O1", "Oz", "O2"]

dl = L4.Downloads("nb-4-6").start()
tmpdir = Path(helpers_l1.download_dir()) / "lemon-windows"
hdr = L4.lemon_headers(REF_SUBJECT)
blocks = L4.lemon_blocks(hdr)
print(f"{REF_SUBJECT} (mirror id {hdr['mirror_id']}): {hdr['n_channels']} channels, {hdr['sfreq']:g} Hz, "
      f"{hdr['binary_format']} {hdr['orientation']}, {len(hdr['markers'])} stimulus markers -> "
      f"{len(blocks)} blocks")
for b in blocks[:8]:
    print(f"    block {b['index']:2d}: code {b['code']} {b['t0_s']:8.3f}..{b['t1_s']:8.3f} s "
          f"({b['duration_s']:.1f} s)")
print(f"    ... {len(blocks)} blocks in total")
print(f"the whole .eeg is {hdr['n_channels'] * 2 * int(hdr['sfreq']) / 1e6:.2f} MB per second of recording; "
      f"{DURATION_S:g} s is {DURATION_S * hdr['n_channels'] * 2 * hdr['sfreq'] / 1e6:.1f} MB")
free disk before nb-4-6: 4,618 MB
sub-010002 (mirror id sub-032301): 62 channels, 2500 Hz, INT_16 MULTIPLEXED, 497 stimulus markers -> 16 blocks
    block  0: code S210    6.385..  64.386 s (58.0 s)
    block  1: code S200   68.749.. 126.750 s (58.0 s)
    block  2: code S210  130.472.. 188.473 s (58.0 s)
    block  3: code S200  192.430.. 250.432 s (58.0 s)
    block  4: code S210  254.368.. 312.369 s (58.0 s)
    block  5: code S200  316.242.. 374.243 s (58.0 s)
    block  6: code S210  378.027.. 436.028 s (58.0 s)
    block  7: code S200  439.627.. 497.628 s (58.0 s)
    ... 16 blocks in total
the whole .eeg is 0.31 MB per second of recording; 60 s is 18.6 MB
In [3]:
def load_window(subject, headers, t0_s, duration_s, downloads, resample_hz=TARGET_SFREQ):
    """Range-fetch one window and return it as a Raw resampled to `resample_hz` (uV in, uV out)."""
    vhdr = L4.fetch_lemon_window(subject, t0_s, duration_s, tmpdir, downloads, headers=headers,
                                 verbose=True)
    if vhdr is None:
        return None
    r = mne.io.read_raw_brainvision(vhdr, preload=True, verbose=False)
    if resample_hz and abs(r.info["sfreq"] - resample_hz) > 1e-6:
        r.filter(None, resample_hz * 0.4, picks="all", method="fir", phase="zero", fir_design="firwin",
                 verbose=False)
        r.resample(resample_hz, npad="auto", verbose=False)
    return r


def relative_alpha(raw_obj, picks=OCCIPITAL, band=(8.0, 12.0), broad=(1.0, 40.0)):
    """Median over 4-s windows of log10(alpha power / broadband power), averaged over `picks`."""
    sfq = float(raw_obj.info["sfreq"])
    d = raw_obj.get_data(picks=[c for c in picks if c in raw_obj.ch_names]) * 1e6
    f, P = sps.welch(d, fs=sfq, nperseg=int(4 * sfq), noverlap=int(2 * sfq), axis=-1)
    a = P[:, (f >= band[0]) & (f <= band[1])].mean(1)
    b = P[:, (f >= broad[0]) & (f <= broad[1])].mean(1)
    return float(np.median(np.log10(a / b)))


probe = {}
for code in ("S210", "S200"):
    b = next(bb for bb in blocks if bb["code"] == code)
    r = load_window(REF_SUBJECT, hdr, b["t0_s"] + 5.0, 12.0, dl)
    probe[code] = relative_alpha(r)
    print(f"  probe of the first {code} block ({b['t0_s'] + 5:.1f} s, 12 s): "
          f"median log10 relative occipital alpha = {probe[code]:+.4f}")
EC_CODE = max(probe, key=probe.get)
print(f"-> eyes-closed code inferred from the signal: {EC_CODE} "
      f"(label_source: algorithmic; TODO(confirm) against the dataset descriptor)")

ref_block = blocks[REF_BLOCK]
print(f"\nAnalysing block {REF_BLOCK} (code {ref_block['code']}, {ref_block['t0_s']:.3f} s):")
raw_ref = load_window(REF_SUBJECT, hdr, ref_block["t0_s"], DURATION_S, dl)
CH = max(OCCIPITAL, key=lambda c: np.var(raw_ref.get_data(picks=[c])[0]) if c in raw_ref.ch_names else -1)
rel = {c: None for c in OCCIPITAL}
sfq = float(raw_ref.info["sfreq"])
f_ref, P_ref = sps.welch(raw_ref.get_data(picks=OCCIPITAL)[:, :] * 1e6, fs=sfq, nperseg=int(4 * sfq),
                         noverlap=int(2 * sfq), axis=-1)
for i, c in enumerate(OCCIPITAL):
    rel[c] = float(P_ref[i, (f_ref >= 8) & (f_ref <= 13)].mean() /
                   P_ref[i, (f_ref >= 1) & (f_ref <= 40)].mean())
CH = max(rel, key=rel.get)
print(f"  channel selection (the shipped asset's criterion: largest ratio of mean PSD in 8-13 Hz to "
      f"mean PSD in 1-40 Hz):")
for c in sorted(rel, key=rel.get, reverse=True):
    print(f"      {c}: relative alpha {rel[c]:.4f}")
print(f"  -> {CH}")
x = raw_ref.get_data(picks=[CH])[0] * 1e6
x = x - x.mean()
print(f"  {DURATION_S:g} s of {CH} at {sfq:g} Hz: SD {x.std():.2f} uV, largest absolute sample "
      f"{np.abs(x).max():.1f} uV, peak-to-peak of a 1-s moving mean "
      f"{np.ptp(np.convolve(x, np.ones(int(sfq)) / int(sfq), 'valid')):.1f} uV")
print(f"  (w-burst-detector/envelope.json records sd_uv 21.68, max_abs_uv 113.2, slow_drift_ptp_uv 97.2 for "
      f"this same segment, and relative_alpha O2 3.1027 > Oz 2.9291 > O1 2.8725)")
ds-lemon sub-010002 (mirror id sub-032301): range request for 12 s from 11.39 s -- 3.7 MB in 1 s (6.3 MB/s), not the 317 MB file
  probe of the first S210 block (11.4 s, 12 s): median log10 relative occipital alpha = +0.0849
ds-lemon sub-010002 (mirror id sub-032301): range request for 12 s from 73.75 s -- 3.7 MB in 1 s (6.7 MB/s), not the 317 MB file
  probe of the first S200 block (73.7 s, 12 s): median log10 relative occipital alpha = -0.0417
-> eyes-closed code inferred from the signal: S210 (label_source: algorithmic; TODO(confirm) against the dataset descriptor)

Analysing block 6 (code S210, 378.027 s):
ds-lemon sub-010002 (mirror id sub-032301): range request for 60 s from 378.03 s -- 18.6 MB in 1 s (17.5 MB/s), not the 317 MB file
  channel selection (the shipped asset's criterion: largest ratio of mean PSD in 8-13 Hz to mean PSD in 1-40 Hz):
      O2: relative alpha 3.2413
      Oz: relative alpha 3.0727
      O1: relative alpha 3.0025
  -> O2
  60 s of O2 at 250 Hz: SD 21.68 uV, largest absolute sample 113.2 uV, peak-to-peak of a 1-s moving mean 97.2 uV
  (w-burst-detector/envelope.json records sd_uv 21.68, max_abs_uv 113.2, slow_drift_ptp_uv 97.2 for this same segment, and relative_alpha O2 3.1027 > Oz 2.9291 > O1 2.8725)

2 · Is there a peak?

Reporting "alpha power" presumes a peak. specparam separates the spectrum into an aperiodic component and whatever sits above it, so the question has an answer rather than an assumption. If no peak is fitted in 7–13 Hz, band power in that band is a measurement of the aperiodic background and should not be called a rhythm.

In [4]:
f_w, P_w = sps.welch(x, fs=sfq, nperseg=int(2 * sfq), noverlap=int(sfq), window="hann")
m = (f_w >= 0.5) & (f_w <= 60.0)
fit = helpers_l1.fit_specparam(f_w[m], P_w[m])
print(f"specparam fit over {helpers_l1.FIT_RANGE[0]:g}-{helpers_l1.FIT_RANGE[1]:g} Hz "
      f"({fit['tool']}, settings {fit['settings']}):")
print(f"    aperiodic offset {fit['offset']:.4f}, exponent {fit['exponent']:.4f}, "
      f"r^2 {fit['r_squared']:.4f}, error {fit['error']:.4f}")
print(f"    peaks (centre Hz, power, bandwidth Hz):")
for cf, pw_, bw_ in fit["peaks"]:
    print(f"        {cf:7.3f}  {pw_:.4f}  {bw_:.3f}")
print(f"    individual alpha frequency in {helpers_l1.IAF_RANGE[0]:g}-{helpers_l1.IAF_RANGE[1]:g} Hz: "
      f"{fit['iaf']:.4f} Hz" if np.isfinite(fit["iaf"]) else "    NO alpha peak fitted")

fig, ax = plt.subplots(figsize=(8.6, 4.3))
ax.semilogy(f_w[m], P_w[m], lw=1.2, color="k", label=f"{CH}, Welch 2-s Hann, 50 % overlap")
ax.semilogy(fit["freqs"] if "freqs" in fit else f_w[m][(f_w[m] >= 1) & (f_w[m] <= 40)],
            10 ** fit["aperiodic_log"], lw=1.4, color="tab:orange",
            label=f"aperiodic fit (exponent {fit['exponent']:.2f})")
ax.semilogy(f_w[m][(f_w[m] >= 1) & (f_w[m] <= 40)], 10 ** fit["model_log"], lw=1.2, color="tab:blue",
            ls="--", label=f"full model (r^2 {fit['r_squared']:.3f})")
if np.isfinite(fit["iaf"]):
    ax.axvline(fit["iaf"], color="tab:red", lw=1.0, ls=":", label=f"peak {fit['iaf']:.3f} Hz")
ax.axvline(L4.DATASETS_L4["ds-lemon"]["mains_hz"], color="0.6", lw=0.8, ls=":",
           label=f"{L4.DATASETS_L4['ds-lemon']['mains_hz']} Hz mains (no notch at any stage)")
ax.set(xlabel="Frequency (Hz)", ylabel="Power spectral density (uV^2/Hz)",
       title=f"ds-lemon {REF_SUBJECT} {CH}, {DURATION_S:g} s eyes closed: is there a peak? (uV^2/Hz)")
ax.legend(fontsize=8)
ax.grid(alpha=0.3, which="both")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

IAF = float(fit["iaf"])
BAND = (8.0, 13.0)
print(f"\nIndividualised band, peak +/- 2 Hz: {IAF - 2:.2f}-{IAF + 2:.2f} Hz. The +/- 2 Hz is a CONVENTION, "
      f"not a measurement from this recording; the analysis below uses the conventional "
      f"{BAND[0]:g}-{BAND[1]:g} Hz band so that its numbers can be compared with the widget's.")
specparam fit over 1-40 Hz (specparam 2.0.0rc4, settings {'peak_width_limits': (1.0, 8.0), 'max_n_peaks': 6, 'min_peak_height': 0.1, 'peak_threshold': 2.0, 'aperiodic_mode': 'fixed'}):
    aperiodic offset 1.1024, exponent 1.1137, r^2 0.9751, error 0.0678
    peaks (centre Hz, power, bandwidth Hz):
         10.272  1.2717  2.407
         18.889  0.7622  8.000
         25.396  0.4534  6.689
    individual alpha frequency in 7-13 Hz: 10.2722 Hz
Figure 1 of notebook nb-4-6-bursts, an output plot. The text around it states what it shows and the units of every axis.
Individualised band, peak +/- 2 Hz: 8.27-12.27 Hz. The +/- 2 Hz is a CONVENTION, not a measurement from this recording; the analysis below uses the conventional 8-13 Hz band so that its numbers can be compared with the widget's.

3 · Bursts, and how far the threshold moves them

The envelope comes from a band-pass and a Hilbert transform; the conventions are the ones w-burst-detector documents, restated in helpers_l4.BURST_CONVENTIONS so that a notebook number and a widget number are the same number. They are choices, and each one is defensible rather than unique:

  • the contaminated edge is (numtaps − 1) / 2 samples at each end, half the zero-phase FIR's impulse response, and it is excluded from the threshold, from the detection and from the duty cycle's denominator;
  • "above" means strictly greater;
  • a run of n samples lasts n/fs seconds and must cover the minimum, so the count is ceil(min × fs);
  • a minimum given in cycles converts at the arithmetic mean of the band edges.

Two of those choices change the answer, and both are shown.

In [5]:
fh = L4.filter_hilbert(x, sfq, BAND)
env = fh["envelope"]
E = fh["edge_samples"]
core = env[E:len(env) - E]
print(f"band-pass {BAND[0]:g}-{BAND[1]:g} Hz: {fh['numtaps']} taps "
      f"({fh['numtaps'] / sfq:.3f} s); contaminated edge {E} samples = {fh['edge_s']:.3f} s each end, "
      f"so {len(core) / sfq:.3f} s of the {DURATION_S:g} s is analysed")
print(f"envelope percentiles over the ANALYSED region (uV): "
      + ", ".join(f"p{p}={np.percentile(core, p):.3f}" for p in (10, 25, 50, 75, 90, 95, 99)))
print(f"envelope percentiles over the WHOLE trace (uV):    "
      + ", ".join(f"p{p}={np.percentile(env, p):.3f}" for p in (10, 25, 50, 75, 90, 95, 99)))
print("  (w-burst-detector/envelope.json records the whole-trace values p50 6.841, p75 10.373, p95 16.801, "
      "mean 7.762, sd 4.937; the analysed-region values are lower because the contaminated edges are "
      "the quietest part of the trace)")

MIN_CYCLES = 3.0
res = {}
rows = []
for pct in (50, 75, 95):
    r = L4.detect_bursts(env, sfq, percentile=pct, min_cycles=MIN_CYCLES, band=BAND, edge_samples=E)
    res[pct] = r
    rows.append({"threshold": f"{pct}th percentile", "uV": r["threshold_uv"], "bursts": r["n_bursts"],
                 "rate (/min)": r["rate_per_min"], "mean duration (ms)": r["mean_duration_s"] * 1000,
                 "median duration (ms)": r["median_duration_s"] * 1000,
                 "duty cycle (%)": 100 * r["duty_cycle"]})
print()
print(f"Bursts in {BAND[0]:g}-{BAND[1]:g} Hz, minimum {MIN_CYCLES:g} cycles of the band centre "
      f"({res[50]['min_duration_s'] * 1000:.1f} ms = {res[50]['min_samples']} samples, ceil), "
      f"edges excluded, {res[50]['analysed_s']:.3f} s analysed:")
print(L4.fmt_table(rows, floatfmt="{:.2f}"))
print()
print(f"The signal is identical in all three rows. The burst rate falls "
      f"{res[50]['n_bursts'] / max(res[95]['n_bursts'], 1):.0f}x and the duty cycle "
      f"{res[50]['duty_cycle'] / max(res[95]['duty_cycle'], 1e-9):.0f}x on the threshold alone.")
band-pass 8-13 Hz: 413 taps (1.652 s); contaminated edge 206 samples = 0.824 s each end, so 58.352 s of the 60 s is analysed
envelope percentiles over the ANALYSED region (uV): p10=2.382, p25=3.943, p50=6.695, p75=10.126, p90=13.698, p95=16.157, p99=22.402
envelope percentiles over the WHOLE trace (uV):    p10=2.422, p25=4.005, p50=6.841, p75=10.373, p90=14.345, p95=16.801, p99=23.742
  (w-burst-detector/envelope.json records the whole-trace values p50 6.841, p75 10.373, p95 16.801, mean 7.762, sd 4.937; the analysed-region values are lower because the contaminated edges are the quietest part of the trace)

Bursts in 8-13 Hz, minimum 3 cycles of the band centre (285.7 ms = 72 samples, ceil), edges excluded, 58.352 s analysed:
      threshold     uV  bursts  rate (/min)  mean duration (ms)  median duration (ms)  duty cycle (%)
---------------  -----  ------  -----------  ------------------  --------------------  --------------
50th percentile   6.69      33        33.93              712.24                572.00           40.28
75th percentile  10.13      19        19.54              547.16                388.00           17.82
95th percentile  16.16       3         3.08              493.33                464.00            2.54

The signal is identical in all three rows. The burst rate falls 11x and the duty cycle 16x on the threshold alone.
In [6]:
fig, axes = plt.subplots(3, 1, figsize=(13.5, 9.0), sharex=True)
for ax, pct in zip(axes, (50, 75, 95)):
    L4.plot_envelope_bursts(fh["filtered"], env, sfq, res[pct], ax=ax, window_s=(5.0, 25.0),
                            title=f"ds-lemon {REF_SUBJECT} {CH}, {BAND[0]:g}-{BAND[1]:g} Hz, threshold at the "
                                  f"{pct}th percentile ({res[pct]['threshold_uv']:.2f} uV): "
                                  f"{res[pct]['n_bursts']} bursts, duty cycle "
                                  f"{100 * res[pct]['duty_cycle']:.1f} % (uV)")
axes[-1].set_xlabel("Time from the start of the 60-s block (s)")
fig.suptitle("The same twenty seconds under three thresholds (uV)", y=1.01)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 2 of notebook nb-4-6-bursts, an output plot. The text around it states what it shows and the units of every axis.
In [7]:
conv = []
for label, kwargs in (
        ("edges excluded, ceil (this notebook and the widget)", dict(edge_samples=E, round_mode="ceil")),
        ("edges excluded, round (the shipped sidecar's rule)", dict(edge_samples=E, round_mode="round")),
        ("whole trace, ceil", dict(edge_samples=0, round_mode="ceil")),
        ("whole trace, round", dict(edge_samples=0, round_mode="round"))):
    row = {"convention": label}
    for pct in (50, 75, 95):
        r = L4.detect_bursts(env, sfq, percentile=pct, min_cycles=MIN_CYCLES, band=BAND, **kwargs)
        row[f"p{pct} bursts"] = r["n_bursts"]
        row[f"p{pct} duty (%)"] = 100 * r["duty_cycle"]
    conv.append(row)
print("How much the two conventions move the answer, same signal, same band:")
print(L4.fmt_table(conv, floatfmt="{:.1f}"))
print()
r_side = L4.detect_bursts(env, sfq, median_multiple=1.5, min_duration_s=0.284, edge_samples=0,
                          round_mode="round")
print(f"The shipped sidecar's own reference row (1.5 x the whole-trace median = "
      f"{r_side['threshold_uv']:.3f} uV, 0.284 s minimum, whole trace, round): "
      f"{r_side['n_bursts']} bursts, mean duration {r_side['mean_duration_s'] * 1000:.1f} ms, duty cycle "
      f"{100 * r_side['duty_cycle']:.2f} %.")
print("  w-burst-detector/envelope.json records 20 bursts, 572.0 ms and 19.07 % for that row; "
      "site/notes/integration-phase3.md records that the widget's ceil rule gives 19 where the sidecar's "
      "round rule gives 20, on one extra sample. This notebook reproduces both.")
How much the two conventions move the answer, same signal, same band:
                                         convention  p50 bursts  p50 duty (%)  p75 bursts  p75 duty (%)  p95 bursts  p95 duty (%)
---------------------------------------------------  ----------  ------------  ----------  ------------  ----------  ------------
edges excluded, ceil (this notebook and the widget)          33          40.3          19          17.8           3           2.5
 edges excluded, round (the shipped sidecar's rule)          34          40.8          19          17.8           3           2.5
                                  whole trace, ceil          33          40.4          18          17.9           4           4.0
                                 whole trace, round          33          40.4          18          17.9           4           4.0

The shipped sidecar's own reference row (1.5 x the whole-trace median = 10.261 uV, 0.284 s minimum, whole trace, round): 20 bursts, mean duration 572.0 ms, duty cycle 19.07 %.
  w-burst-detector/envelope.json records 20 bursts, 572.0 ms and 19.07 % for that row; site/notes/integration-phase3.md records that the widget's ceil rule gives 19 where the sidecar's round rule gives 20, on one extra sample. This notebook reproduces both.

4 · Cycle by cycle

A burst detector says when; it says nothing about shape. Cycle-by-cycle analysis segments the signal at its own extrema and measures each cycle's rise time against its decay time, and its peak sharpness against its trough sharpness. A perfectly sinusoidal rhythm has both symmetries at 0.5. A departure from 0.5 is what makes a rhythm produce harmonics — and harmonics are what produce spurious phase-amplitude coupling (pf-harmonics-as-pac, demonstrated in nb-4-5-itc).

With bycycle installed this uses its burst-consistency criteria. Without it, the fallback below segments at zero crossings of the band-passed signal and computes the same two ratios directly; it has no burst criterion of its own, so it reports every cycle inside a detected burst instead. Both paths print which one ran.

In [8]:
def cycle_features_fallback(sig_bp, sig_raw, fs, burst_segments):
    """Rise/decay and peak/trough symmetry per cycle, segmented at rising zero crossings of `sig_bp`.

    Documented fallback for bycycle: for each pair of consecutive rising zero crossings, the peak is the
    largest sample and the trough the smallest inside it; rise time is peak minus the preceding trough,
    decay time the following trough minus the peak.  Only cycles whose whole extent lies inside one of
    `burst_segments` are returned, since the ratios are meaningless outside a burst.
    """
    zc = np.where((sig_bp[:-1] <= 0) & (sig_bp[1:] > 0))[0] + 1
    out = []
    for a, b in zip(zc[:-1], zc[1:]):
        if not any(s <= a and b <= e for s, e in burst_segments):
            continue
        seg_ = sig_raw[a:b]
        if seg_.size < 4:
            continue
        pk = int(np.argmax(seg_))
        tr = int(np.argmin(seg_))
        if pk == 0 or tr == 0 or pk == seg_.size - 1 or tr == seg_.size - 1:
            continue
        rise = abs(pk - tr)
        decay = seg_.size - 1 - max(pk, tr) + min(pk, tr)
        if rise + decay == 0:
            continue
        out.append({"period_s": (b - a) / fs, "time_rdsym": rise / (rise + decay),
                    "time_ptsym": (pk if pk < tr else seg_.size - pk) / seg_.size,
                    "volt_amp": float(seg_.max() - seg_.min())})
    return out


segs = res[75]["segments"]
if HAVE_BYCYCLE:
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        df = _bycycle_features(x, sfq, BAND, center_extrema="peak", burst_method="cycles",
                               threshold_kwargs=dict(amp_fraction_threshold=0.3,
                                                     amp_consistency_threshold=0.4,
                                                     period_consistency_threshold=0.5,
                                                     monotonicity_threshold=0.6, min_n_cycles=3))
    burst = df[df["is_burst"]]
    rdsym = burst["time_rdsym"].to_numpy(float)
    ptsym = burst["time_ptsym"].to_numpy(float)
    periods = burst["period"].to_numpy(float) / sfq
    amps = burst["volt_amp"].to_numpy(float)
    TOOL = f"bycycle {__import__('bycycle').__version__}, burst_method='cycles'"
    print(f"cycle-by-cycle via {TOOL}: {len(df)} cycles found, {len(burst)} of them in bursts "
          f"({100 * len(burst) / len(df):.1f} %)")
else:
    feats = cycle_features_fallback(fh["filtered"], x, sfq, segs)
    rdsym = np.array([f["time_rdsym"] for f in feats])
    ptsym = np.array([f["time_ptsym"] for f in feats])
    periods = np.array([f["period_s"] for f in feats])
    amps = np.array([f["volt_amp"] for f in feats])
    TOOL = "the documented NumPy fallback in this notebook (bycycle not installed)"
    print(f"cycle-by-cycle via {TOOL}: {len(feats)} cycles inside the 75th-percentile bursts")

fig, axes = plt.subplots(1, 3, figsize=(17, 3.8))
for ax, v, name in ((axes[0], rdsym, "rise-decay symmetry"), (axes[1], ptsym, "peak-trough symmetry")):
    ax.hist(v, bins=25, color="0.7")
    ax.axvline(0.5, color="tab:red", lw=1.4, ls="--", label="0.5 = perfectly sinusoidal")
    ax.axvline(np.median(v), color="tab:blue", lw=1.4, label=f"median {np.median(v):.4f}")
    ax.set(xlabel=f"{name} (dimensionless, 0-1)", ylabel="Cycles",
           title=f"{name}\n{CH}, {BAND[0]:g}-{BAND[1]:g} Hz, {len(v)} cycles in bursts")
    ax.legend(fontsize=8)
axes[2].scatter(1 / periods, amps, s=8, alpha=0.6)
axes[2].axvline(IAF, color="tab:red", lw=1.0, ls=":", label=f"specparam peak {IAF:.2f} Hz")
axes[2].set(xlabel="Instantaneous cycle frequency (Hz)", ylabel="Cycle amplitude, peak to trough (uV)",
            title=f"Every burst cycle: frequency against amplitude\n{CH} (uV)")
axes[2].legend(fontsize=8)
axes[2].grid(alpha=0.3)
fig.suptitle(f"ds-lemon {REF_SUBJECT}, cycle-by-cycle waveform features ({TOOL})", y=1.04)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

from scipy import stats as sstats
for name, v in (("rise-decay symmetry", rdsym), ("peak-trough symmetry", ptsym)):
    t_, p_ = sstats.ttest_1samp(v, 0.5)
    print(f"  {name:22s}: median {np.median(v):.4f}, mean {v.mean():.4f} +/- {v.std(ddof=1):.4f}, "
          f"t({len(v) - 1}) = {t_:+.2f} against 0.5, p = {p_:.2e}")
print(f"  cycle frequency: median {np.median(1 / periods):.3f} Hz (specparam peak {IAF:.3f} Hz); "
      f"amplitude: median {np.median(amps):.2f} uV")
print("  A departure from 0.5 is a statement about waveform shape, not about coupling. It is also exactly "
      "what puts energy at harmonics of the fundamental, which is why L4.5's comodulogram of a "
      "non-sinusoidal rhythm lights up with no coupling present.")
cycle-by-cycle via bycycle 1.2.0, burst_method='cycles': 628 cycles found, 292 of them in bursts (46.5 %)
Figure 3 of notebook nb-4-6-bursts, an output plot. The text around it states what it shows and the units of every axis.
  rise-decay symmetry   : median 0.4599, mean 0.4642 +/- 0.1564, t(291) = -3.91 against 0.5, p = 1.13e-04
  peak-trough symmetry  : median 0.5000, mean 0.5038 +/- 0.1138, t(291) = +0.57 against 0.5, p = 5.67e-01
  cycle frequency: median 10.417 Hz (specparam peak 10.272 Hz); amplitude: median 33.03 uV
  A departure from 0.5 is a statement about waveform shape, not about coupling. It is also exactly what puts energy at harmonics of the fundamental, which is why L4.5's comodulogram of a non-sinusoidal rhythm lights up with no coupling present.

5 · Burst rates per subject

One subject is an anecdote. The cell below repeats the whole pipeline — probe both marker codes, pick the eyes-closed one from the signal, fetch one 60-second block by range, fit specparam, detect bursts — for a documented handful of subjects, deleting each subject's files before starting the next, so the peak disk cost is one window rather than the whole set.

Subjects whose spectrum has no fitted peak in 7–13 Hz are reported as such rather than dropped: "this subject has no alpha peak" is a result, and a burst rate for a band with no peak is a measurement of the aperiodic background.

In [9]:
SUBJECTS = ["sub-010002", "sub-010003", "sub-010004", "sub-010005", "sub-010006"]
PROBE_S = 12.0
cohort = []
for sid in SUBJECTS:
    sub_dl = L4.Downloads(f"{sid}", verbose=False).start() if False else None
    try:
        h = L4.lemon_headers(sid)
        bl = L4.lemon_blocks(h)
        if len(bl) < 4:
            print(f"  {sid}: only {len(bl)} blocks; skipped")
            continue
        scores = {}
        for code in sorted({b["code"] for b in bl}):
            b0 = next(bb for bb in bl if bb["code"] == code)
            rr = load_window(sid, h, b0["t0_s"] + 5.0, PROBE_S, dl)
            scores[code] = relative_alpha(rr) if rr is not None else -np.inf
        ec = max(scores, key=scores.get)
        # the first block of the eyes-closed code that has 60 s of room before the next block
        cand = [b for b in bl if b["code"] == ec and
                (b["next_block_t0_s"] is None or b["next_block_t0_s"] - b["t0_s"] >= DURATION_S)]
        b = cand[min(REF_BLOCK // 2, len(cand) - 1)] if cand else None
        if b is None:
            print(f"  {sid}: no {ec} block with {DURATION_S:g} s of room; skipped")
            continue
        rr = load_window(sid, h, b["t0_s"], DURATION_S, dl)
        if rr is None:
            continue
        sfq_i = float(rr.info["sfreq"])
        pk_ch, pk_rel = None, -np.inf
        f_i, P_i = sps.welch(rr.get_data(picks=[c for c in OCCIPITAL if c in rr.ch_names])[:, :] * 1e6,
                             fs=sfq_i, nperseg=int(4 * sfq_i), noverlap=int(2 * sfq_i), axis=-1)
        for k, c in enumerate([c for c in OCCIPITAL if c in rr.ch_names]):
            v = P_i[k, (f_i >= 8) & (f_i <= 13)].mean() / P_i[k, (f_i >= 1) & (f_i <= 40)].mean()
            if v > pk_rel:
                pk_ch, pk_rel = c, v
        xi = rr.get_data(picks=[pk_ch])[0] * 1e6
        xi = xi - xi.mean()
        fi, Pi = sps.welch(xi, fs=sfq_i, nperseg=int(2 * sfq_i), noverlap=int(sfq_i), window="hann")
        mi = (fi >= 0.5) & (fi <= 60.0)
        fit_i = helpers_l1.fit_specparam(fi[mi], Pi[mi])
        fhi = L4.filter_hilbert(xi, sfq_i, BAND)
        row = {"subject": sid, "eyes-closed code": ec, "block t0 (s)": b["t0_s"], "channel": pk_ch,
               "relative alpha": float(pk_rel), "SD (uV)": float(xi.std()),
               "exponent": fit_i["exponent"], "r^2": fit_i["r_squared"],
               "alpha peak (Hz)": fit_i["iaf"] if np.isfinite(fit_i["iaf"]) else float("nan")}
        for pct in (50, 75, 95):
            ri = L4.detect_bursts(fhi["envelope"], sfq_i, percentile=pct, min_cycles=MIN_CYCLES, band=BAND,
                                  edge_samples=fhi["edge_samples"])
            row[f"p{pct} bursts"] = ri["n_bursts"]
            row[f"p{pct} duty (%)"] = 100 * ri["duty_cycle"]
        cohort.append(row)
        print(f"  {sid}: {ec} block at {b['t0_s']:.0f} s, {pk_ch}, peak "
              + (f"{fit_i['iaf']:.3f} Hz" if np.isfinite(fit_i["iaf"]) else "NONE in 7-13 Hz")
              + f", {row['p75 bursts']} bursts at p75 ({row['p75 duty (%)']:.1f} % duty)")
    finally:
        dl.cleanup()      # every window of this subject goes before the next subject starts
print()
print(L4.fmt_table(cohort, ["subject", "channel", "relative alpha", "SD (uV)", "exponent", "r^2",
                            "alpha peak (Hz)", "p50 bursts", "p75 bursts", "p95 bursts",
                            "p50 duty (%)", "p75 duty (%)", "p95 duty (%)"], floatfmt="{:.3f}"))
no_peak = [r["subject"] for r in cohort if not np.isfinite(r["alpha peak (Hz)"])]
print()
print(f"Subjects with NO fitted alpha peak in {helpers_l1.IAF_RANGE[0]:g}-{helpers_l1.IAF_RANGE[1]:g} Hz: "
      + (", ".join(no_peak) if no_peak else "none of them"))
print(f"Burst rate at the 75th percentile across {len(cohort)} subjects: median "
      f"{np.median([r['p75 bursts'] for r in cohort]):.0f}, range "
      f"{min(r['p75 bursts'] for r in cohort)}-{max(r['p75 bursts'] for r in cohort)}; duty cycle median "
      f"{np.median([r['p75 duty (%)'] for r in cohort]):.1f} %, range "
      f"{min(r['p75 duty (%)'] for r in cohort):.1f}-{max(r['p75 duty (%)'] for r in cohort):.1f} %.")
print("A percentile threshold is defined on each subject's own envelope, so it adapts to their amplitude; "
      "the spread that remains is a spread in burstiness, not in microvolts.")
ds-lemon sub-010002 (mirror id sub-032301): range request for 12 s from 73.75 s -- 3.7 MB in 4 s (1.0 MB/s), not the 317 MB file
ds-lemon sub-010002 (mirror id sub-032301): range request for 12 s from 11.39 s -- 3.7 MB in 4 s (1.1 MB/s), not the 317 MB file
ds-lemon sub-010002 (mirror id sub-032301): range request for 60 s from 378.03 s -- 18.6 MB in 1 s (19.0 MB/s), not the 317 MB file
  sub-010002: S210 block at 378 s, O2, peak 10.272 Hz, 19 bursts at p75 (17.8 % duty)
deleted 9 downloaded file(s), 24.9 MiB freed
ds-lemon sub-010003 (mirror id sub-032302): range request for 12 s from 79.94 s -- 3.7 MB in 1 s (5.6 MB/s), not the 317 MB file
ds-lemon sub-010003 (mirror id sub-032302): range request for 12 s from 18.05 s -- 3.7 MB in 1 s (5.7 MB/s), not the 317 MB file
ds-lemon sub-010003 (mirror id sub-032302): range request for 60 s from 383.11 s -- 18.6 MB in 1 s (16.0 MB/s), not the 317 MB file
  sub-010003: S210 block at 383 s, O2, peak 10.442 Hz, 15 bursts at p75 (10.8 % duty)
deleted 9 downloaded file(s), 24.9 MiB freed
ds-lemon sub-010004 (mirror id sub-032303): range request for 12 s from 77.90 s -- 3.7 MB in 1 s (6.4 MB/s), not the 317 MB file
ds-lemon sub-010004 (mirror id sub-032303): range request for 12 s from 15.37 s -- 3.7 MB in 1 s (5.7 MB/s), not the 317 MB file
ds-lemon sub-010004 (mirror id sub-032303): range request for 60 s from 386.13 s -- 18.6 MB in 1 s (18.0 MB/s), not the 317 MB file
  sub-010004: S210 block at 386 s, O2, peak 10.502 Hz, 15 bursts at p75 (14.1 % duty)
deleted 9 downloaded file(s), 24.9 MiB freed
ds-lemon sub-010005 (mirror id sub-032304): range request for 12 s from 75.79 s -- 3.7 MB in 1 s (6.8 MB/s), not the 317 MB file
ds-lemon sub-010005 (mirror id sub-032304): range request for 12 s from 13.54 s -- 3.7 MB in 1 s (6.5 MB/s), not the 317 MB file
ds-lemon sub-010005 (mirror id sub-032304): range request for 60 s from 395.50 s -- 18.6 MB in 1 s (16.4 MB/s), not the 317 MB file
  sub-010005: S210 block at 396 s, O1, peak 8.953 Hz, 15 bursts at p75 (17.7 % duty)
deleted 9 downloaded file(s), 24.9 MiB freed
ds-lemon sub-010006 (mirror id sub-032305): range request for 12 s from 73.30 s -- 3.7 MB in 1 s (3.7 MB/s), not the 317 MB file
ds-lemon sub-010006 (mirror id sub-032305): range request for 12 s from 11.40 s -- 3.7 MB in 1 s (6.5 MB/s), not the 317 MB file
ds-lemon sub-010006 (mirror id sub-032305): range request for 60 s from 380.02 s -- 18.6 MB in 1 s (14.2 MB/s), not the 317 MB file
  sub-010006: S210 block at 380 s, Oz, peak 8.258 Hz, 18 bursts at p75 (16.5 % duty)
deleted 9 downloaded file(s), 24.9 MiB freed

   subject  channel  relative alpha  SD (uV)  exponent    r^2  alpha peak (Hz)  p50 bursts  p75 bursts  p95 bursts  p50 duty (%)  p75 duty (%)  p95 duty (%)
----------  -------  --------------  -------  --------  -----  ---------------  ----------  ----------  ----------  ------------  ------------  ------------
sub-010002       O2           3.241   21.683     1.114  0.975           10.272          33          19           3        40.280        17.816         2.536
sub-010003       O2           4.069   27.047     1.533  0.993           10.442          49          15           2        37.229        10.790         1.165
sub-010004       O2           2.281   23.585     1.536  0.986           10.502          43          15           3        37.469        14.128         3.647
sub-010005       O1           3.340   26.228     1.960  0.980            8.953          28          15           5        37.922        17.651         3.215
sub-010006       Oz           2.684   21.368     1.324  0.985            8.258          32          18           4        40.609        16.520         3.297

Subjects with NO fitted alpha peak in 7-13 Hz: none of them
Burst rate at the 75th percentile across 5 subjects: median 15, range 15-19; duty cycle median 16.5 %, range 10.8-17.8 %.
A percentile threshold is defined on each subject's own envelope, so it adapts to their amplitude; the spread that remains is a spread in burstiness, not in microvolts.

6 · The numbers

In [10]:
try:
    KEY = {50: (33, 40.3), 75: (19, 17.8), 95: (3, 2.5)}   # site/notes/integration-phase3.md, widgets-K
    KEY_PEAK_HZ = 10.272
    print("nb-4-6-bursts -- L4.6 numbers (draft; TODO(confirm) at author review)")
    print(f"Data: ds-lemon {REF_SUBJECT}, block {REF_BLOCK} (code {ref_block['code']}, "
          f"{ref_block['t0_s']:.3f} s), {DURATION_S:g} s, channel {CH}, resampled "
          f"{hdr['sfreq']:g} -> {sfq:g} Hz with a {sfq * 0.4:g} Hz anti-alias low-pass; mean removed; "
          f"nothing else filtered. Licence {L4.DATASETS_L4['ds-lemon']['license']}")
    print(f"Fetched as a byte range: {DURATION_S * hdr['n_channels'] * 2 * hdr['sfreq'] / 1e6:.1f} MB of a "
          f"317 MB file, plus two {PROBE_S:g}-s probes to identify the eyes-closed marker code.")
    print(f"Eyes-closed code inferred from the signal: {EC_CODE} (relative occipital alpha "
          + ", ".join(f"{k} {v:+.3f}" for k, v in probe.items())
          + "). label_source: algorithmic; TODO(confirm) against the dataset descriptor.")
    print()
    print("ANSWER KEY -- ex-4-6: does this channel have a spectral peak, and what do bursts do with the "
          "threshold?")
    print(f"    Spectral peak: {'YES' if np.isfinite(IAF) else 'NO'}, at {IAF:.3f} Hz "
          f"(specparam {fit['tool']}, aperiodic exponent {fit['exponent']:.3f}, r^2 "
          f"{fit['r_squared']:.3f}). Band power in {BAND[0]:g}-{BAND[1]:g} Hz is therefore about a rhythm "
          f"for this channel, which is what makes it a fair subject for the rest of the lesson.")
    print(f"    Cross-check: w-burst-detector/envelope.json records {KEY_PEAK_HZ:g} Hz, exponent 1.114 and "
          f"r^2 0.975. This notebook "
          f"{'AGREES' if abs(IAF - KEY_PEAK_HZ) < 0.005 else 'DISAGREES'} "
          f"(difference {IAF - KEY_PEAK_HZ:+.5f} Hz).")
    print()
    print(f"    Bursts in {BAND[0]:g}-{BAND[1]:g} Hz, minimum {MIN_CYCLES:g} cycles of the band centre "
          f"({res[50]['min_duration_s'] * 1000:.1f} ms -> {res[50]['min_samples']} samples, ceil), edges "
          f"excluded ({res[50]['edge_s']:.3f} s each end, {res[50]['analysed_s']:.3f} s analysed):")
    ok = True
    for pct in (50, 75, 95):
        r = res[pct]
        kb, kd = KEY[pct]
        same = (r["n_bursts"] == kb) and abs(100 * r["duty_cycle"] - kd) < 0.05
        ok &= same
        print(f"        {pct}th percentile = {r['threshold_uv']:6.2f} uV -> {r['n_bursts']:3d} bursts, "
              f"{r['rate_per_min']:5.1f}/min, mean {r['mean_duration_s'] * 1000:5.0f} ms, duty "
              f"{100 * r['duty_cycle']:5.1f} %   [key {kb} / {kd} % -> {'agrees' if same else 'DIFFERS'}]")
    print(f"    Cross-check against site/notes/integration-phase3.md (widgets-K): "
          f"{'ALL THREE ROWS AGREE' if ok else 'AT LEAST ONE ROW DIFFERS -- report both'}.")
    print(f"    The lesson: the rate falls {res[50]['n_bursts'] / max(res[95]['n_bursts'], 1):.0f}x and the "
          f"duty cycle {res[50]['duty_cycle'] / max(res[95]['duty_cycle'], 1e-9):.0f}x on one free parameter, "
          f"with the signal untouched.")
    print()
    print("    The conventions are choices, and two of them move the count:")
    for c in conv:
        print(f"        {c['convention']:52s} "
              + "  ".join(f"p{p} {c[f'p{p} bursts']:3d} ({c[f'p{p} duty (%)']:.1f} %)" for p in (50, 75, 95)))
    print(f"        the shipped sidecar's own row (1.5 x whole-trace median, 0.284 s, round, whole trace): "
          f"{r_side['n_bursts']} bursts, {100 * r_side['duty_cycle']:.2f} % -- the sidecar records 20 and "
          f"19.07 %.")
    print()
    print(f"Supporting -- cycle-by-cycle ({TOOL}):")
    print(f"    rise-decay symmetry median {np.median(rdsym):.4f}, peak-trough symmetry median "
          f"{np.median(ptsym):.4f} over {len(rdsym)} burst cycles (0.5 would be sinusoidal); median cycle "
          f"frequency {np.median(1 / periods):.3f} Hz, median amplitude {np.median(amps):.2f} uV.")
    print()
    print(f"Supporting -- burst rates per subject ({len(cohort)} LEMON subjects, one 60-s eyes-closed block "
          f"each, one occipital channel each chosen by relative alpha):")
    for r in cohort:
        print(f"    {r['subject']} {r['channel']:>3s}: peak "
              + (f"{r['alpha peak (Hz)']:6.3f} Hz" if np.isfinite(r["alpha peak (Hz)"]) else "   NONE   ")
              + f", exponent {r['exponent']:.3f}, SD {r['SD (uV)']:5.2f} uV | bursts "
              + "/".join(f"{r[f'p{p} bursts']:3d}" for p in (50, 75, 95))
              + " | duty " + "/".join(f"{r[f'p{p} duty (%)']:5.1f}" for p in (50, 75, 95)) + " %")
    print(f"    Subjects with no fitted alpha peak in {helpers_l1.IAF_RANGE[0]:g}-"
          f"{helpers_l1.IAF_RANGE[1]:g} Hz: " + (", ".join(no_peak) if no_peak else "none of them"))
    print()
    print("Pitfalls: pf-band-power-slope, pf-harmonics-as-pac, pf-narrowband-filter-oscillation, "
          "pf-notch-hole-in-band (this recording has NO notch at any stage, so its 50 Hz mains line is "
          "intact and visible in section 2's spectrum -- the pitfall is what a notch would have done to a "
          "band that touches it). Widgets: w-burst-detector, w-aperiodic-explorer (mode compare).")
    print("TODO(confirm): what LEMON's marker codes mean (inferred here from relative occipital alpha); the "
          "exact dataset terms behind the CC BY 4.0 record (spec section 13 item 5); and the +/- 2 Hz "
          "individualised band, which is a convention rather than a measurement.")
finally:
    dl.finish()
nb-4-6-bursts -- L4.6 numbers (draft; TODO(confirm) at author review)
Data: ds-lemon sub-010002, block 6 (code S210, 378.027 s), 60 s, channel O2, resampled 2500 -> 250 Hz with a 100 Hz anti-alias low-pass; mean removed; nothing else filtered. Licence CC-BY-4.0 per the data descriptor; the NITRC/INDI page references an Open Data (PDDL-style) dedication; exact dataset terms TODO(confirm) (spec section 13 item 5)
Fetched as a byte range: 18.6 MB of a 317 MB file, plus two 12-s probes to identify the eyes-closed marker code.
Eyes-closed code inferred from the signal: S210 (relative occipital alpha S210 +0.085, S200 -0.042). label_source: algorithmic; TODO(confirm) against the dataset descriptor.

ANSWER KEY -- ex-4-6: does this channel have a spectral peak, and what do bursts do with the threshold?
    Spectral peak: YES, at 10.272 Hz (specparam specparam 2.0.0rc4, aperiodic exponent 1.114, r^2 0.975). Band power in 8-13 Hz is therefore about a rhythm for this channel, which is what makes it a fair subject for the rest of the lesson.
    Cross-check: w-burst-detector/envelope.json records 10.272 Hz, exponent 1.114 and r^2 0.975. This notebook AGREES (difference +0.00020 Hz).

    Bursts in 8-13 Hz, minimum 3 cycles of the band centre (285.7 ms -> 72 samples, ceil), edges excluded (0.824 s each end, 58.352 s analysed):
        50th percentile =   6.69 uV ->  33 bursts,  33.9/min, mean   712 ms, duty  40.3 %   [key 33 / 40.3 % -> agrees]
        75th percentile =  10.13 uV ->  19 bursts,  19.5/min, mean   547 ms, duty  17.8 %   [key 19 / 17.8 % -> agrees]
        95th percentile =  16.16 uV ->   3 bursts,   3.1/min, mean   493 ms, duty   2.5 %   [key 3 / 2.5 % -> agrees]
    Cross-check against site/notes/integration-phase3.md (widgets-K): ALL THREE ROWS AGREE.
    The lesson: the rate falls 11x and the duty cycle 16x on one free parameter, with the signal untouched.

    The conventions are choices, and two of them move the count:
        edges excluded, ceil (this notebook and the widget)  p50  33 (40.3 %)  p75  19 (17.8 %)  p95   3 (2.5 %)
        edges excluded, round (the shipped sidecar's rule)   p50  34 (40.8 %)  p75  19 (17.8 %)  p95   3 (2.5 %)
        whole trace, ceil                                    p50  33 (40.4 %)  p75  18 (17.9 %)  p95   4 (4.0 %)
        whole trace, round                                   p50  33 (40.4 %)  p75  18 (17.9 %)  p95   4 (4.0 %)
        the shipped sidecar's own row (1.5 x whole-trace median, 0.284 s, round, whole trace): 20 bursts, 19.07 % -- the sidecar records 20 and 19.07 %.

Supporting -- cycle-by-cycle (bycycle 1.2.0, burst_method='cycles'):
    rise-decay symmetry median 0.4599, peak-trough symmetry median 0.5000 over 292 burst cycles (0.5 would be sinusoidal); median cycle frequency 10.417 Hz, median amplitude 33.03 uV.

Supporting -- burst rates per subject (5 LEMON subjects, one 60-s eyes-closed block each, one occipital channel each chosen by relative alpha):
    sub-010002  O2: peak 10.272 Hz, exponent 1.114, SD 21.68 uV | bursts  33/ 19/  3 | duty  40.3/ 17.8/  2.5 %
    sub-010003  O2: peak 10.442 Hz, exponent 1.533, SD 27.05 uV | bursts  49/ 15/  2 | duty  37.2/ 10.8/  1.2 %
    sub-010004  O2: peak 10.502 Hz, exponent 1.536, SD 23.59 uV | bursts  43/ 15/  3 | duty  37.5/ 14.1/  3.6 %
    sub-010005  O1: peak  8.953 Hz, exponent 1.960, SD 26.23 uV | bursts  28/ 15/  5 | duty  37.9/ 17.7/  3.2 %
    sub-010006  Oz: peak  8.258 Hz, exponent 1.324, SD 21.37 uV | bursts  32/ 18/  4 | duty  40.6/ 16.5/  3.3 %
    Subjects with no fitted alpha peak in 7-13 Hz: none of them

Pitfalls: pf-band-power-slope, pf-harmonics-as-pac, pf-narrowband-filter-oscillation, pf-notch-hole-in-band (this recording has NO notch at any stage, so its 50 Hz mains line is intact and visible in section 2's spectrum -- the pitfall is what a notch would have done to a band that touches it). Widgets: w-burst-detector, w-aperiodic-explorer (mode compare).
TODO(confirm): what LEMON's marker codes mean (inferred here from relative occipital alpha); the exact dataset terms behind the CC BY 4.0 record (spec section 13 item 5); and the +/- 2 Hz individualised band, which is a convention rather than a measurement.
deleted 0 downloaded file(s), 0.0 MiB freed
free disk after nb-4-6: 4,621 MB (+3 MB against the start of the notebook; the volume is shared, so anything else running on it moves this number too)