Tutorial#

API Module#

Introduction#

Welcome to the API tutorial for your device! The API gives you access to a seamless communication with your devices, enabling you to unleash the potential of the MaxWell Biosystems technology. In this comprehensive tutorial, we will walk you through the essential functions of the API, demonstrating how you can use it to execute your experiments.

Throughout the Python tutorial, we will focus on a specific experiment goal: simultaneous stimulation of up to 32 distinct electrodes on the chip using biphasic pulses. The example assumes the mapping of a neural pattern and its data collection. The tutorial will guide you through stimulating a set of electrodes and recording the data.

Basics#

It is important to understand how the communication between your script, the mxwserver and the devices works. The maxlab.send() function is responsible for sending objects that have been derived from maxlab.api_object.ApiObject to the mxwserver, where they are further processed. The information sent with maxlab.send() may not be directly, or in some cases, not at all transferred from the mxwserver to the device. This depends on the specific class you are using (more information can be found in the Python API section).

To be able to run the code snippets below, it is essential to ensure that the mxwserver is running and you have a valid Python3 installation.

Note

  • An aggregated version of all the code snippets can be found in the ~/MaxLab/share folder on your PC. The Python examples are in ~/MaxLab/share/python, and the C++ examples can be found in the maxlab_lib folder generated by unpacking ~/MaxLab/share/libmaxlab-*.zip.

Setup the Experiment#

Initialization#

Prior to starting any operations, we first import the Python modules as indicated below:

from typing import List

import random
import time

import maxlab as mx

Note

The maxlab module is what enables you to use the API.

Once the modules are correctly imported, we initialize the system by running:

mx.initialize()
mx.send(mx.Core().enable_stimulation_power(True))

The first command initializes the chip/well plate and loads a default configuration. The subsequent line activates all Stimulation Units, a step required only if stimulation is part of your experiment. Successful execution of this code will be indicated by the ‘Ok’ message, signifying that the system initialization was successful. We can now proceed to preparing the Electrode Selection.

Preparing the Electrode Selection#

We distinguish between two categories of electrodes: Recording Electrodes and Stimulation Electrodes. While all Stimulation Electrodes serve as Recording Electrodes, the reverse is not necessarily true. Therefore, we will refer to Recording Electrodes simply as electrodes and to the Stimulation Electrodes as stim_electrodes. To illustrate this, we will select 12 electrodes, which include 2 Stimulation Electrodes:

electrodes = [4885, 4666, 4886, 4022, 5327, 5328, 5106, 5326, 3138, 3140, 3580, 4887]
stim_electrodes = [3580, 4887]

Note

The maximum allowed number of Recording Electrodes is 1020, corresponding to the number of recording channels, while the maximum number of Stimulation Electrodes is 32, in line with the available Stimulation Units.

Once the Recording Electrodes and the indices of the Stimulation Electrodes are saved in their respective variables, we can proceed to configure the Electrode Array and establish the electrode routing. This procedure remains consistent regardless of the specific stimulation experiment you intend to conduct:

Listing 1 Array configuration and electrodes routing#
array = mx.Array("stimulation")
array.reset()
array.clear_selected_electrodes()
array.select_electrodes(electrodes)
array.select_stimulation_electrodes(stim_electrodes)
array.route()

The select_stimulation_electrodes() merely prioritizes the Stimulation Electrodes in the routing process. The electrodes are only connected to Stimulation Units after routing as described in the next step.

Selecting a large number of Stimulation Electrodes can potentially lead to routing issues when attempting to connect them to Stimulation Units. In such a scenario, a straightforward solution is to choose the electrode adjacent to the problematic one and repeat the procedure.

Alternatively, an existing electrode configuration file can be utilised. You can directly load the configuration file into the electrode array and route all electrodes using:

array = mx.Array("stimulation")
array.load_config("path/to/config/file.cfg")

While it is feasible to load an existing configuration for Recording Electrodes, you will need to manually specify the indices for the Stimulation Electrodes and connect them after routing, as they are not stored within the configuration file.

Note

.cfg files can be generated through Scope by selecting a custom electrode configuration and then clicking “Route”. The corresponding .cfg file can then be found in ~/configs/[date]/[time].cfg.

With the Electrode Array now configured, the next step, if you’re using MaxTwo, is to define from which well(s) you intend to record and stimulate. You can choose to work with one specific well or up to all six wells. For example, if you plan to conduct your experiments using the first two wells, you would execute the following command:

wells = [0, 1]
mx.activate(wells)

Note

