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/fem.py ADDED
@@ -0,0 +1,668 @@
1
+ """
2
+ Linear elastic finite elements on a voxel grid: 8-node hexahedra, all the
3
+ same cube, so one element stiffness matrix serves every element and a
4
+ density (or a material modulus) just scales it.
5
+
6
+ Two things make this fast enough to sit inside an optimization loop that
7
+ solves it a hundred times:
8
+
9
+ - Assembly never sorts. The grid is structured, so every stiffness entry
10
+ lands in a CSR slot we can compute up front from (node, neighbor offset).
11
+ Re-assembling for new densities is 64 vectorized scatter-adds.
12
+ - The solve is conjugate gradient preconditioned by smoothed-aggregation
13
+ algebraic multigrid (pyamg) with the six rigid-body modes as the near
14
+ null space. The hierarchy is expensive to build and densities change
15
+ slowly, so callers reuse it across iterations (`Solver`).
16
+
17
+ Units are whatever the caller uses consistently; loadpath uses mm, N, MPa.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import threading
22
+
23
+ import numpy as np
24
+ import scipy.sparse as sp
25
+ from scipy.sparse.linalg import cg
26
+
27
+ __all__ = ["Layered", "amg_solver", "elastic_c", "hex8_ke", "hex8_b", "hex8_element", "hex8_b_center", "hex8_fth", "thermal_load", "HexMesh", "Stiffness", "Solver", "von_mises"]
28
+
29
+ # Local corner order: bit 0 = +x, bit 1 = +y, bit 2 = +z.
30
+ CORNERS = np.array([[(c >> 0) & 1, (c >> 1) & 1, (c >> 2) & 1] for c in range(8)])
31
+ # The 27 neighbor offsets, lexicographic in (dx, dy, dz). With nodes numbered
32
+ # in C order over (i, j, k), that is also ascending node index, so CSR rows
33
+ # come out with sorted column indices.
34
+ OFFSETS = np.array([[dx, dy, dz] for dx in (-1, 0, 1) for dy in (-1, 0, 1) for dz in (-1, 0, 1)])
35
+
36
+
37
+ def _offset_index(d):
38
+ return (d[..., 0] + 1) * 9 + (d[..., 1] + 1) * 3 + (d[..., 2] + 1)
39
+
40
+
41
+ _VOIGT = ((0, 0), (1, 1), (2, 2), (0, 1), (1, 2), (2, 0))
42
+
43
+
44
+ class Layered:
45
+ """Transversely isotropic elasticity for a part built in layers: the
46
+ same in every direction within a layer, softer across layers. Stands in
47
+ for a Poisson's ratio wherever one is passed (Stiffness, hex8_ke,
48
+ elastic_c), so a layered material threads through the solver unchanged.
49
+
50
+ nu: in-plane Poisson's ratio; ez: modulus across layers / in-plane
51
+ modulus; gz: shear modulus between layers / in-plane E/(2(1+nu));
52
+ normal: the build direction (unit vector, the axis layers stack along).
53
+ The Poisson's ratio of in-plane stress onto the layer normal is taken as
54
+ nu, so nu_zp = nu * ez, which keeps C positive definite for ez in (0, 1]
55
+ and nu < 0.5."""
56
+
57
+ def __init__(self, nu: float, ez: float = 1.0, gz: float = 1.0, normal=(0.0, 0.0, 1.0)):
58
+ n = np.asarray(normal, float)
59
+ if not (0.0 < ez <= 1.0 and 0.0 < gz <= 1.0):
60
+ raise ValueError("Layered: ez and gz are fractions in (0, 1]")
61
+ self.nu = float(nu)
62
+ self.ez = float(ez)
63
+ self.gz = float(gz)
64
+ self.normal = tuple(n / np.linalg.norm(n))
65
+
66
+ def __repr__(self):
67
+ return f"Layered(nu={self.nu}, ez={self.ez}, gz={self.gz}, normal={self.normal})"
68
+
69
+ def __float__(self):
70
+ # code that only wants a Poisson's ratio (tet cross-check decks) gets one
71
+ return self.nu
72
+
73
+ def c(self) -> np.ndarray:
74
+ """6x6 at unit in-plane modulus, in the solver's frame."""
75
+ nu, ez = self.nu, self.ez
76
+ g = 1.0 / (2.0 * (1.0 + nu))
77
+ s = np.zeros((6, 6))
78
+ s[0, 0] = s[1, 1] = 1.0
79
+ s[0, 1] = s[1, 0] = -nu
80
+ s[0, 2] = s[2, 0] = s[1, 2] = s[2, 1] = -nu
81
+ s[2, 2] = 1.0 / ez
82
+ s[3, 3] = 1.0 / g
83
+ s[4, 4] = s[5, 5] = 1.0 / (self.gz * g)
84
+ local = np.linalg.inv(s)
85
+ # rotate from the frame whose z is the layer normal
86
+ z = np.asarray(self.normal)
87
+ a = np.array([1.0, 0.0, 0.0]) if abs(z[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
88
+ x = np.cross(a, z)
89
+ x /= np.linalg.norm(x)
90
+ rot = np.stack([x, np.cross(z, x), z], axis=1) # columns: local axes in solver frame
91
+ return _rotate_c(local, rot)
92
+
93
+
94
+ def _to_tensor(c):
95
+ t = np.zeros((3, 3, 3, 3))
96
+ for a, (i, j) in enumerate(_VOIGT):
97
+ for b, (k, l) in enumerate(_VOIGT):
98
+ for (p, q) in {(i, j), (j, i)}:
99
+ for (r, s_) in {(k, l), (l, k)}:
100
+ t[p, q, r, s_] = c[a, b]
101
+ return t
102
+
103
+
104
+ def _rotate_c(c, rot):
105
+ t = np.einsum("pi,qj,rk,sl,ijkl->pqrs", rot, rot, rot, rot, _to_tensor(c))
106
+ out = np.empty((6, 6))
107
+ for a, (i, j) in enumerate(_VOIGT):
108
+ for b, (k, l) in enumerate(_VOIGT):
109
+ out[a, b] = t[i, j, k, l]
110
+ return out
111
+
112
+
113
+ def elastic_c(e: float, nu) -> np.ndarray:
114
+ """Elasticity matrix (6x6, engineering shear strains): isotropic for a
115
+ Poisson's ratio, layered for a `Layered`."""
116
+ if isinstance(nu, Layered):
117
+ return e * nu.c()
118
+ lam = e * nu / ((1 + nu) * (1 - 2 * nu))
119
+ mu = e / (2 * (1 + nu))
120
+ c = np.zeros((6, 6))
121
+ c[:3, :3] = lam
122
+ c[np.arange(3), np.arange(3)] = lam + 2 * mu
123
+ c[np.arange(3, 6), np.arange(3, 6)] = mu
124
+ return c
125
+
126
+
127
+ def _b_matrix(xi, eta, zeta):
128
+ """Strain-displacement matrix (6x24) of the unit cube at a natural point
129
+ (xi, eta, zeta in [-1, 1]). For side h, divide by h."""
130
+ s = 2.0 * CORNERS - 1.0
131
+ dn = np.empty((8, 3))
132
+ dn[:, 0] = 0.25 * s[:, 0] * (1 + eta * s[:, 1]) * (1 + zeta * s[:, 2])
133
+ dn[:, 1] = 0.25 * s[:, 1] * (1 + xi * s[:, 0]) * (1 + zeta * s[:, 2])
134
+ dn[:, 2] = 0.25 * s[:, 2] * (1 + xi * s[:, 0]) * (1 + eta * s[:, 1])
135
+ b = np.zeros((6, 24))
136
+ for i in range(8):
137
+ dx, dy, dz = dn[i]
138
+ col = 3 * i
139
+ b[0, col] = dx
140
+ b[1, col + 1] = dy
141
+ b[2, col + 2] = dz
142
+ b[3, col], b[3, col + 1] = dy, dx
143
+ b[4, col + 1], b[4, col + 2] = dz, dy
144
+ b[5, col], b[5, col + 2] = dz, dx
145
+ return b
146
+
147
+
148
+ # The element: "im" (default) is the trilinear hex8 enriched with Wilson's
149
+ # nine incompatible bubble modes (1 - xi^2, ... per displacement component),
150
+ # condensed out element by element (Taylor's form: on a cube the bubbles'
151
+ # strains integrate to zero, so the patch test holds). The plain hex8
152
+ # shear-locks in bending: on a grid-aligned cantilever it read the root
153
+ # stress 38/16/9/4% low and the tip deflection 37/14/8/3% low at 1/2/3/5
154
+ # cells through the depth (moonshots/round2, unsafe on thin members); with
155
+ # the bubbles both are exact to under 1%. Same 24 dofs, same sparsity, so
156
+ # every solver path (assembled AMG, matrix-free multigrid, torch) takes it
157
+ # unchanged. LOADPATH_HEX8=plain switches back for A/B comparisons.
158
+ _ELEMENTS = ("im", "plain")
159
+ _PARTS = {}
160
+
161
+
162
+ def hex8_element() -> str:
163
+ """The element in force: LOADPATH_HEX8 ("im" or "plain"), default "im"."""
164
+ import os
165
+
166
+ e = os.environ.get("LOADPATH_HEX8", "im").strip().lower() or "im"
167
+ if e not in _ELEMENTS:
168
+ raise ValueError(f"LOADPATH_HEX8 must be one of {_ELEMENTS}, not {e!r}")
169
+ return e
170
+
171
+
172
+ def _b_bubble(xi, eta, zeta):
173
+ """Strain (6x9) of the nine bubble modes of the unit cube at a natural
174
+ point; column 3*m + c is mode (1 - x_m^2) in component c."""
175
+ x = (xi, eta, zeta)
176
+ b = np.zeros((6, 9))
177
+ for m in range(3):
178
+ d = np.zeros(3)
179
+ d[m] = -4.0 * x[m] # d/dx of (1 - xi^2) on the unit cube
180
+ dx, dy, dz = d
181
+ col = 3 * m
182
+ b[0, col] = dx
183
+ b[1, col + 1] = dy
184
+ b[2, col + 2] = dz
185
+ b[3, col], b[3, col + 1] = dy, dx
186
+ b[4, col + 1], b[4, col + 2] = dz, dy
187
+ b[5, col], b[5, col + 2] = dz, dx
188
+ return b
189
+
190
+
191
+ def _hex8_parts(nu, element):
192
+ """(ke 24x24, R 9x24 or None) of the unit cube at E = 1: R maps the
193
+ element's nodal displacements to its condensed bubble amplitudes."""
194
+ key = (repr(nu), element)
195
+ if key in _PARTS:
196
+ return _PARTS[key]
197
+ c = elastic_c(1.0, nu)
198
+ g = 1.0 / np.sqrt(3.0)
199
+ kuu = np.zeros((24, 24))
200
+ kua = np.zeros((24, 9))
201
+ kaa = np.zeros((9, 9))
202
+ for xi in (-g, g):
203
+ for eta in (-g, g):
204
+ for zeta in (-g, g):
205
+ b = _b_matrix(xi, eta, zeta)
206
+ ba = _b_bubble(xi, eta, zeta)
207
+ kuu += b.T @ c @ b * 0.125 # det J of the unit cube map = 1/8
208
+ kua += b.T @ c @ ba * 0.125
209
+ kaa += ba.T @ c @ ba * 0.125
210
+ if element == "plain":
211
+ out = (0.5 * (kuu + kuu.T), None)
212
+ else:
213
+ r = -np.linalg.solve(kaa, kua.T)
214
+ ke = kuu + kua @ r
215
+ out = (0.5 * (ke + ke.T), r)
216
+ _PARTS[key] = out
217
+ return out
218
+
219
+
220
+ def hex8_ke(nu, element: str | None = None) -> np.ndarray:
221
+ """Stiffness (24x24) of a unit cube with E = 1, 2x2x2 Gauss, bubbles
222
+ condensed (see hex8_element). For side h and modulus E the element
223
+ matrix is E * h * this."""
224
+ return _hex8_parts(nu, element or hex8_element())[0].copy()
225
+
226
+
227
+ def hex8_b(xi, eta, zeta, nu, element: str | None = None) -> np.ndarray:
228
+ """Strain-displacement matrix (6x24) of the unit cube at a natural point
229
+ for the element in force: the trilinear field plus the condensed
230
+ bubbles' strain (zero at the centre). For side h, divide by h. Corner
231
+ stress for nodal averaging must use this, not _b_matrix: in bending the
232
+ bending strain the element carries lives partly in the bubbles."""
233
+ b = _b_matrix(xi, eta, zeta)
234
+ r = _hex8_parts(nu, element or hex8_element())[1]
235
+ if r is not None:
236
+ b = b + _b_bubble(xi, eta, zeta) @ r
237
+ return b
238
+
239
+
240
+ _GAUSS_B = {}
241
+
242
+
243
+ def _gauss_b():
244
+ """(B (8, 6, 24), bubble B (8, 6, 9)) at the 2x2x2 Gauss points of the
245
+ unit cube."""
246
+ if not _GAUSS_B:
247
+ g = 1.0 / np.sqrt(3.0)
248
+ pts = [(a, b, c) for a in (-g, g) for b in (-g, g) for c in (-g, g)]
249
+ _GAUSS_B["b"] = np.array([_b_matrix(*p) for p in pts])
250
+ _GAUSS_B["ba"] = np.array([_b_bubble(*p) for p in pts])
251
+ return _GAUSS_B["b"], _GAUSS_B["ba"]
252
+
253
+
254
+ def hex8_ke_batch(c: np.ndarray, element: str | None = None):
255
+ """Per-element stiffness of unit cubes with per-element elasticity
256
+ matrices `c` (ne, 6, 6): (ke (ne, 24, 24), R (ne, 9, 24) or None), the
257
+ bubbles condensed element by element as hex8_ke does for one material
258
+ (the condensation depends on each element's C, so an anisotropic cell
259
+ cannot share one matrix). For side h the element matrix is h * ke; R
260
+ maps nodal displacements to bubble amplitudes (strain recovery)."""
261
+ element = element or hex8_element()
262
+ c = np.asarray(c, float)
263
+ b, ba = _gauss_b()
264
+ cb = np.einsum("eij,gjk->egik", c, b) # (ne, 8, 6, 24)
265
+ kuu = 0.125 * np.einsum("gia,egib->eab", b, cb)
266
+ if element == "plain":
267
+ return 0.5 * (kuu + np.swapaxes(kuu, 1, 2)), None
268
+ kua = 0.125 * np.einsum("egia,gib->eab", cb, ba) # (ne, 24, 9)
269
+ kaa = 0.125 * np.einsum("gia,eij,gjb->eab", ba, c, ba)
270
+ r = -np.linalg.solve(kaa, np.swapaxes(kua, 1, 2)) # (ne, 9, 24)
271
+ ke = kuu + kua @ r
272
+ return 0.5 * (ke + np.swapaxes(ke, 1, 2)), r
273
+
274
+
275
+ def hex8_fth(nu) -> np.ndarray:
276
+ """Thermal load (24,) of a unit cube with E = 1 and a free expansion
277
+ strain of 1 in every direction (alpha * dT = 1): the integral of
278
+ B^T C m, m = (1, 1, 1, 0, 0, 0), 2x2x2 Gauss. For side h, modulus E and
279
+ expansion strain eps the element's load is E * eps * h^2 * this: the
280
+ nodal forces that hold the cube at its size when it wants to grow."""
281
+ c = elastic_c(1.0, nu)
282
+ m = np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0])
283
+ g = 1.0 / np.sqrt(3.0)
284
+ f = np.zeros(24)
285
+ for xi in (-g, g):
286
+ for eta in (-g, g):
287
+ for zeta in (-g, g):
288
+ f += _b_matrix(xi, eta, zeta).T @ (c @ m) * 0.125
289
+ return f
290
+
291
+
292
+ def thermal_load(mesh, nu, modulus, strain, fixed=None) -> np.ndarray:
293
+ """Global load vector (n_dof,) of per-element expansion strains
294
+ (`strain` = alpha * (T - T_ref), a scalar or per element) at per-element
295
+ `modulus` (E_e, density interpolation included). Solving K u = this
296
+ (plus any mechanical load) gives the thermo-elastic displacement; the
297
+ stress is then Stiffness.stress(u, modulus, strain). Entries on `fixed`
298
+ dofs are zeroed (Dirichlet rows are identity rows here)."""
299
+ w = (np.asarray(modulus, float) * np.asarray(strain, float)) * np.ones(mesh.n_elem)
300
+ fe = hex8_fth(nu) * mesh.pitch ** 2
301
+ f = np.bincount(mesh.edof.ravel(), weights=(w[:, None] * fe[None, :]).ravel(), minlength=mesh.n_dof)
302
+ if fixed is not None:
303
+ f[np.asarray(fixed, bool)] = 0.0
304
+ return f
305
+
306
+
307
+ def hex8_b_center() -> np.ndarray:
308
+ return _b_matrix(0.0, 0.0, 0.0)
309
+
310
+
311
+ def von_mises(stress: np.ndarray) -> np.ndarray:
312
+ sx, sy, sz, txy, tyz, tzx = stress.T
313
+ return np.sqrt(0.5 * ((sx - sy) ** 2 + (sy - sz) ** 2 + (sz - sx) ** 2)
314
+ + 3.0 * (txy ** 2 + tyz ** 2 + tzx ** 2))
315
+
316
+
317
+ class HexMesh:
318
+ """The active elements of a voxel grid and the nodes they use.
319
+
320
+ elem_ijk: (ne, 3) integer cell indices; pitch: cell size; origin: world
321
+ position of node (0, 0, 0), i.e. the min corner of cell (0, 0, 0)."""
322
+
323
+ def __init__(self, elem_ijk: np.ndarray, pitch: float, origin: np.ndarray):
324
+ self.elem_ijk = np.asarray(elem_ijk, np.int64)
325
+ self.pitch = float(pitch)
326
+ self.origin = np.asarray(origin, float)
327
+ ne = len(self.elem_ijk)
328
+ if ne == 0:
329
+ raise ValueError("no active elements")
330
+ dims = self.elem_ijk.max(axis=0) + 2
331
+ self.node_dims = dims
332
+ # Node ids in ascending grid order (what np.unique over the corner
333
+ # indices gave), by marking the node grid instead of sorting 8 ids
334
+ # per cell: linear time and memory, which matters from ~1M cells.
335
+ e_flat = (self.elem_ijk[:, 0] * dims[1] + self.elem_ijk[:, 1]) * dims[2] + self.elem_ijk[:, 2]
336
+ offs = (CORNERS[:, 0] * dims[1] + CORNERS[:, 1]) * dims[2] + CORNERS[:, 2]
337
+ mark = np.zeros(int(np.prod(dims)), bool)
338
+ for o in offs:
339
+ mark[e_flat + o] = True
340
+ used = np.flatnonzero(mark)
341
+ del mark
342
+ self._lookup = np.full(int(np.prod(dims)), -1, np.int64)
343
+ self._lookup[used] = np.arange(len(used))
344
+ self.enode = self._lookup[e_flat[:, None] + offs[None, :]]
345
+ self.node_ijk = np.stack(np.unravel_index(used, tuple(dims)), axis=1).astype(np.int64)
346
+ self.n_nodes = len(used)
347
+ self.node_xyz = self.origin + self.node_ijk * self.pitch
348
+ self.n_dof = 3 * self.n_nodes
349
+ self._edof = None
350
+
351
+ @property
352
+ def n_elem(self):
353
+ return len(self.elem_ijk)
354
+
355
+ @property
356
+ def edof(self):
357
+ # built on first use: 192 bytes per cell, and the multigrid path
358
+ # never needs it
359
+ if self._edof is None:
360
+ self._edof = (3 * self.enode[:, :, None] + np.arange(3)).reshape(self.n_elem, 24)
361
+ return self._edof
362
+
363
+ def elem_centers(self):
364
+ return self.origin + (self.elem_ijk + 0.5) * self.pitch
365
+
366
+ def node_id(self, ijk: np.ndarray) -> np.ndarray:
367
+ """Node id at integer node coords, -1 if absent or out of range."""
368
+ ijk = np.asarray(ijk, np.int64)
369
+ d = self.node_dims
370
+ ok = np.all((ijk >= 0) & (ijk < d), axis=-1)
371
+ flat = (ijk[..., 0] * d[1] + ijk[..., 1]) * d[2] + ijk[..., 2]
372
+ out = np.full(ijk.shape[:-1], -1, np.int64)
373
+ out[ok] = self._lookup[flat[ok]]
374
+ return out
375
+
376
+
377
+ class Stiffness:
378
+ """Precomputed CSR layout for a HexMesh; `assemble(scale)` returns the
379
+ global stiffness for per-element moduli `scale` (E_e, already including
380
+ any density interpolation), with Dirichlet conditions applied as
381
+ identity rows so the matrix keeps one fixed shape."""
382
+
383
+ def __init__(self, mesh: HexMesh, nu, fixed: np.ndarray, element: str | None = None):
384
+ self.mesh = mesh
385
+ # element=None takes hex8_element(); "plain" for the trilinear hex8
386
+ # the geometrically nonlinear element (nonlinear.py) is built on
387
+ self.element = element or hex8_element()
388
+ self.ke = hex8_ke(nu, self.element) * mesh.pitch # E = 1, side h
389
+ self.nu = nu
390
+ nn = mesh.n_nodes
391
+ enode = mesh.enode
392
+ nijk = mesh.node_ijk
393
+
394
+ # Which (node, offset) pairs actually share an element.
395
+ adj = np.zeros((nn, 27), bool)
396
+ pair_off = np.empty((8, 8), np.int64)
397
+ for a in range(8):
398
+ for b in range(8):
399
+ pair_off[a, b] = _offset_index(CORNERS[b] - CORNERS[a])
400
+ adj[enode[:, a], pair_off[a, b]] = True
401
+ cnt = adj.sum(axis=1)
402
+ rank = np.cumsum(adj, axis=1) - 1
403
+ row_len = np.repeat(3 * cnt, 3)
404
+ self.indptr = np.concatenate([[0], np.cumsum(row_len)]).astype(np.int64)
405
+ nnz = int(self.indptr[-1])
406
+ indices = np.empty(nnz, np.int64)
407
+ n_idx, o_idx = np.nonzero(adj) # sorted by node, then offset
408
+ nbr = mesh.node_id(nijk[n_idx] + OFFSETS[o_idx])
409
+ for i in range(3):
410
+ base = self.indptr[3 * n_idx + i] + 3 * rank[n_idx, o_idx]
411
+ for j in range(3):
412
+ indices[base + j] = 3 * nbr + j
413
+ self.indices = indices
414
+ self.nnz = nnz
415
+
416
+ # Per corner pair: the slot of entry (3a+i, 3b+j) is base + i*row + j.
417
+ # int32 slots when they fit: these 64 arrays are the bulk of this
418
+ # object's memory (9 slots per element per corner pair).
419
+ idx_t = np.int32 if nnz < 2 ** 31 - 1 else np.int64
420
+ # one (64, elements, 9) array, the pairs views of it (femops.scatter
421
+ # runs over all 64 in one call)
422
+ self._slots = np.empty((64, mesh.n_elem, 9), idx_t)
423
+ self._blks = np.empty((64, 9))
424
+ self._pairs = []
425
+ for a in range(8):
426
+ na = enode[:, a]
427
+ row = 3 * cnt[na]
428
+ for b in range(8):
429
+ base = self.indptr[3 * na] + 3 * rank[na, pair_off[a, b]]
430
+ blk = self.ke[3 * a:3 * a + 3, 3 * b:3 * b + 3].ravel()
431
+ p = 8 * a + b
432
+ self._slots[p] = (base[:, None] + np.repeat(np.arange(3), 3)[None, :] * row[:, None]
433
+ + np.tile(np.arange(3), 3)[None, :])
434
+ self._blks[p] = blk
435
+ self._pairs.append((self._slots[p], self._blks[p]))
436
+ self.indptr = self.indptr.astype(idx_t)
437
+ self.indices = self.indices.astype(idx_t)
438
+
439
+ fixed = np.asarray(fixed, bool)
440
+ if fixed.shape != (mesh.n_dof,):
441
+ raise ValueError("fixed must be a bool per dof")
442
+ self.fixed = fixed
443
+ row_of_slot = np.repeat(np.arange(mesh.n_dof), row_len)
444
+ self._keep = ~(fixed[row_of_slot] | fixed[indices])
445
+ diag = np.nonzero((row_of_slot == indices) & fixed[row_of_slot])[0]
446
+ self._diag_fixed = diag
447
+ self.free = ~fixed
448
+
449
+ def assemble(self, scale: np.ndarray) -> sp.csr_matrix:
450
+ data = np.zeros(self.nnz)
451
+ from . import femops
452
+
453
+ if femops.available():
454
+ femops.scatter(data, self._slots, self._blks, scale)
455
+ else:
456
+ s = np.asarray(scale, float)[:, None]
457
+ for slots, blk in self._pairs:
458
+ data[slots] += s * blk
459
+ data *= self._keep
460
+ data[self._diag_fixed] = 1.0
461
+ k = sp.csr_matrix((data, self.indices, self.indptr), shape=(self.mesh.n_dof, self.mesh.n_dof))
462
+ k.has_sorted_indices = True
463
+ return k
464
+
465
+ def add_elements(self, data: np.ndarray, kes: np.ndarray, elems: np.ndarray) -> None:
466
+ """Scatter per-element 24x24 matrices (a tangent that differs element
467
+ to element, not a scaled copy of one) for elements `elems` into a CSR
468
+ data array of length nnz. Finish with `finish(data)`."""
469
+ for idx, (slots, _) in enumerate(self._pairs):
470
+ a, b = divmod(idx, 8)
471
+ data[slots[elems]] += kes[:, 3 * a:3 * a + 3, 3 * b:3 * b + 3].reshape(len(elems), 9)
472
+
473
+ def finish(self, data: np.ndarray, diag: float = 1.0) -> sp.csr_matrix:
474
+ """CSR matrix from scattered data, Dirichlet rows and columns cleared
475
+ and `diag` on their diagonal (1 for a stiffness, 0 for a geometric
476
+ stiffness in an eigenproblem)."""
477
+ data = data * self._keep
478
+ data[self._diag_fixed] = diag
479
+ k = sp.csr_matrix((data, self.indices, self.indptr), shape=(self.mesh.n_dof, self.mesh.n_dof))
480
+ k.has_sorted_indices = True
481
+ return k
482
+
483
+ def strain_energy(self, u: np.ndarray) -> np.ndarray:
484
+ """Per element u_e^T Ke u_e at unit modulus (multiply by E_e)."""
485
+ from . import femops
486
+
487
+ if femops.available():
488
+ return femops.element_energy(u, None, self.mesh.edof, self.ke)
489
+ ue = u[self.mesh.edof]
490
+ return np.einsum("ij,jk,ik->i", ue, self.ke, ue)
491
+
492
+ def stress(self, u: np.ndarray, modulus: np.ndarray | float, thermal=None) -> np.ndarray:
493
+ """Element-center stress (ne, 6) for per-element modulus; `thermal`
494
+ is the free expansion strain alpha * dT (scalar or per element),
495
+ which does not stress the cell, so it comes off the normal strains."""
496
+ b = hex8_b_center() / self.mesh.pitch
497
+ c = elastic_c(1.0, self.nu)
498
+ strain = u[self.mesh.edof] @ b.T
499
+ if thermal is not None:
500
+ strain[:, :3] -= (np.asarray(thermal, float) * np.ones(len(strain)))[:, None]
501
+ return (strain @ c.T) * np.asarray(modulus, float).reshape(-1, 1)
502
+
503
+
504
+ def rigid_body_modes(xyz: np.ndarray) -> np.ndarray:
505
+ n = len(xyz)
506
+ b = np.zeros((3 * n, 6))
507
+ x, y, z = (xyz - xyz.mean(axis=0)).T
508
+ for i in range(3):
509
+ b[i::3, i] = 1.0
510
+ b[0::3, 3], b[1::3, 3] = -y, x
511
+ b[1::3, 4], b[2::3, 4] = -z, y
512
+ b[0::3, 5], b[2::3, 5] = z, -x
513
+ return b
514
+
515
+
516
+ _AMG_SETUP = threading.Lock()
517
+
518
+
519
+ def amg_solver(a, **kw):
520
+ """pyamg.smoothed_aggregation_solver, reproducibly. pyamg estimates
521
+ spectral radii (the prolongation smoother's weight) from np.random's
522
+ global stream, so the hierarchy, and with it every iterate, changed from
523
+ run to run: the printed hook's overhang read 0.040 or 0.042 on the same
524
+ code. Seed the stream for the setup and give the caller's back."""
525
+ import pyamg
526
+
527
+ from . import amgfast
528
+
529
+ amgfast.install() # GIL-free Gauss-Seidel, same bits: solve_many's threads overlap
530
+ # one setup at a time: the seeded global stream is shared, and threads
531
+ # setting up at once (verify's submodels) would draw from each other's
532
+ with _AMG_SETUP:
533
+ state = np.random.get_state()
534
+ np.random.seed(0)
535
+ try:
536
+ return pyamg.smoothed_aggregation_solver(a, **kw)
537
+ finally:
538
+ np.random.set_state(state)
539
+
540
+
541
+ class Solver:
542
+ """CG + AMG with a reusable hierarchy. `solve(K, f, x0)` rebuilds the
543
+ multigrid hierarchy when asked to, or when the last solve needed too many
544
+ iterations (the old hierarchy no longer matches K well)."""
545
+
546
+ # Refresh (rather than rebuild) the hierarchy for a new matrix at most
547
+ # this many times in a row.
548
+ REFRESH_AGE = 8
549
+
550
+ def __init__(self, mesh: HexMesh, rtol=1e-6, maxiter=800, rebuild_after_iters=25, refresh=True):
551
+ self.rtol = rtol
552
+ self.refresh = refresh
553
+ self.max_age = self.REFRESH_AGE
554
+ self.maxiter = maxiter
555
+ self.rebuild_after_iters = rebuild_after_iters
556
+ self._b = rigid_body_modes(mesh.node_xyz)
557
+ self._ml = None
558
+ self._age = 0
559
+ self.last_iters = 0
560
+ self.use_amg = True
561
+ try:
562
+ import pyamg # noqa: F401
563
+ except ImportError: # pragma: no cover - pyamg is a declared dependency
564
+ self.use_amg = False
565
+
566
+ def invalidate(self):
567
+ self._ml = None
568
+
569
+ def _precond(self, k):
570
+ if not self.use_amg:
571
+ d = k.diagonal()
572
+ d[d == 0] = 1.0
573
+ return sp.diags(1.0 / d)
574
+ if self._ml is not None and self._ml.levels[0].A is not k:
575
+ if self.refresh and self._age < self.max_age:
576
+ # A new matrix on the same mesh (the optimizer's next design):
577
+ # keep the hierarchy but smooth with the matrix being solved.
578
+ # Smoothing with the previous design's matrix (as this did
579
+ # until 2026-10-30) took 2-5x the CG iterations of a fresh
580
+ # hierarchy, so every other step paid for a rebuild; this
581
+ # cut an arm-link step from 3.1 s to 2.3 s. (Recomputing the
582
+ # coarse operators too, R A P, was slower: the old
583
+ # prolongators span void and solid alike, and on a design
584
+ # that had just moved they converged worse than these.)
585
+ self._ml.levels[0].A = k
586
+ self._age += 1
587
+ else:
588
+ self._ml = None
589
+ if self._ml is None:
590
+ # coarse_solver: pyamg's default is a dense pseudo-inverse (an SVD)
591
+ # of the coarsest level. Aggregation on these stiffness matrices
592
+ # often stops coarsening at a few thousand unknowns, where that SVD
593
+ # alone took ~3s per rebuild; a sparse LU of the same level is
594
+ # milliseconds.
595
+ self._ml = amg_solver(k, B=self._b, symmetry="hermitian", strength="symmetric", max_coarse=400,
596
+ coarse_solver="splu")
597
+ self._age = 0
598
+ return self._ml.aspreconditioner(cycle="V")
599
+
600
+ def _maxiter(self):
601
+ """A refreshed hierarchy that stops working (the design moved too far
602
+ from the one it was aggregated on) gives up early and is rebuilt,
603
+ rather than running to maxiter."""
604
+ if self._age == 0:
605
+ return self.maxiter
606
+ return min(self.maxiter, 3 * self.rebuild_after_iters)
607
+
608
+ def solve(self, k, f, x0=None):
609
+ count = [0]
610
+
611
+ def cb(_):
612
+ count[0] += 1
613
+
614
+ m = self._precond(k)
615
+ u, info = cg(k, f, x0=x0, rtol=self.rtol, maxiter=self._maxiter(), M=m, callback=cb)
616
+ if info != 0 and self._ml is not None:
617
+ # Stale hierarchy: rebuild once and retry from where we got to.
618
+ self._ml = None
619
+ count[0] = 0
620
+ u, info = cg(k, f, x0=u, rtol=self.rtol, maxiter=self.maxiter, M=self._precond(k), callback=cb)
621
+ self.last_iters = count[0]
622
+ if count[0] > self.rebuild_after_iters:
623
+ self._ml = None
624
+ if info != 0:
625
+ raise RuntimeError(f"linear solve did not converge ({count[0]} iterations); "
626
+ "the structure may be a mechanism (check supports)")
627
+ return u
628
+
629
+ def solve_many(self, k, fs, x0s=None):
630
+ """Solve several right-hand sides (load cases) against one matrix.
631
+ The preconditioner is built once and the solves run on threads: the
632
+ sparse kernels release the GIL, and so does the smoother since
633
+ amgfast (pyamg's own holds it); 3 GRIP upper-arm cases 1.35 -> 1.07 s."""
634
+ x0s = x0s or [None] * len(fs)
635
+ if len(fs) == 1:
636
+ return [self.solve(k, fs[0], x0s[0])]
637
+ from concurrent.futures import ThreadPoolExecutor
638
+
639
+ m = self._precond(k)
640
+ # pyamg factors the coarsest level (splu) lazily, on the first
641
+ # V-cycle: started from threads, every case factored it at once and
642
+ # the extra factors stayed allocated (the GRIP wrist yoke's coarsest
643
+ # level is a 60-125 MB LU; resident memory grew ~0.5 GB per design,
644
+ # 3 to 7 GB over a search). One cycle here factors it once.
645
+ m.matvec(np.zeros(k.shape[0]))
646
+ maxiter = self._maxiter()
647
+ counts = [0] * len(fs)
648
+
649
+ def one(i):
650
+ def cb(_):
651
+ counts[i] += 1
652
+ u, info = cg(k, fs[i], x0=x0s[i], rtol=self.rtol, maxiter=maxiter, M=m, callback=cb)
653
+ return u, info
654
+
655
+ with ThreadPoolExecutor(min(len(fs), 4)) as ex:
656
+ res = list(ex.map(one, range(len(fs))))
657
+ out = []
658
+ for i, (u, info) in enumerate(res):
659
+ if info != 0:
660
+ # Fall back to the single-solve path, which rebuilds a stale
661
+ # hierarchy and retries.
662
+ self._ml = None
663
+ u = self.solve(k, fs[i], u)
664
+ out.append(u)
665
+ self.last_iters = max(counts)
666
+ if self.last_iters > self.rebuild_after_iters:
667
+ self._ml = None
668
+ return out