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
@@ -0,0 +1,562 @@
1
+ import numpy as np
2
+ import gdist
3
+ from matplotlib import pyplot as plt
4
+ from scipy.spatial.transform import Rotation as rot
5
+
6
+ import pynibs
7
+
8
+
9
+ def geodesic_dist(nodes, tris, source, source_is_node=True):
10
+ """
11
+ Returns geodesic distance in mm from all nodes to source node (or triangle).
12
+ This is just a wrapper for the gdist package.
13
+
14
+ Example
15
+ -------
16
+
17
+ .. code-block:: python
18
+
19
+ with h5py.File(fn,'r') as f:
20
+ tris = f['/mesh/elm/triangle_number_list'][:]
21
+ nodes = f['/mesh/nodes/node_coord'][:]
22
+ nodes_dist_ged, tris_dist_ged = geodesic_dist(nodes, tris, 3017)
23
+
24
+ pynibs.write_data_hdf5(data_fn,
25
+ data=[tris_dist_ged, nodes_dist_ged],
26
+ data_names=["tris_dist_ged", "nodes_dist_ged"])
27
+ pynibs.write_xdmf(data_fn,hdf5_geo_fn=fn)
28
+
29
+ Parameters
30
+ ----------
31
+ nodes : np.ndarray
32
+ (n_nodes,3) The nodes, determined by their x-y-z-coordinates.
33
+ tris : np.ndarray
34
+ (n_tris,3) Every triangle, determined by the node indices (vertices) that form it.
35
+ source : np.ndarray(int) or int
36
+ Geodesic distances of all nodes (or triangles) to this one will be computed.
37
+ source_is_node : bool
38
+ Whether source is a node idx or a triangle idx.
39
+
40
+ Returns
41
+ -------
42
+ nodes_dist : np.ndarray
43
+ (n_nodes,) Geodesic distance between source and all nodes in mm.
44
+ tris_dist : np.ndarray
45
+ (n_tris,) Geodesic distance between source and all triangles in mm.
46
+ """
47
+ if source_is_node:
48
+ if type(source) is not np.ndarray:
49
+ source = np.array([source])
50
+ nodes_dist = gdist.compute_gdist(nodes.astype(np.float64),
51
+ tris.astype(np.int32),
52
+ source_indices=source.astype(np.int32))
53
+
54
+ else:
55
+ nodes = nodes.astype(np.float64)
56
+ tris = tris.astype(np.int32)
57
+ source = tris[source].astype(np.int32)
58
+ nodes_dist = gdist.compute_gdist(nodes,
59
+ tris,
60
+ source_indices=source)
61
+ # l = []
62
+ # for n in source:
63
+ # l.append(np.mean(gdist.compute_gdist(nodes,
64
+ # tris,
65
+ # source_indices=np.array([n]).astype(np.int32))[tris],axis=1))
66
+ # # a = np.array(l)
67
+ # nodes_dist = np.mean(l,axis=0)
68
+ # a.shape
69
+ tris_dist = np.mean(nodes_dist[tris], axis=1)
70
+
71
+ return nodes_dist, tris_dist
72
+
73
+
74
+ def euclidean_dist(nodes, tris, source, source_is_node=True):
75
+ """
76
+ Returns euclidean distance of all nodes to source node (triangle).
77
+ This is just a wrapper for the gdist package.
78
+
79
+ Example
80
+ -------
81
+ .. code-block:: python
82
+
83
+ with h5py.File(fn,'r') as f:
84
+ tris = f['/mesh/elm/triangle_number_list'][:]
85
+ nodes = f['/mesh/nodes/node_coord'][:]
86
+ nodes_dist_euc, tris_dist_euc = euclidean_dist(nodes, tris, 3017)
87
+
88
+ pynibs.write_data_hdf5(data_fn,
89
+ data=[tris_dist_euc, nodes_dist_euc],
90
+ data_names=["tris_dist_euc", "nodes_dist_euc", "])
91
+ pynibs.write_xdmf(data_fn,hdf5_geo_fn=fn)
92
+
93
+ Parameters
94
+ ----------
95
+ nodes : np.ndarray
96
+ (n_nodes,3) The nodes, determined by their x-y-z-coordinates.
97
+ tris : np.ndarray
98
+ (n_tris,3) Every triangle, determined by the node indices (vertices) that form it.
99
+ source : np.ndarray(int) or int
100
+ Euclidean distances of all nodes (or triangles) to this one will be computed.
101
+ source_is_node : bool
102
+ Whether source is a node idx or a triangle idx.
103
+
104
+ Returns
105
+ -------
106
+ nodes_dist : np.ndarray
107
+ (n_nodes,) Geodesic distance between source and all nodes in mm.
108
+ tris_dist : np.ndarray
109
+ (n_tris,) Geodesic distance between source and all triangles in mm.
110
+ """
111
+ if source_is_node:
112
+ nodes_dist = np.linalg.norm(nodes - nodes[source], axis=1)
113
+ else:
114
+ nodes_dist = np.zeros(nodes.shape[0], )
115
+ for node in nodes[tris[source]]:
116
+ nodes_dist += np.linalg.norm(nodes - node, axis=1)
117
+ nodes_dist /= 3
118
+ tris_dist = np.mean(nodes_dist[tris], axis=1)
119
+
120
+ return nodes_dist, tris_dist
121
+
122
+
123
+ def nrmsd(array, array_ref, error_norm="relative", x_axis=False):
124
+ """
125
+ Determine the normalized root-mean-square deviation between input data and reference data.
126
+
127
+ Notes
128
+ -----
129
+ nrmsd = np.sqrt(1.0 / n_points * np.sum((data - data_ref) ** 2)) / (max(array_ref) - min(array_ref))
130
+
131
+ Parameters
132
+ ----------
133
+ array : np.ndarray
134
+ input data [ (x), y0, y1, y2 ... ].
135
+ array_ref : np.ndarray
136
+ reference data [ (x_ref), y0_ref, y1_ref, y2_ref ... ].
137
+ if array_ref is 1D, all sizes have to match.
138
+ error_norm : str, optional, default="relative"
139
+ Decide if error is determined "relative" or "absolute".
140
+ x_axis : bool, default: False
141
+ If True, the first column of array and array_ref is interpreted as the x-axis, where the data points are
142
+ evaluated. If False, the data points are assumed to be at the same location.
143
+
144
+ Returns
145
+ -------
146
+ normalized_rms : np.ndarray of float
147
+ ([array.shape[1]]) Normalized root-mean-square deviation between the columns of array and array_ref.
148
+ """
149
+
150
+ n_points = array.shape[0]
151
+
152
+ if x_axis:
153
+ # handle different array lengths
154
+ if len(array_ref.shape) == 1:
155
+ array_ref = array_ref[:, None]
156
+ if len(array.shape) == 1:
157
+ array = array[:, None]
158
+
159
+ # determine number of input arrays
160
+ if array_ref.shape[1] == 2:
161
+ n_data = array.shape[1] - 1
162
+ else:
163
+ n_data = array.shape[1]
164
+
165
+ # interpolate array on array_ref data if necessary
166
+ if array_ref.shape[1] == 1:
167
+ data = array
168
+ data_ref = array_ref
169
+ else:
170
+ # crop reference if it is longer than the axis of the data
171
+ data_ref = array_ref[(array_ref[:, 0] >= min(array[:, 0])) & (array_ref[:, 0] <= max(array[:, 0])), 1]
172
+ array_ref = array_ref[(array_ref[:, 0] >= min(array[:, 0])) & (array_ref[:, 0] <= max(array[:, 0])), 0]
173
+
174
+ data = np.zeros([len(array_ref), n_data])
175
+ for i_data in range(n_data):
176
+ data[:, i_data] = np.interp(array_ref, array[:, 0], array[:, i_data + 1])
177
+ else:
178
+ data_ref = array_ref
179
+ data = array
180
+
181
+ # determine "absolute" or "relative" error
182
+ if error_norm == "relative":
183
+ # max_min_idx = np.isclose(np.max(data_ref, axis=0), np.min(data_ref, axis=0))
184
+ delta = np.max(data_ref, axis=0) - np.min(data_ref, axis=0)
185
+
186
+ # if max_min_idx.any():
187
+ # delta[max_min_idx] = max(data_ref[max_min_idx])
188
+ elif error_norm == 'absolute':
189
+ delta = 1
190
+ else:
191
+ raise NotImplementedError
192
+
193
+ # determine normalized rms deviation and return
194
+ normalized_rms = np.sqrt(1.0 / n_points * np.sum((data - data_ref) ** 2, axis=0)) / delta
195
+
196
+ return normalized_rms
197
+
198
+
199
+ def nrmse(array, array_ref, x_axis=False):
200
+ """
201
+ Determine the normalized root-mean-square deviation between input data and reference data.
202
+
203
+ nrmse = np.linalg.norm(array - array_ref) / np.linalg.norm(array_ref)
204
+
205
+ Parameters
206
+ ----------
207
+ array : np.ndarray
208
+ input data [ (x), y0, y1, y2 ... ].
209
+ array_ref : np.ndarray
210
+ reference data [ (x_ref), y0_ref, y1_ref, y2_ref ... ].
211
+ if array_ref is 1D, all sizes have to match.
212
+ error_norm : str, optional, default="relative"
213
+ Decide if error is determined "relative" or "absolute".
214
+ x_axis : bool, default: False
215
+ If True, the first column of array and array_ref is interpreted as the x-axis, where the data points are
216
+ evaluated. If False, the data points are assumed to be at the same location.
217
+
218
+ Returns
219
+ -------
220
+ nrmse: np.ndarray of float
221
+ ([array.shape[1]]) Normalized root-mean-square deviation between the columns of array and array_ref.
222
+ """
223
+
224
+ n_points = array.shape[0]
225
+
226
+ if x_axis:
227
+ # handle different array lengths
228
+ if len(array_ref.shape) == 1:
229
+ array_ref = array_ref[:, None]
230
+ if len(array.shape) == 1:
231
+ array = array[:, None]
232
+
233
+ # determine number of input arrays
234
+ if array_ref.shape[1] == 2:
235
+ n_data = array.shape[1] - 1
236
+ else:
237
+ n_data = array.shape[1]
238
+
239
+ # interpolate array on array_ref data if necessary
240
+ if array_ref.shape[1] == 1:
241
+ data = array
242
+ data_ref = array_ref
243
+ else:
244
+ # crop reference if it is longer than the axis of the data
245
+ data_ref = array_ref[(array_ref[:, 0] >= min(array[:, 0])) & (array_ref[:, 0] <= max(array[:, 0])), 1]
246
+ array_ref = array_ref[(array_ref[:, 0] >= min(array[:, 0])) & (array_ref[:, 0] <= max(array[:, 0])), 0]
247
+
248
+ data = np.zeros([len(array_ref), n_data])
249
+ for i_data in range(n_data):
250
+ data[:, i_data] = np.interp(array_ref, array[:, 0], array[:, i_data + 1])
251
+ else:
252
+ data_ref = array_ref
253
+ data = array
254
+
255
+ # determine normalized rms deviation and return
256
+ nrmse = np.linalg.norm(data - data_ref, axis=0) / np.linalg.norm(data_ref, axis=0)
257
+
258
+ return nrmse
259
+
260
+
261
+ def c_map_comparison(c1, c2, t1, t2, nodes, tris):
262
+ """
263
+ Compares two c-maps in terms of NRMSD and calculates the geodesic distance between the hotspots.
264
+
265
+ Parameters
266
+ ----------
267
+ c1 : np.ndarray of float
268
+ (n_ele) First c-map.
269
+ c2 : np.ndarray of float
270
+ (n_ele) Second c-map (reference).
271
+ t1 : np.ndarray of float
272
+ (3) Coordinates of the hotspot in the first c-map.
273
+ t2 : np.ndarray of float
274
+ Coordinates of the hotspot in the second c-map.
275
+ nodes : np.ndarray of float
276
+ (n_nodes, 3) Node coordinates
277
+ tris : np.ndarray of float
278
+ (n_tris, 3) Connectivity of ROI elements
279
+
280
+ Returns
281
+ -------
282
+ nrmsd : float
283
+ Normalized root-mean-square deviation between the two c-maps in (%).
284
+ gdist : float
285
+ Geodesic distance between the two hotspots in (mm).
286
+ """
287
+ # determine NRMSD between two c-maps
288
+ nrmsd_ = nrmsd(array=c1, array_ref=c2, error_norm="relative", x_axis=False) * 100
289
+
290
+ # determine geodesic distance between hotspots
291
+ tris_center = np.mean(nodes[tris,], axis=1)
292
+
293
+ t1_idx = np.argmin(np.linalg.norm(tris_center - t1, axis=1))
294
+ t2_idx = np.argmin(np.linalg.norm(tris_center - t2, axis=1))
295
+ gdists = geodesic_dist(nodes=nodes, tris=tris, source=t2_idx, source_is_node=False)[1]
296
+ gdist_ = gdists[t1_idx]
297
+
298
+ return nrmsd_, gdist_
299
+
300
+
301
+ def calc_tms_motion_params(coil_positions, reference=None):
302
+ """
303
+ Computes motion parameters for a set of ``coil_positions``, i.e., coil shifts in [mm] in RAS coordinate system and
304
+ rotations (pitch, roll, yaw) in [°].
305
+ Motion is computed w.r.t. the first ('absolute') and to the previous ('relative') stimulation.
306
+
307
+ Position shifts are quantified with respect to the subject/nifti-specific RAS coordinate system.
308
+ Rorational changes are quantified with respect to the coil axes as follows:
309
+
310
+ * pitch: rotation around left/right axis of coil
311
+ * roll: rotation around coil handle axis
312
+ * yaw: rotation around axis from center of coil towards head
313
+ Motion parameters for first coil position are set to 0.
314
+
315
+ Parameters
316
+ ----------
317
+ coil_positions : np.ndarray of float
318
+ (4, 4, n_pulses).
319
+ reference : np.ndarray of float, optional
320
+ (4, 4) Reference coil placement. If None (default), the first placement from ``coil_positions`` is used.
321
+
322
+ Returns
323
+ -------
324
+ pos_diff_abs : np.ndarray
325
+ (3, n_pulses) Absolute position differences (R, A, S).
326
+ euler_rots_abs : np.ndarray
327
+ (3, n_pulses) Absolute rotation angles in euler angles (alpha, beta, gamma).
328
+ pos_diff_rel : np.ndarray
329
+ (3, n_pulses) Relative position differences (R, A, S).
330
+ euler_rots_rel : np.ndarray
331
+ (3, n_pulses) Relative rotation angles in euler angles (alpha, beta, gamma).
332
+ """
333
+ np.set_printoptions(suppress=True)
334
+ if reference is None:
335
+ i_ref = 0
336
+ while np.isnan(coil_positions[:, :, i_ref]).any():
337
+ i_ref += 1
338
+ reference_pos = coil_positions[0:3, 3, i_ref].T
339
+ reference_rot = coil_positions[0:3, 0:3, i_ref]
340
+
341
+ # first rotation is zero because it is the reference
342
+ euler_rots_abs = [[0, 0, 0]]
343
+ else:
344
+ reference = np.squeeze(reference)
345
+ reference_pos = reference[0:3, 3].T
346
+ reference_rot = reference[0:3, 0:3]
347
+
348
+ # compute first rotation towards reference
349
+ rotmat_abs = pynibs.bases2rotmat(reference_rot, coil_positions[0:3, 0:3, 0])
350
+ if np.isnan(rotmat_abs).any():
351
+ euler_rots_abs = [[np.nan, np.nan, np.nan]]
352
+ else:
353
+ euler_rots_abs = [rot.from_matrix(rotmat_abs).as_euler('xyz', degrees=True).tolist()]
354
+
355
+ # compute absolute and relative position differences
356
+ pos_diff_abs = (reference_pos - coil_positions[0:3, 3, :].T).T
357
+ pos_diff_rel = np.diff(coil_positions[0:3, 3, :], 1, axis=1, prepend=coil_positions[0:3, 3, 0, np.newaxis])
358
+
359
+ # bring position differences into reference coordinate system
360
+ pos_diff_abs_rot = reference_rot.T @ pos_diff_abs
361
+ pos_diff_rel_rot = reference_rot.T @ pos_diff_rel
362
+
363
+ n_coilpos = coil_positions.shape[2]
364
+ euler_rots_rel = [[0, 0, 0]]
365
+ for i in range(0, n_coilpos - 1):
366
+ rotmat_abs = pynibs.bases2rotmat(reference_rot, coil_positions[0:3, 0:3, i + 1])
367
+ if np.isnan(rotmat_abs).any():
368
+ euler_abs = [np.nan, np.nan, np.nan]
369
+ else:
370
+ euler_abs = rot.from_matrix(rotmat_abs).as_euler('xyz', degrees=True).tolist()
371
+ euler_rots_abs.append(euler_abs)
372
+
373
+ rotmat_rel = pynibs.bases2rotmat(coil_positions[0:3, 0:3, i], coil_positions[0:3, 0:3, i + 1])
374
+ if np.isnan(rotmat_rel).any():
375
+ euler_rel = [np.nan, np.nan, np.nan]
376
+ else:
377
+ euler_rel = rot.from_matrix(rotmat_rel).as_euler('xyz', degrees=True).tolist()
378
+ euler_rots_rel.append(euler_rel)
379
+
380
+ euler_rots_rel = np.array(euler_rots_rel)
381
+ euler_rots_abs = np.array(euler_rots_abs)
382
+
383
+ return pos_diff_abs_rot, euler_rots_abs.T, pos_diff_rel_rot, euler_rots_rel.T
384
+
385
+
386
+ def plot_tms_motion_parameter(pos_diff, euler_rots, pcd=None, fname=None):
387
+ """
388
+ Plots TMS coil motion parameters.
389
+
390
+ .. figure:: ../../doc/images/pcd.png
391
+ :scale: 80 %
392
+ :alt: TMS motion parameters and PCD
393
+
394
+ Pulsewise Coil Displacement (PCD) is a compound metric to quantify TMS coil movements. Data for a double (space)
395
+ cTBS400 protocol is shown (with a break after 200 bursts).
396
+
397
+ Parameters
398
+ ----------
399
+ pos_diff : np.ndarray
400
+ (3, n_pulses) Position differences (R, A, S).
401
+ euler_rots : np.ndarray
402
+ (3, n_pulses) Rotation angles in euler angles (alpha, beta, gamma).
403
+ pcd : np.ndarray, optional
404
+ (n_pulses,) pulsewise coil displacements.
405
+ fname : str, optional
406
+ Filename to save plot.
407
+
408
+ Returns
409
+ -------
410
+ axes : matplotlib.pyplot.axes
411
+ Figure axes.
412
+ """
413
+ if pcd is not None:
414
+ n_plot_rows = 3
415
+ else:
416
+ n_plot_rows = 2
417
+ n_coilpos = pos_diff.shape[1]
418
+ fig, axes = plt.subplots(n_plot_rows, 2)
419
+
420
+ fig.suptitle('TMS coil displacement')
421
+
422
+ # let's order the plotting based on the data to have the dimension with the fewest changes on top
423
+ order_pos = np.argsort(np.std(pos_diff, axis=1))[::-1]
424
+ order_rot = np.argsort(np.std(euler_rots, axis=1))[::-1]
425
+ colors_pos = ['#12263A', '#50858B', '#99EDCC']
426
+ colors_rot = ['#725752', '#F2A541', '#F8DF8C']
427
+ labels_pos = ['X', 'Y', 'Z']
428
+ labels_rot = ['yaw', 'pitch', 'roll']
429
+
430
+ for i in order_pos:
431
+ axes[0, 0].plot(range(0, n_coilpos), pos_diff[i, :], c=colors_pos[i], label=labels_pos[i], linewidth=1)
432
+ axes[0, 1].plot(range(0, n_coilpos), np.nancumsum(pos_diff[i, :]), c=colors_pos[i], label=labels_pos[i],
433
+ linewidth=1)
434
+ for i in order_rot:
435
+ axes[1, 0].plot(range(0, n_coilpos), euler_rots[i, :], c=colors_rot[i], label=labels_rot[i], linewidth=1)
436
+ axes[1, 1].plot(range(0, n_coilpos), np.nancumsum(euler_rots[i, :]), c=colors_rot[i], label=labels_rot[i],
437
+ linewidth=1)
438
+ np.set_printoptions(suppress=True)
439
+ axes[0, 0].set_title('Relative movement [mm]')
440
+ axes[1, 0].set_title('Relative rotation [°]')
441
+ axes[0, 1].set_title('Cumulative movement [mm]')
442
+ axes[1, 1].set_title('Cumulative rotation [°]')
443
+ fig.delaxes(axes[2, 1])
444
+ if pcd is not None:
445
+ axes[2, 0].plot(range(0, n_coilpos), pcd, c='#843E84', label='PCD',
446
+ linewidth=1)
447
+ axes[2, 0].set_title('Pulsewise coil displacement [AU]')
448
+
449
+ # add some stats to plot
450
+ plt.figtext(0.505, 0.09, f" PCD\n"
451
+ f" Max: {np.nanmax(pcd).round(2):6.2f}\n"
452
+ f" Mean: {np.nanmean(pcd).round(2):6.2f}\n"
453
+ f"Median: {np.nanmedian(pcd).round(2):6.2f}\n"
454
+ f" SD: {np.nanstd(pcd).round(2):6.2f}\n"
455
+ f"n stim: {(~np.isnan(pcd)).sum(): >6}\n"
456
+ f"untracked: {(np.isnan(pcd)).sum(): >3}",
457
+ family='monospace'
458
+ )
459
+
460
+ fig.tight_layout()
461
+ handles_tmp, labels_tmp = axes[0, 0].get_legend_handles_labels()
462
+ fig.legend(handles_tmp, labels_tmp, ncols=1, loc=[0.7, 0.08], title='Shift')
463
+
464
+ handles_tmp, labels_tmp = axes[1, 0].get_legend_handles_labels()
465
+ fig.legend(handles_tmp, labels_tmp, ncols=1, loc=[0.83, 0.08], title='Rotation')
466
+
467
+ # handles_tmp, labels_tmp = axes[2, 0].get_legend_handles_labels()
468
+ # fig.legend(handles_tmp, labels_tmp, ncols=1, loc=[0.85, 0.12], title='PCD')
469
+
470
+ if fname is not None:
471
+ plt.savefig(fname)
472
+
473
+ return axes
474
+
475
+
476
+ def compute_pcd(delta_pos, delta_rot, skin_cortex_distance=20):
477
+ """
478
+ Computes _P_ulsewise _C_oil _D_isplacements (PCD) based on 3 position parameters (``delta_pos``) and
479
+ 3 rotation parmeters (``delta_rot``).
480
+
481
+ The coil rotations (in euler angles) are transformed into a positional change projected on the cortex based on
482
+ ``skin_cortex_distance`` as a proxy for the (local) change of the stimulation.
483
+ ``delta_pos`` and ``delta_rot`` are expected to quantify motion w.r.t. the target coil position/roation, for example
484
+ the absolute deltas to the first stimulation.
485
+
486
+ Axes definitions
487
+ ----------------
488
+ ``delta_pos`` and ``delta_rot`` are supposed to follow this axes definition (SimNIBS):
489
+
490
+ - delta_pos
491
+ - X and Y: coil movement tangential to head surface
492
+ - Z: coil movement perpendicular to head surface
493
+
494
+ - delta_rot
495
+ * pitch: rotation around left/right axis of coil
496
+ * roll: rotation around coil handle axis
497
+ * yaw: rotation around axis from center of coil towards head
498
+
499
+ .. figure:: ../../doc/images/coil_axes_definition.png
500
+ :scale: 80 %
501
+ :alt: Coil axes definition, following SimNIBS conventions.
502
+
503
+
504
+ Example
505
+ -------
506
+ .. code-block:: python
507
+
508
+ # get TriggerMarkers from a Localite session
509
+ mats = pynibs.read_triggermarker_localite(tm_fn)[0]
510
+
511
+ # calculate absolute and relative coil displacements
512
+ delta_pos_abs, delta_rot_abs, delta_pos_rel, delta_rot_rel = pynibs.calc_tms_motion_params(mats)
513
+
514
+ # compute PCD
515
+ pcd, delta_pos, delta_rot = pynibs.compute_pcd(delta_pos_abs, delta_rot_abs)
516
+
517
+ # plot movement
518
+ axes = pynibs.plot_tms_motion_parameter(pos_diff_rel, euler_rots_rel, pcd)
519
+ matplotlib.pyplot.show()
520
+
521
+ Parameters
522
+ ----------
523
+ delta_pos : np.ndarray
524
+ (3, n_pulses) Absolute position differences (R, A, S).
525
+ delta_rot : np.ndarray
526
+ (3, n_pulses) Absolute rotation angles in euler angles (alpha, beta, gamma).
527
+ skin_cortex_distance : int, default: 20
528
+ Cortical depth of target to adjust coil rotations for.
529
+
530
+ Returns
531
+ -------
532
+ pcd : np.ndarray
533
+ Pulsewise coil displacements.
534
+ delta_pos : np.ndarray
535
+ (n_pulses, ) sum(abs(delta_pos)) per pulse.
536
+ delta_rot : np.ndarray
537
+ (n_pulses, ) sum(abs(delta_rot projected by skin_cortex_distance)) per pulse.
538
+ """
539
+ # delta_pos_summed = np.sum(np.abs(delta_pos[:2, :]), axis=0)
540
+ delta_pos_summed = np.sqrt(np.sum(delta_pos[: 2, :] ** 2, axis=0))
541
+ delta_pos_summed = np.squeeze(delta_pos_summed + delta_pos[2, :] ** 2 * np.sign(delta_pos[2, :]))
542
+
543
+ plane_n = np.array([0, 0, 1])
544
+ plane_p = np.array([0, 0, skin_cortex_distance]) # Any point on the plane
545
+
546
+ # compute shift of the center of coil ray
547
+ ray_origin = np.array([0, 0, 0]) # Any point along the ray
548
+ delta_rot_a = []
549
+ for idx, rots in enumerate(delta_rot.T):
550
+ # apply x and y rotation to [0,0,1]
551
+ # rots[2] = 0
552
+ r = rot.from_rotvec(rots, degrees=True)
553
+ ray_dir = r.apply([0, 0, 1])
554
+ intersec = pynibs.intersection_vec_plan(ray_dir, ray_origin, plane_n, plane_p, eps=1e-6)
555
+
556
+ # compute distance from new intersection with [0, 0, scd]
557
+ delta_rot_a.append(np.linalg.norm(plane_p - intersec))
558
+
559
+ # ray shift based on orientation doesn't include any rotations around z axis.
560
+ z_rotation_deltas = np.abs((skin_cortex_distance) * np.sin(np.deg2rad(delta_rot[2, :])))
561
+ delta_rot = np.array(delta_rot_a) + z_rotation_deltas
562
+ return delta_pos_summed + delta_rot, delta_pos_summed, delta_rot