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/mg.py ADDED
@@ -0,0 +1,853 @@
1
+ """
2
+ Matrix-free geometric multigrid for the voxel elasticity problem, so the
3
+ optimizer can run on millions of cells instead of ~100k.
4
+
5
+ The assembled path (fem.Stiffness + pyamg) stores 81 x 3 stiffness entries
6
+ per node plus their slot tables: about 2 KB per cell, and pyamg's setup on
7
+ top of that. Here nothing per cell is stored but the cell's modulus:
8
+
9
+ - The fine operator is applied element by element on the dense node grid:
10
+ gather the 8 corner displacements of every cell (8 slice copies), one GEMM
11
+ with the shared 24x24 unit-cube stiffness, scale by the cell's modulus,
12
+ scatter back. ~100 ns per cell, ~16 bytes of state per cell.
13
+ - Coarse levels are exact Galerkin operators P^T A P built element by
14
+ element. With trilinear prolongation on a grid coarsened by two, a fine
15
+ cell's nodes interpolate only from the coarse cell that contains it, so
16
+ the coarse cell's matrix is the sum over its eight children of
17
+ P_k^T K_child P_k. On the first coarse level that is sum_k E_child * M_k
18
+ with eight fixed 24x24 matrices M_k: the level is stored as the fine
19
+ moduli themselves (a reshape, no setup). Deeper levels store their 24x24
20
+ matrices outright (1/64 of the cells and fewer).
21
+ - Supports: prolongation is masked to free dofs (P' = D P), so a coarse
22
+ correction never moves a held dof and the coarse operator is the exact
23
+ Galerkin product of the held system; cells touching a held node are the
24
+ only ones whose coarse contribution is not a weight times M_k, and they
25
+ are kept as an explicit patch.
26
+ - Smoothing is Chebyshev on the Jacobi-scaled operator (symmetric, no
27
+ ordering, vectorizes), the coarsest level is assembled and solved by
28
+ sparse LU or, if still large, one pyamg V-cycle. The V-cycle runs in
29
+ float32; the outer conjugate gradient and its operator run in float64,
30
+ so the converged answer is a float64 one.
31
+
32
+ Galerkin coarsening is what keeps this robust to SIMP's million-to-one
33
+ stiffness contrast: a coarse cell that straddles a strut and void sees the
34
+ strut's real stiffness, not an average modulus.
35
+
36
+ `GridElasticity` takes a fem.HexMesh, a Poisson's ratio (or fem.Layered), the
37
+ held dofs and optional springs, and solves for per-cell moduli with
38
+ displacements in the HexMesh's own dof numbering, so callers swap it in for
39
+ Stiffness + Solver.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import os
44
+
45
+ import numpy as np
46
+ import scipy.sparse as sp
47
+
48
+ from .fem import CORNERS, hex8_element, hex8_ke, rigid_body_modes
49
+
50
+ __all__ = ["GridElasticity", "prolong", "restrict"]
51
+
52
+ # Corner k of a cell sits at (k & 1, k >> 1 & 1, k >> 2 & 1), as in fem.CORNERS.
53
+ _CORNER_OFF = [tuple(int(v) for v in c) for c in CORNERS]
54
+
55
+
56
+ def _child_prolongations() -> np.ndarray:
57
+ """P_k (8, 24, 24): coarse-cell dofs -> dofs of child k (child k is the
58
+ fine cell at offset CORNERS[k] inside the coarse cell)."""
59
+ out = np.zeros((8, 24, 24))
60
+ for k in range(8):
61
+ for q in range(8):
62
+ p = CORNERS[k] + CORNERS[q] # fine node in fine-cell units, 0..2
63
+ for r in range(8):
64
+ w = 1.0
65
+ for d in range(3):
66
+ t = p[d] / 2.0
67
+ w *= t if CORNERS[r][d] else 1.0 - t
68
+ if w:
69
+ for i in range(3):
70
+ out[k, 3 * q + i, 3 * r + i] = w
71
+ return out
72
+
73
+
74
+ _PK = _child_prolongations()
75
+
76
+ # Chebyshev needs an upper bound on the spectrum of D^-1 A. A dozen power
77
+ # iterations land ~10% under it, and an underestimate lets the top modes
78
+ # grow: on a uniform 500k-cell bar 1.1x took 16 CG iterations, the converged
79
+ # bound 6. Overestimating costs far less than underestimating.
80
+ LMAX_SAFETY = 1.3
81
+
82
+
83
+ def prolong(c: np.ndarray) -> np.ndarray:
84
+ """Trilinear prolongation of a node-grid field (..., nx+1, ny+1, nz+1) to
85
+ the grid with twice the cells (..., 2nx+1, 2ny+1, 2nz+1)."""
86
+ out = c
87
+ for ax in (-3, -2, -1):
88
+ n = out.shape[ax]
89
+ shape = list(out.shape)
90
+ shape[ax] = 2 * n - 1
91
+ f = np.empty(shape, out.dtype)
92
+ sl_even = [slice(None)] * f.ndim
93
+ sl_even[ax] = slice(0, None, 2)
94
+ sl_odd = [slice(None)] * f.ndim
95
+ sl_odd[ax] = slice(1, None, 2)
96
+ lo = [slice(None)] * f.ndim
97
+ lo[ax] = slice(0, n - 1)
98
+ hi = [slice(None)] * f.ndim
99
+ hi[ax] = slice(1, n)
100
+ f[tuple(sl_even)] = out
101
+ np.add(out[tuple(lo)], out[tuple(hi)], out=f[tuple(sl_odd)])
102
+ f[tuple(sl_odd)] *= 0.5
103
+ out = f
104
+ return out
105
+
106
+
107
+ def restrict(f: np.ndarray) -> np.ndarray:
108
+ """Transpose of `prolong`."""
109
+ out = f
110
+ for ax in (-3, -2, -1):
111
+ n = (out.shape[ax] + 1) // 2
112
+ sl_even = [slice(None)] * out.ndim
113
+ sl_even[ax] = slice(0, None, 2)
114
+ sl_odd = [slice(None)] * out.ndim
115
+ sl_odd[ax] = slice(1, None, 2)
116
+ c = out[tuple(sl_even)].copy()
117
+ half = 0.5 * out[tuple(sl_odd)]
118
+ lo = [slice(None)] * c.ndim
119
+ lo[ax] = slice(0, n - 1)
120
+ hi = [slice(None)] * c.ndim
121
+ hi[ax] = slice(1, n)
122
+ c[tuple(lo)] += half
123
+ c[tuple(hi)] += half
124
+ out = c
125
+ return out
126
+
127
+
128
+ def _slab(cells_yz: int, dtype) -> int:
129
+ """x-planes of cells per chunk: keep the (24, m, ny, nz) gather buffer
130
+ near 32 MB."""
131
+ per_plane = 24 * max(cells_yz, 1) * np.dtype(dtype).itemsize
132
+ return max(1, int(32e6 // per_plane))
133
+
134
+
135
+ def _gather(u, r, out):
136
+ x0, x1, y0, y1, z0, z1 = r
137
+ for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
138
+ out[3 * c:3 * c + 3] = u[:, x0 + dx:x1 + dx, y0 + dy:y1 + dy, z0 + dz:z1 + dz]
139
+
140
+
141
+ def _scatter(f, r, out):
142
+ x0, x1, y0, y1, z0, z1 = r
143
+ for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
144
+ out[:, x0 + dx:x1 + dx, y0 + dy:y1 + dy, z0 + dz:z1 + dz] += f[3 * c:3 * c + 3]
145
+
146
+
147
+ # Tiles the operator is applied over: TILE_X x-planes by TILE_Y y-rows, cut
148
+ # to the z-range of cells that carry stiffness, and skipped when none do.
149
+ # A converged design fills a third of its box or less, and the box itself
150
+ # includes keep-outs, so most of the grid is never touched.
151
+ TILE_X, TILE_Y = 8, 16
152
+
153
+
154
+ def _tiles(active: np.ndarray) -> list:
155
+ nx, ny, nz = active.shape
156
+ out = []
157
+ for x0 in range(0, nx, TILE_X):
158
+ x1 = min(x0 + TILE_X, nx)
159
+ for y0 in range(0, ny, TILE_Y):
160
+ y1 = min(y0 + TILE_Y, ny)
161
+ zs = np.flatnonzero(active[x0:x1, y0:y1].any(axis=(0, 1)))
162
+ if len(zs):
163
+ out.append((x0, x1, y0, y1, int(zs[0]), int(zs[-1]) + 1))
164
+ return out
165
+
166
+
167
+ class _Level:
168
+ """One grid level: cells (nx, ny, nz), node grid (3, nx+1, ny+1, nz+1).
169
+
170
+ kind "scaled": cell matrix = E[cell] * ke (the fine level).
171
+ kind "weights": cell matrix = sum_k W[k, cell] * M_k, plus an explicit
172
+ patch for cells listed in patch_ijk (their W is zero).
173
+ kind "explicit": cell matrix stored, K (24, 24, nx, ny, nz).
174
+ `mask` (3, nodes...) is 1 on dofs that are free and carry stiffness;
175
+ the operator is mask * K * mask + (1 - mask) (identity on the rest)."""
176
+
177
+ def __init__(self, kind, dims, dtype):
178
+ self.kind = kind
179
+ self.dims = tuple(int(d) for d in dims)
180
+ self.dtype = dtype
181
+ self.ke = None
182
+ self.e = None
183
+ self.mcat = None
184
+ self.w = None
185
+ self.patch_ijk = None
186
+ self.patch_k = None
187
+ self.k = None
188
+ self.extra = None # sparse on the flattened (3, nodes) layout, or None
189
+ self.mask = None
190
+ self.dinv = None
191
+ self.lmax = None
192
+ self.tiles = None
193
+
194
+ @property
195
+ def node_shape(self):
196
+ nx, ny, nz = self.dims
197
+ return (3, nx + 1, ny + 1, nz + 1)
198
+
199
+ def active(self) -> np.ndarray:
200
+ """Cells with a nonzero matrix outside the patch."""
201
+ if self.kind == "scaled":
202
+ return self.e != 0
203
+ if self.kind == "weights":
204
+ return self.w.any(axis=0)
205
+ return np.einsum("iixyz->xyz", self.k) > 0
206
+
207
+ def _cell_kernel(self, buf, r):
208
+ """f (24, cells of region r) = the cells' matrices times buf."""
209
+ x0, x1, y0, y1, z0, z1 = r
210
+ shp = (x1 - x0, y1 - y0, z1 - z0)
211
+ flat = buf.reshape(24, -1)
212
+ if self.kind == "scaled":
213
+ f = (self.ke @ flat).reshape((24,) + shp)
214
+ f *= self.e[x0:x1, y0:y1, z0:z1][None]
215
+ return f
216
+ if self.kind == "weights":
217
+ g = (self.mcat @ flat).reshape((8, 24) + shp)
218
+ w = self.w[:, x0:x1, y0:y1, z0:z1]
219
+ f = g[0] * w[0][None]
220
+ for k in range(1, 8):
221
+ f += g[k] * w[k][None]
222
+ return f
223
+ kk = self.k[:, :, x0:x1, y0:y1, z0:z1].reshape(24, 24, -1)
224
+ return np.einsum("ijn,jn->in", kk, flat).reshape((24,) + shp)
225
+
226
+ def apply_k(self, u):
227
+ """Unmasked K u on the node grid."""
228
+ if self.tiles is None:
229
+ self.tiles = _tiles(self.active())
230
+ out = np.zeros(self.node_shape, u.dtype)
231
+ for r in self.tiles:
232
+ buf = np.empty((24, r[1] - r[0], r[3] - r[2], r[5] - r[4]), u.dtype)
233
+ _gather(u, r, buf)
234
+ _scatter(self._cell_kernel(buf, r), r, out)
235
+ if self.patch_ijk is not None and len(self.patch_ijk):
236
+ ue = self._patch_gather(u)
237
+ fe = np.matmul(self.patch_k.astype(u.dtype, copy=False), ue[:, :, None])[:, :, 0]
238
+ self._patch_scatter(fe, out)
239
+ if self.extra is not None:
240
+ out += (self.extra @ u.reshape(-1).astype(np.float64)).astype(u.dtype).reshape(out.shape)
241
+ return out
242
+
243
+ def _patch_index(self):
244
+ if getattr(self, "_pidx", None) is None:
245
+ nshape = self.node_shape[1:]
246
+ idx = np.empty((len(self.patch_ijk), 24), np.int64)
247
+ nn = int(np.prod(nshape))
248
+ for c, off in enumerate(_CORNER_OFF):
249
+ node = np.ravel_multi_index(tuple((self.patch_ijk + np.array(off)).T), nshape)
250
+ for i in range(3):
251
+ idx[:, 3 * c + i] = i * nn + node
252
+ self._pidx = idx
253
+ return self._pidx
254
+
255
+ def _patch_gather(self, u):
256
+ return u.reshape(-1)[self._patch_index()]
257
+
258
+ def _patch_scatter(self, fe, out):
259
+ flat = out.reshape(-1)
260
+ np.add.at(flat, self._patch_index().ravel(), fe.ravel())
261
+
262
+ def apply(self, u):
263
+ """The level's operator: identity on masked-out dofs."""
264
+ m = self.mask.astype(u.dtype, copy=False)
265
+ out = self.apply_k(u * m)
266
+ out *= m
267
+ out += (1 - m) * u
268
+ return out
269
+
270
+ def diagonal(self):
271
+ """Diagonal of the unmasked K."""
272
+ nx, ny, nz = self.dims
273
+ d = np.zeros(self.node_shape)
274
+ if self.kind == "scaled":
275
+ dk = np.diag(self.ke)
276
+ for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
277
+ d[:, dx:dx + nx, dy:dy + ny, dz:dz + nz] += dk[3 * c:3 * c + 3, None, None, None] * self.e[None]
278
+ elif self.kind == "weights":
279
+ dm = np.einsum("kii->ki", self.mcat.reshape(8, 24, 24).astype(np.float64))
280
+ dc = (dm.T @ self.w.reshape(8, -1).astype(np.float64)).reshape((24,) + self.dims)
281
+ for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
282
+ d[:, dx:dx + nx, dy:dy + ny, dz:dz + nz] += dc[3 * c:3 * c + 3]
283
+ else:
284
+ dc = np.einsum("iixyz->ixyz", self.k).astype(np.float64)
285
+ for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
286
+ d[:, dx:dx + nx, dy:dy + ny, dz:dz + nz] += dc[3 * c:3 * c + 3]
287
+ if self.patch_ijk is not None and len(self.patch_ijk):
288
+ flat = d.reshape(-1)
289
+ np.add.at(flat, self._patch_index().ravel(),
290
+ np.einsum("nii->ni", self.patch_k.astype(np.float64)).ravel())
291
+ if self.extra is not None:
292
+ d += self.extra.diagonal().reshape(d.shape)
293
+ return d
294
+
295
+ def cell_matrices(self, a, b):
296
+ """Explicit matrices (24, 24, m, ny, nz) of x-slab [a, b), float64."""
297
+ ny, nz = self.dims[1:]
298
+ if self.kind == "explicit":
299
+ return self.k[:, :, a:b].astype(np.float64)
300
+ if self.kind == "scaled":
301
+ return self.ke.astype(np.float64)[:, :, None, None, None] * self.e[a:b][None, None].astype(np.float64)
302
+ mk = self.mcat.reshape(8, 576).astype(np.float64)
303
+ w = self.w[:, a:b]
304
+ out = (mk.T @ w.reshape(8, -1).astype(np.float64)).reshape((24, 24) + w.shape[1:])
305
+ if self.patch_ijk is not None and len(self.patch_ijk):
306
+ sel = (self.patch_ijk[:, 0] >= a) & (self.patch_ijk[:, 0] < b)
307
+ p = self.patch_ijk[sel]
308
+ out[:, :, p[:, 0] - a, p[:, 1], p[:, 2]] += np.moveaxis(self.patch_k[sel].astype(np.float64), 0, -1)
309
+ return out
310
+
311
+
312
+ def _pad_even(a, axes_from=0, fill=0.0):
313
+ pad = [(0, 0)] * a.ndim
314
+ for i in range(3):
315
+ n = a.shape[axes_from + i]
316
+ pad[axes_from + i] = (0, n % 2)
317
+ if any(p[1] for p in pad):
318
+ return np.pad(a, pad, constant_values=fill)
319
+ return a
320
+
321
+
322
+ def _prolong_rows(fine_nodes_ijk: np.ndarray, coarse_node_shape) -> sp.csr_matrix:
323
+ """Scalar trilinear prolongation rows for the given fine nodes."""
324
+ n = len(fine_nodes_ijk)
325
+ rows, cols, vals = [], [], []
326
+ for corner in range(8):
327
+ w = np.ones(n)
328
+ ci = np.empty((n, 3), np.int64)
329
+ for d in range(3):
330
+ i = fine_nodes_ijk[:, d]
331
+ odd = i % 2 == 1
332
+ hi = (corner >> d) & 1
333
+ ci[:, d] = i // 2 + (hi & odd)
334
+ w *= np.where(odd, 0.5, 1.0 if not hi else 0.0)
335
+ ok = w > 0
336
+ rows.append(np.nonzero(ok)[0])
337
+ cols.append(np.ravel_multi_index(tuple(ci[ok].T), coarse_node_shape))
338
+ vals.append(w[ok])
339
+ nc = int(np.prod(coarse_node_shape))
340
+ return sp.csr_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n, nc))
341
+
342
+
343
+ class GridElasticity:
344
+ """Matrix-free multigrid-preconditioned CG for K(E) u = f on a HexMesh.
345
+
346
+ mesh: fem.HexMesh; nu: Poisson's ratio or fem.Layered; fixed: bool per
347
+ mesh dof; springs: sparse (n_dof, n_dof) in mesh dof numbering, added to
348
+ the operator unscaled, or None.
349
+
350
+ `set_moduli(e)` takes per-element moduli (0 removes a cell outright; a
351
+ node left with no stiffness is dropped from the solve and comes back as
352
+ zero) and rebuilds the hierarchy; `solve_many(fs, x0s)` then solves.
353
+
354
+ coarse_cells: stop coarsening geometrically once a level has fewer
355
+ active cells than this, and assemble that level. degree: Chebyshev
356
+ smoothing degree (pre and post). dtype: precision of the V-cycle.
357
+ device: "auto", "cuda", "mps", "torch" or "cpu" (see gpu.py); None reads
358
+ LOADPATH_DEVICE."""
359
+
360
+ def __init__(self, mesh, nu, fixed, springs=None, rtol=1e-6, maxiter=500, coarse_cells=2000,
361
+ degree=2, dtype=np.float32, lu_max_dofs=60_000, device=None):
362
+ self.mesh = mesh
363
+ self.nu = nu
364
+ self.rtol = rtol
365
+ self.maxiter = maxiter
366
+ self.coarse_cells = int(coarse_cells)
367
+ self.degree = int(degree)
368
+ self.dtype = np.dtype(dtype)
369
+ self.lu_max_dofs = int(lu_max_dofs)
370
+ cells = mesh.elem_ijk.max(axis=0) + 1
371
+ cells = cells + cells % 2
372
+ self.cells = tuple(int(c) for c in cells)
373
+ self.node_shape = tuple(int(c) + 1 for c in cells)
374
+ self.nn = int(np.prod(self.node_shape))
375
+ self.node_flat = np.ravel_multi_index(tuple(mesh.node_ijk.T), self.node_shape)
376
+ self.elem_ijk = mesh.elem_ijk
377
+ self.element = hex8_element()
378
+ self.ke = hex8_ke(nu, self.element) * mesh.pitch
379
+ fixed = np.asarray(fixed, bool)
380
+ fg = np.zeros((3, self.nn), bool)
381
+ for i in range(3):
382
+ fg[i, self.node_flat] = fixed[i::3]
383
+ self.fixed_grid = fg.reshape((3,) + self.node_shape)
384
+ self.springs = None
385
+ if springs is not None:
386
+ self.springs = self._to_grid_sparse(sp.csr_matrix(springs))
387
+ self.levels = []
388
+ self.strict = False
389
+ self.last_iters = 0
390
+ self._coarse = None
391
+ self._lmax_vec = {}
392
+ # torch copy of the hierarchy on a GPU (gpu.py), or None for numpy
393
+ from .gpu import resolve_device
394
+
395
+ dev = resolve_device(device)
396
+ self._dev = None
397
+ if dev is not None:
398
+ from .gpu import TorchMG
399
+
400
+ self._dev = TorchMG(self, dev)
401
+
402
+ # --- layout conversion -------------------------------------------------
403
+
404
+ def _to_grid_sparse(self, s):
405
+ g = np.empty(s.shape[0], np.int64)
406
+ for i in range(3):
407
+ g[i::3] = i * self.nn + self.node_flat
408
+ s = s.tocoo()
409
+ return sp.csr_matrix((s.data, (g[s.row], g[s.col])), shape=(3 * self.nn, 3 * self.nn))
410
+
411
+ def to_grid(self, v, dtype=np.float64):
412
+ out = np.zeros((3, self.nn), dtype)
413
+ for i in range(3):
414
+ out[i, self.node_flat] = v[i::3]
415
+ return out.reshape((3,) + self.node_shape)
416
+
417
+ def from_grid(self, g):
418
+ flat = g.reshape(3, -1)
419
+ out = np.empty(3 * len(self.node_flat))
420
+ for i in range(3):
421
+ out[i::3] = flat[i, self.node_flat]
422
+ return out
423
+
424
+ def cell_grid(self, e, dtype=np.float64):
425
+ g = np.zeros(self.cells, dtype)
426
+ g[tuple(self.elem_ijk.T)] = e
427
+ return g
428
+
429
+ # --- hierarchy ---------------------------------------------------------
430
+
431
+ def set_moduli(self, e):
432
+ """Per-element moduli (mesh order). Rebuilds every level.
433
+
434
+ Thin supports: masked prolongation (P' = D P) keeps a coarse dof whose
435
+ fine neighbours are held all but a thin skin, and a support face a
436
+ cell or two from the part's edge leaves several of them interpolating
437
+ onto the same few free fine dofs. Their columns are dependent and the
438
+ Galerkin operator is singular: an L-bracket held by its top 8 mm
439
+ factored "exactly singular" at pitch 0.9 and 1.12, and CG never
440
+ converged at 1.127, while 1.14 took 9 iterations. The strict
441
+ hierarchy (`self.strict`) holds and drops those coarse dofs and
442
+ converges on all of them in about 21, but takes 22 against 9 where
443
+ the plain one works, so it is the fallback: taken when the coarse
444
+ factor is singular or CG stalls (solve_many), and kept from then on."""
445
+ e = np.asarray(e, float)
446
+ self._e = e
447
+ dt = self.dtype
448
+ l0 = _Level("scaled", self.cells, np.float64)
449
+ l0.ke = self.ke.astype(np.float64)
450
+ l0.e = self.cell_grid(e)
451
+ l0.extra = self.springs
452
+ d0 = l0.diagonal()
453
+ alive = d0 > 1e-300
454
+ l0.mask = (alive & ~self.fixed_grid).astype(np.float64)
455
+ l0.dinv = np.where(l0.mask > 0, 1.0 / np.where(alive, d0, 1.0), 1.0)
456
+ self.alive = alive
457
+ self.levels = [l0]
458
+ # The V-cycle's copy of the fine level, in its precision.
459
+ self._l0s = _Level("scaled", self.cells, dt)
460
+ self._l0s.ke = self.ke.astype(dt)
461
+ self._l0s.e = l0.e.astype(dt)
462
+ self._l0s.extra = self.springs
463
+ self._l0s.mask = l0.mask.astype(dt)
464
+ self._l0s.dinv = l0.dinv.astype(dt)
465
+ fine = l0
466
+ n_active = int((e > 0).sum())
467
+ depth = 0
468
+ # Held dofs, carried down by injection, for the strict hierarchy.
469
+ held = alive & self.fixed_grid
470
+ while n_active > self.coarse_cells and min(fine.dims) >= 2:
471
+ coarse = self._coarsen(fine, first=(depth == 0))
472
+ depth += 1
473
+ d = coarse.diagonal()
474
+ dmax = float(d.max()) if d.size else 0.0
475
+ live = d > 1e-12 * max(dmax, 1e-300)
476
+ if self.strict:
477
+ # A coarse dof sitting on a held one is held too, and one that
478
+ # keeps less than an eighth of its interpolation on dofs the
479
+ # finer level solves for (8 inside the part) is dropped: it
480
+ # interpolates onto a sliver its neighbours cover too.
481
+ hc = np.zeros((3,) + coarse.node_shape[1:], bool)
482
+ inj = held[:, ::2, ::2, ::2]
483
+ hc[:, :inj.shape[1], :inj.shape[2], :inj.shape[3]] = inj[:, :hc.shape[1], :hc.shape[2], :hc.shape[3]]
484
+ held = hc
485
+ kept = np.zeros((3,) + coarse.node_shape[1:])
486
+ r = restrict(np.asarray(fine.mask, np.float64))
487
+ kept[:, :r.shape[1], :r.shape[2], :r.shape[3]] = r[:, :kept.shape[1], :kept.shape[2], :kept.shape[3]]
488
+ live &= ~held & (kept >= 1.0)
489
+ coarse.mask = live.astype(dt)
490
+ coarse.dinv = np.where(live, 1.0 / np.where(live, d, 1.0), 1.0).astype(dt)
491
+ n_active = self._active_cells(coarse)
492
+ self.levels.append(coarse)
493
+ fine = coarse
494
+ try:
495
+ self._build_coarse(self.levels[-1])
496
+ except RuntimeError: # "Factor is exactly singular"
497
+ if self.strict:
498
+ raise
499
+ self.strict = True
500
+ return self.set_moduli(e)
501
+ if self._dev is not None:
502
+ self._dev.load()
503
+ return
504
+ for i in range(len(self.levels)):
505
+ self._op(i).lmax = self._estimate_lmax(i)
506
+
507
+ def _active_cells(self, lv):
508
+ if lv.kind == "weights":
509
+ act = lv.w.any(axis=0)
510
+ if lv.patch_ijk is not None and len(lv.patch_ijk):
511
+ act[tuple(lv.patch_ijk.T)] = True
512
+ return int(act.sum())
513
+ return int(np.abs(lv.k).max(axis=(0, 1)).astype(bool).sum())
514
+
515
+ def _coarsen(self, fine, first):
516
+ dt = self.dtype
517
+ nx, ny, nz = fine.dims
518
+ half = (nx // 2, ny // 2, nz // 2)
519
+ # pad to even so the next level can halve it again (padding cells
520
+ # carry no stiffness, their nodes are masked out)
521
+ cdims = tuple(h + h % 2 if h > 1 else h for h in half)
522
+ mask_fine = fine.mask if first else None
523
+ if first:
524
+ # children: E of fine cell (2I + CORNERS[k]); cells touching a held
525
+ # dof go to the patch, with their masked matrices
526
+ held = (1 - fine.mask) * self.alive # free=0 & alive => held
527
+ held_node = held.any(axis=0)
528
+ touch = np.zeros(fine.dims, bool)
529
+ for (dx, dy, dz) in _CORNER_OFF:
530
+ touch |= held_node[dx:dx + nx, dy:dy + ny, dz:dz + nz]
531
+ touch &= fine.e > 0
532
+ w = np.zeros((8,) + cdims, np.float64)
533
+ hx, hy, hz = half
534
+ for k, (a, b, c) in enumerate(_CORNER_OFF):
535
+ w[k, :hx, :hy, :hz] = fine.e[a::2, b::2, c::2]
536
+ ptouch = np.zeros(cdims, bool)
537
+ for k, (a, b, c) in enumerate(_CORNER_OFF):
538
+ ptouch[:hx, :hy, :hz] |= touch[a::2, b::2, c::2]
539
+ lv = _Level("weights", cdims, dt)
540
+ mk = np.einsum("kai,ab,kbj->kij", _PK, fine.ke, _PK)
541
+ lv.mcat = mk.reshape(8 * 24, 24).astype(dt)
542
+ pij = np.argwhere(ptouch)
543
+ pk = np.zeros((len(pij), 24, 24))
544
+ fm = fine.mask
545
+ for k, (a, b, c) in enumerate(_CORNER_OFF):
546
+ fi = 2 * pij + np.array([a, b, c])
547
+ ec = fine.e[tuple(fi.T)]
548
+ dm = np.empty((len(pij), 24))
549
+ for q, (qx, qy, qz) in enumerate(_CORNER_OFF):
550
+ dm[:, 3 * q:3 * q + 3] = fm[:, fi[:, 0] + qx, fi[:, 1] + qy, fi[:, 2] + qz].T
551
+ kc = ec[:, None, None] * fine.ke[None] * dm[:, :, None] * dm[:, None, :]
552
+ pk += np.matmul(np.matmul(_PK[k].T, kc), _PK[k])
553
+ w[:, ptouch] = 0.0
554
+ lv.w = w.astype(dt)
555
+ lv.patch_ijk = pij
556
+ lv.patch_k = pk.astype(dt)
557
+ else:
558
+ lv = _Level("explicit", cdims, dt)
559
+ k = np.zeros((24, 24) + cdims, dt)
560
+ step = max(1, _slab(ny * nz * 24, np.float64) // 2) * 2
561
+ for a in range(0, nx, step):
562
+ b = min(a + step, nx)
563
+ kf = fine.cell_matrices(a, b) # (24, 24, m, ny, nz)
564
+ if self.strict and fine.mask is not None:
565
+ # the fine level's own masked operator (D K D): a dof it
566
+ # holds is not a direction the next level may use either
567
+ fm = fine.mask
568
+ dm = np.empty((24,) + kf.shape[2:])
569
+ for q, (qx, qy, qz) in enumerate(_CORNER_OFF):
570
+ dm[3 * q:3 * q + 3] = fm[:, a + qx:b + qx, qy:qy + ny, qz:qz + nz]
571
+ kf = kf * dm[:, None] * dm[None, :]
572
+ for ch, (ox, oy, oz) in enumerate(_CORNER_OFF):
573
+ sub = kf[:, :, ox::2, oy::2, oz::2]
574
+ shp = sub.shape[2:]
575
+ # P^T K P as two GEMMs
576
+ t = (_PK[ch].T @ sub.reshape(24, -1)).reshape(24, 24, -1) # (i, b, n)
577
+ t = np.tensordot(t, _PK[ch], axes=([1], [0])) # (i, n, j)
578
+ k[:, :, a // 2:a // 2 + shp[0], :shp[1], :shp[2]] += \
579
+ np.moveaxis(t, 2, 1).reshape((24, 24) + shp).astype(dt)
580
+ lv.k = k
581
+ lv.extra = self._coarse_extra(fine, cdims, mask_fine)
582
+ return lv
583
+
584
+ def _coarse_extra(self, fine, cdims, mask_fine):
585
+ s = fine.extra
586
+ if s is None or s.nnz == 0:
587
+ return None
588
+ nshape_f = fine.node_shape[1:]
589
+ nshape_c = tuple(c + 1 for c in cdims)
590
+ nn_f = int(np.prod(nshape_f))
591
+ nn_c = int(np.prod(nshape_c))
592
+ s = s.tocsr()
593
+ dofs = np.unique(np.concatenate([s.tocoo().row, s.tocoo().col]))
594
+ comp, node = np.divmod(dofs, nn_f)
595
+ ijk = np.stack(np.unravel_index(node, nshape_f), axis=1)
596
+ pr = _prolong_rows(ijk, nshape_c).tocoo()
597
+ cols = comp[pr.row] * nn_c + pr.col
598
+ vals = pr.data
599
+ if mask_fine is not None:
600
+ vals = vals * mask_fine.reshape(-1)[dofs[pr.row]]
601
+ p = sp.csr_matrix((vals, (pr.row, cols)), shape=(len(dofs), 3 * nn_c))
602
+ ss = s[dofs][:, dofs]
603
+ return (p.T @ ss @ p).tocsr()
604
+
605
+ def _build_coarse(self, lv):
606
+ """Assemble the coarsest level and factor it (or build pyamg on it)."""
607
+ from .fem import HexMesh, Stiffness
608
+
609
+ nx, ny, nz = lv.dims
610
+ if lv is self.levels[0]:
611
+ self._coarse = None
612
+ return
613
+ kall = lv.cell_matrices(0, nx) # (24, 24, nx, ny, nz)
614
+ act = np.abs(kall).max(axis=(0, 1)) > 0
615
+ ijk = np.argwhere(act)
616
+ if len(ijk) == 0:
617
+ self._coarse = None
618
+ return
619
+ kes = np.moveaxis(kall[:, :, act], -1, 0) # (n, 24, 24)
620
+ hm = HexMesh(ijk, 1.0, np.zeros(3))
621
+ st = Stiffness(hm, 0.3, np.zeros(hm.n_dof, bool))
622
+ data = np.zeros(st.nnz)
623
+ st.add_elements(data, kes, np.arange(len(ijk)))
624
+ k = sp.csr_matrix((data, st.indices, st.indptr), shape=(hm.n_dof, hm.n_dof))
625
+ nshape = lv.node_shape[1:]
626
+ nn = int(np.prod(nshape))
627
+ gnode = np.ravel_multi_index(tuple(hm.node_ijk.T), nshape)
628
+ gdof = np.empty(hm.n_dof, np.int64)
629
+ for i in range(3):
630
+ gdof[i::3] = i * nn + gnode
631
+ if lv.extra is not None:
632
+ k = (k + lv.extra[gdof][:, gdof]).tocsr()
633
+ m = lv.mask.reshape(-1)[gdof].astype(np.float64)
634
+ dm = sp.diags(m)
635
+ k = (dm @ k @ dm + sp.diags(1.0 - m)).tocsr()
636
+ k = 0.5 * (k + k.T)
637
+ self._coarse = {"gdof": gdof, "n": hm.n_dof}
638
+ if hm.n_dof <= self.lu_max_dofs:
639
+ from scipy.sparse.linalg import splu
640
+
641
+ # SPD: symmetric mode without pivoting. Partial pivoting on these
642
+ # high-contrast matrices quadrupled the fill (6 s against 0.5 s
643
+ # for 7k dofs).
644
+ self._coarse["lu"] = splu(k.tocsc(), permc_spec="MMD_AT_PLUS_A", diag_pivot_thresh=0.0,
645
+ options={"SymmetricMode": True})
646
+ else:
647
+ from .fem import amg_solver
648
+
649
+ ml = amg_solver(k, B=rigid_body_modes(hm.node_ijk.astype(float)), symmetry="hermitian",
650
+ strength="symmetric", max_coarse=400, coarse_solver="splu")
651
+ self._coarse["ml"] = ml
652
+
653
+ def _coarse_solve(self, b):
654
+ c = self._coarse
655
+ flat = b.reshape(-1)
656
+ rhs = flat[c["gdof"]].astype(np.float64)
657
+ if "lu" in c:
658
+ x = c["lu"].solve(rhs)
659
+ else:
660
+ x = c["ml"].solve(rhs, x0=np.zeros_like(rhs), tol=1e-30, maxiter=1, cycle="V")
661
+ out = np.zeros_like(flat)
662
+ out[c["gdof"]] = x
663
+ return out.reshape(b.shape)
664
+
665
+ def _op(self, i):
666
+ return self._l0s if i == 0 else self.levels[i]
667
+
668
+ def _estimate_lmax(self, i, its=12):
669
+ """Largest eigenvalue of D^-1 A at level i, by power iteration (a
670
+ Chebyshev smoother needs an upper bound; see LMAX_SAFETY)."""
671
+ lv = self._op(i)
672
+ prev = self._lmax_vec.get(i)
673
+ if prev is not None and prev.shape == lv.node_shape:
674
+ # warm start from the last hierarchy's vector. Not fewer
675
+ # iterations: cutting them to 4 underestimated the bound once
676
+ # cells dropped out of the design, and the CG count tripled.
677
+ v = prev.astype(lv.dinv.dtype) * lv.mask
678
+ else:
679
+ rng = np.random.default_rng(i)
680
+ v = (rng.random(lv.node_shape) - 0.5).astype(lv.dinv.dtype) * lv.mask
681
+ lam = 1.0
682
+ for _ in range(its):
683
+ nv = np.linalg.norm(v)
684
+ if nv == 0:
685
+ return 2.0
686
+ v /= nv
687
+ av = lv.dinv * lv.apply(v) * lv.mask
688
+ lam = float(np.vdot(v, av))
689
+ v = av
690
+ self._lmax_vec[i] = v
691
+ return LMAX_SAFETY * max(lam, 1e-12)
692
+
693
+ def _smooth(self, lv, b, x):
694
+ """Chebyshev iteration on D^-1 A over [lmax/30, lmax]."""
695
+ hi = lv.lmax
696
+ lo = hi / 30.0
697
+ theta = 0.5 * (hi + lo)
698
+ delta = 0.5 * (hi - lo)
699
+ sigma = theta / delta
700
+ rho = 1.0 / sigma
701
+ r = b - lv.apply(x) if x is not None else b.copy()
702
+ if x is None:
703
+ x = np.zeros_like(b)
704
+ d = lv.dinv * r / theta
705
+ for it in range(self.degree):
706
+ x += d
707
+ if it + 1 == self.degree:
708
+ break
709
+ r -= lv.apply(d)
710
+ rho_new = 1.0 / (2.0 * sigma - rho)
711
+ d = rho_new * rho * d + (2.0 * rho_new / delta) * (lv.dinv * r)
712
+ rho = rho_new
713
+ return x
714
+
715
+ def _vcycle(self, i, b):
716
+ lv = self._op(i)
717
+ if i == len(self.levels) - 1:
718
+ if self._coarse is not None:
719
+ return self._coarse_solve(b)
720
+ x = self._smooth(lv, b, None)
721
+ return self._smooth(lv, b, x)
722
+ x = self._smooth(lv, b, None)
723
+ r = b - lv.apply(x)
724
+ cs = self._op(i + 1).node_shape
725
+ rf = np.zeros((3,) + tuple(2 * n - 1 for n in cs[1:]), r.dtype)
726
+ sx, sy, sz = lv.node_shape[1:]
727
+ rf[:, :sx, :sy, :sz] = r * lv.mask
728
+ # the coarse level's masked dofs are identity rows: kept out of the
729
+ # residual it solves and of the correction it sends back
730
+ cm = self._op(i + 1).mask
731
+ xc = self._vcycle(i + 1, (restrict(rf) * cm).astype(self.dtype, copy=False))
732
+ x += prolong(xc * cm)[:, :sx, :sy, :sz] * lv.mask
733
+ return self._smooth(lv, b, x)
734
+
735
+ def precondition(self, r):
736
+ z = self._vcycle(0, r.astype(self.dtype))
737
+ return z.astype(np.float64)
738
+
739
+ # --- solve -------------------------------------------------------------
740
+
741
+ def apply(self, u):
742
+ """The float64 operator on a node-grid field."""
743
+ return self.levels[0].apply(u)
744
+
745
+ def _pcg(self, b, x0):
746
+ a = self.levels[0]
747
+ m = a.mask
748
+ x = np.zeros(a.node_shape) if x0 is None else x0.copy()
749
+ bnorm = float(np.linalg.norm(b))
750
+ if bnorm == 0:
751
+ return np.zeros_like(b), 0, True
752
+ r = b.copy()
753
+ if x0 is not None:
754
+ # Scale the warm start to its energy-optimal multiple, and drop
755
+ # it if even that is worse than zero: after a projection step or
756
+ # a cut of the live cells the last displacement can start 8x
757
+ # further off than zero does (35 CG iterations against 15).
758
+ ax = a.apply(x)
759
+ xax = float(np.vdot(x, ax))
760
+ s = float(np.vdot(b, x)) / xax if xax > 0 else 0.0
761
+ if s > 0:
762
+ rs = b - s * ax
763
+ if np.linalg.norm(rs) < bnorm:
764
+ x *= s
765
+ r = rs
766
+ else:
767
+ x[:] = 0
768
+ else:
769
+ x[:] = 0
770
+ z = self.precondition(r)
771
+ p = z.copy()
772
+ rz = float(np.vdot(r, z))
773
+ tol = self.rtol * bnorm
774
+ for it in range(1, self.maxiter + 1):
775
+ ap = a.apply(p)
776
+ pap = float(np.vdot(p, ap))
777
+ if pap <= 0:
778
+ return x, it, False
779
+ alpha = rz / pap
780
+ x += alpha * p
781
+ r -= alpha * ap
782
+ if float(np.linalg.norm(r)) <= tol:
783
+ return x, it, True
784
+ z = self.precondition(r)
785
+ rz_new = float(np.vdot(r, z))
786
+ p *= rz_new / rz
787
+ p += z
788
+ rz = rz_new
789
+ del m
790
+ return x, self.maxiter, False
791
+
792
+ # CG iterations the plain hierarchy gets before solve_many falls back to
793
+ # the strict one (it takes 9-40 where it works; the strict one about 21)
794
+ STALL = 100
795
+
796
+ def solve_many(self, fs, x0s=None):
797
+ """Solve for each right-hand side (mesh dof numbering). Rows of dofs
798
+ that carry no stiffness are returned as zero."""
799
+ x0s = x0s or [None] * len(fs)
800
+ out = []
801
+ iters = 0
802
+ for f, x0 in zip(fs, x0s):
803
+ b = self.to_grid(f)
804
+ b *= self.levels[0].mask
805
+ g0 = self.to_grid(x0) * self.levels[0].mask if x0 is not None else None
806
+ x, it, ok = self._solve(b, g0)
807
+ iters = max(iters, it)
808
+ if not ok:
809
+ raise RuntimeError(f"linear solve did not converge ({it} iterations); "
810
+ "the structure may be a mechanism (check supports)")
811
+ out.append(self.from_grid(x * self.levels[0].mask))
812
+ self.last_iters = iters
813
+ return out
814
+
815
+ def _solve(self, b, g0):
816
+ if self.strict or len(self.levels) < 2:
817
+ return self._dev.solve(b, g0) if self._dev is not None else self._pcg(b, g0)
818
+ full = self.maxiter
819
+ self.maxiter = min(full, self.STALL)
820
+ try:
821
+ x, it, ok = self._dev.solve(b, g0) if self._dev is not None else self._pcg(b, g0)
822
+ finally:
823
+ self.maxiter = full
824
+ if ok:
825
+ return x, it, ok
826
+ self.strict = True
827
+ self.set_moduli(self._e)
828
+ x, it2, ok = self._dev.solve(b, g0) if self._dev is not None else self._pcg(b, g0)
829
+ return x, it + it2, ok
830
+
831
+ def live_dofs(self):
832
+ """Bool per mesh dof: carries stiffness in the current moduli."""
833
+ a = self.alive.reshape(3, -1)
834
+ out = np.empty(3 * len(self.node_flat), bool)
835
+ for i in range(3):
836
+ out[i::3] = a[i, self.node_flat]
837
+ return out
838
+
839
+ def strain_energy(self, u):
840
+ """Per element u_e^T Ke u_e at unit modulus (mesh order)."""
841
+ g = self.to_grid(u)
842
+ nx, ny, nz = self.cells
843
+ se = np.zeros(self.cells)
844
+ step = _slab(ny * nz, np.float64)
845
+ ke = self.ke
846
+ for a in range(0, nx, step):
847
+ b = min(a + step, nx)
848
+ buf = np.empty((24, b - a, ny, nz))
849
+ _gather(g, (a, b, 0, ny, 0, nz), buf)
850
+ flat = buf.reshape(24, -1)
851
+ se[a:b] = np.einsum("in,in->n", flat, ke @ flat).reshape(b - a, ny, nz)
852
+ return se[tuple(self.elem_ijk.T)]
853
+