While normal recording will work for as many wells as chosen in parallel, real-time data streaming (as discussed in Raw Data Stream) is only possible for one well at a time. If multiple wells are active, you can use mx.set_primary_well() to select from which well the real-time data should be streamed.

Stimulation Units Preparation#

As the Stimulation Electrodes are selected and the Electrode Array is now configured, the next step is to connect these electrodes to the Stimulation Units. This can be achieved using the following code:

Listing 2 Stimulation Units Preparation#
stimulation_units = []
for stimulation_electrode in stimulation_electrodes:
    array.connect_electrode_to_stimulation(stimulation_electrode)
    stim = array.query_stimulation_at_electrode(stimulation_electrode)
    if len(stim) == 0:
        raise RuntimeError(
            f"No stimulation channel can connect to electrode: {str(stim_el)}"
        )
    stim_unit_int = int(stim)
    if stim_unit_int in stim_units:
        raise RuntimeError(
            f"Two electrodes connected to the same stim unit.\
                           This is not allowed. Please Select a neighboring electrode of {stim_el}!"
        )
    else:
        stim_units.append(stim_unit_int)

The code snippet above generates a list of the indices of the Stimulation Units linked to the Stimulation Electrodes. If a Stimulation Electrode cannot be connected to a Stimulation Unit, an error message is displayed. Note that this message serves as a notification and does not halt the execution of your code. Therefore, it important to ensure, before proceeding, that some of the Stimulation Electrodes have been successfully connected. Another error message can occur, if two electrodes are connected to the same Stimulation Unit. If such a situation occurs, it is recommended to select a neighboring electrode.

Before initiating the transmission of stimulation sequences, the next step is to download the pre-configured Electrode Array to the MaxOne/MaxTwo device, as follows:

array.download(wells)

Downloading the Electrode Array is a necessary step. Merely routing is insufficient. Routing takes place within the mxwserver, but no data is being transmitted to the chip.

After successfully downloading the Electrode Array configuration, the subsequent operation involves running an offset compensation:

mx.offset()

Finally, we can configure and power up the selected Stimulation Units (saved in the list stimulation_units), using the following code:

Listing 3 Configure and power up Stimulation Units#
stimulation_unit_commands: List[mx.StimulationUnits] = []
for stimulation_unit in stimulation_units:
    stimulation = mx.StimulationUnit(stimulation_unit)
                    .power_up(True)
                    .connect(True)
                    .set_voltage_mode()
                    .dac_source(0)
    stimulation_unit_commands.append(stimulation)
    mx.send(stimulation)

Up to this stage, the mxwserver has no information regarding the specific stimulation pulses and sequences intended for your experiment. Actual data transmission to the server only occurs when using the maxlab.send() function.

Note

The waveforms which are sent through the Stimulation Units are generated in the DAC channels (Digital-Analog Converters). Thus, the Stimulation Unit’s output are controlled by configuring the values within the DAC channels.

Stimulation Sequences Preparation#

Creating a stimulation sequence is a straightforward process. It commences with defining the ‘shape’ of the stimulation pulse. As an example, we can use a sinusoidal, biphasic pulse.

Note

DAC inputs operate within the range of 0 to 1023 bits, where 512 corresponds to 0 V, and a single bit corresponds to 2.9 mV. Since inverting amplifiers are used for voltage stimulation, to deliver a positive voltage, a negative bit values has to be sent and vice versa. Consequently, to generate a pulse of 100 millivolts, the DAC channel needs to be set to 512 - 34 (equivalent to 100mV/2.9) and subsequently reverted to 512. For further details, please refer to maxlab.chip.DAC.

The following code illustrates a function which creates such a pulse:

Listing 4 Pulse shape function#
def create_stim_pulse(seq: mx.Sequence, amplitude: int, phase: int, dac: int) -> mx.Sequence:
    seq.append(mx.DAC(dac, 512 - amplitude))
    seq.append(mx.DelaySamples(phase))
    seq.append(mx.DAC(dac, 512 + amplitude))
    seq.append(mx.DelaySamples(phase))
    seq.append(mx.DAC(dac, 512))
    return seq

This function creates a pulse with the following shape:

_images/pulse.svg

We will use this function to create our stimulation sequence. In this process, several parameters need to be considered:

  • amplitude: Amplitude of the pulse in bits.

  • phase: Number of samples being sent between different pulse amplitudes, where each sample corresponds to 50us.

  • number_pulses_per_train: Repetitions of the created stimulation pulse with create_stim_pulse one wishes to send.

  • inter_pulse_interval: Number of delay samples between two repetitions of a pulse (50us per sample).

We are now ready to prepare the stimulation sequence.

