pyNIBS 0.2024.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. pyNIBS-0.2024.8.dist-info/LICENSE +623 -0
  2. pyNIBS-0.2024.8.dist-info/METADATA +723 -0
  3. pyNIBS-0.2024.8.dist-info/RECORD +107 -0
  4. pyNIBS-0.2024.8.dist-info/WHEEL +5 -0
  5. pyNIBS-0.2024.8.dist-info/top_level.txt +1 -0
  6. pynibs/__init__.py +34 -0
  7. pynibs/coil.py +1367 -0
  8. pynibs/congruence/__init__.py +15 -0
  9. pynibs/congruence/congruence.py +1108 -0
  10. pynibs/congruence/ext_metrics.py +257 -0
  11. pynibs/congruence/stimulation_threshold.py +318 -0
  12. pynibs/data/configuration_exp0.yaml +59 -0
  13. pynibs/data/configuration_linear_MEP.yaml +61 -0
  14. pynibs/data/configuration_linear_RT.yaml +61 -0
  15. pynibs/data/configuration_sigmoid4.yaml +68 -0
  16. pynibs/data/network mapping configuration/configuration guide.md +238 -0
  17. pynibs/data/network mapping configuration/configuration_TEMPLATE.yaml +42 -0
  18. pynibs/data/network mapping configuration/configuration_for_testing.yaml +43 -0
  19. pynibs/data/network mapping configuration/configuration_modelTMS.yaml +43 -0
  20. pynibs/data/network mapping configuration/configuration_reg_isi_05.yaml +43 -0
  21. pynibs/data/network mapping configuration/output_documentation.md +185 -0
  22. pynibs/data/network mapping configuration/recommendations_for_accuracy_threshold.md +77 -0
  23. pynibs/data/neuron/models/L23_PC_cADpyr_biphasic_v1.csv +1281 -0
  24. pynibs/data/neuron/models/L23_PC_cADpyr_monophasic_v1.csv +1281 -0
  25. pynibs/data/neuron/models/L4_LBC_biphasic_v1.csv +1281 -0
  26. pynibs/data/neuron/models/L4_LBC_monophasic_v1.csv +1281 -0
  27. pynibs/data/neuron/models/L4_NBC_biphasic_v1.csv +1281 -0
  28. pynibs/data/neuron/models/L4_NBC_monophasic_v1.csv +1281 -0
  29. pynibs/data/neuron/models/L4_SBC_biphasic_v1.csv +1281 -0
  30. pynibs/data/neuron/models/L4_SBC_monophasic_v1.csv +1281 -0
  31. pynibs/data/neuron/models/L5_TTPC2_cADpyr_biphasic_v1.csv +1281 -0
  32. pynibs/data/neuron/models/L5_TTPC2_cADpyr_monophasic_v1.csv +1281 -0
  33. pynibs/expio/Mep.py +1518 -0
  34. pynibs/expio/__init__.py +8 -0
  35. pynibs/expio/brainsight.py +979 -0
  36. pynibs/expio/brainvis.py +71 -0
  37. pynibs/expio/cobot.py +239 -0
  38. pynibs/expio/exp.py +1876 -0
  39. pynibs/expio/fit_funs.py +287 -0
  40. pynibs/expio/localite.py +1987 -0
  41. pynibs/expio/signal_ced.py +51 -0
  42. pynibs/expio/visor.py +624 -0
  43. pynibs/freesurfer.py +502 -0
  44. pynibs/hdf5_io/__init__.py +10 -0
  45. pynibs/hdf5_io/hdf5_io.py +1857 -0
  46. pynibs/hdf5_io/xdmf.py +1542 -0
  47. pynibs/mesh/__init__.py +3 -0
  48. pynibs/mesh/mesh_struct.py +1394 -0
  49. pynibs/mesh/transformations.py +866 -0
  50. pynibs/mesh/utils.py +1103 -0
  51. pynibs/models/_TMS.py +211 -0
  52. pynibs/models/__init__.py +0 -0
  53. pynibs/muap.py +392 -0
  54. pynibs/neuron/__init__.py +2 -0
  55. pynibs/neuron/neuron_regression.py +284 -0
  56. pynibs/neuron/util.py +58 -0
  57. pynibs/optimization/__init__.py +5 -0
  58. pynibs/optimization/multichannel.py +278 -0
  59. pynibs/optimization/opt_mep.py +152 -0
  60. pynibs/optimization/optimization.py +1445 -0
  61. pynibs/optimization/workhorses.py +698 -0
  62. pynibs/pckg/__init__.py +0 -0
  63. pynibs/pckg/biosig/biosig4c++-1.9.5.src_fixed.tar.gz +0 -0
  64. pynibs/pckg/libeep/__init__.py +0 -0
  65. pynibs/pckg/libeep/pyeep.so +0 -0
  66. pynibs/regression/__init__.py +11 -0
  67. pynibs/regression/dual_node_detection.py +2375 -0
  68. pynibs/regression/regression.py +2984 -0
  69. pynibs/regression/score_types.py +0 -0
  70. pynibs/roi/__init__.py +2 -0
  71. pynibs/roi/roi.py +895 -0
  72. pynibs/roi/roi_structs.py +1233 -0
  73. pynibs/subject.py +1009 -0
  74. pynibs/tensor_scaling.py +144 -0
  75. pynibs/tests/data/InstrumentMarker20200225163611937.xml +19 -0
  76. pynibs/tests/data/TriggerMarkers_Coil0_20200225163443682.xml +14 -0
  77. pynibs/tests/data/TriggerMarkers_Coil1_20200225170337572.xml +6373 -0
  78. pynibs/tests/data/Xdmf.dtd +89 -0
  79. pynibs/tests/data/brainsight_niiImage_nifticoord.txt +145 -0
  80. pynibs/tests/data/brainsight_niiImage_nifticoord_largefile.txt +1434 -0
  81. pynibs/tests/data/brainsight_niiImage_niifticoord_mixedtargets.txt +47 -0
  82. pynibs/tests/data/create_subject_testsub.py +332 -0
  83. pynibs/tests/data/data.hdf5 +0 -0
  84. pynibs/tests/data/geo.hdf5 +0 -0
  85. pynibs/tests/test_coil.py +474 -0
  86. pynibs/tests/test_elements2nodes.py +100 -0
  87. pynibs/tests/test_hdf5_io/test_xdmf.py +61 -0
  88. pynibs/tests/test_mesh_transformations.py +123 -0
  89. pynibs/tests/test_mesh_utils.py +143 -0
  90. pynibs/tests/test_nnav_imports.py +101 -0
  91. pynibs/tests/test_quality_measures.py +117 -0
  92. pynibs/tests/test_regressdata.py +289 -0
  93. pynibs/tests/test_roi.py +17 -0
  94. pynibs/tests/test_rotations.py +86 -0
  95. pynibs/tests/test_subject.py +71 -0
  96. pynibs/tests/test_util.py +24 -0
  97. pynibs/tms_pulse.py +34 -0
  98. pynibs/util/__init__.py +4 -0
  99. pynibs/util/dosing.py +233 -0
  100. pynibs/util/quality_measures.py +562 -0
  101. pynibs/util/rotations.py +340 -0
  102. pynibs/util/simnibs.py +763 -0
  103. pynibs/util/util.py +727 -0
  104. pynibs/visualization/__init__.py +2 -0
  105. pynibs/visualization/para.py +4372 -0
  106. pynibs/visualization/plot_2D.py +137 -0
  107. pynibs/visualization/render_3D.py +347 -0
