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/am.py ADDED
@@ -0,0 +1,316 @@
1
+ """
2
+ Overhang control for additive manufacturing: Langelaar's AM filter
3
+ (Langelaar 2017, "An additive manufacturing filter for topology
4
+ optimization of print-ready designs", SMO 55).
5
+
6
+ The part is printed layer by layer along a build direction. A cell can only
7
+ be printed if the layer below supports it, so its "printed" density is the
8
+ smaller of its own density and the strongest support beneath it:
9
+
10
+ printed_i = smin(x_i, smax(printed_j for j in S_i))
11
+
12
+ where S_i is the cell directly below and its four neighbors in that layer.
13
+ That stencil allows overhangs up to 45 degrees, the usual self-supporting
14
+ limit for FDM and for metal powder-bed printing. The first layer sits on the
15
+ build plate. Running the optimizer on printed densities means material that
16
+ would need support simply earns nothing, so the design grows its own
17
+ supports: Y-shapes instead of flat ceilings.
18
+
19
+ smax/smin are smooth so gradients exist; the backward pass carries
20
+ sensitivities down through the layers (each layer's support depends on
21
+ every layer under it).
22
+
23
+ Any build direction works, not only the six grid axes. For a tilted one
24
+ (`TiltedAMFilter`) the support stencil is every neighbor among the 26 that
25
+ lies within 45 degrees of straight down, and the "layers" are bands of the
26
+ height along the build direction narrow enough that a cell's supports are
27
+ always in an earlier band. Along a grid axis that stencil is exactly
28
+ Langelaar's five cells.
29
+
30
+ The module also carries what a layered process does to the material
31
+ (`layered_elasticity`): FDM parts are slightly softer and much weaker across
32
+ layers than along them. Verification always solves with the layered
33
+ stiffness and checks the bond. The optimizer can design with it too
34
+ (process.anisotropic: true), or with the across-layer terms cut to the
35
+ bond's strength ratio ("bond"), a surrogate meant to stop compliance
36
+ minimization leaning on material loaded across its layers. Measured on the
37
+ motor mount and the printed hook at fixed volume fraction (2026-10-29),
38
+ neither beat the isotropic design reliably: compliance cannot tell tension
39
+ across layers (which breaks bonds) from compression (which doesn't), and
40
+ the layout shift moved the von Mises peak more than it saved the bond. So
41
+ the default designs isotropic; the bond enters through the safety-factor
42
+ search (verified margins include it) and the choice of build direction
43
+ (orient.py).
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import numpy as np
48
+
49
+ __all__ = ["AMFilter", "TiltedAMFilter", "parse_build_direction", "build_vector", "layered_elasticity",
50
+ "direction_label"]
51
+
52
+ _P = 40.0
53
+ _XI0 = 0.5
54
+ _EPS = 1e-4
55
+ _N_SUPPORT = 5
56
+ _Q = _P + np.log(_N_SUPPORT) / np.log(_XI0)
57
+
58
+
59
+ def parse_build_direction(spec):
60
+ """'+z' -> (axis 2, +1). Layers stack along the direction; the build
61
+ plate is at the far end opposite it. A vector [x, y, z] gives a tilted
62
+ build: returned as a unit 3-tuple, or as (axis, sign) when it lies on a
63
+ grid axis."""
64
+ if spec is None:
65
+ return None
66
+ if isinstance(spec, (list, tuple, np.ndarray)) and len(spec) == 3:
67
+ v = np.asarray(spec, float)
68
+ nrm = np.linalg.norm(v)
69
+ if not np.isfinite(nrm) or nrm == 0:
70
+ raise ValueError("printDirection vector must be nonzero")
71
+ v = v / nrm
72
+ ax = int(np.argmax(np.abs(v)))
73
+ if abs(v[ax]) > 1 - 1e-9:
74
+ return ax, (1 if v[ax] > 0 else -1)
75
+ return tuple(float(c) for c in v)
76
+ if isinstance(spec, tuple) and len(spec) == 2:
77
+ return int(spec[0]), int(spec[1])
78
+ s = str(spec).strip().lower()
79
+ if len(s) == 1:
80
+ s = "+" + s
81
+ if len(s) != 2 or s[0] not in "+-" or s[1] not in "xyz":
82
+ raise ValueError("printDirection must be one of +x, -x, +y, -y, +z, -z (the direction layers stack), "
83
+ "or a vector [x, y, z]")
84
+ return "xyz".index(s[1]), (1 if s[0] == "+" else -1)
85
+
86
+
87
+ def build_vector(direction) -> np.ndarray:
88
+ """Unit vector of a build direction in either form."""
89
+ if len(direction) == 2:
90
+ v = np.zeros(3)
91
+ v[int(direction[0])] = float(direction[1])
92
+ return v
93
+ v = np.asarray(direction, float)
94
+ return v / np.linalg.norm(v)
95
+
96
+
97
+ def direction_label(direction) -> str:
98
+ if direction is None:
99
+ return "none"
100
+ if len(direction) == 2:
101
+ return ("+" if direction[1] > 0 else "-") + "xyz"[direction[0]]
102
+ return "[" + ", ".join(f"{c:.3f}" for c in direction) + "]"
103
+
104
+
105
+ # Layered stiffness, as fractions of the in-plane values. Printed at full
106
+ # density FDM loses little stiffness across layers (published tensile
107
+ # moduli across layers sit at 80-95% of in-plane for PLA/PETG/ABS) but the
108
+ # bond carries 50-70% of the strength, and less in shear.
109
+ _LAYER_STIFFNESS = {"fdm": (0.85, 0.75), "sls": (0.95, 0.9), "mjf": (0.97, 0.95), "lpbf": (0.97, 0.95)}
110
+
111
+
112
+ def layered_elasticity(nu: float, process, design: bool = False):
113
+ """The elasticity a solver should use for a material made by `process`
114
+ (spec.Process): a plain Poisson's ratio when the process has no layers
115
+ worth modeling, else a fem.Layered about the build direction.
116
+
117
+ design=True is the "bond" surrogate: across-layer modulus and shear
118
+ capped at the bond's strength ratio (see the module note for how it
119
+ fared)."""
120
+ if process is None or process.build is None or process.build == "auto":
121
+ return nu
122
+ ez, gz = _LAYER_STIFFNESS.get(process.kind, (1.0, 1.0))
123
+ ez = getattr(process, "ez", None) or ez
124
+ gz = getattr(process, "gz", None) or gz
125
+ if design:
126
+ ez = min(ez, process.interlayer)
127
+ gz = min(gz, process.interlayer)
128
+ if ez >= 1.0 and gz >= 1.0:
129
+ return nu
130
+ from .fem import Layered
131
+ return Layered(nu, ez, gz, build_vector(process.build))
132
+
133
+
134
+ def _smin(x, xi):
135
+ d = x - xi
136
+ r = np.sqrt(d * d + _EPS)
137
+ val = 0.5 * (x + xi - r + np.sqrt(_EPS))
138
+ dx = 0.5 * (1.0 - d / r)
139
+ dxi = 0.5 * (1.0 + d / r)
140
+ return val, dx, dxi
141
+
142
+
143
+ def _neighbors(layer):
144
+ """The 5 support cells under each cell, as shifted copies of the layer
145
+ below (outside the grid counts as empty)."""
146
+ out = [layer]
147
+ for ax in (0, 1):
148
+ for sh in (1, -1):
149
+ s = np.roll(layer, sh, axis=ax)
150
+ idx = [slice(None), slice(None)]
151
+ idx[ax] = slice(0, 1) if sh == 1 else slice(-1, None)
152
+ s[tuple(idx)] = 0.0
153
+ out.append(s)
154
+ return out
155
+
156
+
157
+ def _unshift(grads):
158
+ """Adjoint of _neighbors: route each shifted copy's gradient back to the
159
+ cell it came from."""
160
+ total = grads[0].copy()
161
+ k = 1
162
+ for ax in (0, 1):
163
+ for sh in (1, -1):
164
+ g = grads[k].copy()
165
+ idx = [slice(None), slice(None)]
166
+ idx[ax] = slice(0, 1) if sh == 1 else slice(-1, None)
167
+ g[tuple(idx)] = 0.0
168
+ total += np.roll(g, -sh, axis=ax)
169
+ k += 1
170
+ return total
171
+
172
+
173
+ class AMFilter:
174
+ def __new__(cls, dims, direction, pinned=None):
175
+ if cls is AMFilter and len(direction) == 3:
176
+ return TiltedAMFilter(dims, direction, pinned)
177
+ return super().__new__(cls)
178
+
179
+ def __init__(self, dims, direction: tuple[int, int], pinned: np.ndarray | None = None):
180
+ self.dims = tuple(dims)
181
+ self.axis, self.sign = direction
182
+ # Pinned cells (preserved geometry) print as solid regardless: they
183
+ # are the user's, and they support what is above them.
184
+ self.pinned = None if pinned is None else self._to_layers(pinned.astype(float))
185
+ self._cache = None
186
+
187
+ def _to_layers(self, g):
188
+ """Move the build axis first, ordered from the build plate up."""
189
+ g = np.moveaxis(g, self.axis, 0)
190
+ return g if self.sign > 0 else g[::-1]
191
+
192
+ def _from_layers(self, g):
193
+ g = g if self.sign > 0 else g[::-1]
194
+ return np.moveaxis(g, 0, self.axis)
195
+
196
+ def forward(self, field: np.ndarray) -> np.ndarray:
197
+ x = self._to_layers(np.asarray(field, float))
198
+ n = x.shape[0]
199
+ printed = np.empty_like(x)
200
+ cache = []
201
+ for k in range(n):
202
+ if k == 0:
203
+ xi = np.ones_like(x[0])
204
+ parts, ssum = None, None
205
+ else:
206
+ parts = _neighbors(printed[k - 1])
207
+ ssum = sum(np.maximum(p, 0.0) ** _P for p in parts)
208
+ xi = np.maximum(ssum, 1e-300) ** (1.0 / _Q)
209
+ val, dx, dxi = _smin(x[k], xi)
210
+ if self.pinned is not None:
211
+ pin = self.pinned[k] > 0.5
212
+ val = np.where(pin, 1.0, val)
213
+ dx = np.where(pin, 0.0, dx)
214
+ dxi = np.where(pin, 0.0, dxi)
215
+ printed[k] = val
216
+ cache.append((parts, ssum, dx, dxi))
217
+ self._cache = cache
218
+ return self._from_layers(printed)
219
+
220
+ def backward(self, grad: np.ndarray) -> np.ndarray:
221
+ """Gradient wrt the input field of the last forward(), given the
222
+ gradient wrt its output."""
223
+ g = self._to_layers(np.asarray(grad, float)).copy()
224
+ n = g.shape[0]
225
+ out = np.zeros_like(g)
226
+ carry = np.zeros_like(g[0])
227
+ for k in range(n - 1, -1, -1):
228
+ parts, ssum, dx, dxi = self._cache[k]
229
+ total = g[k] + carry
230
+ out[k] = total * dx
231
+ carry = np.zeros_like(g[0])
232
+ if k > 0:
233
+ # d xi / d support_j = (P/Q) s_j^(P-1) * ssum^(1/Q - 1)
234
+ common = total * dxi * (_P / _Q) * np.maximum(ssum, 1e-300) ** (1.0 / _Q - 1.0)
235
+ grads = [common * np.maximum(p, 0.0) ** (_P - 1.0) for p in parts]
236
+ carry = _unshift(grads)
237
+ return self._from_layers(out)
238
+
239
+
240
+ class TiltedAMFilter:
241
+ """The AM filter for a build direction off the grid axes (see the module
242
+ note). The build plate is the plane through the lowest cell of the grid
243
+ along the direction unless `plate` (a bool grid) says which cells sit on
244
+ it: a support-free tilted print has to grow from wherever it touches."""
245
+
246
+ def __init__(self, dims, direction, pinned: np.ndarray | None = None, plate: np.ndarray | None = None):
247
+ self.dims = tuple(int(d) for d in dims)
248
+ up = build_vector(direction)
249
+ offs = np.array([[i, j, k] for i in (-1, 0, 1) for j in (-1, 0, 1) for k in (-1, 0, 1)
250
+ if (i, j, k) != (0, 0, 0)], float)
251
+ proj = offs @ up
252
+ cosang = -proj / np.linalg.norm(offs, axis=1)
253
+ sel = cosang >= np.cos(np.radians(45.0)) - 1e-9
254
+ self.offsets = offs[sel].astype(np.int64)
255
+ n_sup = len(self.offsets)
256
+ self._q = _P + np.log(n_sup) / np.log(_XI0)
257
+ delta = float((-proj[sel]).min())
258
+ ijk = np.indices(self.dims).reshape(3, -1).T
259
+ h = ijk @ up
260
+ level = np.floor((h - h.min()) / delta + 1e-9).astype(np.int64)
261
+ n = len(ijk)
262
+ self._nbr = []
263
+ for o in self.offsets:
264
+ q = ijk + o
265
+ ok = np.all((q >= 0) & (q < np.array(self.dims)), axis=1)
266
+ flat = np.full(n, -1, np.int64)
267
+ flat[ok] = np.ravel_multi_index(q[ok].T, self.dims)
268
+ self._nbr.append(flat)
269
+ order = np.argsort(level, kind="stable")
270
+ bounds = np.searchsorted(level[order], np.arange(level.max() + 2))
271
+ self._groups = [order[bounds[k]:bounds[k + 1]] for k in range(len(bounds) - 1)
272
+ if bounds[k + 1] > bounds[k]]
273
+ if plate is None:
274
+ plate = level == 0
275
+ else:
276
+ plate = np.asarray(plate, bool).ravel()
277
+ self._plate = plate
278
+ self._pinned = None if pinned is None else np.asarray(pinned, bool).ravel()
279
+ self._cache = None
280
+
281
+ def forward(self, field: np.ndarray) -> np.ndarray:
282
+ x = np.asarray(field, float).ravel()
283
+ printed = np.zeros_like(x)
284
+ padded = np.zeros(len(x) + 1) # slot -1 reads as empty
285
+ cache = []
286
+ for idx in self._groups:
287
+ nb = [f[idx] for f in self._nbr]
288
+ parts = [padded[b] for b in nb]
289
+ ssum = sum(np.maximum(p, 0.0) ** _P for p in parts)
290
+ xi = np.maximum(ssum, 1e-300) ** (1.0 / self._q)
291
+ on_plate = self._plate[idx]
292
+ xi = np.where(on_plate, 1.0, xi)
293
+ val, dx, dxi = _smin(x[idx], xi)
294
+ dxi = np.where(on_plate, 0.0, dxi)
295
+ if self._pinned is not None:
296
+ pin = self._pinned[idx]
297
+ val = np.where(pin, 1.0, val)
298
+ dx = np.where(pin, 0.0, dx)
299
+ dxi = np.where(pin, 0.0, dxi)
300
+ printed[idx] = val
301
+ padded[idx] = val
302
+ cache.append((nb, parts, ssum, dx, dxi))
303
+ self._cache = cache
304
+ return printed.reshape(self.dims)
305
+
306
+ def backward(self, grad: np.ndarray) -> np.ndarray:
307
+ gp = np.zeros(int(np.prod(self.dims)) + 1)
308
+ gp[:-1] = np.asarray(grad, float).ravel()
309
+ out = np.zeros(len(gp) - 1)
310
+ for idx, (nb, parts, ssum, dx, dxi) in zip(reversed(self._groups), reversed(self._cache)):
311
+ total = gp[idx]
312
+ out[idx] = total * dx
313
+ common = total * dxi * (_P / self._q) * np.maximum(ssum, 1e-300) ** (1.0 / self._q - 1.0)
314
+ for b, p in zip(nb, parts):
315
+ gp[b] += common * np.maximum(p, 0.0) ** (_P - 1.0) # one offset: distinct targets
316
+ return out.reshape(self.dims)
loadpath/amgfast.py ADDED
@@ -0,0 +1,326 @@
1
+ """pyamg's Gauss-Seidel smoother without the GIL.
2
+
3
+ fem.Solver.solve_many runs a study's load cases on threads, one CG each,
4
+ sharing one AMG hierarchy: the sparse matrix products release the GIL,
5
+ but pyamg's compiled Gauss-Seidel (amg_core.gauss_seidel,
6
+ bsr_gauss_seidel and block_gauss_seidel, the V-cycle's smoothers and most
7
+ of a solve's time) holds it, so the cases' smoothing ran one at a time:
8
+ on the GRIP upper arm three cases took about as long as three solves.
9
+
10
+ These are the same loops (pyamg 5.x amg_core/relaxation.h: the same
11
+ operations in the same order, so the same bits) as numba kernels that
12
+ release the GIL. `install()` swaps them in for pyamg's three functions;
13
+ everything else in amg_core stays pyamg's. LOADPATH_AMGFAST=0 leaves
14
+ pyamg alone.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+
20
+ import numpy as np
21
+
22
+ try:
23
+ import numba as nb
24
+
25
+ from . import meshops as _meshops
26
+ except ImportError: # pragma: no cover - numba is a dependency
27
+ nb = None
28
+
29
+ _INSTALLED = False
30
+
31
+ if nb is not None:
32
+ # Indices go through uint64: numba wraps negative signed indices, a
33
+ # compare and conditional move on every load that made the kernels half
34
+ # pyamg's speed. The wrappers hand over the index arrays viewed unsigned.
35
+ U = np.uint64
36
+
37
+ @nb.njit(cache=True, nogil=True)
38
+ def _gs(Ap, Aj, Ax, x, b, row_start, row_stop, row_step):
39
+ i = row_start
40
+ while i != row_stop:
41
+ ui = U(i)
42
+ rsum = 0.0
43
+ diag = 0.0
44
+ jj = U(Ap[ui])
45
+ end = U(Ap[ui + U(1)])
46
+ while jj < end:
47
+ j = U(Aj[jj])
48
+ if j == ui:
49
+ diag = Ax[jj]
50
+ else:
51
+ rsum += Ax[jj] * x[j]
52
+ jj += U(1)
53
+ if diag != 0.0:
54
+ x[ui] = (b[ui] - rsum) / diag
55
+ i += row_step
56
+
57
+ @nb.njit(cache=True, nogil=True)
58
+ def _bsr_gs(Ap, Aj, Ax, x, b, row_start, row_stop, row_step, bs):
59
+ # relaxation.h bsr_gauss_seidel: off-diagonal blocks by gemm (each
60
+ # into zeros, then subtracted), then the diagonal block pointwise,
61
+ # in the sweep's direction
62
+ ubs = U(bs)
63
+ B2 = ubs * ubs
64
+ rsum = np.empty(bs)
65
+ axloc = np.empty(bs)
66
+ if row_step < 0:
67
+ step, k0, k1 = -1, bs - 1, -1
68
+ else:
69
+ step, k0, k1 = 1, 0, bs
70
+ i = row_start
71
+ while i != row_stop:
72
+ ui = U(i)
73
+ ib = ui * ubs
74
+ for k in range(bs):
75
+ rsum[k] = b[ib + U(k)]
76
+ diag_ptr = U(0)
77
+ has_diag = False
78
+ jj = U(Ap[ui])
79
+ end = U(Ap[ui + U(1)])
80
+ while jj < end:
81
+ j = U(Aj[jj])
82
+ if j == ui:
83
+ diag_ptr = jj * B2
84
+ has_diag = True
85
+ else:
86
+ col = j * ubs
87
+ blk = jj * B2
88
+ for m in range(bs):
89
+ acc = 0.0
90
+ row = blk + U(m) * ubs
91
+ for k in range(bs):
92
+ acc += Ax[row + U(k)] * x[col + U(k)]
93
+ axloc[m] = acc
94
+ for m in range(bs):
95
+ rsum[m] -= axloc[m]
96
+ jj += U(1)
97
+ if has_diag:
98
+ k = k0
99
+ while k != k1:
100
+ uk = U(k)
101
+ diag = 1.0
102
+ kk = k0
103
+ while kk != k1:
104
+ ukk = U(kk)
105
+ if k == kk:
106
+ diag = Ax[uk * ubs + ukk + diag_ptr]
107
+ else:
108
+ rsum[k] -= Ax[uk * ubs + ukk + diag_ptr] * x[ib + ukk]
109
+ kk += step
110
+ if diag != 0.0:
111
+ x[ib + uk] = rsum[k] / diag
112
+ k += step
113
+ i += row_step
114
+
115
+ @nb.njit(cache=True, nogil=True)
116
+ def _block_gs(Ap, Aj, Ax, x, b, Dinv, row_start, row_stop, row_step, bs):
117
+ # relaxation.h block_gauss_seidel: rsum = sum of off-diagonal block
118
+ # products (each gemm into zeros), x_i = Dinv_i (b_i - rsum)
119
+ ubs = U(bs)
120
+ B2 = ubs * ubs
121
+ rsum = np.empty(bs)
122
+ v = np.empty(bs)
123
+ i = row_start
124
+ while i != row_stop:
125
+ ui = U(i)
126
+ ib = ui * ubs
127
+ for k in range(bs):
128
+ rsum[k] = 0.0
129
+ jj = U(Ap[ui])
130
+ end = U(Ap[ui + U(1)])
131
+ while jj < end:
132
+ j = U(Aj[jj])
133
+ if j != ui:
134
+ col = j * ubs
135
+ blk = jj * B2
136
+ for m in range(bs):
137
+ acc = 0.0
138
+ row = blk + U(m) * ubs
139
+ for k in range(bs):
140
+ acc += Ax[row + U(k)] * x[col + U(k)]
141
+ v[m] = acc
142
+ for k in range(bs):
143
+ rsum[k] += v[k]
144
+ jj += U(1)
145
+ for k in range(bs):
146
+ rsum[k] = b[ib + U(k)] - rsum[k]
147
+ dblk = ui * B2
148
+ for m in range(bs):
149
+ acc = 0.0
150
+ row = dblk + U(m) * ubs
151
+ for k in range(bs):
152
+ acc += Dinv[row + U(k)] * rsum[k]
153
+ x[ib + U(m)] = acc
154
+ i += row_step
155
+
156
+ @nb.njit(cache=True, nogil=True, inline="always")
157
+ def _row_cols(i, Ap, Aj, Bp, Bj, mark, cols):
158
+ # the block row's output columns in scipy's order: first visit
159
+ n = 0
160
+ for jj in range(Ap[i], Ap[i + 1]):
161
+ j = U(Aj[jj])
162
+ for kk in range(Bp[j], Bp[j + U(1)]):
163
+ k = Bj[kk]
164
+ if mark[k] != i:
165
+ mark[k] = i
166
+ cols[n] = k
167
+ n += 1
168
+ return n
169
+
170
+ @nb.njit(cache=True, parallel=True)
171
+ def _bsr_count(n_brow, n_bcol, Ap, Aj, Bp, Bj, counts):
172
+ nch = 64
173
+ for c in nb.prange(nch):
174
+ mark = np.full(n_bcol, -1, np.int64)
175
+ cols = np.empty(n_bcol, np.int64)
176
+ for i in range(c * n_brow // nch, (c + 1) * n_brow // nch):
177
+ counts[i] = _row_cols(i, Ap, Aj, Bp, Bj, mark, cols)
178
+
179
+ @nb.njit(cache=True, parallel=True)
180
+ def _bsr_fill(n_brow, n_bcol, R, C, N, Ap, Aj, Ax, Bp, Bj, Bx, Cp, Cj, Cx):
181
+ # scipy sparsetools bsr_matmat, a block row at a time: each output
182
+ # block gathers A(i, j) B(j, k) in the same (jj, kk) order, by its
183
+ # gemm (dot from the block's value, d ascending), so the same bits
184
+ RC, RN, NC = R * C, R * N, N * C
185
+ nch = 64
186
+ for c in nb.prange(nch):
187
+ mark = np.full(n_bcol, -1, np.int64)
188
+ slot = np.empty(n_bcol, np.int64)
189
+ cols = np.empty(n_bcol, np.int64)
190
+ for i in range(c * n_brow // nch, (c + 1) * n_brow // nch):
191
+ n = _row_cols(i, Ap, Aj, Bp, Bj, mark, cols)
192
+ base = Cp[i]
193
+ for q in range(n):
194
+ Cj[base + q] = cols[q]
195
+ slot[cols[q]] = base + q
196
+ for t in range(RC):
197
+ Cx[(base + q) * RC + t] = 0.0
198
+ for jj in range(Ap[i], Ap[i + 1]):
199
+ j = U(Aj[jj])
200
+ a0 = U(jj) * U(RN)
201
+ for kk in range(Bp[j], Bp[j + U(1)]):
202
+ k = Bj[kk]
203
+ b0 = U(kk) * U(NC)
204
+ c0 = U(slot[k]) * U(RC)
205
+ for r in range(R):
206
+ for cc in range(C):
207
+ o = c0 + U(C * r + cc)
208
+ dot = Cx[o]
209
+ for d in range(N):
210
+ dot += Ax[a0 + U(N * r + d)] * Bx[b0 + U(C * d + cc)]
211
+ Cx[o] = dot
212
+
213
+
214
+ def _u(a):
215
+ """An index array viewed unsigned (they are never negative)."""
216
+ return a.view(np.uint32 if a.dtype.itemsize == 4 else np.uint64)
217
+
218
+
219
+ def _ok(*arrays):
220
+ return all(a.dtype == np.float64 for a in arrays)
221
+
222
+
223
+ from pyamg import amg_core
224
+
225
+
226
+ class _Core:
227
+ """amg_core with the three Gauss-Seidel kernels replaced."""
228
+
229
+ def __init__(self, core):
230
+ self._core = core
231
+
232
+ def __getattr__(self, name):
233
+ return getattr(self._core, name)
234
+
235
+ @staticmethod
236
+ def gauss_seidel(Ap, Aj, Ax, x, b, row_start, row_stop, row_step):
237
+ if not _ok(Ax, x, b):
238
+ return amg_core.gauss_seidel(Ap, Aj, Ax, x, b, row_start, row_stop, row_step)
239
+ _gs(_u(Ap), _u(Aj), Ax, x, b, int(row_start), int(row_stop), int(row_step))
240
+
241
+ @staticmethod
242
+ def bsr_gauss_seidel(Ap, Aj, Ax, x, b, row_start, row_stop, row_step, R):
243
+ if not _ok(Ax, x, b):
244
+ return amg_core.bsr_gauss_seidel(Ap, Aj, Ax, x, b, row_start, row_stop, row_step, R)
245
+ _bsr_gs(_u(Ap), _u(Aj), Ax, x, b, int(row_start), int(row_stop), int(row_step), int(R))
246
+
247
+ @staticmethod
248
+ def block_gauss_seidel(Ap, Aj, Ax, x, b, Tx, row_start, row_stop, row_step, R):
249
+ if not _ok(Ax, x, b, Tx):
250
+ return amg_core.block_gauss_seidel(Ap, Aj, Ax, x, b, Tx, row_start, row_stop, row_step, R)
251
+ _block_gs(_u(Ap), _u(Aj), Ax, x, b, Tx, int(row_start), int(row_stop), int(row_step), int(R))
252
+
253
+
254
+ _MATMUL_MIN = 20_000 # block nonzeros of A below which scipy's own is as quick
255
+
256
+
257
+ def _bsr_matmul(self, other, _orig=None):
258
+ """scipy's BSR @ sparse (bsr._matmul_sparse) with the product itself on
259
+ numba's threads. pyamg's setup (R A P, the prolongation smoother) does
260
+ these on one core while the others wait; same entries, same order, so
261
+ the same bits."""
262
+ M = self.shape[0]
263
+ N = other.shape[1]
264
+ R, n = self.blocksize
265
+ C = other.blocksize[1] if other.format == "bsr" else 1
266
+ if (self.data.dtype != np.float64 or other.dtype != np.float64 or len(self.indices) < _MATMUL_MIN
267
+ or (R == 1 and n == 1 and C == 1)):
268
+ return _orig(self, other)
269
+ if other.format == "csr" and n == 1:
270
+ other = other.tobsr(blocksize=(n, C), copy=False)
271
+ else:
272
+ other = other.tobsr(blocksize=(n, C))
273
+ n_brow, n_bcol = M // R, N // C
274
+ Ap = np.ascontiguousarray(self.indptr, np.int64)
275
+ Aj = np.ascontiguousarray(self.indices, np.int64)
276
+ Bp = np.ascontiguousarray(other.indptr, np.int64)
277
+ Bj = np.ascontiguousarray(other.indices, np.int64)
278
+ counts = np.empty(n_brow, np.int64)
279
+ _bsr_count(n_brow, n_bcol, Ap, Aj, Bp, Bj, counts)
280
+ Cp = np.zeros(n_brow + 1, np.int64)
281
+ np.cumsum(counts, out=Cp[1:])
282
+ nnz = int(Cp[-1])
283
+ Cj = np.empty(nnz, np.int64)
284
+ Cx = np.empty(R * C * nnz, np.float64)
285
+ _bsr_fill(n_brow, n_bcol, R, C, n, Ap, Aj, np.ascontiguousarray(np.ravel(self.data)), Bp, Bj,
286
+ np.ascontiguousarray(np.ravel(other.data)), Cp, Cj, Cx)
287
+ idx = self._get_index_dtype((self.indptr, self.indices, other.indptr, other.indices), maxval=nnz)
288
+ return self._bsr_container((Cx.reshape(-1, R, C), Cj.astype(idx), Cp.astype(idx)), shape=(M, N),
289
+ blocksize=(R, C))
290
+
291
+
292
+ _SCIPY_BSR_MATMUL = None
293
+
294
+
295
+ def _bsr_matmul_method(self, other):
296
+ return _bsr_matmul(self, other, _orig=_SCIPY_BSR_MATMUL)
297
+
298
+
299
+ _bsr_matmul_method._loadpath = True
300
+
301
+
302
+ def installed():
303
+ """Whether pyamg's Gauss-Seidel is the GIL-free one now."""
304
+ return _INSTALLED
305
+
306
+
307
+ def install():
308
+ """Use the GIL-free kernels in pyamg's relaxation module (once)."""
309
+ global _INSTALLED
310
+ if _INSTALLED or nb is None or os.environ.get("LOADPATH_AMGFAST", "1") == "0":
311
+ return _INSTALLED
312
+ try:
313
+ import pyamg.relaxation.relaxation as rel
314
+ except ImportError: # pragma: no cover
315
+ return False
316
+ if not isinstance(rel.amg_core, _Core):
317
+ rel.amg_core = _Core(rel.amg_core)
318
+ global _SCIPY_BSR_MATMUL
319
+ from scipy.sparse import _bsr
320
+
321
+ orig = _bsr._bsr_base._matmul_sparse
322
+ if not getattr(orig, "_loadpath", False):
323
+ _SCIPY_BSR_MATMUL = orig
324
+ _bsr._bsr_base._matmul_sparse = _bsr_matmul_method
325
+ _INSTALLED = True
326
+ return True