Experiments and triggers¶
Once a set of stimuli exists, something has to present it, on the right machine, at the right time, and in an EEG study it has to tell the amplifier when the stimulus appeared. lexsync writes that something. This guide covers the declarative trial model that makes one engine serve five paradigms and three presentation targets, how items are rotated across lists and blocked into practice and fillers, and where the trigger is written in each target and why the placement is the interesting part.
A trial is data¶
The core idea is small. A trial is a list of events, and an event is a dictionary. Nothing about a paradigm is expressed as code in a backend, which is what allows a new paradigm to be a configuration change rather than three new renderers.
import lexsync
for event in lexsync.resolve_events({"paradigm": "lexical_decision"}):
print(event)
{'type': 'fixation', 'content': '+', 'duration_frames': 30}
{'type': 'text', 'content': '{target}', 'duration_frames': 48, 'trigger': 'condition', 'onset_locked': True}
{'type': 'response', 'keys': ['left', 'right'], 'timeout_ms': 2000}
{'type': 'blank', 'duration_frames': 15}
An event's type is one of fixation, text, mask, blank, region_by_region, response,
question or feedback. Its content is either a literal, such as "+" or "#####", or a field reference in
braces, such as "{target}", which is filled per trial from the loop table. duration_ms is the
event's length in milliseconds, the unit all three targets present, so a design means the same
interval wherever it runs. The PsychoPy script measures the display's refresh at start-up and
converts it to the nearest whole number of flips. duration_frames is still accepted for designs
written before that change and is converted at presentation.assumed_refresh_hz. trigger is an
integer EEG code, or the token condition or item, and onset_locked asks for it to be written
on the event's onset flip.
resolve_events returns a design's events list if it has one, and otherwise the default sequence
of the paradigm it names, defaulting to factorial. Supplying events explicitly is how you build
a trial the registry does not have.
Timing that varies from trial to trial¶
A duration need not be the same on every trial. An event may instead declare a
duration: block, in one of two forms:
- type: text
content: '{prime}'
duration: {from_column: soa_ms} # read per trial from the items
- type: blank
duration: {jitter: [400, 800], as: iti_ms} # drawn per trial, in milliseconds
The two exist for different reasons. A duration read from a column is a manipulated variable: the stimulus-onset asynchrony of a priming study is the lever that separates automatic from strategic processing, so it belongs in the item table and in the analysis. A jittered duration is not manipulated at all. It decorrelates the design matrix, as EEG and fMRI designs routinely require.
Neither draws a random number. A jittered value is a uniform integer keyed on the seed, the column name, the list, the set and the condition, so both engines realise the same milliseconds and a rerun reproduces them. Naming the column in the key is what makes two jittered events draw independently, since without it they would share a single value.
Either form writes the realised milliseconds into the stimuli table and the loop
table, because timing that varies is a variable the analysis needs, not
presentation detail. config/design_en_priming_jitter.yaml is a worked
example carrying both.
The five paradigms¶
PARADIGMS is a plain dictionary. Each entry gives the fields the paradigm presents, its
counterbalancing recipe and its default event sequence.
| Paradigm | Presents | Counterbalancing | The trial |
|---|---|---|---|
factorial |
word |
factorial |
Fixation, the critical word carrying the onset-locked condition marker, response, blank. |
lexical_decision |
target |
factorial |
The same shape, with a generic target field so a real word and a pseudoword are interchangeable. |
priming |
prime, target |
latin_square_target |
Fixation, a 3-frame prime with its own fixed marker, a 2-frame mask, then the target with the condition marker. |
self_paced_reading |
sentence, question |
latin_square_target |
Fixation, the sentence region by region with the critical region marked, then a yes/no comprehension question. |
categorisation |
target, category, answer |
latin_square_target |
Fixation, the category cue, then the word to judge against it with the condition marker, response, blank. |
categorisation is worth a paragraph, because what separates it from lexical decision is not the
shape of the trial but where the question lives. The category cue is a trial event, shown afresh each
time, since the category varies from trial to trial, and crossing one word with two cues is how a
categorisation study separates a property of the word from the demands of the task. A robin is a bird
quickly and an animal slowly, and only the question changed.
Its answer field holds the key that is correct on the trial, so scoring is a string comparison
against the recorded response with nothing to look up in whatever language the analysis is
written in. The paradigm requires the field, which means an unscoreable categorisation experiment
cannot be generated. Its recipe is latin_square_target for the same reason a priming design uses
one: each item carries both cues, and a factorial deal would show a participant the same target
twice, turning the second presentation into a repetition-priming trial.
config/design_en_categorisation.yaml is a worked example.
required_fields tells you what a design's items must carry: the paradigm's own fields, plus any
extra field its events reference.
print(lexsync.required_fields({"paradigm": "priming"}))
print(lexsync.required_fields({"paradigm": "self_paced_reading"}))
['prime', 'target']
['sentence', 'question']
Adding a paradigm means adding an entry to PARADIGMS in both engines, with its event sequence, its
fields and its recipe. Both backends then render it with no further code.
Where the items come from¶
The corpus paradigms build their own items. Priming and self-paced reading cannot, because a prime,
a target and a sentence with a marked critical region are editorial work, so those designs set
items.source: table and point at a CSV.
load_items reads it. The table must carry an item identifier, a condition label and the
paradigm's presented fields, and it is checked on the way in. Field values are validated against
control characters and over-long strings, which is not paranoia about typos: the values are written
into a loop table and an experiment script, and a stray newline in a stimulus would corrupt both.
Commas and quotation marks pass through, because they go into a properly quoted CSV that the
experiment reads at run time rather than into generated code. Items are then mapped to an integer
set id in byte order, so that a table-sourced design counterbalances the same way as a
corpus-sourced one, and the same way in both engines.
The self-paced-reading design shows how a sentence carries its own structure: regions are delimited
with | in the sentence field, and a critical_region column names the region that gets the
marker.
A third source is items.source: pool, which hands the matcher a candidate list of your own instead
of a whole lexicon. It is described under supplied item pools,
since what it changes is the selection rather than the trial.
Practice, fillers and feedback¶
Everything above treats one frame as both the materials record and the thing that runs. That holds only while the two are the same trials, and they usually are not. Practice exists to settle the participant into the task and is discarded before analysis. Fillers exist to dilute the manipulation so the participant cannot guess it, and are likewise not analysed. Both have to reach the generated experiment, and neither belongs in the stimuli file, the descriptives or the realised control.
The pipeline therefore splits. The stimuli CSV and the reports are written from the main rows, while
the PsychoPy, OpenSesame and jsPsych experiments are generated from every presented trial. A block
column marks which is which, and it appears only when a design declares the blocks, so a design
without them keeps exactly the columns it had.
practice:
path: items/practice_en_lexdec.csv
fillers:
path: items/fillers_en_lexdec.csv
Where each block goes is a methodological choice. Practice comes first, as its own run, shuffled within itself so participants do not all meet the practice items in one order. Fillers are interleaved with the main trials, because a block of fillers at the end is not a filler at all: it is a second block the participant can tell apart. They are merged in before the order is drawn, so one deterministic shuffle mixes them through, which does renumber the main trials. That is correct, since adding fillers changes the sequence and the stimuli file records where each item actually appeared. Both blocks appear in every list and neither is counterbalanced, because they carry no manipulation to rotate and every participant should get the same practice.
Each block's item table is read with the same validation as any other, and given a set range that
cannot collide with the main items, which a naive read would not manage since practice item 1 and
main item 1 would both be set 1. The counts and the tables' checksums go into the datasheet, because
what the participant saw is part of the materials even when it is not part of the analysis.
A feedback event scores the trial and shows the result. It reads the field named by answer,
compares it as a string with the key the participant pressed, and displays correct, incorrect or
no_response for duration_ms.
- type: feedback
answer: answer
correct: 'Correct'
incorrect: 'Incorrect'
no_response: 'Too slow'
duration_ms: 600
blocks: [practice]
blocks: restricts an event to the named blocks, and this is its main use: feedback teaches the
mapping during practice, and would contaminate reaction times in the task itself. The restriction has
to be expressed on the event because the event list is global to the design. Since a feedback event
scores a keypress, something before it must have collected one. A design whose feedback event has no
preceding response or question is refused when the experiment is generated. Left to run time,
that one design error would surface as three different failures, one per target.
config/design_en_lexdec_blocks.yaml puts all of this together.
Counterbalancing¶
counterbalance picks a recipe from the design's paradigm and applies it. Trial order within each
list comes from a keyed-hash shuffle seeded by schema.seed, and a trial column numbers the
result.
The rest of this page follows one small design through to its three exported experiments. It runs against the bundled lexicon, so the output shown is the output you will get.
from importlib.resources import files
import yaml
import lexsync
data = files("lexsync") / "data"
schema = yaml.safe_load((data / "schema.yaml").read_text(encoding="utf-8"))
lexicon = lexsync.load_lexicon(
str(data / "en_example.csv"), schema, language="english"
)
design = {
"name": "demo", "language": "english", "n_per_condition": 6,
"pool_filters": {"length": [3, 8], "frequency": [3.8, 7.0]},
"conditions": [
{"name": "high_frequency", "define_by": {"frequency": [5.2, 7.0]}},
{"name": "low_frequency", "define_by": {"frequency": [3.8, 4.4]}},
],
"match_on": ["length", "n_density", "old20"],
"counterbalance": {"lists": 1},
"timing": {"fixation_frames": 30, "word_frames": 30, "isi_frames": 15},
}
pool = lexsync.build_pool(lexicon, design["pool_filters"])
stimuli = lexsync.match_stimuli(pool, design, schema)
factorial shows every matched item. With more than one list, matched sets are dealt to lists round
robin by set index, so a list gets a balanced slice of the design rather than a contiguous block of
it.
latin_square_target is for the paired and sentence paradigms, where showing the same target twice
in one list would ruin it. Each item contributes exactly one trial to each list, in a condition
rotated by the list number, so no target repeats within a list and conditions stay balanced because
items rotate through them. With counterbalance.lists unset, the number of lists equals the number
of conditions, which is the fully counterbalanced case.
stimuli = lexsync.counterbalance(stimuli, design, schema)
print(
stimuli[["trial", "list", "set", "condition", "word"]]
.head(3)
.to_string(index=False)
)
trial list set condition word
1 1 4 high_frequency water
2 1 1 high_frequency knew
3 1 3 high_frequency fact
A design with a replicate column, from resample_stimuli, is counterbalanced replicate by
replicate, and trial order is numbered within each.
Balanced list assignment¶
The factorial deal sends set 1 to list 1, set 2 to list 2 and so on. That is reproducible, but it balances nothing: every nth set lands in the same list, so a dimension that happens to vary smoothly across sets is dealt out unevenly, and where each list goes to a different group of participants, the unevenness is confounded with the group.
counterbalance.optimise searches instead for an assignment whose lists have near-equal totals on
the dimensions you name, by exchanging pairs of item sets between lists. List sizes are preserved,
since a swap trades one set for another.
counterbalance:
lists: 4
optimise: true
balance_on: [length, n_density, old20, frequency]
balance_on defaults to match_on, and the example widens it deliberately. Frequency is the
manipulated variable and so is not matched on, but it is manipulated within a list, since every
list holds both conditions. Equating the lists on its total therefore costs the manipulation nothing
and removes a difference between the participant groups who receive different lists. Naming only the
matched dimensions leaves frequency dealt arbitrarily, and measurably so: on the shipped design the
optimiser then improves the three named dimensions and makes frequency worse than the arbitrary deal
had it. Balance what you want equated across lists, which is usually everything.
This is a steepest descent to a local optimum, not a global search. What it guarantees is that no single exchange would improve matters further, and the datasheet records the imbalance before and after, so the improvement is checkable. The objective is all-integer and ties are broken by the seeded keyed hash, which keeps the two engines on the same assignment and stops list 1 being favoured for being numbered first.
It is off by default, and stays off. Switching it on changes which items a participant sees, so it
has to be a deliberate design decision. A package upgrade must never make that change for a study
already running. It is refused on a Latin-square design, where every item already appears in every
list and the lists are balanced on the items by construction. balance_lists runs the search alone
if you want the assignment without applying it. config/design_en_balanced_lists.yaml is the worked
example.
participant_table allocates participants to the cells of any crossed factors, cycling through the
grid so the allocation stays balanced whatever the participant count.
print(
lexsync.participant_table(
{"list": [1, 2], "order": ["forward", "reverse"]}, 4
)
.to_string(index=False)
)
list order participant
1 forward 1
2 forward 2
1 reverse 3
2 reverse 4
The grid is crossed with the first factor varying fastest, matching R's expand.grid, so both
engines put participant 3 in the same cell.
Trial order is part of the parity contract
The selection, the pairing, the condition assignment and the trial order are all byte-identical across the engines. The shuffle draws no random number, since R's and NumPy's generators could never agree on a permutation. Each trial is instead ranked by the SHA-256 digest of its seed, replicate, list, set and condition, so the order is a pure function of the design: the same bytes from either engine on any platform, a different order for every seed, and no systematic position effects.
Triggers¶
assign_triggers gives each row two EEG codes in the 0–255 range a parallel port can carry. The
condition marker starts at 101 and counts up per condition. The item marker starts at 40 and wraps
after 200 sets. export_experiments calls it for you, so you only need it directly if you are
exporting one target at a time.
from lexsync.scripting import assign_triggers
print(
assign_triggers(stimuli)[
["word", "condition", "condition_trigger", "item_trigger"]
]
.head(3)
.to_string(index=False)
)
word condition condition_trigger item_trigger
water high_frequency 101 43
knew high_frequency 101 40
fact high_frequency 101 42
The codes land in the loop table as condition_trigger and item_trigger, and the event's
trigger: condition token is what binds one to the other:
trial,list,set,condition,word,condition_trigger,item_trigger
1,1,4,high_frequency,water,101,43
2,1,1,high_frequency,knew,101,40
3,1,3,high_frequency,fact,101,42
The schema sets the hardware defaults: triggers.parallel_address (0x0378, a typical LPT1 base
address), triggers.trigger_hold_ms (50, comfortably above the 10 ms minimum recorders need to
see, and converted to whole flips against the measured refresh so it does not shorten on a fast
display), and triggers.inter_trigger_ms (10, the spacing of trailing markers). A design can
override them. The older triggers.reset_after_frames is still accepted and converted.
The three targets¶
export_experiments writes all three from the same rendered event list and returns their paths.
# illustrative: writes three experiment files into the working directory
paths = lexsync.export_experiments(
stimuli, design, schema, outdir="output/experiments"
)
# {'psychopy': '.../demo_english_psychopy.py',
# 'opensesame': '.../demo_english.osexp',
# 'jspsych': '.../demo_english.html'}
Generation imports neither PsychoPy nor pyserial. It writes text. That is what lets the whole demonstration, and the test suite, reproduce on a machine with no laboratory hardware attached.
PsychoPy¶
The PsychoPy export (Peirce et al., 2019) is where the methodological
argument for lexsync lives. The script reads its stimulus text as data from the conditions CSV
beside it, and interprets an EVENTS list embedded as JSON, so one interpreter serves every
paradigm.
The trigger is written on the exact buffer flip on which the stimulus first appears:
# illustrative: an excerpt of the generated script, needing a PsychoPy window and port
def show_frames(win, stim, frames, port, trigger):
"""Draw ``stim`` for ``frames`` flips; if a trigger is given, lock it to onset."""
if trigger is not None:
win.callOnFlip(port.setData, trigger)
# callOnFlip runs its callback on the NEXT flip, so queueing the reset on this
# index clears the code one flip later and holds it for exactly
# TRIGGER_HOLD_FRAMES flip intervals.
reset_at = TRIGGER_HOLD_FRAMES - 1
for f in range(frames):
if stim is not None:
stim.draw()
win.flip()
if trigger is not None and f == reset_at:
win.callOnFlip(port.setData, 0)
if trigger is not None and frames <= reset_at:
port.setData(0)
win.callOnFlip queues the port write against the next flip, so the code goes out with the photons
rather than from a later component that merely runs soon afterwards. The common alternative, sending
the trigger from a separate sequence-ordered item, inherits whatever jitter sits between that item
and the flip. The reset is queued the same way, one flip before the hold expires so that it lands
on the flip that ends it, with a direct write as the fallback when the stimulus is shorter than the
hold. TRIGGER_HOLD_FRAMES is computed at start-up from TRIGGER_HOLD_MS and the measured refresh
rate, floored at one flip and at the recorder minimum, which is what keeps a declared hold meaning
the same interval on a 60 Hz and a 144 Hz display.
Stimulus text is never interpolated into the script, only read from the CSV at run time, so nothing in a stimulus can become code. When no parallel-port driver is present, on a development laptop, on macOS, or in continuous integration, a mock port prints the codes and the script still runs. The test suite exercises exactly that: a mock-PsychoPy harness runs the generated script and asserts that the onset trigger is flip-locked.
OpenSesame¶
The .osexp is generated block by block rather than from a template, and the result is a normal
OpenSesame experiment (Mathôt et al., 2012): a trigger-setup inline
script, one inline script per event, a sequence and a loop.
define inline_script lexsync_e1
set description "Show stimulus and send onset-aligned trigger"
set _prepare ""
___run__
c = Canvas()
c.text(var.word)
var.onset_time = c.show()
send_trigger(var.condition_trigger)
clock.sleep(500)
Canvas.show() blocks until the flip and returns its timestamp, so the trigger goes out
immediately after the onset it marks. The setup block opens a parallel or serial device and falls
back to a printing stub when neither is available, so the experiment opens and runs on a machine
with no trigger hardware.
Two details in the generated file are deliberate. The loop is set to sequential, because
OpenSesame's default is random, which would discard the seeded trial order the CSV is sorted by and
put this target out of step with the other two. A response event is preceded by a blank canvas,
because a keyboard_response draws nothing and the preceding stimulus would otherwise stay on
screen for the whole response window instead of offsetting at its stated duration. A structural
validator in the test suite checks the generated file's blocks and references.
jsPsych¶
The browser export is a single HTML file carrying the same rendered events and the trial data inline. It opens with instructions, attaches each item's design fields to every recorded row, and ends by saving the collected data as a CSV download, so a generated experiment gathers usable data with no server behind it. The jsPsych library and its stylesheet load from a content delivery network, so the first run needs an internet connection.
Two things are handled on the way out. Event-model key names are mapped to browser key names, so
left becomes arrowleft and space becomes a literal space. The design's free-text language
label is mapped to a BCP 47 tag for the lang attribute, falling back to und rather than emitting
lang="english", which no user agent can resolve. A design may state language_tag outright.
The browser target records triggers, it does not send them
A browser cannot drive a parallel port. Onset triggers are written into each trial's recorded data, which is enough to reconstruct the design offline but not to synchronise an amplifier. Online EEG synchronisation needs WebSerial, LSL or a photodiode. The two laboratory targets are where the hardware timing lives.
Every worked design in the repository is published as one of these files, which is what the Demo link in the header opens.