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 themaxlab_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:
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:
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:
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:
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:
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.
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:
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:
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.