Using the Bpod Class
The Bpod class is bpod-core’s entry point for communicating
with a Bpod device. It opens a serial connection, sends compiled state machines to
the hardware, controls execution, and returns trial data.
Connecting to a Device
Bpod can connect to a device by port, serial_number,
or — when both are omitted — by auto-discovering the first available Bpod on the system.
In most use cases it’s best to use the class as a context manager: it guarantees the
serial connection is closed and any running trial is allowed to finish on exit.
from bpod_core.bpod import Bpod
with Bpod('/dev/ttyACM0') as bpod:
pass # do things
Tip
Serial port names such as /dev/ttyACM0 are assigned by the OS and may change
across reboots; the serial number, however, is a stable hardware identifier.
If you do not know your device’s serial number yet, connect once (auto-discover or
by port) and read serial_number off the object.
Then use it in all subsequent scripts for a stable, reboot-proof connection:
>>> with Bpod() as bpod:
... bpod.serial_number
'14260000'
>>> with Bpod(serial_number='14260000') as bpod:
... pass # do things ...
Running a State Machine
To run a state machine on a Bpod device, use the run() method
of your Bpod instance. It validates and compiles the state
machine, and finally enqueues it on the Bpod for immediate execution.
Calling get_data() after a state machine run returns the
collected data as a Polars DataFrame.
from bpod_core.bpod import Bpod
from bpod_core.fsm import StateMachine
# define a state machine
fsm = StateMachine()
fsm.add_state('Wait', transitions={'Port1_High': 'LightPort1', 'Port2_High': 'LightPort2'})
fsm.add_state('LightPort1', timer=1, transitions={'Tup': '>exit'}, actions={'PWM1': 255})
fsm.add_state('LightPort2', timer=1, transitions={'Tup': '>exit'}, actions={'PWM2': 255})
# run the state machine
with Bpod() as bpod:
bpod.run(fsm)
# collect the data
data = bpod.get_data()
Note
In the example above, get_data() is called outside the
context manager. The connection is already closed at that point, but the data queue
persists on the Bpod object and is safe to drain after the
context exits.
See also
Refer to Finite-State Machines for the full reference of the
StateMachine object.
Running Several State Machines
You can run run() several times in quick succession.
Bpod will automatically enqueue the state machines for
execution on the Bpod hardware. Subsequent state machines will run with zero inter-trial
downtime, allowing for continuous acquisition. When returning the data with
get_data() the data from individual trials will automatically
be concatenated to a continuous Polars DataFrame.
with Bpod() as bpod:
for trial in range(100):
bpod.run(fsm)
data = bpod.get_data()
Similarly, you can define state machines on-the-fly within the loop. The
run() method is non-blocking – as long as the individual
state machines take longer to execute than the time it takes to prepare and upload their
successors, they will run in a continuous fashion.
from random import random, randint
with Bpod() as bpod:
for trial in range(100):
fsm = StateMachine()
d = random() / 10 # random duration between 0 and 100 ms
i = randint(0, 255) # random PWM value between 0 and 255
fsm.add_state('s1', timer=d, transitions={'Tup': 's2'}, actions={'PWM1': i})
fsm.add_state('s2', timer=0.1, transitions={'Tup': '>exit'})
bpod.run(fsm)
data = bpod.get_data()
Data Format
Data returned by get_data() is organized in tabular form as a
Polars DataFrame. For the state machine in Listing 9 you
may get something like this:
>>> data
shape: (1_100, 8)
┌────────────────────────────┬───────┬──────────────────┬───────┬─────────────────┬───────┬─────────┬───────┐
│ time ┆ trial ┆ state machine ┆ state ┆ type ┆ event ┆ channel ┆ value │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ datetime[μs] ┆ u16 ┆ cat ┆ cat ┆ enum ┆ cat ┆ cat ┆ u8 │
╞════════════════════════════╪═══════╪══════════════════╪═══════╪═════════════════╪═══════╪═════════╪═══════╡
│ 2026-04-16 20:29:12.948426 ┆ 0 ┆ d1af27e5c2b13891 ┆ null ┆ TrialStart ┆ null ┆ null ┆ null │
│ 2026-04-16 20:29:12.948426 ┆ 0 ┆ d1af27e5c2b13891 ┆ s1 ┆ StateStart ┆ null ┆ null ┆ null │
│ 2026-04-16 20:29:12.948426 ┆ 0 ┆ d1af27e5c2b13891 ┆ s1 ┆ OutputAction ┆ null ┆ PWM1 ┆ 235 │
│ 2026-04-16 20:29:13.022426 ┆ 0 ┆ d1af27e5c2b13891 ┆ s1 ┆ InputEvent ┆ Tup ┆ null ┆ null │
│ 2026-04-16 20:29:13.022426 ┆ 0 ┆ d1af27e5c2b13891 ┆ s1 ┆ StateEnd ┆ null ┆ null ┆ null │
│ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … │
│ 2026-04-16 20:29:19.121326 ┆ 99 ┆ 1719d07df94acabf ┆ s2 ┆ OutputAction ┆ null ┆ PWM1 ┆ 0 │
│ 2026-04-16 20:29:19.131326 ┆ 99 ┆ 1719d07df94acabf ┆ s2 ┆ InputEvent ┆ Tup ┆ null ┆ null │
│ 2026-04-16 20:29:19.131326 ┆ 99 ┆ 1719d07df94acabf ┆ s2 ┆ StateEnd ┆ null ┆ null ┆ null │
│ 2026-04-16 20:29:19.131426 ┆ 99 ┆ 1719d07df94acabf ┆ null ┆ TrialEnd ┆ null ┆ null ┆ null │
│ 2026-04-16 20:29:19.131328 ┆ 99 ┆ 1719d07df94acabf ┆ null ┆ TrialEndControl ┆ null ┆ null ┆ null │
└────────────────────────────┴───────┴──────────────────┴───────┴─────────────────┴───────┴─────────┴───────┘
The data is organized into one row per event and the following columns:
time– absolute Bpod timestamp (Datetime)trial– zero-based trial index (UInt16)state machine– hash of the state machine (Categorical)state- state name (Categorical)type– event type (Enum)event– input event name,nullfor non-input rows (Categorical)channel– channel name (Categorical)value– channel value (UInt8)
Absolute vs. Relative Timestamps
The data is stored using absolute timestamps rather than relative ones. This avoids
ambiguity when combining data across trials, state machines, or external data sources,
and preserves the original temporal ordering without requiring additional context.
Relative timestamps can always be derived on demand by subtracting the first timestamp
from the time column. Notice how the inherent unit of the time column is
preserved during this operation, with the resulting column automatically taking on the
Duration type:
>>> data.with_columns(pl.col("time") - pl.col("time").first())
shape: (1_100, 8)
┌──────────────┬───────┬──────────────────┬───────┬─────────────────┬───────┬─────────┬───────┐
│ time ┆ trial ┆ state machine ┆ state ┆ type ┆ event ┆ channel ┆ value │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ duration[μs] ┆ u16 ┆ cat ┆ cat ┆ enum ┆ cat ┆ cat ┆ u8 │
╞══════════════╪═══════╪══════════════════╪═══════╪═════════════════╪═══════╪═════════╪═══════╡
│ 0µs ┆ 0 ┆ d1af27e5c2b13891 ┆ null ┆ TrialStart ┆ null ┆ null ┆ null │
│ 0µs ┆ 0 ┆ d1af27e5c2b13891 ┆ s1 ┆ StateStart ┆ null ┆ null ┆ null │
│ 0µs ┆ 0 ┆ d1af27e5c2b13891 ┆ s1 ┆ OutputAction ┆ null ┆ PWM1 ┆ 235 │
│ 74ms ┆ 0 ┆ d1af27e5c2b13891 ┆ s1 ┆ InputEvent ┆ Tup ┆ null ┆ null │
│ 74ms ┆ 0 ┆ d1af27e5c2b13891 ┆ s1 ┆ StateEnd ┆ null ┆ null ┆ null │
│ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … │
│ 6s 172900µs ┆ 99 ┆ 1719d07df94acabf ┆ s2 ┆ OutputAction ┆ null ┆ PWM1 ┆ 0 │
│ 6s 182900µs ┆ 99 ┆ 1719d07df94acabf ┆ s2 ┆ InputEvent ┆ Tup ┆ null ┆ null │
│ 6s 182900µs ┆ 99 ┆ 1719d07df94acabf ┆ s2 ┆ StateEnd ┆ null ┆ null ┆ null │
│ 6s 183ms ┆ 99 ┆ 1719d07df94acabf ┆ null ┆ TrialEnd ┆ null ┆ null ┆ null │
│ 6s 182902µs ┆ 99 ┆ 1719d07df94acabf ┆ null ┆ TrialEndControl ┆ null ┆ null ┆ null │
└──────────────┴───────┴──────────────────┴───────┴─────────────────┴───────┴─────────┴───────┘
Filtering
The different columns are designed to facilitate filtering the data. If, for instance
, you wanted to look at all events that affect the output channel PWM1, you could
filter the table like so:
>>> import polars as pl
>>> data.filter(pl.col("channel") == "PWM1")
shape: (200, 8)
┌────────────────────────────┬───────┬──────────────────┬───────┬──────────────┬───────┬─────────┬───────┐
│ time ┆ trial ┆ state machine ┆ state ┆ type ┆ event ┆ channel ┆ value │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ datetime[μs] ┆ u16 ┆ cat ┆ cat ┆ enum ┆ cat ┆ cat ┆ u8 │
╞════════════════════════════╪═══════╪══════════════════╪═══════╪══════════════╪═══════╪═════════╪═══════╡
│ 2026-04-16 20:29:12.948426 ┆ 0 ┆ d1af27e5c2b13891 ┆ s1 ┆ OutputAction ┆ null ┆ PWM1 ┆ 235 │
│ 2026-04-16 20:29:13.022426 ┆ 0 ┆ d1af27e5c2b13891 ┆ s2 ┆ OutputAction ┆ null ┆ PWM1 ┆ 0 │
│ 2026-04-16 20:29:13.032526 ┆ 1 ┆ 007c1ac779aa4864 ┆ s1 ┆ OutputAction ┆ null ┆ PWM1 ┆ 154 │
│ 2026-04-16 20:29:13.074626 ┆ 1 ┆ 007c1ac779aa4864 ┆ s2 ┆ OutputAction ┆ null ┆ PWM1 ┆ 0 │
│ 2026-04-16 20:29:13.084726 ┆ 2 ┆ 4c848e03a2b511e4 ┆ s1 ┆ OutputAction ┆ null ┆ PWM1 ┆ 214 │
│ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … │
│ 2026-04-16 20:29:18.998726 ┆ 97 ┆ 1257c55134308481 ┆ s2 ┆ OutputAction ┆ null ┆ PWM1 ┆ 0 │
│ 2026-04-16 20:29:19.008826 ┆ 98 ┆ 7d51489ce6f844f8 ┆ s1 ┆ OutputAction ┆ null ┆ PWM1 ┆ 27 │
│ 2026-04-16 20:29:19.057726 ┆ 98 ┆ 7d51489ce6f844f8 ┆ s2 ┆ OutputAction ┆ null ┆ PWM1 ┆ 0 │
│ 2026-04-16 20:29:19.067826 ┆ 99 ┆ 1719d07df94acabf ┆ s1 ┆ OutputAction ┆ null ┆ PWM1 ┆ 11 │
│ 2026-04-16 20:29:19.121326 ┆ 99 ┆ 1719d07df94acabf ┆ s2 ┆ OutputAction ┆ null ┆ PWM1 ┆ 0 │
└────────────────────────────┴───────┴──────────────────┴───────┴──────────────┴───────┴─────────┴───────┘
Plotting
Using filtering, it becomes relatively straightforward to extract data for plotting.
We use data returned by the state machines in Listing 9 to produce a
stairstep graph of the PWM1 output channel over relative time:
import matplotlib.pyplot as plt
import polars as pl
# filter data to values of the 'PWM1' channel across the first 10 trials
filtered = data.filter(
(pl.col("channel") == "PWM1") &
(pl.col("trial") <= 10)
)
# extract two columns, relative 'time' and 'value'
x = filtered['time'] - filtered['time'].first()
y = filtered['value']
# matplotlib doesn't play nice with timedelta values, so we convert them to float
x = x.dt.total_seconds(fractional=True)
# plot
plt.step(x, y, where='post')
plt.xlabel('Time (s)')
plt.ylabel('PWM Value')
plt.show()
Fig. 9 Visualising values of the PWM1 channel across 10 trials.
Storing Data
Since most of the columns consist of Categorical data,
data can be very efficiently written to disk as a Parquet
file using write_parquet():
data.write_parquet('data.pqt')
If you prefer Comma-Separated Values (CSV)—for example, to inspect the data in a
spreadsheet editor such as Excel—use the write_csv() method
instead:
data.write_csv('data.csv')
Note that CSV files are typically about an order of magnitude larger than their Parquet equivalents.
Pandas
If you prefer Pandas over Polars, you can easily convert
the data to a Pandas DataFrame using the built-in
to_pandas() method:
pandas_data = data.to_pandas()
More on Polars
The examples above only cover a small subset of what is possible with Polars. You can use the full expression system to filter, transform, aggregate, and analyze the data efficiently, even for large datasets.
For a comprehensive overview of available operations and patterns, refer to the Polars documentation.