Developing plugins¶
What is a PyBpod GUI plugin?¶
PyBpod relies on a generic GUI framework, called PyformsGenericEditor which offers a basic user interface and can be extended to provide specific functionality through the installation of plugins.
- You can use plugins for:
extending or overwriting PyBpod core concepts (e.g., experiments, subjects, boxes);
creating new visualization tools for PyBpod sessions (e.g., plots, message filters);
adding new windows, tools or any other UI-related functionality;
Each plugin will be associated with a specific element of PyformsGenericEditor (e.g., project tree node, menu option, workspace area, etc).
Session history plugin, an example¶
This plugin allows you to display session data in a table view and you can order events by column.
https://github.com/pybpod/pybpod-gui-plugin-session-history
Quick review on sessions¶
Each time you run a Bpod protocol on a subject, a new session is created. The GUI collects output from the PyBpod API and processes these events on a list (which we call session history). Besides being on memory, this history is automatically saved on a text file, so you never loose Bpod data.
If you navigate to your project on the filesystem, and locate the desired subject, you should find several files:
CSV and JSON are default outputs from the pybpod-api (for example, you can open CSV on excel and quickly produce some plots)
Plain text file is the output from the GUI
Let’s take a look at a plain text file which was output from running a protocol on the GUI.
print_statement, 2017-05-23T15:41:29.638353, Trial:
print_statement, 2017-05-23T15:41:29.654188, Waiting for poke. Reward:
event_occurrence, 2017-05-23T15:41:33.672094, 50, Port2In, 2017-05-23 15:41:33.672094
event_occurrence, 2017-05-23T15:41:33.771925, 88, Tup, 2017-05-23 15:41:33.771925
state_entry, 2017-05-23T15:41:41.324848, 3, WaitForResponse, 4.1312, 4.3405
state_entry, 2017-05-23T15:41:41.324861, 4, Punish, 4.3405, 11.6663
state_entry, 2017-05-23T15:41:41.324908, 5, Reward, nan, nan
state_change, 2017-05-23T15:41:41.324930, 1, Port2In, 4.0312
state_change, 2017-05-23T15:41:41.324939, 2, Tup, 4.1312
state_change, 2017-05-23T15:41:41.324947, 2, Tup, 11.6663
print_statement, 2017-05-23T15:41:42.317543, Current trial info: {'Bpod start timestamp': 0.011, 'States timestamps': {'WaitForPort2Poke': [(0, 4.0312)], 'FlashStimulus': [(4.0312, 4.1312)], 'WaitForResponse': [(4.1312, 4.3405)], 'Punish': [(4.3405, 11.6663)], 'Reward': [(nan, nan)]}, 'Events timestamps': {'Port2In': [4.0312], 'Tup': [4.1312, 11.6663], 'Port2Out': [4.3405], 'Port3In': [8.6663], 'Port3Out': [8.8762]}}
print_statement, 2017-05-23T15:41:42.322411, Trial:
print_statement, 2017-05-23T15:41:42.325805, Waiting for poke. Reward:
event_occurrence, 2017-05-23T15:41:48.035732, 48, Port1In, 2017-05-23 15:41:48.035732
event_occurrence, 2017-05-23T15:41:48.136440, 88, Tup, 2017-05-23 15:41:48.136440
state_entry, 2017-05-23T15:41:48.160769, 3, WaitForResponse, 3.2538, 3.4102
state_entry, 2017-05-23T15:41:48.160775, 4, Reward, 3.4102, 5.8133
state_entry, 2017-05-23T15:41:48.160781, 5, Punish, nan, nan
state_change, 2017-05-23T15:41:48.160791, 1, Port2In, 3.1538
state_change, 2017-05-23T15:41:48.160804, 3, Port2Out, 3.4102
state_change, 2017-05-23T15:41:48.160808, 4, Port1In, 5.7133
print_statement, 2017-05-23T15:41:49.142529, Current trial info: {'Bpod start timestamp': 12.689, 'States timestamps': {'WaitForPort2Poke': [(0, 3.1538)], 'FlashStimulus': [(3.1538, 3.2538)], 'WaitForResponse': [(3.2538, 3.4102)], 'Reward': [(3.4102, 5.8133)], 'Punish': [(nan, nan)]}, 'Events timestamps': {'Port2In': [3.1538], 'Tup': [3.2538, 5.8133], 'Port2Out': [3.4102], 'Port1In': [5.7133]}}
print_statement, 2017-05-23T15:41:49.147563, Trial:
print_statement, 2017-05-23T15:41:49.151724, Waiting for poke. Reward:
event_occurrence, 2017-05-23T15:41:52.731798, 50, Port2In, 2017-05-23 15:41:52.731798
event_occurrence, 2017-05-23T15:41:53.845332, 48, Port1In, 2017-05-23 15:41:53.845332
event_occurrence, 2017-05-23T15:41:53.946396, 88, Tup, 2017-05-23 15:41:53.946396
state_entry, 2017-05-23T15:41:53.974354, 1, WaitForPort2Poke, 0, 3.5869
state_entry, 2017-05-23T15:41:53.974475, 5, Punish, nan, nan
state_change, 2017-05-23T15:41:53.974495, 1, Port2In, 3.5869
state_change, 2017-05-23T15:41:53.974536, 3, Port2Out, 3.8881
state_change, 2017-05-23T15:41:53.974545, 4, Port1In, 4.7007
print_statement, 2017-05-23T15:41:54.955371, Current trial info: {'Bpod start timestamp': 19.513, 'States timestamps': {'WaitForPort2Poke': [(0, 3.5869)], 'FlashStimulus': [(3.5869, 3.6869)], 'WaitForResponse': [(3.6869, 3.8881)], 'Reward': [(3.8881, 4.8007)], 'Punish': [(nan, nan)]}, 'Events timestamps': {'Port2In': [3.5869], 'Tup': [3.6869, 4.8007], 'Port2Out': [3.8881], 'Port1In': [4.7007]}}
What is going on here? Each line is a new message, where the first column identifies the type of an event on the session history: it can be a bpod state change, state entry, a user print, etc. These events represent messages that were sent from the Bpod and processed by the GUI.
Parsing board messages¶
Currently, PyBpod GUI supports the following events from Bpod board:
Session History Event Type |
Occurs during trial run? |
Description |
---|---|---|
Event occurrence |
YES |
Any Bpod event during trial run |
State change |
NO |
Events detected by Bpod’s inputs can be set to trigger transitions between specific states. |
State entry |
NO |
State entered during the state matrix run |
Print statement |
YES |
User defined print messages on protocol |
All these classes represent board messages and inherit a generic class BoardMessage. For more information on how the GUI parses these messages, see Message factory.
Register plugin on the GUI¶
The first thing you need to do is to register your plugin. For that, edit your user settings. From the top menu, go to Options > Edit user settings.
Edit the GENERIC_EDITOR_PLUGINS_PATH
variable as this:
GENERIC_EDITOR_PLUGINS_LIST = [
'pybpodgui_plugin',
'pybpodgui_plugin_session_history',
]
- For the GUI to be able to detect the plugin source code you have 2 options:
Download the plugin folder you want and place it on the “plugins” folder you have just indicated before (useful when you run pybpod GUI as an executable)
Install the plugin with PIP (only applies if you are running the GUI from source code).
On this example, we will assume option #2 since we will be developing a plugin from the source code.
In that case, you may leave the GENERIC_EDITOR_PLUGINS_PATH = None
because the plugin will be already on the Python path.
But don’t forget! Every time you make changes to the plugin you have to install it with PIP again (unless your IDE does that for you).
Finally, restart the GUI. The Session History plugin is a type of plugin that will be connected to a session and extend its behavior. Thus, after installing this plugin, you will see a new option by right-clicking a session node in the project tree. But how this works?
Connecting the plugin with a session node¶
Every node on the project tree node has a window assigned to it. In order to plugins show up on a project tree node, we need to extend the corresponing node window behavior. For example:
an experiment node is connected to the
pybpodgui_api.models.experiment.experiment_treenode.ExperimentTreeNode
classa board node is connected to the
pybpodgui_api.models.board.board_treenode.BoardTreeNode
classa session node is connected to the
pybpodgui_api.models.session.session_treenode.SessionTreeNode
class
The PyformsGenericEditor enables that all these classes may be extended by looking for classes on plugins that have the same name and path.
On the Session History plugin, since we want to override session behavior we need to have the following structrure:
On the models.session.__init__.py module, you must define the class that will override the original SessionTreeNode class. If you inspect the __init__.py you will find this:
from pybpodgui_plugin_session_history.models.session.session_treenode import SessionTreeNode as Session
By using Python inheritance, PyformsGenericEditor discovers that SessionTreeNode will match the original class from the GUI.
On the session_treenode.py file on our plugin, one can now redefine the behavior of the desired methods. In this case, we are overriding the create_treenode method to add a new option when the user right-clicks the project tree node. We also override other methods to personalize details such as window title or double-clicking.
(...)
from pybpodgui_plugin_session_history.session_history import SessionHistory
(...)
class SessionTreeNode(object):
def create_treenode(self, tree):
"""
Extends create_treenode behavior by calling the parent and adding a new option
when user right-clicks the node.
See also: pybpodgui_api.models.session.session_treenode.SessionTreeNode.create_treenode
"""
node = super(SessionTreeNode, self).create_treenode(tree)
tree.add_popup_menu_option('History', self.open_session_history_plugin, item=self.node,
icon=QIcon(conf.SESSIONLOG_PLUGIN_ICON))
return node
def node_double_clicked_event(self):
super(SessionTreeNode, self).node_double_clicked_event()
self.open_session_history_plugin()
def open_session_history_plugin(self):
if not hasattr(self, 'session_history_plugin'):
self.session_history_plugin = SessionHistory(self)
self.session_history_plugin.show()
self.session_history_plugin.subwindow.resize(*conf.SESSIONLOG_PLUGIN_WINDOW_SIZE)
else:
self.session_history_plugin.show()
def remove(self):
if hasattr(self, 'session_history_plugin'): self.mainwindow.mdi_area -= self.session_history_plugin
super(SessionTreeNode, self).remove()
@property
def name(self):
return super(SessionTreeNode, self.__class__).name.fget(self)
@name.setter
def name(self, value):
super(SessionTreeNode, self.__class__).name.fset(self, value)
if hasattr(self, 'session_history_plugin'): self.session_history_plugin.title = value
This should be the final result:
Handling session history from the plugin¶
On the previous section, we defined a new action for the session node. We have done that by linking the “History” menu option to the method open_session_history_plugin. Inside this method we invoke a class from the session_history.py module.
The session_history.py is responsible for creating a new window that shows up in the GUI workspace. This window must inherit from BaseWidget in order to make use of the necessary PyForms controls.
Since the GUI holds session history on memory, a list of board messages, session plugins can easily access to this list and process the events as needed. In our window, we will define a ControlList to list all the session history events. We will then define a timer that fires periodically to check for new messages and update the list.
(...)
from pyforms.basewidget import BaseWidget
from pyforms.controls import ControlProgress
from pyforms.controls import ControlList
(...)
from pybpodgui_plugin.com.messaging import ErrorMessage
from pybpodgui_plugin.com.messaging import PrintStatement
from pybpodgui_plugin.com.messaging import StateChange
from pybpodgui_plugin.com.messaging import StateEntry
from pybpodgui_plugin.com.messaging import EventOccurrence
(...)
class SessionHistory(BaseWidget):
""" Plugin main window """
def __init__(self, session):
(...)
self._log = ControlList()
self._formset = [
'_log',
]
self._history_index = 0
self._log.readonly = True
self._log.horizontal_headers = ['#', 'Type', 'Name', 'Channel Id', 'Start', 'End', 'PC timestamp']
self._log.set_sorting_enabled(True)
(...)
self._timer = QTimer()
self._timer.timeout.connect(self.read_message_queue)
(...)
def read_message_queue(self, update_gui=False):
""" Update board queue and retrieve most recent messages """
messages_history = self.session.messages_history
recent_history = messages_history[self._history_index:]
if update_gui:
self._progress.show()
self._progress.value = 0
try:
for message in recent_history:
table_line = None
if issubclass(type(message), StateChange):
table_line = (self._history_index, message.MESSAGE_TYPE_ALIAS, message.event_name,
'-', message.board_timestamp, message.board_timestamp, str(message.pc_timestamp))
if issubclass(type(message), StateEntry):
table_line = (self._history_index, message.MESSAGE_TYPE_ALIAS, message.state_name,
'-', message.start_timestamp, message.end_timestamp, str(message.pc_timestamp))
if issubclass(type(message), EventOccurrence):
table_line = (self._history_index, message.MESSAGE_TYPE_ALIAS, message.event_name,
message.event_id, '-', '-', str(message.pc_timestamp))
if table_line:
self._log += table_line
QEventLoop()
if update_gui:
self._progress += 1
if self._progress.value >= 99: self._progress.value = 0
self._history_index += 1
except Exception as err:
if hasattr(self, '_timer'):
self._timer.stop()
logger.error(str(err), exc_info=True)
QMessageBox.critical(self, "Error",
"Unexpected error while loading session history. Pleas see log for more details.")
if update_gui:
self._progress.hide()
(...)
This should be the final result: