pyNIBS 0.2024.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. pyNIBS-0.2024.8.dist-info/LICENSE +623 -0
  2. pyNIBS-0.2024.8.dist-info/METADATA +723 -0
  3. pyNIBS-0.2024.8.dist-info/RECORD +107 -0
  4. pyNIBS-0.2024.8.dist-info/WHEEL +5 -0
  5. pyNIBS-0.2024.8.dist-info/top_level.txt +1 -0
  6. pynibs/__init__.py +34 -0
  7. pynibs/coil.py +1367 -0
  8. pynibs/congruence/__init__.py +15 -0
  9. pynibs/congruence/congruence.py +1108 -0
  10. pynibs/congruence/ext_metrics.py +257 -0
  11. pynibs/congruence/stimulation_threshold.py +318 -0
  12. pynibs/data/configuration_exp0.yaml +59 -0
  13. pynibs/data/configuration_linear_MEP.yaml +61 -0
  14. pynibs/data/configuration_linear_RT.yaml +61 -0
  15. pynibs/data/configuration_sigmoid4.yaml +68 -0
  16. pynibs/data/network mapping configuration/configuration guide.md +238 -0
  17. pynibs/data/network mapping configuration/configuration_TEMPLATE.yaml +42 -0
  18. pynibs/data/network mapping configuration/configuration_for_testing.yaml +43 -0
  19. pynibs/data/network mapping configuration/configuration_modelTMS.yaml +43 -0
  20. pynibs/data/network mapping configuration/configuration_reg_isi_05.yaml +43 -0
  21. pynibs/data/network mapping configuration/output_documentation.md +185 -0
  22. pynibs/data/network mapping configuration/recommendations_for_accuracy_threshold.md +77 -0
  23. pynibs/data/neuron/models/L23_PC_cADpyr_biphasic_v1.csv +1281 -0
  24. pynibs/data/neuron/models/L23_PC_cADpyr_monophasic_v1.csv +1281 -0
  25. pynibs/data/neuron/models/L4_LBC_biphasic_v1.csv +1281 -0
  26. pynibs/data/neuron/models/L4_LBC_monophasic_v1.csv +1281 -0
  27. pynibs/data/neuron/models/L4_NBC_biphasic_v1.csv +1281 -0
  28. pynibs/data/neuron/models/L4_NBC_monophasic_v1.csv +1281 -0
  29. pynibs/data/neuron/models/L4_SBC_biphasic_v1.csv +1281 -0
  30. pynibs/data/neuron/models/L4_SBC_monophasic_v1.csv +1281 -0
  31. pynibs/data/neuron/models/L5_TTPC2_cADpyr_biphasic_v1.csv +1281 -0
  32. pynibs/data/neuron/models/L5_TTPC2_cADpyr_monophasic_v1.csv +1281 -0
  33. pynibs/expio/Mep.py +1518 -0
  34. pynibs/expio/__init__.py +8 -0
  35. pynibs/expio/brainsight.py +979 -0
  36. pynibs/expio/brainvis.py +71 -0
  37. pynibs/expio/cobot.py +239 -0
  38. pynibs/expio/exp.py +1876 -0
  39. pynibs/expio/fit_funs.py +287 -0
  40. pynibs/expio/localite.py +1987 -0
  41. pynibs/expio/signal_ced.py +51 -0
  42. pynibs/expio/visor.py +624 -0
  43. pynibs/freesurfer.py +502 -0
  44. pynibs/hdf5_io/__init__.py +10 -0
  45. pynibs/hdf5_io/hdf5_io.py +1857 -0
  46. pynibs/hdf5_io/xdmf.py +1542 -0
  47. pynibs/mesh/__init__.py +3 -0
  48. pynibs/mesh/mesh_struct.py +1394 -0
  49. pynibs/mesh/transformations.py +866 -0
  50. pynibs/mesh/utils.py +1103 -0
  51. pynibs/models/_TMS.py +211 -0
  52. pynibs/models/__init__.py +0 -0
  53. pynibs/muap.py +392 -0
  54. pynibs/neuron/__init__.py +2 -0
  55. pynibs/neuron/neuron_regression.py +284 -0
  56. pynibs/neuron/util.py +58 -0
  57. pynibs/optimization/__init__.py +5 -0
  58. pynibs/optimization/multichannel.py +278 -0
  59. pynibs/optimization/opt_mep.py +152 -0
  60. pynibs/optimization/optimization.py +1445 -0
  61. pynibs/optimization/workhorses.py +698 -0
  62. pynibs/pckg/__init__.py +0 -0
  63. pynibs/pckg/biosig/biosig4c++-1.9.5.src_fixed.tar.gz +0 -0
  64. pynibs/pckg/libeep/__init__.py +0 -0
  65. pynibs/pckg/libeep/pyeep.so +0 -0
  66. pynibs/regression/__init__.py +11 -0
  67. pynibs/regression/dual_node_detection.py +2375 -0
  68. pynibs/regression/regression.py +2984 -0
  69. pynibs/regression/score_types.py +0 -0
  70. pynibs/roi/__init__.py +2 -0
  71. pynibs/roi/roi.py +895 -0
  72. pynibs/roi/roi_structs.py +1233 -0
  73. pynibs/subject.py +1009 -0
  74. pynibs/tensor_scaling.py +144 -0
  75. pynibs/tests/data/InstrumentMarker20200225163611937.xml +19 -0
  76. pynibs/tests/data/TriggerMarkers_Coil0_20200225163443682.xml +14 -0
  77. pynibs/tests/data/TriggerMarkers_Coil1_20200225170337572.xml +6373 -0
  78. pynibs/tests/data/Xdmf.dtd +89 -0
  79. pynibs/tests/data/brainsight_niiImage_nifticoord.txt +145 -0
  80. pynibs/tests/data/brainsight_niiImage_nifticoord_largefile.txt +1434 -0
  81. pynibs/tests/data/brainsight_niiImage_niifticoord_mixedtargets.txt +47 -0
  82. pynibs/tests/data/create_subject_testsub.py +332 -0
  83. pynibs/tests/data/data.hdf5 +0 -0
  84. pynibs/tests/data/geo.hdf5 +0 -0
  85. pynibs/tests/test_coil.py +474 -0
  86. pynibs/tests/test_elements2nodes.py +100 -0
  87. pynibs/tests/test_hdf5_io/test_xdmf.py +61 -0
  88. pynibs/tests/test_mesh_transformations.py +123 -0
  89. pynibs/tests/test_mesh_utils.py +143 -0
  90. pynibs/tests/test_nnav_imports.py +101 -0
  91. pynibs/tests/test_quality_measures.py +117 -0
  92. pynibs/tests/test_regressdata.py +289 -0
  93. pynibs/tests/test_roi.py +17 -0
  94. pynibs/tests/test_rotations.py +86 -0
  95. pynibs/tests/test_subject.py +71 -0
  96. pynibs/tests/test_util.py +24 -0
  97. pynibs/tms_pulse.py +34 -0
  98. pynibs/util/__init__.py +4 -0
  99. pynibs/util/dosing.py +233 -0
  100. pynibs/util/quality_measures.py +562 -0
  101. pynibs/util/rotations.py +340 -0
  102. pynibs/util/simnibs.py +763 -0
  103. pynibs/util/util.py +727 -0
  104. pynibs/visualization/__init__.py +2 -0
  105. pynibs/visualization/para.py +4372 -0
  106. pynibs/visualization/plot_2D.py +137 -0
  107. pynibs/visualization/render_3D.py +347 -0
