Implementing new tasks
There are two scenarios for adding a task to MultiTaskBattery:
Adding a task locally to your experiment. The task lives only in your experiment folder and is registered via
task_modulesin yourconstants.py. The shared package is unchanged. This is the right path for almost everyone — seeexperiments/example_custom_taskfor a working reference.Contributing a task to the shared library. The task is added to
MultiTaskBattery/task_blocks.py,task_file.py, andtask_table.tsv, documented intask_details.json, and submitted via pull request. Use this path only when you want others outside your project to use the task too.
Adding a task locally to your experiment
Recommended layout: both the runtime Task class and the file-generator
TaskFile class for one task live in a single module in your experiment
folder.
1. Create a local module
Add a new .py file (e.g. my_tasks.py) in your experiment folder
that defines both classes:
from MultiTaskBattery.task_blocks import Task
from MultiTaskBattery.task_file import TaskFile
from psychopy import visual, event
import pandas as pd
import numpy as np
class MyTask(Task):
def __init__(self, info, screen, ttl_clock, const, subj_id):
super().__init__(info, screen, ttl_clock, const, subj_id)
self.feedback_type = 'acc+rt' # or 'none', 'acc', 'rt'
def init_task(self):
... # read trial info, load stimuli
def display_instructions(self):
... # task-specific instructions
def run_trial(self, trial):
... # display stimulus, collect response, return trial
class MyTaskFile(TaskFile):
def __init__(self, const):
super().__init__(const)
self.name = 'my_task' # must match the row in task_table.tsv
def make_task_file(self, ..., file_name=None):
# Every row MUST include trial_num, start_time, end_time and
# trial_dur — Task.run() waits on trial.start_time for all tasks.
... # generate trial-level rows, write tsv
Methods to implement on the Task subclass:
init_task(): Read the task’s trial-info TSV intoself.trial_info. Load any stimuli into memory.display_instructions(): Show task-specific instructions on the screen.run_trial(trial): Run a single trial — display stimuli, collect responses, return the trial row with any added columns.
Useful methods inherited from the Task parent:
wait_response(start_time, max_wait_time): Wait for a button press and return(key, rt).display_trial_feedback(give_feedback, correct): Show a green check or red cross based on correctness.screen_quit(): Check for the escape key to quit the experiment.
feedback_type selects the end-of-run scoreboard ('none', 'acc',
'rt', or 'acc+rt') and is load-bearing: if it contains 'acc',
run_trial must set trial['correct'] on the returned row; if it
contains 'rt', it must set trial['rt']. Task.run() averages
those columns at the end of the block, so a missing one raises KeyError on
every run. The skeleton above uses 'acc+rt', which requires both.
If your task generates random stimuli (no fixed stimulus file per run),
omit run_number from the make_task_file signature — make_files.py
inspects the signature to decide whether to pass it.
2. Add a row to a local task_table.tsv
Create a task_table.tsv file in your experiment folder with a single
tab-separated row for your task (same columns as the shared table):
name task_class descriptive_name code
my_task MyTask my_task mytsk
The local table is merged with the shared one automatically when
make_files.py passes exp_dir=const.exp_dir to tf.make_run_file
and tf.get_task_class (the example make_files.py already does this).
3. Register your module in constants.py
Import your local module and add it to task_modules:
import my_tasks
task_modules = [my_tasks]
At runtime, ut.get_task_class walks task_modules first and falls
back to the shared package. At file-generation time,
ut.get_task_file_class does the same — and appends 'File'
automatically when searching local modules — so make_files.py needs
zero changes for new tasks.
4. Add stimuli (if your task uses them)
Stimulus files (images, audio, video) live in a per-task subfolder
<root>/<task_name>/ (the <task_name> matches the name column in
task_table.tsv). If your task generates stimuli procedurally, skip this
step.
Stimuli are resolved with the same local-first, package-fallback logic as
task classes. Both at runtime and during file generation, each stimulus is
looked up as <root>/<task_name>/<file> across a search path of roots, in
order, and the first existing file wins. The search path is:
the root(s) in
const.stim_dirs(a list) if you define it, otherwise the singleconst.stim_dir; thenthe package’s own bundled
stimulifolder, always appended as a final fallback.
Because your own roots are searched first, the package fallback can only add resolvable files — it never overrides yours. This lets a custom task keep its stimuli inside your experiment folder while the built-in tasks still resolve their stimuli from the package. So you have two options:
Local (recommended for custom tasks): add your experiment’s stimulus folder to the search path in
constants.pyand put files there:stim_dirs = [exp_dir / "stimuli", stim_dir] # local first, package fallback
then place your files in
<exp_dir>/stimuli/<task_name>/.Shared library (when contributing a task): place files under the package’s
stimuli/<task_name>/at the repository root.
If your task reads stimuli, resolve them with the helpers
ut.find_stim(const, task_name, *parts) (a single file — e.g.
ut.find_stim(self.const, self.name, 'clip.mov')) and
ut.find_stim_dir(const, task_name) (the task’s folder, e.g. for globbing),
rather than building the path from const.stim_dir directly, so your task
picks up the full search path.
5. Test
Add your task to the blocks list in your experiment’s
make_files.py (as ('my_task', {}), or with kwargs such as
('my_task', {'condition': 'A'})), generate the run and task files,
and run run.py.
experiments/example_custom_task is the reference for a working
custom-task setup.