A simulated real-time pipeline: the end-to-end latency budget derived, measured with a loopback probe and checked against the widget, then an alpha-envelope feedback loop with sham controls and the false-positive rate of the naive within-session test

nb-7-3-realtime Level 7 · Applied Electives ~3 min Used in L7.3 · Real-time processing and neurofeedback

Downloads from ds-eegbci 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-7-3-realtime · Real-time processing and neurofeedback (L7.3)

Lesson L7.3 · Level 7 · Status draft — for expert review; uncertain points carry TODO(confirm).

A real-time pipeline is the same signal processing as every level before this one, with one thing taken away: it may not look at the future. This notebook builds one, budgets its latency, measures the budget instead of trusting it, and then asks the question the lesson exists for — how would you know the feedback did anything?

  1. The latency budget, derived from first principles and then measured with a loopback probe.
  2. Causal versus zero-phase, made concrete: how far into the future a zero-phase filter has to see before it can answer, which is why it cannot run live.
  3. The ring buffer and block-wise replay. Filtering block by block with carried state is bit-for-bit the same signal as filtering the whole recording; filtering each block independently is pf-filter-across-boundaries and is not.
  4. An alpha-envelope feedback loop on replayed ds-eegbci data, with a positive control: eyes closed against eyes open.
  5. A sham-control mode, and the measurement that makes the lesson's point — the naive "did it go up during the session?" test, run on replayed recordings where learning is impossible by construction, so every significant result is a false positive.

Data. ds-eegbci — EEG Motor Movement/Imagery Dataset (EEGMMIDB), Schalk, McFarland, Hinterberger, Birbaumer & Wolpaw (2004), BCI2000, IEEE TBME 51(6), 1034–1043, DOI 10.1109/TBME.2004.827072; PhysioNet v1.0.0, DOI 10.13026/C28G6P. Licence ODC-By 1.0, access: open. Runs R01 (eyes open) and R02 (eyes closed), channel O1, 160 Hz, no hardware filters. About 1.2 MB per run.

The pipeline this notebook budgets is the one w-latency-budget opens on: 160 Hz, an 8–12 Hz band, a 129-tap linear-phase FIR, a 32-sample block, 10 ms of stated processing, causal. Every number below was derived here independently and then compared against the widget's; section 1.4 prints the comparison.

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting, a quiet downloader.
import importlib.util
import subprocess
import sys
import time
import warnings
from pathlib import Path

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

_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l7.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L7/ (or notebooks/) so that _shared/helpers_l7.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l6 as L6
import helpers_l7 as L7

import matplotlib.pyplot as plt
import numpy as np
import mne
import pooch
from scipy import stats
from scipy.signal import lfilter, sosfiltfilt

mne.set_log_level("WARNING")
# Silence the downloader at the source rather than scrubbing cache paths out of stored outputs
# afterwards: a re-execution would put them straight back (Phase 3 note in notebooks/README.md).
pooch.get_logger().setLevel("WARNING")
plt.rcParams["figure.dpi"] = 72

FS = float(L7.LATENCY_SPEC["sfreq_hz"])
BAND = tuple(L7.LATENCY_SPEC["band_hz"])
N_TAPS = int(L7.LATENCY_SPEC["fir_taps"])
BLOCK = int(L7.LATENCY_SPEC["block_samples"])
PROCESSING_MS = float(L7.LATENCY_SPEC["processing_ms"])
SUBJECTS = [f"S{i:03d}" for i in range(1, 13)]     # ds-eegbci baseline runs, 12 subjects
SEGMENT_S = 60.0
SEED = L7.SEED

print(f"MNE {mne.__version__}; helpers_l7 imported from notebooks/_shared")
print(f"pipeline: {FS:.0f} Hz, {BAND[0]:g}-{BAND[1]:g} Hz, {N_TAPS}-tap FIR, {BLOCK}-sample block, "
      f"{PROCESSING_MS:g} ms processing, causal")
MNE 1.10.2; helpers_l7 imported from notebooks/_shared
pipeline: 160 Hz, 8-12 Hz, 129-tap FIR, 32-sample block, 10 ms processing, causal

1 · The latency budget

End-to-end latency is the time between an event in the signal and the feedback reflecting it. An event at sample n appears in the filter output at n + D, where D is the group delay. That output sample then waits for its block to complete. Then the stated processing delay.

$$\text{worst case} = \frac{D}{f_s} + \text{buffer} + P \qquad \text{mean} = \frac{D}{f_s} + \frac{\text{buffer}}{2} + P$$

1.1 · The filter row

A linear-phase FIR delays every frequency in its pass-band by the same amount, exactly (N − 1)/2 samples. That is worth checking three ways rather than asserting, because it is the largest row in the budget.

In [2]:
taps = L7.fir_taps(N_TAPS, BAND, FS)
algebra = L7.fir_group_delay_samples(N_TAPS)

from scipy.signal import group_delay
_, gd = group_delay((taps, [1.0]), w=[2 * np.pi * f / FS for f in (8.0, 10.0, 12.0)])

imp = np.zeros(2048)
imp[512] = 1.0
resp = lfilter(taps, [1.0], imp)
peak = int(np.argmax(np.abs(resp))) - 512
centroid = L7.energy_centroid(np.arange(len(resp)), resp) - 512

print(f"{'method':52s} {'samples':>10s} {'ms':>9s}")
print(f"{'(N - 1) / 2, the algebra':52s} {algebra:10.4f} {algebra / FS * 1000:9.4f}")
for f, g in zip((8.0, 10.0, 12.0), gd):
    print(f"{f'scipy.signal.group_delay at {f:g} Hz':52s} {g:10.4f} {g / FS * 1000:9.4f}")
print(f"{'position of a filtered impulse':52s} {peak:10.4f} {peak / FS * 1000:9.4f}")
print(f"{'energy centroid of the impulse response':52s} {centroid:10.4f} "
      f"{centroid / FS * 1000:9.4f}")
print(f"\ntransition band for {N_TAPS} taps (Hamming, 3.3 fs / N): {3.3 * FS / N_TAPS:.2f} Hz — "
      f"this is what the {N_TAPS} taps are buying, and the 400 ms is what they cost")
