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.
- pyNIBS-0.2024.8.dist-info/LICENSE +623 -0
- pyNIBS-0.2024.8.dist-info/METADATA +723 -0
- pyNIBS-0.2024.8.dist-info/RECORD +107 -0
- pyNIBS-0.2024.8.dist-info/WHEEL +5 -0
- pyNIBS-0.2024.8.dist-info/top_level.txt +1 -0
- pynibs/__init__.py +34 -0
- pynibs/coil.py +1367 -0
- pynibs/congruence/__init__.py +15 -0
- pynibs/congruence/congruence.py +1108 -0
- pynibs/congruence/ext_metrics.py +257 -0
- pynibs/congruence/stimulation_threshold.py +318 -0
- pynibs/data/configuration_exp0.yaml +59 -0
- pynibs/data/configuration_linear_MEP.yaml +61 -0
- pynibs/data/configuration_linear_RT.yaml +61 -0
- pynibs/data/configuration_sigmoid4.yaml +68 -0
- pynibs/data/network mapping configuration/configuration guide.md +238 -0
- pynibs/data/network mapping configuration/configuration_TEMPLATE.yaml +42 -0
- pynibs/data/network mapping configuration/configuration_for_testing.yaml +43 -0
- pynibs/data/network mapping configuration/configuration_modelTMS.yaml +43 -0
- pynibs/data/network mapping configuration/configuration_reg_isi_05.yaml +43 -0
- pynibs/data/network mapping configuration/output_documentation.md +185 -0
- pynibs/data/network mapping configuration/recommendations_for_accuracy_threshold.md +77 -0
- pynibs/data/neuron/models/L23_PC_cADpyr_biphasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L23_PC_cADpyr_monophasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_LBC_biphasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_LBC_monophasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_NBC_biphasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_NBC_monophasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_SBC_biphasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_SBC_monophasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L5_TTPC2_cADpyr_biphasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L5_TTPC2_cADpyr_monophasic_v1.csv +1281 -0
- pynibs/expio/Mep.py +1518 -0
- pynibs/expio/__init__.py +8 -0
- pynibs/expio/brainsight.py +979 -0
- pynibs/expio/brainvis.py +71 -0
- pynibs/expio/cobot.py +239 -0
- pynibs/expio/exp.py +1876 -0
- pynibs/expio/fit_funs.py +287 -0
- pynibs/expio/localite.py +1987 -0
- pynibs/expio/signal_ced.py +51 -0
- pynibs/expio/visor.py +624 -0
- pynibs/freesurfer.py +502 -0
- pynibs/hdf5_io/__init__.py +10 -0
- pynibs/hdf5_io/hdf5_io.py +1857 -0
- pynibs/hdf5_io/xdmf.py +1542 -0
- pynibs/mesh/__init__.py +3 -0
- pynibs/mesh/mesh_struct.py +1394 -0
- pynibs/mesh/transformations.py +866 -0
- pynibs/mesh/utils.py +1103 -0
- pynibs/models/_TMS.py +211 -0
- pynibs/models/__init__.py +0 -0
- pynibs/muap.py +392 -0
- pynibs/neuron/__init__.py +2 -0
- pynibs/neuron/neuron_regression.py +284 -0
- pynibs/neuron/util.py +58 -0
- pynibs/optimization/__init__.py +5 -0
- pynibs/optimization/multichannel.py +278 -0
- pynibs/optimization/opt_mep.py +152 -0
- pynibs/optimization/optimization.py +1445 -0
- pynibs/optimization/workhorses.py +698 -0
- pynibs/pckg/__init__.py +0 -0
- pynibs/pckg/biosig/biosig4c++-1.9.5.src_fixed.tar.gz +0 -0
- pynibs/pckg/libeep/__init__.py +0 -0
- pynibs/pckg/libeep/pyeep.so +0 -0
- pynibs/regression/__init__.py +11 -0
- pynibs/regression/dual_node_detection.py +2375 -0
- pynibs/regression/regression.py +2984 -0
- pynibs/regression/score_types.py +0 -0
- pynibs/roi/__init__.py +2 -0
- pynibs/roi/roi.py +895 -0
- pynibs/roi/roi_structs.py +1233 -0
- pynibs/subject.py +1009 -0
- pynibs/tensor_scaling.py +144 -0
- pynibs/tests/data/InstrumentMarker20200225163611937.xml +19 -0
- pynibs/tests/data/TriggerMarkers_Coil0_20200225163443682.xml +14 -0
- pynibs/tests/data/TriggerMarkers_Coil1_20200225170337572.xml +6373 -0
- pynibs/tests/data/Xdmf.dtd +89 -0
- pynibs/tests/data/brainsight_niiImage_nifticoord.txt +145 -0
- pynibs/tests/data/brainsight_niiImage_nifticoord_largefile.txt +1434 -0
- pynibs/tests/data/brainsight_niiImage_niifticoord_mixedtargets.txt +47 -0
- pynibs/tests/data/create_subject_testsub.py +332 -0
- pynibs/tests/data/data.hdf5 +0 -0
- pynibs/tests/data/geo.hdf5 +0 -0
- pynibs/tests/test_coil.py +474 -0
- pynibs/tests/test_elements2nodes.py +100 -0
- pynibs/tests/test_hdf5_io/test_xdmf.py +61 -0
- pynibs/tests/test_mesh_transformations.py +123 -0
- pynibs/tests/test_mesh_utils.py +143 -0
- pynibs/tests/test_nnav_imports.py +101 -0
- pynibs/tests/test_quality_measures.py +117 -0
- pynibs/tests/test_regressdata.py +289 -0
- pynibs/tests/test_roi.py +17 -0
- pynibs/tests/test_rotations.py +86 -0
- pynibs/tests/test_subject.py +71 -0
- pynibs/tests/test_util.py +24 -0
- pynibs/tms_pulse.py +34 -0
- pynibs/util/__init__.py +4 -0
- pynibs/util/dosing.py +233 -0
- pynibs/util/quality_measures.py +562 -0
- pynibs/util/rotations.py +340 -0
- pynibs/util/simnibs.py +763 -0
- pynibs/util/util.py +727 -0
- pynibs/visualization/__init__.py +2 -0
- pynibs/visualization/para.py +4372 -0
- pynibs/visualization/plot_2D.py +137 -0
- 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)
|