Route every event marker through push_marker() / subscribe_marker()#328
Draft
pellet wants to merge 4 commits into
Draft
Route every event marker through push_marker() / subscribe_marker()#328pellet wants to merge 4 commits into
push_marker() / subscribe_marker()#328pellet wants to merge 4 commits into
Conversation
…ubscribe_marker() Route all stimulus-marker emission through a single push_marker() on BaseExperiment, backed by a subscriber list. One call fans the marker out to the EEG board, every auxiliary device, and any optional observers under one shared timestamp — replacing the per-paradigm boilerplate that re-derived the timestamp and branched on eeg.backend to wrap muse markers. - subscribe_marker(callback, raise_on_error=True): register any callable(marker, timestamp, trial_idx). raise_on_error=True aborts the run on failure (essential recorders); False logs-and-swallows (optional telemetry), logging each broken subscriber's traceback only once to keep per-onset log I/O off the presentation thread. - push_marker(marker, trial_idx=None) -> float: emit under one shared timestamp and return it so callers can stamp their own bookkeeping. - Built-in essential subscribers _emit_to_eeg / _emit_to_devices preserve the existing self.devices contract (dev.push_sample(marker=, timestamp=)). - muse scalar->[marker] wrap now lives once in EEG._muse_push_sample instead of being duplicated across paradigms. - eoec: LSL outlet + serial trigger box subscribe as inline lambdas; the serial write rides push_marker's shared timestamp (best-effort, raise_on_error=False). - SSVEP emits on the first flip so the recorded onset is the true stimulus onset. Removes BaseExperiment.send_triggers() (folded into _emit_to_devices). Adds tests/test_push_marker.py.
…tent naming - Convert p300 and n170 to push_marker(): drop the pre-abstraction self.eeg guards and p300's inline muse-wrap/manual timestamp (the wrap now lives in EEG._muse_push_sample). p300 was the last raw-push holdout. - Rename built-in subscribers _emit_to_eeg/_emit_to_devices -> _write_marker_to_eeg/_write_marker_to_devices and purge "emit" from docstrings/comments in favour of "marker". Note "trigger" as the ERP synonym and leave a send_triggers breadcrumb in the push_marker docstring. - Simplify test_push_marker.py (real construction via a fixture instead of object.__new__; one fake sink) and move the muse-wrap unit test to test_acquisition.py, alongside the other EEG-device tests.
…riber alias Add MarkerSubscriber = Callable[[int, float, Optional[int]], None] and annotate subscribe_marker(callback) and marker_subscribers with it, so the subscriber contract is a single named type: autocomplete and static checks at the call site, and Optional[int] surfaces that trial_idx may be None.
- conftest: skip tests/test_push_marker.py when psychopy is absent (it imports BaseExperiment -> devices.vr -> psychopy), matching the existing eegnb/experiments gate. Fixes test collection on the py-3.12/3.13 streaming CI env. - eoec: annotate self.outlet/self.serial as Optional[...] and bind locals before the subscribe_marker closures so mypy can narrow them. Fixes the two attr-defined errors in the typecheck job.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every paradigm currently hand-rolls its own marker handling: an
if self.eeg:blockthat re-derives the timestamp, branches on
self.eeg.backend == "muselsl"to wrap themarker in a list, and then separately calls
send_triggers()forself.devices. Thesame boilerplate is copy-pasted across N170, SSVEP, eoec, etc.
This PR routes marker handling through a single
push_marker()method onBaseExperiment, backed by a small subscriber list. One call fans the marker out to theEEG board, every auxiliary device, and any optional observers under one shared
timestamp.
Net paradigm change (N170 shown):
Motivation
muselsl → wrap-in-listbranch lived in every paradigm. It now lives inexactly one place (
EEG._muse_push_sample), where the pylsl-outlet requirementactually belongs. Paradigm code drops to a single line.
send_triggers()eachminted their own
time()(andsend_triggersminted a fresh one per device insideits loop), so a single stimulus onset was stamped with several slightly different
times across streams.
push_marker()computes the timestamp once and shares it withevery sink — important for ERP epoching and multi-device alignment.
can subscribe to marker onsets without editing any paradigm.
API
Two built-in essential subscribers are registered in
__init__:_write_marker_to_eeg(the board) and
_write_marker_to_devices(aux devices). Paradigms can add their own — e.g. eoecsubscribes its LSL Markers outlet and, when present, the serial trigger box.
Failure handling for optional subscribers is spam- and latency-safe: a broken subscriber's
traceback is logged once, subsequent failures are counted (a dict increment, no
formatting/IO on the presentation thread), and a per-subscriber total is reported at the
end of
run().Preserved contract (no change for device authors)
self.devicesand its default are unchanged.dev.push_sample(marker=..., timestamp=...). Existing device objects work as-is.Behavior changes to be aware of (review these)
send_triggers()is removed. Its behavior is folded into the_write_marker_to_devicessubscriber. In-repo callers (N170, SSVEP, eoec, p300) are updated. If external/downstream code calls
send_triggers(), that's a breaking change — happy to keep a thin back-compat shim(
send_triggers = lambda self, m: self.push_marker(m)) if preferred.µs–ms. This is the intended fix, but it does change recorded marker timestamps.
auditory_oddballandpattern_reversal_vepstill call
eeg.push_sample()directly. Converting them is a mechanical follow-up.SSVEP timing note
SSVEP now pushes the marker on the first flip of the flicker loop (guarded by a
marker_pushedflag) so the recorded onset is the true stimulus onset rather than oneframe early.
Tests
tests/test_push_marker.py— display-free unit coverage that runs headless in CI (nowindow): shared-timestamp fan-out, optional-subscriber isolation (both directions),
log-once-per-subscriber anti-spam, and the muse scalar→
[marker]wrap/back-compat.