From a3437970c92086ece261dbe7555c975d21f86417 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 2 Sep 2026 17:22:51 +0300 Subject: [PATCH 1/2] fix(nwb): resolve compound stimulus components from the data The exporter decided a stimulus was compound by looking for an underscore in the class name. Plugin classes are named TonesGrating and TonesPanda, so they took the single-stimulus branch and only the first modality reached the file. No error was raised: the result was a valid NWB file missing the other modality's parameters. Components are now read back from the database instead of parsed out of the name. A stimulus writes one row per cond_table under the trial's stim_hash, so the components are the stimulus tables holding conditions for that session's trials. This drops the naming requirement and works for both naming styles, while leaving single stimuli whose names contain words that look like table names (PsychoGrating, VROdors) intact. Three further defects surfaced once the compound path ran: - Only the first component's conditions were written. Each component now gets its own Conditions/Stimulus_ table; a simple stimulus keeps the single Stimulus table. - Conditions were deduplicated on the hash alone, which discarded the rows of part tables that extend the primary key. Panda.Object adds obj_id and Panda.Light adds light_idx, so a two-object two-light condition kept 1 of its 4 combinations. Dedup is now on the primary key of the joined table. - Those same part tables repeated each presentation once per combination, giving 16 identical timing rows for 4 trials. The presentation timing is collapsed back to one row per trial. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S7gfyfdvHFBvXP5RNMPJ5c --- docs/nwb_docs.md | 29 +++-- src/ethopy/utils/export/nwb.py | 200 ++++++++++++++++++++------------- 2 files changed, 140 insertions(+), 89 deletions(-) diff --git a/docs/nwb_docs.md b/docs/nwb_docs.md index a26df2d..7784dc2 100644 --- a/docs/nwb_docs.md +++ b/docs/nwb_docs.md @@ -174,7 +174,7 @@ The export function includes the following data types: - **States Data**: Trial state transitions and timing ### Special Features -- **Compound Stimuli**: Automatically handles multi-component stimuli (e.g., "Tones_Grating") +- **Compound Stimuli**: Automatically handles multi-component (multimodal) stimuli, whatever the stimulus class is named - **Fallback Timing**: Robust trial timing extraction with multiple strategies - **Data Validation**: Comprehensive error checking and data integrity validation @@ -182,18 +182,31 @@ The export function includes the following data types: ### Compound Stimulus Support -The export function automatically detects and handles compound stimuli (stimuli with multiple components separated by underscores): +A combined (multimodal) stimulus writes its parameters to several tables, one per component, all keyed by the same `stim_hash`. The export function resolves those components automatically and writes each one to the NWB file separately: ```python -# If your session uses compound stimuli like "Tones_Grating" +# If your session uses a combined stimulus such as TonesGrating # The export will automatically: -# 1. Detect the compound stimulus -# 2. Split it into components ("Tones" and "Grating") -# 3. Validate all components exist in the database -# 4. Export each component separately +# 1. Resolve its components ("Tones" and "Grating") +# 2. Fetch the conditions of each component +# 3. Export each component separately filename = export_to_nwb(animal_id=123, session_id=1) ``` +Each component gets its presentation timing under `nwbfile.stimulus[]`, and its parameters in the `Conditions` module as `Stimulus_` (`Stimulus_Grating`, `Stimulus_Tones`). A session with a simple stimulus keeps the single `Stimulus` conditions table. + +Components are read back from the data, not parsed out of the class name: they are the stimulus tables holding conditions for the trials that used this stimulus class. + +No naming convention is required, so a compound stimulus named after its tables (`Tones_Grating`) and one named freely (`TonesGrating`, `TonesPanda`) are handled identically. What determines the exported components is the stimulus class's `cond_tables`, not its name. + +### Stimuli with part tables + +A stimulus whose parameters live in part tables (`Panda.Object`, `Panda.Light`, `Panda.Environment`, `Panda.Movie`) has those tables joined into a single condition table on export. + +Part tables that extend the primary key multiply the rows: `Panda.Object` adds `obj_id` and `Panda.Light` adds `light_idx`, so a condition with two objects and the default two lights produces four rows per trial. The export keeps all of them in the conditions table, one row per (object, light) combination, and collapses the repeated presentation timing back to one row per trial. + +Note that the part tables are combined with natural joins, so a part table populated for only *some* of a session's conditions will drop the others from the exported conditions. + ### Robust Trial Timing The system uses multiple strategies to extract trial timing: @@ -255,7 +268,7 @@ The generated NWB file contains: The export function performs extensive validation: - Checks for session existence -- Validates stimulus components for compound stimuli +- Resolves and validates stimulus components for compound stimuli - Verifies trial timing data consistency - Reports missing or incomplete data with detailed logging diff --git a/src/ethopy/utils/export/nwb.py b/src/ethopy/utils/export/nwb.py index 4fd026c..082e729 100644 --- a/src/ethopy/utils/export/nwb.py +++ b/src/ethopy/utils/export/nwb.py @@ -18,6 +18,7 @@ import datajoint as dj import numpy as np +from datajoint.utils import to_camel_case from dateutil import tz from pynwb import NWBHDF5IO, NWBFile, TimeSeries from pynwb.behavior import BehavioralEvents @@ -253,30 +254,6 @@ def get_non_empty_children( return restricted_children -def parse_compound_stimulus(stimulus_class: str) -> List[str]: - """ - Parse compound stimulus names separated by underscores. - - Compound stimuli are represented by multiple stimulus types separated by underscores. - This function splits them into individual components. - - Args: - stimulus_class: Stimulus class name that may contain multiple components - (e.g., 'Tones_Grating', 'Visual_Auditory_Tactile') - - Returns: - List of individual stimulus component names - (e.g., ['Tones', 'Grating'], ['Visual', 'Auditory', 'Tactile']) - - Example: - >>> parse_compound_stimulus('Tones_Grating') - ['Tones', 'Grating'] - >>> parse_compound_stimulus('SimpleStimulus') - ['SimpleStimulus'] - """ - return stimulus_class.split("_") - - def combine_children_tables(children: List[dj.Table]) -> dj.Table: """ Combine all child tables using the DataJoint join operator. @@ -340,68 +317,85 @@ def get_stimulus_conditions( return (stimulus_module.StimCondition.Trial & session_key) * comb_tables -def validate_stimulus_components( - stimulus_module: Any, class_name: str -) -> Tuple[bool, List[str]]: +def resolve_stimulus_components( + stimulus_module: Any, + experiment_module: Any, + session_key: Dict[str, Any], + class_name: str, +) -> List[str]: """ - Validate that all components of a compound stimulus exist in the stimulus module. + Resolve the stimulus tables holding the parameters of a stimulus class. + + A stimulus class declares the tables holding its parameters in ``cond_tables``, + and every one of those tables receives a row keyed by the trial's ``stim_hash``. + The components are therefore read back from the data: the stimulus tables that + hold conditions for the trials of this stimulus class. A compound (multimodal) + stimulus resolves to one component per modality, a simple stimulus to one. Args: stimulus_module: DataJoint stimulus module - class_name: Name of the compound stimulus class (e.g., 'Tones_Grating') + experiment_module: DataJoint experiment module + session_key: Primary key identifying the session + class_name: Value of ``stimulus_class`` logged for the session Returns: - Tuple of (all_exist: bool, missing_components: List[str]) - """ - stimulus_components = parse_compound_stimulus(class_name) - missing_components = [] + List of stimulus table names (e.g., ['Grating'] or ['Grating', 'Tones']) - for component in stimulus_components: - try: - getattr(stimulus_module, component) - logger.debug(f"Stimulus component '{component}' found in module") - except AttributeError: - missing_components.append(component) - logger.error( - f"Stimulus component '{component}' not found in stimulus module" - ) + Raises: + NWBExportError: If no stimulus component could be resolved + + Example: + >>> session_key = {'animal_id': 1, 'session': 1} + >>> resolve_stimulus_components(stimulus, experiment, session_key, 'TonesGrating') + ['Grating', 'Tones'] + """ + # Trials of this session that used this stimulus class. Restricting by class + # matters for sessions that alternate between several stimulus classes. + class_trials = ( + (experiment_module.Condition * experiment_module.Trial) + & session_key + & {"stimulus_class": class_name} + ) + session_hash = stimulus_module.StimCondition.Trial & class_trials + + components = [] + for child in stimulus_module.StimCondition.children(as_objects=True): + # Part tables of StimCondition (e.g. StimCondition.Trial) hold timestamps, + # not stimulus parameters, so they are not components. + if "__" in child.table_name: + continue + if len(child & session_hash) == 0: + continue + components.append(to_camel_case(child.table_name)) + + if not components: + raise NWBExportError( + f"Could not resolve any stimulus component for class '{class_name}'. " + f"No stimulus table holds conditions for session {session_key}" + ) - all_exist = len(missing_components) == 0 - return all_exist, missing_components + logger.debug(f"Stimulus class '{class_name}' resolved to components {components}") + return components def get_multiple_stimulus_conditions( - stimulus_module: Any, session_key: Dict[str, Any], class_name: str + stimulus_module: Any, session_key: Dict[str, Any], components: List[str] ) -> Dict[str, dj.Table]: """ - Fetch stimulus conditions for compound stimulus classes (e.g., 'Tones_Grating'). + Fetch stimulus conditions for each component of a compound stimulus. Args: stimulus_module: DataJoint stimulus module session_key: Primary key identifying the session - class_name: Name of the compound stimulus class (e.g., 'Tones_Grating') + components: Component table names, as returned by resolve_stimulus_components + (e.g., ['Tones', 'Grating']) Returns: Dictionary mapping component names to their condition tables - - Raises: - NWBExportError: If any stimulus components are missing from the database """ - # First validate that all components exist - all_exist, missing_components = validate_stimulus_components( - stimulus_module, class_name - ) - - if not all_exist: - raise NWBExportError( - f"Missing stimulus components in database: {missing_components}. " - f"Cannot export compound stimulus '{class_name}'" - ) - - stimulus_components = parse_compound_stimulus(class_name) conditions_dict = {} - for component in stimulus_components: + for component in components: component_conditions = get_stimulus_conditions( stimulus_module, session_key, component ) @@ -417,7 +411,8 @@ def get_multiple_stimulus_conditions( if not conditions_dict: logger.error( - f"No stimulus conditions found for any component of '{class_name}' in session" + f"No stimulus conditions found for any of the components {components} " + f"in session" ) return conditions_dict @@ -913,12 +908,23 @@ def analyze_array_column(series: pd.Series, col_name: str) -> dict: def add_conditions_module( nwbfile: NWBFile, exp_conditions: dj.Table, - stim_conditions: dj.Table, + stimulus_conditions: Dict[str, dj.Table], beh_conditions: dj.Table, class_names: SessionClasses, ) -> None: """ Create and add conditions metadata module to NWB file. + + A compound stimulus contributes one condition table per component, named + 'Stimulus_'; a simple stimulus contributes a single 'Stimulus' table. + + Args: + nwbfile: NWB file object + exp_conditions: Experiment condition table + stimulus_conditions: Condition table per stimulus component, as returned by + get_multiple_stimulus_conditions + beh_conditions: Behavior condition table + class_names: Classes logged for the session """ logger.info("Add condition parameters for experiment, behavior and stimuli") meta_data = nwbfile.create_processing_module( @@ -934,11 +940,17 @@ def add_condition_table( col for col in conditions.heading.names if col not in columns_to_remove ] - # Find the hash column for deduplication - hash_cols = [col for col in columns_of_interest if "_hash" in col] + # Deduplicate on the primary key, which for these joins is the union of the + # component tables' primary keys. The condition hash alone is not enough: + # part tables that extend the key (Panda.Object adds obj_id, Panda.Light + # adds light_idx) give several rows per hash, and collapsing on the hash + # would keep only one of them. + identity_cols = [ + col for col in conditions.primary_key if col in columns_of_interest + ] unique_combinations = ( - df[columns_of_interest].drop_duplicates(subset=hash_cols).copy() + df[columns_of_interest].drop_duplicates(subset=identity_cols).copy() ) if unique_combinations.empty: @@ -983,12 +995,18 @@ def add_condition_table( add_condition_table( exp_conditions, "Experiment", class_names.experiment[0], skip_cols ) - add_condition_table( - stim_conditions, - "Stimulus", - class_names.stimulus[0], - skip_cols + ["start_time", "end_time"], - ) + + # One table per stimulus component, so a compound stimulus does not lose the + # parameters of every component after the first. + stim_skip = skip_cols + ["start_time", "end_time"] + is_compound = len(stimulus_conditions) > 1 + for component, conditions in stimulus_conditions.items(): + add_condition_table( + conditions, + f"Stimulus_{component}" if is_compound else "Stimulus", + component, + stim_skip, + ) def create_dynamic_table_from_dj_table( @@ -1139,6 +1157,16 @@ def add_stimulus_data( if isinstance(first_val, (np.ndarray, list)): subset_df = subset_df.explode(col) + # Part tables that extend the primary key (e.g. Panda.Object, Panda.Light) + # repeat each presentation once per combination; the timing is identical. + n_before = len(subset_df) + subset_df = subset_df.drop_duplicates() + if len(subset_df) != n_before: + logger.debug( + f"[{stimulus_class}] collapsed {n_before} presentation rows to " + f"{len(subset_df)} after removing part-table repetitions" + ) + subset_df = subset_df.reset_index(drop=True) if subset_df.empty: @@ -1430,14 +1458,20 @@ def export_to_nwb( experiment, session_key, class_names.experiment[0] ) - # Check if stimulus is compound (contains underscore) + # Resolve which stimulus tables hold this session's parameters stimulus_class = class_names.stimulus[0] - is_compound_stimulus = "_" in stimulus_class + stimulus_components = resolve_stimulus_components( + stimulus, experiment, session_key, stimulus_class + ) + is_compound_stimulus = len(stimulus_components) > 1 if is_compound_stimulus: - logger.info(f"Detected compound stimulus: {stimulus_class}") + logger.info( + f"Detected compound stimulus '{stimulus_class}' with components: " + f"{stimulus_components}" + ) stimulus_conditions_dict = get_multiple_stimulus_conditions( - stimulus, session_key, stimulus_class + stimulus, session_key, stimulus_components ) # For trial hash, use the first available stimulus conditions if stimulus_conditions_dict: @@ -1449,9 +1483,9 @@ def export_to_nwb( stim_conditions = None else: stim_conditions = get_stimulus_conditions( - stimulus, session_key, stimulus_class + stimulus, session_key, stimulus_components[0] ) - stimulus_conditions_dict = {stimulus_class: stim_conditions} + stimulus_conditions_dict = {stimulus_components[0]: stim_conditions} beh_conditions = get_behavior_conditions( behavior, session_key, class_names.behavior[0] @@ -1483,7 +1517,11 @@ def export_to_nwb( # Add conditions metadata add_conditions_module( - nwbfile, exp_conditions, stim_conditions, beh_conditions, class_names + nwbfile, + exp_conditions, + stimulus_conditions_dict, + beh_conditions, + class_names, ) # Add stimulus data From b822bd75990e5ad3b51c4e74439823a3992f6716 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 2 Sep 2026 17:23:02 +0300 Subject: [PATCH 2/2] docs: add a guide for building compound stimuli Explains how to combine several modalities into one stimulus class, which is done by inheritance rather than composition: subclass the dominant modality, then extend cond_tables, required_fields and default_key. Includes a minimal Tones + Grating example and the task file that runs it. The bulk of the guide is the pitfalls, most of which fail silently rather than raising. Stimulus.__init__ wipes cond_tables, required_fields and default_key, so a subclass that sets them as class attributes gets an empty contract and every condition collapses to the same stim_hash. A missing required field skips a whole condition table. A component decorated with the stimulus schema registers a second time. Logging a trial in both parents duplicates it. Also distinguishes a compound stimulus, where the modalities share one stim_hash, from stim_periods, which sequences them within a trial. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S7gfyfdvHFBvXP5RNMPJ5c --- docs/compound_stimulus_example.md | 325 +++++++++++++++++++++++++++++ docs/creating_custom_components.md | 12 +- mkdocs.yml | 1 + 3 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 docs/compound_stimulus_example.md diff --git a/docs/compound_stimulus_example.md b/docs/compound_stimulus_example.md new file mode 100644 index 0000000..331387c --- /dev/null +++ b/docs/compound_stimulus_example.md @@ -0,0 +1,325 @@ +# 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](dot_stimulus_example.md) 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: + +```python +# ethopy/core/experiment.py +self.stim = self.stims[self.curr_cond["stimulus_class"]] +``` + +So a compound stimulus is a *single class* that: + +1. **Declares several condition tables** in `cond_tables`, one per modality. +2. **Merges the parameter contracts** (`required_fields`, `default_key`) of the modalities it combines. +3. **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. + +!!! note "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: + + ```python + conditions += exp.make_conditions( + stim_class=Panda(), + conditions={**block.dict(), **key}, + stim_periods=["Cue", "Response"], + ) + ``` + + 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: + +```python +# ~/.ethopy/ethopy_plugins/stimuli/tones.py +import datajoint as dj + +from ethopy.core.logger import stimulus +from ethopy.core.stimulus import Stimulus + + +@stimulus.schema +class Tones(Stimulus, dj.Manual): + definition = """ + # This class handles the presentation of Tones + -> stimulus.StimCondition + --- + tone_duration : int # tone duration (ms) + tone_frequency : int # tone frequency (hz) + tone_volume : int # tone volume (percent) + tone_pulse_freq : float # frequency of tone pulses (hz) + """ + + def __init__(self): + super().__init__() + self.cond_tables = ["Tones"] + self.required_fields = ["tone_duration", "tone_frequency"] + self.default_key = {"tone_volume": 50, "tone_pulse_freq": 0} +``` + +After adding a new condition table, create it in the database: + +```bash +ethopy-setup-schema +``` + +### 2. The compound stimulus + +```python +# ~/.ethopy/ethopy_plugins/stimuli/tones_grating.py +from ethopy.stimuli.grating import Grating + + +class TonesGrating(Grating): + """Presents a Grating and a Tone in the same trial.""" + + def __init__(self): + super().__init__() # installs Grating's cond_tables / required_fields / default_key + + # 1. add the second modality's condition table + self.cond_tables += ["Tones"] + + # 2. merge the parameter contract + self.required_fields += ["tone_duration", "tone_frequency"] + self.default_key.update({"tone_volume": 50, "tone_pulse_freq": 0}) + + # 3. per-modality state + self.sound_in_operation = False + self.grating_in_operation = False + + def start(self): + self.sound_in_operation = True + self.grating_in_operation = True + self.exp.interface.give_sound( + self.curr_cond["tone_frequency"], + self.curr_cond["tone_volume"], + self.curr_cond["tone_pulse_freq"], + ) + super().start() # logs the start time and starts the timer + + def present(self): + elapsed = self.timer.elapsed_time() + + if elapsed > self.curr_cond["tone_duration"] and self.sound_in_operation: + self.exp.interface.stop_sound() + self.sound_in_operation = False + + if elapsed > self.curr_cond["duration"] and self.grating_in_operation: + self.grating_in_operation = False + + # the trial ends only when BOTH modalities are done + if not self.sound_in_operation and not self.grating_in_operation: + self.in_operation = False + elif self.grating_in_operation: + super().present() + + def stop(self): + super().stop() # Grating.stop() -> fill, log_stop, close movie + self.exp.interface.stop_sound() + + def exit(self): + self.exp.interface.stop_sound() + super().exit() +``` + +Building the parameter contract *additively* on top of `super().__init__()` is deliberate, see [Copy the parent's whole contract](#copy-the-parents-whole-contract). + +### 3. The task + +Nothing special is required in the task file, parameters of both modalities go into the same condition dictionary: + +```python +from ethopy.behaviors.multi_port import MultiPort +from ethopy.experiments.match_port import Experiment +from ethopy.stimuli.tones_grating import TonesGrating + +exp = Experiment() +exp.setup(logger, MultiPort, {"setup_conf_idx": 0, "max_reward": 3000}) + +key = { + # Tones + "tone_duration": 3000, + "tone_frequency": 40000, + "tone_volume": 50, + # Grating + "duration": 3000, + "contrast": 80, + "spatial_freq": 0.05, + # trial control + "trial_duration": 5000, + "reward_amount": 8, +} + +conditions = [] +block = exp.Block(difficulty=1, next_up=1, next_down=1, trial_selection="staircase") +for port, theta in {1: 0, 2: 90}.items(): + conditions += exp.make_conditions( + stim_class=TonesGrating(), + conditions={**block.dict(), **key, "theta": theta, + "reward_port": port, "response_port": port}, + ) + +exp.push_conditions(conditions) +exp.start() +``` + +## 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: + +```python +conditions = self.exp.log_conditions( + conditions, + schema="stimulus", + hash_field="stim_hash", + condition_tables=["StimCondition"] + self.cond_tables, +) +``` + +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: + +```python +# ethopy/core/experiment.py +stim_dict = self.get_keys_from_dict(conditions, get_parameters(stim_class).keys()) +``` + +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_conditions` asserts 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.`. + +## 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: + +```python +class MyStimulus(Stimulus, dj.Manual): + cond_tables = ["MyStimulus"] # WRONG - silently ignored + + def __init__(self): + super().__init__() # resets cond_tables to [] +``` + +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__()`: + +```python + def __init__(self): + super().__init__() + self.cond_tables = ["MyStimulus"] # correct +``` + +### 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: + +```python +self.cond_tables += ["Tones"] # keeps Grating's tables +self.default_key.update({"tone_volume": 50}) # keeps Grating's defaults +``` + +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: + +```python +self.cond_tables = ["Tones", "Panda", "Panda.Object", + "Panda.Environment", "Panda.Light", "Panda.Movie"] +``` + +### 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: + +``` +WARNING Skipping Tones, Missing keys:{'tone_pulse_freq'} +``` + +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: + +```python + def stop(self): + self.log_stop() # once here... + self.exp.interface.stop_sound() + super().stop() # ...and again inside the parent's stop() +``` + +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: + +```python +# ethopy/experiments/passive.py +elif not self.stim.in_operation: # timed out + return "InterTrial" +``` + +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: + +```bash +ethopy-setup-schema +``` + +## Checklist + +Before running a new compound stimulus: + +- [ ] `cond_tables`, `required_fields` and `default_key` are set **inside `__init__`**, + after `super().__init__()`. +- [ ] `cond_tables` covers every modality, including the parent's part tables. +- [ ] Every field of every listed table is in `required_fields` or `default_key`. +- [ ] The class has no `@stimulus.schema` decorator, unless it defines new parameters. +- [ ] `start()` and `stop()` log exactly once. +- [ ] `in_operation` clears only when every modality is done. +- [ ] `exit()` tears down every modality (screen *and* sound). +- [ ] `ethopy-setup-schema` has 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 , Missing keys` warning appeared in the log. diff --git a/docs/creating_custom_components.md b/docs/creating_custom_components.md index 7aa097f..0cf62a1 100644 --- a/docs/creating_custom_components.md +++ b/docs/creating_custom_components.md @@ -34,7 +34,16 @@ The Dot stimulus provides a simple visual element that can be displayed at diffe - Managing the lifecycle of visual elements - Implementing timing-based presentation -### 3. [MultiPort Behavior](multi_port_behavior_example.md) +### 3. [Compound Stimulus](compound_stimulus_example.md) + +A compound stimulus presents several modalities in the same trial (e.g. a tone together with a grating). This example covers: + +- Combining the condition tables of several stimuli under one `stim_hash` +- Merging the required fields and defaults of the modalities +- Deciding which modality ends the trial +- The pitfalls that fail silently + +### 4. [MultiPort Behavior](multi_port_behavior_example.md) The MultiPort behavior handles interactions with multiple response ports. This example illustrates: @@ -76,6 +85,7 @@ Explore each example in detail: - [Experiment](match_port_example.md) for state machine implementation - [Stimulus](dot_stimulus_example.md) for visual stimulus creation +- [Compound Stimulus](compound_stimulus_example.md) for multimodal stimuli - [Behavior](multi_port_behavior_example.md) for response handling These examples provide a foundation for understanding how to extend Ethopy with custom components tailored to your specific experimental needs. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 5c2ad3a..3b26b1c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,7 @@ nav: - Overview: creating_custom_components.md - Experiment: match_port_example.md - Stimulus: dot_stimulus_example.md + - Compound Stimulus: compound_stimulus_example.md - Behavior: multi_port_behavior_example.md - Plugins: plugin.md - Task Setup: task_setup.md