Note
Go to the end to download the full example code.
21: Online mode¶
Fit a frozen template to each new pulse as it arrives, with a running existence test.
Introduction¶
For research use only: evoparam is not a medical device and controls nothing. The online mode exposes numbers as a stream comes in; any control loop is the user’s own.
evoparam.stream turns an offline fit into a live estimator. You fit a first block the
usual way to get a template (the bumps). Then, for every new pulse in the stream, a
threshold detector finds the pulse, and once its window is complete the frozen template
is shifted and scaled on each channel: you get an amplitude, a latency and a per-pulse
existence score, in a few milliseconds, without re-fitting from scratch. A running
existence test keeps its 5 % false-positive rate per channel however often you read it.
We simulate a stream (no hardware): one pulse per second, an N1 on half of eight channels, fed in small chunks as a device would deliver it.
import warnings
import numpy as np
from evoparam.data import TrialSet
from evoparam.models import fit
from evoparam.stream import StreamFitter
SFREQ = 2048.0
N_CHANNELS = 8
N1 = (-40.0, 0.03, 0.02)
ARTIFACT = (-0.002, 0.008)
def gauss(times, latency, fwhm):
"""Return a Gaussian bump of the given latency and width (s)."""
return np.exp(-0.5 * ((times - latency) / (fwhm / 2.3548)) ** 2)
1. The offline template¶
First, a normal fit on an offline block of 40 trials. Its bumps become the template the stream will track. This is the only fit done; the stream only shifts and scales it.
rng = np.random.default_rng(0)
times = np.arange(-1024, 2048) / SFREQ
block = rng.normal(0.0, 10.0, (40, N_CHANNELS, times.size))
block[:, : N_CHANNELS // 2] += N1[0] * gauss(times, N1[1], N1[2])
with warnings.catch_warnings():
warnings.simplefilter("ignore")
offline = fit(
TrialSet(data=block, times=times, sfreq=SFREQ,
ch_names=[f"c{channel}" for channel in range(N_CHANNELS)]),
"ccep",
) # fmt: skip
template = offline.fits[0]
print(f"template: {template.components[0].label} at {template.components[0].latency * 1e3:.1f} ms")
template: N1 at 29.9 ms
2. The stream¶
StreamFitter holds the template and the detector. We feed it 30 pulses, one per
second, in chunks of 32 samples (push returns the finished estimates). Each pulse has
a big artifact and an N1 on the first four channels.
fitter = StreamFitter(
template, sfreq=SFREQ, ch_names=[f"c{channel}" for channel in range(N_CHANNELS)],
artifact_window=ARTIFACT, existence_max_pulses=30, random_state=0,
) # fmt: skip
estimates = []
for _ in range(30):
chunk = rng.normal(0.0, 10.0, (int(SFREQ), N_CHANNELS))
onset = int(0.45 * SFREQ)
chunk[onset : onset + 3] += np.array([3000.0, -2000.0, 500.0, 400.0, 0.0, 0.0, 0.0, 0.0])[
None, :
] # artifact
relative = (np.arange(len(chunk)) - onset) / SFREQ
chunk[:, : N_CHANNELS // 2] += N1[0] * gauss(relative, N1[1], N1[2])[:, None]
for first in range(0, len(chunk), 32):
estimates += fitter.push(chunk[first : first + 32])
print(f"\n{len(estimates)} per-channel estimates over 30 pulses; the last pulse:")
for estimate in estimates[-N_CHANNELS:]:
print(
f" {estimate.channel}: amplitude {estimate.amplitude:7.1f} uV, "
f"latency {estimate.latency * 1e3:5.1f} ms, "
f"z {estimate.existence_z:5.1f}, {estimate.status}"
)
240 per-channel estimates over 30 pulses; the last pulse:
c0: amplitude -41.3 uV, latency 29.6 ms, z 33.4, ok
c1: amplitude -38.9 uV, latency 30.4 ms, z 22.9, ok
c2: amplitude -38.5 uV, latency 30.6 ms, z 23.6, ok
c3: amplitude -40.7 uV, latency 29.6 ms, z 23.1, ok
c4: amplitude 2.9 uV, latency 28.0 ms, z -1.8, ok
c5: amplitude -2.8 uV, latency 25.8 ms, z 1.1, ok
c6: amplitude 3.3 uV, latency 24.9 ms, z -1.9, ok
c7: amplitude 1.9 uV, latency 25.0 ms, z -0.6, ok
The responding channels come back near the template’s amplitude and latency with a high existence z; the silent channels stay near zero. Each estimate is one pulse on one channel, delivered as soon as its window closes.
3. The running existence test and the timing budget¶
existence is the verdict per channel after all the pulses seen so far. It corrects for
being looked at after every pulse, so the 5 % false-positive rate holds however often you
read it. latency_stats is the compute time per pulse: the online budget.
result = fitter.existence()
triples = zip(result["channels"], result["significant"], result["n"], strict=False)
for name, significant, n in triples:
print(f" {name}: {'response' if significant else 'no response'} after {n} pulses")
budget = {key: round(value, 2) for key, value in fitter.latency_stats.items()}
print("\ncompute time per pulse (ms):", budget)
c0: response after 29 pulses
c1: response after 29 pulses
c2: response after 29 pulses
c3: response after 29 pulses
c4: no response after 29 pulses
c5: no response after 29 pulses
c6: response after 29 pulses
c7: no response after 29 pulses
compute time per pulse (ms): {'p50': 1.22, 'p95': 1.53, 'p99': 1.64, 'max': 1.65}
The four responding channels are found; among the four silent ones, a correction holds the false-positive rate at 5 % per channel across all the looks, so now and then one silent channel is declared responsive (with four of them, roughly one run in five). That is the test working as specified, not a bug: read a single channel’s verdict with that rate in mind.
Note
With Lab Streaming Layer (the optional stream extra), the same fitter reads an
LSLInlet and publishes with an LSLOutlet, so it drops into an LSL setup
unchanged:
from evoparam.stream import LSLInlet, LSLOutlet
inlet = LSLInlet(stype="EEG")
fitter = StreamFitter(template, sfreq=inlet.sfreq, ch_names=inlet.ch_names,
artifact_window=ARTIFACT)
outlet = LSLOutlet(fitter) # tagged "research only; not a medical device"
inlet.feed(fitter, duration=60.0, callback=outlet.push)
Conclusion¶
The online mode reuses an offline fit as a template and tracks it pulse by pulse: one amplitude, latency and existence score per channel, in milliseconds, with a running test that stays honest under repeated looks. It targets soft real time (a latency budget), not hard deadlines, and it reports numbers only; closing a loop on them is the user’s responsibility.
Total running time of the script: (0 minutes 0.990 seconds)