Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ target/
profile_default/
ipython_config.py

# LLM files
.repotomdrc

# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- convenience functions for reading and writing integers in `com.ExtendedSerial`.
- `misc.extend_packed` for extending a bytearray by multiple values of the same format.
- `misc.DocstringInheritanceMixin` for inheriting docstrings from base classes.
- more examples.

### Changed
- more verbose debug logging during state machine runs.
- replace unmaintained `appdirs` dependency with `platformdirs`.
- switch to zero-based indexing for global counters, timers, conditions and soft-codes.
- added file locking to `misc.SettingsDict`.
Expand Down
83 changes: 65 additions & 18 deletions bpod_core/bpod.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ def __init__( # noqa: PLR0913
softcode_handler: Callable,
state_transitions: NDArray[np.uint8],
use_back_op: bool,
event_names: list[str],
) -> None:
"""
Initialize the FSMThread.
Expand Down Expand Up @@ -199,6 +200,7 @@ def __init__( # noqa: PLR0913
self._softcode_handler = softcode_handler
self._state_transitions = state_transitions
self._use_back_op = use_back_op
self._event_names = event_names

def stop(self) -> None:
self._stop_event.set()
Expand All @@ -216,26 +218,22 @@ def run(self) -> None:
target_exit = np.uint8(state_transitions.shape[0])
target_back = np.uint8(255)
use_back_op = self._use_back_op
event_names = self._event_names

# create buffers for repeated serial reads
opcode_buf = bytearray(2) # buffer for opcodes
event_data_buf = bytearray(259) # max 255 events + 4 bytes for n_cycles

# should we use debug logging?
debug = logger.isEnabledFor(logging.DEBUG)

# confirm the state machine
if self._confirm_fsm:
if serial.read(1) != b'\x01':
raise RuntimeError(f'State machine #{index} was not confirmed by Bpod')
if debug:
logger.debug('State machine #%d confirmed by Bpod', index)
logger.debug('State machine #%d confirmed by Bpod', index)

# read the start time of the state machine
t0 = serial.read_uint64()
if debug:
logger.debug('%d µs: Starting state machine #%d', t0, index)
logger.debug('%d µs: State %d', t0, current_state)
logger.debug('%d µs: Starting state machine #%d', t0, index)
logger.debug('%d µs: State %d', t0, current_state)
# TODO: handle start of state machine
# TODO: handle start of state

Expand All @@ -257,8 +255,10 @@ def run(self) -> None:
# handle each event
events = event_data_view[:param]
for event in events:
if debug:
logger.debug('%d µs: Event %d', micros, event)
if event != 255:
logger.debug(
'%d µs: Event %d - %s)', micros, event, event_names[event]
)
# TODO: handle event

# handle state transitions / exit event
Expand All @@ -278,14 +278,12 @@ def run(self) -> None:
previous_state = current_state
current_state = target_state
# TODO: handle start of state
if debug:
logger.debug('%d µs: State %d', micros, current_state)
logger.debug('%d µs: State %d', micros, current_state)
break # only handle the first state transition

elif opcode == 2: # handle softcodes
param -= 1
if debug:
logger.debug('Softcode %d', param)
logger.debug('Softcode %d', param)
softcode_handler(param)

else:
Expand All @@ -294,10 +292,9 @@ def run(self) -> None:
# exit state machine
# read 12 bytes: cycles (uInt32) and micros (uInt64)
cycles, micros = self._struct_exit.unpack(serial.read(12))
if debug:
logger.debug(
'%d µs: Ending state machine #%d (%d cycles)', micros, index, cycles
)
logger.debug(
'%d µs: Ending state machine #%d (%d cycles)', micros, index, cycles
)
# TODO: handle end of state machine


Expand Down Expand Up @@ -1301,6 +1298,7 @@ def _run_state_machine(self, *, blocking: bool) -> None:
self._softcode_handler,
self._state_transitions,
self._use_back_op,
self.event_names,
)
self._fsm_thread.start()
self._waiting_for_confirmation = False
Expand Down Expand Up @@ -1554,6 +1552,55 @@ def relay(self, state: bool) -> None:
"""The current state of the serial relay."""
self.set_relay(state)

@validate_call
def load_serial_message(
self,
message_id: int,
message_bytes: bytes,
) -> bool:
"""
Load a serial message targeting the module.

Serial messages are byte sequences targeting a specific module that can be
triggered as output actions during a state machine run. Each message is
identified by a ``message_id``.

Parameters
----------
message_id : int
Identifier for the message, in the range ``[0, 254]``.
message_bytes : bytes
The message payload (1 to 3 bytes).

Returns
-------
bool
:obj:`True` if the Bpod acknowledged the message, :obj:`False` otherwise.