pynibs/expio/Mep.py ADDED
@@ -0,0 +1,1518 @@
1
+ import re
2
+ import json
3
+ import pylab
4
+ import warnings
5
+ import datetime
6
+ import numpy as np
7
+ import pandas as pd
8
+ from lmfit import Model
9
+ import scipy.io as spio
10
+ from scipy.signal import butter, lfilter
11
+ import matplotlib.pyplot as plt
12
+
13
+ import pynibs
14
+
15
+ from pynibs.expio.fit_funs import sigmoid, sigmoid4_log, linear, exp, exp0
16
+
17
+ try:
18
+ import biosig
19
+ except ImportError:
20
+ # print("WARNING: Please install biosig from pynibs/pkg/biosig folder!")
21
+ pass
22
+ np.seterr(over="ignore")
23
+
24
+
25
+ def get_mep_virtual_subject_TVS(x, p1=-5.0818, p2=-2.4677, p3=3.6466, p4=0.42639, p5=1.6665,
26
+ mu_y_add=10 ** -5.0818, mu_y_mult=-0.9645334, mu_x_add=0.68827324,
27
+ sigma_y_add=1.4739 * 1e-6, k=0.39316, sigma2_y_mult=2.2759 * 1e-2,
28
+ sigma2_x_add=2.3671 * 1e-2,
29
+ subject_variability=False, trial_variability=True):
30
+ print("get_mep_virtual_subject_TVS() got renamed to get_virtual_mep_tvs()")
31
+ raise NotImplementedError
32
+
33
+
34
+ def get_virtual_mep_tvs(x, p1=-5.0818, p2=-2.4677, p3=3.6466, p4=0.42639, p5=1.6665,
35
+ mu_y_add=10 ** -5.0818, mu_y_mult=-0.9645334, mu_x_add=0.68827324,
36
+ sigma_y_add=1.4739 * 1e-6, k=0.39316, sigma2_y_mult=2.2759 * 1e-2,
37
+ sigma2_x_add=2.3671 * 1e-2,
38
+ subject_variability=False, trial_variability=True):
39
+ """
40
+ Creates random mep data using the 3 variability source model from [1]_.
41
+ There are typos in the paper but the code seems to be correct.
42
+ Originally from S. Goetz: https://github.com/sgoetzduke/Statistical-MEP-Model .
43
+ Rewritten from MATLAB to Python by Konstantin Weise.
44
+
45
+ Parameters
46
+ ----------
47
+ x : np.ndarray of float
48
+ (n_x) Normalized stimulator intensities [0 ... 1].
49
+ p1 : float, default: -5.0818
50
+ First parameter of sigmoidal hilltype function.
51
+ p2 : float, default: 4.5323
52
+ Second parameter of sigmoidal hilltype function.
53
+ p3 : float, default: 3.6466
54
+ Third parameter of sigmoidal hilltype function.
55
+ p4 : float, default: 0.42639
56
+ Fourth parameter of sigmoidal hilltype function.
57
+ p5 : float, default: 1.6665
58
+ Fifth parameter of sigmoidal hilltype function.
59
+ mu_y_add : float, default: 10**-5.0818
60
+ Mean value of additive y variability source.
61
+ mu_y_mult : float, default: -0.9645334
62
+ Mean value of multiplicative y variability source.
63
+ mu_x_add : float, default: -0.68827324
64
+ Mean value of additive x variability source.
65
+ sigma_y_add : float, default: 1.4739*1e-6
66
+ Standard deviation of additive y variability source.
67
+ k : float, default: 0.39316
68
+ Shape parameter of generalized extreme value distribution.
69
+ sigma2_y_mult : float, default: 2.2759*1e-2
70
+ Standard deviation of multiplicative y variability source.
71
+ sigma2_x_add : float, default: 2.3671*1e-2
72
+ Standard deviation of additive x variability source.
73
+ subject_variability : bool, default: False
74
+ Choose if shape parameters from IO curve are sampled from random distributions to model subject variability.
75
+ This does not influence the trial-to-trial variability.
76
+ trial_variability : bool, default: True
77
+ Enable or disable trial-to-trial variability. Disabling it will result in ideal IO curves w/o noise.
78
+
79
+ Returns
80
+ -------
81
+ mep : np.ndarray of float
82
+ (n_x) Motor evoked potential values in mV.
83
+
84
+ References
85
+ ----------
86
+ .. [1] Goetz, S. M., Alavi, S. M., Deng, Z. D., & Peterchev, A. V. (2019).
87
+ Statistical Model of Motor-Evoked Potentials.
88
+ IEEE Transactions on Neural Systems and Rehabilitation Engineering, 27(8), 1539-1545.
89
+ """
90
+ x = x * 100
91
+ n_x = len(x)
92
+ mu_y_add = 10 ** p1
93
+
94
+ # determine variabilities
95
+ if trial_variability:
96
+ e_y_mult = 10 ** np.random.normal(loc=mu_y_mult, scale=np.sqrt(sigma2_y_mult), size=n_x)
97
+ e_x_add = 10 ** np.random.normal(loc=mu_x_add, scale=np.sqrt(sigma2_x_add), size=n_x)
98
+
99
+ # determine generalized value distribution of additive y variability
100
+ p_e_y_add = pynibs.generalized_extreme_value_distribution(x=np.linspace(5e-6, 1e-4, 100000),
101
+ mu=mu_y_add, sigma=sigma_y_add, k=k)
102
+
103
+ # sample from generalized value distribution to determine additive y variability
104
+ e_y_add = np.random.choice(np.linspace(5e-6, 1e-4, 100000), p=p_e_y_add / np.sum(p_e_y_add), size=n_x)
105
+
106
+ else:
107
+ e_y_mult = np.ones(n_x) * 10 ** mu_y_mult
108
+ e_x_add = np.ones(n_x) * 10 ** mu_x_add
109
+ e_y_add = np.ones(n_x) * mu_y_add
110
+
111
+ x_prime = 2.224 * 1e-16 + x - 10 ** p5 + e_x_add
112
+ x_prime[x_prime < 0] = 2.224 * 1e-16
113
+
114
+ mep = e_y_add + np.exp(np.log(10) * (e_y_mult - 7 + (p2 + 7) / (1 + (10 ** p3) / (x_prime ** (10 ** p4)))))
115
+ mep = mep * 1000
116
+
117
+ return mep
118
+
119
+
120
+ def get_mep_virtual_subject_DVS(x, x0=0.5, r=10, amp=1, sigma_x=0, sigma_y=0, y0=1e-2, seed=None, rng=None):
121
+ """
122
+ Creates random mep data using the 2 variability source model from Goetz et al. 2014 [1]_ together with a standard 3
123
+ parametric sigmoid function.
124
+
125
+ Parameters
126
+ ----------
127
+ x : np.ndarray of float
128
+ (n_x) Normalized stimulator intensities [0 ... 1].
129
+ x0 : float, default: 0.5
130
+ Location of turning point sigmoidal function.
131
+ r : float, default: 0.25
132
+ Steepness of sigmoidal function.
133
+ amp : float, default: 1.0
134
+ Saturation amplitude of sigmoidal function.
135
+ sigma_x : float, default: 0.1
136
+ Standard deviation of additive x variability source.
137
+ sigma_y : float, default: 0.1
138
+ Standard deviation of additive y variability source.
139
+ y0 : float, default: 1e-2
140
+ y-offset.
141
+ seed : int, optional
142
+ Seed to use.
143
+ rng : numpy._generator.Generator, optional
144
+ An already initialized random number generator that will be used instead of intializing a new one with seed.
145
+
146
+ Returns
147
+ -------
148
+ mep : np.ndarray of float
149
+ (n_x) Motor evoked potential values
150
+
151
+ References
152
+ ----------
153
+ .. [1] Goetz, S. M., Luber, B., Lisanby, S. H., & Peterchev, A. V. (2014).
154
+ A novel model incorporating two variability sources for describing motor evoked potentials.
155
+ Brain stimulation, 7(4), 541-552.
156
+ """
157
+ if not isinstance(x, np.ndarray):
158
+ x = np.atleast_1d(x)
159
+ if type(rng) is np.random._generator.Generator:
160
+ default_rng = rng
161
+ else:
162
+ if type(rng) is not type(None):
163
+ print("[get_mep_virtual_subject_DVS] Invalid argument: get_mep_virtual_subject_DVS. Using numpy default_rng"
164
+ f" with seed={seed}")
165
+
166
+ default_rng = np.random.default_rng(seed=seed)
167
+
168
+ if not isinstance(x, np.ndarray):
169
+ x = np.array([x])
170
+
171
+ n_x = len(x)
172
+
173
+ if sigma_x > 0:
174
+ e_x_add = default_rng.normal(loc=0, scale=sigma_x, size=n_x)
175
+ else:
176
+ e_x_add = np.zeros(n_x)
177
+
178
+ if sigma_y > 0:
179
+ e_y_add = default_rng.normal(loc=0, scale=sigma_y, size=n_x)
180
+ else:
181
+ e_y_add = np.zeros(n_x)
182
+
183
+ x_pert = x + e_x_add
184
+
185
+ mep = 10 ** (sigmoid4_log(x=x_pert, x0=x0, r=r, amp=amp, y0=y0) + e_y_add)
186
+
187
+ return mep
188
+
189
+
190
+ class Mep:
191
+ """
192
+ Mep object.
193
+
194
+ Attributes
195
+ ----------
196
+ fun : function
197
+ Function type to fit data with (``pynibs.sigmoid`` / ``pynibs.exp`` / ``pynibs.linear``).
198
+ fun_sig : function
199
+ Best fitting equivalent sigmoidal function (added by ``self.add_sigmoidal_bestfit()``).
200
+ popt : np.ndarray of float
201
+ (N_para) Fitted optimal function parameters.
202
+ popt_sig : np.ndarray of float
203
+ (3) Best fitting parameters ``x0``, ``r``, and ``amp`` of equivalent sigmoidal function.
204
+ copt : np.ndarray of float
205
+ (N_para, N_para) covariance matrix of fitted parameters.
206
+ pstd : np.ndarray of float
207
+ (N_para) Standard deviation of fitted parameters.
208
+ fit : object instance
209
+ Gmodel object instance of parameter fit.
210
+ x_limits : np.ndarray of float
211
+ (2) Minimal and maximal value of intensity data.
212
+ y_limits : np.ndarray of float
213
+ (2) Minimal and maximal value of mep data.
214
+ mt : float
215
+ Motor threshold (MEP > 50 uV), evaluated from fitted curve, added after fitting.
216
+ """
217
+
218
+ # TODO: implement list of already fitted function types and their indices in self.fit[] for plot function. \
219
+ # condition name as attribute
220
+ def __init__(self, intensities, mep, intensity_min_threshold=None, mep_min_threshold=None):
221
+ """
222
+ Initialize ``Mep`` object.
223
+
224
+ Parameters
225
+ ----------
226
+ intensities : np.ndarray of float
227
+ (N_mep) Intensities of TMS stimulator corresponding to measured MEP amplitudes.
228
+ mep : np.ndarray of float
229
+ (N_mep) MEP amplitudes in [V] measured during TMS.
230
+ intensity_min_threshold : float, optional
231
+ Minimum user defined intensity (values below it will be filtered out).
232
+ mep_min_threshold : float, optional
233
+ Minimum user defined MEP amplitude (values below it will be filtered out).
234
+ """
235
+
236
+ self.intensities_orig = intensities
237
+ self.mep_orig = mep
238
+ self.cvar = []
239
+ self.popt = []
240
+ self.pstd = []
241
+ self.fun = sigmoid
242
+ self.fun_sig = sigmoid
243
+ self.popt_sig = []
244
+ self.fit = []
245
+ self.best_fit_idx = None
246
+ self.mt = None
247
+ self.constraints = None
248
+
249
+ if intensity_min_threshold is None:
250
+ intensity_min_threshold = -np.inf
251
+
252
+ if mep_min_threshold is None:
253
+ mep_min_threshold = -np.inf
254
+
255
+ # filter negative meps or too small intensities
256
+ mmask = np.where(self.mep_orig >= mep_min_threshold)
257
+ self.intensities = self.intensities_orig[mmask] # + np.random.randn(intensities.size)*1e-6
258
+ self.mep = self.mep_orig[mmask]
259
+
260
+ imask = np.where(self.intensities >= intensity_min_threshold)
261
+ self.intensities = self.intensities[imask] # + np.random.randn(intensities.size)*1e-6
262
+ self.mep = self.mep[imask]
263
+
264
+ self.x_limits = np.array([np.min(self.intensities), np.max(self.intensities)])
265
+ self.y_limits = np.array([np.min(self.mep), np.max(self.mep)])
266
+
267
+ # sort data in ascending order
268
+ idx_sort = np.argsort(self.intensities)
269
+ self.intensities = self.intensities[idx_sort]
270
+ self.mep = self.mep[idx_sort]
271
+
272
+ def fit_mep_to_function(self, p0=None):
273
+ """
274
+ Fits MEP data to function. The algorithm tries to fit the function first to a sigmoid, then to an
275
+ exponential and finally to a linear function.
276
+
277
+ Parameters
278
+ ----------
279
+ p0: np.ndarray of float, optional
280
+ Initial guess of parameter values.
281
+
282
+ Notes
283
+ -----
284
+ Additional attributes:
285
+
286
+ Mep.popt : np.ndarray of float
287
+ (N_para) Fitted optimal function parameters.
288
+ Mep.copt : np.ndarray of float
289
+ (N_para, N_para) Covariance matrix of fitted parameters.
290
+ Mep.pstd : np.ndarray of float
291
+ (N_para) Standard deviation of fitted parameters.
292
+ Mep.fun : function
293
+ Function mep data was fitted with.
294
+ Mep.fit : object instance
295
+ Gmodel object instance of parameter fit.
296
+ Mep.mt : float
297
+ Motor threshold (MEP > 50 uV).
298
+ """
299
+ if p0 is None:
300
+ p0 = []
301
+
302
+ delta = 0.4
303
+
304
+ try_to_fit = True
305
+ i_try = 1
306
+ while try_to_fit:
307
+ # unsuccessful fit
308
+ if i_try == 4:
309
+ break
310
+
311
+ if i_try == 1:
312
+ self.fun = sigmoid
313
+ gmodel = Model(self.fun)
314
+ self.fit = gmodel.fit(self.mep, x=self.intensities, x0=p0[0], r=p0[1], amp=p0[2])
315
+
316
+ if i_try == 2:
317
+ self.fun = exp
318
+ gmodel = Model(self.fun)
319
+ self.fit = gmodel.fit(self.mep, x=self.intensities, x0=p0[0], r=p0[1], y0=p0[2])
320
+
321
+ if i_try == 3:
322
+ self.fun = linear
323
+ gmodel = Model(self.fun)
324
+ self.fit = gmodel.fit(self.mep,
325
+ x=self.intensities,
326
+ m=(np.max(self.mep) - np.min(self.mep)) /
327
+ (np.max(self.intensities) - np.min(self.intensities)),
328
+ n=0)
329
+ i_try = i_try + 1
330
+
331
+ print('Fitting data to {} function ...'.format(str(self.fun.__name__)))
332
+
333
+ # filter out unseccussful fit
334
+ if self.fit.covar is None:
335
+ i_try = i_try + 1
336
+ print('Unsuccessful fit ... trying next function!')
337
+ continue
338
+
339
+ # get names of arguments of function
340
+ argnames = self.fun.__code__.co_varnames[1:self.fun.__code__.co_argcount]
341
+
342
+ # read out optimal function parameters
343
+ self.popt = []
344
+ for i in range(len(argnames)):
345
+ self.popt.append(self.fit.best_values[argnames[i]])
346
+
347
+ self.popt = np.asarray(self.popt)
348
+ self.cvar = self.fit.covar
349
+ self.pstd = np.sqrt(np.diag(self.cvar))
350
+
351
+ # re-fit if std of any parameter is > delta
352
+ if ((self.pstd / self.popt) > delta).any() and i_try < 3:
353
+ print(f'Unsuccessful fit ... relative STD of one parameter is > {delta * 100}% ... '
354
+ f'trying next function!')
355
+ i_try = i_try + 1
356
+ continue
357
+
358
+ # successful fit
359
+ if ((self.pstd / self.popt) <= delta).all():
360
+ break
361
+
362
+ # determine motor threshold considering a threshold of 50 uV
363
+ self.calc_motor_threshold(threshold=0.05)
364
+
365
+ def fit_mep_to_function_multistart(self, p0=None, constraints=None, fun=None):
366
+ """
367
+ Fits MEP data to function.
368
+
369
+ Parameters
370
+ ----------
371
+ p0 : np.ndarray of float, optional
372
+ Initial guess of parameter values.
373
+ constraints : dict, optional
374
+ Dictionary with parameter names as keys and [min, max] values as constraints.
375
+ fun : list of functions, optional
376
+ Functions to incorporate in the multistart fit (e.g.
377
+ [``pynibs.sigmoid``, ``pynibs.exp0``, ``pynibs.linear``]).
378
+
379
+ Notes
380
+ -----
381
+ Add Attributes:
382
+
383
+ Mep.popt : np.ndarray of float
384
+ (N_para) Best fitted optimal function parameters.
385
+ Mep.copt : np.ndarray of float
386
+ (N_para, N_para) Covariance matrix of best fitted parameters.
387
+ Mep.pstd : np.ndarray of float
388
+ (N_para) Standard deviation of best fitted parameters.
389
+ Mep.fun : function
390
+ Function of best fit mep data was fitted with.
391
+ Mep.fit : list of fit object instances
392
+ Gmodel object instances of parameter fits.
393
+ Mep.best_fit_idx : int
394
+ Index of best function fit (``fit[best_fit_idx]``).
395
+ Mep.constraints : dict
396
+ Dictionary with parameter names as keys and [min, max] values as constraints.
397
+ """
398
+ if fun is None:
399
+ fun = [sigmoid, exp0, linear]
400
+ if p0 is None:
401
+ p0 = []
402
+
403
+ self.constraints = constraints
404
+
405
+ self.fit = []
406
+ p0_passed = []
407
+ argnames = []
408
+
409
+ # fun = [sigmoid, exp0, linear]
410
+ # # fun = [sigmoid]
411
+ pstd_mean = np.zeros(len(fun))
412
+ aic = np.zeros(len(fun))
413
+
414
+ for i in range(len(fun)):
415
+ if fun[i] == sigmoid:
416
+ p0_passed = p0
417
+ elif fun[i] == exp:
418
+ p0_passed = p0 # (p0[0], p0[1])
419
+ elif fun[i] == exp0:
420
+ p0_passed = (p0[0], p0[1])
421
+ elif fun[i] == linear:
422
+ p0_passed = ((np.max(self.mep) - np.min(self.mep)) /
423
+ (np.max(self.intensities) - np.min(self.intensities)),
424
+ 1)
425
+
426
+ print('Fitting data to {} function ...'.format(str(fun[i].__name__)))
427
+ self.fit.append(self.run_fit_multistart(fun[i],
428
+ x=self.intensities,
429
+ y=self.mep,
430
+ p0=p0_passed,
431
+ constraints=constraints))
432
+
433
+ if self.fit[i] is not None:
434
+ if self.fit[i].covar is not None:
435
+ pstd_mean[i] = np.mean(np.sqrt(np.diag(self.fit[i].covar)))
436
+ else:
437
+ pstd_mean[i] = None
438
+
439
+ aic[i] = self.fit[i].aic
440
+ print("\t> found optimal parameters")
441
+ else:
442
+ pstd_mean[i] = None
443
+ aic[i] = np.inf
444
+ print("\t> failed")
445
+
446
+ # get names of arguments of function
447
+ argnames.append(fun[i].__code__.co_varnames[1:fun[i].__code__.co_argcount])
448
+
449
+ # determine best model by aic (Akaike information criterion)
450
+ self.best_fit_idx = int(np.argmin(aic))
451
+
452
+ print("====================================")
453
+ print("> Best fit: {} function".format(str(fun[self.best_fit_idx].__name__)))
454
+ print("\n")
455
+
456
+ # read out optimal function parameters from best fit
457
+ self.popt = []
458
+ for i in range(len(argnames[self.best_fit_idx])):
459
+ self.popt.append(self.fit[self.best_fit_idx].best_values[argnames[self.best_fit_idx][i]])
460
+
461
+ self.popt = np.asarray(self.popt)
462
+ self.cvar = np.asarray(self.fit[self.best_fit_idx].covar)
463
+ self.pstd = np.sqrt(np.diag(self.cvar))
464
+ self.fun = fun[self.best_fit_idx]
465
+
466
+ # determine motor threshold considering a threshold of 50 uV
467
+ self.calc_motor_threshold(threshold=0.05)
468
+
469
+ def run_fit_multistart(self, fun, x, y, p0, constraints=None, verbose=False, n_multistart=100):
470
+ """
471
+ Run multistart approach to fit data to function. ``n_multistart`` optimizations are performed based on random
472
+ variations of the initial guess parameters ``p0``. The fit with the lowest AIC (Akaike information criterion),
473
+ i.e. best fit is returned as gmodel fit instance.
474
+
475
+ Parameters
476
+ ----------
477
+ fun : function
478
+ Function mep data has to be fitted with.
479
+ x : np.ndarray of float
480
+ (N_data) Independent variable the data is fitted on.
481
+ y : np.ndarray of float
482
+ (N_data) Dependent data the curve is fitted through.
483
+ p0 : np.ndarray of float or list of float
484
+ Initial guess of parameter values.
485
+ constraints : dict, optional
486
+ Dictionary with parameter names as keys and [min, max] values as constraints.
487
+ verbose : bool, default: False
488
+ Show output messages.
489
+ n_multistart : int, default: 100
490
+ Number of repeated optimizations with different starting points to perform.
491
+
492
+ Returns
493
+ -------
494
+ fit : object instance
495
+ Gmodel object instance of best parameter fit with lowest parameter variance.
496
+ """
497
+
498
+ argnames = fun.__code__.co_varnames[1:fun.__code__.co_argcount]
499
+ gmodel = Model(fun)
500
+
501
+ if constraints:
502
+ for i in range(len(list(constraints.keys()))):
503
+ gmodel.set_param_hint(list(constraints.keys())[i],
504
+ value=(constraints[list(constraints.keys())[i]][0] +
505
+ constraints[list(constraints.keys())[i]][1]) / 2,
506
+ min=constraints[list(constraints.keys())[i]][0],
507
+ max=constraints[list(constraints.keys())[i]][1])
508
+
509
+ n_p = len(argnames)
510
+ fit = []
511
+
512
+ # run n_s curve fits with random starting points
513
+ for i in range(n_multistart):
514
+
515
+ params = dict()
516
+
517
+ # generate random samples of between constraint min and max or start points between 0 ... 2*p0
518
+ for j in range(n_p):
519
+ if constraints and argnames[j] in list(constraints.keys()):
520
+ params[argnames[j]] = np.random.random_sample(1) \
521
+ * (constraints[argnames[j]][1] - constraints[argnames[j]][0]) \
522
+ + constraints[argnames[j]][0]
523
+ else:
524
+ params[argnames[j]] = p0[j] * (1 + (2 * np.random.random_sample(1) - 1))
525
+
526
+ # set underflow and overflow warnings to exceptions, to be able to catch them
527
+ old_np_settings = np.seterr(under='raise', over='raise')
528
+ try:
529
+ if fun == sigmoid:
530
+ fit.append(gmodel.fit(y, x=x, x0=params['x0'], r=params['r'], amp=params['amp'], verbose=False))
531
+ if fun == exp:
532
+ fit.append(gmodel.fit(y, x=x, x0=params['x0'], r=params['r'], y0=params['y0'], verbose=False))
533
+ if fun == exp0:
534
+ fit.append(gmodel.fit(y, x=x, x0=params['x0'], r=params['r'], verbose=False))
535
+ if fun == linear:
536
+ fit.append(gmodel.fit(y, x=x, m=params['m'], n=params['n'], verbose=False))
537
+
538
+ except (ValueError, FloatingPointError):
539
+ if verbose:
540
+ print('\t Single fit failed in multistart optimization ... skipping value!')
541
+ fit.append(None)
542
+
543
+ # reset warnings to previous state
544
+ np.seterr(**old_np_settings)
545
+
546
+ # select best fit with lowest aic (Akaike information criterion) and return it
547
+ pstd = []
548
+ aic = []
549
+ for i in range(n_multistart):
550
+ if fit[i] is not None:
551
+ if fit[i].covar is None:
552
+ pstd.append(float('Inf'))
553
+ aic.append(float('Inf'))
554
+ else:
555
+ pstd.append(np.mean(np.sqrt(np.diag(fit[i].covar))))
556
+ aic.append(fit[i].aic)
557
+ else:
558
+ pstd.append(float('Inf'))
559
+ aic.append(float('Inf'))
560
+ idx = int(np.argmin(np.asarray(aic)))
561
+
562
+ return fit[idx]
563
+
564
+ def calc_motor_threshold(self, threshold):
565
+ """
566
+ Determine motor threshold of stimulator depending on MEP threshold given in [mV].
567
+
568
+ Parameters
569
+ ----------
570
+ threshold : float
571
+ Threshold of MEP amplitude in [mV].
572
+
573
+ Notes
574
+ -----
575
+ Add Attributes:
576
+
577
+ Mep.mt : float
578
+ Motor threshold for given MEP threshold.
579
+ """
580
+
581
+ self.mt = np.nan
582
+
583
+ # sample MEP curve very fine in given range
584
+ intensities = np.linspace(self.x_limits[0], self.x_limits[1], 1000)
585
+ mep_curve = self.eval_opt(intensities)
586
+ i_threshold_idx = np.where(mep_curve > threshold)[0]
587
+
588
+ if i_threshold_idx.any():
589
+ self.mt = intensities[i_threshold_idx[0]]
590
+
591
+ def plot(self, label, sigma=3, plot_samples=True, show_plot=False, fname_plot='', ylim=None, ylabel=None,
592
+ fontsize_axis=10, fontsize_legend=10, fontsize_label=10, fontsize_title=10, fun=None):
593
+ """
594
+ Plotting mep data and fitted curve together with uncertainties.
595
+ If ``fun == None`, the optimal function is plotted.
596
+
597
+ Parameters
598
+ ----------
599
+ label : str
600
+ Plot title.
601
+ sigma : float, default: 3
602
+ Factor of standard deviations the uncertainty of the fit is plotted with.
603
+ plot_samples : bool, default: True
604
+ Plot sampling curves of the fit in the uncertainty interval.
605
+ show_plot : bool, default: False
606
+ Show or hide plot window (``TRUE`` / ``FALSE``).
607
+ fname_plot : str, default: ''
608
+ Filename of plot showing fitted data (with .png extension).
609
+ ylim : list of float, optional
610
+ (2) Min and max values of y-axis.
611
+ fontsize_axis : int, default: 10
612
+ Fontsize of axis numbers.
613
+ fontsize_legend : int, default: 10
614
+ Fontsize of Legend labels.
615
+ fontsize_label : int, default: 10
616
+ Fontsize of axis labels.
617
+ fontsize_title : int, default: 10
618
+ Fontsize of title.
619
+ fun : str, default: None
620
+ Which function to plot (None, ``sigmoid``, ``exp``, ``linear``).
621
+
622
+ Returns
623
+ -------
624
+ <File> : .png file
625
+ Plot of Mep data and fit (format: png).
626
+ """
627
+ p = []
628
+ if show_plot or fname_plot:
629
+ x_range = np.max(self.intensities) - np.min(self.intensities)
630
+ x = np.linspace(np.min(self.intensities) - 0.0 * x_range, np.max(self.intensities) + 0.0 * x_range, 100)
631
+
632
+ # plot random sampling curves
633
+ if plot_samples:
634
+ n_s = 100
635
+ params = np.random.randn(n_s, self.popt.shape[0]) * self.pstd + self.popt
636
+ for i in range(n_s):
637
+ pylab.plot(x, self.fun(x, *params[i, :]), 'k', linewidth=0.25)
638
+
639
+ # plot raw data
640
+ p.append(pylab.plot(self.intensities, self.mep, 'o', label='data', markersize=3))
641
+
642
+ # plot fit if present
643
+ if self.fit:
644
+ if fun is not None:
645
+ assert fun in ['sigmoid', 'exp0', 'linear']
646
+ # assert order of self.fit is ['sigmoid', 'linear', 'exp0']
647
+ if fun == 'sigmoid':
648
+ argnames = sigmoid.__code__.co_varnames[1:sigmoid.__code__.co_argcount]
649
+ opt = []
650
+ for i in range(len(argnames)):
651
+ opt.append(self.fit[0].best_values[argnames[i]])
652
+
653
+ opt = np.asarray(opt)
654
+ y = sigmoid(x, *opt)
655
+ elif fun == 'exp0':
656
+ argnames = exp0.__code__.co_varnames[1:exp0.__code__.co_argcount]
657
+ opt = []
658
+ for i in range(len(argnames)):
659
+ opt.append(self.fit[1].best_values[argnames[i]])
660
+
661
+ opt = np.asarray(opt)
662
+ y = exp0(x, *opt)
663
+ elif fun == 'linear':
664
+ argnames = linear.__code__.co_varnames[1:linear.__code__.co_argcount]
665
+ opt = []
666
+ for i in range(len(argnames)):
667
+ opt.append(self.fit[2].best_values[argnames[i]])
668
+
669
+ opt = np.asarray(opt)
670
+ y = linear(x, *opt)
671
+ else:
672
+ raise ValueError("{} is unknown. One of [None, 'sigmoid', 'exp', 'linear'")
673
+ else:
674
+ y = self.fun(x, *self.popt)
675
+ p.append(pylab.plot(x, y, 'r', label='fit ({})'.format(self.fun.__name__)))
676
+ if ylim is not None:
677
+ pylab.ylim(ylim[0], ylim[1])
678
+ pylab.title(label, size=fontsize_title)
679
+
680
+ if sigma != 0:
681
+ y_min, y_max = self.eval_uncertainties(x, sigma=sigma)
682
+ p.append(pylab.fill_between(x, y_max, y_min, facecolor=[0.8, 0.8, 0.8],
683
+ interpolate=True, label='std'))
684
+
685
+ pylab.grid(color=[0.6, 0.6, 0.6], linestyle='--', linewidth=0.25)
686
+ pylab.legend(loc=2, fontsize=fontsize_legend)
687
+ pylab.xticks(size=fontsize_axis)
688
+ pylab.yticks(size=fontsize_axis)
689
+ pylab.xlabel(r'Stimulator intensity [A/$\mu$s]', size=fontsize_label)
690
+
691
+ if ylabel is not None:
692
+ pylab.ylabel(ylabel, size=fontsize_label)
693
+
694
+ if show_plot:
695
+ pylab.show()
696
+
697
+ if fname_plot:
698
+ pylab.savefig(fname_plot, format='png', bbox_inches='tight', pad_inches=0.01 * 4, dpi=600)
699
+
700
+ pylab.close()
701
+
702
+ def eval_uncertainties(self, x, sigma=1):
703
+ """
704
+ Evaluating approximated uncertainty interval around fitted distribution.
705
+
706
+ Parameters
707
+ ----------
708
+ x : np.ndarray of float
709
+ (N_x) Function values where uncertainty is evaluated.
710
+ sigma : float, default: 1
711
+ Standard deviation of parameters taken into account when evaluating uncertainty interval.
712
+
713
+ Returns
714
+ -------
715
+ y_min : np.ndarray of float
716
+ (N_x) Lower bounds of y-values.
717
+ y_max : np.ndarray of float
718
+ (N_x) Upper bounds of y-values.
719
+ """
720
+
721
+ if not self.fit:
722
+ raise Exception('Please fit function first before evaluating uncertainties!')
723
+
724
+ p = [np.array([self.popt[i] - sigma * self.pstd[i], self.popt[i] + sigma * self.pstd[i]])
725
+ for i in range(self.popt.shape[0])]
726
+
727
+ para_combinations = pynibs.get_cartesian_product(p)
728
+
729
+ y = np.zeros((x.shape[0], para_combinations.shape[0]))
730
+
731
+ for i in range(para_combinations.shape[0]):
732
+ y[:, i] = self.eval(x, para_combinations[i, :])
733
+
734
+ y_min = np.min(y, axis=1)
735
+ y_max = np.max(y, axis=1)
736
+
737
+ return y_min, y_max
738
+
739
+ def eval_opt(self, x):
740
+ """
741
+ Evaluating fitted function with optimal parameters in points x.
742
+
743
+ Parameters
744
+ ----------
745
+ x : np.ndarray of float
746
+ (N_x) Function arguments.
747
+
748
+ Returns
749
+ -------
750
+ y : np.ndarray of float
751
+ (N_x) Function values.
752
+ """
753
+
754
+ y = self.fun(x, *self.popt)
755
+ return y
756
+
757
+ def eval(self, x, p):
758
+ """
759
+ Evaluating fitted function with optimal parameters in points x.
760
+
761
+ Parameters
762
+ ----------
763
+ x: np.ndarray of float
764
+ (N_x) Function arguments.
765
+ p: tuple of float
766
+ Function parameters.
767
+
768
+ Returns
769
+ -------
770
+ y: np.ndarray of float
771
+ (N_x) Function values.
772
+ """
773
+
774
+ y = self.fun(x, *p)
775
+ return y
776
+
777
+ def eval_fun_sig(self, x, p):
778
+ """
779
+ Evaluating optimally fitted sigmoidal function with optimal parameters in points x.
780
+
781
+ Parameters
782
+ ----------
783
+ x: np.ndarray of float
784
+ (N_x) Function arguments.
785
+ p: tuple of float
786
+ Function parameters.
787
+
788
+ Returns
789
+ -------
790
+ y: np.ndarray of float
791
+ (N_x) Function values.
792
+ """
793
+
794
+ y = self.fun_sig(x, *p)
795
+ return y
796
+
797
+
798
+ def read_biosig_emg_data(fn_data, include_first_trigger=False, type="cfs"):
799
+ """
800
+ Reads EMG data from a biosig file.
801
+
802
+ Parameters
803
+ ----------
804
+ fn_data : str
805
+ Path to the biosig file.
806
+ include_first_trigger : bool, default: False
807
+ Whether to include the first trigger event in the data (default: False).
808
+ type : str, default: 'cfs'
809
+ Type of the biosig file.
810
+
811
+ Returns
812
+ -------
813
+ emg_data : np.ndarray
814
+ (num_sweeps, num_channels, samples_per_sweep) EMG data with shape.
815
+ time_diff_list : list
816
+ Time differences between trigger events in seconds.
817
+ num_sweeps : int
818
+ Number of sweeps in the EMG data.
819
+ num_channels : int
820
+ Number of channels in the EMG data.
821
+ samples_per_sweep : int
822
+ Number of samples per sweep in the EMG data.
823
+ sampling_rate : int
824
+ Sampling rate of the EMG data.
825
+ """
826
+ try:
827
+ import biosig
828
+ except ImportError:
829
+ ImportError("Please install biosig from pynibs/pkg/biosig folder!")
830
+ return
831
+
832
+ if type == "cfs": # TODO: also move the TXT reader here?
833
+ cfs_fn = fn_data
834
+ cfs_header = json.loads(biosig.header(cfs_fn))
835
+ cfs_emg = biosig.data(cfs_fn)
836
+
837
+ num_sweeps = cfs_header["NumberOfSweeps"]
838
+ num_channels = cfs_emg.shape[1]
839
+
840
+ total_num_samples = cfs_header["NumberOfSamples"]
841
+ samples_per_sweep = int(total_num_samples / num_sweeps)
842
+ sampling_rate = int(cfs_header["Samplingrate"])
843
+
844
+ # get timestamps
845
+ tms_pulse_timedelta = datetime.timedelta()
846
+ # get hour, minute and second
847
+ time_mep_list = []
848
+ time_diff_list = []
849
+ trigger_event_idcs = []
850
+ if include_first_trigger:
851
+ trigger_event_idcs.append(0)
852
+ time_diff_list.append(0)
853
+ time_mep_list.append(
854
+ datetime.datetime.strptime(
855
+ cfs_header["EVENT"][0]["TimeStamp"], '%Y-%b-%d %H:%M:%S'
856
+ )
857
+ -
858
+ datetime.timedelta(
859
+ seconds=float(cfs_header["EVENT"][0]["POS"])
860
+ )
861
+ )
862
+
863
+ # convert time string into integer
864
+ for event in cfs_header["EVENT"]:
865
+ date = datetime.datetime.strptime(event["TimeStamp"], '%Y-%b-%d %H:%M:%S')
866
+
867
+ # we are interested in the tms pulse time, so add it to ts
868
+ date += tms_pulse_timedelta
869
+ time_mep_list.append(date)
870
+ time_diff_list.append((date - time_mep_list[0]).total_seconds())
871
+
872
+ # compute indices in data block corresponding to the events
873
+ if event["TYP"] == "0x7ffe":
874
+ trigger_event_idcs.append(
875
+ round(event["POS"] * cfs_header["Samplingrate"])
876
+ )
877
+
878
+ num_sweeps = min(num_sweeps, len(trigger_event_idcs))
879
+
880
+ emg_data = np.zeros((num_sweeps, num_channels, samples_per_sweep), dtype=np.float32)
881
+
882
+ for c_idx in range(num_channels):
883
+ # Use emg data starting from the index of the first trigger event
884
+ # assumptions:
885
+ # - after an initial offset all emg data were captured consecutively
886
+ # - the first emg data frame may be captured without an explicit TMS
887
+ # tigger (eg. by checking the "write to disk" option)
888
+ # - if we had dropouts in between the emg data block (not just at the
889
+ # beginning) we would need to adhere to the entire trigger_event_indices
890
+ # list.
891
+ emg_data[:, c_idx, :] = np.reshape(
892
+ cfs_emg[trigger_event_idcs[0]:, c_idx],
893
+ (num_sweeps, samples_per_sweep)
894
+ )
895
+
896
+ return emg_data, time_diff_list, num_sweeps, num_channels, samples_per_sweep, sampling_rate
897
+
898
+
899
+ def get_mep_elements(mep_fn, tms_pulse_time, drop_mep_idx=None, cfs_data_column=0, channels=None, time_format="delta",
900
+ plot=False, start_mep=18, end_mep=35):
901
+ """
902
+ Read EMG data from CED .cfs or .txt file and returns MEP amplitudes.
903
+
904
+ Parameters
905
+ ----------
906
+ mep_fn : string
907
+ path to .cfs-file or .txt file (Signal export).
908
+ tms_pulse_time : float
909
+ Time in [s] of TMS pulse as specified in signal.
910
+ drop_mep_idx : List of int or None, optional
911
+ Which MEPs to remove before matching.
912
+ cfs_data_column : int or list of int, default: 0
913
+ Column(s) of dataset in cfs file. +1 for .txt.
914
+ channels : list of str, optional
915
+ Channel names.
916
+ time_format : str, default: "delta"
917
+ Format of mep time stamps in time_mep_lst to return.
918
+
919
+ * ``"delta"`` returns list of datetime.timedelta in seconds.
920
+ * ``"hms"`` returns datetime.datetime(year, month, day, hour, minute, second, microsecond).
921
+
922
+ plot : bool, default: False
923
+ Plot MEPs.
924
+ start_mep : float, default: 18
925
+ Start of time frame after TMS pulse where p2p value is evaluated (in ms).
926
+ end_mep : float, default: 35
927
+ End of time frame after TMS pulse where p2p value is evaluated (in ms).
928
+
929
+ Returns
930
+ -------
931
+ p2p_array : np.ndarray of float
932
+ (N_stim) Peak to peak values of N sweeps.
933
+ time_mep_lst : list of datetime.timedelta
934
+ MEP-timestamps
935
+ mep_raw_data : np.ndarray of float
936
+ (N_channel, N_stim, N_samples) Raw (unfiltered) MEP data.
937
+ mep_filt_data : np.ndarray of float
938
+ (N_channel, N_stim, N_samples) Filtered MEP data (Butterworth lowpass filter).
939
+ time : np.ndarray of float
940
+ (N_samples) Time axis corresponding to MEP data.
941
+ mep_onset_array : np.ndarray of float
942
+ (S_samples) MEP onset after TMS pulse.
943
+ """
944
+ # convert pulse time to datetime object in case of "delta"
945
+ if time_format == "delta":
946
+ tms_pulse_timedelta = datetime.timedelta(milliseconds=tms_pulse_time * 1000)
947
+ elif time_format == "hms":
948
+ tms_pulse_timedelta = datetime.timedelta()
949
+ else:
950
+ raise NotImplementedError("Specified time_format not implemented yet...")
951
+
952
+ if mep_fn.endswith('.cfs'):
953
+ # get data from cfs file
954
+ import biosig
955
+ mep_raw_data_tmp = biosig.data(mep_fn)
956
+ mep_raw_data_tmp = mep_raw_data_tmp[:, cfs_data_column] # get first channel
957
+
958
+ # get header from cfs file
959
+ cfs_header = biosig.header(mep_fn)
960
+
961
+ # get timestamps
962
+ # get all indices of timestamps from cfs header
963
+ ts_mep_lst = [timestamp.start() for timestamp in re.finditer('TimeStamp', cfs_header)]
964
+ # get hour, minute and second
965
+ time_mep_list = []
966
+ # convert time string into integer
967
+ for index in ts_mep_lst:
968
+ hour = int(cfs_header[index + 26:index + 28])
969
+ minute = int(cfs_header[index + 29:index + 31])
970
+ second = int(cfs_header[index + 32:index + 34])
971
+ # fix bug with second 60
972
+ if second == 60:
973
+ ts = datetime.datetime(1900, 1, 1, hour, minute, 59)
974
+ ts += datetime.timedelta(seconds=1)
975
+ else:
976
+ ts = datetime.datetime(1900, 1, 1, hour, minute, second)
977
+
978
+ # we are interested in the tms pulse time, so add it to ts
979
+ ts += tms_pulse_timedelta
980
+ time_mep_list.append(ts)
981
+
982
+ if time_format == "delta":
983
+ time_mep_list = [time_mep_list[i] - time_mep_list[0] for i in range(len(time_mep_list))]
984
+ if time_format == "hms":
985
+ pass
986
+
987
+ # add first timestamp (not saved by signal) and shift other by isi
988
+ time_mep_list = [datetime.timedelta(seconds=0)] + [t + time_mep_list[1] - time_mep_list[0] for t in
989
+ time_mep_list]
990
+
991
+ # get peak-to-peak values
992
+ # get the ratio of samples per sweep
993
+ sweep_index = cfs_header.find('NumberOfSweeps')
994
+ comma_index = cfs_header.find(',', sweep_index)
995
+ n_sweeps = int(cfs_header[sweep_index + 18:comma_index])
996
+ record_index = cfs_header.find('NumberOfRecords')
997
+ comma_index = cfs_header.find(',', record_index)
998
+ records = int(cfs_header[record_index + 19:comma_index])
999
+ n_samples = int(records / n_sweeps)
1000
+ if not isinstance(n_samples, int):
1001
+ print('Warning: Number of samples is not an integer.')
1002
+ # TODO: Correct get_mep_elements() sample number check. This does not work as expected (from Ole)
1003
+
1004
+ # reshape numpy array
1005
+ mep_raw_arr = np.zeros((len(cfs_data_column), n_sweeps, n_samples))
1006
+
1007
+ for i in cfs_data_column:
1008
+ mep_raw_arr[i, :, :] = np.reshape(mep_raw_data_tmp[:, i], (n_sweeps, n_samples))
1009
+
1010
+ sampling_rate = get_mep_sampling_rate(mep_fn)
1011
+
1012
+ elif mep_fn.endswith('.mat'):
1013
+ mep_data = spio.loadmat(mep_fn, struct_as_record=False, squeeze_me=True)
1014
+
1015
+ # find data
1016
+ for k in mep_data.keys():
1017
+ if isinstance(mep_data[k], spio.matlab.mio5_params.mat_struct):
1018
+ mep_data = mep_data[k].__dict__
1019
+ break
1020
+
1021
+ n_samples = mep_data['points']
1022
+ mep_raw_arr = mep_data['values'].transpose(1, 2, 0)
1023
+ time_mep_list = [datetime.timedelta(seconds=f.__dict__['start']) for f in mep_data['frameinfo']]
1024
+ sampling_rate = get_mep_sampling_rate(mep_fn)
1025
+
1026
+ elif mep_fn.endswith('.txt'):
1027
+ warnings.warn(".txt import is deprecated - use .mat or .cfs.", DeprecationWarning)
1028
+ print("Reading MEP from .txt file")
1029
+ # The Signal text export looks like this:
1030
+ #
1031
+ # "s"\t"ADC 0"\t"ADC 1"
1032
+ # 0.00000000\t-0.066681\t-0.047607
1033
+ # 0.00025000\t-0.066376\t-0.049286
1034
+ # 0.00050000\t-0.066528\t-0.056610
1035
+ #
1036
+ # "s"\t"ADC 0"\t"ADC 1"
1037
+ # 0.00000000\t-0.066681\t-0.047607
1038
+ # 0.00025000\t-0.066376\t-0.049286
1039
+ # 0.00050000\t-0.066528\t-0.056610
1040
+ #
1041
+ # With first column = time, second = 1st electrode, ...
1042
+ # This is an example of 2 sweeps, 3 samples each, sampling rate = 4000
1043
+
1044
+ # Find number of samples per sweep
1045
+ pattern = '"s"'
1046
+ with open(mep_fn, 'r') as f:
1047
+ for line_nr, line in enumerate(f):
1048
+ print(f'{line_nr}: {line}')
1049
+ if pattern in line and line_nr > 0:
1050
+ # find second occurance of "s" -> end of first sweep
1051
+ n_samples = line_nr
1052
+ print(f'{line_nr}: {line}')
1053
+ if line != '\n':
1054
+ last_sample_time = line
1055
+
1056
+ # extract time (first column) of last samples
1057
+ last_sample_time = float(last_sample_time[0:last_sample_time.find('\t')])
1058
+
1059
+ # subtract 2 because first row is header ("s"\t"ADC 0"\t"ADC 1") and last row is blank
1060
+ n_samples = n_samples - 2
1061
+
1062
+ df_mep = pd.read_csv(mep_fn,
1063
+ delimiter="\t",
1064
+ skip_blank_lines=True,
1065
+ skiprows=lambda x: x % (n_samples + 2) == 0 and x > 0)
1066
+
1067
+ n_sweeps = int(df_mep.shape[0] / n_samples)
1068
+ mep_raw_arr = np.zeros((len(cfs_data_column), n_sweeps, n_samples))
1069
+
1070
+ for i in range(n_sweeps):
1071
+ mep_raw_arr[:, i, :] = df_mep.iloc[i * n_samples:(i + 1) * n_samples, 1:].transpose()
1072
+
1073
+ # get sampling rate by dividing number of sweeps by timing
1074
+ sampling_rate = int(mep_raw_arr.shape[2] - 1) / last_sample_time
1075
+
1076
+ # build time_mep_list
1077
+ # we only have information about the single mep timings, so let's assume signal sticks strictly to the protocol
1078
+ sample_len = last_sample_time + 1 / sampling_rate
1079
+
1080
+ # TODO: The ISI is missing here, do we want to add it to the subject object?
1081
+ time_mep_list = [datetime.timedelta(seconds=i * sample_len) +
1082
+ tms_pulse_timedelta for i in range(mep_raw_arr.shape[1])]
1083
+
1084
+ else:
1085
+ raise ValueError("Unknown MEP file extension. Use .cfs or .txt.")
1086
+
1087
+ # get peak to peak value of every sweep and plot results in mep/plots/channels
1088
+ if channels is None:
1089
+ channels = [str(i) for i in cfs_data_column]
1090
+
1091
+ tmp = np.zeros((mep_raw_arr.shape[0], mep_raw_arr.shape[1], 3)).astype(object)
1092
+ for i_channel in range(mep_raw_arr.shape[0]):
1093
+ print(f"Calculating p2p values for channel: {channels[i_channel]}")
1094
+
1095
+ for i_zap in range(mep_raw_arr.shape[1]):
1096
+ tmp[i_channel, i_zap, 0], \
1097
+ tmp[i_channel, i_zap, 1], \
1098
+ tmp[i_channel, i_zap, 2] = calc_p2p(sweep=mep_raw_arr[i_channel, i_zap, :],
1099
+ tms_pulse_time=tms_pulse_time,
1100
+ sampling_rate=sampling_rate,
1101
+ fn_plot=None,
1102
+ start_mep=start_mep,
1103
+ end_mep=end_mep)
1104
+
1105
+ p2p_arr = np.zeros((tmp.shape[0], tmp.shape[1]))
1106
+ mep_onset_arr = np.zeros((tmp.shape[0], tmp.shape[1]))
1107
+ mep_filt_arr = np.zeros(mep_raw_arr.shape)
1108
+
1109
+ time = np.arange(mep_raw_arr.shape[2]) / sampling_rate
1110
+
1111
+ for idx_channel in cfs_data_column:
1112
+ for i, t in enumerate(tmp[idx_channel, :, :]):
1113
+ p2p_arr[idx_channel, i] = tmp[idx_channel, i, 0]
1114
+ mep_onset_arr[idx_channel, i] = tmp[idx_channel, i, 2]
1115
+ mep_filt_arr[idx_channel, i, :] = tmp[idx_channel, i, 1]
1116
+
1117
+ if time_format == "delta":
1118
+ time_mep_list = [time_mep_list[i] - time_mep_list[0] for i in range(len(time_mep_list))]
1119
+ elif time_format == "hms":
1120
+ pass
1121
+
1122
+ # remove MEPs according to drop_mep_idx and reset time
1123
+ if drop_mep_idx is not None:
1124
+ p2p_arr = np.delete(p2p_arr, drop_mep_idx)
1125
+ mep_onset_arr = np.delete(mep_onset_arr, drop_mep_idx)
1126
+ time_mep_list = np.delete(time_mep_list, drop_mep_idx)
1127
+
1128
+ keep_mep_idx = [i for i in range(mep_raw_arr.shape[1]) if i not in np.array(drop_mep_idx)]
1129
+ mep_raw_arr = mep_raw_arr[:, keep_mep_idx, :]
1130
+ mep_filt_arr = mep_filt_arr[:, keep_mep_idx, :]
1131
+
1132
+ return p2p_arr, time_mep_list, mep_raw_arr, mep_filt_arr, time, mep_onset_arr
1133
+
1134
+
1135
+ def calc_p2p_old_exp0(sweep, start_mep=None, end_mep=None, tms_pulse_time=None, sampling_rate=None):
1136
+ """
1137
+ Calc peak-to-peak values of an mep sweep.
1138
+ This version was probably used in the ancient times of experiment 0.
1139
+
1140
+ Parameters
1141
+ ----------
1142
+ sweep : np.ndarray of float
1143
+ (Nx1) Input curve.
1144
+ start_mep : None
1145
+ Not used.
1146
+ end_mep : None
1147
+ Not used.
1148
+ tms_pulse_time : None
1149
+ Not used.
1150
+ sampling_rate : None
1151
+ Not used.
1152
+
1153
+ Returns
1154
+ -------
1155
+ p2p : float
1156
+ Peak-to-peak value of input curve.
1157
+ """
1158
+ warnings.warn(DeprecationWarning("Use calc_p2p(). calc_p2p_old_exp0 only used for data-reproducibility"))
1159
+
1160
+ # Filter requirements.
1161
+ order = 6
1162
+ fs = 16000 # sample rate, Hz
1163
+ cutoff = 2000 # desired cutoff frequency of the filter, Hz
1164
+
1165
+ # Get the filter coefficients so we can check its frequency response.
1166
+ # import matplotlib.pyplot as plt
1167
+ # b, a = butter_lowpass(cutoff, fs, order)
1168
+ #
1169
+ # # Plot the frequency response.
1170
+ # w, h = freqz(b, a, worN=8000)
1171
+ # plt.subplot(2, 1, 1)
1172
+ # plt.plot(0.5 * fs * w / np.pi, np.abs(h), 'b')
1173
+ # plt.plot(cutoff, 0.5 * np.sqrt(2), 'ko')
1174
+ # plt.axvline(cutoff, color='k')
1175
+ # plt.xlim(0, 0.5 * fs)
1176
+ # plt.title("Lowpass Filter Frequency Response")
1177
+ # plt.xlabel('Frequency [Hz]')
1178
+ # plt.grid()
1179
+
1180
+ sweep_filt = butter_lowpass_filter(sweep, cutoff, fs, order)
1181
+
1182
+ # get indices for max
1183
+ index_max_begin = np.argmin(sweep) + 40 # get TMS impulse # int(0.221 / 0.4 * sweep.size)
1184
+ index_max_begin = int(0.221 / 0.4 * sweep.size)
1185
+
1186
+ index_max_end = sweep_filt.size # int(0.234 / 0.4 * sweep.size) + 1
1187
+ if index_max_begin >= index_max_end:
1188
+ index_max_begin = index_max_end - 1
1189
+ # index_max_end = index_max_begin + end_mep
1190
+
1191
+ # get maximum and max index
1192
+ sweep_max = np.amax(sweep_filt[index_max_begin:index_max_end])
1193
+ sweep_max_index = index_max_begin + np.argmax(sweep_filt[index_max_begin:index_max_end])
1194
+
1195
+ # if list of indices then get last value
1196
+ if sweep_max_index.size > 1:
1197
+ sweep_max_index = sweep_max_index[0]
1198
+
1199
+ # get minimum and mix index
1200
+ index_min_begin = sweep_max_index # int(sweep_max_index + 0.002 / 0.4 * sweep_filt.size)
1201
+ index_min_end = sweep_max_index + 40 # int(sweep_max_index + 0.009 / 0.4 * sweep_filt.size) + 1
1202
+
1203
+ # Using the same window as the max should make this more robust
1204
+ # index_min_begin = index_max_begi
1205
+ sweep_min = np.amin(sweep_filt[index_min_begin:index_min_end])
1206
+
1207
+ return sweep_max - sweep_min
1208
+
1209
+
1210
+ def calc_p2p_old_exp1(sweep, start_mep=18, end_mep=35, tms_pulse_time=None, sampling_rate=2000):
1211
+ """
1212
+ Calc peak-to-peak values of an mep sweep.
1213
+ This version was probably used for the first fits of experiment 1.
1214
+
1215
+ Parameters
1216
+ ----------
1217
+ sweep : np.ndarray of float
1218
+ (Nx1) Input curve.
1219
+ start_mep: float, default: 18
1220
+ Starttime in [ms] after TMS for MEP search window.
1221
+ end_mep: float, default: 35
1222
+ Endtime in [ms] after TMS for MEP search window.
1223
+ tms_pulse_time : None
1224
+ Not used.
1225
+ sampling_rate : int, default: 2000
1226
+ Sampling rate in Hz.
1227
+
1228
+ Returns
1229
+ -------
1230
+ p2p : float
1231
+ Peak-to-peak value of input curve.
1232
+ """
1233
+ warnings.warn(DeprecationWarning("Use calc_p2p(). calc_p2p_old_exp1 only used for data-reproducibility"))
1234
+
1235
+ # Compute start and stop idx according to sampling rate
1236
+ start_mep = int((start_mep / 1000.) * sampling_rate)
1237
+ end_mep = int((end_mep / 1000.) * sampling_rate)
1238
+
1239
+ # Filter requirements.
1240
+ order = 6
1241
+ fs = 16000 # sample rate, Hz
1242
+ cutoff = 2000 # desired cutoff frequency of the filter, Hz
1243
+
1244
+ sweep_filt = butter_lowpass_filter(sweep, cutoff, fs, order)
1245
+
1246
+ # get indices for max
1247
+ index_max_begin = np.argmin(sweep) + start_mep # get TMS impulse # int(0.221 / 0.4 * sweep.size)
1248
+ if index_max_begin >= sweep_filt.size:
1249
+ index_max_begin = sweep_filt.size - 1
1250
+ # index_max_end = sweep_filt.size # int(0.234 / 0.4 * sweep.size) + 1
1251
+ index_max_end = index_max_begin + end_mep
1252
+
1253
+ # get maximum and max index
1254
+ sweep_max = np.amax(sweep_filt[index_max_begin:index_max_end])
1255
+
1256
+ # Using the same window as the max should make this more robust
1257
+ index_min_begin = index_max_begin
1258
+ index_min_end = index_max_end
1259
+ sweep_min = np.amin(sweep_filt[index_min_begin:index_min_end])
1260
+
1261
+ return sweep_max - sweep_min
1262
+
1263
+
1264
+ def calc_p2p(sweep, tms_pulse_time=.2, start_mep=20, end_mep=35, measurement_start_time=0, sampling_rate=4000,
1265
+ cutoff_freq=500, fn_plot=None):
1266
+ """
1267
+ Calc peak-to-peak values of and mep sweep.
1268
+
1269
+ Parameters
1270
+ ----------
1271
+ sweep : np.ndarray of float
1272
+ (Nx1) Input curve.
1273
+ tms_pulse_time : float, default: .2
1274
+ Onset time of TMS pulse trigger in [s].
1275
+ start_mep : int, default: 18
1276
+ Start of p2p search window after TMS pulse in [ms].
1277
+ end_mep : int, default: 35
1278
+ End of p2p search window after TMS pulse in [ms].
1279
+ measurement_start_time : float, default: 0
1280
+ Start time of the EMG measurement in [ms].
1281
+ sampling_rate : int, default: 2000
1282
+ Sampling rate in Hz.
1283
+ cutoff_freq : int, default: 500
1284
+ Desired cutoff frequency of the filter in Hz.
1285
+ fn_plot : str, optional
1286
+ Filename of sweep to plot (.png). If None, plot is omitted.
1287
+
1288
+ Returns
1289
+ -------
1290
+ p2p : float
1291
+ Peak-to-peak value of input curve.
1292
+ sweep_filt : np.ndarray of float
1293
+ Filtered input curve (Butter lowpass filter with specified cutoff_freq).
1294
+ onset : float
1295
+ MEP onset after tms_pulse_time.
1296
+ """
1297
+
1298
+ def time_to_idx_conversion(t):
1299
+ return int((t - measurement_start_time) * sampling_rate / 1000)
1300
+
1301
+ def idx_to_time_conversion(i):
1302
+ return i * 1000 / sampling_rate + measurement_start_time
1303
+
1304
+ # Compute start and stop idx according to sampling rate
1305
+ if tms_pulse_time > 1:
1306
+ warnings.warn(f"Is tms_pulse_time={tms_pulse_time} really in seconds?")
1307
+
1308
+ # Filter requirements.
1309
+ order = 6
1310
+
1311
+ sweep_filt = butter_lowpass_filter(sweep, cutoff_freq, sampling_rate, order)
1312
+
1313
+ # beginning of mep search window
1314
+ srch_win_start = time_to_idx_conversion(tms_pulse_time * 1000 + start_mep) # get TMS impulse
1315
+
1316
+ if srch_win_start >= sweep_filt.size:
1317
+ srch_win_start = sweep_filt.size - 1
1318
+ srch_win_end = time_to_idx_conversion(tms_pulse_time * 1000 + end_mep)
1319
+
1320
+ # get maximum and max index
1321
+ sweep_max = np.amax(sweep_filt[srch_win_start:srch_win_end])
1322
+
1323
+ # Using the same window as the max should make this more robust
1324
+ sweep_min = np.amin(sweep_filt[srch_win_start:srch_win_end])
1325
+ p2p = sweep_max - sweep_min
1326
+
1327
+ # find onset in [s] of mep after tms pulse
1328
+ onset_max = np.argmax(sweep[srch_win_start:srch_win_end])
1329
+ onset_min = np.argmin(sweep[srch_win_start:srch_win_end])
1330
+ if onset_min < onset_max:
1331
+ onset_max = onset_min
1332
+ onset_max += srch_win_start
1333
+ onset_max /= sampling_rate
1334
+ onset_max -= tms_pulse_time
1335
+
1336
+ if fn_plot is not None:
1337
+ timepoints = np.asarray([idx_to_time_conversion(i) for i in np.arange(len(sweep))], dtype=np.float32)
1338
+ sweep_min_idx = np.argmin(sweep_filt[srch_win_start:srch_win_end]) + srch_win_start
1339
+ sweep_max_idx = np.argmax(sweep_filt[srch_win_start:srch_win_end]) + srch_win_start
1340
+
1341
+ timepoints /= 1000
1342
+ plt.figure(figsize=[4.07, 3.52])
1343
+ plt.plot(timepoints, sweep)
1344
+ plt.plot(timepoints, sweep_filt)
1345
+ plt.scatter(timepoints[sweep_min_idx], sweep_min, 15, color="r")
1346
+ plt.scatter(timepoints[sweep_max_idx], sweep_max, 15, color="r")
1347
+ plt.plot(timepoints, np.ones(len(timepoints)) * sweep_min, linestyle="--", color="r", linewidth=1)
1348
+ plt.plot(timepoints, np.ones(len(timepoints)) * sweep_max, linestyle="--", color="r", linewidth=1)
1349
+ plt.grid()
1350
+ plt.legend(["raw", "filtered", "p2p"], loc='upper right')
1351
+
1352
+ plt.xlim([np.max((tms_pulse_time - 0.01, np.min(timepoints))),
1353
+ np.min((timepoints[srch_win_end] + .1, np.max(timepoints)))])
1354
+ plt.ylim([-1.1 * np.abs(sweep_min), 1.1 * np.abs(sweep_max)])
1355
+
1356
+ plt.xlabel("time in s", fontsize=11)
1357
+ plt.ylabel("MEP in mV", fontsize=11)
1358
+ plt.tight_layout()
1359
+
1360
+ plt.savefig(fn_plot, dpi=300, transparent=True)
1361
+ plt.close()
1362
+
1363
+ return p2p, sweep_filt, onset_max
1364
+
1365
+
1366
+ def get_mep_sampling_rate(cfs_path):
1367
+ """
1368
+ Returns sampling rate [Hz] for CED Signal EMG data in .cfs, .mat or .txt file.
1369
+
1370
+ The sampling rate is saved in the cfs header like this:
1371
+
1372
+ .. code-block:: sh
1373
+
1374
+ ``Samplingrate"\t: 3999.999810,\n``
1375
+
1376
+ Parameters
1377
+ ----------
1378
+ cfs_path : str
1379
+ Path to .cfs file or .txt file.
1380
+
1381
+ Returns
1382
+ -------
1383
+ float : sampling rate
1384
+ """
1385
+ if cfs_path.endswith('.cfs'):
1386
+ # get header from cfs file
1387
+ cfs_header = biosig.header(cfs_path)
1388
+
1389
+ # get start and end idx of 'samplingrate'
1390
+ idx_a = re.search('Samplingrate', cfs_header)
1391
+
1392
+ # get idx of first '\n' after 'samplingrate'
1393
+ idx_b = re.search(',\n', cfs_header[idx_a.end():])
1394
+
1395
+ sr_start = idx_a.end() + 4 # 'Samplingrate"\t: '
1396
+ return float(cfs_header[sr_start:idx_b.start() + idx_a.end()])
1397
+
1398
+ elif cfs_path.endswith('.mat'):
1399
+ mep_data = spio.loadmat(cfs_path, struct_as_record=False, squeeze_me=True)
1400
+ for k in mep_data.keys():
1401
+ if isinstance(mep_data[k], spio.matlab.mio5_params.mat_struct):
1402
+ mep_data = mep_data[k].__dict__
1403
+ break
1404
+ return 1 / mep_data['interval']
1405
+
1406
+ elif cfs_path.endswith('.txt'):
1407
+ # search for end of first frame / sweep
1408
+ pattern = '"s"'
1409
+ with open(cfs_path, 'r') as f:
1410
+ for line_nr, line in enumerate(f):
1411
+ if pattern in line and line_nr > 0:
1412
+ # find second occurance of "s" -> end of first sweep
1413
+ n_samples = line_nr
1414
+ break
1415
+ if line != '\n':
1416
+ last_sample_time = line
1417
+ # extract time (first column) of last samples
1418
+ last_sample_time = float(last_sample_time[0:last_sample_time.find('\t')])
1419
+
1420
+ # subtract 2 because first row is header ("s"\t"ADC 0"\t"ADC 1") and last row is blank
1421
+ n_samples -= 2
1422
+
1423
+ # get sampling rate by dividing number of sweeps by timing
1424
+ return n_samples / last_sample_time
1425
+ else:
1426
+ raise ValueError(f"cfs_path={cfs_path} must be .csf or .txt filename.")
1427
+
1428
+
1429
+ def butter_lowpass(cutoff, fs, order=5):
1430
+ """
1431
+ Setup Butter low-pass filter and return filter parameters.
1432
+
1433
+ Parameters
1434
+ ----------
1435
+ cutoff : float
1436
+ Cutoff frequency in [Hz].
1437
+ fs : float
1438
+ Sampling frequency in [Hz].
1439
+ order : int, default: 5
1440
+ Filter order.
1441
+
1442
+ Returns
1443
+ -------
1444
+ b, a : np.ndarray, np.ndarray
1445
+ Numerator (b) and denominator (a) polynomials of the IIR filter.
1446
+ """
1447
+
1448
+ nyq = 0.5 * fs
1449
+ normal_cutoff = cutoff / nyq
1450
+ b, a = butter(order, normal_cutoff, btype='low', analog=False)
1451
+ return b, a
1452
+
1453
+
1454
+ def butter_lowpass_filter(data, cutoff, fs, order=5):
1455
+ """
1456
+ Applies Butterworth lowpass filter.
1457
+
1458
+ Parameters
1459
+ ----------
1460
+ data : np.ndarray of float
1461
+ (N_samples) Input of the digital filter.
1462
+ cutoff : float
1463
+ Cutoff frequency in [Hz].
1464
+ fs : float
1465
+ Sampling frequency in [Hz].
1466
+ order : int
1467
+ Filter order.
1468
+
1469
+ Returns
1470
+ -------
1471
+ y : np.ndarray
1472
+ (N_samples) Output of the digital filter.
1473
+ """
1474
+
1475
+ b, a = butter_lowpass(cutoff, fs, order=order)
1476
+ y = lfilter(b, a, data)
1477
+ return y
1478
+
1479
+
1480
+ def get_time_date(cfs_paths):
1481
+ """
1482
+ Get time and date of the start of the recording out of .cfs file.
1483
+
1484
+ Parameters
1485
+ ----------
1486
+ cfs_paths : str
1487
+ Path to .cfs mep file.
1488
+
1489
+ Returns
1490
+ -------
1491
+ time_date : str
1492
+ Date and time.
1493
+ """
1494
+
1495
+ cfs_header = biosig.header(cfs_paths[0])
1496
+ index = cfs_header.find('StartOfRecording')
1497
+ time_date = cfs_header[index + 21:index + 40]
1498
+ return time_date
1499
+
1500
+
1501
+ def scale_e_for_dvs(e, mt=60, upper_limit=100):
1502
+ """
1503
+ Scale the electric field for DVS and TVS to 0-1 range, with 0 being motor threshold.
1504
+
1505
+ Parameters
1506
+ ----------
1507
+ e : np.array
1508
+ Electric field in V/m.
1509
+ mt : float, default: 60
1510
+ Desired motor threshold in V/m.
1511
+ upper_limit : float, default: 100
1512
+ Upper limit of the activation curve in V/m.
1513
+
1514
+ Returns
1515
+ -------
1516
+ float : scaled electric field in V/m.
1517
+ """
1518
+ return (e - mt)/(upper_limit - mt)