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/gpu.py ADDED
@@ -0,0 +1,521 @@
1
+ """
2
+ GPU backend for the matrix-free multigrid solve (mg.GridElasticity).
3
+
4
+ Everything the large-grid solver spends its time on is dense array work on
5
+ the voxel node grid: eight slice gathers, one GEMM with a shared 24x24
6
+ matrix, eight slice scatters, Chebyshev smoothing, trilinear transfers.
7
+ That maps one to one onto torch, which runs it on Apple GPUs (MPS) and
8
+ NVIDIA GPUs (CUDA) from the same code. Hierarchy setup stays in numpy (about
9
+ 1% of the time) and the tensors are uploaded once per `set_moduli`.
10
+
11
+ Precision follows the CPU solver: the V-cycle in float32, the outer
12
+ conjugate gradient and its operator in float64, so the answer is the same
13
+ float64 one (same CG counts, compliance equal to ~1e-9).
14
+
15
+ Apple GPUs have no float64, and consumer NVIDIA cards run it at 1/32 to
16
+ 1/64 of float32 speed. A plain float32 CG diverged on the arm link (~40
17
+ iterations where float64 needs 12): a slender part's displacement is
18
+ hundreds of times a cell's stretch, mostly rigid translation and rotation
19
+ of each cell, and K u cancels that away together with float32's digits. So
20
+ those devices run a float32-only solve (`fp32`, below): iterative
21
+ refinement whose outer residual keeps the displacement as a float32 pair
22
+ (hi + lo, ~48 bits) and applies each cell's matrix only to the cell's
23
+ deformation, with its rigid translation and mean rotation taken out first
24
+ in pair arithmetic (K annihilates rigid motion, so the product is the
25
+ same, but now it is computed from small numbers). Inner corrections are
26
+ plain float32 CG. On the arm link (114k cells) this lands within 4e-6 of
27
+ the float64 compliance and 1e-5 of the peak strain energy in 33 V-cycles
28
+ against float64's 26, with no float64 anywhere. Selection: on by default
29
+ on MPS; LOADPATH_FP32=1 forces it on any device, 0 keeps the old split
30
+ (CG in float64 on the CPU, V-cycle on the GPU).
31
+
32
+ The coarsest level stays where it is (a sparse LU or pyamg on the CPU):
33
+ it is a few thousand dofs, and copying its right-hand side over is cheaper
34
+ than a device factorization.
35
+
36
+ Selection: LOADPATH_DEVICE=auto (default: CUDA, then MPS, then torch on the
37
+ CPU, which on its own beats the numpy kernels several times over), cuda,
38
+ mps, torch (torch on the CPU), or cpu (the numpy solver). torch is an
39
+ optional dependency (extra "gpu"); without it everything runs as before.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import os
44
+
45
+ import numpy as np
46
+
47
+ from .mg import _CORNER_OFF, LMAX_SAFETY
48
+
49
+ __all__ = ["resolve_device", "TorchMG"]
50
+
51
+ # Bytes of gather buffer per chunk of x-planes (like mg._slab, larger: the
52
+ # device has the bandwidth and launch overhead dominates small chunks).
53
+ _CHUNK_BYTES = 256e6
54
+
55
+
56
+ def resolve_device(name: str | None = None):
57
+ """The torch device to run the multigrid on, or None for the numpy path."""
58
+ name = (name if name is not None else os.environ.get("LOADPATH_DEVICE", "auto")).strip().lower()
59
+ if name in ("", "cpu", "none", "off", "numpy"):
60
+ return None
61
+ try:
62
+ import torch
63
+ except ImportError:
64
+ if name in ("auto",):
65
+ return None
66
+ raise RuntimeError(f"LOADPATH_DEVICE={name} needs torch (pip install 'loadpath[gpu]')")
67
+ if name == "auto":
68
+ if torch.cuda.is_available():
69
+ return torch.device("cuda")
70
+ if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
71
+ return torch.device("mps")
72
+ return torch.device("cpu")
73
+ if name == "torch":
74
+ return torch.device("cpu")
75
+ if name == "cuda" and not torch.cuda.is_available():
76
+ raise RuntimeError("LOADPATH_DEVICE=cuda but torch sees no CUDA device")
77
+ if name == "mps" and not torch.backends.mps.is_available():
78
+ raise RuntimeError("LOADPATH_DEVICE=mps but torch sees no Metal device")
79
+ return torch.device(name)
80
+
81
+
82
+ def _prolong(c):
83
+ """mg.prolong on a tensor (..., n0, n1, n2) -> (..., 2n0-1, 2n1-1, 2n2-1)."""
84
+ import torch
85
+
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 = torch.empty(shape, dtype=out.dtype, device=out.device)
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
+ f[tuple(sl_even)] = out
97
+ f[tuple(sl_odd)] = 0.5 * (out.narrow(ax, 0, n - 1) + out.narrow(ax, 1, n - 1))
98
+ out = f
99
+ return out
100
+
101
+
102
+ def _restrict(f):
103
+ """Transpose of _prolong."""
104
+ out = f
105
+ for ax in (-3, -2, -1):
106
+ n = (out.shape[ax] + 1) // 2
107
+ sl_even = [slice(None)] * out.ndim
108
+ sl_even[ax] = slice(0, None, 2)
109
+ sl_odd = [slice(None)] * out.ndim
110
+ sl_odd[ax] = slice(1, None, 2)
111
+ c = out[tuple(sl_even)].clone()
112
+ half = 0.5 * out[tuple(sl_odd)]
113
+ c.narrow(ax, 0, n - 1).add_(half)
114
+ c.narrow(ax, 1, n - 1).add_(half)
115
+ out = c
116
+ return out
117
+
118
+
119
+ def _two_sum(a, b):
120
+ """Error-free a + b = s + e (Knuth), elementwise in the tensors' dtype."""
121
+ s = a + b
122
+ bb = s - a
123
+ return s, (a - (s - bb)) + (b - bb)
124
+
125
+
126
+ def _rigid_free(h, lo, corners):
127
+ """(24, n) gathers of a cell's corner displacements as a float32 pair
128
+ (hi h, lo lo or None) -> (24, n) float32 deformation: the corner-0
129
+ translation and the mean (infinitesimal) rotation removed, in pair
130
+ arithmetic so nothing large is rounded. K times it equals K times the
131
+ input exactly in exact arithmetic (K annihilates rigid motion)."""
132
+ n = h.shape[-1]
133
+ h = h.reshape(8, 3, n)
134
+ s, e = _two_sum(h, -h[0:1])
135
+ if lo is not None:
136
+ lo = lo.reshape(8, 3, n)
137
+ e = e + (lo - lo[0:1])
138
+ d = s + e
139
+ # mean displacement gradient over the cell (cell units), its skew part
140
+ import torch
141
+
142
+ g = 0.5 * torch.einsum("ka,kin->ian", corners["c"], d)
143
+ w = 0.5 * (g - g.transpose(0, 1))
144
+ for a in range(3):
145
+ sel = corners["sel"][a]
146
+ s2, e2 = _two_sum(s[sel], -w[:, a].unsqueeze(0))
147
+ s[sel] = s2
148
+ e[sel] += e2
149
+ return (s + e).reshape(24, n)
150
+
151
+
152
+ class _DevLevel:
153
+ """A level's operator on a torch device in `dtype`, built from an mg._Level."""
154
+
155
+ def __init__(self, lv, dev, dtype):
156
+ import torch
157
+
158
+ t = lambda a: torch.as_tensor(np.ascontiguousarray(a), dtype=dtype, device=dev)
159
+ self.kind = lv.kind
160
+ self.dims = lv.dims
161
+ self.node_shape = lv.node_shape
162
+ self.mask = t(lv.mask)
163
+ self.dinv = t(lv.dinv)
164
+ self.lmax = None
165
+ act = lv.active()
166
+ self.box = None
167
+ if act.any():
168
+ lo = [int(np.flatnonzero(act.any(axis=tuple(j for j in range(3) if j != d)))[0]) for d in range(3)]
169
+ hi = [int(np.flatnonzero(act.any(axis=tuple(j for j in range(3) if j != d)))[-1]) + 1 for d in range(3)]
170
+ self.box = (lo[0], hi[0], lo[1], hi[1], lo[2], hi[2])
171
+ if lv.kind == "scaled":
172
+ self.ke = t(lv.ke)
173
+ self.e = t(lv.e)
174
+ off = torch.as_tensor(np.array(_CORNER_OFF), device=dev)
175
+ self.corners = {"c": off.to(dtype) - 0.5, "sel": [off[:, a] > 0 for a in range(3)]}
176
+ elif lv.kind == "weights":
177
+ self.mcat = t(lv.mcat)
178
+ self.w = t(lv.w)
179
+ else:
180
+ # (x, y, z, 24, 24): one batched matvec per cell
181
+ self.k = t(np.moveaxis(lv.k, (0, 1), (-2, -1)))
182
+ self.pidx = self.pk = None
183
+ if lv.patch_ijk is not None and len(lv.patch_ijk):
184
+ self.pidx = torch.as_tensor(lv._patch_index(), device=dev)
185
+ self.pk = t(lv.patch_k)
186
+ self.extra = None
187
+ if lv.extra is not None and lv.extra.nnz:
188
+ s = lv.extra.tocoo()
189
+ self.extra = (torch.as_tensor(s.row.astype(np.int64), device=dev),
190
+ torch.as_tensor(s.col.astype(np.int64), device=dev), t(s.data))
191
+ cyz = (self.box[3] - self.box[2]) * (self.box[5] - self.box[4]) if self.box else 1
192
+ per_plane = 24 * cyz * 4 * (8 if lv.kind == "weights" else 2)
193
+ self.chunk = max(1, int(_CHUNK_BYTES // per_plane))
194
+
195
+ def _kernel(self, flat, r):
196
+ x0, x1, y0, y1, z0, z1 = r
197
+ shp = (x1 - x0, y1 - y0, z1 - z0)
198
+ if self.kind == "scaled":
199
+ f = (self.ke @ flat).reshape((24,) + shp)
200
+ return f.mul_(self.e[x0:x1, y0:y1, z0:z1])
201
+ if self.kind == "weights":
202
+ g = (self.mcat @ flat).reshape((8, 24) + shp)
203
+ w = self.w[:, x0:x1, y0:y1, z0:z1]
204
+ return (g * w[:, None]).sum(0)
205
+ kk = self.k[x0:x1, y0:y1, z0:z1].reshape(-1, 24, 24)
206
+ f = (kk @ flat.T.unsqueeze(-1)).squeeze(-1) # (n, 24)
207
+ return f.T.reshape((24,) + shp)
208
+
209
+ def apply_k(self, u):
210
+ import torch
211
+
212
+ out = torch.zeros(self.node_shape, dtype=u.dtype, device=u.device)
213
+ if self.box is not None:
214
+ bx0, bx1, y0, y1, z0, z1 = self.box
215
+ for x0 in range(bx0, bx1, self.chunk):
216
+ x1 = min(x0 + self.chunk, bx1)
217
+ r = (x0, x1, y0, y1, z0, z1)
218
+ buf = torch.cat([u[:, x0 + dx:x1 + dx, y0 + dy:y1 + dy, z0 + dz:z1 + dz]
219
+ for (dx, dy, dz) in _CORNER_OFF], 0)
220
+ f = self._kernel(buf.reshape(24, -1), r)
221
+ for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
222
+ out[:, x0 + dx:x1 + dx, y0 + dy:y1 + dy, z0 + dz:z1 + dz] += f[3 * c:3 * c + 3]
223
+ flat_out = out.view(-1)
224
+ if self.pidx is not None:
225
+ ue = u.reshape(-1)[self.pidx] # (n, 24)
226
+ fe = (self.pk @ ue.unsqueeze(-1)).squeeze(-1)
227
+ flat_out.index_add_(0, self.pidx.reshape(-1), fe.reshape(-1))
228
+ if self.extra is not None:
229
+ rows, cols, vals = self.extra
230
+ flat_out.index_add_(0, rows, vals * u.reshape(-1)[cols])
231
+ return out
232
+
233
+ def apply_rf(self, u, lo=None):
234
+ """apply() on the pair u + lo for the fine ("scaled") level, each
235
+ cell's matrix applied to its rigid-motion-free deformation (see
236
+ _rigid_free): float32 only, accurate on slender parts."""
237
+ import torch
238
+
239
+ m = self.mask
240
+ uh = u * m
241
+ ul = lo * m if lo is not None else None
242
+ out = torch.zeros(self.node_shape, dtype=u.dtype, device=u.device)
243
+ if self.box is not None:
244
+ bx0, bx1, y0, y1, z0, z1 = self.box
245
+ for x0 in range(bx0, bx1, self.chunk):
246
+ x1 = min(x0 + self.chunk, bx1)
247
+ gat = lambda v: torch.cat([v[:, x0 + dx:x1 + dx, y0 + dy:y1 + dy, z0 + dz:z1 + dz]
248
+ for (dx, dy, dz) in _CORNER_OFF], 0).reshape(24, -1)
249
+ d = _rigid_free(gat(uh), gat(ul) if ul is not None else None, self.corners)
250
+ f = self._kernel(d, (x0, x1, y0, y1, z0, z1))
251
+ for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
252
+ out[:, x0 + dx:x1 + dx, y0 + dy:y1 + dy, z0 + dz:z1 + dz] += f[3 * c:3 * c + 3]
253
+ if self.extra is not None:
254
+ rows, cols, vals = self.extra
255
+ out.view(-1).index_add_(0, rows, vals * uh.reshape(-1)[cols])
256
+ out.mul_(m)
257
+ out.add_((1 - m) * u)
258
+ return out
259
+
260
+ def apply(self, u):
261
+ m = self.mask
262
+ out = self.apply_k(u * m)
263
+ out.mul_(m)
264
+ out.add_((1 - m) * u)
265
+ return out
266
+
267
+
268
+ def _pair_arithmetic_ok(dev) -> bool:
269
+ """True if the device rounds float32 adds to nearest without
270
+ reassociating them (fast-math compilers can), which _two_sum needs."""
271
+ import torch
272
+
273
+ a = torch.tensor([1.0, 3.0e7, -2.0], dtype=torch.float32, device=dev)
274
+ b = torch.tensor([1.0e-8, 1.0, 1.0e-7], dtype=torch.float32, device=dev)
275
+ s, e = _two_sum(a, b)
276
+ exact = (s.double().cpu() + e.double().cpu()).numpy()
277
+ return bool(np.allclose(exact, a.double().cpu().numpy() + b.double().cpu().numpy(), rtol=0, atol=1e-15))
278
+
279
+
280
+ class TorchMG:
281
+ """Device copy of a GridElasticity hierarchy, and the mixed-precision
282
+ solve on it. `load()` after every set_moduli; `solve(b, x0)` on
283
+ float64 numpy node grids returns (x, device iterations, converged)."""
284
+
285
+ def __init__(self, grid, device):
286
+ import torch
287
+
288
+ self.torch = torch
289
+ self.g = grid
290
+ self.dev = device
291
+ self.dtype = torch.float32
292
+ self.levels = []
293
+ self._lmax_vec = {}
294
+ # The outer CG and its operator need float64: in float32 a slender
295
+ # part's K u cancels away its digits (displacements are hundreds of
296
+ # times a cell's stretch) and CG diverged after ~40 iterations on
297
+ # the arm link where float64 takes 12. Apple GPUs have no float64,
298
+ # so there the CG runs on the CPU (torch, unified memory: the copies
299
+ # are cheap) and only the V-cycle, ~85% of the work, on the GPU.
300
+ fp32 = os.environ.get("LOADPATH_FP32", "").strip().lower()
301
+ self.fp32 = device.type == "mps" if fp32 in ("", "auto") else fp32 in ("1", "true", "on", "yes")
302
+ if self.fp32 and not _pair_arithmetic_ok(device):
303
+ self.fp32 = False
304
+ self.cg_dev = torch.device("cpu") if device.type == "mps" else device
305
+ self.a64 = None
306
+
307
+ def load(self):
308
+ g = self.g
309
+ self.levels = [_DevLevel(g._l0s, self.dev, self.dtype)] + \
310
+ [_DevLevel(lv, self.dev, self.dtype) for lv in g.levels[1:]]
311
+ self.a64 = None if self.fp32 else _DevLevel(g.levels[0], self.cg_dev, self.torch.float64)
312
+ for i, lv in enumerate(self.levels):
313
+ lv.lmax = self._estimate_lmax(i)
314
+ # keep the CPU levels' bound in step (diagnostics, CPU fallback)
315
+ g._op(i).lmax = lv.lmax
316
+
317
+ def _estimate_lmax(self, i, its=12):
318
+ torch = self.torch
319
+ lv = self.levels[i]
320
+ prev = self._lmax_vec.get(i)
321
+ if prev is not None and tuple(prev.shape) == tuple(lv.node_shape):
322
+ v = prev * lv.mask
323
+ else:
324
+ rng = np.random.default_rng(i)
325
+ v = torch.as_tensor(rng.random(lv.node_shape) - 0.5, dtype=self.dtype, device=self.dev) * lv.mask
326
+ lam = 1.0
327
+ for _ in range(its):
328
+ nv = float(torch.linalg.vector_norm(v))
329
+ if nv == 0:
330
+ return 2.0
331
+ v = v / nv
332
+ av = lv.dinv * lv.apply(v) * lv.mask
333
+ lam = float(torch.dot(v.reshape(-1), av.reshape(-1)))
334
+ v = av
335
+ self._lmax_vec[i] = v
336
+ return LMAX_SAFETY * max(lam, 1e-12)
337
+
338
+ def _smooth(self, lv, b, x):
339
+ hi = lv.lmax
340
+ lo = hi / 30.0
341
+ theta = 0.5 * (hi + lo)
342
+ delta = 0.5 * (hi - lo)
343
+ sigma = theta / delta
344
+ rho = 1.0 / sigma
345
+ if x is None:
346
+ r = b.clone()
347
+ x = self.torch.zeros_like(b)
348
+ else:
349
+ r = b - lv.apply(x)
350
+ d = lv.dinv * r / theta
351
+ for it in range(self.g.degree):
352
+ x += d
353
+ if it + 1 == self.g.degree:
354
+ break
355
+ r -= lv.apply(d)
356
+ rho_new = 1.0 / (2.0 * sigma - rho)
357
+ d = rho_new * rho * d + (2.0 * rho_new / delta) * (lv.dinv * r)
358
+ rho = rho_new
359
+ return x
360
+
361
+ def _vcycle(self, i, b):
362
+ torch = self.torch
363
+ g = self.g
364
+ lv = self.levels[i]
365
+ if i == len(self.levels) - 1:
366
+ if g._coarse is not None:
367
+ xc = g._coarse_solve(b.cpu().numpy())
368
+ return torch.as_tensor(xc, dtype=self.dtype, device=self.dev)
369
+ x = self._smooth(lv, b, None)
370
+ return self._smooth(lv, b, x)
371
+ x = self._smooth(lv, b, None)
372
+ r = b - lv.apply(x)
373
+ cs = self.levels[i + 1].node_shape
374
+ rf = torch.zeros((3,) + tuple(2 * n - 1 for n in cs[1:]), dtype=r.dtype, device=self.dev)
375
+ sx, sy, sz = lv.node_shape[1:]
376
+ rf[:, :sx, :sy, :sz] = r * lv.mask
377
+ cm = self.levels[i + 1].mask
378
+ xc = self._vcycle(i + 1, _restrict(rf) * cm)
379
+ x += _prolong(xc * cm)[:, :sx, :sy, :sz] * lv.mask
380
+ return self._smooth(lv, b, x)
381
+
382
+ def _warm(self, A, b, x, norm, dot):
383
+ """GridElasticity._pcg's warm start: the energy-optimal multiple of
384
+ x0, or zero if even that is worse. Returns (x, r)."""
385
+ ax = A.apply(x)
386
+ xax = dot(x, ax)
387
+ s = dot(b, x) / xax if xax > 0 else 0.0
388
+ if s > 0:
389
+ rs = b - s * ax
390
+ if norm(rs) < norm(b):
391
+ return x * s, rs
392
+ return x.zero_(), b.clone()
393
+
394
+ def solve(self, b, x0):
395
+ if self.fp32:
396
+ return self.solve_fp32(b, x0)
397
+ return self.solve_mixed(b, x0)
398
+
399
+ def _pcg32(self, r, tol, maxiter):
400
+ """Plain float32 PCG on the fine level (rigid-free operator) for a
401
+ correction: K d = r to tol relative. Returns (d, iterations)."""
402
+ torch = self.torch
403
+ a = self.levels[0]
404
+ dot = lambda p, q: float(torch.dot(p.reshape(-1), q.reshape(-1)))
405
+ rnorm = float(torch.linalg.vector_norm(r))
406
+ d = torch.zeros_like(r)
407
+ if rnorm == 0:
408
+ return d, 0
409
+ r = r.clone()
410
+ z = self._vcycle(0, r)
411
+ p = z.clone()
412
+ rz = dot(r, z)
413
+ for it in range(1, maxiter + 1):
414
+ ap = a.apply_rf(p)
415
+ pap = dot(p, ap)
416
+ if pap <= 0:
417
+ return d, it
418
+ alpha = rz / pap
419
+ d.add_(p, alpha=alpha)
420
+ r.sub_(ap, alpha=alpha)
421
+ if float(torch.linalg.vector_norm(r)) <= tol * rnorm:
422
+ return d, it
423
+ z = self._vcycle(0, r)
424
+ rz_new = dot(r, z)
425
+ p.mul_(rz_new / rz).add_(z)
426
+ rz = rz_new
427
+ return d, maxiter
428
+
429
+ def solve_fp32(self, b, x0, inner_rtol=3e-3, inner_max=50, outer_max=40):
430
+ """float32-only solve for devices without (fast) float64: iterative
431
+ refinement on the pair x = hi + lo. Each outer step forms the
432
+ residual with the rigid-free operator from the pair, solves the
433
+ correction with float32 PCG to 3e-3, and adds it in pair
434
+ arithmetic. Converged when the correction's energy d.r is under
435
+ rtol^2 of the solution's x.b: the residual's 2-norm floors at
436
+ float32 assembly noise on slender parts (internal forces dwarf the
437
+ load), noise that does not move the solution. Returns (x float64
438
+ numpy, V-cycles, converged)."""
439
+ torch = self.torch
440
+ g = self.g
441
+ a = self.levels[0]
442
+ b = np.asarray(b, np.float64)
443
+ if not np.any(b):
444
+ return np.zeros_like(b), 0, True
445
+ bh = torch.as_tensor(b.astype(np.float32), device=self.dev)
446
+ bl = torch.as_tensor((b - b.astype(np.float32)).astype(np.float32), device=self.dev)
447
+ dot = lambda p, q: float(torch.dot(p.reshape(-1), q.reshape(-1)))
448
+ xh = torch.zeros_like(bh)
449
+ xl = torch.zeros_like(bh)
450
+ if x0 is not None and np.any(x0):
451
+ # GridElasticity._pcg's warm start: the energy-optimal multiple
452
+ # of x0, or zero if even that is worse
453
+ hi = x0.astype(np.float32)
454
+ th = torch.as_tensor(hi, device=self.dev)
455
+ tl = torch.as_tensor((x0 - hi).astype(np.float32), device=self.dev)
456
+ ax = a.apply_rf(th, tl)
457
+ xax = dot(th, ax) + dot(tl, ax)
458
+ s = (dot(bh, th) + dot(bh, tl)) / xax if xax > 0 else 0.0
459
+ if s > 0 and float(torch.linalg.vector_norm(bh - s * ax)) < float(torch.linalg.vector_norm(bh)):
460
+ x = x0 * s
461
+ hi = x.astype(np.float32)
462
+ xh = torch.as_tensor(hi, device=self.dev)
463
+ xl = torch.as_tensor((x - hi).astype(np.float32), device=self.dev)
464
+ tol2 = g.rtol ** 2
465
+ total = 0
466
+ for _ in range(outer_max):
467
+ r = (bh - a.apply_rf(xh, xl)) + bl
468
+ d, it = self._pcg32(r, inner_rtol, inner_max)
469
+ total += it
470
+ xh, e = _two_sum(xh, d)
471
+ xl = xl + e
472
+ xh, xl = _two_sum(xh, xl)
473
+ de = abs(dot(d, r))
474
+ xe = dot(xh, bh)
475
+ if xe <= 0 or not np.isfinite(de):
476
+ break
477
+ if de <= tol2 * xe:
478
+ x = xh.cpu().numpy().astype(np.float64) + xl.cpu().numpy()
479
+ return x, total, True
480
+ x = xh.cpu().numpy().astype(np.float64) + xl.cpu().numpy()
481
+ return x, total, False
482
+
483
+ def solve_mixed(self, b, x0):
484
+ """GridElasticity._pcg with torch: float64 CG on `cg_dev`, float32
485
+ V-cycle on `dev`. float64 b, x0 (node grids, masked) -> (x,
486
+ iterations, converged)."""
487
+ torch = self.torch
488
+ g = self.g
489
+ f64 = torch.float64
490
+ dot = lambda p, q: float(torch.dot(p.reshape(-1), q.reshape(-1)))
491
+ norm = lambda v: float(torch.linalg.vector_norm(v))
492
+ A = self.a64
493
+ cdev = self.cg_dev
494
+ bd = torch.as_tensor(b, dtype=f64, device=cdev).clone()
495
+ bnorm = norm(bd)
496
+ if bnorm == 0:
497
+ return np.zeros_like(b), 0, True
498
+ if x0 is not None:
499
+ x, r = self._warm(A, bd, torch.as_tensor(x0, dtype=f64, device=cdev).clone(), norm, dot)
500
+ else:
501
+ x, r = torch.zeros_like(bd), bd.clone()
502
+ pre = lambda r: self._vcycle(0, r.to(self.dev, self.dtype)).to(cdev, f64)
503
+ z = pre(r)
504
+ p = z.clone()
505
+ rz = dot(r, z)
506
+ tol = g.rtol * bnorm
507
+ for it in range(1, g.maxiter + 1):
508
+ ap = A.apply(p)
509
+ pap = dot(p, ap)
510
+ if pap <= 0:
511
+ return x.cpu().numpy(), it, False
512
+ alpha = rz / pap
513
+ x.add_(p, alpha=alpha)
514
+ r.sub_(ap, alpha=alpha)
515
+ if norm(r) <= tol:
516
+ return x.cpu().numpy(), it, True
517
+ z = pre(r)
518
+ rz_new = dot(r, z)
519
+ p.mul_(rz_new / rz).add_(z)
520
+ rz = rz_new
521
+ return x.cpu().numpy(), g.maxiter, False