method                                                  samples        ms
(N - 1) / 2, the algebra                                64.0000  400.0000
scipy.signal.group_delay at 8 Hz                        64.0000  400.0000
scipy.signal.group_delay at 10 Hz                       64.0000  400.0000
scipy.signal.group_delay at 12 Hz                       64.0000  400.0000
position of a filtered impulse                          64.0000  400.0000
energy centroid of the impulse response                 64.0000  400.0000

transition band for 129 taps (Hamming, 3.3 fs / N): 4.09 Hz — this is what the 129 taps are buying, and the 400 ms is what they cost

1.2 · The buffer row, and the convention it depends on

The buffer term has two defensible readings that differ by exactly one sample. w-latency-budget reported the disagreement rather than picking one, and site/notes/integration-phase4.md settled it: the block-period reading is the one this site teaches, because a sample is not available until its acquisition period completes, so a block of B samples is handed over at the end of the last sample's period and the oldest sample in it has waited a full B/f_s.

The other reading is a real quantity and is printed beside it, because — in the words of the shipped asset — quoting one while meaning the other is the commonest error in a latency budget.

In [3]:
for k, v in L7.BUFFER_CONVENTIONS.items():
    print(f"[{k}]\n  {v}\n")
print(f"at {FS:.0f} Hz with a {BLOCK}-sample block:")
print(f"  block-period   B / fs       = {BLOCK} / {FS:.0f} = {BLOCK / FS * 1000:.2f} ms")
print(f"  first-to-last  (B - 1) / fs = {BLOCK - 1} / {FS:.0f} = {(BLOCK - 1) / FS * 1000:.2f} ms")
print(f"  they differ by exactly one sample = {1 / FS * 1000:.2f} ms, for any block size")
print(f"  loop update rate = fs / B = {FS / BLOCK:.2f} Hz")
[block-period]
  B / fs — the whole acquisition period of the block.  A sample is not available until its acquisition period completes, so a block of B samples is handed over at the end of the last sample's period and the oldest sample in it has waited a full B / fs.  This is the convention w-latency-budget defaults to, the one its shipped asset defines, and the one site/notes/integration-phase4.md settles on.

[first-to-last]
  (B - 1) / fs — the difference between the arrival instants of the first and the last sample of the block.  A real quantity, and not the wait: it is what 'how long did this sample wait' means if a sample is treated as an instant rather than as an interval.  One sample smaller, which is 6.25 ms at 160 Hz for any block size.

[plus-hold]
  B / fs again on top, for the time the displayed value stays on screen before the next update.  That answers 'how stale is the number in front of me right now', which is a different question from 'how late does an event appear', and it is not budgeted here.

at 160 Hz with a 32-sample block:
  block-period   B / fs       = 32 / 160 = 200.00 ms
  first-to-last  (B - 1) / fs = 31 / 160 = 193.75 ms
  they differ by exactly one sample = 6.25 ms, for any block size
  loop update rate = fs / B = 5.00 Hz

1.3 · The budget

In [4]:
budget = L7.latency_budget()
L7.print_budget(budget, title=f"{FS:.0f} Hz, {BAND[0]:g}-{BAND[1]:g} Hz, {N_TAPS}-tap FIR, "
                              f"{BLOCK}-sample block, {PROCESSING_MS:g} ms processing, causal")
160 Hz, 8-12 Hz, 129-tap FIR, 32-sample block, 10 ms processing, causal
row          detail                                           samples        ms
--------------------------------------------------------------------------------
filter       FIR group delay (N - 1) / 2, 129 taps              64.00    400.00
buffer       32 samples, block-period                           32.00    200.00
processing   stated, not measured                                1.60     10.00
--------------------------------------------------------------------------------
TOTAL        worst case, block-period                           97.60    610.00
             worst case, first-to-last                                   603.75
             mean over block position                                    510.00

loop update rate 5.00 Hz (32 samples at 160 Hz)

1.4 · Does it agree with the widget?

site/notes/widget-latency.md publishes the numbers w-latency-budget asserts in its own unit tests, computed independently in TypeScript against a SciPy reference. Everything above was derived here from scratch. A disagreement would be a finding; this is the check.

In [5]:
widget = {                 # site/notes/widget-latency.md, sections 2.1 and 2.2
    "FIR group delay, 129 taps (ms)": 400.00,
    "buffer, 32 samples, block period (ms)": 200.00,
    "buffer, 32 samples, first-to-last (ms)": 193.75,
    "total, worst case, block period (ms)": 610.00,
    "total, worst case, first-to-last (ms)": 603.75,
    "total, mean over block position (ms)": 510.00,
    "loop update rate (Hz)": 5.00,
    "zero-phase FIR look-ahead, 129 taps (ms)": 800.00,
    "FIR group delay, 65 taps (ms)": 200.00,
    "FIR group delay, 257 taps (ms)": 800.00,
    "FIR group delay, 513 taps (ms)": 1600.00,
    "transition band, 129 taps (Hz)": 4.09,
    "Butterworth group delay at 10 Hz, order 2 (samples)": 17.802755,
    "Butterworth group delay at 10 Hz, order 4 (samples)": 32.727137,
    "Butterworth group delay at 10 Hz, order 6 (samples)": 48.368038,
    "Butterworth group delay at 10 Hz, order 8 (samples)": 64.159335,
    "zero-phase Butterworth look-ahead, order 2 (samples)": 83,
    "zero-phase Butterworth look-ahead, order 4 (samples)": 145,
    "zero-phase Butterworth look-ahead, order 6 (samples)": 186,
    "zero-phase Butterworth look-ahead, order 8 (samples)": 226,
}
other = L7.latency_budget(convention="first-to-last")
mine = {
    "FIR group delay, 129 taps (ms)": L7.fir_group_delay_samples(129) / FS * 1000,
    "buffer, 32 samples, block period (ms)": BLOCK / FS * 1000,
    "buffer, 32 samples, first-to-last (ms)": (BLOCK - 1) / FS * 1000,
    "total, worst case, block period (ms)": budget["total_ms"],
    "total, worst case, first-to-last (ms)": other["total_ms"],
    "total, mean over block position (ms)": budget["mean_ms"],
    "loop update rate (Hz)": budget["update_rate_hz"],
    "zero-phase FIR look-ahead, 129 taps (ms)":
        L7.zero_phase_lookahead_samples(taps, fir=True) / FS * 1000,
    "FIR group delay, 65 taps (ms)": L7.fir_group_delay_samples(65) / FS * 1000,
    "FIR group delay, 257 taps (ms)": L7.fir_group_delay_samples(257) / FS * 1000,
    "FIR group delay, 513 taps (ms)": L7.fir_group_delay_samples(513) / FS * 1000,
    "transition band, 129 taps (Hz)": 3.3 * FS / 129,
}
for o in (2, 4, 6, 8):
    sos = L7.butter_sos(o, BAND, FS)
    mine[f"Butterworth group delay at 10 Hz, order {o} (samples)"] = \
        L7.iir_group_delay_samples(sos, 10.0, FS)
    mine[f"zero-phase Butterworth look-ahead, order {o} (samples)"] = \
        L7.zero_phase_lookahead_samples(sos, fir=False, fs=FS)

