EEG_MI / src /experiment /screening_paradigm.py
Twu31's picture
Mirror of github.com/twu3202/EEG_MI at 66726e1
d37c079 verified
Raw
History Blame Contribute Delete
7.87 kB
#!/usr/bin/env python
"""Task-screening paradigm — cue 8-12 candidate mental tasks, push LSL markers.
Records nothing itself: it draws timed cues and publishes an LSL **Markers** stream
('MI_Cues'). Run it alongside `udp_lsl_bridge.py` (which publishes 'Cap32' EEG) and
point **LabRecorder** at both → one time-synced XDF. Later, epoch on the `task/<name>`
markers and feed `src/exploration/task_separability.py`.
Modes:
(default) PsychoPy visual cues + LSL markers (needs psychopy + a display)
--simulate no psychopy: run the exact timing + push markers to LSL (test LabRecorder)
--dry-run just print the randomized trial sequence (no LSL, no timing)
python screening_paradigm.py --dry-run
python screening_paradigm.py --simulate --reps 2
python screening_paradigm.py --reps 8 --runs 3 # the real session
Install note: DON'T pip-install psychopy into the `eegmi` env (heavy deps can break
torch/mne). Use the PsychoPy standalone app or a separate env; --simulate/--dry-run need
only pylsl.
"""
from __future__ import annotations
import argparse
import time
from dataclasses import dataclass
# ---- candidate repertoire (motor + non-motor; generators under well-seated central/frontal) ----
@dataclass
class Task:
name: str
cue: str # short on-screen label
instr: str # what to actually do
cat: str # motor | nonmotor | rest
color: str = "#e8edf4"
TASKS = [
Task("rest", "Rest", "Relax, eyes on the cross, think of nothing", "rest", "#8b95a5"),
Task("left_hand", "LEFT hand", "Feel yourself squeezing your LEFT hand (don't move)", "motor", "#5aa9e6"),
Task("right_hand", "RIGHT hand", "Feel yourself squeezing your RIGHT hand (don't move)", "motor", "#5aa9e6"),
Task("feet", "FEET", "Feel yourself flexing both feet (don't move)", "motor", "#5aa9e6"),
Task("tongue", "TONGUE", "Feel pressing your tongue to the roof of your mouth", "motor", "#5aa9e6"),
Task("typing", "Type / piano", "Imagine typing or playing piano — a movement sequence", "motor", "#7fd1b8"),
Task("math", "Mental math", "Count down from 300 in steps of 7", "nonmotor", "#e6a15a"),
Task("words", "Word gen", "Silently list words starting with 'S'", "nonmotor", "#e6a15a"),
Task("song", "Song", "Replay a familiar song in your head", "nonmotor", "#e6a15a"),
Task("navigate", "Walk home", "Imagine walking through your home room by room", "nonmotor", "#e6a15a"),
]
TASK_BY_NAME = {t.name: t for t in TASKS}
# --------------------------------------------------------------- trial sequence (testable)
def make_sequence(task_names, reps, runs, seed=7):
"""Balanced, interleaved order — no task repeats back-to-back within a run."""
import random
rng = random.Random(seed)
runs_out = []
for _ in range(runs):
pool = task_names * reps
for _try in range(200):
rng.shuffle(pool)
if all(pool[i] != pool[i + 1] for i in range(len(pool) - 1)):
break
runs_out.append(list(pool))
return runs_out
# ------------------------------------------------------------------------- LSL markers
def make_marker_outlet():
from pylsl import StreamInfo, StreamOutlet
info = StreamInfo("MI_Cues", "Markers", 1, 0, "string", "mi-cues-01")
return StreamOutlet(info)
def push(outlet, msg):
if outlet is not None:
from pylsl import local_clock
outlet.push_sample([msg], local_clock())
# ------------------------------------------------------------------------- timing spec
@dataclass
class Timing:
fixation: float = 2.0
cue: float = 1.5
task: float = 4.0
rest: float = 2.0 # inter-trial
# --------------------------------------------------------------------- runners
def run_simulate(seq, timing, outlet):
"""Full timing + markers, console output, no psychopy."""
for r, run in enumerate(seq):
push(outlet, f"run/start/{r}")
print(f"\n=== run {r+1}/{len(seq)} ({len(run)} trials) ===")
for i, name in enumerate(run):
t = TASK_BY_NAME[name]
push(outlet, "fixation"); time.sleep(timing.fixation)
push(outlet, f"cue/{name}"); print(f" [{i+1:02d}] cue: {t.cue:<14}{t.instr}")
time.sleep(timing.cue)
push(outlet, f"task/{name}") # <-- the epoching marker
time.sleep(timing.task)
push(outlet, "rest"); time.sleep(timing.rest)
push(outlet, f"run/end/{r}")
print("\ndone.")
def run_psychopy(seq, timing, outlet):
from psychopy import visual, core, event
win = visual.Window(fullscr=True, color="#0e1116", units="norm")
fix = visual.TextStim(win, text="+", height=0.2, color="#c8ced8")
cue = visual.TextStim(win, text="", height=0.14, color="#e8edf4", wrapWidth=1.6)
instr = visual.TextStim(win, text="", height=0.06, color="#8b95a5", pos=(0, -0.25), wrapWidth=1.6)
go = visual.TextStim(win, text="", height=0.16, color="#e8edf4")
def wait_draw(stims, dur):
stims = stims if isinstance(stims, list) else [stims]
t0 = core.getTime()
while core.getTime() - t0 < dur:
for s in stims:
s.draw()
win.flip()
if "escape" in event.getKeys():
win.close(); core.quit()
# intro
cue.text = "Screening session\n\npress SPACE to start"
cue.draw(); win.flip(); event.waitKeys(keyList=["space"])
for r, run in enumerate(seq):
push(outlet, f"run/start/{r}")
for name in run:
t = TASK_BY_NAME[name]
push(outlet, "fixation"); wait_draw(fix, timing.fixation)
cue.text, cue.color, instr.text = t.cue, t.color, t.instr
push(outlet, f"cue/{name}"); wait_draw([cue, instr], timing.cue)
go.text, go.color = t.cue, t.color
push(outlet, f"task/{name}"); wait_draw(go, timing.task) # <-- epoching marker
push(outlet, "rest"); wait_draw(fix, timing.rest)
push(outlet, f"run/end/{r}")
cue.text, cue.color = f"break — run {r+1}/{len(seq)} done\n\npress SPACE", "#c8ced8"
cue.draw(); win.flip(); event.waitKeys(keyList=["space"])
win.close(); core.quit()
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--tasks", nargs="+", default=[t.name for t in TASKS],
help="subset of task names to screen")
ap.add_argument("--reps", type=int, default=6, help="trials per task per run")
ap.add_argument("--runs", type=int, default=2)
ap.add_argument("--seed", type=int, default=7)
ap.add_argument("--simulate", action="store_true")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
unknown = [t for t in args.tasks if t not in TASK_BY_NAME]
if unknown:
raise SystemExit(f"unknown tasks: {unknown}\navailable: {list(TASK_BY_NAME)}")
seq = make_sequence(args.tasks, args.reps, args.runs, args.seed)
total = sum(len(r) for r in seq)
per = {n: sum(run.count(n) for run in seq) for n in args.tasks}
print(f"{len(args.tasks)} tasks × {args.reps} reps × {args.runs} runs = {total} trials "
f"(~{Timing().__dict__['fixation']+Timing().cue+Timing().task+Timing().rest:.0f}s each "
f"→ ~{total*9/60:.0f} min)\nper-task: {per}")
if args.dry_run:
for r, run in enumerate(seq):
print(f"run {r+1}: {run}")
return
outlet = make_marker_outlet()
print("LSL 'MI_Cues' marker stream open — start udp_lsl_bridge.py + LabRecorder now.")
timing = Timing()
(run_simulate if args.simulate else run_psychopy)(seq, timing, outlet)
if __name__ == "__main__":
main()