diff --git a/.gitignore b/.gitignore index 65de09cc..24aa61a4 100644 --- a/.gitignore +++ b/.gitignore @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 409d747b..0769a664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/bpod_core/bpod.py b/bpod_core/bpod.py index fc6e539b..7bf5251b 100644 --- a/bpod_core/bpod.py +++ b/bpod_core/bpod.py @@ -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. @@ -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() @@ -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 @@ -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 @@ -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: @@ -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 @@ -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 @@ -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' None: """ - Configure a global timer with the specified parameters. + Configure a global counter with the specified parameters. Parameters ---------- diff --git a/docs/source/conf.py b/docs/source/conf.py index 4be9786f..7684a527 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -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) @@ -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.', '', diff --git a/examples/ipython_notebooks/graph_state_machine.ipynb b/examples/ipython_notebooks/graph_state_machine.ipynb new file mode 100644 index 00000000..180d71fb --- /dev/null +++ b/examples/ipython_notebooks/graph_state_machine.ipynb @@ -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 +} diff --git a/examples/minimal_examples/run_global_timer.py b/examples/minimal_examples/run_global_timer.py new file mode 100644 index 00000000..b3e6fc1b --- /dev/null +++ b/examples/minimal_examples/run_global_timer.py @@ -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() diff --git a/examples/back_operator.py b/examples/state_machines/back_operator.py similarity index 100% rename from examples/back_operator.py rename to examples/state_machines/back_operator.py diff --git a/examples/bnc_triggered_state_change.py b/examples/state_machines/bnc_triggered_state_change.py similarity index 100% rename from examples/bnc_triggered_state_change.py rename to examples/state_machines/bnc_triggered_state_change.py diff --git a/examples/conditions.py b/examples/state_machines/conditions.py similarity index 100% rename from examples/conditions.py rename to examples/state_machines/conditions.py diff --git a/examples/global_counters.py b/examples/state_machines/global_counters.py similarity index 100% rename from examples/global_counters.py rename to examples/state_machines/global_counters.py diff --git a/examples/state_machines/global_timers.py b/examples/state_machines/global_timers.py new file mode 100644 index 00000000..21ab25c7 --- /dev/null +++ b/examples/state_machines/global_timers.py @@ -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}, +) diff --git a/examples/light_chasing.py b/examples/state_machines/light_chasing.py similarity index 100% rename from examples/light_chasing.py rename to examples/state_machines/light_chasing.py diff --git a/examples/two_choice.py b/examples/state_machines/two_choice.py similarity index 100% rename from examples/two_choice.py rename to examples/state_machines/two_choice.py diff --git a/pyproject.toml b/pyproject.toml index f674a762..a2944e37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,7 @@ source-include = [ "tests/*.py", "schema/*.json", "docs/source/**/*", - "examples/*" + "examples/**/*" ] source-exclude = [ "docs/source/api",