Scripting

The scripting interface is one of the most extensible features PhysioLabXR offers. You can run your own Python scripts to create and deploy custom data processing pipelines. For example, you can write script to

  • Stream data from a custom hardware device

  • Implement a custom algorithm (e.g., a digital signal processing algorithm)

  • Run a machine learning model with real-time data streams

  • Communicate with external applications such as a cloud-computing platform through Python APIs

Get started with a simple example

In this example, we will write a script to process a simulated EEG stream named Dummy-8Chan: a randomly generated stream.

Create Stream

We will create a dummy stream to record. Create a new python file, put in the following snippet.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
"""Example program to demonstrate how to send a multi-channel time series to
LSL."""
import random
import sys
import getopt
import string
import numpy as np
import time
from random import random as rand

from pylsl import StreamInfo, StreamOutlet, local_clock


def main(argv):
    srate = 128
    name = 'Dummy-8Chan'
    print('Stream name is ' + name)
    type = 'EEG'
    n_channels = 8
    help_string = 'SendData.py -s <sampling_rate> -n <stream_name> -t <stream_type>'
    try:
        opts, args = getopt.getopt(argv, "hs:c:n:t:", longopts=["srate=", "channels=", "name=", "type"])
    except getopt.GetoptError:
        print(help_string)
        sys.exit(2)
    for opt, arg in opts:
        if opt == '-h':
            print(help_string)
            sys.exit()
        elif opt in ("-s", "--srate"):
            srate = float(arg)
        elif opt in ("-c", "--channels"):
            n_channels = int(arg)
        elif opt in ("-n", "--name"):
            name = arg
        elif opt in ("-t", "--type"):
            type = arg
    info = StreamInfo(name, type, n_channels, srate, 'float32', 'someuuid1234')

    # next make an outlet
    outlet = StreamOutlet(info)

    print("now sending data...")
    start_time = local_clock()
    sent_samples = 0
    while True:
        elapsed_time = local_clock() - start_time
        required_samples = int(srate * elapsed_time) - sent_samples
        for sample_ix in range(required_samples):
            # make a new random n_channels sample; this is converted into a
            # pylsl.vectorf (the data type that is expected by push_sample)
            mysample = [rand()*10 for _ in range(n_channels)]
            # now send it
            outlet.push_sample(mysample)
        sent_samples += required_samples
        # now send it and wait for a bit before trying again.
        time.sleep(0.01)


if __name__ == '__main__':
    main(sys.argv[1:])

Now run the script in your terminal with a command like python LSLExampleOutlet.py. The script will start an LSL stream with stream name ‘Dummy-8Chan’ and stream type ‘EEG’. The stream will generate random data with 8 channels and a sampling rate of 250 Hz. The stream will keep running until you stop the script.

Create Script

Now we will create a new python script to process out Dummy stream. Go to Scripting tab and click on the Add button. A script widget containing some informations of the script should show up. It should look like this:

_images/scriptingtab.png

Let’s take a closer look at what each component does:

  • Script Name: The path of your script file on the system. You don’t have to manually type in the path. To create a new script, click on the Create button beside and specify the path and the name of the script file. Then click save. Alternatively you can load existing scripts by clicking the Locate button.

  • Run Frequency (time per second): The maximum frequency the script will run at. The real running frequency won’t necessarily be exactly the same, it can be limited by the performance of your device.

  • Input Buffer Duration (sec): The size of the buffer for receiving input data from network interfaces, in seconds. The buffer is implemented as a FIFO (First In First Out) queue and this parameter will determine the maximum data you can access to in during a loop of your script.

  • Inputs Pane: Where you can add streams to your script as input. To add a stream as an input, you should first make sure it’s already added in the stream tab. Added input streams can be accessed in the script using self.inputs[my_stream_name]. Accessing the input gives a data matrix and the timestamp vector. The data matrix is of shape (f nominal ∗ T bufferduration, N channels), where f nominal is the nominal sampling rate, T bufferduration is the amount of data in seconds the buffer contains and N channels is the number of channels for this stream. The timestamp vector has f nominal ∗ T bufferduration items, each corresponding to a row of entries in the data matrix

  • Output Pane: Where you can create output streams. An output is defined by the output stream name and its number of channels. The output stream will be broadcasted on the network through LSL or ZMQ per the user’s choice, allowing it to be visualized the same way as an external data source in the stream tab. Meanwhile, a script may take output streams from another script as input, thus creating a cascading pipeline. You can assign the data to transmit in your script by assigning self.outputs[my_stream_name] in your script.

  • Parameters Pane: Where you can initiate variables that the user wishes to alter during runtime or variables that depend on the host computer (e.g., the path to a pre-trained ML model) when the researcher wants to avoid hardcoding these variables into the script. You can define parameters by their names, data type, and values. Then throughout the script, parameters are accessible using its name: self.params[my_parameter_name]. Changes to parameter values will be reflected immediately in real time while the scripting is running.

  • Console Log: The button to bring up console log

  • Run/Stop/Kill: The button to run the script, it will change into stop when the script starts to run. When stop is clicked, the button will changed to kill, so you can kill the process manually if it’s not closed automatically.

  • Simulate Input check box: Check the box if you want to use randomly generated data for all your input streams.

  • Loop (with overheads) per second: This is where you can monitor the performance of the script. The number shows how many seconds the script needed to run for one loop.

  • Average loop call runtime: The average runtime of a loop.

