#!/usr/bin/python
"""
STIMULATION EXAMPLES
This script can be used to run stimulation on a MaxTwo or
MaxOne system via the Python API. Several combinations of pulse
trains can be run and the script can be called from the command
line directly.
Warning: No recordings are made in this example, but you can find
an example about how to record in another script, called `recordings.py`.
"""
import time
import maxlab as mx
from typing import List, Optional
from pathlib import Path
# fmt: off
electrodes = [4885, 4666, 4886, 4022, 5327, 5328, 5106, 5326, 3138, 3140, 2919,
5105, 4667, 4448, 5109, 4669, 4665, 3798, 4021, 3141, 4668, 4240,
3363, 3803, 3580, 3801, 2921, 3799, 4239, 3359, 3142, 3797, 3361,
4020, 4241, 4018, 4889, 4447, 3357, 5108, 4888, 5107, 4446, 3583,
3360, 3802, 3358, 3578, 2920, 4019, 3582, 3362, 3577, 4887, 3139,
3800, 3579, 3581,]
# fmt: on
event_counter = 1 # variable to keep track of the event_id
def initialize_system() -> None:
"""Initialize system into a defined state
The function initializes the system into a defined state before
starting any script. This way, one can be sure that the system
is always in the same state while running the script, regardless
of what has been done before with it. The function also powers on
the stimulation units, which are turned off by default.
Raises
------
RuntimeError
If system does not initialize correctly.
"""
mx.initialize()
if mx.send(mx.Core().enable_stimulation_power(True)) != "Ok":
raise RuntimeError("The system didn't initialize correctly.")
def configure_array(electrodes: List[int], stim_electrodes: List[int]) -> mx.Array:
"""Configure array
This function configures the array, given a list of recording
and stimulation electrodes. An important step in this function
is the routing, which happens once the electrodes are selected.
Parameters
----------
electrodes : List[int]
List of the index of the recording electrodes
stim_electrodes : List[int]
List of the index of the stimulation electrodes
Returns
-------
mx.Array
The configured array.
Notes
-----
To finalize the configuration, the array needs to be downloaded to
the MaxOne/Two. However, before being able to do that, we first need
to connect the stimulation units to the stimulation electrodes with the
function `connect_stim_units_to_stim_electrodes`. This is the reason
why `array.download` is not part of this function.
"""
array = mx.Array("stimulation")
array.reset()
array.clear_selected_electrodes()
array.select_electrodes(electrodes)
array.select_stimulation_electrodes(stim_electrodes)
array.route()
return array
def load_config(config_file: str) -> mx.Array:
"""Load a previously created configuration
Load and configure an array from a previously generated
configuration, either through the Python API or through
the Scope GUI.
Parameters
----------
config_file : str
Name of the previously generated config file.
Returns
-------
mx.Array
The configured array.
Raises
------
FileNotFoundError
If the file cannot be found.
Exception
If the config_file cannot be loaded properly.
"""
path = Path(config_file)
if not path.is_file():
raise FileNotFoundError(f"Config file '{config_file}' not found.")
array = mx.Array("stimulation")
try:
array.load_config(config_file)
except Exception as e:
raise Exception(f"Error loading config file '{config_file}': {str(e)}")
return array
def connect_stim_units_to_stim_electrodes(
stim_electrodes: List[int], array: mx.Array
) -> List[int]:
"""Connect the stimulation units to the stimulation electrodes
Once an array configuration has been obtained, either through routing
or through loading a previous configuration, the stimulation units
can be connected to the desired electrodes.
Notes
-----
With this step, one needs to be careful, in rare cases it can happen
that an electrode cannot be stimulated. For example, the electrode
could not be routed (due to routing constraints), and the error message
"No stimulation channel can connect to electrode: ..." will be printed.
If this situation occurs, it is recommended to then select the electrode
next to it.
Parameters
----------
stim_electrodes : List[int]
List of the index of the stimulation electrodes
array : mx.Array
The configured array
Returns
-------
List[str]
List of stimulation units indices corresponding to the connected
stimulation electrodes
Raises
------
RuntimeError
If an electrode cannot be connected to a stimulation unit.
If two electrodes are connected to the same stimulation unit.
"""
stim_units: List[int] = []
for stim_el in stim_electrodes:
array.connect_electrode_to_stimulation(stim_el)
stim = array.query_stimulation_at_electrode(stim_el)
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)
return stim_units
def powerup_stim_unit(stim_unit: int) -> mx.StimulationUnit:
"""Power up and connect a stimulation unit
This function powers up and connect a specific stimulation
unit in the MaxOne/Two. Without it, we would not be able to
send the prepared sequence to the device and it thus needs
to be run before running `mx.send(stim)` (as shown in the
function `configure_and_powerup_stim_units).
Parameters
----------
stim_unit : str
The index of the stimulation unit to power up
Returns
-------
mx.StimulationUnit
The powered up stimulation unit
"""
return (
mx.StimulationUnit(stim_unit)
.power_up(True)
.connect(True)
.set_voltage_mode()
.dac_source(0)
)
def configure_and_powerup_stim_units(stim_units: List[int]) -> List[mx.StimulationUnit]:
"""Configure and powerup the stimulation units
Once the electrodes are connected to the stimulation units,
the stimulation units need to be configured and powererd up.
Parameters
----------
stim_units : List[str]
List of stimulation units indices corresponding to the connected
stimulation electrodes
Returns
-------
List[mx.StimulationUnit]
List of stimulation unit objects corresponding to the connected
stimulation electrodes.
"""
stim_unit_commands: List[mx.StimulationUnit] = []
for stim_unit in stim_units:
stim = powerup_stim_unit(stim_unit)
stim_unit_commands.append(stim)
mx.send(stim)
return stim_unit_commands
def poweroff_all_stim_units() -> None:
"""Poweroff all stimulation units
This function is used to make sure that every stimulation units is
powered-off before starting sequentially to send the sequences to the
different stimulation units individually.
Returns
-------
None
"""
for stimulation_unit in range(0, 32):
stim = mx.StimulationUnit(stimulation_unit)
stim.power_up(False)
stim.connect(False)
mx.send(stim)
def create_stim_pulse(
seq: mx.Sequence, amplitude: int, delay_samples: int
) -> mx.Sequence:
"""Create stimulation pulse
The stimulation units can be controlled through three independent
sources, what we call DAC channels (for digital analog converter).
By programming a DAC channel with digital values, we can control
the output the stimulation units. DAC inputs are in the range between
0 to 1023 bits, whereas 512 corresponds to zero volt and one bit
corresponds to 2.9 mV. When the stimulation buffers are set to voltage
mode, they act like an inverting amplifier, hence 512 + a number of bits corresponds to a negative voltage, or 512 - a number of bits corresponds to a positive voltage.
corresponds to a negative voltage. Thus, to give a pulse of 100mV,
the DAC channel temporarily would need to be set to 512 - 34 (100mV/2.9)
and back again to 512.
Notes
-----
In this example, all 32 units are controlled through the same DAC
channel ( dac_source(0) ), thus by programming a biphasic pulse
on DAC channel 0, all the stimulation units exhibit the biphasic
pulse.
Parameters
----------
seq : mx.Sequence
Sequence object holding a sequence of commands, as generated
by `mx.Sequence()`.
amplitude : int
Amplitude of the pulse, with units [100mV/2.9], as explained above.
delay_samples : int
How many samples should sand between different sequence amplitude.
Returns
-------
mx.Sequence
Sequence object filled by the pulse.
"""
global event_counter
event_counter += 1
seq.append(mx.Event(0, 1, event_counter, f"amplitude {amplitude} event_id {event_counter}"))
seq.append(mx.DAC(0, 512 - amplitude))
seq.append(mx.DelaySamples(delay_samples))
seq.append(mx.DAC(0, 512 + amplitude))
seq.append(mx.DelaySamples(delay_samples))
seq.append(mx.DAC(0, 512))
return seq
def prepare_stim_sequence(
number_pulses_per_train: int,
inter_pulse_interval: int,
phase: int,
amplitude: int,
changing_amplitude: Optional[bool] = False,
max_amplitude: Optional[int] = None,
amplitude_interval: Optional[int] = None,
) -> mx.Sequence:
"""Prepare a stimulation sequence.
This is just and example and it only illustrates how sequences
can be constructed.
Parameters
----------
number_pulses_per_train : int
Number of repetitions of one pulse.
inter_pulse_interval : int
Number of samples to delay between two consecutive pulses.
phase : int
Number of samples before the switch from high-low (and vice versa)
voltage when creating stimulation pulse.
amplitude : int
Amplitude of the pulse (minimal if changing_amplitude is True).
Unit is millivolt.
changing_amplitude : Optional[bool]
Whether the pulse amplitude changes, by default False.
max_amplitude : Optional[int]
Maximal amplitude of the pulse if changing_amplitude is True.
Unit is millivolt.
amplitude_interval : Optional[int]
Increment amplitude interval if changing_amplitude is True.
Unit is millivolt.
Returns
-------
mx.Sequence
Sequence object filled with the stimulation sequence.
"""
seq = mx.Sequence()
dac_lsb_mV = float(mx.query_DAC_lsb_mV())
if changing_amplitude:
if max_amplitude is None or amplitude_interval is None:
raise ValueError(
"Both max_amplitude and amplitude_interval are required for changing_amplitude."
)
for cur_amplitude in range(amplitude, max_amplitude, amplitude_interval):
for _ in range(number_pulses_per_train):
seq = create_stim_pulse(seq, int(cur_amplitude/dac_lsb_mV), phase)
seq.append(mx.DelaySamples(inter_pulse_interval))
seq.append(mx.DelaySamples(inter_pulse_interval))
else:
for _ in range(number_pulses_per_train):
seq = create_stim_pulse(seq, int(amplitude/dac_lsb_mV), phase)
seq.append(mx.DelaySamples(inter_pulse_interval))
return seq
def send_stim_pulses_all_units(seq: mx.Sequence, number_pulse_trains: int) -> None:
"""Send stimulation pulses to all units simultaneously
This function sends the sequence of pulses built by the
function `prepare_stim_sequence` (the pulse train) for the
case where the stimulation pulses are sent simultaneously to all units.
Parameters
----------
number_pulse_trains : int
The number of repetitions of the pulse sequence.
seq : mx.Sequence
The sequence of pulses
Returns
-------
None
"""
for _ in range(number_pulse_trains):
print("Send pulse")
seq.send()
time.sleep(10)
def send_stim_pulses_units_sequentially(
seq: mx.Sequence, stim_units: List[int]
) -> None:
"""Send stimulation pulses to units sequentially
This function sends the sequence of pulses built by the
function `prepare_stim_sequence` for the case where there
stimulation pulses are sent sequentially to one unit at a time.
Parameters
----------
seq : mx.Sequence
The sequence of pulses
stim_units : List[int]
List of stimulation units indices corresponding to the connected
stimulation electrodes
Returns
-------
None
"""
for stim_unit in stim_units:
print(f"Power up stimulation unit {stim_unit}")
stim = mx.StimulationUnit(stim_unit)
stim.power_up(True).connect(True).set_voltage_mode().dac_source(0)
mx.send(stim)
print("Send pulse")
seq.send()
print(f"Power down stimulation unit {stim_unit}")
stim = mx.StimulationUnit(stim_unit).power_up(False)
mx.send(stim)
time.sleep(2)
if __name__ == "__main__":
# The fist thing we do is initalize the system.
initialize_system()
# We have selected some recording electrodes (see at the top,
# directly after the imports) and here we write down some
# stimulation electrodes.
# Note that these choices follow certain rules, i.e.
# * You cannot choose more than 32 stimulation electrodes.
# * You cannot choose more than 1024 recording electrodes.
# * You cannot choose two stimulation electrodes that get connected
# to the same stimulation unit.
# The above selected recording electrodes are just an example and
# should showcase how one can select specific electrodes on the array.
stim_electrodes = [3580, 4887]
array = configure_array(electrodes, stim_electrodes)
# If instead we use a pre-defined config, we can load it like this:
# array = load_config("path/to/config/file.cfg")
# Select the subset of wells we want to stimulate.
wells = list(range(1))
mx.activate(wells)
stim_units = connect_stim_units_to_stim_electrodes(stim_electrodes, array)
array.download(wells)
# Wait a few seconds to make sure the configuration is downloaded
time.sleep(mx.Timing.waitAfterDownload)
mx.offset()
# Wait a few more seconds to make sure the offset compensation is done
time.sleep(15)
mx.clear_events() # Empty event-buffer before adding anything to it
simultaneous_and_same_seq = True
if simultaneous_and_same_seq:
stim_unit_commands = configure_and_powerup_stim_units(stim_units)
# At this stage, we can start the recordings. Note that, if we wish to have events written
# to the recorded file, it is important to construct our sequence after starting the recording.
seq = prepare_stim_sequence(
number_pulses_per_train=10,
inter_pulse_interval=2000, # samples
phase=4, # 4 samples = 200us (4*50us)
amplitude=200, # mV
)
# Note that since the loop to send the pulse trains is done in python,
# the timing between the pulse trains won't be particularly precise.
# Instead consider createing one large sequence (although there are
# some limitations for MaxOne here as well, see documentation).
send_stim_pulses_all_units(seq, number_pulse_trains=5)
else:
poweroff_all_stim_units()
# Start potential recordings.
seq = prepare_stim_sequence(
number_pulses_per_train=10,
inter_pulse_interval=2000, # samples
phase=4, # 4 samples = 200us (4*50us)
amplitude=200, # mV
changing_amplitude=True,
max_amplitude=400, # mV
amplitude_interval=50, # mV
)
send_stim_pulses_units_sequentially(seq, stim_units)
# If one wishes to send 2 sequences (pulse trains) randomly, run for example:
#
# seq_1 = prepare_stim_sequence(number_pulses_per_train, inter_pulse_interval, phase, amplitude=200)
# seq_2 = prepare_stim_sequence(number_pulses_per_train, inter_pulse_interval, phase, amplitude=150)
# The pulse trains can be delivered whenever seems reasonable, or following
# a specific stimulation schedudle, such as:
# time.sleep(1)
# send_stim_pulses_all_units(number_pulse_trains, seq_1)
# time.sleep(5)
# send_stim_pulses_all_units(number_pulse_trains, seq_2)