Programming tasks in pyControl

Overview

Behavioural tasks in pyControl are implemented as state machines, the basic elements of which are states and events. To implement a task the user creates a task definition file written in Python. Example task definition files are provided in the tasks folder.

Task definition files run directly on the Micropython microcontroller, not on the computer. Python modules such as Numpy that are installed on the computer will therefore not be available in the task definition. For a list of the modules available in Micropython see the Micropython docs.

Imports

Every task definition file starts by importing some utility functions and constants:

from pyControl.utility import *

If you are using a hardware definition file to specify external hardware you also need to import the hardware definition:

import hardware_definition as hw

States

The state machine has a set of states defined by the user, and at any point in time it is in one of these states. The set of states is defined by a varible called states which is a list of state names:

states = ['state_A', 'state_B']

The state the machine starts in must be specified by setting a variable called initial_state to the name of the initial state:

initial_state = 'state_A'

Events

Events cause the state machine to do things, they may be generated externally by hardware inputs or internally by setting timers. The user specifies the events used by the state machine by creating a variable called events which is a list of event names.

events = ['event_A, event_B'] 

Events used by internal timers can have whatever name you want. Events generated by external inputs must have the event name specified for the relevent input in the hardware definition.

State behaviour functions

The behaviour of the state machine is defined by writing a set of state behaviour functions, each of which defines the behaviour of one state. The name of the function must be the same as the name of the state whose behaviour it defines, and the function must take a single argument called event. When an event occurs the function corresponding to the current state is called with the event that occured as an argument. For example if 'event_A' occurs when the state machine is in 'state_A', the function state_A is called with 'event_A' as an argument. State behaviour functions usually consist of a set of if / elif statements which determine what happens when different events occur.

def state_A(event):
    if event == 'event_A':
        # do something.
    elif event == 'event_B':
        # do something else.

Any valid micropython code can be put in state behaviour functions, though the code should execute fast as it will block any other behaviour of the state machine while it is executing.

Changing state

The function goto_state tells the state machine to transition to the state who's name is provided as an argument.

goto_state('state_B')

When a state transition occurs two special events called 'exit' and 'entry' are generated. The 'exit' event is evaluated in the state that is being exited, while the 'entry' event is evaluated in the state which is being entered. These events allow code to be executed every time a state is entered or exited, irrespective of the event that triggered the state transition. Example usage:

def state_A(event):
    if event == 'entry':
        # Code here executes when state_A is entered.
    elif event == 'exit':
        # Code here executes when state_A is exited.

Calling goto_state in response to an 'entry' or 'exit' event will cause an error because the state machine is already in transition to a new state when these events occur.

It is not recomended to put any code in a state behaviour function that executes after a goto_state because it will be executed following the state transition when the task is in the subsequent state, potentially causing hard to identify bugs.

Time dependent behaviour.

The simplest way to implement time dependent behaviour is using the timed_goto_state function. This tells the state machine to transition to a specified state after a specified interval has elapsed.

timed_goto_state('state_C', 10*second) # Transition to state 'state_C' after 10 seconds.

If a state transition occurs for any reason before the timed_goto_state triggers, the timed_goto_state is canceled and will have no effect.

Timers provide a more flexible way of implementing time dependent behaviour by allowing a specified event to be triggered after a specified time has elapsed.

set_timer('event_A', 100*ms) # Trigger event 'event_A' after 100 ms.

Timers generate the specified event regardless of whether the state machine has changed state since the timer was set. You can set multiple timers for the same event, for example the following code will set two timers, causing event_C to occur twice, once after 1 second and once after 3 seconds have elapsed.

set_timer('event_C', 1*second)
set_timer('event_C', 3*second)

The constants ms, second, minute, and hour can be used as in the examples above.

The function disarm_timer disarms all active timers which are due to generate the specified event.

disarm_timer('event_A')

The function reset_timer disarms all active timers due to generate the specified event and sets a new timer to generate the specified event after the specified duration.

reset_timer('event_A', 50*minute)

