Creating a Compound Stimulus¶
A compound (or multimodal) stimulus presents more than one modality in the same trial, a tone together with a grating, a sound together with a 3D object, and stores the parameters of every modality in the same trial condition.
This guide shows how to build one, and what to be careful about. It assumes you have read Creating a Custom Stimulus first.
How compound stimuli work in EthoPy¶
There is no container that holds several stimulus objects. The state machine drives exactly one stimulus instance per trial:
1 2 | |
So a compound stimulus is a single class that:
- Declares several condition tables in
cond_tables, one per modality. - Merges the parameter contracts (
required_fields,default_key) of the modalities it combines. - Drives all modalities from its own lifecycle methods (
start,present,stop,exit).
In practice you subclass the dominant modality, the one with the heavy machinery, usually the visual one, and add the second modality on top. The second modality's class is typically not instantiated; you reuse its condition table and call the interface directly.
Compound stimulus vs. stimulus periods
A compound stimulus presents several modalities at once, in one trial.
If instead you want the same stimulus class presented with different parameters at different points of a trial, you don't need a compound stimulus, use stimulus periods:
1 2 3 4 5 | |
Each period is logged separately in StimCondition.Trial.period.
A minimal example¶
We combine an auditory Tones stimulus with the built-in visual Grating.
1. The component stimulus¶
Tones is an ordinary stimulus with its own table, nothing compound about it yet:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | |
After adding a new condition table, create it in the database:
1 | |
2. The compound stimulus¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | |
Building the parameter contract additively on top of super().__init__() is deliberate, see Copy the parent's whole contract.
3. The task¶
Nothing special is required in the task file, parameters of both modalities go into the same condition dictionary:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | |
The three things you must get right¶
Condition tables¶
cond_tables lists the DataJoint tables in the stimulus schema that store this stimulus's parameters. Stimulus.make_conditions writes to all of them:
1 2 3 4 5 6 | |
The stim_hash is computed over the union of the fields of every listed table. A compound stimulus therefore produces one hash per condition, with one row in each component table under that same hash.
Required fields and defaults¶
required_fields and default_key are not only validation. Together they are the filter deciding which task parameters reach the stimulus:
1 2 | |
where get_parameters() returns required_fields ∪ default_key.keys(). A key that appears in neither is not part of the stimulus condition: it is reported as an unused parameter and is never written to a condition table, so it does not contribute to the stim_hash either. It is still visible in curr_cond at run time, which makes this easy to miss — the stimulus behaves as intended during the session, and the parameter is simply absent when you come back to analyse the data.
required_fields— must be supplied by the task;make_conditionsasserts on them.default_key— filled in when the task omits them.
Class naming¶
The class name is stored as stimulus_class in the Condition table and is the key into exp.stims, so it must be unique across the stimuli used in one session.
Follow the plugin conventions for the module: a snake_case file under stimuli/,
imported as ethopy.stimuli.<module>.
What to be careful about¶
Set the three attributes inside __init__¶
Stimulus.__init__ resets cond_tables, required_fields and default_key to empty. Declaring them as class attributes therefore does nothing — the instance attributes created by super().__init__() shadow them:
1 2 3 4 5 | |
The failure is silent and nasty: with empty cond_tables the hash is computed over zero fields, so every condition gets the same stim_hash, and no parameters are stored. Always assign after super().__init__():
1 2 3 | |
Copy the parent's whole contract¶
When you subclass a stimulus, super().__init__() already installs the parent's cond_tables, required_fields and default_key. Extend them rather than reassigning:
1 2 | |
If you reassign instead, you must repeat every parent entry by hand and the class will silently stop accepting any parameter the parent adds later.
Note that cond_tables includes part tables. A stimulus built on Panda must carry all of them:
1 2 | |
A table with missing fields is skipped, not reported as an error¶
If a condition does not contain every field of a listed table, log_conditions skips that table with a warning and carries on:
1 | |
The trial still runs and still gets a stim_hash but one modality's parameters are missing from the database. Watch for this warning on the first run of a new compound stimulus. It usually means a field is in the table definition but in neither required_fields nor default_key.
Do not give the compound class its own table unless it adds parameters¶
A compound class that only aggregates existing tables needs no @stimulus.schema decorator and no definition. If you decorate a subclass that has no definition of its own, it inherits the parent's and DataJoint declares a second, permanently empty table.
Add a table only if the combination introduces genuinely new parameters, an audiovisual onset asynchrony, say — and then add it to cond_tables alongside the others.
Log the trial exactly once¶
log_stop() writes the StimCondition.Trial row and toggles the sync signal (sync_out(False)). Calling it more than once per trial fires the sync output twice, the duplicate insert is dropped by the logger, so nothing errors.
The trap is calling it and delegating to a parent that calls it too:
1 2 3 4 | |
Pick one: either call super().stop() and let the parent log, or handle the whole stop yourself. The same applies to log_start() via super().start().
Decide which modality ends the trial¶
self.in_operation is what the state machine polls to decide the trial is over:
1 2 3 | |
With several modalities running at different durations, the recommended pattern is a flag per modality, with the shared in_operation cleared only once all of them have finished, as in the example above. The trial then lasts as long as the longest modality, and each stops at its own duration.
The alternative is to let one modality own the clock and not override present() at all. That is simpler, but be aware of the consequence: the other modality's duration parameter is still stored in its condition table while having no effect on presentation, it will look meaningful during analysis and not be. If you take this route, document it, and consider leaving the unused duration out of the condition.
Remember to create the tables¶
A new component table only exists in the database after:
1 | |
Checklist¶
Before running a new compound stimulus:
- [ ]
cond_tables,required_fieldsanddefault_keyare set inside__init__, aftersuper().__init__(). - [ ]
cond_tablescovers every modality, including the parent's part tables. - [ ] Every field of every listed table is in
required_fieldsordefault_key. - [ ] The class has no
@stimulus.schemadecorator, unless it defines new parameters. - [ ]
start()andstop()log exactly once. - [ ]
in_operationclears only when every modality is done. - [ ]
exit()tears down every modality (screen and sound). - [ ]
ethopy-setup-schemahas been run.
After the first session, verify in the database that each trial produced one stim_hash with one row in each component table, and that no Skipping <table>, Missing keys warning appeared in the log.