Raises
------
ValidationError
If the provided parameters cannot be validated or coerced to the expected
type.
ValueError
If ```message_id``, or ``message_bytes`` length is out of range.
"""
if not (0 <= message_id <= 254):
raise ValueError('Message ID must be between 0 and 254')
if not (1 <= (message_length := len(message_bytes)) <= 3):
raise ValueError('Message must be between 1 and 3 bytes long')

self._bpod.serial0.write_struct(
f'<c4B{message_length}s',
b'L',
self.index,
1, # number of messages loaded - always 1 for now
message_id,
message_length,
message_bytes,
)
return self._bpod.serial0.verify()


class RemoteBpod(AbstractBpod):
"""Class representing a Bpod connected via zeroMQ."""
Expand Down
2 changes: 1 addition & 1 deletion bpod_core/fsm.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ def set_global_counter(
threshold: GlobalCounterThreshold,
) -> None:
"""
Configure a global timer with the specified parameters.
Configure a global counter with the specified parameters.

Parameters
----------
Expand Down
4 changes: 2 additions & 2 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def generate_fsm_examples(app):
return

# Create docs/source/state_machines/examples/ with one page per example
examples_source_path = project_root / 'examples'
examples_source_path = project_root / 'examples' / 'state_machines'
examples_target_path = docs_source_path / 'state_machines' / 'examples'
examples_target_path.mkdir(parents=True, exist_ok=True)
example_files = sorted(examples_source_path.glob('*.py'), key=lambda f: f.name)
Expand Down Expand Up @@ -63,7 +63,7 @@ def generate_fsm_examples(app):
'',
' .. tab-item:: Python',
'',
f' .. literalinclude:: ../../../../examples/{fn.name}',
f' .. literalinclude:: ../../../../examples/state_machines/{fn.name}',
' :language: python',
' :start-at: from bpod_core.',
'',
Expand Down
99 changes: 99 additions & 0 deletions examples/ipython_notebooks/graph_state_machine.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
{
"cells": [
{
"cell_type": "code",
"id": "e8a973cd",
"metadata": {},
"source": [
"from bpod_core.fsm import StateMachine"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"id": "09331e75",
"metadata": {},
"source": [
"### Define a state machine"
]
},
{
"cell_type": "code",
"id": "1dfc3950",
"metadata": {},
"source": [
"fsm = StateMachine()\n",
"\n",
"fsm.set_global_timer(\n",
" index=0,\n",
" duration=5\n",
")\n",
"\n",
"fsm.add_state(\n",
" name='StartGlobalTimer',\n",
" timer=0.25,\n",
" transitions={'Tup': 'Port1Light'},\n",
" actions={'GlobalTimerTrig': 0},\n",
")\n",
"fsm.add_state(\n",
" name='Port1Light',\n",
" timer=0.25,\n",
" transitions={\n",
" 'Tup': 'Port3Light',\n",
" 'GlobalTimer0_End': '>exit',\n",
" },\n",
" actions={'PWM1': 255},\n",
")\n",
"fsm.add_state(\n",
" name='Port3Light',\n",
" timer=0.25,\n",
" transitions={\n",
" 'Tup': 'Port1Light',\n",
" 'GlobalTimer0_End': '>exit',\n",
" },\n",
" actions={'PWM3': 255},\n",
")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"id": "a32cfda8",
"metadata": {},
"source": [
"### Display the statemechine for easy verification"
]
},
{
"cell_type": "code",
"id": "2c77e81e",
"metadata": {},
"source": "fsm.to_digraph()",
"outputs": [],
"execution_count": null
}
],
"metadata": {
"kernelspec": {
"display_name": "bpod-core",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.7"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
49 changes: 49 additions & 0 deletions examples/minimal_examples/run_global_timer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Global Timer Example.

This example demonstrates how to define a state machine with a global timer and run it
on a Bpod with debug logging enabled. The state machine alternates LEDs of Port 1 and
Port 3 every 250 ms until a 5-second global timer expires, then exits the state machine.
"""

import logging

from bpod_core.bpod import Bpod
from bpod_core.fsm import StateMachine

# configure debug logging
logging.basicConfig(level=logging.DEBUG)

# create a new StateMachine instance and configure a 5-second global timer
fsm = StateMachine()
fsm.set_global_timer(index=0, duration=5)

# define the state machine's states
fsm.add_state(
name='StartGlobalTimer',
timer=0.25,
transitions={'Tup': 'Port1Light'},
actions={'GlobalTimerTrig': 0},
)
fsm.add_state(
name='Port1Light',
timer=0.25,
transitions={
'Tup': 'Port3Light',
'GlobalTimer0_End': '>exit',
},
actions={'PWM1': 255},
)
fsm.add_state(
name='Port3Light',
timer=0.25,
transitions={
'Tup': 'Port1Light',
'GlobalTimer0_End': '>exit',
},
actions={'PWM3': 255},
)

# connect to the Bpod, send the state machine, and run it
with Bpod() as bpod:
bpod.send_state_machine(fsm)
bpod.run_state_machine()
File renamed without changes.
File renamed without changes.
File renamed without changes.
38 changes: 38 additions & 0 deletions examples/state_machines/global_timers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Global Timers.

A global timer ends an infinite loop when 5 seconds have passed.
"""

from bpod_core.fsm import StateMachine

fsm = StateMachine()

fsm.set_global_timer(
index=0,
duration=5,
)

fsm.add_state(
name='StartGlobalTimer',
timer=0.25,
transitions={'Tup': 'Port1Light'},
actions={'GlobalTimerTrig': 0},
)
fsm.add_state(
name='Port1Light',
timer=0.25,
transitions={
'Tup': 'Port3Light',
'GlobalTimer0_End': '>exit',
},
actions={'PWM1': 255},
)
fsm.add_state(
name='Port3Light',
timer=0.25,
transitions={
'Tup': 'Port1Light',
'GlobalTimer0_End': '>exit',
},
actions={'PWM3': 255},
)
File renamed without changes.
File renamed without changes.
Loading