Listing 5 Stimulation sequence Preparation#
number_pulses_per_train = 10
amplitude = 200
phase = 4
inter_pulse_interval = 200
seq = mx.Sequence()

for _ in range(number_pulses_per_train):
    seq = create_stim_pulse(seq, amplitude, phase, 0)
    seq.append(mx.DelaySamples(inter_pulse_interval))

The provided code generates a sequence object, appends a stimulation pulse using the create_stim_pulse function, and introduces a delay of 200 samples before incorporating the subsequent stimulation pulse. Note that up to this point, no data has been transmitted to the MaxOne/MaxTwo device. Before starting the stimulation, a recording should be started first, a topic we will delve into in the next section.

Run the Experiment#

Starting the Recording#

To start a recording, first we need to specify the name of the recording file, the directory where the file will be stored, and, for the MaxTwo, from which well we wish to record data.

Note that the recording wells must be given in a list format. For instance [0] represents well 0, and [1, 2, 5] represents wells 1, 2, and 5.

Once this is defined, the recording can be prepared:

Listing 6 Recording preparation#
directory_name = "\tmp"
file_name = "stim"
recording_wells = [0]

s = mx.Saving()
s.open_directory(directory_name)
s.start_file(file_name)
s.group_delete_all()

Per default, we will record detected spikes from every recording well. If we also want to record the raw data, the final step before initiating the recording process is to define electrode groups. This is achieved using the s.group_define() method, which follows this format: s.group_define(well_nr,"any_name", list_of_channels_to_record_from). In the context of our example, the configuration will look like this:

s.group_define(0, "all_channels", list(range(1024)))

Note

Calling s.group_define() enables the saving of raw voltage traces from the specified channels. Without it, only spikes are recorded.

We are now ready to start the recordings:

s.start_recording(recording_wells)

Once the recording started, we can send our prepared stimulation sequence.

Send Stimulation Sequence#

The way of sending the stimulation sequence can be tailored. For example, you can transmit the sequence sequentially to all Stimulation Units, or alternatively, you can send the same sequence to one Stimulation Unit at a time. In this tutorial, we will focus on the simplest scenario, where we send the previously prepared stimulation sequence to all units simultaneously. Before proceeding, it is crucial to determine how many times you want to send the stimulation sequence. A sequence of pulses, created as shown above, will be referred to as stimulation train. Therefore, you need to specify the number of stimulation trains you wish to send, and then execute the following code:

Listing 7 Send stimulation sequence#
number_pulse_trains = 5

for _ in range(number_pulse_trains):
    print("Send pulse")
    seq.send()
    time.sleep(10)

This code sends the prepared sequence 5 times, and it waits a specified time between each train.

Note

Using a for-loop to send individual stimulation sequences is only recommended when there are long breaks in between (seconds) and the exact timing does not have to be perfect. In cases where the exact timing has to be precise, appending multiple sequences and adding breaks in between using the mx.DelaySamples class is recommended. With this approach, perfectly timed execution of the commands without any jitter can be achieved.

Once the stimulation is done, we need to stop the recording, which is done the following way:

s.stop_recording()
s.stop_file()
s.group_delete_all()

Closed-Loop API#

Prerequisites#

The static library you need to link your code against has been written in C++. To simplify the linking step, the interface of the library only exposes C-compatible types (i.e. only functions, struct’s, and no classes, etc.). It was compiled with:

  • Compiler: gcc-11.2.1

  • GLIBC: 2.3.2

  • CPP-Standard: GNU C++20 (-std=gnu++20)

Introduction#

To conduct closed-loop, real-time experiments, a different approach is necessary. The general setup remains similar to the previous one, with an Electrode Array preparation and sequence creation. However, to gain more precise control over when to transmit the prepared sequence to the MaxOne/MaxTwo device, an additional C++ interface comes into play. This interface monitors the incoming data stream and swiftly delivers a response (the closed-loop sequence) based on predefined custom conditions. This bi-directional data exchange involves concurrent reading and transmission on two separate ports.

Note

Before starting with closed-loop experiments, it is highly recommended to be familiar with the Python API code explained above. Additionally, C++ knowledge is required. You will find all referenced files in this tutorial in the maxlab_lib folder, which you get by unzipping ~/MaxLab/share/libmaxlab-*.zip.

For this example, we will simulate a triggering sequence here. In a real experimental setup, this sequence should not be created, as the triggers will come from your cells.

Python Setup#

The first step involves creating a python setup script, following the guidelines outlined in the Python API tutorial, with the following additional changes.

System Initialization#

