Using the Bpod Class
====================
The :class:`~bpod_core.bpod.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.
.. _Bpod: https://sanworks.github.io/Bpod_Wiki/
Connecting to a Device
----------------------
:class:`~bpod_core.bpod.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.
.. _context manager: https://docs.python.org/3/library/stdtypes.html#typecontextmanager
.. testsetup:: bpod-context-manager
import atexit
import types
from unittest.mock import patch
from types import SimpleNamespace
from bpod_core.bpod import Bpod
from unittest.mock import MagicMock
original_run = Bpod.run
original_validate = Bpod.validate_state_machine
def fake_init(self, *args, **kwargs):
self._disable_all_module_relays = lambda: None
self._hardware = SimpleNamespace(
max_states=256,
n_global_timers=16,
n_global_counters=16,
n_conditions=64,
cycle_frequency=1000,
)
self._bpod_finalizer = SimpleNamespace(detach=lambda: None)
self._serial_device_finalizer = SimpleNamespace(detach=lambda: None)
self._softcode_thread = MagicMock()
self._hardware_hash = b''
self._serial = None
self._serial_number = '14260000'
self.run = types.MethodType(original_run, self)
self.validate_state_machine = types.MethodType(original_validate, self)
patcher = patch.object(Bpod, "__init__", fake_init)
patcher.start()
atexit.register(patcher.stop)
.. testcode-code-block:: python3
:caption: Using a context manager to connect to a Bpod.
:group: bpod-context-manager
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 :attr:`~bpod_core.bpod.Bpod.serial_number` off the object.
Then use it in all subsequent scripts for a stable, reboot-proof connection:
.. doctest-code-block::
:group: bpod-context-manager
>>> 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 :meth:`~bpod_core.bpod.Bpod.run` method
of your :class:`~bpod_core.bpod.Bpod` instance. It validates and compiles the state
machine, and finally enqueues it on the Bpod for immediate execution.
Calling :meth:`~bpod_core.bpod.Bpod.get_data` after a state machine run returns the
collected data as a Polars :class:`~polars.DataFrame`.
.. testcode-code-block:: python3
:caption: Defining and running a state machine.
:group: bpod-context-manager
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, :meth:`~bpod_core.bpod.Bpod.get_data` is called *outside* the
context manager. The connection is already closed at that point, but the data queue
persists on the :class:`~bpod_core.bpod.Bpod` object and is safe to drain after the
context exits.
.. seealso::
Refer to :doc:`../state_machines/index` for the full reference of the
:class:`~bpod_core.fsm.StateMachine` object.
Running Several State Machines
------------------------------
You can run :meth:`~bpod_core.bpod.Bpod.run` several times in quick succession.
:class:`~bpod_core.bpod.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
:meth:`~bpod_core.bpod.Bpod.get_data` the data from individual trials will automatically
be concatenated to a continuous Polars :class:`~polars.DataFrame`.
.. testcode-code-block:: python3
:caption: Running several trials of the same state machine in immediate succession.
:group: bpod-context-manager
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
:meth:`~bpod_core.bpod.Bpod.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.
.. testcode-code-block:: python3
:name: on_the_fly_fsm
:caption: Generating state machines on the fly.
:group: bpod-context-manager
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
-----------
.. testsetup:: polars
import polars as pl
from pathlib import Path
from bpod_core.bpod.threads import _TRIAL_DATA_SCHEMA
pqt_file = Path(_DOCS_STATIC) / "example_dataframe.pqt"
data = pl.read_parquet(pqt_file)
assert dict(data.schema) == _TRIAL_DATA_SCHEMA
pl.Config.set_tbl_width_chars(200)
Data returned by :meth:`~bpod_core.bpod.Bpod.get_data` is organized in tabular form as a
Polars :class:`~polars.DataFrame`. For the state machine in :numref:`on_the_fly_fsm` you
may get something like this:
.. doctest-code-block::
:caption: A Polars DataFrame as returned by :meth:`~bpod_core.bpod.Bpod.get_data`
:group: polars
>>> 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 (:class:`~polars.datatypes.Datetime`)
- ``trial`` – zero-based trial index (:class:`~polars.datatypes.UInt16`)
- ``state machine`` – hash of the state machine (``Categorical``)
- ``state`` - state name (:class:`~polars.datatypes.Categorical`)
- ``type`` – event type (:class:`~polars.datatypes.Enum`)
- ``event`` – input event name, ``null`` for non-input rows (:class:`~polars.datatypes.Categorical`)
- ``channel`` – channel name (:class:`~polars.datatypes.Categorical`)
- ``value`` – channel value (:class:`~polars.datatypes.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
:class:`~polars.datatypes.Duration` type:
.. doctest-code-block::
:caption: Transforming absolute timestamps into relative timestamps.
:group: polars
>>> 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:
.. doctest-code-block::
:caption: Filtering by events is straightforward.
:group: polars
>>> 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 :numref:`on_the_fly_fsm` to produce a
stairstep graph of the ``PWM1`` output channel over relative time:
.. plot::
:context:
:include-source: false
import polars as pl
from pathlib import Path
from bpod_core.bpod.threads import _TRIAL_DATA_SCHEMA
pqt_file = Path(_DOCS_STATIC) / "example_dataframe.pqt"
data = pl.read_parquet(pqt_file)
.. plot::
:caption: Visualising values of the ``PWM1`` channel across 10 trials.
:context:
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()
Storing Data
^^^^^^^^^^^^
Since most of the columns consist of `Categorical data `_,
data can be very efficiently written to disk as a `Parquet `_
file using :meth:`~polars.DataFrame.write_parquet`:
.. code-block:: python
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 :meth:`~polars.DataFrame.write_csv` method
instead:
.. code-block:: python
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 :class:`~pandas.DataFrame` using the built-in
:meth:`~polars.DataFrame.to_pandas` method:
.. code-block:: python
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 `_.