print(f"{'quantity':54s} {'this notebook':>14s} {'the widget':>12s} {'difference':>12s}")
print("-" * 96)
worst = 0.0
for k, v in widget.items():
    m = mine[k]
    d = m - v
    worst = max(worst, abs(d))
    print(f"{k:54s} {m:14.6f} {v:12.6f} {d:12.3e}")
print("-" * 96)
print(f"largest disagreement anywhere: {worst:.3e}")
assert worst < 5e-3, "a number disagrees with the widget: report both, change nothing"
print("Every quantity agrees.  The independent derivation and the widget's TypeScript, checked "
      "against SciPy, give the same answers.")
print("The one non-zero row is the transition band: site/notes/widget-latency.md quotes 4.09 Hz to "
      f"two decimals and the exact value is {3.3 * FS / 129:.4f} Hz.  That is the published figure "
      "being rounded, not a disagreement.")
quantity                                                this notebook   the widget   difference
------------------------------------------------------------------------------------------------
FIR group delay, 129 taps (ms)                             400.000000   400.000000    0.000e+00
buffer, 32 samples, block period (ms)                      200.000000   200.000000    0.000e+00
buffer, 32 samples, first-to-last (ms)                     193.750000   193.750000    0.000e+00
total, worst case, block period (ms)                       610.000000   610.000000    0.000e+00
total, worst case, first-to-last (ms)                      603.750000   603.750000    0.000e+00
total, mean over block position (ms)                       510.000000   510.000000    0.000e+00
loop update rate (Hz)                                        5.000000     5.000000    0.000e+00
zero-phase FIR look-ahead, 129 taps (ms)                   800.000000   800.000000    0.000e+00
FIR group delay, 65 taps (ms)                              200.000000   200.000000    0.000e+00
FIR group delay, 257 taps (ms)                             800.000000   800.000000    0.000e+00
FIR group delay, 513 taps (ms)                            1600.000000  1600.000000    0.000e+00
transition band, 129 taps (Hz)                               4.093023     4.090000    3.023e-03
Butterworth group delay at 10 Hz, order 2 (samples)         17.802755    17.802755   -6.845e-08
Butterworth group delay at 10 Hz, order 4 (samples)         32.727137    32.727137    4.633e-07
Butterworth group delay at 10 Hz, order 6 (samples)         48.368038    48.368038   -4.590e-07
Butterworth group delay at 10 Hz, order 8 (samples)         64.159335    64.159335    4.626e-07
zero-phase Butterworth look-ahead, order 2 (samples)        83.000000    83.000000    0.000e+00
zero-phase Butterworth look-ahead, order 4 (samples)       145.000000   145.000000    0.000e+00
zero-phase Butterworth look-ahead, order 6 (samples)       186.000000   186.000000    0.000e+00
zero-phase Butterworth look-ahead, order 8 (samples)       226.000000   226.000000    0.000e+00
------------------------------------------------------------------------------------------------
largest disagreement anywhere: 3.023e-03
Every quantity agrees.  The independent derivation and the widget's TypeScript, checked against SciPy, give the same answers.
The one non-zero row is the transition band: site/notes/widget-latency.md quotes 4.09 Hz to two decimals and the exact value is 4.0930 Hz.  That is the published figure being rounded, not a disagreement.

1.5 · Why the IIR group delay is summed over sections

Expanding a cascade of second-order sections into one transfer function and differentiating its phase is numerically unstable at high order. w-latency-budget hit exactly this: an eighth-order Butterworth came back with a smaller delay than a sixth-order one, which would teach a learner that a steeper filter is faster. Group delay is additive over cascaded sections, so summing per section is stable, and the phase derivative of the full cascade is the independent check.

In [6]:
from scipy.signal import sos2tf

print(f"{'order':>6s} {'sum of sections':>16s} {'phase derivative':>18s} {'expanded (b, a)':>17s} "
      f"{'ms':>9s}")
complaints = set()
for o in (2, 4, 6, 8):
    sos = L7.butter_sos(o, BAND, FS)
    per_section = L7.iir_group_delay_samples(sos, 10.0, FS)
    by_phase = L7.iir_group_delay_by_phase(sos, 10.0, FS)
    b, a = sos2tf(sos)
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        _, expanded = group_delay((b, a), w=[2 * np.pi * 10.0 / FS])
    complaints.update(str(w.message).split(";")[0] for w in caught)
    print(f"{o:6d} {per_section:16.6f} {by_phase:18.6f} {float(expanded[0]):17.6f} "
          f"{per_section / FS * 1000:9.4f}")
print("\nThe first two columns agree to six decimals at every order.  The third is the expansion, and "
      "it is the one that says an order-8 filter is faster than an order-6 one.")
for c in sorted(complaints):
    print(f"  SciPy says so itself while computing that column: \"{c}\"")
 order  sum of sections   phase derivative   expanded (b, a)        ms
     2        17.802755          17.802755         17.802755  111.2672
     4        32.727137          32.727137         32.727137  204.5446
     6        48.368038          48.368038         48.350036  302.3002
     8        64.159335          64.159335          3.432598  400.9958

The first two columns agree to six decimals at every order.  The third is the expansion, and it is the one that says an order-8 filter is faster than an order-6 one.
  SciPy says so itself while computing that column: "The filter's denominator is extremely small at frequencies [0.393], around which a singularity may be present"

2 · Causal versus zero-phase

A zero-phase filter has no delay because it runs forwards and then backwards, which means it needs the signal's future. "Cannot run causally" is not an assertion to take on trust: the question has a number, and the number is how far into the future it has to see.

In [7]:
print(f"{'filter':34s} {'causal group delay':>20s} {'zero-phase look-ahead':>23s} {'ratio':>7s}")
rows = [(f"FIR, {N_TAPS} taps",
         L7.fir_group_delay_samples(N_TAPS),
         L7.zero_phase_lookahead_samples(taps, fir=True))]