Firstly, two additional parameters need to be set. The first parameter defines the amplitude of the trigger stimulation sequence, which originates from an electrode named trigger_electrode. The second parameter defines the amplitude of the closed-loop response, generated from an electrode designated as closed_loop_electrode. The newly introduced parameters are as follows:

trigger_electrode = 13248
closed_loop_electrode = 13378
trigger_stimulation_amplitude = 6
closed_loop_stimulation_amplitude = 10

Note

When using the closed-loop scripts outside of this tutorial, there is no need to simulate a trigger sequence. The simulation is solely for illustrative purposes. Hence, there is no necessity to set trigger_stimulation_amplitude. Instead, you need to set a condition in the C++ script, as explained later.

Prior to initializing the system, you need to make sure that no sequence has been pre-defined in the server. This can be achieved with the following procedure:

s = mx.Sequence('trigger', persistent=False)
del(s)
s = mx.Sequence('closed_loop', persistent=False)
del(s)

The system is now ready for initialization, as detailed earlier. However, for the purposes of this example, a final adjustment is required to mitigate the occurrence of false positives. This adjustment involves setting the detection threshold to 8.5 times the standard deviation of the noise:

mx.set_event_threshold(8.5)

Array and Stimulation Units Preparation#

The configuration process for the Electrode Array and Stimulation Units follows the same steps as detailed above. However, there is a key distinction: unlike in the Python API tutorial, where multiple electrodes are stimulated, here, only two electrodes will be stimulated. These two electrodes will be associated with their respective stimulation units, designated as stimulation_1 and stimulation_2.

Stimulation Sequence(s) Preparation#

The stimulation sequences (in our case the trigger and closed-loop sequences) can be prepared similarly as explained in the Python API. The two must be associated with different DACs so that they can independently be controlled. Assuming that the function create_stim_pulse as defined above exists, we would run the following code:

sequence_1 = mx.Sequence('trigger', persistent=True)
for _ in range(stimulation_pulses_number):
    sequence_1 = create_stim_pulse(sequence_1, trigger_stimulation_amplitude, phase, 0)
    sequence_1.append(mx.DelaySamples(interpulse_interval))

sequence_2 = mx.Sequence('closed_loop_sequence', persistent=True)
for _ in range(stimulation_pulses_number):
    sequence_2 = create_stim_pulse(sequence_2, closed_loop_stimulation_amplitude, phase, 1)
    sequence_2.append(mx.DelaySamples(interpulse_interval))

Similar to the explanation provided in the Python API Tutorial, to this point, no data has been sent to the device, and the Electrode Array and sequence preparations have been executed only locally.

Stimulation Sequence(s) Sending and Data Saving#

Sending and recording the data must be then run similarly as in the Python API tutorial.

Once the script is ready, it should not be executed immediately. It should be saved with a filename like closedLoopSetup.py, and run from a terminal or executable after setting up the C++ part.

C++ Setup#

This code interacts with the MaxOne/MaxTwo, listening to the data stream and responding in real time. There are two distinct types of data streams: the raw data stream and the filtered data stream. The former contains unprocessed data, while the latter exclusively includes the spikes detected by MaxLab Live.

Raw Data Stream#

We start here by explaining how the C++ code for processing raw data works. Here is a copy of the script:

#include <stdlib.h>
#include <stdio.h>
#include <thread>
#include <chrono>
#include "maxlab/maxlab.h"

int main(int argc, char * argv[])
{
    if (argc < 2)
    {
        fprintf(stderr, "Call with: %s [detection_channel]", argv[0]);
        exit(1);
    }
    const int detection_channel = atoi(argv[1]);

    maxlab::checkVersions();
    maxlab::verifyStatus(maxlab::DataStreamerRaw_open());
    std::this_thread::sleep_for(std::chrono::seconds(2));//Allow data stream to open

    uint64_t blanking = 0;
    while (true)
    {
        maxlab::RawFrameData frameData;
        maxlab::Status status = maxlab::DataStreamerRaw_receiveNextFrame(&frameData);
        if (status == maxlab::Status::MAXLAB_NO_FRAME || frameData.frameInfo.corrupted)
            continue;
        if (blanking > 0)
        {
            blanking--;
            if(blanking != 0)
                continue;
        }

        if (frameData.amplitudes[detection_channel] > 40.f) //Amplitudes can be variable. Adjust this as necessary.
        {
            maxlab::verifyStatus(maxlab::sendSequence("closed_loop"));
            blanking = 8000;
        }
    }
    maxlab::verifyStatus(maxlab::DataStreamerRaw_close());
}