The function pause_timer pauses, and the function unpause_timer unpauses, all timers set to generate the specified event. When a timer is paused the time remaining untill the event is generated is stored, then when it is unpaused it is restarted with the remaining time. For example if a timer is set for 5 seconds, then paused after 3 seconds, when it is unpaused it will have 2 seconds remaining till the event is generated.

pause_timer('event_A')

unpause_timer('event_A')

The function timer_remaining returns the time in ms until a timer set for the specified event elapses, and returns 0 if no timer is set for that event.

timer_remaining('event_A')

The get_current_time function returns the number of milliseconds since the framework started running.

x = get_current_time()

Run start and end behaviour

Code can be executed when the framework starts or stops running by defining the following functions in the state machine discription:

def  run_start():
    # Code here is executed when the framework starts running.

def run_end():
    # Code here is executed when the framework stops running.

The framework can be stopped from within a state machine by calling the function:

stop_framework()

When the framwork is stopped in this way, the run_end function will be called as usual. The typical use of stop_framework is to stop the framework at the end of the behaviour session, dependent on a timer or criterion such as the number of rewards delivered.

State independent behaviour

The all_states function can be used to implement behaviour which occurs irrespective of the state the task is in. Combined with timers and variables, the all_states function is a powerful tool for making things happen in parallel with the main state set of the task. For a simple example of this see the all_states_example task.

If the all_states function is defined, it will be called every time an event occurs with the event that occured as an argument.

def all_states(event):
    # Code here will be executed when any event occurs,
    # irrespective of the state the machine is in.

After the all_states function has executed the state behaviour function for the current state will be executed. To block execution of the current state's behaviour function make the all_states function return True.

def all_states(event):
    # Code here will be executed when any event occurs,
    # irrespective of the state the machine is in.
    if condition:
        return True # If condition is True, the current state's 
                    # behaviour function will not be executed.

Variables

Variable names should start with v. , for example:

v.variable_A = 0.5

Variables are referenced in state behaviour functions using the same syntax:

def state_A(event):
    if event == 'event_A':
        if v.variable_A > 0.3:
            # do something
        else:
            # do something else.

Starting all variable names with v. makes all variables attributes of a single object called v (imported from pyControl.utility). This avoids problems that could otherwise occur when variables are accessed or set in state behaviour functions due to these functions having their own namespaces. It also allows the GUI and CLI to access the variables to set or get their values.

You can make task variables invisible to the GUI by ending their name in three underscores (e.g. v.my_private_variable___). Such 'private' variables work as normal in the task file but do not show up in the GUI. This can be useful if you have a lot of task variables, making it hard to find the ones you need to change in the GUI.

Data output

Whenever an external event occurs it is output to the data log along with a timestamp. Whenever a state transition occurs the state that is entered is output with a timestamp. Events triggered by timers are not logged in the data output by default, but any state transitions they generate are logged as normal. If you want an event triggered by a timer to be recorded in the data output, set the output_event argument to True when setting the timer:

set_timer('event_A', 3*second, output_event=True)

reset_timer('event_B', 3*second, output_event=True)

The print() function has modified behavour when called within the context of a task definition file - the printed string is output to the data log along with a timestamp. The print function can therefore be used to output arbitrary data with timestamps consistent with those of events and state transitions. The reversal_learning example shows one approach to using print statements to output task data. One line is printed for each trial summarising what happened on that trial and the current state of the task.

Structuring task files

As pyControl task definition files are just Python modules, you can define arbitrary functions that can be called from within state behaviour functions. A useful application of this is to seperate code that defines the behaviour of the state machine from code which implements other aspects of the task, such as block structure or stimulus selection. Organising task code in this way is recommended as it makes task definitions easier to understand and maintain. The reversal_learning example uses this structure.

Function reference

State machine functions


goto_state

goto_state(next_state)

Transition to state next state. An 'exit' event is processed in the state that is being left, and an 'entry' event is processed in the state that is being entered.

Example usage:

goto_state('state_A') # Transition to state 'state_A'

timed_goto_state

timed_goto_state(next_state, interval)