for o in (2, 4, 6, 8):
    sos = L7.butter_sos(o, BAND, FS)
    rows.append((f"Butterworth, order {o}",
                 L7.iir_group_delay_samples(sos, 10.0, FS),
                 L7.zero_phase_lookahead_samples(sos, fir=False, fs=FS)))
for name, causal, ahead in rows:
    print(f"{name:34s} {causal / FS * 1000:17.2f} ms {ahead / FS * 1000:20.2f} ms "
          f"{ahead / causal:7.2f}")
print("\nFor the FIR the look-ahead is exact: N - 1 samples, the support of the kernel, which is "
      "twice the causal group delay of the same filter.  For an IIR there is no exact answer, only "
      "a truncation criterion — how far before an impulse the forward-backward response still "
      "exceeds 1 % of its peak.  A tighter tolerance demands a longer wait, so the criterion is "
      "part of the number and is quoted with it.")
zp = L7.latency_budget(phase="zero-phase-live")
print(f"\nA 'zero-phase' loop that waits for its kernel rather than cheating: "
      f"{zp['total_ms']:.2f} ms end to end against {budget['total_ms']:.2f} ms causal — "
      f"{zp['total_ms'] / budget['total_ms']:.2f}x, for a filter that is usually described as "
      f"having no delay at all.")
filter                               causal group delay   zero-phase look-ahead   ratio
FIR, 129 taps                                 400.00 ms               800.00 ms    2.00
Butterworth, order 2                          111.27 ms               518.75 ms    4.66
Butterworth, order 4                          204.54 ms               906.25 ms    4.43
Butterworth, order 6                          302.30 ms              1162.50 ms    3.85
Butterworth, order 8                          401.00 ms              1412.50 ms    3.52

For the FIR the look-ahead is exact: N - 1 samples, the support of the kernel, which is twice the causal group delay of the same filter.  For an IIR there is no exact answer, only a truncation criterion — how far before an impulse the forward-backward response still exceeds 1 % of its peak.  A tighter tolerance demands a longer wait, so the criterion is part of the number and is quoted with it.

A 'zero-phase' loop that waits for its kernel rather than cheating: 1010.00 ms end to end against 610.00 ms causal — 1.66x, for a filter that is usually described as having no delay at all.
In [8]:
budgets = {
    "FIR 129, causal": budget,
    "FIR 129, zero-phase live": zp,
    "Butterworth 4, causal": L7.latency_budget(sos=L7.butter_sos(4, BAND, FS)),
    "Butterworth 8, causal": L7.latency_budget(sos=L7.butter_sos(8, BAND, FS)),
}
fig, _ = L7.plot_latency_budget(budgets)
plt.show()
Figure 1 of notebook nb-7-3-realtime, an output plot. The text around it states what it shows and the units of every axis.

3 · The ring buffer, and what block-wise filtering does and does not change

Filtering block by block with the filter state carried across blocks is bit-for-bit the same signal as filtering the whole recording at once. Only the timing changes. Filtering each block independently is a different thing entirely, and it is pf-filter-across-boundaries.

In [9]:
rng = np.random.default_rng(SEED)
x = rng.standard_normal(16000)

whole = lfilter(taps, [1.0], x)
online = L7.OnlineFIR(taps)
blockwise = np.concatenate([online.process(b)[0] for _, b in L7.stream_blocks(x, BLOCK)])
err = np.abs(blockwise - whole[: len(blockwise)]).max() / np.std(x)
print(f"causal FIR, carried state:  max |error| / signal RMS = {err:.3e}")

sos = L7.butter_sos(4, BAND, FS)
ref = sosfiltfilt(sos, x)
per_block = np.concatenate([sosfiltfilt(sos, b[0])      # scipy's own default padding
                            for _, b in L7.stream_blocks(x, BLOCK)])
err2 = np.std(per_block - ref[: len(per_block)]) / np.std(ref)
print(f"zero-phase, each block on its own: error RMS / signal RMS = {err2:.3f}")
print(f"\nThe first is machine precision.  The second is {100 * err2:.0f} % of the signal — the same "
      f"filter, applied the same number of times, to blocks instead of to a recording.  That is "
      f"pf-filter-across-boundaries, and it is why the loop carries state.")

ring = L7.RingBuffer(1, capacity=4 * BLOCK)
for _, b in L7.stream_blocks(x[:400], BLOCK):
    ring.push(b)
print(f"\nring buffer: capacity {ring.capacity} samples, {ring.n_pushed} pushed, "
      f"latest({BLOCK}) returns {ring.latest(BLOCK).shape} and matches the last block: "
      f"{np.allclose(ring.latest(BLOCK)[0], x[384 - BLOCK:384])}")
causal FIR, carried state:  max |error| / signal RMS = 2.203e-16
zero-phase, each block on its own: error RMS / signal RMS = 1.046

The first is machine precision.  The second is 105 % of the signal — the same filter, applied the same number of times, to blocks instead of to a recording.  That is pf-filter-across-boundaries, and it is why the loop carries state.

ring buffer: capacity 128 samples, 384 pushed, latest(32) returns (1, 32) and matches the last block: True

4 · Measuring the budget instead of trusting it

A loopback probe: one Hann-windowed packet at the band centre, injected into silence and timed by its energy centroid, which is what you would do on a real rig.

Cross-correlating the pipeline output against an offline reference does not work here and fails quietly — both signals are narrow-band, so the correlation peaks at every multiple of the carrier period. w-latency-budget found that the hard way and the fix is this probe.

Two lags come back. The filtered stream's lag must equal the group delay exactly. The feedback value's lag must equal the group delay plus (B + 1)/2 samples: the block's own centre of mass sits at (B − 1)/2 and the value does not exist until the block completes at B. That is half a sample away from the block-period convention's mean, for the same instant-versus-interval reason that separates the two conventions.

In [10]:
probe = L7.measure_loop_delay()
print(f"{'quantity':44s} {'measured':>12s} {'predicted':>12s} {'difference':>12s}")
print("-" * 84)
print(f"{'filter stream, energy-centroid lag (ms)':44s} {probe['filter_ms']:12.4f} "
      f"{probe['expected_filter_ms']:12.4f} "
      f"{probe['filter_ms'] - probe['expected_filter_ms']:12.2e}")
