diff --git a/.schema/statemachine.json b/.schema/statemachine.json index 9be4edd9..1798e523 100644 --- a/.schema/statemachine.json +++ b/.schema/statemachine.json @@ -16,6 +16,7 @@ "type": "object" }, "Condition": { + "additionalProperties": false, "description": "A condition in the state machine.", "properties": { "channel": { @@ -38,6 +39,7 @@ "type": "object" }, "Conditions": { + "additionalProperties": false, "description": "A collection of conditions.", "patternProperties": { "^\\d+$": { @@ -48,6 +50,7 @@ "type": "object" }, "GlobalCounter": { + "additionalProperties": false, "description": "A global counter in the state machine.", "properties": { "event": { @@ -72,6 +75,7 @@ "type": "object" }, "GlobalCounters": { + "additionalProperties": false, "description": "A collection of global counters.", "patternProperties": { "^\\d+$": { @@ -82,6 +86,7 @@ "type": "object" }, "GlobalTimer": { + "additionalProperties": false, "description": "A global timer in the state machine.", "properties": { "duration": { @@ -100,6 +105,9 @@ "channel": { "anyOf": [ { + "description": "The channel affected by the global timer", + "minLength": 1, + "title": "Channel", "type": "string" }, { @@ -133,7 +141,7 @@ }, "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", @@ -141,7 +149,7 @@ }, "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" @@ -161,6 +169,7 @@ "type": "object" }, "GlobalTimers": { + "additionalProperties": false, "description": "A collection of global timers.", "patternProperties": { "^\\d+$": { @@ -171,6 +180,7 @@ "type": "object" }, "State": { + "additionalProperties": false, "description": "A state in the state machine.", "properties": { "timer": { @@ -207,6 +217,7 @@ "type": "object" }, "States": { + "additionalProperties": false, "description": "A collection of states.", "patternProperties": { "^(?!>)(?!exit$)(?!back$).+$": { @@ -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": { diff --git a/bpod_core/fsm.py b/bpod_core/fsm.py index 3557eb11..5c380b16 100644 --- a/bpod_core/fsm.py +++ b/bpod_core/fsm.py @@ -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, @@ -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, @@ -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, ), @@ -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() @@ -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 @@ -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 @@ -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 @@ -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.""" @@ -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', }, ) diff --git a/docs/source/bpod/index.rst b/docs/source/bpod/index.rst index 7399f243..6aa950b3 100644 --- a/docs/source/bpod/index.rst +++ b/docs/source/bpod/index.rst @@ -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 @@ -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): @@ -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. diff --git a/docs/source/state_machines/index.rst b/docs/source/state_machines/index.rst index 8cad87b2..31ca0723 100644 --- a/docs/source/state_machines/index.rst +++ b/docs/source/state_machines/index.rst @@ -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: @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 2126eafe..476e3323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] @@ -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"