After you create the script file, it’s time to write the script. Let’s start with a simple script that just add a constant value to the input. Open the script file and you should see a template like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import numpy as np

from physiolabxr.scripting.RenaScript import RenaScript


class ScriptName(RenaScript):
    def __init__(self, *args, **kwargs):
        """
        Please do not edit this function
        """
        super().__init__(*args, **kwargs)

    # Start will be called once when the run button is hit.
    def init(self):
        pass

    # loop is called <Run Frequency> times per second
    def loop(self):
        print('Loop function is called')

    # cleanup is called when the stop button is hit
    def cleanup(self):
        print('Cleanup function is called')
  • init: init is called once when the start button in the widget is clicked. This function is intended for the user to instantiate objects to be used later in the primary duty-cycle function: loop. Users may add routines such as defining variables, loading pre-trained parameters for an ML model from the file system, and connecting with servers and cloud platforms.

  • loop: After init concludes, loop function will be called at run frequency is presumably the most often called function and uses the most processor time to continuously process input data and communicate with other processes via the outputs. At the same time, the user can tune the behavior by modifying the parameters.

  • cleanup: The cleanup is called when the stop button is clicked. Here, the user may free occupied memory, disconnect hardware ports and close any opened file system resources.

In this example, we will only be writing the loop function.

  1. we retrive the input data from the input buffer:

input_data = self.inputs.get_data('Dummy-8Chan')
  1. we add a constant value to the input data as a parameter that is tunable during runtime, and assign the output data to the output stream:

self.outputs['Dummy-8Chan-add-X'] = input_data - self.params['x']
  1. clean up the buffer by calling the clear_buffer API function:

self.inputs.clear_buffer()
  1. The clear_buffer function will not only clear the data (which is a value of a dict in the buffer), but also delete the stream key from the input buffer. So in every loop, we need to check if the stream is still in the buffer. If not, we will return until the stream is received again:

if 'Dummy-8Chan' in self.inputs.buffer.keys() :
    input_data = self.inputs.get_data('Dummy-8Chan')
    print(input_data)
    print('Loop function is called')
    self.outputs['Dummy-8Chan-add-X'] = input_data + self.params['x']
    self.inputs.clear_buffer()

The final script should look like this:

import numpy as np

from physiolabxr.scripting.RenaScript import RenaScript


class ScriptName(RenaScript):
    def __init__(self, *args, **kwargs):
        """
        Please do not edit this function
        """
        super().__init__(*args, **kwargs)

    # Start will be called once when the run button is hit.
    def init(self):
        print('Init function is called')
        pass

    # loop is called <Run Frequency> times per second
    def loop(self):

        if 'Dummy-8Chan' in self.inputs.buffer.keys() :
            input_data = self.inputs.get_data('Dummy-8Chan')
            print(input_data)
            print('Loop function is called')
            self.outputs['Dummy-8Chan-add-X'] = input_data + self.params['x']
            self.inputs.clear_buffer()

    # cleanup is called when the stop button is hit
    def cleanup(self):
        print('Cleanup function is called')

Run the script

After you finish writing the script, go back to the scripting tab, type Dummy-8Chan in the input stream box, and click the add button. Then type Dummy-8Chan-add-X in the output stream box, and click the add button. Now you should see the input stream and output stream in the input and output pane respectively. You can also add parameters in the parameters pane. In this example, we will add a parameter called x with data type float and default value 0. Now you can click the run button and see the output stream in the stream tab. Change the parameter value and you will see the output stream change accordingly.

More Examples

If you want to some more involved examples on building data processing script for experiments and BCI, check out these tutorials:

FixationDetection

Multi-modal Event-related Potential Classifier Communicate with other programs using script output