print(f"{'feedback value, energy-centroid lag (ms)':44s} {probe['value_ms']:12.4f} "
      f"{probe['expected_value_ms']:12.4f} "
      f"{probe['value_ms'] - probe['expected_value_ms']:12.2e}")
print("-" * 84)
print(f"spread over the {probe['alignments']} phases of the block grid: "
      f"{probe['value_min_ms']:.2f}-{probe['value_max_ms']:.2f} ms "
      f"(SD {probe['value_sd_ms']:.2f}); that spread IS the buffer term")
print(f"criterion: {probe['criterion']}")
print(f"\nThe budget's worst case is {budget['total_ms']:.2f} ms and its mean is "
      f"{budget['mean_ms']:.2f} ms; the probe measures the centre of mass, not the worst case, and "
      f"the simulation has no processing stage, so the number to compare it with is "
      f"{budget['mean_ms'] - PROCESSING_MS:.2f} ms plus the half sample above.")
quantity                                         measured    predicted   difference
------------------------------------------------------------------------------------
filter stream, energy-centroid lag (ms)          399.9995     400.0000    -4.56e-04
feedback value, energy-centroid lag (ms)         503.1245     503.1250    -4.56e-04
------------------------------------------------------------------------------------
spread over the 32 phases of the block grid: 495.58-510.67 ms (SD 5.77); that spread IS the buffer term
criterion: energy centroid of a Hann-windowed packet at the band centre

The budget's worst case is 610.00 ms and its mean is 510.00 ms; the probe measures the centre of mass, not the worst case, and the simulation has no processing stage, so the number to compare it with is 500.00 ms plus the half sample above.
In [11]:
fig, _ = L7.plot_feedback(probe, budget_ms=budget["total_ms"])
plt.show()
Figure 2 of notebook nb-7-3-realtime, an output plot. The text around it states what it shows and the units of every axis.

Every stage you add is more latency

The loop above adds no smoothing beyond the block itself, which is why its measured delay matches the arithmetic. A smoother buys a steadier number on the screen, and it is paid for in milliseconds.

In [12]:
print(f"{'smoothing time constant':>24s} {'filter lag (ms)':>16s} {'feedback lag (ms)':>18s} "
      f"{'added (ms)':>11s}")
base = None
for smooth in (0.0, 0.25, 0.5, 1.0):
    pr = L7.measure_loop_delay(smooth_s=smooth, alignments=8)
    base = pr["value_ms"] if base is None else base
    print(f"{smooth:21.2f} s {pr['filter_ms']:16.2f} {pr['value_ms']:18.2f} "
          f"{pr['value_ms'] - base:11.2f}")
print("\nThe filter row does not move; the smoother is a fourth row the budget above does not have. "
      "A neurofeedback protocol that reports 'a 0.5-second smoothing window' has reported a quarter "
      "of a second of latency without saying so.")
 smoothing time constant  filter lag (ms)  feedback lag (ms)  added (ms)
                 0.00 s           400.00             503.12        0.00
                 0.25 s           400.00             620.87      117.75
                 0.50 s           400.00             762.31      259.19
                 1.00 s           400.00            1024.23      521.10

The filter row does not move; the smoother is a fourth row the budget above does not have. A neurofeedback protocol that reports 'a 0.5-second smoothing window' has reported a quarter of a second of latency without saying so.

5 · A feedback loop on real data

ds-eegbci runs R01 and R02 are one minute of eyes-open and one minute of eyes-closed baseline from the same subject, recorded with no hardware filters. Replaying them through the loop gives a positive control: the occipital alpha rhythm is genuinely larger with the eyes closed, so a loop that measures anything at all must see it.

In [13]:
L7.disk_report("before any download")
runs = ("R01", "R02")
already_on_disk = [f for s in SUBJECTS for f in L6.eegbci_run_files(s, runs)]
print(f"{len(already_on_disk)} of the {2 * len(SUBJECTS)} runs were already cached "
      f"(the ds-eegbci cache is shared with Levels 0, 4, 5 and 6, so only what this run fetches "
      f"is deleted)")

t0 = time.time()
eyes_open, eyes_closed = {}, {}
n = int(SEGMENT_S * FS)
for s in SUBJECTS:
    for run, store in (("R01", eyes_open), ("R02", eyes_closed)):
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            raw = helpers.load_spine("ds-eegbci", s, [run], verbose=False).pick(["O1"])
        assert raw.info["sfreq"] == FS, f"{s} {run}: {raw.info['sfreq']} Hz, not {FS}"
        store[s] = raw.get_data()[0][:n] * 1e6
print(f"{len(SUBJECTS)} subjects x 2 runs in {time.time() - t0:.1f} s; "
      f"{SEGMENT_S:.0f} s of O1 each at {FS:.0f} Hz")
assert len(eyes_open) == len(eyes_closed) == len(SUBJECTS)
L7.disk_report("after the downloads")
free disk before any download: 1.93 GB
24 of the 24 runs were already cached (the ds-eegbci cache is shared with Levels 0, 4, 5 and 6, so only what this run fetches is deleted)
12 subjects x 2 runs in 1.9 s; 60 s of O1 each at 160 Hz
free disk after the downloads: 1.93 GB
Out[13]:
{'free_gb': 1.92585728, 'folders_mb': {}}
In [14]:
ec = np.array([L7.alpha_feedback(eyes_closed[s])["values"].mean() for s in SUBJECTS])
eo = np.array([L7.alpha_feedback(eyes_open[s])["values"].mean() for s in SUBJECTS])
t_ec, p_ec = stats.ttest_rel(ec, eo)
print(f"{'subject':>8s} {'eyes closed':>12s} {'eyes open':>11s} {'ratio':>7s}   (µV RMS per block)")
for s, a, b in zip(SUBJECTS, ec, eo):
    print(f"{s:>8s} {a:12.3f} {b:11.3f} {a / b:7.2f}")
print(f"\nmean {ec.mean():.3f} against {eo.mean():.3f} µV, {ec.mean() / eo.mean():.2f}x; "
      f"{int((ec > eo).sum())} of {len(SUBJECTS)} subjects higher with the eyes closed; "
      f"t({len(ec) - 1}) = {t_ec:.2f}, p = {p_ec:.2e}")