pynibs/util/simnibs.py ADDED
@@ -0,0 +1,763 @@
1
+ import gc
2
+ import os
3
+ import re
4
+ import copy
5
+
6
+ import h5py
7
+ import tqdm
8
+ import trimesh
9
+
10
+ try:
11
+ import simnibs
12
+ except (ModuleNotFoundError, ImportError):
13
+ print("SimNIBS not found. Some functions might not work.")
14
+ import warnings
15
+ import collections
16
+ import numpy as np
17
+
18
+ import pynibs
19
+
20
+
21
+ def e_field_gradient_between_wm_gm(
22
+ roi_surf,
23
+ mesh,
24
+ gm_nodes,
25
+ wm_nodes,
26
+ gm_center_distance,
27
+ wm_center_distance
28
+ ):
29
+ """
30
+ Compute local the E-field gradient at the ROI nodes between the gray and white matter boundary sufaces.
31
+ Adapted from neuronibs/cortical_layer.py/add_e_field_gradient_between_wm_gm_field
32
+
33
+ Parameters
34
+ ----------
35
+ roi_surf : simnibs.Msh
36
+ The surface object representing the ROI.
37
+ mesh : simnibs.Msh
38
+ SimNIBS headmesh.
39
+ gm_nodes : np.ndarray, (3, len(roi_surf.nodes))
40
+ For each node of 'roi_surf' (representing, e.g., a cortical layer) the corresponding point on the gray matter surface.
41
+ -> as returned by 'precompute_geo_info_for_layer_field_interpolation'
42
+ wm_nodes : np.ndarray, (3, len(roi_surf.nodes))
43
+ For each node of 'roi_surf' (representing, e.g., a cortical layer) the corresponding point on the white matter surface.
44
+ -> as returned by 'precompute_geo_info_for_layer_field_interpolation'
45
+ gm_center_distance : np.ndarray, (3, len(roi_surf.nodes))
46
+ The distance between the layer nodes and their corresponding point on the gray matter surface.
47
+ -> as returned by 'precompute_geo_info_for_layer_field_interpolation'
48
+ wm_center_distance : np.ndarray, (3, len(roi_surf.nodes))
49
+ The distance between the layer nodes and their corresponding point on the white matter surface.
50
+ -> as returned by 'precompute_geo_info_for_layer_field_interpolation'
51
+
52
+ Returns
53
+ -------
54
+ e_field_gradient_per_mm : simnibs.mesh.mesh_io.ElementData
55
+ The E-field gradient from the GM to the WM surface normalized to 1 mm with respect to the gray matter thickness.
56
+ per ROI node
57
+ """
58
+ roi_centers = roi_surf.elements_baricenters().value
59
+
60
+ # concatenate the point lists to avoid re-building the (simnibs-internal) data structures
61
+ # for interpolation upon consecutive calls
62
+ to_be_interpolated = np.concatenate((gm_nodes, roi_centers, wm_nodes))
63
+ try:
64
+ interpolated = mesh.field['magnE'].interpolate_scattered(to_be_interpolated)
65
+ except KeyError:
66
+ e_mag = mesh.field['E'].norm()
67
+ interpolated = e_mag.interpolate_scattered(to_be_interpolated)
68
+
69
+ num_nodes = gm_nodes.shape[0]
70
+ num_nodes_x2 = num_nodes * 2
71
+ num_nodes_x3 = num_nodes * 3
72
+ e_field_gm = interpolated[:num_nodes]
73
+ e_field_center = interpolated[num_nodes:num_nodes_x2]
74
+ e_field_wm = interpolated[num_nodes_x2:num_nodes_x3]
75
+
76
+ e_field_gradient = e_field_gm - e_field_wm
77
+ gray_matter_thickness = gm_center_distance + wm_center_distance
78
+ e_field_gradient_per_mm = e_field_gradient / gray_matter_thickness
79
+ relative_e_field_gradient_per_mm = e_field_gradient_per_mm / e_field_center * 100
80
+
81
+ return simnibs.ElementData(relative_e_field_gradient_per_mm, name='rel_gradient_per_mm', mesh=roi_surf)
82
+
83
+
84
+ def e_field_angle_theta(surface, mesh):
85
+ """
86
+ Compute angle between local the E-field vector and surface vector at the ROI nodes.
87
+
88
+ Parameters
89
+ ----------
90
+ surface : simnibs.Msh
91
+ The surface object representing the ROI.
92
+ mesh: simnibs.Msh
93
+ The (volumetric) head mesh.
94
+
95
+ Returns
96
+ -------
97
+ theta : simnibs.mesh.mesh_io.ElementData
98
+ The angle between local the E-field vector and surface vector for each surface element of the ROI.
99
+ """
100
+ layer_centers = surface.elements_baricenters().value
101
+ interpolated_cell_centers = mesh.field['E'].interpolate_scattered(layer_centers, out_fill='nearest')
102
+ tri_normal = surface.triangle_normals(smooth=30).value
103
+ e_at_roi_cell_centers_normalized = np.divide(
104
+ interpolated_cell_centers,
105
+ np.linalg.norm(interpolated_cell_centers, axis=1)[:, np.newaxis]
106
+ )
107
+ angle = np.array([np.arccos(np.dot(a, b)) / np.pi * 180 for a, b in
108
+ zip(tri_normal, e_at_roi_cell_centers_normalized)])
109
+
110
+ return simnibs.ElementData(angle, name="theta", mesh=surface)
111
+
112
+
113
+ def precompute_geo_info_for_layer_field_interpolation(simnibs_mesh, roi):
114
+ """
115
+ Precomputes geometric properties of the corresponding GM and WM nodes
116
+ in the 'simnibs_mesh' of each node of each layer in ``'roi'``.
117
+
118
+ * The corresponding point on the GM and WM surface to each vertex of te ROI surface
119
+ are determined (by raycasting and nearest neighbour search as fallback)
120
+ (interpolation will take place between these nodes)
121
+ * The nodes are moved inside the gray matter by 20 % of their total distance from the GM/WM
122
+ boundary to the midlayer.
123
+ * The distance of the relocated GM and WM nodes to the ROI nodes is determined (required to
124
+ computed the gradient per mm).
125
+
126
+ Parameters
127
+ ----------
128
+ simnibs_mesh : simnibs.Msh
129
+ The head model volume mesh.
130
+ roi: pynibs.roi.RegionOfInterestSurface
131
+ The ROI object containing the layers.
132
+
133
+ Returns
134
+ -------
135
+ layer_gm_wm_info : dict[str, dict[str,np.ndarray]]
136
+ For each layer defined in the mesh (outer dict key: layer_id),
137
+ provide pre-computed geometrical information as a dictionary (outer dict value):
138
+
139
+ * key: gm_nodes
140
+ For each layer node, the corresponding point on the GM surface.
141
+ * key: wm_nodes
142
+ For each layer node, the corresponding point on the WM surface.
143
+ * key: gm_center_distance
144
+ The distance between the layer nodes and the corresponding points on the GM.
145
+ * key: wm_center_distance
146
+ The distance between the layer nodes and the corresponding points on the WM.
147
+ """
148
+ WHITE_MATTER_SURFACE_LABEL = 1001
149
+ GRAY_MATTER_SURFACE_LABEL = 1002
150
+
151
+ import vtk
152
+
153
+ def make_vtkpolydata(pts, tris):
154
+ # prepare vertices
155
+ pts_vtk = vtk.vtkPoints()
156
+ pts_vtk.SetNumberOfPoints(pts.shape[0])
157
+ for i in range(pts.shape[0]):
158
+ pts_vtk.SetPoint(i, pts[i][0], pts[i][1], pts[i][2])
159
+
160
+ # prepare triangles
161
+ tris_vtk = vtk.vtkCellArray()
162
+ for tri in tris:
163
+ tris_vtk.InsertNextCell(3)
164
+ for v in tri:
165
+ tris_vtk.InsertCellPoint(v)
166
+
167
+ # prepare GM polygonal surface
168
+ surf_vtk = vtk.vtkPolyData()
169
+ surf_vtk.SetPoints(pts_vtk)
170
+ surf_vtk.SetPolys(tris_vtk)
171
+
172
+ return surf_vtk
173
+
174
+ gray_matter_surface = simnibs_mesh.crop_mesh(GRAY_MATTER_SURFACE_LABEL)
175
+ white_matter_surface = simnibs_mesh.crop_mesh(WHITE_MATTER_SURFACE_LABEL)
176
+
177
+ gm_nodes = gray_matter_surface.nodes.node_coord
178
+ gm_tris = gray_matter_surface.elm.node_number_list
179
+ wm_nodes = white_matter_surface.nodes.node_coord
180
+ wm_tris = white_matter_surface.elm.node_number_list
181
+
182
+ # for each point on the layer find corresponding points on the GM/WM surfaces
183
+ # using the VTK implementation of Raycasting
184
+ # make zero indexed and shape (n,3) instead of (n,4), with -1 in the 4th column
185
+ gm_surf_vtk = make_vtkpolydata(gm_nodes, gm_tris[:, :3] - 1)
186
+ wm_surf_vtk = make_vtkpolydata(wm_nodes, wm_tris[:, :3] - 1)
187
+
188
+ gm_intersector = vtk.vtkOBBTree()
189
+ gm_intersector.SetDataSet(gm_surf_vtk)
190
+ gm_intersector.BuildLocator()
191
+
192
+ wm_intersector = vtk.vtkOBBTree()
193
+ wm_intersector.SetDataSet(wm_surf_vtk)
194
+ wm_intersector.BuildLocator()
195
+
196
+ intersectors = [gm_intersector, wm_intersector]
197
+ normal_sign = [1, -1]
198
+
199
+ layer_gm_wm_info = dict()
200
+ for layer_idx in range(len(roi.layers)):
201
+ intersec_pts = [[], []]
202
+ layer_id = roi.layers[layer_idx].id
203
+ layer_surf = roi.layers[layer_idx].surface
204
+ layer_centers = layer_surf.elements_baricenters().value
205
+ layer_normals = roi.layers[layer_idx].get_smoothed_normals()
206
+
207
+ for pt, normal in zip(layer_centers, layer_normals):
208
+ for idx in range(len(intersectors)):
209
+ intersection_pts = vtk.vtkPoints()
210
+ intersected_tris = vtk.vtkIdList()
211
+
212
+ intersector = intersectors[idx]
213
+
214
+ intersector.IntersectWithLine(pt, normal_sign[idx] * 100 * normal + pt, intersection_pts,
215
+ intersected_tris)
216
+ if intersection_pts.GetNumberOfPoints() > 0:
217
+ intersec_pts[idx].append(intersection_pts.GetPoint(0))
218
+ else:
219
+ intersec_pts[idx].append([np.iinfo(np.uint16).max] * 3)
220
+
221
+ gm_intersec_pts = np.array(intersec_pts[0])
222
+ wm_intersec_pts = np.array(intersec_pts[1])
223
+
224
+ # Check the distances of the found points on the GM/WM surface to the layer nodes.
225
+ # If the distance is too high, raycasting failed (e.g. no intersection found or too far away).
226
+ # In this case, we determine the nearest neighbor for these nodes.
227
+ layer_gm_distance = np.linalg.norm(layer_centers - gm_intersec_pts, ord=2, axis=1)
228
+ layer_wm_distance = np.linalg.norm(layer_centers - wm_intersec_pts, ord=2, axis=1)
229
+
230
+ # TODO: consider using a relative distance depending on the local GM thickness
231
+ raycast_error_nodes_gm = np.argwhere(layer_gm_distance > 2) # mm
232
+ raycast_error_nodes_wm = np.argwhere(layer_wm_distance > 2) # mm
233
+
234
+ closest_gm_nodes, _ = gray_matter_surface.nodes.find_closest_node(layer_centers[raycast_error_nodes_gm],
235
+ return_index=True)
236
+ closest_wm_nodes, _ = white_matter_surface.nodes.find_closest_node(layer_centers[raycast_error_nodes_wm],
237
+ return_index=True)
238
+
239
+ gm_intersec_pts[raycast_error_nodes_gm] = closest_gm_nodes
240
+ wm_intersec_pts[raycast_error_nodes_wm] = closest_wm_nodes
241
+
242
+ layer_gm_distance = np.linalg.norm(layer_centers - gm_intersec_pts, ord=2, axis=1)
243
+ layer_wm_distance = np.linalg.norm(layer_centers - wm_intersec_pts, ord=2, axis=1)
244
+
245
+ center_to_gm_vecs = gm_intersec_pts - layer_centers
246
+ center_to_wm_vecs = wm_intersec_pts - layer_centers
247
+
248
+ associated_gray_matter_points = gm_intersec_pts - \
249
+ np.multiply(
250
+ # multiply unit-normals by individual gm thickness, then scale to 20%
251
+ center_to_gm_vecs,
252
+ layer_gm_distance[:, np.newaxis]
253
+ ) * 0.1
254
+ associated_white_matter_points = wm_intersec_pts - \
255
+ np.multiply(
256
+ # multiply unit-normals by individual wm thickness, then scale to 20%
257
+ center_to_wm_vecs,
258
+ layer_wm_distance[:, np.newaxis]
259
+ ) * 0.1
260
+
261
+ layer_gm_wm_info[layer_id] = {
262
+ "assoc_gm_points": associated_gray_matter_points,
263
+ "assoc_wm_points": associated_white_matter_points,
264
+ "layer_gm_dist": layer_gm_distance,
265
+ "layer_wm_dist": layer_wm_distance
266
+ }
267
+
268
+ return layer_gm_wm_info
269
+
270
+
271
+ def calc_e_in_midlayer_roi(
272
+ e,
273
+ roi,
274
+ mesh=None,
275
+ qoi=None,
276
+ layer_gm_wm_info=None
277
+ ):
278
+ """
279
+ This is to be called by Simnibs as postprocessing function per FEM solve.
280
+
281
+ Parameters
282
+ ----------
283
+ e : np.ndarray or tuple
284
+ E to interpolate. Used to be (v, dAdt).
285
+ roi : pynibs.roi.RegionOfInterestSurface
286
+ mesh : simnibs.msh.Mesh
287
+ qoi : list of str
288
+ List of identifiers of the to-be calculated quantities of interest.
289
+ layer_gm_wm_info : dict[str, dict[str, np.ndarray]], optional
290
+ For each layer defined in the mesh (outer dict key: layer_id),
291
+ provide pre-computed geometrical information as a dictionary (outer dict value):
292
+
293
+ * key: gm_nodes, For each layer node, the corresponding point on the GM surface.
294
+ * key: wm_nodes, For each layer node, the corresponding point on the WM surface.
295
+ * key: gm_center_distance, The distance between the layer nodes and the corresponding points on the GM.
296
+ * key: wm_center_distance, The distance between the layer nodes and the corresponding points on the WM.
297
+
298
+ Returns
299
+ -------
300
+ (roi.n_tris, 4) : np.vstack((e_mag, e_norm, e_tan, e_angle)).transpose() for the midlayer
301
+ (len(roi.layers)x(roi.layers[idx].surface.n_tris,4)) : np.vstack((e_mag, e_norm, e_tan, e_angle)).transpose()
302
+ """
303
+ # set default return quantities
304
+ if qoi is None:
305
+ qoi = ['E', 'mag', 'norm', 'tan', 'angle']
306
+ qois_midlayer = copy.copy(qoi)
307
+ ret_arr_num_components = len(qoi)
308
+
309
+ # special care for E
310
+ if 'E' in qoi:
311
+ ret_arr_num_components += 2 # E is a 3D quantity whose components are stored individually
312
+ qois_midlayer.remove('E') # we don't need this to be computed, it already is
313
+
314
+ qois_layers = [] # the QOIs computed for the cortical layers (not midlayer)
315
+ max_num_elmts = roi.n_tris # number of midlayer elements to define the shape of the output data structure
316
+ interpolation_surfaces = { # initialize the ROI surfaces the QOIs will be interpolated onto
317
+ "midlayer": simnibs.Msh(
318
+ nodes=simnibs.Nodes(node_coord=roi.node_coord_mid),
319
+ elements=simnibs.Elements(triangles=roi.node_number_list + 1)
320
+ )
321
+ }
322
+
323
+ # check if interpolation must be done for the cortical layers and initialize accordingly
324
+ if len(roi.layers) > 0 and layer_gm_wm_info is not None:
325
+ if len(layer_gm_wm_info.keys()) == len(roi.layers):
326
+ qois_layers = ['mag', 'theta', 'gradient'] # = parameters of the neuronal meanfield model
327
+
328
+ for layer in roi.layers:
329
+ # Do the cortical layers have more elements than the midlayer?
330
+ max_num_elmts = np.max((max_num_elmts, layer.surface.elm.node_number_list.shape[0]))
331
+ # Expand shape of output data structure by the number of QOIs computed for this layer
332
+ ret_arr_num_components += len(qois_layers)
333
+ # Add this layer to the interpolation surfaces.
334
+ interpolation_surfaces[layer.id] = layer.surface
335
+ else:
336
+ print("[calc_e_in_midlayer] Computation of parameters for neuronal meanfield model was requested,"
337
+ "but number of layer information in 'layer_gm_wm_info' does not match the number of layers"
338
+ "found in the mesh. Skipping computation of neuronal model parameters...")
339
+
340
+ # We used to use (v, dadt) but nowadays E is enough
341
+ if isinstance(e, tuple):
342
+ e = e[0]
343
+ # simnibs calls this with empty data so find out about the results dimensions
344
+ if (e == 0).all():
345
+ return np.zeros((max_num_elmts, ret_arr_num_components))
346
+
347
+ # calc QOIs (all calculations are performed in the element centers)
348
+ data = simnibs.ElementData(e, name='E', mesh=mesh)
349
+
350
+ interp_res_per_surface = dict()
351
+ mesh.elmdata.append(simnibs.ElementData(e, name='E', mesh=mesh))
352
+
353
+ for surface_id, surface in interpolation_surfaces.items():
354
+ print(f"Computing {surface_id}")
355
+ barycenter_surface = simnibs.Msh(
356
+ nodes=simnibs.Nodes(node_coord=surface.elements_baricenters().value),
357
+ elements=None
358
+ )
359
+
360
+ # determine surface normals in elements
361
+ p1_tri = surface.nodes.node_coord[surface.elm.node_number_list[:, 0] - 1, :]
362
+ p2_tri = surface.nodes.node_coord[surface.elm.node_number_list[:, 1] - 1, :]
363
+ p3_tri = surface.nodes.node_coord[surface.elm.node_number_list[:, 2] - 1, :]
364
+
365
+ triangles_normals = np.cross(p2_tri - p1_tri, p3_tri - p1_tri).T
366
+ triangles_normals /= np.linalg.norm(triangles_normals, axis=0)
367
+ triangles_normals = triangles_normals.T
368
+
369
+ interpolated = data.interpolate_to_surface(barycenter_surface)
370
+ del barycenter_surface
371
+ qois_calculated = collections.OrderedDict() # maintain order of calculation with OrderedDict
372
+
373
+ if surface_id == "midlayer":
374
+ qois_to_calc = qois_midlayer
375
+ # E is the result of the FEM; all other quantities are secondary QOIs computed from E
376
+ if 'E' in qoi:
377
+ qois_calculated['E'] = interpolated.value
378
+ # qois_calculated['E'] = pynibs.data_nodes2elements(data=qois_calculated['E'],
379
+ # con=(surface.elm.node_number_list - 1))
380
+ elif surface_id != "midlayer" and len(qois_layers) > 0:
381
+ qois_to_calc = qois_layers
382
+ else:
383
+ continue
384
+
385
+ for quant in qois_to_calc:
386
+ if quant == 'mag':
387
+ qois_calculated[quant] = interpolated.norm().value
388
+ elif quant == 'norm':
389
+ qois_calculated[quant] = np.sum(qois_calculated["E"] * triangles_normals, axis=1)
390
+ qois_calculated[quant] *= -1
391
+ elif quant == 'tan':
392
+ e_n = np.sum(qois_calculated["E"] * triangles_normals, axis=1)
393
+ e_mag = np.linalg.norm(qois_calculated["E"], axis=1)
394
+ qois_calculated[quant] = np.sqrt(e_mag ** 2 - e_n ** 2)
395
+ elif quant == 'angle':
396
+ qois_calculated[quant] = interpolated.angle().value
397
+ elif quant == 'theta': # basically 'angle' but we want it in degrees for the meanfield model
398
+ qois_calculated[quant] = e_field_angle_theta(surface, mesh).value
399
+ elif quant == 'gradient':
400
+ qois_calculated[quant] = e_field_gradient_between_wm_gm(
401
+ surface,
402
+ mesh,
403
+ layer_gm_wm_info[surface_id]["assoc_gm_points"],
404
+ layer_gm_wm_info[surface_id]["assoc_wm_points"],
405
+ layer_gm_wm_info[surface_id]["layer_gm_dist"],
406
+ layer_gm_wm_info[surface_id]["layer_wm_dist"],
407
+ ).value
408
+ else:
409
+ raise ValueError('Invalid quantity: {0}'.format(quant))
410
+
411
+ # - wrap 1D data in array
412
+ try:
413
+ if qois_calculated[quant].ndim == 1:
414
+ qois_calculated[quant] = qois_calculated[quant][:, np.newaxis]
415
+ except KeyError:
416
+ pass
417
+
418
+ # OrderedDict has maintained the order of the QOI-list.
419
+ interp_res_per_surface[surface_id] = [*qois_calculated.values()]
420
+
421
+ mesh.elmdata.pop()
422
+ del qois_calculated, surface, interpolated, mesh, data, e, roi, triangles_normals
423
+ gc.collect()
424
+
425
+ # 0-pad midlayer & layer data to have the same number of rows (hstack not possible otherwise),
426
+ # according to the layer with the highest number of elements.
427
+ for surface_id in interp_res_per_surface.keys():
428
+ num_values_to_be_padded = max_num_elmts - interp_res_per_surface[surface_id][0].shape[0]
429
+ for q in range(len(interp_res_per_surface[surface_id])):
430
+ interp_res_per_surface[surface_id][q] = np.pad(interp_res_per_surface[surface_id][q],
431
+ ((0, num_values_to_be_padded), (0, 0)))
432
+
433
+ # Stack all result fields column-wise; each column = 1-result array (or in case of E, a component of E)
434
+ # For midlayer + 5 layers, there will be 21 columns and max_num_elements rows
435
+ # 21 = midlayer(3x for E, 1x each for magE, tanE, normE) + 3*layer(1x each for magE, theta, gradient)
436
+ return np.hstack([np.hstack(surf_data) for surf_data in interp_res_per_surface.values()])
437
+
438
+
439
+ def read_coil_geo(fn_coil_geo):
440
+ """
441
+ Reads a coil .geo file.
442
+
443
+ Parameters
444
+ ----------
445
+ fn_coil_geo : str
446
+ Filename of .geo file created from SimNIBS containing the dipole information
447
+
448
+ This reads data from lines like this:
449
+ SP(-5.906416407434245, -56.83325018618547, 104.15283927746198){0.0};
450
+ or
451
+ VP(-70.46122751969759, -68.28080005454271, 29.538763084748382){-0.10087956492712635, 0.0, -0.9948986447775034};
452
+
453
+ Returns
454
+ -------
455
+ dipole_pos : np.ndarray of float
456
+ (n_dip, 3) Dipole positions ``(x, y, z)``.
457
+ dipole_mag : np.ndarray of float
458
+ (n_dip, 1) Dipole magnitude.
459
+ """
460
+ regex = r"(S|V)P\((.*?)\)\{(.*?)\}"
461
+ with open(fn_coil_geo, 'r') as f:
462
+ dipole_pos = []
463
+ dipole_mag = []
464
+ while f:
465
+ te = f.readline()
466
+
467
+ if te == "":
468
+ break
469
+
470
+ try:
471
+ _, pos, mag_or_vec = re.findall(regex, te)[0]
472
+ pos = [float(x) for x in pos.split(',')]
473
+ mag_or_vec = [float(f) for f in mag_or_vec.split(', ')]
474
+ if len(mag_or_vec) > 1:
475
+ mag_or_vec = np.linalg.norm(mag_or_vec)
476
+ # else:
477
+ # mag_or_vec = mag_or_vec[0]
478
+ dipole_pos.append(pos)
479
+ dipole_mag.append(mag_or_vec)
480
+ except IndexError:
481
+ pass
482
+
483
+ dipole_pos = np.vstack(dipole_pos)
484
+ dipole_mag = np.atleast_2d(dipole_mag)
485
+
486
+ return dipole_pos, dipole_mag
487
+
488
+
489
+ def check_mesh(mesh, verbose=False):
490
+ """
491
+ Check a simmibs.Mesh for degenerated elements:
492
+
493
+ * zero surface triangles
494
+ * zerso volume tetrahedra
495
+ * negative volume tetrahedra
496
+
497
+ Parameters
498
+ ----------
499
+ mesh : str or simnibs.Mesh
500
+
501
+ Other parameters
502
+ ----------------
503
+ verbose : book, default: False
504
+ Print some verbosity messages.
505
+
506
+ Returns
507
+ -------
508
+ zero_tris : np.ndarray
509
+ Element indices for zero surface tris (0-indexed)
510
+ zero_tets : np.ndarray
511
+ Element indices for zero volume tets (0-indexed)
512
+ neg_tets : np.ndarray
513
+ Element indicies for negative volume tets (0-indexed)
514
+ """
515
+ if isinstance(mesh, str):
516
+ mesh = simnibs.read_msh(mesh)
517
+ tris = mesh.elm.node_number_list[mesh.elm.triangles - 1][:, :3]
518
+ points_tri = mesh.nodes[tris]
519
+ tri_area = pynibs.calc_tri_surface(points_tri)
520
+ zero_tris = np.argwhere(np.isclose(tri_area, 0, atol=1e-13))
521
+ if verbose:
522
+ print(f"{len(zero_tris)} zero surface triangles found.")
523
+
524
+ tets = mesh.elm.node_number_list[mesh.elm.tetrahedra - 1]
525
+ points_tets = mesh.nodes[tets]
526
+ tets_volume = pynibs.calc_tet_volume(points_tets)
527
+ zero_tets = np.argwhere(np.isclose(tets_volume, 0, atol=1e-13))
528
+ if verbose:
529
+ print(f"{len(zero_tets)} zero volume tetrahedra found.")
530
+
531
+ tet_idx = mesh.elm.node_number_list[mesh.elm.tetrahedra - 1]
532
+ vol = pynibs.calc_tet_volume(mesh.nodes.node_coord[tet_idx - 1], abs=False)
533
+ neg_idx = np.argwhere(vol > 0)
534
+ if verbose:
535
+ print(f"{len(neg_idx)} negative tets found.")
536
+ neg_idx_in_full_arr = mesh.elm.tetrahedra[neg_idx] - 1
537
+
538
+ return zero_tris, zero_tets + len(mesh.elm.triangles), neg_idx_in_full_arr
539
+
540
+
541
+ def fix_mesh(mesh, verbose=False):
542
+ """
543
+ Fixes simnibs.Mesh by removing any zero surface tris and zero volume tets and by fixing negative volume tets.
544
+
545
+ Parameters
546
+ ----------
547
+ mesh : str or simnibs.Mesh
548
+ Filename of mesh or mesh object.
549
+
550
+ Other parameters
551
+ ----------------
552
+ verbose : bool, default: False
553
+ Print some verbosity messages.
554
+
555
+ Returns
556
+ -------
557
+ fixed_mesh : simnibs.Mesh
558
+ """
559
+ if isinstance(mesh, str):
560
+ mesh = simnibs.read_msh(mesh)
561
+
562
+ zero_tris, zero_tets, neg_tets = check_mesh(mesh, verbose=verbose)
563
+
564
+ if neg_tets.size:
565
+ mesh.elm.node_number_list[neg_tets, [0, 1, 2, 3]] = mesh.elm.node_number_list[neg_tets, [0, 1, 3, 2]]
566
+
567
+ if zero_tris.size:
568
+ mesh = mesh.remove_from_mesh(elements=zero_tris + 1)
569
+
570
+ if zero_tets.size:
571
+ mesh = mesh.remove_from_mesh(elements=zero_tets + 1 - zero_tris.size)
572
+
573
+ # check again
574
+ zero_tris, zero_tets, neg_tets = check_mesh(mesh)
575
+
576
+ if zero_tris.size or zero_tets.size or neg_tets.size:
577
+ warnings.warn(f"Couldn't fix mesh: zero_tris: "
578
+ f"{zero_tris.size} zero tris, {zero_tets.size} zero_tets, {neg_tets.size} neg_tets left over.")
579
+
580
+ return mesh
581
+
582
+
583
+ def smooth_mesh(mesh, output_fn, smooth=.8, approach='taubin', skin_only_output=False, smooth_tissue='skin'):
584
+ """
585
+ Smoothes the skin compartment of a simnibs mesh. Uses one of three trimesh.smoothing approaches.
586
+ Because tetrahedra and triangle share the same nodes, this also smoothes the volume domain.
587
+
588
+ Parameters
589
+ ----------
590
+ mesh : str or simnibs.Mesh
591
+ output_fn : str
592
+ smooth : float, default: 0.8
593
+ Smoothing aggressiveness. ``[0, ..., 1]``.
594
+ approach: str, default: 'taubin'
595
+ Which smoothing approach to use. One of (``'taubin'``, ``'laplacian'``, ``'humphrey'``.)
596
+ smooth_tissue : str or list of int, default: 'skin'
597
+ Which tissue type to smooth. E.g. ``'gm'`` or ``[2, 1002]``.
598
+
599
+ Other parameters
600
+ ----------------
601
+ skin_only_output : bool, default: True
602
+ If true, a skin only mesh is written out instead of the full mesh.
603
+
604
+ Returns
605
+ -------
606
+ <file> : The smoothed mesh.
607
+
608
+ .. figure:: ../../doc/images/smooth_mesh.png
609
+ :scale: 50 %
610
+ :alt: Original and smoothed surfaces and volumes.
611
+
612
+ Left: original, spiky mesh. Right: smoothed mesh.
613
+ """
614
+ if isinstance(mesh, str):
615
+ mesh = simnibs.mesh_io.read_msh(mesh)
616
+
617
+ # only triangles
618
+ mesh_cropped = mesh.crop_mesh(elm_type=2)
619
+ if smooth_tissue == 'skin':
620
+ smooth_tissue = [5, 1005]
621
+ elif smooth_tissue == 'gm':
622
+ smooth_tissue = [2, 1002]
623
+ elif smooth_tissue == 'wm':
624
+ smooth_tissue = [1, 1001]
625
+ elif type(smooth_tissue) == str:
626
+ raise ValueError(f"Don't know smooth_tissue='{smooth_tissue}'.")
627
+
628
+ # only triangles + only specific tissue type
629
+ mesh_cropped = mesh_cropped.crop_mesh(smooth_tissue)
630
+
631
+ # simnibs node indexing is 1-based
632
+ tri_node_nr = mesh_cropped.elm.node_number_list[mesh_cropped.elm.triangles - 1] - 1
633
+ tri_node_nr = tri_node_nr[:, :3] # triangles only have 3 dimensions
634
+
635
+ assert output_fn.endswith('.msh'), f"Wrong file suffix: {output_fn}. Use .msh"
636
+ assert 0 <= smooth <= 1, f"'smooth={smooth}' parameter must be within [0,1]. "
637
+
638
+ # create a Trimesh object based on the skin tris
639
+ mesh_trimesh = trimesh.Trimesh(vertices=mesh_cropped.nodes.node_coord,
640
+ faces=tri_node_nr,
641
+ process=False) # process=False keeps original ordering
642
+ if approach == 'taubin':
643
+ smoothed_mesh = trimesh.smoothing.filter_taubin(mesh_trimesh.copy(), lamb=smooth) # do smoothing
644
+ elif approach == 'laplacian':
645
+ smoothed_mesh = trimesh.smoothing.filter_laplacian(mesh_trimesh.copy(), lamb=smooth) # do smoothing
646
+ elif approach == 'humphrey':
647
+ smoothed_mesh = trimesh.smoothing.filter_humphrey(mesh_trimesh.copy(), alpha=smooth) # do smoothing
648
+ else:
649
+ raise NotImplementedError(f"Approach {approach} not implemented. Use 'taubin', laplacian', or 'humphrey'.")
650
+
651
+ # find indices of nodes in original mesh
652
+ ind_nodes = np.in1d(mesh.nodes.node_coord[:, 0], mesh_cropped.nodes.node_coord[:, 0]) + \
653
+ np.in1d(mesh.nodes.node_coord[:, 1], mesh_cropped.nodes.node_coord[:, 1]) + \
654
+ np.in1d(mesh.nodes.node_coord[:, 2], mesh_cropped.nodes.node_coord[:, 2])
655
+ ind_nodes = np.where(ind_nodes)[0]
656
+
657
+ # This doesn't work if several nodes are at the same location
658
+ if ind_nodes.shape[0] != mesh_cropped.nodes.node_coord.shape[0]:
659
+ # probably some duplicate nodes
660
+ if mesh_cropped.nodes.node_coord.shape[0] != np.unique(mesh_cropped.nodes.node_coord, axis=0).shape[0]:
661
+ # Some other problem
662
+ raise ValueError("Duplicate nodes found in cropped mesh.")
663
+
664
+ # Find the duplicate notes in mesh and remove from ind_nodes list
665
+ unique_elms, dup_elms = np.unique(mesh.nodes.node_coord, axis=0, return_counts=True)
666
+ assert unique_elms.shape[0] == dup_elms.shape[0]
667
+
668
+ # go over nodes that have been found twice
669
+ for dup_idx in np.where(dup_elms != 1)[0]:
670
+ found_dup_idx = np.argwhere(np.sum(mesh.nodes.node_coord == unique_elms[dup_idx], axis=1) == 3)
671
+ for i in range(1, len(found_dup_idx)):
672
+ try:
673
+ dup_2_rem = np.argwhere(ind_nodes == found_dup_idx[i])[0]
674
+ ind_nodes = np.delete(ind_nodes, dup_2_rem)
675
+ except IndexError:
676
+ pass
677
+
678
+ if skin_only_output:
679
+ # replace smoothed nodes in skin only mesh and write to disk
680
+ mesh_cropped.nodes.node_coord = smoothed_mesh.vertices # overwrite simnibs Mesh object's nodes
681
+ mesh_cropped.write(output_fn)
682
+ else:
683
+ # replace surface node_number list in full mesh and write to disk
684
+ mesh.nodes.node_coord[ind_nodes, :] = smoothed_mesh.vertices
685
+ mesh.write(output_fn)
686
+
687
+
688
+ def get_opt_mat(folder, roi=0):
689
+ """
690
+ Load optimal coil position/orientation matsimnibs from SimNIBS online FEM.
691
+
692
+ Parameter:
693
+ ----------
694
+ folder : str
695
+ Folder with optimization results.
696
+ roi : str or int, default: 0
697
+ Region of interest to read data for.
698
+
699
+ Returns
700
+ -------
701
+ opt_matsimnibs : np.ndarray
702
+ Optimal coil position/orientation.
703
+
704
+ """
705
+ e_fn = os.path.join(folder, 'e.hdf5')
706
+ coil_pos_fn = os.path.join(folder, 'search_positions.hdf5')
707
+ assert os.path.exists(e_fn)
708
+ assert os.path.exists(coil_pos_fn)
709
+
710
+ with h5py.File(e_fn, 'r') as f_e:
711
+ keys = list(f_e[f'e/roi_{roi}'].keys())
712
+ keys = [int(k) for k in keys]
713
+ keys.sort()
714
+ max_val = 0
715
+ best_idx = None
716
+ for k in tqdm.tqdm(keys, total=len(keys), desc="Getting max E"):
717
+ mean_e = f_e[f'e/roi_{roi}/{k:0>4}'][:].mean()
718
+ if mean_e > max_val:
719
+ max_val = mean_e
720
+ best_idx = k
721
+ print(f"Max E {max_val.round(2)} found for idx {best_idx}.")
722
+
723
+ with h5py.File(coil_pos_fn, 'r') as coil_f:
724
+ if coil_f['matsimnibs'][:].shape[2] != len(keys):
725
+ warnings.warn(f"{coil_pos_fn} and {e_fn} have different sizes: {len(keys)} vs "
726
+ f"{coil_f['matsimnibs'][:].shape[2]} "
727
+ f"coil positions.")
728
+ return coil_f['matsimnibs'][:, :, best_idx]
729
+
730
+
731
+ def get_skin_cortex_distance(mesh, coords, radius=5):
732
+ """
733
+ Computes the skin-cortex distance (SCD).
734
+
735
+ Parameters
736
+ ----------
737
+ mesh : str or simnibs.Mesh
738
+ Mesh object or .msh filename.
739
+ coords : np.ndarray
740
+ [3,] x,y,z coordinates to compute skin-cortex-distacnce for.
741
+ radius : float, default: 5
742
+ Spherical radius around ``coords`` to include points in for SCD computation.
743
+
744
+ Returns
745
+ -------
746
+ SCD : np.ndarray of float
747
+ Skin-cortex distance.
748
+ elm_in_roi : np.ndarray of int
749
+ Elelement numbers from mesh.elm.elm_number that where used to calculate PCD for.
750
+ """
751
+ from simnibs import read_msh
752
+ if isinstance(mesh, str):
753
+ mesh = read_msh(mesh)
754
+
755
+ centers = mesh.elements_baricenters()[:]
756
+ dist = np.linalg.norm(centers - coords, axis=1)
757
+ elm = mesh.elm.elm_number[
758
+ (dist < radius) *
759
+ np.isin(mesh.elm.tag1, [2]) * # only grey matter
760
+ np.isin(mesh.elm.elm_type, [4]) # only tetrahedra
761
+ ]
762
+ skin = centers[np.isin(mesh.elm.tag1, [1005])] # skin triangles
763
+ return np.linalg.norm(skin - coords, axis=1).min(), elm