Implementing Tasks

In AxoPy, the individual components of an experiment are tasks. In essence, a task does any or all of the following:

  • Takes in data from previous tasks (read)
  • Streams data from a data acquisition device (daq)
  • Processes the streaming or task data (proc)
  • Draws things to the screen (ui)
  • Outputs data of its own (write)

One example of a task is the Oscilloscope which we saw on the previous page. It is responsible for streaming data from a data acquisition device (daq) and displaying it in a dynamic plot (ui). The purpose of this task is usually just to allow the researcher to visually verify the integrity of the data coming in from the device before proceeding with the rest of the experiment.

Another example of a task is a cursor control task (subject produces input in attempt to hit targets on the screen). This kind of task reads in and processes data from an input device (daq), displays some information on screen to give feedback to the subject (ui), and records some data for post-experiment analysis (write). It may also require some calibration parameters from a previous task (read). This is a fairly complex task with an enormous number of possible implementations, so there’s no built-in CursorControlTask.

There isn’t really a strict definition of what a single task is or what it should or shouldn’t do, but it’s a good idea to make tasks as simple as possible — any given task should do just a couple things and do them well. This encourages modularity so you can re-use task implementations in different experiments, etc.

In terms of the AxoPy API, a task looks like the following:

../_images/task_diagram.png

In this part of the user guide, we’ll go through how to make each of the four connections in the diagram and refer to separate documents for the details of working with those four components.

The Task Lifecycle

AxoPy experiments are event-driven, following the way graphical user interface frameworks tend to operate. This can be an unfamiliar way of writing programs, so it’s important to understand the overall idea before seeing some of the details. Tasks in an experiment all go through the same lifecycle, shown below. First, the Task instances are created (by you) and then they’re handed off to an Experiment, like we saw in the previous section:

exp = Experiment(...)
exp.run(Oscilloscope())

Once you call run(), the Experiment collects the task objects passed in, sets up the shared resources (data storage, data acquisition, graphical backend), then proceeds to prepare and run each task in sequence. That means pretty much all of the functionality of the experiment is defined in the Task classes.

The most important thing to understand about tasks is that they’re written by defining what should happen in response to certain events. For example, the Oscilloscope task defines a method that gets called every time there is new data available from the data acquisition stream, allowing it to update the signals displayed on the screen. This is sometimes referred to as a callback. You can think of the Experiment as running an infinite loop checking for events that occur, then dispatching the data from those events to the task if appropriate.

There are a standard set of methods that are automatically run by the Experiment the task belongs to, and you can optionally implement these methods to make use of the shared resources that the Experiment manages. These are the prepare methods: prepare_design, prepare_storage, prepare_input_stream, and prepare_graphics.

Say you’re writing a task that makes use of data storage only (read and write). A common example of this is processing some data to make it suitable for other tasks later on in the experiment. To interact with the storage functionality set up by the Experiment, your class should implement the Task.prepare_storage() method. If you click on the link to the API documentation for that method, you’ll see that a Storage object is passed into this method, which is provided by the Experiment. We’ll see more details about setting up storage specifically later on, but for the sake of the example, it’s sufficient to point out that the storage object lets you create reader and/or writer objects that you can save for use later on in your task:

from axopy.task import Task

class MyTask1(Task):

   def prepare_storage(self, storage):
      # here's where we can use the storage object to read data from other
      # tasks or write our own data to storage

The Task.prepare_design() is slightly different from the others in that it’s not actually for setting up a shared resource. It’s actually just an opportunity for your task to build a Design so that it can easily be written in terms of a series of repeated trials.

After the rest of the prepare methods are called, the Task.run() method is called. This is where your task should set up its own events and start running. In “experiment tasks” (those implementing prepare_design), the flow of the task proceeds through it’s Design by calling Task.next_trial`.

There are two main ways for a task to end. One is by calling Task.finished() somewhere in your task implementation. This signals to the Experiment that the task is done, then the Task.finish() method is called so you can clean up anything you need to before the next task runs. A common example of cleanup is to make sure the DAQ is stopped.

The flowchart below shows the lifecycle of a Task when it’s run by an Experiment.

../_images/task_flowchart.png