print("The loop measures something real.  Everything in section 6 is about whether a CHANGE in it "
      "during a session means anything.")
 subject  eyes closed   eyes open   ratio   (µV RMS per block)
    S001       51.828      10.803    4.80
    S002       22.966       6.385    3.60
    S003       49.952      10.690    4.67
    S004       20.333       3.411    5.96
    S005        4.916       3.960    1.24
    S006        3.466       3.082    1.12
    S007       32.950      15.506    2.12
    S008       14.108       4.913    2.87
    S009       15.904       7.646    2.08
    S010       35.931       9.978    3.60
    S011       14.864       3.408    4.36
    S012        4.388       3.519    1.25

mean 22.634 against 6.942 µV, 3.26x; 12 of 12 subjects higher with the eyes closed; t(11) = 3.94, p = 2.31e-03
The loop measures something real.  Everything in section 6 is about whether a CHANGE in it during a session means anything.
In [15]:
one = SUBJECTS[0]
fb = L7.alpha_feedback(eyes_closed[one])
truth = L7.offline_envelope(eyes_closed[one], FS, BAND)
t_s = np.arange(len(eyes_closed[one])) / FS
fig, axes = plt.subplots(2, 1, figsize=(11, 4.6), sharex=True)
axes[0].plot(t_s, eyes_closed[one], lw=0.5)
axes[0].set_ylabel("O1 (µV)")
axes[0].set_title(f"{one} R02, eyes closed, O1 — the replayed signal (µV)", fontsize=10)
axes[1].plot(t_s, truth, lw=0.8, label="offline zero-phase envelope (needs the future)")
axes[1].step(fb["times_s"], fb["values"], where="post", lw=1.1,
             label=f"live feedback, {fb['update_rate_hz']:.0f} Hz update (causal)")
axes[1].set_ylabel("Alpha amplitude\n(µV)")
axes[1].set_xlabel("Time (s)")
axes[1].legend(fontsize=8)
for a in axes:
    a.grid(alpha=0.3)
fig.suptitle(f"What the loop can see against what an offline analysis would report "
             f"(µV; {BAND[0]:g}-{BAND[1]:g} Hz)", fontsize=10)
fig.tight_layout()
plt.show()
Figure 3 of notebook nb-7-3-realtime, an output plot. The text around it states what it shows and the units of every axis.

6 · The sham control, and what neurofeedback evidence requires

§6 asks this notebook for a sham-control mode, because that is the lesson's point about evidence. The four conditions below are the ones a neurofeedback study can run, and each controls for something different.

In [16]:
for k, v in L7.SHAM_MODES.items():
    print(f"[{k}]\n  {v}\n")
[veridical]
  Feedback computed from this participant's own band-limited signal, in real time.  The experimental condition.

[sham-yoked]
  Feedback replayed from a DIFFERENT recording, played back on the same schedule.  It looks and moves like feedback and carries no information about this participant's brain, so it controls for the display, the task, the time on task and the expectation, and it is the control CRED-nf asks for by name.

[sham-band]
  Feedback computed from the same participant, in real time, from a control band the protocol does not target.  It controls for the participant's own arousal and signal quality, which the yoked sham does not, and it does NOT control for the possibility that the control band moves with the target band.

[sham-inverted]
  Feedback computed from the target band and then inverted.  It controls for everything the veridical condition does except the direction of the contingency, and a participant who learns to lower the number is doing the same thing in reverse, which makes it hard to interpret.

In [17]:
one = SUBJECTS[0]
donor = eyes_open[SUBJECTS[1]]
runs_shown = {m: L7.sham_feedback(eyes_closed[one], donor=donor, mode=m)
              for m in ("veridical", "sham-band", "sham-yoked")}
for m, r in runs_shown.items():
    print(f"{m:14s} source: {r['source']}")
fig, _ = L7.plot_sham_comparison(runs_shown,
                                 title=f"{one}: what the participant would see, by condition "
                                       f"(µV RMS per {BLOCK / FS:.2f}-s block)")
plt.show()
veridical      source: own signal, 8-12 Hz (the target band)
sham-band      source: own signal, 16-20 Hz
sham-yoked     source: another recording, replayed on the same schedule
Figure 4 of notebook nb-7-3-realtime, an output plot. The text around it states what it shows and the units of every axis.

The measurement that makes the point

Every recording here is replayed. Nobody is in the loop, nothing can be learned, and the null is true by construction. So every "significant" within-session increase below is a false positive, and counting them measures the false-positive rate of the analysis an uncontrolled neurofeedback report runs.

In [18]:
modes = ("veridical", "sham-band", "sham-yoked", "sham-inverted")
trend = {}
print(f"{'condition':16s} {'p < .05':>9s} {'and rising':>11s} {'median |slope|':>15s} "
      f"{'median lag-1 r':>15s}")
for m in modes:
    ps, slopes, lags = [], [], []
    for i, s in enumerate(SUBJECTS):
        r = L7.sham_feedback(eyes_closed[s], donor=eyes_open[SUBJECTS[(i + 1) % len(SUBJECTS)]],
                             mode=m)
        tr = L7.within_session_trend(r["values"], r["times_s"])
        ps.append(tr["p"])
        slopes.append(tr["slope_per_min"])
        lags.append(tr["lag1_autocorrelation"])
    ps, slopes, lags = np.asarray(ps), np.asarray(slopes), np.asarray(lags)
    trend[m] = (ps, slopes, lags)
    print(f"{m:16s} {int((ps < 0.05).sum()):5d}/{len(ps):<3d} "
          f"{int(((ps < 0.05) & (slopes > 0)).sum()):7d}/{len(ps):<3d} "
          f"{np.median(np.abs(slopes)):15.4f} {np.median(lags):15.3f}")
print(f"\nnominal false-positive rate at alpha = .05: {0.05 * len(SUBJECTS):.1f} of {len(SUBJECTS)}")
print("The cause is in the last column.  The per-block feedback values are strongly "
      "autocorrelated, so the ordinary-least-squares standard error is far too small and the "
      "p-value is anti-conservative.  The test is invalid, not unlucky.")
print("\nAnd the sham conditions flag about as often as the veridical one, which is the whole point: "
      "'alpha went up during the session' carries no information about whether the feedback was "
      "real, because it does the same thing when the feedback comes from somebody else.")
condition          p < .05  and rising  median |slope|  median lag-1 r
veridical            8/12        5/12           5.2805           0.686
sham-band            6/12        2/12           0.7882           0.384
sham-yoked           6/12        6/12           0.7627           0.377
sham-inverted        8/12        3/12           5.2805           0.686

