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
@@ -0,0 +1,811 @@
1
+ """
2
+ Compliant mechanisms optimized on their large-deflection response.
3
+
4
+ mechanism.py designs on the linear model, which is right for small strokes
5
+ and wrong for a TPU finger that curls tens of degrees: members swing round,
6
+ the leverage changes as they do, and a linkage that looks best at the
7
+ start of the stroke can stall or reverse halfway (Pedersen, Buhl & Sigmund
8
+ 2001, "Topology synthesis of large-displacement compliant mechanisms").
9
+ Here every design iteration solves the geometrically nonlinear problem
10
+ (total Lagrangian hex8, neo-Hookean, nonlinear.py's element) with the input
11
+ driven through its full stroke, and the sensitivity comes from one adjoint
12
+ solve on the converged tangent:
13
+
14
+ maximize J = l . u(stroke) (output travel against k_out)
15
+ R(u, x) = f_int(u, x) + k_out u = 0 on the free dofs, input prescribed
16
+ K_T lambda = l, dJ/dx_e = -lambda_e . dR_e/dx_e
17
+
18
+ Void cells are the classic trouble: at 1e-9 of the modulus they turn inside
19
+ out under the large motion of their solid neighbours and Newton fails. The
20
+ fix is Wang, Lazarov & Sigmund 2014 ("Interpolation scheme for fictitious
21
+ domain techniques and topology optimization of finite strain elastic
22
+ problems"): each cell's internal force is
23
+
24
+ f_e = E_e [ gamma_e f_NL(gamma_e u_e) + (1 - gamma_e^2) K0 u_e ]
25
+
26
+ with gamma_e a sharp step of the density at `void_threshold` (1 above, 0
27
+ below), so solid cells are fully nonlinear and void cells linear, which
28
+ cannot invert. The default, 0.1, treats grey material as nonlinear too, so
29
+ the optimizer sees the large-deflection behaviour from the first
30
+ iteration; 0.5 keeps grey cells linear (a continuation from the linear
31
+ design, more forgiving of Newton failures, and on the inverter a worse
32
+ optimum: 2.4 against 3.5). A design step whose Newton solve fails is
33
+ backtracked halfway to the last design that converged.
34
+
35
+ The filter, projection, extrusion, robust (eroded / dilated) option and MMA
36
+ are mechanism.py's. Each iteration costs one Newton solve per realization
37
+ (warm-started from the last design's displacement, with load steps only
38
+ when that fails) plus one linear adjoint solve.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import time
43
+
44
+ import numpy as np
45
+ import scipy.sparse as sp
46
+
47
+ from .fem import Stiffness, hex8_ke
48
+ from .mechanism import MechanismOptions, MechanismResult, _spring_diag
49
+ from .nonlinear import _CENTER, _CHUNK, _GAUSS, _element, _Linear
50
+ from .simp import E_MIN, PENALTY, _dproject, _extrude, _project, density_filter
51
+
52
+ __all__ = ["optimize_mechanism_nonlinear", "nonlinear_response", "stroke_dofs", "RigidCylinder", "RigidObject", "Contacts", "ServoTendon",
53
+ "tendon_grip"]
54
+
55
+ GAMMA_ETA, GAMMA_BETA = 0.1, 100.0 # the solid/void step on the physical density
56
+
57
+
58
+ def _gamma(xp, eta=GAMMA_ETA, beta=GAMMA_BETA):
59
+ t0 = np.tanh(beta * eta)
60
+ den = t0 + np.tanh(beta * (1 - eta))
61
+ g = (t0 + np.tanh(beta * (xp - eta))) / den
62
+ dg = beta * (1 - np.tanh(beta * (xp - eta)) ** 2) / den
63
+ return g, dg
64
+
65
+
66
+ def stroke_dofs(mesh, f, fixed, stroke):
67
+ """The input region's dofs moved by `stroke` mm along the input force's
68
+ direction: (dofs, values)."""
69
+ fv = np.asarray(f, float).reshape(-1, 3)
70
+ loaded = np.flatnonzero(np.abs(fv).sum(axis=1) > 0)
71
+ d = fv.sum(axis=0)
72
+ d /= np.linalg.norm(d)
73
+ pd, pv = [], []
74
+ for c in range(3):
75
+ if abs(d[c]) > 1e-9:
76
+ dofs = 3 * loaded + c
77
+ dofs = dofs[~np.asarray(fixed, bool)[dofs]]
78
+ pd.append(dofs)
79
+ pv.append(np.full(len(dofs), stroke * d[c]))
80
+ return np.concatenate(pd), np.concatenate(pv)
81
+
82
+
83
+ class ServoTendon:
84
+ """A tendon wound in by a servo: the actuator pulls `stroke` mm of cable
85
+ in through `path` (as nonlinear.Tendon: fixed points and guides on the
86
+ part), and the cable and servo give like a spring of `stiffness` N/mm,
87
+ so the tension is
88
+
89
+ T = stiffness * (stroke - how far the part has let the cable in)
90
+
91
+ (never negative: a slack cable pushes nothing). It is displacement
92
+ driven, as a position-controlled servo is, so the solve can follow a
93
+ finger past the point where a constant tension would snap it over, and
94
+ the tension can never exceed stiffness * stroke (the stall force, say).
95
+ Load steps scale the stroke."""
96
+
97
+ def __init__(self, path, stroke, stiffness, slack=0.05):
98
+ from .nonlinear import Tendon
99
+ self._t = Tendon(tension=1.0, path=path)
100
+ self.stroke, self.k, self.w = float(stroke), float(stiffness), float(slack)
101
+ self.tension = self.k * self.stroke # the most it can pull
102
+
103
+ def _setup(self, mesh):
104
+ self._t._setup(mesh)
105
+ self._len0 = self._t._len0
106
+
107
+ def pull(self, u, lam):
108
+ """Tension (N), force on the part, and the load stiffness."""
109
+ f1, k1, length = self._t._load(u, 1.0) # f1 = -dL/du
110
+ z = (length - (self._len0 - lam * self.stroke)) / self.w
111
+ t = self.k * self.w * np.logaddexp(0.0, z)
112
+ dt = self.k * 0.5 * (1 + np.tanh(0.5 * z))
113
+ dl = sp.csr_matrix(-f1)
114
+ return t, t * f1, t * k1 + dt * (dl.T @ dl)
115
+
116
+ def _load(self, u, scale):
117
+ t, f, k = self.pull(u, scale)
118
+ return f, k, self._t._load(u, 1.0)[2]
119
+
120
+
121
+ class RigidCylinder:
122
+ """A rigid, fixed object for the part to close on: a cylinder along
123
+ `axis` (default y, the thickness of a plate) through `center`, radius
124
+ `radius` (mm). Contact is a smooth penalty on the part's nodes:
125
+
126
+ p(g) = k s ln(1 + exp(-g / s)), g = distance to the axis - radius
127
+
128
+ so a node pressed `d` into the surface pushes back about k d (N), one
129
+ `s` outside it a little, and the response has a gradient everywhere. A
130
+ node's share is weighted by the density around it (cubed, like SIMP),
131
+ so void cells cannot grip. The grip force is the sum of the normal
132
+ forces."""
133
+
134
+ def __init__(self, center, radius, axis="y", stiffness=50.0, smooth=0.05):
135
+ self.center = np.asarray(center, float)
136
+ self.radius, self.axis = float(radius), "xyz".index(axis)
137
+ self.k, self.s = float(stiffness), float(smooth)
138
+
139
+ def _setup(self, mesh):
140
+ self.plane = [i for i in range(3) if i != self.axis]
141
+ adj = sp.csr_matrix((np.ones(mesh.n_elem * 8), (mesh.enode.ravel(), np.repeat(np.arange(mesh.n_elem), 8))),
142
+ shape=(mesh.n_nodes, mesh.n_elem))
143
+ cnt = np.asarray(adj.sum(axis=1)).ravel()
144
+ self.avg = sp.diags(1.0 / np.maximum(cnt, 1)) @ adj # node density = mean of its cells
145
+ self.x0 = mesh.node_xyz[:, self.plane] - self.center[self.plane]
146
+ self.dofs = 3 * np.arange(mesh.n_nodes)[:, None] + np.array(self.plane)[None, :]
147
+
148
+ def weights(self, xp):
149
+ rho = self.avg @ xp
150
+ return 1e-3 + (1 - 1e-3) * rho ** 3, self.avg.multiply((3 * (1 - 1e-3) * rho ** 2)[:, None]).tocsr()
151
+
152
+ def state(self, u):
153
+ r = self.x0 + u[self.dofs]
154
+ dist = np.linalg.norm(r, axis=1)
155
+ nrm = r / np.maximum(dist, 1e-12)[:, None]
156
+ z = -(dist - self.radius) / self.s
157
+ sig = 0.5 * (1 + np.tanh(0.5 * z))
158
+ p = self.k * self.s * np.logaddexp(0.0, z)
159
+ dp = -self.k * sig # dp/dg
160
+ ddp = self.k / self.s * sig * (1 - sig)
161
+ return dist, nrm, p, dp, ddp
162
+
163
+ def load(self, u, w, tangent=True):
164
+ """Contact as a resisting force (adds to f_int) and its stiffness."""
165
+ dist, nrm, p, dp, ddp = self.state(u)
166
+ act = np.flatnonzero(p * w > 1e-12 * self.k)
167
+ f = np.zeros(u.shape[0])
168
+ np.add.at(f, self.dofs[act].ravel(), (-(w * p)[act, None] * nrm[act]).ravel())
169
+ k = None
170
+ if tangent:
171
+ nn = np.einsum("ni,nj->nij", nrm[act], nrm[act])
172
+ ke = -(w[act] * dp[act])[:, None, None] * nn - (w[act] * p[act] / dist[act])[:, None, None] * \
173
+ (np.eye(2)[None] - nn)
174
+ rows = np.repeat(self.dofs[act], 2, axis=1).ravel()
175
+ cols = np.tile(self.dofs[act], (1, 2)).ravel()
176
+ k = sp.csr_matrix((ke.ravel(), (rows, cols)), shape=(u.shape[0], u.shape[0]))
177
+ return f, k
178
+
179
+ def grip(self, u, w):
180
+ """Grip force (N), its gradient in u, and per-node pressures."""
181
+ dist, nrm, p, dp, _ = self.state(u)
182
+ dj = np.zeros(u.shape[0])
183
+ np.add.at(dj, self.dofs.ravel(), ((w * dp)[:, None] * nrm).ravel())
184
+ return float(w @ p), dj, p, nrm
185
+
186
+ def weight_terms(self, u, lam, grip):
187
+ """Per node, what d(objective)/d(contact weight) collects through
188
+ the adjoint lam: the pressure times (1 if it is the grip objective)
189
+ plus the adjoint's travel along the normal (the contact force in
190
+ the residual)."""
191
+ _, nrm, p, _, _ = self.state(u)
192
+ return p * ((1.0 if grip else 0.0) + np.einsum("ni,ni->n", lam[self.dofs], nrm))
193
+
194
+
195
+ class RigidObject(RigidCylinder):
196
+ """RigidCylinder's grip contact on any contact.RigidObstacle shape (a
197
+ plane, a sphere, a cylinder at any angle, a box; `inside` for a cavity),
198
+ in 3D: the object a finger, a gripper jaw or a clamp closes on. The
199
+ obstacle's own penalty settings are not used: contact here is the
200
+ optimizer's smooth softplus law (`stiffness` N/mm per node, `smooth`
201
+ mm), weighted by the density round each node so void cells cannot
202
+ grip, and the grip force (the objective) is the sum of the normal
203
+ forces."""
204
+
205
+ def __init__(self, obstacle, stiffness=50.0, smooth=0.05):
206
+ self.obstacle = obstacle
207
+ self.k, self.s = float(stiffness), float(smooth)
208
+
209
+ def _setup(self, mesh):
210
+ adj = sp.csr_matrix((np.ones(mesh.n_elem * 8), (mesh.enode.ravel(), np.repeat(np.arange(mesh.n_elem), 8))),
211
+ shape=(mesh.n_nodes, mesh.n_elem))
212
+ cnt = np.asarray(adj.sum(axis=1)).ravel()
213
+ self.avg = sp.diags(1.0 / np.maximum(cnt, 1)) @ adj
214
+ self.x0 = mesh.node_xyz
215
+ self.dofs = 3 * np.arange(mesh.n_nodes)[:, None] + np.arange(3)[None, :]
216
+
217
+ def state(self, u):
218
+ g, nrm, H = self.obstacle.sdf(self.x0 + u[self.dofs])
219
+ self._H = H
220
+ z = -g / self.s
221
+ sig = 0.5 * (1 + np.tanh(0.5 * z))
222
+ p = self.k * self.s * np.logaddexp(0.0, z)
223
+ return g, nrm, p, -self.k * sig, self.k / self.s * sig * (1 - sig)
224
+
225
+ def load(self, u, w, tangent=True):
226
+ g, nrm, p, dp, ddp = self.state(u)
227
+ act = np.flatnonzero(p * w > 1e-12 * self.k)
228
+ f = np.zeros(u.shape[0])
229
+ np.add.at(f, self.dofs[act].ravel(), (-(w * p)[act, None] * nrm[act]).ravel())
230
+ k = None
231
+ if tangent:
232
+ nn = np.einsum("ni,nj->nij", nrm[act], nrm[act])
233
+ ke = -(w[act] * dp[act])[:, None, None] * nn - (w[act] * p[act])[:, None, None] * self._H[act]
234
+ rows = np.repeat(self.dofs[act], 3, axis=1).ravel()
235
+ cols = np.tile(self.dofs[act], (1, 3)).ravel()
236
+ k = sp.csr_matrix((ke.ravel(), (rows, cols)), shape=(u.shape[0], u.shape[0]))
237
+ return f, k
238
+
239
+
240
+ class Contacts:
241
+ """Several rigid objects at once for the optimizer: the one(s) the part
242
+ grips (summed into the grip objective) and others it may only press on
243
+ (a palm, an end stop, a neighbouring finger's envelope, a wall), which
244
+ resist like the gripped object but count for nothing. Each item is a
245
+ RigidCylinder, a RigidObject or a contact.RigidObstacle (wrapped in a
246
+ RigidObject); `grip` says which of them the grip force sums over
247
+ (default: the first only)."""
248
+
249
+ def __init__(self, items, grip=None, stiffness=50.0, smooth=0.05):
250
+ from .contact import RigidObstacle
251
+ self.items = [RigidObject(c, stiffness, smooth) if isinstance(c, RigidObstacle) else c for c in items]
252
+ if not self.items:
253
+ raise ValueError("Contacts: no objects")
254
+ g = [0] if grip is None else list(grip)
255
+ self.gripped = np.zeros(len(self.items), bool)
256
+ self.gripped[g] = True
257
+
258
+ def _setup(self, mesh):
259
+ for c in self.items:
260
+ c._setup(mesh)
261
+ self.dofs = self.items[0].dofs
262
+
263
+ def weights(self, xp):
264
+ return self.items[0].weights(xp)
265
+
266
+ def load(self, u, w, tangent=True):
267
+ f, k = None, None
268
+ for c in self.items:
269
+ fc, kc = c.load(u, w, tangent)
270
+ f = fc if f is None else f + fc
271
+ if tangent:
272
+ k = kc if k is None else k + kc
273
+ return f, k
274
+
275
+ def grip(self, u, w):
276
+ """Grip force on the gripped objects (N), its gradient, and the
277
+ pressure per node on them (summed); normals None."""
278
+ j, dj, p = 0.0, np.zeros(u.shape[0]), np.zeros(len(w))
279
+ for c, g in zip(self.items, self.gripped):
280
+ if g:
281
+ jc, djc, pc, _ = c.grip(u, w)
282
+ j, dj, p = j + jc, dj + djc, p + pc
283
+ return j, dj, p, None
284
+
285
+ def forces(self, u, w):
286
+ """The force on each object (N): what a stop or palm has to hold."""
287
+ out = []
288
+ for c in self.items:
289
+ _, nrm, p, _, _ = c.state(u)
290
+ out.append((-(w * p)[:, None] * nrm).sum(axis=0).tolist())
291
+ return out
292
+
293
+ def weight_terms(self, u, lam, grip):
294
+ return sum(c.weight_terms(u, lam, grip and g) for c, g in zip(self.items, self.gripped))
295
+
296
+
297
+ class _Model:
298
+ """Mixed linear/nonlinear assembly on one mesh, with the input prescribed."""
299
+
300
+ def __init__(self, mesh, nu, fixed, pd, pv, springs, kind="neo-hookean", tendons=None, contact=None):
301
+ self.mesh, self.kind = mesh, kind
302
+ self.tendons = list(tendons or [])
303
+ for t in self.tendons:
304
+ t._setup(mesh)
305
+ self.contact = contact
306
+ if contact is not None:
307
+ contact._setup(mesh)
308
+ self.w = None # contact weights, set per design
309
+ # Poisson's ratio per element (two materials) or one for all
310
+ nu_e = np.broadcast_to(np.asarray(nu, float), (mesh.n_elem,))
311
+ self.groups = [(float(v), np.flatnonzero(nu_e == v)) for v in np.unique(nu_e)]
312
+ # plain hex8: the linear cells must match the nonlinear element's
313
+ # small-strain limit where the two meet
314
+ self.k0s = {v: hex8_ke(v, "plain") * mesh.pitch for v, _ in self.groups}
315
+ nu = float(nu_e[0])
316
+ cons = np.asarray(fixed, bool).copy()
317
+ cons[pd] = True
318
+ self.cons, self.pd, self.pv = cons, pd, pv
319
+ self.free = ~cons
320
+ self.keep = sp.diags(self.free.astype(float)).tocsr()
321
+ self.stiff = Stiffness(mesh, nu, cons)
322
+ self.springs = np.where(cons, 0.0, springs)
323
+ self.lin = _Linear(mesh)
324
+
325
+ def assemble(self, u, mod, gam, tangent=True, parts=False, lam=1.0):
326
+ mesh = self.mesh
327
+ fint = np.zeros(mesh.n_dof)
328
+ data = np.zeros(self.stiff.nnz) if tangent else None
329
+ jmin = np.inf
330
+ br = np.zeros((mesh.n_elem, 24)) if parts else None
331
+ dbr = np.zeros((mesh.n_elem, 24)) if parts else None
332
+ from . import nlkernel
333
+ fast = nlkernel.available()
334
+ # the compiled kernel takes a whole group at once; the numpy element goes in chunks
335
+ chunks = [(v, idx) for v, idx in self.groups] if fast else \
336
+ [(v, idx[s:s + _CHUNK]) for v, idx in self.groups for s in range(0, len(idx), _CHUNK)]
337
+ for nu, el in chunks:
338
+ k0 = self.k0s[nu]
339
+ edof = mesh.edof[el]
340
+ ue = u[edof]
341
+ g = gam[el]
342
+ if fast:
343
+ m = len(el)
344
+ fh, kh = nlkernel.elements((g[:, None] * ue).ravel(), np.arange(24 * m).reshape(m, 24), None,
345
+ np.ones(m), mesh.pitch, nu, self.kind, True, np.stack(_GAUSS))[:2]
346
+ else:
347
+ fh, kh, _ = _element((g[:, None] * ue).reshape(-1, 8, 3), mesh.pitch, nu, self.kind, True)
348
+ # J of the solid cells only: void ones are linear and may not invert
349
+ solid = g > 0.5
350
+ if solid.any():
351
+ F = np.eye(3)[None] + np.einsum("mai,aj->mij", ue[solid].reshape(-1, 8, 3), _CENTER / mesh.pitch)
352
+ jmin = min(jmin, float(np.linalg.det(F).min()))
353
+ ku = ue @ k0.T
354
+ b = g[:, None] * fh + (1 - g ** 2)[:, None] * ku
355
+ me = mod[el]
356
+ if tangent:
357
+ ke = (g ** 2 * me)[:, None, None] * kh + ((1 - g ** 2) * me)[:, None, None] * k0[None]
358
+ if fast:
359
+ fe_, d_ = nlkernel.scatter(mesh.n_dof, self.stiff.nnz, self.stiff._slots if len(el) == mesh.n_elem else self.stiff._slots[:, el], edof,
360
+ b * me[:, None], ke if tangent else None, tangent)
361
+ fint += fe_
362
+ if tangent:
363
+ data += d_
364
+ else:
365
+ np.add.at(fint, edof.ravel(), (b * me[:, None]).ravel())
366
+ if tangent:
367
+ self.stiff.add_elements(data, ke, el)
368
+ if parts:
369
+ br[el] = b
370
+ dbr[el] = fh + g[:, None] * np.einsum("mij,mj->mi", kh, ue) - 2 * g[:, None] * ku
371
+ fint += self.springs * u
372
+ k = None
373
+ if tangent:
374
+ k = self.stiff.finish(data) + sp.diags(self.springs)
375
+ # the tendons' and contacts' stiffness only on the free dofs: the
376
+ # held ones keep Stiffness's identity rows and columns (left in,
377
+ # they couple a held dof to the rest, and the adjoint, which zeroes
378
+ # the held dofs after the solve, came out wrong wherever a tendon
379
+ # knot or a contact sat on a node with a held direction)
380
+ for t in self.tendons: # the tendon pulls: an outside load, with its own stiffness
381
+ ft, kt, _ = t._load(u, lam) # (a ServoTendon's lam scales its stroke)
382
+ fint -= ft
383
+ if tangent:
384
+ k = k + self.keep @ kt @ self.keep
385
+ if self.contact is not None:
386
+ fc, kc = self.contact.load(u, self.w, tangent)
387
+ fint += fc
388
+ if tangent:
389
+ k = k + self.keep @ kc @ self.keep
390
+ if tangent:
391
+ k = k.tocsr()
392
+ return fint, k, jmin, br, dbr
393
+
394
+ def newton(self, u, mod, gam, lam, rtol=1e-8, max_it=40):
395
+ u = u.copy()
396
+ u[self.pd] = lam * self.pv
397
+ r0 = None
398
+ for _ in range(max_it):
399
+ fint, k, jmin, _, _ = self.assemble(u, mod, gam, lam=lam)
400
+ if not np.isfinite(jmin) or jmin <= 0:
401
+ return None
402
+ r = np.where(self.free, fint, 0.0)
403
+ rn = float(np.linalg.norm(r))
404
+ if r0 is None:
405
+ r0 = max(rn, float(np.linalg.norm(fint[self.pd])), lam * sum(t.tension for t in self.tendons), 1e-30)
406
+ if rn <= rtol * r0:
407
+ return u
408
+ du = self.lin.solve(k, -r)
409
+ du[~self.free] = 0.0
410
+ # damped step: a full Newton step can invert a solid cell next
411
+ # to soft void ones; halve it until no cell inverts and the
412
+ # residual does not blow up
413
+ for _ in range(6):
414
+ un = u + du
415
+ fn, _, jn, _, _ = self.assemble(un, mod, gam, tangent=False, lam=lam)
416
+ if np.all(np.isfinite(un)) and np.isfinite(jn) and jn > 0 and \
417
+ np.linalg.norm(np.where(self.free, fn, 0.0)) < 2.0 * rn:
418
+ break
419
+ du *= 0.5
420
+ else:
421
+ return None
422
+ u = un
423
+ return None
424
+
425
+ def solve_path(self, mod, gam, prev=None, steps=4):
426
+ """Equilibrium at the full stroke followed up the load from rest, so
427
+ the answer is the branch the part actually takes (the one the stroke
428
+ check finds), not whichever equilibrium Newton lands on from the last
429
+ design's answer: near a snap or a buckle the two differ, and which
430
+ one a warm start picks flips on rounding. The levels are fixed
431
+ (1/steps, 2/steps, .. 1); each starts from this design's answer one
432
+ level down plus the last design's increment between the two levels
433
+ (prev: that design's path), and a level that will not converge is
434
+ reached in halved sub-steps. Returns (u at the full stroke, path)."""
435
+ n = self.mesh.n_dof
436
+ u, lam, path = np.zeros(n), 0.0, []
437
+ for i in range(steps):
438
+ t = (i + 1) / steps
439
+ un = None
440
+ if prev is not None and len(prev) == steps:
441
+ un = self.newton(u + prev[i] - (prev[i - 1] if i else 0.0), mod, gam, t)
442
+ if un is None:
443
+ un = self._march(u, lam, t, mod, gam)
444
+ u, lam = un, t
445
+ path.append(u)
446
+ return u, path
447
+
448
+ def _march(self, u, lam, t_end, mod, gam):
449
+ """From (u, lam) to t_end in sub-steps, halving one that fails."""
450
+ dl, v = t_end - lam, None
451
+ while lam < t_end - 1e-12:
452
+ t = min(t_end, lam + dl)
453
+ pred = u if v is None else u + v * ((t - lam) / dlv)
454
+ un = self.newton(pred, mod, gam, t)
455
+ if un is None:
456
+ dl /= 2
457
+ if dl < 1e-3:
458
+ raise RuntimeError(f"Newton failed at {lam:.3f} of the stroke")
459
+ continue
460
+ v, dlv = un - u, t - lam
461
+ u, lam = un, t
462
+ dl = min(dl * 1.5, t_end - lam) if lam < t_end else dl
463
+ return u
464
+
465
+ def solve(self, mod, gam, u0=None, steps=4):
466
+ """Equilibrium at the full stroke: straight from u0 (the last design's
467
+ answer), else in load steps from rest, halving a step that fails."""
468
+ if u0 is not None:
469
+ u = self.newton(u0, mod, gam, 1.0)
470
+ if u is not None:
471
+ return u
472
+ u = np.zeros(self.mesh.n_dof)
473
+ lam, dl = 0.0, 1.0 / steps
474
+ while lam < 1.0 - 1e-12:
475
+ t = min(1.0, lam + dl)
476
+ un = self.newton(u * (t / lam) if lam > 0 else u, mod, gam, t)
477
+ if un is None:
478
+ dl /= 2
479
+ if dl < 1e-3:
480
+ raise RuntimeError(f"Newton failed at {lam:.3f} of the stroke")
481
+ continue
482
+ u, lam = un, t
483
+ dl = min(dl * 1.5, 1.0 - lam if lam < 1 else dl)
484
+ return u
485
+
486
+
487
+ def nonlinear_response(model: _Model, xp, modulus, lvec, u0=None, steps=4, void_threshold=GAMMA_ETA,
488
+ follow=False):
489
+ """J at the full stroke (or tension) and dJ/dxp per element: J = lvec . u
490
+ (output travel), or with lvec None the grip force on model.contact.
491
+ modulus: scalar or per element (a two-material part: the solid's E in
492
+ each cell). follow: trace the load path from rest (_Model.solve_path);
493
+ u0 is then the last design's path (or None) and the third value
494
+ returned is this design's path, whose last entry is u."""
495
+ modulus = np.asarray(modulus, float)
496
+ mod = modulus * (E_MIN + xp ** PENALTY * (1 - E_MIN))
497
+ dmod = modulus * PENALTY * xp ** (PENALTY - 1) * (1 - E_MIN)
498
+ gam, dgam = _gamma(xp, void_threshold)
499
+ dw = None
500
+ if model.contact is not None:
501
+ model.w, dw = model.contact.weights(xp)
502
+ if follow:
503
+ u, path = model.solve_path(mod, gam, u0, steps)
504
+ else:
505
+ u = path = model.solve(mod, gam, u0, steps)
506
+ _, k, _, br, dbr = model.assemble(u, mod, gam, parts=True)
507
+ if lvec is None:
508
+ j, dju, p, nrm = model.contact.grip(u, model.w)
509
+ else:
510
+ j, dju = float(lvec @ u), lvec
511
+ lam = model.lin.solve(k, np.where(model.free, dju, 0.0))
512
+ lam[~model.free] = 0.0
513
+ le = lam[model.mesh.edof]
514
+ dj = -(np.einsum("mi,mi->m", le, br) * dmod + np.einsum("mi,mi->m", le, dbr) * mod * dgam)
515
+ if dw is not None:
516
+ # the contact weights depend on the density: the grip term itself
517
+ # and the contact force in the residual
518
+ dj += dw.T @ model.contact.weight_terms(u, lam, lvec is None)
519
+ return j, dj, path
520
+
521
+
522
+ def _tension(tendons, u, lam):
523
+ return float(sum(t.pull(u, lam)[0] if isinstance(t, ServoTendon) else lam * t.tension for t in tendons))
524
+
525
+
526
+ def tendon_grip(domain, xphys, modulus, nu, tendons, contact, threshold=0.5, steps=8,
527
+ material="neo-hookean"):
528
+ """Check a design the way it will be printed: cells above `threshold`
529
+ solid, the rest empty (linear, at E_MIN, so they carry nothing), the
530
+ tendons pulled to full tension in load steps, the part closing on
531
+ `contact`. Returns the grip force (N), how much cable the actuator
532
+ pulled in (mm), the nodes touching the object, the grip at each step,
533
+ and the deformed positions (for a picture). A design that cannot take
534
+ the full tension reports how far it got and its grip there."""
535
+ mesh = domain.mesh
536
+ xp = (np.asarray(xphys) > threshold).astype(float)
537
+ model = _Model(mesh, nu, domain.fixed, np.zeros(0, np.int64), np.zeros(0), np.zeros(mesh.n_dof), material,
538
+ tendons, contact)
539
+ model.w, _ = contact.weights(xp)
540
+ mod = modulus * (E_MIN + xp ** PENALTY * (1 - E_MIN))
541
+ gam, _ = _gamma(xp, 0.5)
542
+ u, lam, dl, history = np.zeros(mesh.n_dof), 0.0, 1.0 / steps, []
543
+ while lam < 1.0 - 1e-12:
544
+ t = min(1.0, lam + dl)
545
+ un = model.newton(u, mod, gam, t)
546
+ if un is None:
547
+ dl /= 2
548
+ if dl < 1e-3:
549
+ break
550
+ continue
551
+ u, lam = un, t
552
+ history.append({"load": lam, "tension": _tension(model.tendons, u, lam), "grip": contact.grip(u, model.w)[0]})
553
+ dl = min(dl * 1.5, 1.0 - lam if lam < 1 else dl)
554
+ j, _, p, _ = contact.grip(u, model.w)
555
+ rep = {"converged": lam >= 1.0 - 1e-12, "loadReached": lam, "tension": _tension(model.tendons, u, lam),
556
+ "grip": j, "contactNodes": int((model.w * p > 0.01 * max(j, 1e-9)).sum()),
557
+ "tendonTravel": [float(t._len0 - t._load(u, lam)[2]) for t in model.tendons], "history": history,
558
+ "deformed": mesh.node_xyz + u.reshape(-1, 3), "solid": xp > 0.5}
559
+ if isinstance(contact, Contacts):
560
+ rep["forces"] = contact.forces(u, model.w)
561
+ if not rep["converged"]:
562
+ rep["message"] = "the solve stopped short of the full pull (the part snaps or wraps past the object)"
563
+ return rep
564
+
565
+
566
+ def optimize_mechanism_nonlinear(domain, nu, opts: MechanismOptions, stroke: float | None = None, steps: int = 4,
567
+ material: str = "neo-hookean", void_threshold: float = GAMMA_ETA,
568
+ on_iter=None, tendons: list | None = None,
569
+ contact=None, follow_path: bool = True, settle: bool = True, starts: int = 1,
570
+ start: int = 0, keep_best: bool = True) -> MechanismResult:
571
+ """mechanism.optimize_mechanism on the large-deflection response.
572
+
573
+ Input: the input case's region driven `stroke` mm along its force, or
574
+ `tendons` (nonlinear.Tendon: a cable at constant tension through guides
575
+ on the part, which follows the part as it curls; the guides should sit
576
+ on preserved cells).
577
+
578
+ Objective: the output case's travel along its direction against k_out
579
+ (the workpiece as a spring), or with `contact` (the object the part
580
+ closes on: a RigidCylinder, a RigidObject, or a contact.RigidObstacle
581
+ of any shape, which is wrapped in a RigidObject) the grip force on it.
582
+ Several objects: a Contacts (or a list, whose first item is gripped and
583
+ the rest are stops the part may press on but that count for nothing).
584
+
585
+ k_in is ignored. nu and opts.modulus may be per element (a two-material
586
+ part). output_displacement is the travel (mm) or the grip force (N).
587
+
588
+ Repeatability: the large-deflection optimum is not unique, and which
589
+ one a run finds can turn on rounding (sharpening refusals and
590
+ backtracks are yes/no decisions). follow_path traces each design's
591
+ equilibrium up the load from rest, so the optimizer sees the branch the
592
+ part really takes, and settle shrinks the move limit once the
593
+ projection is at its sharpest. starts > 1 runs from that many starts
594
+ (start 0 uniform, the others nudged by a seeded 1e-6 per cell) and
595
+ keeps the one whose thresholded design (cells above 0.5 solid, the
596
+ rest void) does best on the large-deflection check;
597
+ history[-1]["starts"] lists every start's thresholded value (None for
598
+ a start that failed; a start whose first design cannot take the stroke
599
+ is skipped). keep_best (default) checks the thresholded design at every
600
+ iteration of the sharpest phase (beta at its cap, full tension, volume
601
+ within 1% of the target) and returns the best one seen, not wherever
602
+ the run stopped (history[-1]["keptIteration"] when that is an earlier
603
+ one): about 15% more time. history[-1]["thresholdedOutput"] is that
604
+ value for the design returned (None if the thresholded design does not
605
+ reach the stroke)."""
606
+ if starts > 1:
607
+ t_all = time.time()
608
+ runs, err = [], None
609
+ for k in range(starts):
610
+ try:
611
+ runs.append(optimize_mechanism_nonlinear(domain, nu, opts, stroke, steps, material, void_threshold,
612
+ on_iter, tendons, contact, follow_path, settle, 1, k,
613
+ keep_best))
614
+ except RuntimeError as ex: # this start's first design could not take the stroke
615
+ runs.append(None)
616
+ err = ex
617
+ if all(r is None for r in runs):
618
+ raise err
619
+ vals = [None if r is None else r.history[-1].get("thresholdedOutput") for r in runs]
620
+ best = max(range(starts), key=lambda k: -np.inf if runs[k] is None else
621
+ (-1e300 if vals[k] is None else vals[k]))
622
+ r = runs[best]
623
+ r.history[-1]["starts"] = vals
624
+ r.history[-1]["start"] = best
625
+ r.seconds = time.time() - t_all
626
+ return r
627
+ t0 = time.time()
628
+ mesh = domain.mesh
629
+ n = mesh.n_elem
630
+ pres = domain.preserve
631
+ design = ~pres
632
+ if (stroke is None) == (not tendons):
633
+ raise ValueError("drive the mechanism with a stroke or with tendons (one of them)")
634
+ if tendons:
635
+ pd, pv = np.zeros(0, np.int64), np.zeros(0)
636
+ else:
637
+ pd, pv = stroke_dofs(mesh, domain.loads[opts.input_case], domain.fixed, stroke)
638
+ from .contact import RigidObstacle
639
+ if isinstance(contact, RigidObstacle):
640
+ contact = RigidObject(contact)
641
+ elif isinstance(contact, (list, tuple)):
642
+ contact = Contacts(contact)
643
+ if contact is None:
644
+ lo = domain.loads[opts.output_case]
645
+ wts = np.abs(lo.reshape(-1, 3)).sum(axis=1) > 0
646
+ d = lo.reshape(-1, 3).sum(axis=0)
647
+ lvec = (wts[:, None] * (d / np.linalg.norm(d))[None, :]).ravel() / max(wts.sum(), 1)
648
+ springs = _spring_diag(lo, opts.k_out, mesh.n_dof)
649
+ springs[domain.fixed] = 0.0
650
+ else:
651
+ lvec, springs = None, np.zeros(mesh.n_dof)
652
+ model = _Model(mesh, nu, domain.fixed, pd, pv, springs, material, tendons, contact)
653
+
654
+ columns = None
655
+ if opts.extrusion is not None:
656
+ a = opts.extrusion
657
+ others = [i for i in range(3) if i != a]
658
+ ij = mesh.elem_ijk[:, others]
659
+ _, columns = np.unique(ij[:, 0] * (domain.dims[others[1]] + 1) + ij[:, 1], return_inverse=True)
660
+ h, hs = density_filter(domain, opts.filter_radius, pad=opts.extrusion is None)
661
+ ht = h.T.tocsr()
662
+ x = np.full(n, opts.volume_fraction)
663
+ if start:
664
+ # a seeded nudge of 1e-6 per cell: the path amplifies differences
665
+ # this small into different optima (rounding alone spreads the
666
+ # curling finger 5.5-8 mm), and nudges this small stay in the
667
+ # uniform start's neighbourhood; a visible ripple (+-0.05) landed
668
+ # in worse designs (3-5 mm)
669
+ x = x + 1e-6 * np.random.default_rng(start).uniform(-1.0, 1.0, n)
670
+ x[pres] = 1.0
671
+ target = opts.volume_fraction * design.sum()
672
+ beta, since = 1.0, 0
673
+ etas = [0.5] if not opts.robust else [0.5 + opts.robust, 0.5, 0.5 - opts.robust]
674
+ from .mma import MMA
675
+ mma = MMA(int(design.sum()), 1, xmin=0.0, xmax=1.0, move=opts.move)
676
+ history, u_prev, j0 = [], {}, None
677
+ x_ok, beta_ok, backtracks, beta_cap, stopped, refused = None, beta, 0, opts.beta_max, None, 0
678
+ it = 0
679
+ best, tpath = None, None
680
+ full = [t.tension for t in tendons or []]
681
+ # (servo tendons are displacement driven and need no ramp)
682
+ ts_ok = 0.08 if tendons and not any(isinstance(t, ServoTendon) for t in tendons) else 0.8
683
+ for it in range(1, opts.max_iterations + 1):
684
+ # tension continuation: the uniform grey start is far too soft for
685
+ # the full pull, so the tension rises 25% an iteration from 10% for
686
+ # as long as the design can take it
687
+ ts = min(1.0, ts_ok * 1.25)
688
+ real = None
689
+ for tries in range(8):
690
+ for t, tfull in zip(tendons or [], full):
691
+ if not isinstance(t, ServoTendon):
692
+ t.tension = tfull * ts
693
+ xt = _extrude((h @ x) / hs, columns, design)
694
+ try:
695
+ real, up = [], {}
696
+ for eta in etas:
697
+ xp = _project(xt, beta, eta)
698
+ xp[pres] = 1.0
699
+ j, dj, u = nonlinear_response(model, xp, opts.modulus, lvec, u_prev.get(eta), steps,
700
+ void_threshold, follow=follow_path)
701
+ up[eta] = u
702
+ real.append((j, dj * _dproject(xt, beta, eta)))
703
+ u_prev.update(up)
704
+ break
705
+ except RuntimeError:
706
+ real = None
707
+ if x_ok is None:
708
+ raise
709
+ backtracks += 1
710
+ if ts > ts_ok:
711
+ ts = ts_ok # too much pull for this design yet: hold the tension
712
+ elif beta > beta_ok:
713
+ # the sharper projection is what broke it: keep the last
714
+ # beta for now and try again later; stop sharpening
715
+ # after three refusals
716
+ beta, since, refused = beta_ok, 0, refused + 1
717
+ if refused >= 3:
718
+ beta_cap = beta_ok
719
+ else:
720
+ x = 0.5 * (x + x_ok) # backtrack toward the last design that converged
721
+ if real is None:
722
+ # no nearby design reaches the full stroke: keep the last one that did
723
+ x, beta, stopped = x_ok, beta_ok, "the stroke could not be reached from the last design"
724
+ break
725
+ x_ok, beta_ok, ts_ok = x.copy(), beta, ts
726
+ jb = real[len(etas) // 2][0]
727
+ if j0 is None:
728
+ # a grip starts at zero when the part does not reach the object yet
729
+ j0 = max(abs(jb), 0.05 * sum(full), 1e-9)
730
+ vals = np.array([r[0] / j0 for r in real])
731
+ if len(etas) == 1:
732
+ obj, g = vals[0], real[0][1] / j0
733
+ else:
734
+ q = 30.0
735
+ w = np.exp(-q * (vals - vals.min()))
736
+ obj = float(vals.min() - np.log(w.sum()) / q)
737
+ w /= w.sum()
738
+ g = sum(wk * gk for wk, (_, gk) in zip(w, real)) / j0
739
+ g = ht @ (_extrude(g, columns, design) / hs)
740
+ dv = ht @ (_extrude(_dproject(xt, beta, 0.5), columns, design) / hs)
741
+ xp = _project(xt, beta, 0.5)
742
+ xp[pres] = 1.0
743
+ vol = float(xp[design].sum())
744
+ xn = x.copy()
745
+ xn[design] = mma.update(x[design], -obj * 10.0, (-g * 10.0)[design],
746
+ np.array([vol / target - 1.0]), np.array([(dv / target)[design]]))
747
+ change = float(np.abs(xn - x).max())
748
+ x = xn
749
+ rec = {"iteration": it, "outputDisplacement": jb, "objective": obj, "beta": beta, "change": change,
750
+ "volume": float(xp[design].mean()), "backtracks": backtracks}
751
+ if tendons:
752
+ rec["tensionScale"] = ts
753
+ if len(etas) > 1:
754
+ rec["robustOutputs"] = [float(r[0]) for r in real]
755
+ if keep_best and beta >= beta_cap and ts >= 1.0 and vol <= target * 1.01:
756
+ # the design as printed, at every iteration of the sharpest
757
+ # phase: keep the best one seen
758
+ try:
759
+ jt, _, tpath = nonlinear_response(model, (xp > 0.5).astype(float), opts.modulus, lvec, tpath,
760
+ 2 * steps, 0.5, follow=True)
761
+ rec["thresholdedOutput"] = float(jt)
762
+ if best is None or jt > best[0]:
763
+ best = (float(jt), x_ok.copy(), beta, it)
764
+ except (RuntimeError, ValueError):
765
+ tpath = None
766
+ history.append(rec)
767
+ if on_iter is not None:
768
+ on_iter(rec, xp)
769
+ since += 1
770
+ if beta < beta_cap and (since >= opts.beta_every or change < opts.tol * 2):
771
+ beta, since = min(beta * 2, beta_cap), 0
772
+ elif beta >= beta_cap and ts >= 1.0:
773
+ if settle:
774
+ # settle: shrink the move limit so the design converges
775
+ # instead of wandering round the optimum until the
776
+ # plateau test happens to stop it
777
+ mma.move = max(opts.move * 0.1, mma.move * 0.85)
778
+ if change < opts.tol:
779
+ break
780
+ if since >= 10 and len(history) > 10:
781
+ old = history[-11]["objective"]
782
+ if abs(obj - old) <= 2e-3 * abs(old):
783
+ break
784
+ for t, tfull in zip(tendons or [], full):
785
+ if not isinstance(t, ServoTendon):
786
+ t.tension = tfull
787
+ if stopped:
788
+ history[-1]["stopped"] = stopped
789
+ if beta_cap < opts.beta_max:
790
+ history[-1]["betaCap"] = beta_cap
791
+ xt = _extrude((h @ x) / hs, columns, design)
792
+ xp = _project(xt, beta, 0.5)
793
+ xp[pres] = 1.0
794
+ # the design as it will be printed: thresholded, void cells linear
795
+ try:
796
+ jt = nonlinear_response(model, (xp > 0.5).astype(float), opts.modulus, lvec, None, 2 * steps, 0.5,
797
+ follow=True)[0]
798
+ except (RuntimeError, ValueError):
799
+ jt = None
800
+ history[-1]["thresholdedOutput"] = None if jt is None else float(jt)
801
+ if best is not None and (jt is None or best[0] > jt):
802
+ # an earlier iterate of the sharpest phase prints better: return it
803
+ x, beta = best[1], best[2]
804
+ xt = _extrude((h @ x) / hs, columns, design)
805
+ xp = _project(xt, beta, 0.5)
806
+ xp[pres] = 1.0
807
+ history[-1]["thresholdedOutput"] = best[0]
808
+ history[-1]["keptIteration"] = best[3]
809
+ history[-1]["outputDisplacement"] = history[best[3] - 1]["outputDisplacement"]
810
+ return MechanismResult(x=x, xphys=xp, output_displacement=float(history[-1]["outputDisplacement"]),
811
+ iterations=it, seconds=time.time() - t0, history=history)