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/mma.py ADDED
@@ -0,0 +1,214 @@
1
+ """
2
+ The Method of Moving Asymptotes (Svanberg 1987; the 2007 formulation in
3
+ "MMA and GCMMA — two methods for nonlinear optimization"), written for
4
+ loadpath from the published method so it stays MIT.
5
+
6
+ MMA is what lets an optimization carry real constraints (a stress limit, a
7
+ deflection limit, several at once) instead of the single volume constraint
8
+ optimality criteria can handle. Each iteration builds a convex separable
9
+ approximation of the problem around the current design, using moving
10
+ asymptotes that tighten when the design oscillates and relax when it moves
11
+ steadily, and solves it exactly with a primal-dual interior-point method.
12
+ The problem solved is
13
+
14
+ minimize f0(x) + a0 z + sum_i (c_i y_i + 0.5 d_i y_i^2)
15
+ subject to f_i(x) - a_i z - y_i <= 0, xmin <= x <= xmax,
16
+ y_i >= 0, z >= 0
17
+
18
+ with the usual choice a0 = 1, a_i = 0, d_i = 1 and large c_i, which makes
19
+ the artificial y_i zero whenever the real constraints can be met.
20
+
21
+ Sized for topology optimization: n (design cells) is large and m
22
+ (constraints) is small, so the Newton steps solve an m x m system.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import numpy as np
27
+
28
+ __all__ = ["MMA"]
29
+
30
+
31
+ class MMA:
32
+ def __init__(self, n, m, xmin=0.0, xmax=1.0, move=0.2, c=1000.0, asyinit=0.5,
33
+ asyincr=1.2, asydecr=0.7):
34
+ self.n, self.m = n, m
35
+ self.xmin = np.broadcast_to(np.asarray(xmin, float), (n,)).copy()
36
+ self.xmax = np.broadcast_to(np.asarray(xmax, float), (n,)).copy()
37
+ self.move = move
38
+ self.a0 = 1.0
39
+ self.a = np.zeros(m)
40
+ self.c = np.full(m, float(c))
41
+ self.d = np.ones(m)
42
+ self.asyinit, self.asyincr, self.asydecr = asyinit, asyincr, asydecr
43
+ self.xold1 = None
44
+ self.xold2 = None
45
+ self.low = None
46
+ self.upp = None
47
+ self.iter = 0
48
+
49
+ def tighten(self, factor):
50
+ """Pull the asymptotes toward the last design by `factor`: a more
51
+ curved, more conservative model for the next step (what GCMMA's
52
+ inner loop does, without its extra function evaluations)."""
53
+ if self.xold1 is not None and self.low is not None:
54
+ self.low = self.xold1 - factor * (self.xold1 - self.low)
55
+ self.upp = self.xold1 + factor * (self.upp - self.xold1)
56
+
57
+ def update(self, x, f0, df0, f, df):
58
+ """One MMA step from design x with objective value/gradient (f0, df0)
59
+ and constraint values/gradients (f: (m,), df: (m, n)). Returns the new
60
+ design."""
61
+ xmin, xmax = self.xmin, self.xmax
62
+ # Non-finite values (a solve that broke down) would turn the whole
63
+ # subproblem NaN; read them as a large violation and stay in bounds.
64
+ x = np.clip(np.nan_to_num(np.asarray(x, float), nan=0.5), xmin, xmax)
65
+ f = np.nan_to_num(np.asarray(f, float).reshape(self.m), nan=1e6, posinf=1e6, neginf=-1e6)
66
+ df = np.nan_to_num(np.asarray(df, float).reshape(self.m, self.n), nan=0.0, posinf=1e6, neginf=-1e6)
67
+ df0 = np.nan_to_num(np.asarray(df0, float), nan=0.0, posinf=1e6, neginf=-1e6)
68
+ self.iter += 1
69
+ span = xmax - xmin
70
+ if self.iter <= 2 or self.low is None:
71
+ self.low = x - self.asyinit * span
72
+ self.upp = x + self.asyinit * span
73
+ else:
74
+ zzz = (x - self.xold1) * (self.xold1 - self.xold2)
75
+ factor = np.ones(self.n)
76
+ factor[zzz > 0] = self.asyincr
77
+ factor[zzz < 0] = self.asydecr
78
+ self.low = x - factor * (self.xold1 - self.low)
79
+ self.upp = x + factor * (self.upp - self.xold1)
80
+ self.low = np.clip(self.low, x - 10 * span, x - 0.01 * span)
81
+ self.upp = np.clip(self.upp, x + 0.01 * span, x + 10 * span)
82
+ low, upp = self.low, self.upp
83
+ albefa = 0.1
84
+ alfa = np.maximum.reduce([low + albefa * (x - low), x - self.move * span, xmin])
85
+ beta = np.minimum.reduce([upp - albefa * (upp - x), x + self.move * span, xmax])
86
+
87
+ raa0 = 1e-5
88
+ xmami = np.maximum(span, 1e-5)
89
+ ux1 = upp - x
90
+ xl1 = x - low
91
+ ux2, xl2 = ux1 * ux1, xl1 * xl1
92
+ p0 = np.maximum(df0, 0.0)
93
+ q0 = np.maximum(-df0, 0.0)
94
+ pq0 = 0.001 * (p0 + q0) + raa0 / xmami
95
+ p0 = (p0 + pq0) * ux2
96
+ q0 = (q0 + pq0) * xl2
97
+ P = np.maximum(df, 0.0)
98
+ Q = np.maximum(-df, 0.0)
99
+ PQ = 0.001 * (P + Q) + raa0 / xmami
100
+ P = (P + PQ) * ux2
101
+ Q = (Q + PQ) * xl2
102
+ b = P @ (1.0 / ux1) + Q @ (1.0 / xl1) - f
103
+
104
+ with np.errstate(divide="ignore", invalid="ignore"):
105
+ xnew = self._subsolv(low, upp, alfa, beta, p0, q0, P, Q, b)
106
+ if not np.all(np.isfinite(xnew)):
107
+ # the interior point broke down: keep those variables in place
108
+ xnew = np.where(np.isfinite(xnew), xnew, x)
109
+ xnew = np.clip(xnew, alfa, beta)
110
+ self.xold2 = self.xold1 if self.xold1 is not None else x.copy()
111
+ self.xold1 = x.copy()
112
+ return xnew
113
+
114
+ def _subsolv(self, low, upp, alfa, beta, p0, q0, P, Q, b, epsimin=1e-7):
115
+ m = self.m
116
+ a0, a, c, d = self.a0, self.a, self.c, self.d
117
+ x = 0.5 * (alfa + beta)
118
+ y = np.ones(m)
119
+ z = 1.0
120
+ lam = np.ones(m)
121
+ xsi = np.maximum(1.0 / (x - alfa), 1.0)
122
+ eta = np.maximum(1.0 / (beta - x), 1.0)
123
+ mu = np.maximum(1.0, 0.5 * c)
124
+ zet = 1.0
125
+ s = np.ones(m)
126
+ epsi = 1.0
127
+
128
+ def residual(x, y, z, lam, xsi, eta, mu, zet, s, epsi):
129
+ ux1 = upp - x
130
+ xl1 = x - low
131
+ plam = p0 + P.T @ lam
132
+ qlam = q0 + Q.T @ lam
133
+ gvec = P @ (1.0 / ux1) + Q @ (1.0 / xl1)
134
+ dpsidx = plam / (ux1 * ux1) - qlam / (xl1 * xl1)
135
+ r = np.concatenate([
136
+ dpsidx - xsi + eta,
137
+ c + d * y - mu - lam,
138
+ [a0 - zet - a @ lam],
139
+ gvec - a * z - y + s - b,
140
+ xsi * (x - alfa) - epsi,
141
+ eta * (beta - x) - epsi,
142
+ mu * y - epsi,
143
+ [zet * z - epsi],
144
+ lam * s - epsi,
145
+ ])
146
+ return r
147
+
148
+ while epsi > epsimin:
149
+ r = residual(x, y, z, lam, xsi, eta, mu, zet, s, epsi)
150
+ norm = np.linalg.norm(r)
151
+ rmax = np.abs(r).max()
152
+ it = 0
153
+ while rmax > 0.9 * epsi and it < 200:
154
+ it += 1
155
+ ux1 = upp - x
156
+ xl1 = x - low
157
+ ux2, xl2 = ux1 * ux1, xl1 * xl1
158
+ ux3, xl3 = ux1 * ux2, xl1 * xl2
159
+ plam = p0 + P.T @ lam
160
+ qlam = q0 + Q.T @ lam
161
+ gvec = P @ (1.0 / ux1) + Q @ (1.0 / xl1)
162
+ GG = P / ux2 - Q / xl2
163
+ dpsidx = plam / ux2 - qlam / xl2
164
+ delx = dpsidx - epsi / (x - alfa) + epsi / (beta - x)
165
+ dely = c + d * y - lam - epsi / y
166
+ delz = a0 - a @ lam - epsi / z
167
+ dellam = gvec - a * z - y - b + epsi / lam
168
+ diagx = 2.0 * (plam / ux3 + qlam / xl3) + xsi / (x - alfa) + eta / (beta - x)
169
+ diagy = d + mu / y
170
+ diaglamyi = s / lam + 1.0 / diagy
171
+ blam = dellam + dely / diagy - GG @ (delx / diagx)
172
+ alam = np.diag(diaglamyi) + (GG / diagx) @ GG.T
173
+ aa = np.zeros((m + 1, m + 1))
174
+ aa[:m, :m] = alam
175
+ aa[:m, m] = a
176
+ aa[m, :m] = a
177
+ aa[m, m] = -zet / z
178
+ sol = np.linalg.solve(aa, np.concatenate([blam, [delz]]))
179
+ dlam, dz = sol[:m], sol[m]
180
+ dx = -delx / diagx - (GG.T @ dlam) / diagx
181
+ dy = -dely / diagy + dlam / diagy
182
+ dxsi = -xsi + epsi / (x - alfa) - xsi * dx / (x - alfa)
183
+ deta = -eta + epsi / (beta - x) + eta * dx / (beta - x)
184
+ dmu = -mu + epsi / y - mu * dy / y
185
+ dzet = -zet + epsi / z - zet * dz / z
186
+ ds = -s + epsi / lam - s * dlam / lam
187
+
188
+ xx = np.concatenate([y, [z], lam, xsi, eta, mu, [zet], s])
189
+ dxx = np.concatenate([dy, [dz], dlam, dxsi, deta, dmu, [dzet], ds])
190
+ stmxx = np.max(-1.01 * dxx / xx)
191
+ stmalfa = np.max(-1.01 * dx / (x - alfa))
192
+ stmbeta = np.max(1.01 * dx / (beta - x))
193
+ steg = 1.0 / max(stmalfa, stmbeta, stmxx, 1.0)
194
+ old = (x, y, z, lam, xsi, eta, mu, zet, s)
195
+ for _ in range(50):
196
+ x = old[0] + steg * dx
197
+ y = old[1] + steg * dy
198
+ z = old[2] + steg * dz
199
+ lam = old[3] + steg * dlam
200
+ xsi = old[4] + steg * dxsi
201
+ eta = old[5] + steg * deta
202
+ mu = old[6] + steg * dmu
203
+ zet = old[7] + steg * dzet
204
+ s = old[8] + steg * ds
205
+ r = residual(x, y, z, lam, xsi, eta, mu, zet, s, epsi)
206
+ newnorm = np.linalg.norm(r)
207
+ if newnorm < 2.0 * norm:
208
+ break
209
+ steg /= 2.0
210
+ norm = newnorm
211
+ rmax = np.abs(r).max()
212
+ epsi *= 0.1
213
+ self.lam = lam
214
+ return x
loadpath/modal.py ADDED
@@ -0,0 +1,210 @@
1
+ """
2
+ Natural frequencies, mode shapes and damping, on the same voxel hex mesh.
3
+
4
+ A part that shakes (a motor mount, an arm link carrying a gripper, a sensor
5
+ bracket on a printer gantry) fails by resonance long before its static
6
+ safety factor says it should: if a mode sits near the excitation the
7
+ response is the static one times 1/(2 zeta). This module finds the modes:
8
+
9
+ - `modal_analysis`: the generalized eigenproblem K phi = omega^2 M phi with
10
+ the consistent-free lumped mass (each cell's mass split over its 8 nodes),
11
+ per-cell modulus and density, so a homogenized graded lattice (modulus
12
+ E C rho^n, density rho_s rho) is analysed exactly like a solid. Point
13
+ masses (a motor, a gripper) attach to nodes. Returns frequencies (Hz),
14
+ modes, and each mode's effective mass fraction per axis (how much of
15
+ the part a base shake in that direction moves).
16
+ - `transmissibility`: base-to-part amplification of a single mode at a
17
+ forcing frequency, with modal damping zeta = eta / 2 from the material's
18
+ loss factor (LOSS_FACTOR).
19
+
20
+ On damping: a part made of one linear viscoelastic material has the modal
21
+ loss factor of that material whatever its shape (every mode stores its
22
+ energy in the same material), so a lattice of PA12 damps like solid PA12.
23
+ Lattices raise damping only through something else: a second, lossy
24
+ material (a TPU lattice, a TPU-filled core), friction, or unfused powder
25
+ left in the cells (particle damping, large but unmodelled here). What a
26
+ lattice does change is where the modes are: E/rho drops as rho^(n-1), so a
27
+ lattice core lowers frequencies, which is what an isolator wants and a
28
+ stiff bracket does not.
29
+
30
+ Units: mm, N, MPa, g/cm^3 for density (converted to tonne/mm^3, which with
31
+ N and mm gives seconds), kg for point masses, Hz out.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ from dataclasses import dataclass
36
+
37
+ import numpy as np
38
+ import scipy.sparse as sp
39
+
40
+ from .fem import HexMesh, Stiffness
41
+
42
+ __all__ = ["ModalResult", "modal_analysis", "transmissibility", "base_transmissibility", "LOSS_FACTOR", "loss_factor", "vibration_report"]
43
+
44
+ # Room-temperature loss factor (tan delta) near 10-100 Hz, typical values
45
+ # from DMA data sheets and literature; rough (+-50%), and strongly
46
+ # temperature dependent near Tg.
47
+ LOSS_FACTOR = {
48
+ "pla": 0.015, "petg": 0.02, "abs": 0.02, "asa": 0.02, "pc": 0.012, "pa12": 0.04, "pa11": 0.05,
49
+ "pa12-gb": 0.03, "pa12-cf": 0.03, "tpu": 0.12, "resin-rigid": 0.03, "resin-tough": 0.05,
50
+ "alsi10mg": 0.002, "ti64": 0.002, "316l": 0.002, "aluminum": 0.001, "steel": 0.001,
51
+ }
52
+
53
+
54
+ def loss_factor(material) -> float:
55
+ name = getattr(material, "name", str(material)).lower()
56
+ return LOSS_FACTOR.get(name, 0.02)
57
+
58
+
59
+ @dataclass
60
+ class ModalResult:
61
+ frequencies: np.ndarray # Hz, ascending
62
+ modes: np.ndarray # (n_dof, k), mass-normalized
63
+ mass: float # kg, the part plus point masses
64
+ participation: np.ndarray # (k, 3) effective mass fraction per axis
65
+ mass_vector: np.ndarray | None = None # (n_dof,) lumped mass per dof, tonnes
66
+
67
+ def to_dict(self, eta: float | None = None) -> dict:
68
+ out = {"frequencies": [round(float(f), 2) for f in self.frequencies], "mass": round(self.mass, 5),
69
+ "massFraction": [[round(float(x), 3) for x in row] for row in self.participation]}
70
+ if eta is not None:
71
+ out["lossFactor"] = eta
72
+ out["dampingRatio"] = round(eta / 2.0, 4)
73
+ out["resonantAmplification"] = round(1.0 / eta, 1)
74
+ return out
75
+
76
+
77
+ def _lumped_mass(mesh: HexMesh, density):
78
+ rho = np.broadcast_to(np.asarray(density, float), (mesh.n_elem,)) * 1e-9 # g/cm^3 -> t/mm^3
79
+ m_node = np.zeros(mesh.n_nodes)
80
+ np.add.at(m_node, mesh.enode, (rho * mesh.pitch ** 3 / 8.0)[:, None])
81
+ return m_node
82
+
83
+
84
+ def modal_analysis(mesh: HexMesh, modulus, density, nu: float, fixed: np.ndarray, n_modes: int = 6,
85
+ point_masses: list | None = None) -> ModalResult:
86
+ """Lowest `n_modes` natural frequencies (Hz) of a voxel model.
87
+
88
+ modulus: E per element (MPa) or scalar; density: g/cm^3 per element or
89
+ scalar; fixed: bool per dof (clamped). point_masses: [(node_ids, kg)],
90
+ each spread evenly over its nodes (all three directions)."""
91
+ from scipy.sparse.linalg import eigsh
92
+
93
+ fixed = np.asarray(fixed, bool)
94
+ st = Stiffness(mesh, nu, fixed)
95
+ k = st.assemble(np.broadcast_to(np.asarray(modulus, float), (mesh.n_elem,)))
96
+ m_node = _lumped_mass(mesh, density)
97
+ for nodes, kg in point_masses or []:
98
+ nodes = np.asarray(nodes, np.int64)
99
+ m_node[nodes] += kg * 1e-3 / len(nodes)
100
+ m = np.repeat(m_node, 3)
101
+ free = np.flatnonzero(~fixed)
102
+ kf = k[free][:, free].tocsc()
103
+ mf = m[free]
104
+ nm = min(n_modes, len(free) - 2)
105
+ if len(free) <= 120_000:
106
+ w, v = eigsh(kf, k=nm, M=sp.diags(mf).tocsc(), sigma=0.0, which="LM")
107
+ else:
108
+ # big models: LOBPCG on the mass-scaled operator with an AMG preconditioner
109
+ from scipy.sparse.linalg import lobpcg
110
+
111
+ from .fem import amg_solver
112
+
113
+ ml = amg_solver(kf.tocsr())
114
+ x0 = np.random.default_rng(0).standard_normal((len(free), nm + 4))
115
+ w, v = lobpcg(kf, x0, B=sp.diags(mf), M=ml.aspreconditioner(), largest=False, tol=1e-6, maxiter=400)
116
+ w, v = w[:nm], v[:, :nm]
117
+ order = np.argsort(w)
118
+ w, v = np.maximum(w[order], 0.0), v[:, order]
119
+ # mass-normalize (phi^T M phi = 1)
120
+ v = v / np.sqrt(np.einsum("ik,i,ik->k", v, mf, v))[None, :]
121
+ modes = np.zeros((mesh.n_dof, nm))
122
+ modes[free] = v
123
+ total = m_node.sum()
124
+ part = np.zeros((nm, 3))
125
+ for a in range(3):
126
+ r = np.zeros(mesh.n_dof)
127
+ r[a::3] = 1.0
128
+ g = (v * (mf * r[free])[:, None]).sum(axis=0) # participation factor phi^T M r
129
+ part[:, a] = g ** 2 / total
130
+ return ModalResult(np.sqrt(w) / (2 * np.pi), modes, float(total * 1e3), part, m)
131
+
132
+
133
+ def transmissibility(f_forcing, f_natural, eta: float):
134
+ """Base-excitation transmissibility |X / Y| of one mode with structural
135
+ damping (loss factor eta): 1 / eta at resonance, below 1 above
136
+ sqrt(2) f_n (isolation)."""
137
+ r2 = (np.asarray(f_forcing, float) / f_natural) ** 2
138
+ return np.sqrt((1 + eta ** 2) / ((1 - r2) ** 2 + eta ** 2))
139
+
140
+
141
+ def base_transmissibility(res: ModalResult, dofs, axis: int, freqs, eta: float, fixed=None,
142
+ stiffness_scale=None) -> np.ndarray:
143
+ """|absolute motion / base motion| at the given dofs (averaged, as complex
144
+ numbers) for a base shaken along `axis` (0, 1, 2) at each frequency, by
145
+ modal superposition with structural damping eta. The base is the
146
+ clamped dofs; `fixed` (bool per dof) leaves their mass out of the
147
+ participation factors. Truncated to the computed modes, so it holds up
148
+ to well below the highest of them. stiffness_scale (one per frequency)
149
+ multiplies every mode's stiffness at that frequency: a viscoelastic
150
+ mount that stiffens with frequency (viscoelastic.dynamic_factor over its
151
+ value where the modes were computed)."""
152
+ m = res.mass_vector.copy()
153
+ if fixed is not None:
154
+ m[np.asarray(fixed, bool)] = 0.0
155
+ r = np.zeros(len(m))
156
+ r[axis::3] = 1.0
157
+ gam = res.modes.T @ (m * r) # participation factors
158
+ phi = res.modes[np.asarray(dofs, np.int64)].mean(axis=0) # (k,)
159
+ wr2 = (2 * np.pi * res.frequencies) ** 2
160
+ w2 = (2 * np.pi * np.asarray(freqs, float))[:, None] ** 2
161
+ if stiffness_scale is not None:
162
+ wr2 = wr2[None, :] * np.asarray(stiffness_scale, float)[:, None]
163
+ drive = 1.0 if np.all(np.asarray(dofs) % 3 == axis) else 0.0
164
+ wr2 = np.broadcast_to(wr2, (w2.shape[0], len(gam)))
165
+ rel = (phi * gam)[None, :] * w2 / (wr2 - w2 + 1j * eta * wr2)
166
+ return np.abs(drive + rel.sum(axis=1))
167
+
168
+
169
+ def vibration_report(graph, spec: dict, material) -> dict:
170
+ """The field graph's `vibration` block: modes of the `envelope` filled
171
+ with the `lattice` at `density` (homogenized: E C rho^n, rho_s rho),
172
+ clamped inside the `fixed` solid, with point masses."""
173
+ from .impact import sample_core
174
+ from .lattice import sheet_modulus
175
+
176
+ kind = spec.get("lattice", "gyroid")
177
+ inside, rho, lo, h = sample_core(graph, spec["envelope"], spec.get("density"), float(spec.get("pitch", 1.0)))
178
+ ijk = np.argwhere(inside)
179
+ mesh = HexMesh(ijk, h, lo)
180
+ r = rho[inside]
181
+ mod = float(material.E) * sheet_modulus(kind, r)
182
+ dens = float(material.density) * r
183
+
184
+ def nodes_in(node):
185
+ return np.flatnonzero(graph.evaluate(mesh.node_xyz, node) <= 0.5 * h)
186
+
187
+ fixed = np.zeros(mesh.n_dof, bool)
188
+ fn = nodes_in(spec["fixed"])
189
+ if len(fn) == 0:
190
+ raise ValueError(f"vibration: no nodes inside the fixed node {spec['fixed']!r}")
191
+ for a in range(3):
192
+ fixed[3 * fn + a] = True
193
+ pm = []
194
+ for p in spec.get("pointMasses", []):
195
+ nn = nodes_in(p["region"])
196
+ if len(nn) == 0:
197
+ raise ValueError(f"vibration: point mass region {p['region']!r} has no nodes")
198
+ pm.append((nn, float(p["mass"])))
199
+ res = modal_analysis(mesh, mod, dens, float(material.nu), fixed, int(spec.get("modes", 6)), pm)
200
+ eta = float(spec.get("lossFactor", loss_factor(material)))
201
+ out = {"lattice": kind, "cells": int(mesh.n_elem), **res.to_dict(eta)}
202
+ f1 = float(res.frequencies[0])
203
+ if spec.get("minFrequency") is not None:
204
+ out["minFrequency"] = float(spec["minFrequency"])
205
+ out["ok"] = bool(f1 >= float(spec["minFrequency"]))
206
+ if spec.get("excitation") is not None:
207
+ fx = float(spec["excitation"])
208
+ out["excitation"] = fx
209
+ out["transmissibility"] = round(float(max(transmissibility(fx, f, eta) for f in res.frequencies)), 2)
210
+ return out