Finite-State Machines
This chapter introduces the finite-state machine (FSM) concept and explains how to create, validate, visualize, import, and export state machines with bpod-core.
What is a Finite State Machine?
A finite-state machine (FSM) is a model of computation made up of a finite number of states and transitions between those states. At any given moment, the machine is in exactly one state, and certain events cause it to move, or transition, to another state. Think of the finite-state machine as a flowchart with a list of named boxes (states) and arrows (transitions) between them—this type of flowchart is called a state diagram.
Fig. 1 A state diagram.
In practice, finite-state machines are used to model a wide range of systems where behavior depends on a sequence of events, making the logic easier to design and understand. A simple light switch offers an intuitive example of a finite-state machine. It has only two states—Off and On—and two events that trigger transitions between them: flip up and flip down. At any given moment, the switch is in exactly one state, and performing the corresponding flip moves the system to the other.
Fig. 2 Flip up, flip down—ad infinitum.
While the light switch illustrates a finite-state machine with no clearly defined start or end, many real-world processes have natural beginnings and endings. The scientific publication process provides an example. It begins in the Draft state, progresses through the Review state, and—after a few revision—(hopefully) concludes with a Publication. In a state diagram, the entry to the finite-state machine is typically indicated by a filled circle, while the exit is shown with a double circle:
Fig. 3 If only the reviewers ever agreed …
Finite-state machines can be a powerful tool in the design of behavioral experiments. In this context, they can be used to specify trial structure, stimulus presentation, and response contingencies in a clear and reproducible way. Each state represents a specific phase of the experiment, and transitions are triggered by events such as a subject’s action or a timer. Timers are particularly useful to control the duration of states and the timing of their associated output actions—such as turning on a light, sounding a buzzer, or delivering a reward.
Fig. 4 A simple trial sequence implemented as a finite-state machine.
The finite-state machine pictured above represents a single trial in a behavioral experiment. It heavily relies on timers to define both the duration of states and their associated output actions. The Stimulus ends automatically when its timer expires, moving the subject into the Wait state. From there, the trial can proceed in two ways: if the subject performs the required action (pressing a lever) within the allotted time of the Wait state, the machine transitions to Reward; otherwise, a Buzzer signals a missed opportunity. The durations of both the reward and the buzzer are again governed by their respective timers, after which either state transitions to the trial’s End state and, finally, to the trial’s exit.
Key Concepts
- State
A specific configuration of the system at a given moment.
- Event
A trigger that causes the system to transition from one state to another.
- Transition
The movement of the system from one state to another in response to an event.
- Output action
A controlled action that occurs with the onset of the state.
- Timer
A mechanism that, after a set interval, generates an event which may trigger a transition.
The StateMachine Data Model
In bpod-core, an FSM is represented by the StateMachine
class.
It defines states, state transitions, and the output actions assigned to each state. It
also introduces Bpod-specific concepts such as state timers, global timers, conditions,
and global counters. Finally, it provides tools for validation, visualization, and
importing/exporting to and from other formats.
Creating a State Machine
A state machine is created by instantiating a StateMachine
object and adding states with its add_state()
method:
from bpod_core.fsm import StateMachine
fsm = StateMachine()
fsm.add_state(name='Hello')
The commands above create a state machine with a single state named Hello. The very first state that is added to a state machine automatically becomes the entry state — this is where execution begins. Every state comes with a state timer, which can be used to generate timer-based events. By default, this timer is set to 0 s, meaning the state will immediately trigger a timeout event. The created state machine can be visualized using the following state diagram:
Fig. 5 Well hello!
See the section Import and Export for details on how such state diagrams are
generated. To make things a bit more interesting, let’s add a second state named
World, this time with a 1 s state timer. Normally, we could just call the
add_state()
method again. However, for the sake of
demonstration, we’ll use a different approach by adding a new entry directly to
our state machine’s states
dictionary.
fsm.states['World'] = {'timer': 1}
Now our state machine contains two states: Hello and World. However, they are not yet connected, which is obvious in the state diagram below:
Fig. 6 That doesn’t look right.
As you can see, we’re missing a transition from Hello to World. Fortunately,
transitions can be added later by directly modifying the fields of the
StateMachine
instance.
Let’s add a transition from Hello to World, triggered by the end of Hello’s
state timer. While we’re at it, we’ll also change Hello’s state timer to 1.5 s.
Finally, we’ll add a transition from World to the special exit state:
fsm.states['Hello'].transitions = {'Tup': 'World'}
fsm.states['Hello'].timer = 1.5
fsm.states['World'].transitions = {'Tup': '>exit'}
The end of a state timer is signaled by the Tup
event (short for time up).
A state’s transitions are defined in a Python dict
, where keys are the
triggering events (e.g. Tup), and values are the transition targets (either another
state’s name or an operator such as >exit
or >back
).
Fig. 7 Getting there.
At this point, our state machine is functional: it moves from Hello to World after 1.5 seconds, and then exits after 1 more second. However, it still doesn’t do anything, because we haven’t defined any output actions yet. Let’s fix that by adding actions to each state. Actions define what happens when a state is active, such as turning on an output channel:
fsm.states['Hello'].actions = {'BNC1': 1}
fsm.states['World'].actions = {'BNC2': 1}
And with that, our Hello, World! example is complete:
Fig. 8 Our finite-state machine is complete.
In the previous examples, we first added bare-bones states and then modified them
afterward (changing timers, adding transitions, assigning actions). This was done purely
for demonstration purposes, so you could see that states behave like regular Python
objects and can be manipulated at any time. In practice, however, you would usually
choose the more straightforward approach: specify everything a state needs (its timer,
transitions, and actions) directly in the call to
add_state()
:
from bpod_core.fsm import StateMachine
fsm = StateMachine()
fsm.add_state(name='Hello', timer=1.5, transitions={'Tup': 'World'}, actions={'BNC1': 1})
fsm.add_state(name='World', timer=1.0, transitions={'Tup': '>exit'}, actions={'BNC2': 1})
Using this simple concept you can create arbitrarily complex patterns and behavioral sequences. See the section Example State Machines for more examples.
Take-Home Messages
- Adding states
Use
add_state()
with parametersname
,timer
,transitions
, andactions
.- Modifying states
You can create and modify states by directly manipulating the fields of
StateMachine
.- Entry state
The first state you add is always the entry point of the state machine.
- State Timers
Every state has a timer (default 0 s), which triggers the
Tup
events on expiry.- Transitions
States define transitions in a Python
dict
, mapping events to targets.- Actions
States can perform output actions (e.g., activate a port or channel) while active.
Validation
The class StateMachine
and related classes are Pydantic
models. All parameters are strictly typed and come with defined value ranges and
constraints. Field values are coerced to their respective types and validated both
at creation and assignment:
>>> fsm.add_state(name='MyState', timer=-1)
Traceback (most recent call last):
...
pydantic_core._pydantic_core.ValidationError: 1 validation error for StateMachine.add_state
timer
Input should be greater than or equal to 0 [type=greater_than_equal, input_value=-1, input_type=int]
For further information visit https://errors.pydantic.dev/2.11/v/greater_than_equal
>>> fsm.add_state(name='MyState', timer=1)
>>> fsm.states['MyState'].actions = 42
Traceback (most recent call last):
...
pydantic_core._pydantic_core.ValidationError: 1 validation error for State
actions
Input should be a valid dictionary [type=dict_type, input_value=42, input_type=int]
For further information visit https://errors.pydantic.dev/2.11/v/dict_type
This validation mechanism helps catch errors early in the design phase of an experiment. More detailed validation is performed at runtime, when the specific constraints of the hardware are known:
ValueError
is raised when attempting to run a state machine that exceeds the hardware’s capabilities.>>> fsm.set_global_timer(index=20, duration=5) # this validates OK
>>> bpod = Bpod()
>>> bpod.send_state_machine(fsm)
Traceback (most recent call last):
...
ValueError: Too many global timers in state machine - hardware supports up to 16 global timers
Import and Export
There are several convenient methods to serialize and visualize state machines:
to_json()
,to_yaml()
andto_dict()
return in-memory representations as a JSON string, YAML string and Python dict, respectively.to_digraph()
returns a GraphvizDigraph
instance which can be used to render the state diagram, for instance in a Jupyter notebook.to_file()
, depending on the file extension, writes either:.json
: a JSON serialization of theStateMachine
,.yaml
,.yml
: a YAML serialization of theStateMachine
,.svg
,.png
,.pdf
: a rendered state diagram via Graphviz.
from_json()
,from_dict()
, andfrom_file()
create a StateMachine from serialized data.
from bpod_core.fsm import StateMachine
# create a state machine and serialize it as a JSON string
fsm1 = StateMachine()
fsm1.add_state(name='Pi', timer=3.1415)
json_string = fsm1.to_json()
# create a second, identical state machine from the JSON string
fsm2 = StateMachine.from_json(json_string)
assert fsm2 == fsm1
from bpod_core.fsm import StateMachine
fsm = StateMachine.from_file('state_machine.json')
fsm.to_file('state_machine.png')
Note
Rendering diagrams depends on the Graphviz system libraries. See the Graphviz documentation for installation instructions.
Example State Machines
The following examples illustrate usage and features of the StateMachine
class.