Transition to state next state after interval milliseconds have elapsed. The current state will behave as normal till the state transition occurs. 'entry' and 'exit' actions will be processed when the state transition occurs as with goto_state. If a state transition occurs for any reason before the timed_goto_state triggers, the timed_goto_state is canceled and will have no effect. Constants ms, second, minute and hour can be used in specifying the interval as shown in an example below.

Example usage:

timed_goto_state('state_B', 100*ms)    # Transition to state 'state_B' after 100 ms

timed_goto_state('state_C', 10*second) # Transition to state 'state_C' after 10 seconds.

set_timer

set_timer(event, interval, output_event=False)

Set a timer to trigger the specified event after interval milliseconds have elapsed. When a timer is set it will trigger the specified event at the specified time irrespective of any state transitions that have occurred after it was set, unless it is disarmed or reset using disarm_timer or reset_timer (see below). The event triggered by the timer is only recorded in the data output if the output_event argument is set to True.

Example usage:

set_timer('event_X', 5*second) # Trigger event 'event_X' after 5 seconds

disarm_timer

disarm_timer(event)

Disarm any timers due to trigger specified event. The events that would have been generated by the disarmed timers will not be generated.

Example usage:

disarm_timer('event_X') # Disarm all timers set to trigger event 'event_X'

reset_timer

reset_timer(event, interval, output_event=False)

Disable any timers due to trigger specified event and set new timer to return specified event after interval milliseconds have elapsed. The event triggered by the timer is only recorded in the data output if the output_event argument is set to True.

Example usage:

reset_timer('event_X', 5*minute) # Disable any timers due to trigger event 'event_X' and
                                 # set a timer to trigger 'event_X' in 5 minutes.

pause_timer

pause_timer(event)

Pause any timers due to trigger specified event. While a timer is paused the timer remaining till it elapses does not decreace.


unpause_timer

unpause_timer(event)

Unpause any timers due to trigger specified event. When a timer is unpaused it restarts with the same time remaining as when it was paused.


timer_remaining

timer_remaining(event)

Return the time in ms until a timer set to trigger the specified event elapses, returns 0 if no timer is set for event.


get_current_time

get_current_time()

Returns the number of milliseconds since the framework started running.


publish_event

publish_event(event)

Trigger the specified event. The principal use case is to generate an event in the data output to log when something important has happened.


print

print(print_string)

Print print_string to the data output. Printed strings are given a time-stamp so that the time the print statement was executed is recorded in the data file.


stop_framework

stop_framework()

Stop the framework running.


Random functions and classes

Functions and classes for random number generation.


random

random()

Return a random float between 0 and 1.


withprob

withprob(p)

Return a random boolean that is True with probability p.


shuffled

shuffled(L)

Return a shuffled copy of list L.


choice

choice(L)

Return a randomly selected element from list L.


randint

randint(a,b)

Return a random integer N such that a <= N <= b.


exp_rand

exp_rand(m)

Return a random number drawn from an exponential distribution with mean m.


gauss_rand

gauss_rand(m,s)

Return a random number drawn from a gaussian distribution with mean m and standard deviation s.


sample_without_replacement

class sample_without_replacement(items)

Class for randomly sampling elements from list items without replacement. When all items have been sampled the sampling begins again with the full list.

Methods:

sample_without_replacement.next() Get the next sample.

Example usage:


x = sample_without_replacement(['a','b','c']) # Instantiate with list ['a','b','c']

next_sample = x.next() # Get the next sample.

Math functions and classes.

The Micropython math module provides many math functions:


import math

math.log(x) # Return the natural logarithm of x

math.sin(x) # Return the sine of x

pyControl provides the following additional maths functions and classes:


mean

mean(x)

Return the mean value of x.


exp_mov_ave

class exp_mov_ave(tau, init_value=0)

Class for calculating exponential moving average with specified time constant tau and intial value init_value.

Methods:

exp_mov_ave.update(sample) Update the moving average with a new sample.

Atributes:

exp_mov_ave.value The current value of the moving average.

Example usage:

y = exp_mov_ave(tau=8, init_value=0.5) # Initiaise a moving average object with
                                       # time constant of 8 samples and initial value of 0.5

y.update(sample)  # Update the value of the moving average with a new sample.

current_value = y.value  # Get the current value of the moving average.