Skip to content
Draft
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
16 changes: 14 additions & 2 deletions .schema/statemachine.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"type": "object"
},
"Condition": {
"additionalProperties": false,
"description": "A condition in the state machine.",
"properties": {
"channel": {
Expand All @@ -38,6 +39,7 @@
"type": "object"
},
"Conditions": {
"additionalProperties": false,
"description": "A collection of conditions.",
"patternProperties": {
"^\\d+$": {
Expand All @@ -48,6 +50,7 @@
"type": "object"
},
"GlobalCounter": {
"additionalProperties": false,
"description": "A global counter in the state machine.",
"properties": {
"event": {
Expand All @@ -72,6 +75,7 @@
"type": "object"
},
"GlobalCounters": {
"additionalProperties": false,
"description": "A collection of global counters.",
"patternProperties": {
"^\\d+$": {
Expand All @@ -82,6 +86,7 @@
"type": "object"
},
"GlobalTimer": {
"additionalProperties": false,
"description": "A global timer in the state machine.",
"properties": {
"duration": {
Expand All @@ -100,6 +105,9 @@
"channel": {
"anyOf": [
{
"description": "The channel affected by the global timer",
"minLength": 1,
"title": "Channel",
"type": "string"
},
{
Expand Down Expand Up @@ -133,15 +141,15 @@
},
"loop": {
"default": 0,
"description": "Whether the global timer is looping or not",
"description": "0 = off, 1 = loop until canceled or trial end, >1 = fixed number of iterations (max 255)",
"maximum": 255,
"minimum": 0,
"title": "Loop Mode",
"type": "integer"
},
"loop_interval": {
"default": 0.0,
"description": "The interval in seconds that the global timer is looping",
"description": "Delay in seconds between the end of a loop iteration and the start of the next",
"minimum": 0.0,
"title": "Loop Interval",
"type": "number"
Expand All @@ -161,6 +169,7 @@
"type": "object"
},
"GlobalTimers": {
"additionalProperties": false,
"description": "A collection of global timers.",
"patternProperties": {
"^\\d+$": {
Expand All @@ -171,6 +180,7 @@
"type": "object"
},
"State": {
"additionalProperties": false,
"description": "A state in the state machine.",
"properties": {
"timer": {
Expand Down Expand Up @@ -207,6 +217,7 @@
"type": "object"
},
"States": {
"additionalProperties": false,
"description": "A collection of states.",
"patternProperties": {
"^(?!>)(?!exit$)(?!back$).+$": {
Expand Down Expand Up @@ -254,6 +265,7 @@
},
"$id": "https://raw.githubusercontent.com/int-brain-lab/bpod-core/main/.schema/statemachine.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "Definition of a Bpod finite-state machine.",
"properties": {
"name": {
Expand Down
42 changes: 33 additions & 9 deletions bpod_core/fsm.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def _validate_operator(v: Any, h: ValidatorFunctionWrapHandler) -> 'Operator':

GlobalTimerChannel = Annotated[
str,
msgspec.Meta(
Field(
title='Channel',
description='The channel affected by the global timer',
min_length=1,
Expand Down Expand Up @@ -204,7 +204,10 @@ def _validate_operator(v: Any, h: ValidatorFunctionWrapHandler) -> 'Operator':
int,
Field(
title='Loop Mode',
description='Whether the global timer is looping or not',
description=(
'0 = off, 1 = loop until canceled or trial end, >1 = fixed number '
'of iterations (max 255)'
),
default=0,
ge=0,
le=255,
Expand All @@ -215,7 +218,10 @@ def _validate_operator(v: Any, h: ValidatorFunctionWrapHandler) -> 'Operator':
float,
Field(
title='Loop Interval',
description='The interval in seconds that the global timer is looping',
description=(
'Delay in seconds between the end of a loop iteration and the start of the '
'next'
),
default=0.0,
ge=0.0,
),
Expand Down Expand Up @@ -343,9 +349,11 @@ def __init__(
) -> None: ...


class State(BaseModel, validate_assignment=True, title='State'):
class State(BaseModel, title='State'):
"""A state in the state machine."""

model_config = ConfigDict(validate_assignment=True, extra='forbid')

timer: StateTimer = StateTimer()
transitions: Transitions = Transitions()
actions: Actions = Actions()
Expand All @@ -357,9 +365,11 @@ def __repr__(self) -> str:
return f'{self.__class__.__name__}({values})'


class GlobalTimer(BaseModel, validate_assignment=True, title='Global Timer'):
class GlobalTimer(BaseModel, title='Global Timer'):
"""A global timer in the state machine."""

model_config = ConfigDict(validate_assignment=True, extra='forbid')

duration: GlobalTimerDuration
onset_delay: GlobalTimerOnsetDelay = 0.0
channel: GlobalTimerChannel | None = None
Expand All @@ -376,9 +386,11 @@ def __repr__(self) -> str:
return f'{self.__class__.__name__}({values})'


class GlobalCounter(BaseModel, validate_assignment=True, title='Global Counter'):
class GlobalCounter(BaseModel, title='Global Counter'):
"""A global counter in the state machine."""

model_config = ConfigDict(validate_assignment=True, extra='forbid')

event: Event
threshold: GlobalCounterThreshold

Expand All @@ -388,9 +400,11 @@ def __repr__(self) -> str:
return f'{self.__class__.__name__}({values})'


class Condition(BaseModel, validate_assignment=True, title='Condition'):
class Condition(BaseModel, title='Condition'):
"""A condition in the state machine."""

model_config = ConfigDict(validate_assignment=True, extra='forbid')

channel: ConditionChannel
value: ConditionValue

Expand All @@ -403,6 +417,8 @@ def __repr__(self) -> str:
class States(ValidatedDict[StateName, State], title='States'):
"""A collection of states."""

model_config = ConfigDict(json_schema_extra={'additionalProperties': False})

@property
def transition_targets(self) -> set[str]:
"""A set of all transition targets."""
Expand All @@ -422,21 +438,29 @@ def transition_targets(self) -> set[str]:
class GlobalTimers(ValidatedDict[Index, GlobalTimer], title='Global Timers'):
"""A collection of global timers."""

model_config = ConfigDict(json_schema_extra={'additionalProperties': False})


class GlobalCounters(ValidatedDict[Index, GlobalCounter], title='Global Counters'):
"""A collection of global counters."""

model_config = ConfigDict(json_schema_extra={'additionalProperties': False})


class Conditions(ValidatedDict[Index, Condition], title='Conditions'):
"""A collection of conditions."""

model_config = ConfigDict(json_schema_extra={'additionalProperties': False})


class StateMachine(BaseModel, validate_assignment=True, title='State Machine'):
class StateMachine(BaseModel, title='State Machine'):
"""Definition of a Bpod finite-state machine."""

model_config = ConfigDict(
validate_assignment=True,
extra='forbid',
json_schema_extra={
'$id': 'https://github.com/int-brain-lab/bpod-core/blob/main/.schema/statemachine.json',
'$id': 'https://raw.githubusercontent.com/int-brain-lab/bpod-core/main/.schema/statemachine.json',
'$schema': 'https://json-schema.org/draft/2020-12/schema',
},
)
Expand Down
48 changes: 30 additions & 18 deletions docs/source/bpod/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,18 @@ current state machine takes to execute.
Retrieval of Partial Data
^^^^^^^^^^^^^^^^^^^^^^^^^

Finally, it is possible to retrieve a partial copy of a state machine's data while it's
still running. To do so, use the :meth:`~bpod_core.bpod.Bpod.peek_data` method. In the
following example, we use two state machines per trial. The first measures the duration
of an input event, while the second one returns an output action of identical duration.
:meth:`~bpod_core.bpod.Bpod.peek_data` blocks until one of the ``trigger_states`` has
been reached—in this case the ``pause`` state—and then returns the data collected up to
that point. This way, you can use the results from one state machine to prepare the next
without introducing idle time between state machine runs. The final call to
:meth:`~bpod_core.bpod.Bpod.get_data` will collect data across all trials.
:meth:`~bpod_core.bpod.Bpod.peek_data` retrieves data from a running state machine
without waiting for it to finish. It blocks until one of the specified
``trigger_states`` is reached, then returns a copy of all data collected up to that
point.

The example below uses two state machines per trial: the first measures the duration of
an event on ``Port0``; the second plays back an output of that same duration on
``PWM0``. After starting ``fsm1``, :meth:`~bpod_core.bpod.Bpod.peek_data` blocks until
the ``pause`` state is reached, extracts the timing, and uses it to configure ``fsm2``
before queuing it—with no idle time between the two runs. The final
:meth:`~bpod_core.bpod.Bpod.get_data` call collects data across all trials.


.. testcode-code-block:: python3
:name: peek_data_fsm
Expand All @@ -201,11 +204,15 @@ without introducing idle time between state machine runs. The final call to
from bpod_core.fsm import StateMachine
from bpod_core.bpod import Bpod

# construct first state machine
# construct first state machine (identical across all trials)
fsm1 = StateMachine()
fsm1.add_state('wait', transitions={'Port1_High': 'measure'}) # wait for 'Port1_High'
fsm1.add_state('measure', transitions={'Port1_Low': 'pause'}) # wait for 'Port1_Low'
fsm1.add_state('pause', timer=0.5, transitions={'Tup': '>exit'}) # pause, then exit
fsm1.add_state('wait', transitions={'Port0_High': 'measure'})
fsm1.add_state('measure', transitions={'Port0_Low': 'pause'})
fsm1.add_state('pause', timer=0.5, transitions={'Tup': '>exit'})

# construct second state machine (will be modified within each trial)
fsm2 = StateMachine()
fsm2.add_state('echo', transitions={'Tup': '>exit'}, actions={'PWM0': 255})

with Bpod() as bpod:
for i in range(10):
Expand All @@ -215,12 +222,17 @@ without introducing idle time between state machine runs. The final call to
# peek at data once 'pause' state has been reached
runtime_data = bpod.peek_data(trigger_states=['pause'])

# calculate duration of 'Port1_High'
duration = runtime_data.filter(pl.col("channel") == "Port1")['time'].diff().last()
# calculate duration of 'Port0_High'
d = runtime_data.filter(pl.col("channel") == "Port0")['time'].diff().last()

# construct and run second state machine on the fly
fsm2 = StateMachine()
fsm2.add_state('echo', timer=duration, transitions={'Tup': '>exit'}, actions={'PWM1': 255})
# set state timer and run second state machine
fsm2.states['echo'].timer = d
bpod.run(fsm2, trial_number=i)

data = bpod.get_data() # collect data across all state machine runs

.. note::

The trial number is normally incremented automatically with each call to
:meth:`~bpod_core.bpod.Bpod.run`. Here, two state machines share a single
trial, so ``trial_number`` is set explicitly to keep it aligned with the loop index.
8 changes: 4 additions & 4 deletions docs/source/state_machines/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,8 @@ turning on an output channel:
:group: hello_world
:filename: hello_world_04.svg

fsm.states['Hello'].actions = {'BNC1': 1}
fsm.states['World'].actions = {'BNC2': 1}
fsm.states['Hello'].actions = {'TTLOut0': 1}
fsm.states['World'].actions = {'TTLOut1': 1}

And with that, our `Hello, World!` example is complete:

Expand All @@ -260,8 +260,8 @@ transitions, and actions) directly in the call to
from bpod_core.fsm import StateMachine

fsm = StateMachine()
fsm.add_state(name='Hello', timer=1.5, transitions={'Tup': 'World'}, actions={'BNC1': 1})
fsm.add_state(name='World', timer=1.0, transitions={'Tup': '>exit'}, actions={'BNC2': 1})
fsm.add_state(name='Hello', timer=1.5, transitions={'Tup': 'World'}, actions={'TTLOut0': 1})
fsm.add_state(name='World', timer=1.0, transitions={'Tup': '>exit'}, actions={'TTLOut1': 1})

.. testcode:: hello_world
:hide:
Expand Down
11 changes: 8 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ version = '0.1.0a12'
description = "Python package for interfacing with the Bpod finite state machine"
authors = [{ name = "Florian Rau", email = "bimac@users.noreply.github.com" }]
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Development Status :: 3 - Alpha",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering",
"Typing :: Typed",
]
Expand All @@ -35,7 +39,8 @@ dependencies = [
"cachetools>=7.0.0",
"typing_extensions>=4.0",
]
license = { text = "MIT" }
license = "MIT"
license-files = ["LICENSE"]
readme = "README.md"
requires-python = ">=3.10"

Expand Down
Loading