nominal false-positive rate at alpha = .05: 0.6 of 12
The cause is in the last column.  The per-block feedback values are strongly autocorrelated, so the ordinary-least-squares standard error is far too small and the p-value is anti-conservative.  The test is invalid, not unlucky.

And the sham conditions flag about as often as the veridical one, which is the whole point: 'alpha went up during the session' carries no information about whether the feedback was real, because it does the same thing when the feedback comes from somebody else.

The fix is the unit of analysis

The block is not the experimental unit; the session is. One number per session, tested across sessions, is a valid test — and on data where nothing can be learned it says so.

In [19]:
print(f"{'condition':16s} {'second half - first half':>26s} {'t':>7s} {'p':>9s}")
session_level = {}
for m in modes:
    d = []
    for i, s in enumerate(SUBJECTS):
        r = L7.sham_feedback(eyes_closed[s], donor=eyes_open[SUBJECTS[(i + 1) % len(SUBJECTS)]],
                             mode=m)
        v = r["values"]
        h = len(v) // 2
        d.append(float(v[h:].mean() - v[:h].mean()))
    d = np.asarray(d)
    t_s, p_s = stats.ttest_1samp(d, 0.0)
    session_level[m] = (d, t_s, p_s)
    print(f"{m:16s} {d.mean():+21.4f} µV {t_s:7.2f} {p_s:9.4f}")

dv = session_level["veridical"][0]
ds = session_level["sham-yoked"][0]
t_c, p_c = stats.ttest_rel(dv, ds)
print(f"\nthe controlled contrast — veridical change minus yoked-sham change: "
      f"{dv.mean() - ds.mean():+.4f} µV, t({len(dv) - 1}) = {t_c:.2f}, p = {p_c:.4f}")
print("Correctly null, which is what a valid test does on data where the null is true.")
condition          second half - first half       t         p
veridical                      +1.1198 µV    0.53    0.6097
sham-band                      +0.0683 µV    0.13    0.9024
sham-yoked                     +1.4349 µV    1.92    0.0805
sham-inverted                  -1.1198 µV   -0.53    0.6097

the controlled contrast — veridical change minus yoked-sham change: -0.3152 µV, t(11) = -0.15, p = 0.8860
Correctly null, which is what a valid test does on data where the null is true.

The reporting standard

The lesson's second exercise asks for one design flaw in a provided neurofeedback protocol. The protocol is in the lesson; the checklist it is read against is here, and so is the latency line that most protocols leave out.

In [20]:
print("What a neurofeedback claim has to carry (CRED-nf-style reporting items):")
for i, item in enumerate(L7.CREDNF_ITEMS, 1):
    print(f"  {i}. {item}")
print(f"\nItem 4 in full, for the pipeline this notebook budgets:")
print(f"  band              {BAND[0]:g}-{BAND[1]:g} Hz")
print(f"  derivation        {L7.LATENCY_SPEC['channel']} "
      f"({L7.DATASETS_L7['ds-eegbci']['name']}, reference TODO(confirm) in the directory)")
print(f"  online filter     {N_TAPS}-tap linear-phase FIR, causal, applied block by block with "
      f"carried state")
print(f"  update rate       {budget['update_rate_hz']:.2f} Hz ({BLOCK} samples at {FS:.0f} Hz)")
print(f"  end-to-end latency {budget['total_ms']:.2f} ms worst case, {budget['mean_ms']:.2f} ms mean, "
      f"buffer quoted on the {budget['convention']} convention")
print("\nTODO(confirm): typical real-world processing delays.  The 10 ms row here is a stated number, "
      "not a measured one; this notebook cannot measure compute, transport or display and does not "
      "pretend to.  An author with a real rig should replace it with a measurement.")
What a neurofeedback claim has to carry (CRED-nf-style reporting items):
  1. Pre-registration of the primary outcome and the control condition, before data collection.
  2. A control group or condition (sham feedback, an active alternative task, or both).
  3. Blinding: the participant, and where possible the experimenter and the analyst.
  4. The feedback signal reported in full — the band, the derivation, the reference, the online filter, the update rate and the end-to-end latency.
  5. Evidence that the participants learned to change the targeted signal, reported separately from whether the clinical or behavioural outcome changed.
  6. The trial-level and session-level data made available so the analysis can be repeated.

Item 4 in full, for the pipeline this notebook budgets:
  band              8-12 Hz
  derivation        O1 (EEG Motor Movement/Imagery Dataset (EEGMMIDB), reference TODO(confirm) in the directory)
  online filter     129-tap linear-phase FIR, causal, applied block by block with carried state
  update rate       5.00 Hz (32 samples at 160 Hz)
  end-to-end latency 610.00 ms worst case, 510.00 ms mean, buffer quoted on the block-period convention

TODO(confirm): typical real-world processing delays.  The 10 ms row here is a stated number, not a measured one; this notebook cannot measure compute, transport or display and does not pretend to.  An author with a real rig should replace it with a measurement.

7 · What the lesson's exercise asks for

In [21]:
print("=" * 96)
print("nb-7-3-realtime — the numbers the L7.3 exercises ask for")
print("=" * 96)
print(f"\n[1] END-TO-END LATENCY for the stated pipeline "
      f"({FS:.0f} Hz, {BAND[0]:g}-{BAND[1]:g} Hz, {N_TAPS}-tap FIR, {BLOCK}-sample block, "
      f"{PROCESSING_MS:g} ms processing, causal):")
for r in budget["rows"]:
    print(f"      {r['row']:12s} {r['detail']:44s} {r['ms']:8.2f} ms")
print(f"      {'TOTAL':12s} {'worst case, block-period convention':44s} "
      f"{budget['total_ms']:8.2f} ms   <- the answer")
print(f"      {'':12s} {'worst case, first-to-last convention':44s} "
      f"{budget['total_other_convention_ms']:8.2f} ms")
print(f"      {'':12s} {'mean over block position':44s} {budget['mean_ms']:8.2f} ms")
print(f"    The convention has to be named with the number: the three readings sit within 100 ms of "
      f"each other and a tolerance wide enough to admit {budget['mean_ms']:.0f} would accept an "
      f"answer that budgeted nothing.")

