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.

Listing 6 Using a context manager to connect to a Bpod.
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.

Listing 7 Defining and running a state machine.
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.

Listing 8 Running several trials of the same state machine in immediate succession.
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.

Listing 9 Generating state machines on the fly.
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:

Listing 10 A Polars DataFrame as returned by get_data()
>>> 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, null for 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:

Listing 11 Transforming absolute timestamps into relative timestamps.
>>> 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:

Listing 12 Filtering by events is straightforward.
>>> 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()
../_images/index-2.svg

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.