Task Development Guide
To create a task, create a Python file inside your project’s code directory, and
within it, a class named after the task, inheriting from the generic TaskBase
class. Naming conventions follow Python standards: CamelCase for class names,
lower_case for filenames and function/variable names. Let’s look at an example.
A minimal task
village/code/bpod_1_habituation.py is the simplest real task in this project — the
mouse is just left alone in the box, pokes are logged, no reward is given:
from village.custom_classes.task_base import BpodEvent, BpodOutput, TaskBase
class Bpod1Habituation(TaskBase):
def __init__(self):
super().__init__()
self.info = """
Habituation Task (Bpod)
----------------------------------------------------------
This task is an automatic mouse habituation to the box.
Nothing will happen during the task, the mouse will be left alone in the box
for the duration of the task.
Port pokes will be registered but no reward will be delivered.
"""
The task is named Bpod1Habituation. It’s initialized with __init__, and we
acquire all the properties of the generic TaskBase class using super().__init__().
self.info is shown to the user when the task is selected in the GUI to run
manually.
Four methods must be implemented in your class: start, create_trial,
after_trial, and close.
The start() method
Called once, when the task starts. Use it to compute anything the whole session needs — valve opening times, loaded sounds, opened serial connections, etc.
def start(self):
"""In this simple task we don't need to do anything in the start method."""
pass
The most commonly used attributes available by default inside any task method
(see the TaskBase class docstring for the full list) are:
self.bpod— the Bpod interface, used insidecreate_trialto build the state machine.self.settings— the session’s parameters, as defined intraining_protocol.py(e.g.self.settings.reward_volume).self.calibrations— convert hardware values to real-world units, e.g.self.calibrations.water_calibration.get_valve_time(port, volume)orself.calibrations.sound_calibration.get_sound_gain(speaker, dB, sound_name).self.cam_box,self.gpio,self.custom_areas— the box camera, GPIO output control, and any custom-shaped detection areas the project defines.self.name,self.subject,self.current_trial,self.system_name,self.date.self.trial_data— populated automatically after each trial, available insideafter_trial(see below).
A task that delivers water almost always needs a valve opening time first — calibrate the ports from the Water Calibration panel before using this, or it raises an exception:
def start(self):
self.valve_l_time = self.calibrations.water_calibration.get_valve_time(
port=1, volume=self.settings.reward_volume
)
self.valve_r_time = self.calibrations.water_calibration.get_valve_time(
port=3, volume=self.settings.reward_volume
)
The create_trial() method
Called once per trial. It builds the Bpod state machine for that trial (the
machine is only sent to Bpod and actually run once create_trial returns).
def create_trial(self):
"""
This task is very simple, the state machine has only one state
called "ready_to_explore". We give it a 60-second timer and a
timer-up condition (once the timer elapses) that takes us to "exit".
Also, every time there's a poke in any port, we switch to the "exit" state.
"""
self.bpod.add_state(
state_name="ready_to_explore",
state_timer=60,
state_change_conditions={
BpodEvent.Tup: "exit",
BpodEvent.Port1In: "exit",
BpodEvent.Port2In: "exit",
BpodEvent.Port3In: "exit",
},
output_actions=[],
)
BpodEvent and BpodOutput (imported from village.custom_classes.task_base
alongside TaskBase) enumerate every Bpod input event and output action. See
More Bpod primitives below for a full walkthrough of
add_state, LEDs, valves, softcodes and TTL.
The after_trial() method
Called once after each trial ends, to register whatever values you want saved
to the session’s data file. self.trial_data is populated automatically by
then — self.trial_data.get("Port1In", []) is the list of timestamps at which
port 1 was poked during the trial (empty if it never was), and
self.trial_data.get("STATE_<name>_START", []) / "..._END" likewise for every
Bpod state visited.
def after_trial(self):
"""
Here we look at the trial_data dictionary that bpod records automatically
to find out which events we got.
"""
outcome = "miss"
# for each portIn, we have a list of times at which pokes were registered,
# so we check that there's an entry for that port and that it isn't empty,
# which indicates that a poke happened.
if self.trial_data.get("Port1In"):
outcome = "left_poke"
if self.trial_data.get("Port2In"):
outcome = "center_poke"
if self.trial_data.get("Port3In"):
outcome = "right_poke"
# Register the outcome of the trial and the water consumed.
# Registering "water" (in microliters) is mandatory on every task --
# it's how the system tracks each subject's daily water intake.
self.register_value("outcome", outcome)
self.register_value("water", 0)
The close() method
Called once when the task finishes (session ends, or manually stopped). Use it for any cleanup — closing a serial connection, sending a Slack/email summary, generating a plot, etc.
def close(self):
"""
We don't need to do any extra work when the task finishes.
"""
pass
A more complete example: FollowTheLight
Now let’s look at a task closer to a real 2-choice discrimination protocol —
village/code/bpod_4_center_initiated.py and bpod_5_introduce_penalty.py are
the real versions of this in the project (respectively without and with a
penalty for the wrong side); this walkthrough merges both into one task that
switches behavior based on self.settings.stage:
The mouse initiates each trial by poking the center port (its LED turns on).
After the center poke, one of the two side LEDs turns on at random.
Poking the correct side delivers a reward.
In stage 1, poking the wrong side does nothing (the mouse can just try again). In stage 2, it triggers a penalty (noise + timeout) instead.
Progression between stages is decided in
training_protocol.py’supdate_training_settings, based on performance.
import random
from village.custom_classes.task_base import BpodEvent, BpodOutput, TaskBase
class FollowTheLight(TaskBase):
def __init__(self):
super().__init__()
self.info = """
Follow The Light Task
-------------------
The mouse pokes the center port to start a trial. One of the two side
ports then lights up; poking it delivers a reward. Stage 1 has no
penalty for the wrong side, stage 2 does (see self.settings.stage).
"""
def start(self):
"""
Required settings (defined in training_protocol.py):
- self.settings.reward_volume: reward volume delivered on a correct poke
- self.settings.led_intensity: port LED brightness (0-255)
- self.settings.c_led_on_time: time allowed to poke the center port, seconds
- self.settings.led_on_time: time allowed to poke the correct side, seconds
- self.settings.iti_time: inter-trial interval, seconds
- self.settings.stage: 1 (no penalty) or 2 (penalty for the wrong side)
- self.settings.noise_time, self.settings.timeout: only used in stage 2
"""
self.valve_l_time = self.calibrations.water_calibration.get_valve_time(
port=1, volume=self.settings.reward_volume
)
self.valve_r_time = self.calibrations.water_calibration.get_valve_time(
port=3, volume=self.settings.reward_volume
)
def create_trial(self):
self.side = random.choice(["left", "right"])
if self.side == "left":
valvetime = self.valve_l_time
valve_action = BpodOutput.Valve1
correct_led = (BpodOutput.PWM1, self.settings.led_intensity)
correct_side = BpodEvent.Port1In
wrong_side = BpodEvent.Port3In
else:
valvetime = self.valve_r_time
valve_action = BpodOutput.Valve3
correct_led = (BpodOutput.PWM3, self.settings.led_intensity)
correct_side = BpodEvent.Port3In
wrong_side = BpodEvent.Port1In
# In stage 1, a wrong poke isn't wired to any transition, so it's just
# ignored and the mouse can try again before "side_led_on" times out.
side_led_on_conditions = {
BpodEvent.Tup: "exit",
correct_side: "water_delivery",
}
if self.settings.stage == 2:
side_led_on_conditions[wrong_side] = "wrong_choice"
# 'c_led_on': center LED on, waits for the trial-initiating center poke
self.bpod.add_state(
state_name="c_led_on",
state_timer=self.settings.c_led_on_time,
state_change_conditions={
BpodEvent.Tup: "exit",
BpodEvent.Port2In: "side_led_on",
},
output_actions=[(BpodOutput.PWM2, self.settings.led_intensity)],
)
# 'side_led_on': only the correct side's LED turns on
self.bpod.add_state(
state_name="side_led_on",
state_timer=self.settings.led_on_time,
state_change_conditions=side_led_on_conditions,
output_actions=[correct_led],
)
self.bpod.add_state(
state_name="water_delivery",
state_timer=valvetime,
state_change_conditions={BpodEvent.Tup: "iti"},
output_actions=[valve_action],
)
self.bpod.add_state(
state_name="iti",
state_timer=self.settings.iti_time,
state_change_conditions={BpodEvent.Tup: "exit"},
output_actions=[],
)
# Stage 2 only: noise, then a silent timeout, before exiting.
self.bpod.add_state(
state_name="wrong_choice",
state_timer=self.settings.noise_time,
state_change_conditions={BpodEvent.Tup: "timeout"},
output_actions=[BpodOutput.SoftCode4],
)
self.bpod.add_state(
state_name="timeout",
state_timer=self.settings.timeout - self.settings.noise_time,
state_change_conditions={BpodEvent.Tup: "exit"},
output_actions=[],
)
def after_trial(self):
"""Work out response_side and outcome for this trial.
Whichever side the animal poked first (if any) after the side LED
turned on determines the outcome, independently of which Bpod state
that poke happened to transition into -- this way stage 1 (where a
wrong poke isn't wired to any transition) and stage 2 (where it goes
to "wrong_choice") are scored the same way.
"""
side_led_on_start = self.trial_data.get("STATE_side_led_on_START")
if not side_led_on_start:
# The center poke never happened -> side LED never turned on.
self.register_value("rewarded_side", self.side)
self.register_value("water", 0)
self.register_value("outcome", "omission")
self.register_value("response_side", "none")
return
t_side_led_on = side_led_on_start[0]
correct_key, wrong_key = (
("Port1In", "Port3In") if self.side == "left" else ("Port3In", "Port1In")
)
correct_pokes = [
t for t in self.trial_data.get(correct_key, []) if t >= t_side_led_on
]
wrong_pokes = [
t for t in self.trial_data.get(wrong_key, []) if t >= t_side_led_on
]
if correct_pokes and (not wrong_pokes or correct_pokes[0] <= wrong_pokes[0]):
outcome = "correct"
response_side = self.side
water = self.settings.reward_volume
elif wrong_pokes:
outcome = "incorrect"
response_side = "right" if self.side == "left" else "left"
water = 0
else:
outcome = "miss"
response_side = "none"
water = 0
self.register_value("rewarded_side", self.side)
self.register_value("water", water)
self.register_value("outcome", outcome)
self.register_value("response_side", response_side)
def close(self):
pass
More Bpod primitives
For a runnable walkthrough of the Bpod building blocks, see two reference
tasks in this project’s code directory (neither is a real behavioral
protocol):
bpod_example_outputs_and_events.py— LEDs, a valve, poke in/out, softcodes in both directions, and TTL pulses in both directions.bpod_example_global_timer.py— a global timer: a deadline that ticks in the background across every state, independently of each state’s own timer, until it ends or is cancelled.
Two more primitives exist beyond what those examples cover — global counters
(self.bpod.set_global_counter(counter_number, target_event, threshold),
counts occurrences of an event and fires once a threshold is reached) and
conditions (self.bpod.set_condition(condition_number, condition_channel, channel_value), checks a channel’s current level at the moment of a
transition rather than reacting to an edge). For both, see the official Bpod
documentation: sanworks.github.io/Bpod_Wiki.