print(f"\n[2] AGREEMENT WITH w-latency-budget: every one of the {len(widget)} quantities in "
      f"site/notes/widget-latency.md reproduces, largest disagreement {worst:.1e}.  Derived here "
      f"independently; no number was taken from the widget and checked against itself.")

print(f"\n[3] MEASURED, not assumed: the loopback probe reads the filter row at "
      f"{probe['filter_ms']:.4f} ms against {probe['expected_filter_ms']:.4f} predicted, and the "
      f"feedback value at {probe['value_ms']:.4f} ms against {probe['expected_value_ms']:.4f}; "
      f"the {probe['value_min_ms']:.2f}-{probe['value_max_ms']:.2f} ms spread over the block grid "
      f"is the buffer term.")

print(f"\n[4] A zero-phase filter cannot run live: the same {N_TAPS}-tap kernel needs "
      f"{L7.zero_phase_lookahead_samples(taps, fir=True) / FS * 1000:.0f} ms of FUTURE signal, so "
      f"the honest budget is {zp['total_ms']:.2f} ms — {zp['total_ms'] / budget['total_ms']:.2f}x "
      f"the causal one.  Block-wise causal filtering is machine-precision identical to whole-signal "
      f"filtering ({err:.1e}); filtering each block on its own is not ({100 * err2:.0f} % error, with "
      f"scipy's own default padding).")

print(f"\n[5] POSITIVE CONTROL: eyes closed {ec.mean():.3f} µV against eyes open {eo.mean():.3f} µV "
      f"through the same loop, {ec.mean() / eo.mean():.2f}x, "
      f"{int((ec > eo).sum())}/{len(SUBJECTS)} subjects, t({len(ec) - 1}) = {t_ec:.2f}, "
      f"p = {p_ec:.2e}.")

print(f"\n[6] THE SHAM RESULT.  On {len(SUBJECTS)} replayed recordings where learning is impossible "
      f"by construction, the naive within-session trend test called:")
for m in modes:
    ps, slopes, _ = trend[m]
    print(f"      {m:16s} {int((ps < 0.05).sum())}/{len(ps)} sessions significant, "
          f"{int(((ps < 0.05) & (slopes > 0)).sum())}/{len(ps)} of them 'an increase'")
print(f"      nominal rate at alpha = .05: {0.05 * len(SUBJECTS):.1f}/{len(SUBJECTS)}.  "
      f"Median lag-1 autocorrelation of the feedback values: "
      f"{np.median(trend['veridical'][2]):.3f}.")
print(f"      With the session as the unit instead of the block, every condition is null "
      f"(p = " + ", ".join(f"{session_level[m][2]:.3f}" for m in modes) + "), and the controlled "
      f"contrast is null too (p = {p_c:.4f}).")
print(f"      The design flaw the exercise asks for has a shape: a protocol with no control "
      f"condition, whose outcome is a within-session trend in the feedback signal itself, reports "
      f"the same thing whether the feedback was the participant's own brain or somebody else's.")
print("=" * 96)
================================================================================================
nb-7-3-realtime — the numbers the L7.3 exercises ask for
================================================================================================

[1] END-TO-END LATENCY for the stated pipeline (160 Hz, 8-12 Hz, 129-tap FIR, 32-sample block, 10 ms processing, causal):
      filter       FIR group delay (N - 1) / 2, 129 taps          400.00 ms
      buffer       32 samples, block-period                       200.00 ms
      processing   stated, not measured                            10.00 ms
      TOTAL        worst case, block-period convention            610.00 ms   <- the answer
                   worst case, first-to-last convention           603.75 ms
                   mean over block position                       510.00 ms
    The convention has to be named with the number: the three readings sit within 100 ms of each other and a tolerance wide enough to admit 510 would accept an answer that budgeted nothing.

[2] AGREEMENT WITH w-latency-budget: every one of the 20 quantities in site/notes/widget-latency.md reproduces, largest disagreement 3.0e-03.  Derived here independently; no number was taken from the widget and checked against itself.

[3] MEASURED, not assumed: the loopback probe reads the filter row at 399.9995 ms against 400.0000 predicted, and the feedback value at 503.1245 ms against 503.1250; the 495.58-510.67 ms spread over the block grid is the buffer term.

[4] A zero-phase filter cannot run live: the same 129-tap kernel needs 800 ms of FUTURE signal, so the honest budget is 1010.00 ms — 1.66x the causal one.  Block-wise causal filtering is machine-precision identical to whole-signal filtering (2.2e-16); filtering each block on its own is not (105 % error, with scipy's own default padding).

[5] POSITIVE CONTROL: eyes closed 22.634 µV against eyes open 6.942 µV through the same loop, 3.26x, 12/12 subjects, t(11) = 3.94, p = 2.31e-03.

[6] THE SHAM RESULT.  On 12 replayed recordings where learning is impossible by construction, the naive within-session trend test called:
      veridical        8/12 sessions significant, 5/12 of them 'an increase'
      sham-band        6/12 sessions significant, 2/12 of them 'an increase'
      sham-yoked       6/12 sessions significant, 6/12 of them 'an increase'
      sham-inverted    8/12 sessions significant, 3/12 of them 'an increase'
      nominal rate at alpha = .05: 0.6/12.  Median lag-1 autocorrelation of the feedback values: 0.686.
      With the session as the unit instead of the block, every condition is null (p = 0.610, 0.902, 0.081, 0.610), and the controlled contrast is null too (p = 0.8860).
      The design flaw the exercise asks for has a shape: a protocol with no control condition, whose outcome is a within-session trend in the feedback signal itself, reports the same thing whether the feedback was the participant's own brain or somebody else's.
================================================================================================
In [22]:
# Only what this run downloaded is deleted: the ds-eegbci cache is shared with four other levels.
freed = 0
for s in SUBJECTS:
    freed += L6.drop_eegbci_runs(s, runs, keep=already_on_disk)
print(f"deleted {freed / 1e6:.1f} MB of ds-eegbci runs fetched by this notebook")
L7.disk_report("at the end")
left = {f.name for s in SUBJECTS for f in L6.eegbci_run_files(s, runs)}
assert left <= {f.name for f in already_on_disk}, "this run left a download behind"
print(f"ds-eegbci runs still on disk: {len(left)} (all pre-existing); "
      f"no download made by this run was left behind")
deleted 0.0 MB of ds-eegbci runs fetched by this notebook
free disk at the end: 1.91 GB
ds-eegbci runs still on disk: 24 (all pre-existing); no download made by this run was left behind