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/pressure.py ADDED
@@ -0,0 +1,171 @@
1
+ """
2
+ Pressure on the walls of a cavity: a boosted intake plenum, a coolant
3
+ jacket, a pneumatic chamber.
4
+
5
+ A pressure load names a keep-out (the fluid's volume) and a gauge pressure
6
+ in MPa (1 bar = 0.1 MPa). It pushes on every face of material that lines
7
+ that keep-out, along the face's normal, out of the fluid.
8
+
9
+ On the voxel grid the lined surface is the set of element faces between
10
+ material and cavity cells, a staircase. That is exact where it matters: a
11
+ uniform pressure on a closed staircase sums to zero force and zero moment,
12
+ like on the true surface (both are p times the integral of n dA, and of
13
+ x cross n dA, over a closed surface), and each face's share of p A sits at
14
+ its centre. So a sealed vessel's load is self-equilibrating to rounding,
15
+ and a study whose only loads are pressures needs no supports: the solver
16
+ holds it by six statically determinate constraints at three nodes on the
17
+ wall (`stabilize`), which take no reaction from a balanced load and only
18
+ remove the rigid-body motion.
19
+
20
+ Cavity cells are the empty cells within half a cell of the keep-out, plus
21
+ every empty pocket the material seals off that reaches into the keep-out.
22
+ The first rule finds the wet surface of an open duct (whose cavity runs out
23
+ of the grid at its ends); the second keeps a sealed cavity's surface closed
24
+ where a cell only grazes the shape, so its load balances exactly.
25
+
26
+ The wall is fixed geometry (spec.py pins a `wall` round every pressurized
27
+ keep-out), so the load does not depend on the design and the optimizer's
28
+ sensitivities need no pressure term: the pressure boundary of a housing is
29
+ its flow path, which aerodynamics sets, not the optimizer.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import numpy as np
34
+ from scipy import ndimage
35
+
36
+ from .fem import CORNERS, HexMesh
37
+
38
+ __all__ = ["cavity_faces", "pressure_force", "stabilize"]
39
+
40
+ _DIRS = [(a, s) for a in range(3) for s in (-1, 1)]
41
+ # Tolerance for "within half a cell": cells partly inside a keep-out are
42
+ # left out of the design (domain.build_domain), and those have a sampling
43
+ # point inside, so their centres are within 0.43 cells of the shape.
44
+ _REACH = 0.5
45
+
46
+
47
+ def _occupancy(mesh: HexMesh):
48
+ ijk = mesh.elem_ijk
49
+ lo = ijk.min(axis=0) - 1
50
+ dims = ijk.max(axis=0) - lo + 2
51
+ occ = np.zeros(dims, bool)
52
+ occ[tuple((ijk - lo).T)] = True
53
+ return occ, lo
54
+
55
+
56
+ def _cavity_cells(mesh: HexMesh, shape, occ, lo):
57
+ """Bool grid (occupancy's frame): the empty cells the fluid fills."""
58
+ pitch, origin = mesh.pitch, mesh.origin
59
+ dims = np.asarray(occ.shape)
60
+ slo, shi = shape.bounds()
61
+ a = np.clip(np.floor((slo - origin) / pitch).astype(int) - lo - 1, 0, dims)
62
+ b = np.clip(np.ceil((shi - origin) / pitch).astype(int) - lo + 2, 0, dims)
63
+ cav = np.zeros(occ.shape, bool)
64
+ inside = np.zeros(occ.shape, bool)
65
+ if np.any(b <= a):
66
+ return cav
67
+ box = tuple(slice(a[i], b[i]) for i in range(3))
68
+ idx = np.argwhere(~occ[box])
69
+ if len(idx) == 0:
70
+ return cav
71
+ centers = origin + (idx + a + lo + 0.5) * pitch
72
+ d = shape.sdf(centers)
73
+ near = idx[d < _REACH * pitch] + a
74
+ cav[tuple(near.T)] = True
75
+ deep = idx[d < 0.0] + a
76
+ inside[tuple(deep.T)] = True
77
+ # sealed pockets: empty components that never reach the grid's border
78
+ lbl, n = ndimage.label(~occ)
79
+ if n:
80
+ border = np.unique(np.concatenate([lbl[0].ravel(), lbl[-1].ravel(), lbl[:, 0].ravel(),
81
+ lbl[:, -1].ravel(), lbl[:, :, 0].ravel(), lbl[:, :, -1].ravel()]))
82
+ seeds = np.unique(lbl[inside])
83
+ sealed = np.setdiff1d(seeds[seeds > 0], border)
84
+ if len(sealed):
85
+ cav |= np.isin(lbl, sealed)
86
+ return cav
87
+
88
+
89
+ def cavity_faces(mesh: HexMesh, shape):
90
+ """Wetted element faces: list of (element indices, axis, sign), the
91
+ cavity lying on the `sign` side of each element along `axis`."""
92
+ occ, lo = _occupancy(mesh)
93
+ cav = _cavity_cells(mesh, shape, occ, lo)
94
+ out = []
95
+ rel = mesh.elem_ijk - lo
96
+ for a, s in _DIRS:
97
+ nb = rel.copy()
98
+ nb[:, a] += s
99
+ hit = np.nonzero(cav[tuple(nb.T)])[0]
100
+ if len(hit):
101
+ out.append((hit, a, s))
102
+ return out
103
+
104
+
105
+ def pressure_force(mesh: HexMesh, shape, pressure: float):
106
+ """Nodal forces (per dof) of `pressure` (MPa) in the cavity `shape` on
107
+ the material lining it, and the bool mask of the nodes it acts on."""
108
+ f = np.zeros((mesh.n_nodes, 3))
109
+ nodes = np.zeros(mesh.n_nodes, bool)
110
+ share = pressure * mesh.pitch ** 2 / 4.0
111
+ for elems, a, s in cavity_faces(mesh, shape):
112
+ corners = np.nonzero(CORNERS[:, a] == (1 if s > 0 else 0))[0]
113
+ nid = mesh.enode[np.ix_(elems, corners)].ravel()
114
+ # out of the fluid: against the direction the cavity lies in
115
+ np.add.at(f[:, a], nid, -s * share)
116
+ nodes[nid] = True
117
+ return f.ravel(), nodes
118
+
119
+
120
+ def stabilize(mesh: HexMesh, nodes: np.ndarray):
121
+ """Six statically determinate constraints on three of `nodes` (a 3-2-1
122
+ support): the node lowest in x held in x, y and z; the node highest in x
123
+ held across x; a third, farthest from the line between them, held in the
124
+ one direction that stops rotation about that line. A self-equilibrating
125
+ load takes no reaction from them. Returns (fixed dofs, support nodes)."""
126
+ idx = np.nonzero(nodes)[0]
127
+ if len(idx) < 3:
128
+ raise ValueError("a pressure-only study needs a wetted wall to hold it by; the cavity touches no material")
129
+ xyz = mesh.node_xyz[idx]
130
+ order = np.lexsort((xyz[:, 2], xyz[:, 1], xyz[:, 0]))
131
+ ia, ib = order[0], order[-1]
132
+ pa, pb = xyz[ia], xyz[ib]
133
+ ab = pb - pa
134
+ if np.linalg.norm(ab) <= 0:
135
+ raise ValueError("pressure-only study: the wetted wall is a single point")
136
+ lever = np.cross(ab / np.linalg.norm(ab), xyz - pa)
137
+ ic = int(np.argmax(np.linalg.norm(lever, axis=1)))
138
+ fixed = np.zeros(mesh.n_dof, bool)
139
+ na, nb, nc = idx[ia], idx[ib], idx[ic]
140
+ fixed[3 * na:3 * na + 3] = True
141
+ across = [i for i in range(3) if i != int(np.argmax(np.abs(ab)))]
142
+ for i in across:
143
+ fixed[3 * nb + i] = True
144
+ # the one direction at C that, with the five above, leaves no rigid
145
+ # motion: try them in order of C's lever about A-B
146
+ ac = xyz[ic] - pa
147
+
148
+ def rigid_rank(k):
149
+ rows = []
150
+ for n, p in ((na, pa), (nb, pb), (nc, xyz[ic])):
151
+ for i in range(3):
152
+ if fixed[3 * n + i] or (n == nc and i == k):
153
+ r = p - pa
154
+ t = np.zeros(6)
155
+ t[i] = 1.0
156
+ # rotation w about A moves p by w x r: d(u_i)/d(w)
157
+ e = np.zeros(3)
158
+ e[i] = 1.0
159
+ t[3:] = np.cross(r, e)
160
+ rows.append(t)
161
+ return np.linalg.matrix_rank(np.array(rows), tol=1e-9 * max(1.0, np.abs(ac).max() ** 2))
162
+
163
+ for k in np.argsort(-np.abs(np.cross(ab, ac))):
164
+ if rigid_rank(int(k)) == 6:
165
+ fixed[3 * nc + int(k)] = True
166
+ break
167
+ else:
168
+ raise ValueError("pressure-only study: the wetted wall is too flat to hold without supports; add one")
169
+ sup = np.zeros(mesh.n_nodes, bool)
170
+ sup[[na, nb, nc]] = True
171
+ return fixed, sup
loadpath/preview.py ADDED
@@ -0,0 +1,335 @@
1
+ """
2
+ A live stress preview for the field editor: the part straight from its
3
+ field graph on a coarse grid, solved in well under a second or two, so a
4
+ heatmap can follow the sliders.
5
+
6
+ No mesh is made. The graph's distance field is sampled at cell centres and
7
+ each cell's filled share (half a pitch either side of the surface) sets its
8
+ stiffness, as `verify_part(fractional=True)` does, so the reading moves
9
+ smoothly with a slider rather than in whole-cell steps. Supports and loads
10
+ come from the study (domain.boundary_conditions, the same problem the
11
+ optimizer and the check solve); the solve is the matrix-free multigrid.
12
+ The two highest spots per load case are re-solved finer (_submodel) and
13
+ the peak is multiplied by HOTSPOT_FACTOR, calibrated so the preview's
14
+ safety factor reads under the full check's on every part of
15
+ bench/preview_calibration.py (0.72-0.95 of it). What it returns is still
16
+ a preview: no thickness or printability checks, no across-layer check,
17
+ and a coarse grid. A Run (verify_part on the meshed part) is the check.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import time
23
+
24
+ import numpy as np
25
+ from scipy import ndimage
26
+
27
+ PREVIEW_CELLS = 16_000
28
+ # hot spots per load case re-solved finer (_submodel), and how
29
+ REFINE = 2
30
+ SUB_HALF = 3.0 # box half-width, coarse cells
31
+ SUB_RATIO = 2.5 # coarse pitch / submodel pitch
32
+ HOTSPOT_FACTOR = 1.3 # the refined peak times this: bench/preview_calibration.py, docs/fields.md
33
+
34
+
35
+ def study_ref(doc):
36
+ """The study the graph is analyzed under: its tune block's, else the
37
+ first `result` node's."""
38
+ t = doc.get("tune")
39
+ if isinstance(t, dict) and isinstance(t.get("study"), str):
40
+ return t["study"]
41
+ for spec in (doc.get("nodes") or {}).values():
42
+ if isinstance(spec, dict) and spec.get("op") == "result" and isinstance(spec.get("study"), str):
43
+ return spec["study"]
44
+ return None
45
+
46
+
47
+ def _fill(g, name, lo, dims, pitch):
48
+ """Each cell's filled share from the distance at its centre."""
49
+ axes = [lo[i] + (np.arange(dims[i]) + 0.5) * pitch for i in range(3)]
50
+ P = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
51
+ d = np.concatenate([g.evaluate(P[i:i + 400_000], name) for i in range(0, len(P), 400_000)])
52
+ d = np.nan_to_num(d, nan=1e3).reshape(tuple(dims))
53
+ return np.clip(0.5 - d / pitch, 0.0, 1.0)
54
+
55
+
56
+ def _hotspots(xyz, vm, body, sep, count):
57
+ """The `count` highest nodes in the body, each at least `sep` from the
58
+ ones before it."""
59
+ order = np.argsort(np.where(body, vm, -1.0))[::-1]
60
+ out = []
61
+ for i in order[:5000]:
62
+ if not body[i]:
63
+ break
64
+ if all(np.linalg.norm(xyz[i] - c) > sep for c in out):
65
+ out.append(xyz[i])
66
+ if len(out) == count:
67
+ break
68
+ return out
69
+
70
+
71
+ def _grid(lo, hi, pitch):
72
+ """Cells on multiples of the pitch, a cell of margin each side, so a
73
+ slider's neighbouring values land on one lattice and compare fairly."""
74
+ a = np.floor(lo / pitch).astype(int) - 1
75
+ b = np.ceil(hi / pitch).astype(int) + 1
76
+ return a * pitch, np.maximum(b - a, 1)
77
+
78
+
79
+ def _submodel(g, name, study, mat, hm, u, center, half, h, contact_xyz, band, margin):
80
+ """The peak von Mises in a box around a hot spot, re-solved at pitch `h`
81
+ from the field, driven on the box's faces by the
82
+ coarse displacements (verify._submodel_peak's submodelling, without a
83
+ mesh). Returns (MPa, position) or None."""
84
+ from scipy.sparse.linalg import splu
85
+ from scipy.spatial import cKDTree
86
+
87
+ from .fem import HexMesh, Stiffness
88
+ from .verify import nodal_von_mises
89
+
90
+ dims = np.full(3, max(int(np.ceil(2 * half / h)), 2))
91
+ origin = np.asarray(center, float) - 0.5 * dims * h
92
+ # whole cells here, as the check's own submodels are: on the arm link a
93
+ # filled-share box closed 5% of the gap to the check, a whole-cell one 15%
94
+ fill = (_fill(g, name, origin, dims, h) >= 0.5).astype(float)
95
+ solid = fill > 0
96
+ if solid.sum() < 64:
97
+ return None
98
+ sm = HexMesh(np.argwhere(solid), h, origin)
99
+ blo, bhi = origin, origin + dims * h
100
+ eps = 0.25 * h
101
+ cut = np.any((sm.node_xyz < blo + eps) | (sm.node_xyz > bhi - eps), axis=1)
102
+ near_contact = np.zeros(sm.n_nodes, bool)
103
+ if len(contact_xyz):
104
+ near_contact = cKDTree(contact_xyz).query(sm.node_xyz, k=1, distance_upper_bound=band)[0] < np.inf
105
+ driven = cut | near_contact
106
+ if driven.all() or not driven.any():
107
+ return None
108
+ # material that reaches no driven node would float: left out
109
+ lbl, _ = ndimage.label(solid)
110
+ ijk = np.argwhere(solid)
111
+ held = np.unique(lbl[tuple(ijk[driven[sm.enode].any(axis=1)].T)])
112
+ keep = np.isin(lbl[tuple(ijk.T)], held)
113
+ if not keep.all():
114
+ ijk = ijk[keep]
115
+ sm = HexMesh(ijk, h, origin)
116
+ cut = np.any((sm.node_xyz < blo + eps) | (sm.node_xyz > bhi - eps), axis=1)
117
+ near_contact = (cKDTree(contact_xyz).query(sm.node_xyz, k=1, distance_upper_bound=band)[0] < np.inf
118
+ if len(contact_xyz) else np.zeros(sm.n_nodes, bool))
119
+ driven = cut | near_contact
120
+ gd, gi = cKDTree(hm.node_xyz).query(sm.node_xyz[driven], k=8)
121
+ w = 1.0 / np.maximum(gd, 1e-9) ** 2
122
+ w /= w.sum(axis=1, keepdims=True)
123
+ up = (w[..., None] * u.reshape(-1, 3)[gi]).sum(axis=1).ravel()
124
+ pd = np.repeat(driven, 3)
125
+ st = Stiffness(sm, study.elastic(mat), np.zeros(sm.n_dof, bool))
126
+ k = st.assemble(mat.E * fill[tuple(ijk.T)]).tocsr()
127
+ free = ~pd
128
+ kff = k[free][:, free].tocsc()
129
+ rhs = -(k[free][:, pd] @ up)
130
+ try:
131
+ uf = splu(kff, permc_spec="MMD_AT_PLUS_A", diag_pivot_thresh=0.0,
132
+ options={"SymmetricMode": True}).solve(rhs)
133
+ except RuntimeError:
134
+ return None
135
+ us = np.zeros(sm.n_dof)
136
+ us[pd], us[free] = up, uf
137
+ vm = nodal_von_mises(st, us, mat.E)
138
+ inner = np.all((sm.node_xyz > blo + margin) & (sm.node_xyz < bhi - margin), axis=1) & ~near_contact
139
+ if not inner.any():
140
+ return None
141
+ i = int(np.argmax(np.where(inner, vm, -1.0)))
142
+ return float(vm[i]), sm.node_xyz[i].tolist()
143
+
144
+
145
+ class PreviewState:
146
+ """What one editor session's previews carry between slider moves: the
147
+ grid pitch, kept while the cell count stays near the target. (Starting
148
+ each solve from the last one's displacements was tried: on the arm link
149
+ it saved no CG iterations, 10 either way, and the mapping cost 0.2 s.)"""
150
+
151
+ def __init__(self):
152
+ self.pitch = None
153
+
154
+
155
+ def stress_preview(doc, base_dir=None, study=None, graph=None, cells=PREVIEW_CELLS, pitch=None, state=None,
156
+ device=None, refine=REFINE):
157
+ """Coarse stress of the graph's output under its study. Returns
158
+ {"safetyFactor", "deflection" (worst case, mm), "mass" (g), "cells",
159
+ "peak" (the highest stress over allowed outside the contact bands, 1 /
160
+ safetyFactor),
161
+ "pitch", "seconds", "timing" {field, setup, solve, stress},
162
+ "cases" [{name, safetyFactor, deflection}], "lo", "hi", "dims", "data"}:
163
+ `data` is the nodal stress over the allowed stress (strength /
164
+ STRESS_ALLOWANCE, as the check reads it) on the node grid, float16,
165
+ x fastest, base64; 1.0 is where the check's safety factor would be 1.
166
+ `pitch` (or `state`, a PreviewState) reuses a previous preview's grid
167
+ while it lands within a factor of two of `cells`. The solve is the matrix-free
168
+ multigrid (mg.py) on `device` (default LOADPATH_DEVICE: a GPU when torch
169
+ sees one, else torch on the CPU, else numpy)."""
170
+ from .domain import boundary_conditions
171
+ from .fem import HexMesh
172
+ from .fields import FieldGraph, load_study
173
+ from .verify import STRESS_ALLOWANCE, nodal_von_mises
174
+
175
+ t0 = time.time()
176
+ timing = {}
177
+ if study is None:
178
+ ref = study_ref(doc)
179
+ if ref is None:
180
+ raise ValueError("the stress preview needs a study: give the graph a tune block or a result node")
181
+ study = load_study(ref, base_dir, doc.get("material"))
182
+ g = graph or FieldGraph(doc, base_dir=base_dir)
183
+ name = doc.get("output")
184
+ b = g.bounds(name)
185
+ if b is None:
186
+ raise ValueError("the part is unbounded: intersect it with a solid")
187
+ lo, hi = np.asarray(b[0], float), np.asarray(b[1], float)
188
+
189
+ def sample(h):
190
+ o, dm = _grid(lo, hi, h)
191
+ return o, dm, _fill(g, name, o, dm, h)
192
+
193
+ if pitch is None and state is not None:
194
+ pitch = state.pitch
195
+ if pitch:
196
+ origin, dims, fill = sample(float(pitch))
197
+ n = int((fill >= 0.05).sum())
198
+ if not 0.5 * cells <= n <= 2.0 * cells:
199
+ pitch = None
200
+ if not pitch:
201
+ # a first look at a quarter of the cells finds the part's volume
202
+ h0 = (float(np.prod(hi - lo)) / (0.25 * cells)) ** (1.0 / 3.0)
203
+ _, _, f0 = sample(h0)
204
+ vol = max(float(f0.sum()) * h0 ** 3, 1e-9)
205
+ pitch = (vol / cells) ** (1.0 / 3.0)
206
+ origin, dims, fill = sample(pitch)
207
+ pitch = float(pitch)
208
+ timing["field"] = time.time() - t0
209
+
210
+ mat = study.material
211
+ from .directions import expand
212
+ from .mg import GridElasticity
213
+
214
+ def setup(origin, fill, pitch):
215
+ solid = fill >= 0.05
216
+ lbl, _ = ndimage.label(solid)
217
+ ijk = np.argwhere(solid)
218
+ hm = HexMesh(ijk, pitch, origin)
219
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
220
+ held = np.unique(lbl[tuple(ijk[bc.support_nodes[hm.enode].any(axis=1)].T)])
221
+ keep = np.isin(lbl[tuple(ijk.T)], held)
222
+ if not keep.any():
223
+ raise ValueError("no material touches a support")
224
+ if not keep.all():
225
+ ijk = ijk[keep]
226
+ hm = HexMesh(ijk, pitch, origin)
227
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
228
+ return hm, bc, fill[tuple(ijk.T)]
229
+
230
+ # The multigrid's coarse levels keep only the fine level's even nodes,
231
+ # and on some grids a thin support's fixed nodes all sit on odd ones: the
232
+ # coarsest level floats and factors singular, or CG stalls (an
233
+ # L-bracket held by one face: 9 iterations at pitch 1.12 and 1.13, none
234
+ # converging in 2000 at 1.127). It converges in 8-13 iterations when it
235
+ # works, so 50 is the cap; a grid 2% coarser falls differently;
236
+ # after two of those the assembled solve (5-80 s) is the last resort.
237
+ disp, base = None, (pitch, origin, dims, fill)
238
+ for attempt in range(3):
239
+ t1 = time.time()
240
+ if attempt:
241
+ pitch *= 1.02
242
+ origin, dims, fill = sample(pitch)
243
+ hm, bc, share = setup(origin, fill, pitch)
244
+ spreads = bc.spreads or [None] * len(study.load_cases)
245
+ rhs, layout = expand(bc.loads, spreads, None)
246
+ timing["setup"] = timing.get("setup", 0.0) + time.time() - t1
247
+ t2 = time.time()
248
+ springs = mat.E * bc.springs if bc.springs is not None else None
249
+ try:
250
+ stiff = GridElasticity(hm, study.elastic(mat), bc.fixed, springs=springs, rtol=1e-3, maxiter=50,
251
+ device=device)
252
+ stiff.set_moduli(mat.E * share)
253
+ disp = stiff.solve_many(rhs)
254
+ iters = stiff.last_iters
255
+ except RuntimeError:
256
+ disp = None
257
+ timing["solve"] = timing.get("solve", 0.0) + time.time() - t2
258
+ if disp is not None:
259
+ break
260
+ if disp is None:
261
+ # on the grid it started from, so a slider's next value lands on it too
262
+ from .fem import Solver, Stiffness
263
+
264
+ t2 = time.time()
265
+ pitch, origin, dims, fill = base
266
+ hm, bc, share = setup(origin, fill, pitch)
267
+ rhs, layout = expand(bc.loads, bc.spreads or [None] * len(study.load_cases), None)
268
+ springs = mat.E * bc.springs if bc.springs is not None else None
269
+ stiff = Stiffness(hm, study.elastic(mat), bc.fixed)
270
+ k = stiff.assemble(mat.E * share)
271
+ if springs is not None:
272
+ k = (k + springs).tocsr()
273
+ solver = Solver(hm, rtol=1e-4, maxiter=2000)
274
+ disp = solver.solve_many(k, rhs)
275
+ iters = solver.last_iters
276
+ timing["solve"] += time.time() - t2
277
+ if state is not None:
278
+ state.pitch = pitch
279
+
280
+ t3 = time.time()
281
+ from scipy.spatial import cKDTree
282
+
283
+ allowed = mat.strength / STRESS_ALLOWANCE
284
+ util = np.zeros(hm.n_nodes)
285
+ band = 1.5 * pitch
286
+ cases, worst_sf, worst_defl, peak_util, worst_raw, worst_ref = [], np.inf, 0.0, 0.0, np.inf, np.inf
287
+ for ci, case in enumerate(study.load_cases):
288
+ idx = [i for i in layout[ci] if i is not None]
289
+ # a load of unknown direction: each axis's solution, the worst at each node
290
+ vm = np.max([nodal_von_mises(stiff, disp[i], mat.E) for i in idx], axis=0)
291
+ contact = bc.support_nodes | bc.case_load_nodes[ci]
292
+ near = cKDTree(hm.node_xyz[contact]).query(hm.node_xyz, k=1, distance_upper_bound=band)[0] < np.inf
293
+ body = ~near if (~near).any() else np.ones_like(near)
294
+ peak = float(vm[body].max())
295
+ raw_peak = peak
296
+ if refine:
297
+ u_worst = disp[idx[int(np.argmax([nodal_von_mises(stiff, disp[i], mat.E)[body].max() for i in idx]))]] \
298
+ if len(idx) > 1 else disp[idx[0]]
299
+ for c in _hotspots(hm.node_xyz, vm, body, SUB_HALF * pitch, refine):
300
+ r = _submodel(g, name, study, mat, hm, u_worst, c, SUB_HALF * pitch, pitch / SUB_RATIO,
301
+ hm.node_xyz[contact], band, 0.75 * pitch)
302
+ if r is not None and r[0] > peak:
303
+ peak = r[0]
304
+ raw_sf = allowed / raw_peak if raw_peak > 0 else float("inf")
305
+ refined_sf = allowed / peak if peak > 0 else float("inf")
306
+ sf = refined_sf / HOTSPOT_FACTOR
307
+ ln = bc.case_load_nodes[ci]
308
+ defl = max(float(np.linalg.norm(disp[i].reshape(-1, 3)[ln].mean(axis=0))) for i in idx) if ln.any() else 0.0
309
+ cases.append({"name": case.name, "safetyFactor": sf, "rawSafetyFactor": raw_sf,
310
+ "refinedSafetyFactor": refined_sf, "deflection": defl})
311
+ worst_sf, worst_defl = min(worst_sf, sf), max(worst_defl, defl)
312
+ worst_raw, worst_ref = min(worst_raw, raw_sf), min(worst_ref, refined_sf)
313
+ util = np.maximum(util, vm / allowed)
314
+ peak_util = max(peak_util, raw_peak / allowed) # the map is the coarse solve
315
+ # onto the node grid; empty nodes take their nearest node's value so the
316
+ # GPU's trilinear filter doesn't darken the surface
317
+ ndims = dims + 1
318
+ nijk = np.rint((hm.node_xyz - origin) / pitch).astype(int)
319
+ grid = np.zeros(tuple(ndims), np.float32)
320
+ have = np.zeros(tuple(ndims), bool)
321
+ grid[tuple(nijk.T)] = util
322
+ have[tuple(nijk.T)] = True
323
+ near_idx = ndimage.distance_transform_edt(~have, return_distances=False, return_indices=True)
324
+ grid = grid[tuple(near_idx)]
325
+ data = np.ascontiguousarray(np.clip(grid, 0, 6e4).astype(np.float16).transpose(2, 1, 0))
326
+ timing["stress"] = time.time() - t3
327
+ return {"safetyFactor": worst_sf, "rawSafetyFactor": worst_raw, "refinedSafetyFactor": worst_ref,
328
+ "hotspotFactor": HOTSPOT_FACTOR, "deflection": worst_defl,
329
+ "mass": float(share.sum()) * pitch ** 3 * mat.density / 1000.0,
330
+ "material": mat.name, "strength": mat.strength,
331
+ "goal": {"safetyFactor": study.goal.safety_factor, "maxDeflection": study.goal.max_deflection},
332
+ "cells": int(hm.n_elem), "pitch": pitch, "peak": peak_util, "allowed": allowed, "iterations": int(iters), "cases": cases,
333
+ "lo": origin.tolist(), "hi": (origin + dims * pitch).tolist(), "dims": [int(x) for x in ndims],
334
+ "data": base64.b64encode(data.tobytes()).decode("ascii"),
335
+ "timing": {k: round(v, 3) for k, v in timing.items()}, "seconds": round(time.time() - t0, 3)}