loadpath 0.1.1__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 (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/nonlinear.py ADDED
@@ -0,0 +1,1095 @@
1
+ """
2
+ Large deflection and buckling, for parts that are meant to bend.
3
+
4
+ Everything in fem.py is linear: stiffness fixed at the undeformed shape,
5
+ strains small. That is right for a bracket and wrong for a TPU finger that
6
+ curls 60 degrees, a flexure hinge, a snap fit or a slender strut near
7
+ buckling. This module adds the two analyses those parts need, on the same
8
+ voxel hex mesh:
9
+
10
+ - `solve_nonlinear`: geometrically nonlinear static analysis. Total
11
+ Lagrangian 8-node hexahedra (2x2x2 Gauss), a hyperelastic material
12
+ (compressible neo-Hookean by default, St. Venant-Kirchhoff on request:
13
+ both reduce to fem.py's linear element at small strain), and Newton's
14
+ method on the residual with the full tangent (material + geometric
15
+ stiffness). Load is applied in increments that grow when Newton converges
16
+ quickly and halve when it does not; a Newton update that would turn a
17
+ cell inside out is halved instead of failing the step. Elements are
18
+ built by a compiled kernel on all cores (nlkernel.py). Loads are dead (they keep their
19
+ direction as the part deforms). Supports can be pushed instead of loaded
20
+ (`prescribed`), which follows a part through a snap-through that load
21
+ control cannot pass, and reports the force it takes (`reaction`).
22
+ - `linear_buckling`: the classic eigenvalue estimate. Solve the linear
23
+ problem, build the geometric stiffness of its stresses, and find load
24
+ factors lambda with (K + lambda K_sigma) phi = 0. lambda < 1 means the
25
+ load buckles the part; the modes say how.
26
+
27
+ Contact (contact.py: rigid planes, spheres, cylinders and boxes, fixed or
28
+ moving with the load, and the part's surface on itself) and tendons
29
+ (cables through guides at a tension, or wound in by a servo) go in as
30
+ `contact=` and `tendons=`; both follow the deformed part.
31
+ `large_deflection_report` runs all of it from a field graph's
32
+ `largeDeflection` block.
33
+
34
+ `verify_mechanism` puts both behind one call for a compliant mechanism
35
+ design from mechanism.py: the solid cells of the result, the real input
36
+ force, and the output's travel compared with what the linear model
37
+ promised.
38
+
39
+ Plain linear hexahedra are stiff in bending (shear locking): one or two
40
+ cells across a flexure overestimate its stiffness badly. As in fem.py, the
41
+ element carries Wilson's nine incompatible bubble modes, here as an enhanced
42
+ displacement gradient (Simo-Armero) condensed element by element at each
43
+ Newton iteration, which removes the locking; at small strain it is fem.py's
44
+ linear element exactly. `element="plain"` (or LOADPATH_HEX8=plain) gives the
45
+ old trilinear element. mechanism_nl.py's optimizer still uses the plain one. Validation against the elastica and Euler's column is in
46
+ tests/test_nonlinear.py.
47
+
48
+ Units as everywhere in loadpath: mm, N, MPa.
49
+ """
50
+ from __future__ import annotations
51
+
52
+ from dataclasses import dataclass, field
53
+
54
+ import numpy as np
55
+ import scipy.sparse as sp
56
+ from scipy.sparse.linalg import LinearOperator, eigsh, splu
57
+
58
+ from .fem import CORNERS, HexMesh, Solver, Stiffness, _hex8_parts, hex8_element, von_mises
59
+
60
+ __all__ = ["NonlinearResult", "BucklingResult", "Tendon", "nodes_near", "solve_nonlinear", "linear_buckling", "stroke_mechanism",
61
+ "large_deflection_report",
62
+ "solid_submesh", "verify_mechanism"]
63
+
64
+ # Above this many dofs the Newton steps use CG + AMG instead of a direct
65
+ # factorization (SuperLU's fill on 3D hex meshes grows fast: 1.5 s at 20k
66
+ # dofs, 9 s at 40k). CG needs the tangent positive definite, so there a load
67
+ # step that lands on an unstable state is cut back, and the stability check
68
+ # runs LOBPCG with the multigrid preconditioner instead of shift-invert.
69
+ DIRECT_MAX_DOF = 30_000
70
+ # Step control. A step that converged in at most GROW_NEWTON iterations
71
+ # (assemblies, the converged one included) lets the next one grow 1.5x.
72
+ # A retry with line search (after plain Newton failed a contact step) that
73
+ # has not halved its best residual in STALL_NEWTON iterations is chattering
74
+ # (contacts switching on and off) and gives up the step: a shorter step is
75
+ # cheaper than more iterations. (Plain Newton gets its full max_newton: a
76
+ # contact plateau of ten or more iterations often ends in convergence.)
77
+ GROW_NEWTON = 6
78
+ STALL_NEWTON = 8
79
+ _CHUNK = 1024 # elements per batch: big enough to vectorize, small enough to stay in cache
80
+ _VOIGT = [(0, 0), (1, 1), (2, 2), (0, 1), (1, 2), (2, 0)] # fem.py order: xx yy zz xy yz zx
81
+ _VI = np.array([p[0] for p in _VOIGT])
82
+ _VJ = np.array([p[1] for p in _VOIGT])
83
+
84
+
85
+ def _dn(xi, eta, zeta):
86
+ """Shape function gradients (8, 3) of the unit cube at a natural point."""
87
+ s = 2.0 * CORNERS - 1.0
88
+ dn = np.empty((8, 3))
89
+ dn[:, 0] = 0.25 * s[:, 0] * (1 + eta * s[:, 1]) * (1 + zeta * s[:, 2])
90
+ dn[:, 1] = 0.25 * s[:, 1] * (1 + xi * s[:, 0]) * (1 + zeta * s[:, 2])
91
+ dn[:, 2] = 0.25 * s[:, 2] * (1 + xi * s[:, 0]) * (1 + eta * s[:, 1])
92
+ return dn
93
+
94
+
95
+ _G = 1.0 / np.sqrt(3.0)
96
+ _GAUSS = [_dn(a, b, c) for a in (-_G, _G) for b in (-_G, _G) for c in (-_G, _G)]
97
+ _CENTER = _dn(0.0, 0.0, 0.0)
98
+
99
+
100
+ def _lame(nu):
101
+ return nu / ((1 + nu) * (1 - 2 * nu)), 1.0 / (2 * (1 + nu)) # at E = 1
102
+
103
+
104
+ _I3 = np.eye(3)
105
+ # Products of shape function gradients at each Gauss point, for the tangent
106
+ # as one matrix product: W[g][(l, n), (a, b)] = dN_a/dX_l dN_b/dX_n (unit cube).
107
+ _W = np.stack([np.einsum("al,bn->lnab", g, g).reshape(9, 64) for g in _GAUSS])
108
+ # The incompatible-mode element: three more "nodes" whose rows are the
109
+ # gradients of the bubbles 1 - xi_m^2 on the unit cube (-4 xi_m along axis m;
110
+ # fem._b_bubble's modes), so their amplitudes alpha (3 modes x 3 components)
111
+ # enter F like nodal displacements.
112
+ _GAUSS_IM = [np.vstack([dn, np.diag(-4.0 * np.array(p))]) for dn, p in
113
+ zip(_GAUSS, [(a, b, c) for a in (-_G, _G) for b in (-_G, _G) for c in (-_G, _G)])]
114
+ _W_IM = np.stack([np.einsum("al,bn->lnab", g, g).reshape(9, 121) for g in _GAUSS_IM])
115
+
116
+
117
+ def _material(F, nu, kind):
118
+ """2nd Piola-Kirchhoff stress S (m, 3, 3) and the material tangent
119
+ dS/dE as a 4th-order tensor (m, 3, 3, 3, 3), at unit modulus."""
120
+ lam, mu = _lame(nu)
121
+ C = np.einsum("mki,mkj->mij", F, F)
122
+ if kind == "svk":
123
+ E = 0.5 * (C - _I3)
124
+ tr = np.trace(E, axis1=1, axis2=2)
125
+ S = lam * tr[:, None, None] * _I3 + 2 * mu * E
126
+ A = (lam * np.einsum("ij,kl->ijkl", _I3, _I3)
127
+ + mu * (np.einsum("ik,jl->ijkl", _I3, _I3) + np.einsum("il,jk->ijkl", _I3, _I3)))
128
+ return S, np.broadcast_to(A, (len(F), 3, 3, 3, 3))
129
+ J = np.linalg.det(F)
130
+ lnJ = np.log(np.maximum(J, 1e-12))
131
+ Ci = np.linalg.inv(C)
132
+ S = mu * (_I3 - Ci) + (lam * lnJ)[:, None, None] * Ci
133
+ a = Ci[:, :, None, :, None] * Ci[:, None, :, None, :] # Ci_ik Ci_jl
134
+ A = lam * Ci[:, :, :, None, None] * Ci[:, None, None, :, :] \
135
+ + (mu - lam * lnJ)[:, None, None, None, None] * (a + a.transpose(0, 1, 2, 4, 3))
136
+ return S, A
137
+
138
+
139
+ def _gauss_terms(ux, grads, wmat, h, nu, kind, tangent):
140
+ """Internal force (m, 3n) and tangent (m, 3n, 3n) at unit modulus for
141
+ elements with generalized displacements ux (m, n, 3), whose deformation
142
+ gradient at Gauss point g is I + sum_a ux_a (x) grads[g][a] / h, and the
143
+ smallest volume ratio J seen at a Gauss point.
144
+
145
+ With P = F S the first Piola-Kirchhoff stress, f_ai = sum_g w P_il G_al,
146
+ and the tangent is K_aibj = sum_g w G_al Q_iljn G_bn with
147
+ Q_iljn = F_ik A_klpn F_jp + S_ln delta_ij (material + geometric): a
148
+ (m*9, 9) x (9, n^2) matrix product per Gauss point instead of 3n x 3n
149
+ products per element (all eight stacked into one)."""
150
+ m, n = ux.shape[:2]
151
+ w = h ** 3 / 8.0
152
+ fint = np.zeros((m, n, 3))
153
+ qall = np.empty((m, 9, 8, 9)) if tangent else None
154
+ jmin = np.inf
155
+ for g, dn0 in enumerate(grads):
156
+ dn = dn0 / h
157
+ F = _I3 + np.einsum("mai,aj->mij", ux, dn)
158
+ jmin = min(jmin, float(np.linalg.det(F).min()))
159
+ S, A = _material(F, nu, kind)
160
+ P = F @ S
161
+ fint += w * (P @ dn.T).transpose(0, 2, 1)
162
+ if tangent:
163
+ fa = (F @ A.reshape(m, 3, 27)).reshape(m, 3, 3, 3, 3)
164
+ Q = np.einsum("milpn,mjp->mijln", fa, F, optimize=True)
165
+ Q += _I3[None, :, :, None, None] * S[:, None, None, :, :]
166
+ qall[:, :, g, :] = Q.reshape(m, 9, 9)
167
+ kt = None
168
+ if tangent:
169
+ kq = qall.reshape(m * 9, 72) @ (wmat.reshape(72, n * n) * (w / h ** 2))
170
+ kt = kq.reshape(m, 3, 3, n, n).transpose(0, 3, 1, 4, 2).reshape(m, 3 * n, 3 * n)
171
+ return fint.reshape(m, 3 * n), kt, jmin
172
+
173
+
174
+ def _element(ue, h, nu, kind, tangent, alpha=None):
175
+ """Internal force (m, 24) and tangent (m, 24, 24) at unit modulus for
176
+ elements with nodal displacements ue (m, 8, 3), and the smallest volume
177
+ ratio J seen at a Gauss point (<= 0 means an element turned inside out).
178
+
179
+ alpha None: the plain trilinear hexahedron. Otherwise (m, 3, 3), the
180
+ amplitudes of the nine incompatible modes (fem.hex8_element's bubbles,
181
+ here as an enhanced displacement gradient, Simo-Armero): the element
182
+ takes one local Newton step on them, condenses them out, and returns
183
+ (fint, kt, jmin, alpha_new, R_alpha (m, 9)). fint is the condensed
184
+ residual R_u - K_ua K_aa^-1 R_a, i.e. R_u at alpha_new to first order,
185
+ so the global Newton on u stays quadratic. At u = 0 the condensed
186
+ tangent is fem.py's linear incompatible-mode element exactly."""
187
+ if alpha is None:
188
+ return _gauss_terms(ue, _GAUSS, _W, h, nu, kind, tangent)
189
+ m = len(ue)
190
+ ux = np.concatenate([ue, alpha], axis=1) # (m, 11, 3)
191
+ fx, kx, jmin = _gauss_terms(ux, _GAUSS_IM, _W_IM, h, nu, kind, True)
192
+ ru, ra = fx[:, :24], fx[:, 24:]
193
+ kua, kaa = kx[:, :24, 24:], kx[:, 24:, 24:]
194
+ sol = np.linalg.solve(kaa, np.concatenate([ra[:, :, None], kua.transpose(0, 2, 1)], axis=2))
195
+ da, kaa_kau = sol[:, :, 0], sol[:, :, 1:]
196
+ fint = ru - np.einsum("mij,mj->mi", kua, da)
197
+ kt = kx[:, :24, :24] - kua @ kaa_kau if tangent else None
198
+ alpha_new = alpha - da.reshape(m, 3, 3)
199
+ return fint, kt, jmin, alpha_new, ra, kaa_kau
200
+
201
+
202
+ def _assemble(stiff, u, mod, nu, kind, tangent=True, alpha=None, coupling=None):
203
+ """Global internal force and tangent; with alpha (n_elem, 3, 3), the
204
+ incompatible-mode element, also (alpha_new, |R_alpha|) with R_alpha
205
+ weighted by the element moduli as the forces are."""
206
+ mesh = stiff.mesh
207
+ from . import nlkernel
208
+ if nlkernel.available():
209
+ grads = np.stack(_GAUSS_IM if alpha is not None else _GAUSS)
210
+ fe, ke, jm, a_new, ra, cpl = nlkernel.elements(u, mesh.edof, alpha, mod, mesh.pitch, nu, kind, tangent, grads)
211
+ fint, data = nlkernel.scatter(mesh.n_dof, stiff.nnz, stiff._slots, mesh.edof, fe, ke, tangent)
212
+ k = stiff.finish(data) if tangent else None
213
+ jmin = float(jm.min()) if len(jm) else np.inf
214
+ if alpha is None:
215
+ return fint, k, jmin
216
+ if coupling is not None:
217
+ coupling[:] = cpl
218
+ return fint, k, jmin, a_new, float(np.sqrt(((ra * mod[:, None]) ** 2).sum()))
219
+ fint = np.zeros(mesh.n_dof)
220
+ data = np.zeros(stiff.nnz) if tangent else None
221
+ jmin = np.inf
222
+ a_new = None if alpha is None else np.empty_like(alpha)
223
+ ra2 = 0.0
224
+ for s in range(0, mesh.n_elem, _CHUNK):
225
+ el = np.arange(s, min(s + _CHUNK, mesh.n_elem))
226
+ edof = mesh.edof[el]
227
+ ue = u[edof].reshape(-1, 8, 3)
228
+ me = mod[el]
229
+ if alpha is None:
230
+ fe, ke, j = _element(ue, mesh.pitch, nu, kind, tangent)
231
+ else:
232
+ fe, ke, j, a_new[el], ra, cpl = _element(ue, mesh.pitch, nu, kind, tangent, alpha[el])
233
+ if coupling is not None:
234
+ coupling[el] = cpl
235
+ ra2 += float(((ra * me[:, None]) ** 2).sum())
236
+ jmin = min(jmin, j)
237
+ np.add.at(fint, edof.ravel(), (fe * me[:, None]).ravel())
238
+ if tangent:
239
+ stiff.add_elements(data, ke * me[:, None, None], el)
240
+ k = stiff.finish(data) if tangent else None
241
+ if alpha is None:
242
+ return fint, k, jmin
243
+ return fint, k, jmin, a_new, np.sqrt(ra2)
244
+
245
+
246
+ def _splu(k):
247
+ # symmetric pattern: minimum degree on A^T + A, no pivoting off the diagonal
248
+ return splu(k.tocsc(), permc_spec="MMD_AT_PLUS_A", diag_pivot_thresh=0.0, options={"SymmetricMode": True})
249
+
250
+
251
+ class _Linear:
252
+ """Direct factorization for small and medium problems, CG + AMG above
253
+ (the hierarchy is reused across Newton iterations until it goes stale)."""
254
+
255
+ def __init__(self, mesh):
256
+ self.direct = mesh.n_dof <= DIRECT_MAX_DOF
257
+ self.iter = None if self.direct else Solver(mesh, rtol=1e-9, maxiter=2000)
258
+
259
+ def solve(self, k, r):
260
+ if self.direct:
261
+ return _splu(k).solve(r)
262
+ return self.iter.solve(k, r)
263
+
264
+ def lowest_mode(self, k):
265
+ """Smallest eigenvalue of a (symmetric) tangent and its mode."""
266
+ n = k.shape[0]
267
+ if self.direct:
268
+ lu = _splu(k)
269
+ op = LinearOperator((n, n), matvec=lu.solve, dtype=float)
270
+ # a fixed start vector: ARPACK's random one made the branch
271
+ # a perfect column takes (and so whether it found it) vary run to run
272
+ v0 = np.random.default_rng(0).standard_normal(n)
273
+ vals, vecs = eigsh(k, k=1, sigma=0.0, which="LM", OPinv=op, tol=1e-6, v0=v0)
274
+ return float(vals[0]), vecs[:, 0]
275
+ from scipy.sparse.linalg import lobpcg
276
+ m = self.iter._precond(k)
277
+ x0 = np.random.default_rng(0).standard_normal((n, 1))
278
+ import warnings
279
+ with warnings.catch_warnings():
280
+ # only the sign matters here; a loose eigenvalue is fine
281
+ warnings.simplefilter("ignore", UserWarning)
282
+ vals, vecs = lobpcg(k, x0, M=m, largest=False, tol=1e-6, maxiter=300)
283
+ return float(vals[0]), vecs[:, 0]
284
+
285
+
286
+ @dataclass
287
+ class NonlinearResult:
288
+ u: np.ndarray # displacement per dof at the last converged load
289
+ load_factor: float # fraction of the load (or stroke) reached; 1.0 when complete
290
+ converged: bool # reached the full load
291
+ history: list = field(default_factory=list) # per converged step
292
+ stress: np.ndarray | None = None # element-center Cauchy von Mises, MPa
293
+ strain: np.ndarray | None = None # element-center largest principal stretch - 1
294
+ message: str = ""
295
+
296
+
297
+ def _post(mesh, u, mod, nu, kind):
298
+ ue = u[mesh.edof].reshape(-1, 8, 3)
299
+ F = np.eye(3) + np.einsum("mai,aj->mij", ue, _CENTER / mesh.pitch)
300
+ S, _ = _material(F, nu, kind)
301
+ J = np.linalg.det(F)
302
+ sig = np.einsum("mik,mkl,mjl->mij", F, S, F) / J[:, None, None] * mod[:, None, None]
303
+ vm = von_mises(sig[:, _VI, _VJ])
304
+ C = np.einsum("mki,mkj->mij", F, F)
305
+ stretch = np.sqrt(np.maximum(np.linalg.eigvalsh(C)[:, -1], 0.0))
306
+ return vm, stretch - 1.0
307
+
308
+
309
+ @dataclass
310
+ class Tendon:
311
+ """A cable at constant tension through guides on the part, like a
312
+ tendon in its channel: each point of `path` is either a fixed point in
313
+ space (a float array of 3, e.g. the servo's spool or a palm pulley) or
314
+ a guide on the part (an int array of node ids; the guide sits at their
315
+ mean and moves with them). The cable runs straight between consecutive
316
+ points and slides freely through the guides, so each guide is pulled
317
+ toward both its neighbours along the deformed cable and the last point
318
+ (the knot) toward the one before it. The load follows the part as it
319
+ curls, which a dead force does not: on a curling finger a fixed-
320
+ direction force gains moment arm, a tendon's stays in its channel.
321
+
322
+ Constant tension makes it conservative (potential T * cable length), so
323
+ the load stiffness is symmetric and Newton keeps its tangent symmetric.
324
+ Tension scales with the load factor like `force`. Each step records the
325
+ tendon's travel (how much cable the actuator has pulled in, mm)."""
326
+ tension: float
327
+ path: list
328
+
329
+ def _setup(self, mesh):
330
+ pts = []
331
+ for p in self.path:
332
+ a = np.asarray(p)
333
+ if a.dtype.kind in "iu":
334
+ ids = np.unique(a.ravel()).astype(np.int64)
335
+ if len(ids) == 0:
336
+ raise ValueError("Tendon: a guide with no nodes")
337
+ w = np.full(len(ids), 1.0 / len(ids))
338
+ rows = np.repeat(np.arange(3), len(ids))
339
+ cols = (3 * ids[None, :] + np.arange(3)[:, None]).ravel()
340
+ g = sp.csr_matrix((np.tile(w, 3), (rows, cols)), shape=(3, mesh.n_dof))
341
+ pts.append((mesh.node_xyz[ids].mean(axis=0), g))
342
+ else:
343
+ if a.shape != (3,):
344
+ raise ValueError("Tendon: a fixed point is 3 floats; a guide is an int array of node ids")
345
+ pts.append((a.astype(float), None))
346
+ if len(pts) < 2:
347
+ raise ValueError("Tendon: the path needs at least two points")
348
+ segs = []
349
+ for (xa, ga), (xb, gb) in zip(pts[:-1], pts[1:]):
350
+ gd = (gb if gb is not None else 0) - (ga if ga is not None else 0)
351
+ if isinstance(gd, int):
352
+ continue # between two fixed points: no load on the part
353
+ segs.append((xb - xa, sp.csr_matrix(gd)))
354
+ self._segs = segs
355
+ self._len0 = sum(np.linalg.norm(x0) for x0, _ in segs)
356
+ # all segments at once: d = d0 + G u, (S, 3)
357
+ self._d0 = np.array([x0 for x0, _ in segs]).reshape(-1, 3)
358
+ self._G = sp.vstack([gd for _, gd in segs]).tocsr() if segs else sp.csr_matrix((0, mesh.n_dof))
359
+ S = len(segs)
360
+ self._bi = (3 * np.arange(S)[:, None, None] + np.arange(3)[None, :, None] + 0 * np.arange(3)[None, None, :]).ravel()
361
+ self._bj = (3 * np.arange(S)[:, None, None] + 0 * np.arange(3)[None, :, None] + np.arange(3)[None, None, :]).ravel()
362
+
363
+ def _load(self, u, scale):
364
+ """Force on the part (per dof), load stiffness, and cable length
365
+ (over the segments that touch the part)."""
366
+ t = self.tension * scale
367
+ n = u.shape[0]
368
+ S = len(self._d0)
369
+ if S == 0:
370
+ return np.zeros(n), sp.csr_matrix((n, n)), 0.0
371
+ d = self._d0 + (self._G @ u).reshape(S, 3)
372
+ ell = np.linalg.norm(d, axis=1)
373
+ e = d / ell[:, None]
374
+ f = -t * (self._G.T @ e.ravel())
375
+ blocks = t * (np.eye(3)[None] - np.einsum("si,sj->sij", e, e)) / ell[:, None, None]
376
+ mb = sp.csr_matrix((blocks.ravel(), (self._bi, self._bj)), shape=(3 * S, 3 * S))
377
+ k = (self._G.T @ mb @ self._G).tocsr()
378
+ return f, k, float(ell.sum())
379
+
380
+
381
+ def nodes_near(mesh: HexMesh, point, radius: float) -> np.ndarray:
382
+ """Node ids within `radius` of `point`: a tendon guide or knot."""
383
+ d = np.linalg.norm(mesh.node_xyz - np.asarray(point, float), axis=1)
384
+ ids = np.nonzero(d <= radius)[0]
385
+ if len(ids) == 0:
386
+ raise ValueError(f"no nodes within {radius} of {point}")
387
+ return ids
388
+
389
+
390
+ def solve_nonlinear(mesh: HexMesh, nu: float, modulus, fixed: np.ndarray, force: np.ndarray | None = None,
391
+ prescribed: tuple | None = None, *, material: str = "neo-hookean", steps: int = 10,
392
+ max_step: float = 0.25, min_step: float = 1e-4, rtol: float = 1e-7, max_newton: int = 25,
393
+ springs: np.ndarray | None = None, tendons: list | None = None, contact: list | None = None,
394
+ stability: bool = True, probes: dict | None = None,
395
+ on_step=None, element: str | None = None) -> NonlinearResult:
396
+ """Geometrically nonlinear static solve.
397
+
398
+ modulus: E, scalar or per element (MPa). fixed: bool per dof, held at 0.
399
+ force: force per dof (N) at full load. prescribed: (dofs, values) moved to
400
+ `values` (mm) at full load, e.g. an actuator's stroke. probes: name ->
401
+ weight per dof; each step records weight . u (with unit-sum weights over
402
+ a face along a direction, the face's mean travel along it). tendons:
403
+ Tendon loads (cables through guides) that follow the deformed part, at
404
+ constant tension, or mechanism_nl.ServoTendon (a servo winding in a
405
+ stroke of cable through a compliant cable: displacement driven).
406
+ contact: contact.RigidObstacle (a rigid plane, sphere, cylinder or box,
407
+ fixed or moving with the load) and contact.SelfContact (the part's
408
+ surface on itself), penalty, frictionless or with Coulomb friction
409
+ (`friction=mu`: sticks until the tangential force reaches mu times the
410
+ normal force, then slides; history dependent, updated at each converged
411
+ step). Each step records each contact's force, nodes touching and
412
+ deepest penetration, with friction the friction force and the nodes
413
+ sliding.
414
+ springs:
415
+ grounded linear springs, N/mm per dof (an actuator's compliance, a
416
+ workpiece pushing back).
417
+
418
+ stability: after each step, check the tangent's lowest eigenvalue. Newton
419
+ happily converges to an equilibrium that is unstable (a column kept
420
+ straight past its buckling load, a snap-through that hasn't snapped);
421
+ when the tangent has a negative eigenvalue the solve kicks the part
422
+ along that mode and finds the stable state nearby, as a real part would
423
+ (a print is never perfectly straight).
424
+
425
+ element: "im" (fem.hex8_element's default) or "plain". The incompatible
426
+ modes take bending without shear locking, so one or two cells across a
427
+ flexure no longer read it too stiff.
428
+
429
+ Each history entry has the load factor, Newton iterations, the probes,
430
+ with prescribed dofs the reaction there (the force the actuator pushes
431
+ with, N, as a 3-vector summed over the prescribed nodes), and with
432
+ stability the tangent's lowest eigenvalue and whether the step had to
433
+ leave an unstable branch ("branchSwitch").
434
+ """
435
+ n = mesh.n_dof
436
+ mod = np.broadcast_to(np.asarray(modulus, float), (mesh.n_elem,)).copy()
437
+ f = np.zeros(n) if force is None else np.asarray(force, float)
438
+ cons = np.asarray(fixed, bool).copy()
439
+ pd, pv = (np.zeros(0, np.int64), np.zeros(0)) if prescribed is None else \
440
+ (np.asarray(prescribed[0], np.int64), np.asarray(prescribed[1], float))
441
+ cons[pd] = True
442
+ element = element or hex8_element()
443
+ stiff = Stiffness(mesh, nu, cons, element=element)
444
+ lin = _Linear(mesh)
445
+ # incompatible-mode amplitudes per element at the last converged state
446
+ alpha = np.zeros((mesh.n_elem, 3, 3)) if element == "im" else None
447
+ probes = probes or {}
448
+ pnodes = np.unique(pd // 3)
449
+ size = float(np.linalg.norm(np.ptp(mesh.node_xyz, axis=0)))
450
+ # Don't let one Newton update move any node more than a fraction of the
451
+ # part's size (a wild first iterate after a big step).
452
+ cap = 0.25 * size
453
+ kick_step = min(0.02, 0.25 / steps)
454
+ seeded = False
455
+
456
+ spr = None
457
+ if springs is not None:
458
+ sd = np.where(cons, 0.0, np.asarray(springs, float))
459
+ spr = sp.diags(sd).tocsr()
460
+
461
+ tendons = tendons or []
462
+ keep_free = sp.diags((~cons).astype(float))
463
+ for t in tendons:
464
+ t._setup(mesh)
465
+ from .contact import SelfContact
466
+ contact = contact or []
467
+ for c in contact:
468
+ if isinstance(c, SelfContact):
469
+ c._setup(mesh, mod, fixed=cons)
470
+ else:
471
+ c._setup(mesh, mod)
472
+ loads = tendons + contact
473
+ if contact:
474
+ # active sets settle over a few iterations, and a node sliding from
475
+ # one face to the next leaves a residual of about 1e-7 of the
476
+ # contact force: converge to 1e-5 of the load (the penalty itself
477
+ # is a far bigger approximation)
478
+ max_newton = max(max_newton, 40)
479
+ rtol = max(rtol, 1e-5)
480
+ # Coulomb friction's sliding tangent is not symmetric: the direct LU
481
+ # takes it as it is; CG and the stability check take its symmetric part
482
+ # (and so is self contact's, with the faces' turning in it)
483
+ def _unsym(c):
484
+ return getattr(c, "friction", 0.0) > 0 or getattr(c, "rotation", False)
485
+ asym = any(_unsym(c) for c in contact)
486
+
487
+ def sym(k):
488
+ return (0.5 * (k + k.T)).tocsr() if asym else k
489
+
490
+ def stab(k, v, target):
491
+ """The tangent for the stability check: with friction, without the
492
+ sliding force's growth with the normal force, and self contact's
493
+ without the faces' turning (both unsymmetric, and their symmetric
494
+ parts are indefinite: not a buckle)."""
495
+ if not asym:
496
+ return k
497
+ for c in contact:
498
+ if _unsym(c):
499
+ kf = c._load(v, target)[1]
500
+ c._symmetric = True
501
+ try:
502
+ ks = c._load(v, target)[1]
503
+ finally:
504
+ c._symmetric = False
505
+ k = k + keep_free @ (ks - kf) @ keep_free
506
+ return sym(k)
507
+
508
+ # the smallest force worth resolving: a strain of 1e-6 on one cell face
509
+ # (a part only carried along rigidly, e.g. towards a contact it has not
510
+ # reached yet, has no load to measure its residual against)
511
+ fscale = 1e-6 * float(mod.max()) * mesh.pitch ** 2
512
+
513
+ def newton(v, target, a=None, search=False):
514
+ rnorm0 = None
515
+ a = alpha if a is None else a
516
+ # K_aa^-1 K_au per element (incompatible modes), two buffers so a
517
+ # backtrack can still use the last good iterate's
518
+ bufs = None if a is None else [np.empty((mesh.n_elem, 9, 24)) for _ in range(2)]
519
+ cur, prev, cuts, best = 0, None, 0, None
520
+
521
+ def follow(a_lin, c, d):
522
+ # the bubbles follow the nodes: alpha += -K_aa^-1 K_au du
523
+ if c is None:
524
+ return a_lin
525
+ return a_lin - np.einsum("mij,mj->mi", c, d[mesh.edof]).reshape(-1, 3, 3)
526
+
527
+ for its in range(1, max_newton + 1):
528
+ ra = 0.0
529
+ cpl = None if bufs is None else bufs[cur]
530
+ if a is None:
531
+ fint, k, jmin = _assemble(stiff, v, mod, nu, material)
532
+ a_lin = None
533
+ else:
534
+ fint, k, jmin, a_lin, ra = _assemble(stiff, v, mod, nu, material, alpha=a, coupling=cpl)
535
+ if not np.isfinite(jmin) or jmin <= 0:
536
+ # the update turned a cell inside out: halve it (a damped
537
+ # Newton step), rather than give up the whole load step
538
+ if prev is None or cuts >= 8:
539
+ return None
540
+ v0, a0, d, c0 = prev
541
+ d = 0.5 * d
542
+ prev = v0, a0, d, c0
543
+ v, a = v0 + d, follow(a0, c0, d)
544
+ cuts += 1
545
+ continue
546
+ if spr is not None:
547
+ fint = fint + spr @ v
548
+ k = (k + spr).tocsr()
549
+ fext = target * f
550
+ for t in loads:
551
+ ft, kt, _ = t._load(v, target)
552
+ fext = fext + ft
553
+ # constrained rows and columns stay identity, as Stiffness leaves them
554
+ k = (k + keep_free @ kt @ keep_free).tocsr()
555
+ r = fext - fint
556
+ r[cons] = 0.0
557
+ ref = max(np.linalg.norm(fext), np.linalg.norm(fint[pd]) if len(pd) else 0.0, fscale)
558
+ rnorm = np.hypot(np.linalg.norm(r), ra)
559
+ if rnorm <= rtol * ref:
560
+ return v, its, fint, k, a_lin
561
+ if rnorm0 is None:
562
+ rnorm0 = rnorm
563
+ elif its > 6 and rnorm > rnorm0:
564
+ return None # diverging
565
+ if best is None or rnorm < 0.5 * best[0]:
566
+ best = rnorm, its
567
+ elif search and its - best[1] >= STALL_NEWTON:
568
+ return None # stalled (contact chatter): a shorter step is cheaper
569
+ try:
570
+ d = lin.solve(k if lin.direct else sym(k), r)
571
+ except RuntimeError:
572
+ return None
573
+ if not np.all(np.isfinite(d)):
574
+ return None
575
+ d[cons] = 0.0
576
+ big = np.abs(d).max()
577
+ if big > cap:
578
+ d *= cap / big
579
+ if search:
580
+ # contact switches on and off between iterations, and its
581
+ # tangent leaves out the faces' rotation, so Newton can
582
+ # cycle: on a retry, backtrack a step that makes the
583
+ # residual worse
584
+ for _ in range(5):
585
+ rt = trial(v + d, target, follow(a_lin, cpl, d) if a_lin is not None else None)
586
+ if rt is not None and rt < rnorm:
587
+ break
588
+ d *= 0.5
589
+ prev = v, a_lin, d, cpl
590
+ v, a = v + d, (follow(a_lin, cpl, d) if a_lin is not None else None)
591
+ cur = 1 - cur
592
+ return None
593
+
594
+ def trial(v, target, a):
595
+ """Residual norm at v without the tangent (None if a cell inverts)."""
596
+ if a is None:
597
+ fint, _, jmin = _assemble(stiff, v, mod, nu, material, tangent=False)
598
+ ra = 0.0
599
+ else:
600
+ fint, _, jmin, _, ra = _assemble(stiff, v, mod, nu, material, tangent=False, alpha=a)
601
+ if not np.isfinite(jmin) or jmin <= 0:
602
+ return None
603
+ if spr is not None:
604
+ fint = fint + spr @ v
605
+ fext = target * f
606
+ for t in loads:
607
+ fext = fext + t._load(v, target)[0]
608
+ r = fext - fint
609
+ r[cons] = 0.0
610
+ return float(np.hypot(np.linalg.norm(r), ra))
611
+
612
+ u = np.zeros(n)
613
+ lam, dlam = 0.0, min(1.0 / steps, max_step)
614
+ du_prev, dlam_prev = None, None
615
+ history = []
616
+ message = ""
617
+ touch_step = max(4 * min_step, 1.0 / 128)
618
+ while lam < 1.0 - 1e-12:
619
+ dlam = min(dlam, 1.0 - lam)
620
+ target = lam + dlam
621
+ # Predictor: extrapolate the last step's increment (the prescribed
622
+ # dofs move exactly by it).
623
+ v = u.copy()
624
+ if du_prev is not None:
625
+ v += du_prev * (dlam / dlam_prev)
626
+ v[pd] = target * pv
627
+ out = newton(v, target)
628
+ if out is None and contact:
629
+ out = newton(v, target, search=True)
630
+ if out is None:
631
+ if dlam <= min_step:
632
+ message = (f"stopped at {lam:.3f} of the load: no equilibrium past it under load control "
633
+ "(a limit point, i.e. buckling or snap-through, or the part folded). "
634
+ "Try a prescribed displacement instead of a force.")
635
+ break
636
+ dlam *= 0.5
637
+ continue
638
+ v, its, fint, k, a_conv = out
639
+ if asym and dlam > touch_step:
640
+ # friction: a contact that touches during a step measures its
641
+ # slip from the touch along a straight path through the step,
642
+ # which the part did not take (it moved rigidly up to the touch
643
+ # and deformed after; the stiff penalty turns the difference
644
+ # into a spurious stick force). When contacts carrying a good
645
+ # share of the load touched during the step, bisect towards the
646
+ # touch so the step that makes it is short.
647
+ for c in contact:
648
+ if getattr(c, "friction", 0.0) > 0:
649
+ c._load(v, target)
650
+ share = max((c._early[0] for c in contact if getattr(c, "friction", 0.0) > 0), default=0.0)
651
+ if share > 0.25:
652
+ dlam = max(0.5 * dlam, touch_step)
653
+ continue
654
+ rec = {"loadFactor": target, "newton": its}
655
+ if stability:
656
+ lmin, phi = lin.lowest_mode(stab(k, v, target))
657
+ rec["lowestEigenvalue"] = lmin
658
+ tol = 1e-9 * abs(k.diagonal()).max()
659
+ if lmin < -tol:
660
+ # An unstable equilibrium. First creep up on it: with any
661
+ # imperfection the stable path bends away smoothly and small
662
+ # steps follow it. At the bifurcation itself (a perfect
663
+ # part), kick along the unstable mode until Newton lands on
664
+ # a stable state. CG can't solve with an indefinite tangent,
665
+ # so on large meshes seed a small imperfection instead (a
666
+ # force along the unstable mode, 0.1% of the load) and
667
+ # redo the step from the last stable state.
668
+ if not lin.direct and not seeded:
669
+ scale = max(np.linalg.norm(f), np.linalg.norm(fint[pd]) / max(target, 1e-12) if len(pd) else 0.0)
670
+ f = f + 1e-3 * scale * np.where(cons, 0.0, phi) / np.linalg.norm(phi)
671
+ seeded = True
672
+ dlam *= 0.5
673
+ continue
674
+ if dlam > kick_step:
675
+ dlam *= 0.5
676
+ continue
677
+ phi = phi / np.abs(phi).max()
678
+ for amp in ((0.01, 0.03, 0.1, 0.3) if lin.direct else ()):
679
+ found = None
680
+ for sgn in (1.0, -1.0):
681
+ o = newton(v + sgn * amp * size * phi, target)
682
+ if o is not None:
683
+ l2 = lin.lowest_mode(stab(o[3], o[0], target))[0]
684
+ if l2 >= -tol:
685
+ found = o, l2
686
+ break
687
+ if found is not None:
688
+ (v, its2, fint, k, a_conv), l2 = found
689
+ rec["newton"] += its2
690
+ rec["branchSwitch"] = True
691
+ rec["lowestEigenvalue"] = l2
692
+ break
693
+ else:
694
+ rec["unstable"] = True
695
+ du_prev, dlam_prev = (None if rec.get("branchSwitch") else v - u), dlam
696
+ u, lam = v, target
697
+ alpha = a_conv
698
+ for name, wv in probes.items():
699
+ rec[name] = float(wv @ u)
700
+ if len(pd):
701
+ rec["reaction"] = fint.reshape(-1, 3)[pnodes].sum(axis=0).tolist()
702
+ for i, t in enumerate(tendons):
703
+ rec[f"tendon{i}Travel"] = t._len0 - t._load(u, lam)[2]
704
+ if hasattr(t, "pull"):
705
+ rec[f"tendon{i}Tension"] = float(t.pull(u, lam)[0])
706
+ for i, c in enumerate(contact):
707
+ info = c._load(u, lam)[2]
708
+ rec[f"contact{i}"] = {k: v for k, v in info.items()}
709
+ if hasattr(c, "_commit"):
710
+ c._commit(u, lam) # friction: where each contact sticks from here on
711
+ history.append(rec)
712
+ if on_step is not None:
713
+ on_step(rec, u)
714
+ if rec["newton"] <= GROW_NEWTON:
715
+ dlam = min(dlam * 1.5, max_step)
716
+ vm, strain = _post(mesh, u, mod, nu, material)
717
+ done = lam >= 1.0 - 1e-12
718
+ if done:
719
+ message = ""
720
+ if seeded:
721
+ message = (message + " " if message else "") + \
722
+ "The part went unstable (buckled); a 0.1% imperfection along the buckling mode was added to follow it."
723
+ return NonlinearResult(u=u, load_factor=lam, converged=done, history=history, stress=vm, strain=strain,
724
+ message=message)
725
+
726
+
727
+ @dataclass
728
+ class BucklingResult:
729
+ factors: np.ndarray # load multipliers that buckle the part, smallest first
730
+ modes: np.ndarray # (n_modes, n_dof), each scaled to a largest nodal travel of 1
731
+ u: np.ndarray # the linear displacement under the reference load
732
+
733
+
734
+ def _geometric(stiff, u, mod, nu):
735
+ """Geometric stiffness of the linear stresses in displacement u (fixed
736
+ dofs cleared, zero on their diagonal). With the incompatible-mode
737
+ element the bubbles carry their linear amplitudes (R u) both in the
738
+ stresses and in the mode shapes: Kg = T^T Kg_ext T, T = [I; R]."""
739
+ mesh = stiff.mesh
740
+ h, w = mesh.pitch, mesh.pitch ** 3 / 8.0
741
+ lam, mu = _lame(nu)
742
+ im = getattr(stiff, "element", "plain") == "im"
743
+ grads, wmat, n = (_GAUSS_IM, _W_IM, 11) if im else (_GAUSS, _W, 8)
744
+ if im:
745
+ r = _hex8_parts(nu, "im")[1] # (9, 24): amplitudes 3*mode + component
746
+ t = np.vstack([np.eye(24), r]) # (33, 24)
747
+ data = np.zeros(stiff.nnz)
748
+ for s in range(0, mesh.n_elem, _CHUNK):
749
+ el = np.arange(s, min(s + _CHUNK, mesh.n_elem))
750
+ m = len(el)
751
+ ue = u[mesh.edof[el]].reshape(m, 24)
752
+ ux = (ue @ t.T if im else ue).reshape(m, n, 3)
753
+ kq = np.zeros((m, n * n))
754
+ for g, dn0 in enumerate(grads):
755
+ grad = np.einsum("mai,aj->mij", ux, dn0 / h)
756
+ eps = 0.5 * (grad + grad.transpose(0, 2, 1))
757
+ sig = (lam * np.trace(eps, axis1=1, axis2=2)[:, None, None] * _I3 + 2 * mu * eps) * mod[el, None, None]
758
+ kq += (w / h ** 2) * (sig.reshape(m, 9) @ wmat[g])
759
+ kg = np.einsum("mab,ij->maibj", kq.reshape(m, n, n), _I3).reshape(m, 3 * n, 3 * n)
760
+ if im:
761
+ kg = t.T @ kg @ t
762
+ stiff.add_elements(data, kg, el)
763
+ return stiff.finish(data, diag=0.0)
764
+
765
+
766
+ def linear_buckling(mesh: HexMesh, nu: float, modulus, fixed: np.ndarray, force,
767
+ n_modes: int = 3, springs=None, element: str | None = None,
768
+ direct_max_dof: int | None = None, tol: float = 1e-8):
769
+ """Smallest positive load factors lambda at which lambda * force buckles
770
+ the part (linear eigenvalue buckling: exact for a perfect column, an
771
+ upper bound for real parts with imperfections; take a margin of 2-3 on
772
+ printed slender members). Below 1, the load as given buckles it.
773
+ `springs` is a per-dof diagonal or a sparse matrix (absolute stiffness).
774
+ `direct_max_dof` (default DIRECT_MAX_DOF) is the size up to which K is
775
+ factored directly: the eigensolver solves with K every iteration, and a
776
+ factorization pays for itself many times over (a 21k-cell bracket: 17 s
777
+ direct, 144 s CG + AMG). A list of forces returns a list of results,
778
+ all on the one factorization. `tol` is the eigensolver's relative
779
+ tolerance."""
780
+ mod = np.broadcast_to(np.asarray(modulus, float), (mesh.n_elem,)).copy()
781
+ # solve_nonlinear's element (incompatible modes by default), so the load
782
+ # factor and the post-buckling path it seeds are one model's; the plain
783
+ # hex8 shear-locks, which put the load of a column one / two cells thick
784
+ # 54% / 14% above Euler's (incompatible modes: within 0.3%)
785
+ stiff = Stiffness(mesh, nu, fixed, element=element)
786
+ k = stiff.assemble(mod)
787
+ if springs is not None:
788
+ if sp.issparse(springs):
789
+ free = sp.diags((~np.asarray(fixed, bool)).astype(float))
790
+ k = (k + free @ springs @ free).tocsr()
791
+ else:
792
+ k = (k + sp.diags(np.where(fixed, 0.0, springs))).tocsr()
793
+ if mesh.n_dof <= (DIRECT_MAX_DOF if direct_max_dof is None else direct_max_dof):
794
+ kinv = _splu(k).solve
795
+ else:
796
+ sol = Solver(mesh, rtol=1e-10, maxiter=3000)
797
+ kinv = lambda r: sol.solve(k, r) # noqa: E731
798
+ n = mesh.n_dof
799
+ nm = min(n_modes, n - 2)
800
+ minv = LinearOperator((n, n), matvec=kinv, dtype=float)
801
+ many = isinstance(force, (list, tuple))
802
+ out = []
803
+ for fv in (force if many else [force]):
804
+ f = np.asarray(fv, float).copy()
805
+ f[np.asarray(fixed, bool)] = 0.0
806
+ u = kinv(f)
807
+ kg = _geometric(stiff, u, mod, nu)
808
+ # K phi = lambda (-Kg) phi <=> (-Kg) phi = (1 / lambda) K phi: the
809
+ # largest positive 1/lambda are the smallest positive buckling loads.
810
+ mu, vec = eigsh(-kg, k=nm, M=k, Minv=minv, which="LA", tol=tol)
811
+ order = np.argsort(-mu)
812
+ mu, vec = mu[order], vec[:, order]
813
+ pos = mu > 1e-12
814
+ factors = np.full(len(mu), np.inf)
815
+ factors[pos] = 1.0 / mu[pos]
816
+ modes = vec.T.copy()
817
+ for m in modes:
818
+ big = np.abs(m.reshape(-1, 3)).max() or 1.0
819
+ m /= big
820
+ out.append(BucklingResult(factors=factors, modes=modes, u=u))
821
+ return out if many else out[0]
822
+
823
+
824
+ def solid_submesh(mesh: HexMesh, keep: np.ndarray, fixed: np.ndarray, vectors: list):
825
+ """The mesh of elements `keep`, minus any piece not connected to a
826
+ support (it would float). Returns (submesh, element index into the old
827
+ mesh, fixed, vectors) with the per-dof arrays carried over to the new
828
+ nodes; load on nodes that fell away is dropped."""
829
+ from scipy.sparse.csgraph import connected_components
830
+ el = np.nonzero(keep)[0]
831
+ sub = HexMesh(mesh.elem_ijk[el], mesh.pitch, mesh.origin)
832
+ old = mesh.node_id(sub.node_ijk)
833
+ fx = np.asarray(fixed, bool).reshape(-1, 3)[old]
834
+ # elements joined through shared nodes
835
+ ne = sub.n_elem
836
+ inc = sp.csr_matrix((np.ones(ne * 8), (np.repeat(np.arange(ne), 8), sub.enode.ravel())), shape=(ne, sub.n_nodes))
837
+ _, lab = connected_components(inc @ inc.T, directed=False)
838
+ # a piece is held only if its supports restrain all three directions: a
839
+ # plate's symmetry plane (one direction) does not stop it floating
840
+ held = np.zeros((lab.max() + 1, 3), bool)
841
+ np.logical_or.at(held, lab, fx[sub.enode].any(axis=1))
842
+ hold = held[lab].all(axis=1)
843
+ if not hold.all():
844
+ el = el[hold]
845
+ sub = HexMesh(mesh.elem_ijk[el], mesh.pitch, mesh.origin)
846
+ old = mesh.node_id(sub.node_ijk)
847
+ fx = np.asarray(fixed, bool).reshape(-1, 3)[old]
848
+ out = [np.asarray(v, float).reshape(-1, 3)[old].ravel() for v in vectors]
849
+ return sub, el, fx.ravel(), out
850
+
851
+
852
+ def verify_mechanism(domain, xphys: np.ndarray, modulus: float, nu: float, input_case: int = 0,
853
+ output_case: int = 1, threshold: float = 0.5, input_scale: float = 1.0,
854
+ steps: int = 10, buckling: bool = True, material: str = "neo-hookean",
855
+ k_in: float = 0.0, k_out: float = 0.0) -> dict:
856
+ """Check a compliant mechanism design (mechanism.optimize_mechanism) with
857
+ large deflections: the solid cells (xphys > threshold) under the input
858
+ case's force, output travel along the output case's direction, compared
859
+ with the linear answer on the same solid mesh. With buckling, also the
860
+ load factor at which the input force buckles the design.
861
+
862
+ k_in, k_out: the optimization's springs (N/mm) at the input and output.
863
+ Leave them at 0 for the free stroke under the actuator's force (a finger
864
+ before it touches anything); give the optimization's values to check
865
+ the case it was designed for.
866
+ """
867
+ from .mechanism import _spring_diag
868
+ mesh = domain.mesh
869
+ fin = domain.loads[input_case] * input_scale
870
+ lout = domain.loads[output_case]
871
+ keep = np.asarray(xphys) > threshold
872
+ sub, el, fx, (f, lo) = solid_submesh(mesh, keep, domain.fixed, [fin, lout])
873
+ dirn = lo.reshape(-1, 3).sum(axis=0)
874
+ nrm = np.linalg.norm(dirn)
875
+ if nrm == 0:
876
+ raise ValueError("the output region has no solid cells under it")
877
+ wts = np.abs(lo.reshape(-1, 3)).sum(axis=1) > 0
878
+ lw = (wts[:, None] * (dirn / nrm)[None, :]).ravel() / max(wts.sum(), 1)
879
+
880
+ report = {"elements": int(sub.n_elem), "inputForceKept": float(np.linalg.norm(f.reshape(-1, 3).sum(axis=0))
881
+ / max(np.linalg.norm(fin.reshape(-1, 3).sum(axis=0)), 1e-30))}
882
+ sd = _spring_diag(f, k_in, sub.n_dof) + _spring_diag(lo, k_out, sub.n_dof)
883
+ sd[fx] = 0.0
884
+ # the linear answer on the nonlinear element's own small-strain limit
885
+ stiff = Stiffness(sub, nu, fx)
886
+ k = (stiff.assemble(np.full(sub.n_elem, modulus)) + sp.diags(sd)).tocsr()
887
+ ul = _Linear(sub).solve(k, np.where(fx, 0.0, f))
888
+ report["linearOutput"] = float(lw @ ul)
889
+ r = solve_nonlinear(sub, nu, modulus, fx, f, material=material, steps=steps, springs=sd, probes={"output": lw})
890
+ report["nonlinearOutput"] = float(lw @ r.u)
891
+ report["loadFactorReached"] = r.load_factor
892
+ report["converged"] = r.converged
893
+ report["maxStress"] = float(r.stress.max())
894
+ report["maxStrain"] = float(r.strain.max())
895
+ report["history"] = r.history
896
+ if r.message:
897
+ report["message"] = r.message
898
+ if report["linearOutput"]:
899
+ report["nonlinearOverLinear"] = report["nonlinearOutput"] / report["linearOutput"]
900
+ if buckling:
901
+ b = linear_buckling(sub, nu, modulus, fx, f, n_modes=1, springs=sd)
902
+ report["bucklingFactor"] = float(b.factors[0])
903
+ report["u"] = r.u
904
+ report["elementIndex"] = el
905
+ return report
906
+
907
+
908
+ def stroke_mechanism(domain, xphys: np.ndarray, modulus: float, nu: float, stroke: float, input_case: int = 0,
909
+ output_case: int = 1, k_out: float = 0.0, threshold: float = 0.5, steps: int = 10,
910
+ material: str = "neo-hookean") -> dict:
911
+ """Drive a compliant mechanism the way a servo or a lead screw does: move
912
+ the input region `stroke` mm along the input force's direction
913
+ (prescribed, so a snap-through cannot run away) and follow the output
914
+ with large deflections. k_out (N/mm): the workpiece, a grounded spring
915
+ on the output; 0 for the free stroke.
916
+
917
+ Returns the output's travel along its direction (nonlinear and linear),
918
+ the actuator force it took (N, along the stroke), the grip force on the
919
+ workpiece (k_out x travel), the geometric advantage (output travel /
920
+ stroke) and mechanical advantage (grip force / actuator force), the
921
+ peak stress and strain, and the per-step history."""
922
+ from .mechanism import _spring_diag
923
+ mesh = domain.mesh
924
+ keep = np.asarray(xphys) > threshold
925
+ sub, el, fx, (f, lo) = solid_submesh(mesh, keep, domain.fixed, [domain.loads[input_case],
926
+ domain.loads[output_case]])
927
+ fv = f.reshape(-1, 3)
928
+ loaded = np.flatnonzero(np.abs(fv).sum(axis=1) > 0)
929
+ if len(loaded) == 0:
930
+ raise ValueError("the input region has no solid cells under it")
931
+ din = fv.sum(axis=0)
932
+ din /= np.linalg.norm(din)
933
+ pd, pv = [], []
934
+ for c in range(3):
935
+ if abs(din[c]) > 1e-9:
936
+ dofs = 3 * loaded + c
937
+ dofs = dofs[~fx[dofs]]
938
+ pd.append(dofs)
939
+ pv.append(np.full(len(dofs), stroke * din[c]))
940
+ pd, pv = np.concatenate(pd), np.concatenate(pv)
941
+ dirn = lo.reshape(-1, 3).sum(axis=0)
942
+ nrm = np.linalg.norm(dirn)
943
+ if nrm == 0:
944
+ raise ValueError("the output region has no solid cells under it")
945
+ wts = np.abs(lo.reshape(-1, 3)).sum(axis=1) > 0
946
+ lw = (wts[:, None] * (dirn / nrm)[None, :]).ravel() / max(wts.sum(), 1)
947
+ sd = _spring_diag(lo, k_out, sub.n_dof)
948
+ sd[fx] = 0.0
949
+ # linear answer on the same mesh: K u = 0 with the input moved
950
+ cons = fx.copy()
951
+ cons[pd] = True
952
+ stiff = Stiffness(sub, nu, cons)
953
+ k_free = Stiffness(sub, nu, np.zeros(sub.n_dof, bool)).assemble(np.full(sub.n_elem, modulus))
954
+ kc = (stiff.assemble(np.full(sub.n_elem, modulus)) + sp.diags(np.where(cons, 0.0, sd))).tocsr()
955
+ up = np.zeros(sub.n_dof)
956
+ up[pd] = pv
957
+ rhs = np.where(cons, up, -(k_free @ up))
958
+ ul = _Linear(sub).solve(kc, rhs)
959
+ lin_force = float(((k_free @ ul).reshape(-1, 3)[np.unique(pd // 3)].sum(axis=0)) @ din)
960
+ r = solve_nonlinear(sub, nu, modulus, fx, None, prescribed=(pd, pv), material=material, steps=steps,
961
+ springs=sd, probes={"output": lw})
962
+ out_nl = float(lw @ r.u)
963
+ force = float(np.asarray(r.history[-1]["reaction"]) @ din) if r.history else float("nan")
964
+ rep = {"elements": int(sub.n_elem), "stroke": stroke, "kOut": k_out, "converged": r.converged,
965
+ "strokeReached": r.load_factor * stroke,
966
+ "linearOutput": float(lw @ ul), "output": out_nl, "linearActuatorForce": lin_force,
967
+ "actuatorForce": force, "geometricAdvantage": out_nl / (r.load_factor * stroke) if r.load_factor else 0.0,
968
+ "maxStress": float(r.stress.max()), "maxStrain": float(r.strain.max()),
969
+ "history": [{"stroke": h["loadFactor"] * stroke, "output": h.get("output"),
970
+ "actuatorForce": float(np.asarray(h["reaction"]) @ din) if "reaction" in h else None,
971
+ **({"branchSwitch": True} if h.get("branchSwitch") else {})} for h in r.history]}
972
+ if k_out > 0:
973
+ rep["gripForce"] = k_out * out_nl
974
+ rep["mechanicalAdvantage"] = rep["gripForce"] / force if force else None
975
+ if r.message:
976
+ rep["message"] = r.message
977
+ return rep
978
+
979
+
980
+ def large_deflection_report(graph, spec: dict, material) -> dict:
981
+ """The field graph's `largeDeflection` block: the `part` (a solid node,
982
+ default the graph's output) voxelized at `pitch`, clamped inside
983
+ `fixed`, under dead `loads`, prescribed `displacements` (an actuator's
984
+ stroke), `tendons` (cables through guides at a tension, or wound in by
985
+ a servo: stroke and stiffness) and `contact` (rigid obstacles, fixed or
986
+ moving, and the part on itself), solved with large deflections.
987
+
988
+ Reports how far the load got, peak strain and stress, each probe's
989
+ travel, each tendon's travel and tension, each contact's force, nodes
990
+ touching and penetration, and the step history."""
991
+ from .contact import SelfContact, obstacle_from_spec
992
+ from .impact import sample_core
993
+ from .mechanism_nl import ServoTendon
994
+
995
+ part = spec.get("part") or graph.output
996
+ pitch = float(spec.get("pitch", 1.0))
997
+ max_cells = int(spec.get("maxCells", 20000))
998
+ b = graph.bounds(part)
999
+ if b is None:
1000
+ raise ValueError(f"largeDeflection: node {part!r} has no bounds")
1001
+ for _ in range(8):
1002
+ inside, _, lo, h = sample_core(graph, part, None, pitch, bounds=b)
1003
+ if inside.sum() <= max_cells:
1004
+ break
1005
+ pitch *= 1.05 * (inside.sum() / max_cells) ** (1 / 3)
1006
+ ijk = np.argwhere(inside)
1007
+ if len(ijk) == 0:
1008
+ raise ValueError(f"largeDeflection: no cells inside {part!r} at pitch {pitch:g}")
1009
+ mesh = HexMesh(ijk, h, lo)
1010
+
1011
+ def nodes_in(node, what):
1012
+ ids = np.flatnonzero(graph.evaluate(mesh.node_xyz, node) <= 0.5 * h)
1013
+ if len(ids) == 0:
1014
+ raise ValueError(f"largeDeflection: no nodes inside {what} {node!r}")
1015
+ return ids
1016
+
1017
+ def where(item, what):
1018
+ if "region" in item:
1019
+ return nodes_in(item["region"], what)
1020
+ if "at" in item:
1021
+ return nodes_near(mesh, item["at"], float(item.get("radius", 1.5 * h)))
1022
+ raise ValueError(f"largeDeflection: {what} needs a region (node) or at (point)")
1023
+
1024
+ fixed = np.zeros(mesh.n_dof, bool)
1025
+ fn = nodes_in(spec["fixed"], "fixed")
1026
+ for a in range(3):
1027
+ fixed[3 * fn + a] = True
1028
+ force = np.zeros(mesh.n_dof)
1029
+ for ld in spec.get("loads", []):
1030
+ ids = where(ld, "load")
1031
+ force[(3 * ids[:, None] + np.arange(3)).ravel()] += np.tile(np.asarray(ld["force"], float) / len(ids), len(ids))
1032
+ pd, pv = [], []
1033
+ for dsp in spec.get("displacements", []):
1034
+ ids = where(dsp, "displacement")
1035
+ mv = np.asarray(dsp["move"], float)
1036
+ for c in range(3):
1037
+ if dsp.get("free") and abs(mv[c]) < 1e-12:
1038
+ continue
1039
+ dofs = 3 * ids + c
1040
+ dofs = dofs[~fixed[dofs]]
1041
+ pd.append(dofs)
1042
+ pv.append(np.full(len(dofs), mv[c]))
1043
+ prescribed = (np.concatenate(pd), np.concatenate(pv)) if pd else None
1044
+ tendons = []
1045
+ for t in spec.get("tendons", []):
1046
+ path = []
1047
+ for pt in t["path"]:
1048
+ path.append(where(pt, "tendon guide") if isinstance(pt, dict) else np.asarray(pt, float))
1049
+ if "stroke" in t:
1050
+ tendons.append(ServoTendon(path, float(t["stroke"]), float(t["stiffness"])))
1051
+ else:
1052
+ tendons.append(Tendon(float(t["tension"]), path))
1053
+ contact = []
1054
+ cs = spec.get("contact") or {}
1055
+ stiff = {k: cs[k] for k in ("stiffness", "friction") if k in cs}
1056
+ for ob in cs.get("obstacles", []):
1057
+ contact.append(obstacle_from_spec({**stiff, **ob}))
1058
+ if cs.get("self"):
1059
+ contact.append(SelfContact(**stiff))
1060
+ probes = {}
1061
+ for p in spec.get("probes", []):
1062
+ ids = where(p, "probe")
1063
+ d = np.asarray(p["direction"], float)
1064
+ d = d / np.linalg.norm(d)
1065
+ w = np.zeros(mesh.n_dof)
1066
+ for c in range(3):
1067
+ w[3 * ids + c] = d[c] / len(ids)
1068
+ probes[p.get("name") or f"probe{len(probes)}"] = w
1069
+ E, nu = float(material.E), float(spec.get("nu", material.nu))
1070
+ r = solve_nonlinear(mesh, nu, E, fixed, force if force.any() else None, prescribed,
1071
+ material=spec.get("model", "neo-hookean"), steps=int(spec.get("steps", 10)),
1072
+ tendons=tendons, contact=contact, probes=probes)
1073
+ last = r.history[-1] if r.history else {}
1074
+ out = {"cells": int(mesh.n_elem), "pitch": h, "converged": r.converged, "loadReached": r.load_factor,
1075
+ "maxStrain": float(r.strain.max()), "maxStress": float(r.stress.max()),
1076
+ "maxDisplacement": float(np.linalg.norm(r.u.reshape(-1, 3), axis=1).max())}
1077
+ if probes:
1078
+ out["probes"] = {k: last.get(k) for k in probes}
1079
+ if tendons:
1080
+ out["tendons"] = [{"travel": last.get(f"tendon{i}Travel"),
1081
+ "tension": last.get(f"tendon{i}Tension", r.load_factor * getattr(t, "tension", 0.0))}
1082
+ for i, t in enumerate(tendons)]
1083
+ if contact:
1084
+ names = [getattr(c, "name", None) or (c.shape if hasattr(c, "shape") else "self") for c in contact]
1085
+ out["contact"] = [{"name": nm, **(last.get(f"contact{i}") or {})} for i, nm in enumerate(names)]
1086
+ if prescribed is not None and "reaction" in last:
1087
+ out["actuatorForce"] = last["reaction"]
1088
+ if spec.get("maxStrain") is not None:
1089
+ out["ok"] = bool(r.converged and out["maxStrain"] <= float(spec["maxStrain"]))
1090
+ if r.message:
1091
+ out["message"] = r.message
1092
+ keep = ("loadFactor", "newton", "branchSwitch", *probes)
1093
+ out["history"] = [{**{k: h_[k] for k in keep if k in h_},
1094
+ **{k: v for k, v in h_.items() if k.startswith(("tendon", "contact"))}} for h_ in r.history]
1095
+ return out