Electrical Stimuli¶
The stimuli
module provides a number of common
electrical stimulus types, which can be assigned to electrodes of a
ProsthesisSystem
object:
Stimulus | Description |
MonophasicPulse |
single phase: cathodic or anodic |
BiphasicPulse |
biphasic: cathodic + anodic |
AsymmetricBiphasicPulse |
biphasic with unequal amplitude/duration |
PulseTrain |
combine any Stimulus into a pulse train |
BiphasicPulseTrain |
series of (symmetric) biphasic pulses |
AsymmetricBiphasicPulseTrain |
series of asymmetric biphasic pulses |
BiphasicTripletTrain |
series of biphasic pulse triplets |
In addition, pulse2percept provides convenience functions to convert
images and videos into Stimulus
objects
(see the io
module).
Important
Stimuli specify electrical currents in microamps (uA) and time in milliseconds (ms). When in doubt, check the docstring of the function or class you are trying to use.
Understanding the Stimulus class¶
The Stimulus
object defines a common
interface for all electrical stimuli, consisting of a 2D data array with
labeled axes, where rows denote electrodes and columns denote points in time.
A stimulus can be created from a variety of source types. The number of electrodes and time points will be automatically extracted from the source type:
Source type | electrodes | time |
Scalar value | 1 | None |
Nx1 NumPy array | N | None |
NxM NumPy array | N | M |
In addition, you can also pass a collection of source types (e.g., list, dictionary).
Note
Depending on the source type, a stimulus might have a time component or not.
Single-electrode stimuli¶
The easiest way to create a stimulus is to specify the current amplitude (uA) to be delivered to an electrode:
In [1]: from pulse2percept.stimuli import Stimulus
# Stimulate an unnamed electrode with -14uA:
In [2]: Stimulus(-14)
Out[2]:
Stimulus(data=[[-14.]], dt=0.001, electrodes=[0],
is_charge_balanced=False,
metadata={'electrodes': {}, 'user': None},
shape=(1, 1), time=None)
You can also specify the name of the electrode to be stimulated:
# Stimulate Electrode 'B7' with -14uA:
In [3]: Stimulus(-14, electrodes='B7')
Out[3]:
Stimulus(data=[[-14.]], dt=0.001, electrodes=['B7'],
is_charge_balanced=False,
metadata={'electrodes': {}, 'user': None},
shape=(1, 1), time=None)
By default, this stimulus will not have a time component
(stim.time
is None).
Some models, such as
ScoreboardModel
, cannot handle stimuli in
time.
To create stimuli in time, you can use one of the above mentioned stimulus
types, such as MonophasicPulse
or
BiphasicPulseTrain
:
# Stimulate Electrode 'A001' with a 20Hz pulse train lasting 0.5s
# (pulses: cathodic-first, 10uA amplitude, 0.45ms phase duration):
In [4]: from pulse2percept.stimuli import BiphasicPulseTrain
In [5]: pt = BiphasicPulseTrain(20, 10, 0.45, stim_dur=500)
In [6]: stim = Stimulus(pt)
In [7]: stim
Out[7]:
Stimulus(data=[[ 0. -10. -10. ... 10. 0. 0.]],
dt=0.001, electrodes=[0],
is_charge_balanced=False, metadata=dict,
shape=(1, 71), time=<(71,) np.ndarray>)
# This stimulus has a time component:
In [8]: stim.time
Out[8]:
array([0.000e+00, 1.000e-03, 4.490e-01, ..., 4.509e+02, 4.509e+02,
5.000e+02], dtype=float32)
You can specify not only the name of the electrode but also the time steps to be used:
# Stimulate Electrode 'C7' with int time steps:
In [9]: Stimulus(pt, electrodes='C7', time=np.arange(pt.shape[-1]))
Out[9]:
Stimulus(data=[[ 0. -10. -10. ... 10. 0. 0.]],
dt=0.001, electrodes=['C7'],
is_charge_balanced=True, metadata=dict,
shape=(1, 71), time=[ 0. 1. 2. ... 68. 69. 70.])
Creating multi-electrode stimuli¶
Stimuli can also be created from a list or dictionary of source types:
# Stimulate three unnamed electrodes with -2uA, 14uA, and -100uA,
# respectively:
In [10]: Stimulus([-2, 14, -100])
Out[10]:
Stimulus(data=[[ -2.], [ 14.], [-100.]], dt=0.001,
electrodes=[0 1 2], is_charge_balanced=False,
metadata={'electrodes': {}, 'user': None},
shape=(3, 1), time=None)
Electrode names can be passed in a list:
In [11]: Stimulus([-2, 14, -100], electrodes=['A1', 'B1', 'C1'])
Out[11]:
Stimulus(data=[[ -2.], [ 14.], [-100.]], dt=0.001,
electrodes=['A1' 'B1' 'C1'],
is_charge_balanced=False,
metadata={'electrodes': {}, 'user': None},
shape=(3, 1), time=None)
Alternatively, stimuli can be created from a dictionary:
# Equivalent to the previous one:
In [12]: Stimulus({'A1': -2, 'B1': 14, 'C1': -100})
Out[12]:
Stimulus(data=[[ -2.], [ 14.], [-100.]], dt=0.001,
electrodes=['A1' 'B1' 'C1'],
is_charge_balanced=False,
metadata={'electrodes': {}, 'user': None},
shape=(3, 1), time=None)
The same is true for a dictionary of pulse trains:
In [13]: from pulse2percept.stimuli import BiphasicPulse
In [14]: Stimulus({'A1': BiphasicPulse(10, 0.45, stim_dur=100),
....: 'C9': BiphasicPulse(-30, 1, delay_dur=10, stim_dur=100)})
....:
Out[14]:
Stimulus(data=<(2, 15) np.ndarray>, dt=0.001,
electrodes=['A1' 'C9'], is_charge_balanced=True,
metadata=dict, shape=(2, 15),
time=<(15,) np.ndarray>)
Interacting with stimuli¶
Accessing individual data points¶
You can directly index into the Stimulus
object to retrieve individual data points: stim[item]
.
item
can be an integer, string, slice, or tuple.
For example, to retrieve all data points of the first electrode in a multi-electrode stimulus, use the following:
In [15]: stim = Stimulus(np.arange(10).reshape((2, 5)))
In [16]: stim[0]
Out[16]: array([0., 1., 2., 3., 4.], dtype=float32)
Here 0
is a valid electrode index, because we did not specify an electrode
name. Analogously:
In [17]: stim = Stimulus(np.arange(10).reshape((2, 5)), electrodes=['B1', 'C2'])
In [18]: stim['B1']
Out[18]: array([0., 1., 2., 3., 4.], dtype=float32)
Similarly, you can retrieve all data points at a particular time:
In [19]: stim = Stimulus(np.arange(10).reshape((2, 5)))
In [20]: stim[:, 3]
Out[20]:
array([[3.],
[8.]])
Important
The second index or slice into stim
is not a column index into
stim.data
, but an exact time specified in ms!
For example, stim[:, 3]
translates to “retrieve all data points at
time = 3 ms”, not “retrieve stim.data[:, 3]”.
This works even when the specified time is not explicitly provided in the
stimulus!
In that case, the value is automatically interpolated (using SciPy’s
interp1d
):
# A single-electrode ramp stimulus:
In [21]: stim = Stimulus(np.arange(10).reshape((1, -1)))
In [22]: stim
Out[22]:
Stimulus(data=[[0. 1. 2. ... 7. 8. 9.]], dt=0.001,
electrodes=[0], is_charge_balanced=False,
metadata={'electrodes': {}, 'user': None},
shape=(1, 10), time=[0. 1. 2. ... 7. 8. 9.])
# Retrieve stimulus at t=3:
In [23]: stim[0, 3]
Out[23]: 3.0
# Time point 3.45 is not in the data provided above, but can be
# interpolated as follows:
In [24]: stim[0, 3.45]
Out[24]: 3.450000047683716
# This also works for multiple time points:
In [25]: stim[0, [3.45, 6.78]]
Out[25]: array([[3.45, 6.78]])
# Time points above the valid range will return the largest stored value:
In [26]: stim[0, 123.45]
Out[26]: 9.0
Accessing the raw data¶
The raw data is accessible as a 2D NumPy array (electrodes x time) stored in
the data
container of a Stimulus:
In [27]: stim = Stimulus(np.arange(10).reshape((2, 5)))
In [28]: stim.data
Out[28]:
array([[0., 1., 2., 3., 4.],
[5., 6., 7., 8., 9.]], dtype=float32)
You can index and slice the data
container like any NumPy array.
Assigning new coordinates to an existing stimulus¶
You can change the coordinates of an existing
Stimulus
object, but retain all its data,
by wrapping it in a second Stimulus object:
# Say you have a Stimulus object with unlabeled axes:
In [29]: stim = Stimulus(np.ones((2, 5)))
In [30]: stim
Out[30]:
Stimulus(data=[[1. 1. 1. 1. 1.], [1. 1. 1. 1. 1.]],
dt=0.001, electrodes=[0 1],
is_charge_balanced=False,
metadata={'electrodes': {}, 'user': None},
shape=(2, 5), time=[0. 1. 2. 3. 4.])
# You can create a new object from it with named electrodes:
In [31]: Stimulus(stim, electrodes=['A1', 'F10'])
Out[31]:
Stimulus(data=[[1. 1. 1. 1. 1.], [1. 1. 1. 1. 1.]],
dt=0.001, electrodes=['A1' 'F10'],
is_charge_balanced=False,
metadata={'electrodes': {}, 'user': None},
shape=(2, 5), time=[0. 1. 2. 3. 4.])
# Same goes for time points:
In [32]: Stimulus(stim, time=[0, 0.1, 0.2, 0.3, 0.4])
Out[32]:
Stimulus(data=[[1. 1. 1. 1. 1.], [1. 1. 1. 1. 1.]],
dt=0.001, electrodes=[0 1],
is_charge_balanced=False,
metadata={'electrodes': {}, 'user': None},
shape=(2, 5), time=[0. 0.1 0.2 0.3 0.4])
Compressing a stimulus¶
The compress
method automatically
compresses the data in two ways:
- Removes electrodes with all-zero activation.
- Retains only the time points at which the stimulus changes.
For example, only the signal edges of a pulse train are saved. That is, rather than saving the current amplitude at every 0.1ms time step, only the non-redundant values are retained. This drastically reduces the memory footprint of the stimulus. You can convince yourself of that by inspecting the size of a Stimulus object before and after compression:
# An uncompressed stimulus:
In [33]: stim = Stimulus([[0, 0, 0, 1, 2, 0, 0, 0]], time=[0, 1, 2, 3, 4, 5, 6, 7])
In [34]: stim
Out[34]:
Stimulus(data=[[0. 0. 0. ... 0. 0. 0.]], dt=0.001,
electrodes=[0], is_charge_balanced=False,
metadata={'electrodes': {}, 'user': None},
shape=(1, 8), time=[0. 1. 2. ... 5. 6. 7.])
# Now compress the data:
In [35]: stim.compress()
# Notice how the time axis have changed:
In [36]: stim
Out[36]:
Stimulus(data=[[0. 0. 1. 2. 0. 0.]], dt=0.001,
electrodes=[0], is_charge_balanced=False,
metadata={'electrodes': {}, 'user': None},
shape=(1, 6), time=[0. 2. 3. 4. 5. 7.])
Accessing stimulus metadata¶
Stimuli can store additional relevant information in the metadata
dictionary.
Users can also pass their own metadata, which will be stored in metadata["user"]
Stimuli built from a collection of sources store the metadata for each source
in metadata["electrodes"][electrode]["metadata"]
:
# Accessing metadata
In [37]: stim = Stimulus([[0, 1, 2, 3]], metadata='user_metadata')
In [38]: stim.metadata
Out[38]: {'electrodes': {}, 'user': 'user_metadata'}
# Some objects store their own metadata
In [39]: stim = BiphasicPulseTrain(1,1,1, metadata='user_metadata')
In [40]: stim.metadata
Out[40]: {'freq': 1, 'amp': 1, 'phase_dur': 1, 'delay_dur': 0, 'user': 'user_metadata'}
# Multiple source metadata
In [41]: stim = Stimulus({'A1' : BiphasicPulseTrain(1,1,1, metadata='A1 metadata'),
....: 'B2' : BiphasicPulseTrain(1,1,1, metadata='B2 metadata')},
....: metadata='stimulus metadata')
....:
In [42]: stim.metadata
Out[42]:
{'electrodes': {'A1': {'metadata': {'freq': 1,
'amp': 1,
'phase_dur': 1,
'delay_dur': 0,
'user': 'A1 metadata'},
'type': pulse2percept.stimuli.pulse_trains.BiphasicPulseTrain},
'B2': {'metadata': {'freq': 1,
'amp': 1,
'phase_dur': 1,
'delay_dur': 0,
'user': 'B2 metadata'},
'type': pulse2percept.stimuli.pulse_trains.BiphasicPulseTrain}},
'user': 'stimulus metadata'}
Examples using Stimulus
¶

Generating a drifting sinusoidal grating or drifting bar stimulus

Beyeler et al. (2019): Focal percepts with the scoreboard model

Beyeler et al. (2019): Axonal streaks with the axon map model

Retinotopy: Predicting the perceptual effects of different visual field maps

Granley et al. (2021): Effects of Biphasic Pulse Parameters with the BiphasicAxonMapModel

Horsager et al. (2009): Predicting temporal sensitivity

Nanduri et al. (2012): Frequency vs. amplitude modulation