The general approach of the code is the following: we first open the stream from which we will receive real-time data that is coming from the MaxOne/MaxTwo. Notice how in the end we need to again close this stream. This is important because otherwise we will leave the mxwserver in a undefined state! We also call the function maxlab::verifyStatus to ensure that no errors happened during the opening of the stream. You should only call this function if the error is not recoverable, since we exit execution in case something goes wrong. The next step is to create an instance of maxlab::RawFrameData in which the information that is received in maxlab::DataStreamerRaw_receiveNextFrame can be stored in. Notice that we also check the status of the reception to make sure that actually received data (the while-loop might be faster than the sampling rate of the device and we thus might not have any data in the stream). If we have data, we can check a condition to decide if we want to stimulate or not. Here we have chosen to simply stimulate if we have a spike on a particular channel (i.e. detection_channel). In that case we call maxlab::sendSequence to send the response stimulus.

Most of the general structure from the example above can be taken for any experiment. A simple generalization here might be to write a function with the signature bool condition(maxlab::RawFrameData&) that takes the received data and returns a bool based on the input if a stimulation pulse should be sent.

if (condition(frameData))
{
    maxlab::verifyStatus(maxlab::sendSequence("closed_loop"));
    blanking = 8000;
}

To complete our understanding of this code, we here briefly explain the role that the variable blanking plays. After sending a stimulation pulse to the MaxOne/MaxTwo, the voltage of the chip needs some amount of time until returning back to neutral (0V). This is done by continuously decrementing this variable after having sent the stimulation pulse.

Note

Since the blanking variable is decremented for every received frame, this variable needs to be set differently for MaxOne/MaxTwo with their different sampling rates. In this example, the 8000 frames would correspond to 0.4 ms and 0.8 ms for MaxOne/MaxTwo respectively. Additionally, 0.4 ms is a very conservative number and can likely be decreased further without any issues.

Filtered Data Stream#

The process_spike_events.cpp (filtered data stream) script closely resembles the raw data stream script. However, it distinguishes itself through the incorporation of an additional for-loop. For every frame, this loop checks if any spikes were detected on the detection channel, triggering the stimulation if that was the case.

#include <iostream>
#include <thread>
#include <chrono>

#include "maxlab/maxlab.h"
#include <unistd.h>

int main(int argc, char * argv[])
{
    if (argc < 2)
    {
        std::cerr << "Call with:\t" << argv[0] << "\t[detection_channel]" << std::endl;
        exit(1);
    }
    const int detection_channel = atoi(argv[1]);

    uint8_t targetWell{0};

    uint64_t blanking{0};
    maxlab::checkVersions();
    maxlab::verifyStatus(maxlab::DataStreamerFiltered_open(maxlab::FilterType::IIR));
    std::this_thread::sleep_for(std::chrono::seconds(2));;//Allow data stream to open

    maxlab::FilteredFrameData frameData;
    while (true)
    {
        maxlab::Status status = maxlab::DataStreamerFiltered_receiveNextFrame(&frameData);
        if (status == maxlab::Status::MAXLAB_NO_FRAME)
            continue;

        if (frameData.frameInfo.well_id != targetWell)
            continue;

        if (blanking > 0)
        {
            blanking--;
            continue;
        }

        for (uint64_t i = 0; i < frameData.spikeCount; ++i)
        {
            const maxlab::SpikeEvent & spike = frameData.spikeEvents[i];
            if (spike.channel == detection_channel)
            {
                maxlab::verifyStatus(maxlab::sendSequence("closed_loop"));
                blanking = 8000;
            }
        }
    }
    maxlab::verifyStatus(maxlab::DataStreamerFiltered_close());
}

This ensures that only actual spikes are processed and what doesn’t pass as a spike event is not considered. For more details, have a look at maxlab::SpikeEvent.

Integrating everything together#

Upon properly setting up the scripts, you can start the experiment. This process follows a specific sequence. Begin by compiling the C++ code; execute this step by running the make command from the main folder. This will generate a new directory named build, where the executable file can be found:

# cd /path/to/maxlab_lib
make

For more information on GNU CMake, please consult the official documentation.

Once the executable is created, the mxwserver can be launched through the GUI. The next step will be to run the compiled C++ script:

# replace `detection_channel` with the appropriate number
./example_raw detection_channel

And, finally, we are ready to run the Python setup script, called here closeLoopSetup.py. This can be done with:

python3 closeLoopSetup.py

Note

To execute the Python script, it is important to install the maxlab package first. If a virtual environment, such as pyenv or conda, is in use, ensure that the environment is activated correctly.

The experiment is in progress, and it can be observed by accessing the GUI. The data is systematically saved.

Other experimental workflows are also possible. Another option is to create a Python setup script that calls the C++ executable from within, so that only the Python script needs to be manually run.