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/crosscheck.py ADDED
@@ -0,0 +1,1107 @@
1
+ """
2
+ A second opinion on a delivered part, from an independent solver.
3
+
4
+ verify.py analyses the part on the same kind of voxel grid the optimizer
5
+ uses. That is fast and consistent, and it shares the optimizer's blind
6
+ spots: a staircase boundary, stress that depends on where the grid falls on
7
+ a fillet (GRIP's turret read SF 2.41 at 150k cells and 1.92 at 120k). This
8
+ module re-meshes the delivered surface into curved-edge (quadratic)
9
+ tetrahedra that follow it exactly, applies the study's supports and loads,
10
+ and solves linear elasticity with a separate code path:
11
+
12
+ - mesh: gmsh (optional dependency; GPL, called as a tool, not linked into
13
+ loadpath's own code), 10-node tetrahedra sized from the part's thinnest
14
+ member;
15
+ - solve: quadratic (P2) tetrahedra assembled here, vectorized, solved by CG
16
+ with a p-multigrid preconditioner (pyamg on the linear problem);
17
+ - or, with `backend="ccx"` and CalculiX's `ccx` on PATH, the same mesh and
18
+ boundary conditions written as an Abaqus-style deck and solved by
19
+ CalculiX, the open-source reference for this kind of check.
20
+
21
+ The two analyses agreeing is the evidence; disagreeing is a finding.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import shutil
27
+ import subprocess
28
+ import tempfile
29
+ import time
30
+
31
+ import numpy as np
32
+
33
+ from .spec import Study
34
+ from .verify import STRESS_ALLOWANCE
35
+
36
+ __all__ = ["crosscheck", "element_size", "unsupported"]
37
+
38
+
39
+ def _tet_mesh(mesh, size, order=1, size_at=None, mesher=None):
40
+ """Tetrahedra filling a closed triangle mesh: (points, tets), 4 or 10
41
+ nodes.
42
+
43
+ fTetWild (pytetwild) by default when installed. Delivered parts come out
44
+ of the booleans with needle triangles and near-touching facets (4k of
45
+ 60k triangles on the motor-mount eval). Constrained meshers must keep
46
+ those triangles, so they leave flat slivers along the surface: gmsh's
47
+ Delaunay ground for 20 minutes on that part and failed, HXT met it in
48
+ 2 s with 11k interior slivers that CG could not converge on. fTetWild
49
+ meshes within a small envelope of the surface instead (0.1% of the
50
+ part's diagonal, under 0.1 mm here) and came out with no element below
51
+ a fifth of the regular tet's quality, in about a minute.
52
+
53
+ gmsh otherwise: on failure the surface is cleaned with manifold's
54
+ simplify at growing tolerances (0.05 mm moved nothing visible on the GRIP
55
+ wrist yoke) and tried again."""
56
+ mesher = mesher or ("tetwild" if _has_tetwild() and size_at is None and order == 1 else "gmsh")
57
+ if mesher == "tetwild":
58
+ return _tetwild(mesh, size)
59
+ from .shapes import manifold_from_trimesh, trimesh_from_manifold
60
+
61
+ # (Decimating a fine STL to the element size first halved the time and
62
+ # under-read the yoke's peak by a third: the surface resolution is part
63
+ # of the answer. Keep it.)
64
+ last = None
65
+ for eps in (0.0, 0.03, 0.05, 0.08, 0.12):
66
+ m = mesh if eps == 0.0 else trimesh_from_manifold(manifold_from_trimesh(mesh).simplify(eps))
67
+ for algo in (1, 10):
68
+ try:
69
+ return _tet_mesh_once(m, size, order, algo, size_at)
70
+ except Exception as ex: # noqa: BLE001 - gmsh raises bare Exception
71
+ last = ex
72
+ raise RuntimeError(f"could not tetrahedralize the part: {last}")
73
+
74
+
75
+ def element_size(study: Study) -> float:
76
+ """The default tetrahedron edge, mm: half the thinnest member."""
77
+ return max(0.75, 0.5 * (study.min_member or 3.0))
78
+
79
+
80
+ # Memory of a native P2 solve, measured on GRIP's upper arm (180 cm3):
81
+ # about 10 V / h^3 tetrahedra at edge h, and 7 kB each with the matrix
82
+ # assembled in place (520k tets at 1.5 mm: 3.8 GB peak, all three cases).
83
+ # It was 20 kB a tet while assembly summed chunk matrices, held a second
84
+ # free-dof copy and formed P^T K whole: the upper arm was coarsened to
85
+ # 1.8 mm to fit a 15 GB box.
86
+ _TETS_PER_VOLUME = 10.0
87
+ _BYTES_PER_TET = 7_500
88
+
89
+
90
+ def memory_budget() -> float:
91
+ """Bytes a tet solve may use: LOADPATH_TET_MEMORY (GB), else 40% of
92
+ the memory free now (in a container, its limit less its use), at most 8 GB."""
93
+ env = os.environ.get("LOADPATH_TET_MEMORY")
94
+ if env:
95
+ return float(env) * 1e9
96
+ from .study import _available_bytes # MemAvailable, and the container's free share
97
+
98
+ avail = _available_bytes() or 8e9
99
+ return min(8e9, 0.4 * avail)
100
+
101
+
102
+ def fit_size(volume: float, size: float, budget: float | None = None) -> float:
103
+ """`size`, coarsened just enough that the solve fits `budget` bytes."""
104
+ budget = memory_budget() if budget is None else budget
105
+ need = (_TETS_PER_VOLUME * abs(volume) * _BYTES_PER_TET / max(budget, 1e8)) ** (1.0 / 3.0)
106
+ return max(size, need)
107
+
108
+
109
+ def unsupported(study: Study) -> str | None:
110
+ """Why the cross-check cannot stand in for verify.py on this study, or
111
+ None when it can: it solves linear elasticity under the study's
112
+ supports, forces, moments and bearing loads, and nothing else. A layered
113
+ process is covered: the stiffness is the voxel verify's (study.elastic:
114
+ transversely isotropic about the build direction) and the safety factor
115
+ takes the same across-layer bond check (process.interlayer)."""
116
+ if not study.supports:
117
+ return "no supports (a self-equilibrated part)"
118
+ if study.gravity is not None:
119
+ return "gravity loads"
120
+ if any(ld.pressure for c in study.load_cases for ld in c.loads):
121
+ return "pressure loads"
122
+ return mesher_missing()
123
+
124
+
125
+ def mesher_missing() -> str | None:
126
+ """Why no tetrahedral mesh can be made here, or None when one can."""
127
+ if not _has_tetwild():
128
+ try:
129
+ import gmsh # noqa: F401 (availability check)
130
+ except (ImportError, OSError):
131
+ # OSError: the wheel is there but its shared libraries are not
132
+ # (libGLU on a headless box), which failed every SF study
133
+ return "no tetrahedral mesher (pip install loadpath[crosscheck])"
134
+ return None
135
+
136
+
137
+ def _has_tetwild():
138
+ try:
139
+ import pytetwild # noqa: F401 (availability check)
140
+ except ImportError:
141
+ return False
142
+ return True
143
+
144
+
145
+ # fTetWild's surface envelope, as a fraction of the bounding-box diagonal
146
+ TETWILD_EPSILON = 1e-3
147
+
148
+
149
+ def _tet_cache_dir():
150
+ """Where volume meshes are kept, by surface: LOADPATH_TET_CACHE (a
151
+ directory, or "0" for none), else ~/.cache/loadpath/tets."""
152
+ env = os.environ.get("LOADPATH_TET_CACHE")
153
+ if env == "0":
154
+ return None
155
+ return env or os.path.join(os.path.expanduser("~"), ".cache", "loadpath", "tets")
156
+
157
+
158
+ # fTetWild meshes the part's whole bounding box at the target edge and keeps
159
+ # the inside: on GRIP's upper arm (13% of its box) it optimized ~2M tets to
160
+ # keep 237k, 250 s of a 260 s mesh. Its global edge is set this many times
161
+ # coarser, and a sizing field asks for `size` inside the part and within
162
+ # 1.5 grid steps of its surface: the same mesh in the part (228k tets, mean
163
+ # edge 1.94 vs 1.91 mm, worst-element energy 17 vs 19) in 87 s.
164
+ _COARSE_OUTSIDE = 8.0
165
+
166
+
167
+ def _inside_grid(tri, axes):
168
+ """Which points of the grid axes[0] x axes[1] x axes[2] are inside the
169
+ closed triangle soup `tri` (n, 3, 3): one ray along +x per (y, z)
170
+ column, its crossings found in 2D, parity counted per grid x. (trimesh's
171
+ mesh.contains, without embree, ran past 14 GB on 7k points of the GRIP
172
+ wrist link and took the box down; this is a few seconds.) Columns are
173
+ nudged off the grid by a fraction of a micron, so a ray never grazes an
174
+ edge exactly."""
175
+ ax, ay, az = (np.asarray(a, float) for a in axes)
176
+ out = np.zeros((len(ax), len(ay), len(az)), bool)
177
+ ylo, yhi = tri[:, :, 1].min(1), tri[:, :, 1].max(1)
178
+ zlo, zhi = tri[:, :, 2].min(1), tri[:, :, 2].max(1)
179
+ a, b, c = tri[:, 0], tri[:, 1], tri[:, 2]
180
+ for j, y in enumerate(ay + 1.37e-4):
181
+ rows = np.flatnonzero((ylo <= y) & (yhi >= y))
182
+ if not len(rows):
183
+ continue
184
+ for k, z in enumerate(az + 2.91e-4):
185
+ t = rows[(zlo[rows] <= z) & (zhi[rows] >= z)]
186
+ if not len(t):
187
+ continue
188
+ # barycentrics of (y, z) in each triangle's y-z projection
189
+ ab, ac = b[t] - a[t], c[t] - a[t]
190
+ det = ab[:, 1] * ac[:, 2] - ab[:, 2] * ac[:, 1]
191
+ ok = np.abs(det) > 1e-15
192
+ det = np.where(ok, det, 1.0)
193
+ py, pz = y - a[t, 1], z - a[t, 2]
194
+ u = (py * ac[:, 2] - pz * ac[:, 1]) / det
195
+ w = (ab[:, 1] * pz - ab[:, 2] * py) / det
196
+ hit = ok & (u >= 0) & (w >= 0) & (u + w <= 1)
197
+ if not hit.any():
198
+ continue
199
+ xs = np.sort((a[t, 0] + u * ab[:, 0] + w * ac[:, 0])[hit])
200
+ # crossings beyond each grid x: odd = inside
201
+ out[:, j, k] = (len(xs) - np.searchsorted(xs, ax, side="right")) % 2 == 1
202
+ return out
203
+
204
+
205
+ def _outside_coarse(mesh, v, size):
206
+ """fTetWild's background sizing field: (points, tets, edge lengths) on
207
+ a grid 4 elements apart, `size` inside the part and near it, growing
208
+ with distance outside (to _COARSE_OUTSIDE times). Beyond the grid,
209
+ fTetWild uses its (coarse) global edge. None if the part's inside test
210
+ fails (a mesh that is not watertight)."""
211
+ from scipy.spatial import cKDTree
212
+
213
+ h = 4.0 * size
214
+ lo, hi = v.min(0) - 2.0 * h, v.max(0) + 2.0 * h
215
+ axes = [np.arange(lo[i], hi[i] + h, h) for i in range(3)]
216
+ n = [len(a) for a in axes]
217
+ G = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
218
+ if not getattr(mesh, "is_watertight", True):
219
+ return None
220
+ inside = _inside_grid(np.asarray(mesh.triangles, float), axes).ravel()
221
+ surf = np.vstack([v, np.asarray(mesh.triangles_center)])
222
+ d = np.where(inside, 0.0, cKDTree(surf).query(G)[0])
223
+ # every grid cell the surface passes through, and its neighbours, stays
224
+ # at `size` at all 8 corners, so no element at the surface is coarser
225
+ near = 1.5 * h
226
+ val = np.minimum(size + 0.5 * np.maximum(d - near, 0.0), _COARSE_OUTSIDE * size)
227
+ # each grid cube as six tetrahedra around its main diagonal (Kuhn)
228
+ i, j, k = np.meshgrid(*(np.arange(m - 1) for m in n), indexing="ij")
229
+ base = (i * n[1] + j) * n[2] + k
230
+ base = base.ravel()
231
+ off = np.array([[a * n[1] * n[2] + b * n[2] + c for a, b, c in corner] for corner in (
232
+ ((0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)), ((0, 0, 0), (1, 0, 0), (1, 0, 1), (1, 1, 1)),
233
+ ((0, 0, 0), (0, 1, 0), (1, 1, 0), (1, 1, 1)), ((0, 0, 0), (0, 1, 0), (0, 1, 1), (1, 1, 1)),
234
+ ((0, 0, 0), (0, 0, 1), (1, 0, 1), (1, 1, 1)), ((0, 0, 0), (0, 0, 1), (0, 1, 1), (1, 1, 1)))])
235
+ tets = (base[:, None, None] + off[None]).reshape(-1, 4).astype(np.int32)
236
+ x = G[tets]
237
+ neg = np.linalg.det(x[:, 1:] - x[:, :1]) < 0
238
+ tets[neg] = tets[neg][:, [0, 2, 1, 3]]
239
+ return np.ascontiguousarray(G, np.float64), tets, np.ascontiguousarray(val, np.float64)
240
+
241
+
242
+ def _tetwild(mesh, size):
243
+ """fTetWild's mesh of `mesh`, the same one every time for the same
244
+ surface and settings.
245
+
246
+ fTetWild is not reproducible: the same surface comes back with a
247
+ different mesh on every call (4,904 vs 4,920 vertices on a small lug with
248
+ one thread, optimization and simplification off, address randomization
249
+ off; its triangle insertion already differs), and the cross-check's
250
+ peak stress moved 3-8% with it. None of that is reachable from Python,
251
+ so the mesh is kept by a hash of the surface and the settings, and the
252
+ same part is analysed on the same mesh, and reads the same, every time
253
+ after the first."""
254
+ import hashlib
255
+
256
+ import pytetwild
257
+
258
+ # copies: the binding refuses a read-only array ("incompatible function
259
+ # arguments"), and ascontiguousarray hands a read-only mesh's own back,
260
+ # which failed every tet confirm, the study falling back to voxels
261
+ v = np.array(mesh.vertices, np.float64, order="C", copy=True)
262
+ f = np.array(mesh.faces, np.int32, order="C", copy=True)
263
+ opts = dict(edge_length_abs=float(size), epsilon=TETWILD_EPSILON,
264
+ # its defaults (energy 10, 80 passes) took twice as long for
265
+ # a handful fewer elements under a tenth of regular quality
266
+ stop_energy=50.0, num_opt_iter=20)
267
+ sizing = _outside_coarse(mesh, v, size) if os.environ.get("LOADPATH_TET_SIZING", "1") != "0" else None
268
+ if sizing is not None:
269
+ opts["edge_length_abs"] = float(size) * _COARSE_OUTSIDE
270
+ key = hashlib.sha256(v.tobytes() + f.tobytes() + repr(sorted(opts.items())).encode()
271
+ + (b"sizing-v1" if sizing is not None else b"")
272
+ + str(getattr(pytetwild, "__version__", "")).encode()).hexdigest()[:24]
273
+ cache = _tet_cache_dir()
274
+ path = os.path.join(cache, key + ".npz") if cache else None
275
+ if path and os.path.exists(path):
276
+ try:
277
+ with np.load(path) as z:
278
+ return z["pts"].astype(float), z["tets"].astype(np.int64)
279
+ except (OSError, ValueError, KeyError):
280
+ pass
281
+ bg = dict(zip(("bg_vertices", "bg_tets", "bg_values"), sizing)) if sizing is not None else {}
282
+ got = _tetwild_guarded(v, f, opts, bg)
283
+ if got is None and bg:
284
+ # fTetWild went past its memory limit with the sizing field: mesh
285
+ # at the plain edge everywhere (0.55 GB on the GRIP wrist link, 0.94
286
+ # GB on the upper arm). The result is kept under this key, so the
287
+ # surface goes straight to it next time.
288
+ opts["edge_length_abs"] = float(size)
289
+ got = _tetwild_guarded(v, f, opts, {})
290
+ if got is None:
291
+ raise MemoryError(f"fTetWild needed more than {_mesher_budget() / 1e9:.1f} GB "
292
+ "(LOADPATH_TET_MESH_MEMORY sets the limit, in GB)")
293
+ pts, tets = got
294
+ pts, tets = np.asarray(pts, float), np.asarray(tets, np.int64)
295
+ x = pts[tets]
296
+ neg = np.linalg.det(x[:, 1:] - x[:, :1]) < 0
297
+ tets[neg] = tets[neg][:, [0, 2, 1, 3]]
298
+ if path:
299
+ try:
300
+ os.makedirs(cache, exist_ok=True)
301
+ tmp_path = path + f".{os.getpid()}.tmp.npz"
302
+ np.savez_compressed(tmp_path, pts=pts, tets=tets.astype(np.int32))
303
+ os.replace(tmp_path, path)
304
+ except OSError:
305
+ pass
306
+ return pts, tets
307
+
308
+
309
+ def _mesher_budget() -> float:
310
+ """Bytes fTetWild may use before it is stopped: LOADPATH_TET_MESH_MEMORY
311
+ (GB), else 60% of the memory free now, at least 2 GB."""
312
+ env = os.environ.get("LOADPATH_TET_MESH_MEMORY")
313
+ if env:
314
+ return float(env) * 1e9
315
+ from .study import _available_bytes
316
+
317
+ return max(2e9, 0.6 * (_available_bytes() or 8e9))
318
+
319
+
320
+ def _rss(pid: int) -> float | None:
321
+ """Resident bytes of process `pid` (Linux /proc, else ps), or None."""
322
+ try:
323
+ with open(f"/proc/{pid}/status") as fh:
324
+ for line in fh:
325
+ if line.startswith("VmRSS:"):
326
+ return float(line.split()[1]) * 1024.0
327
+ except OSError:
328
+ pass
329
+ try:
330
+ out = subprocess.run(["ps", "-o", "rss=", "-p", str(pid)], capture_output=True, text=True, timeout=5, check=False)
331
+ return float(out.stdout.strip()) * 1024.0 if out.stdout.strip() else None
332
+ except (OSError, ValueError, subprocess.SubprocessError):
333
+ return None
334
+
335
+
336
+ def _tetwild_main(work: str) -> None:
337
+ """Child process entry: mesh `work`/in.npz (v, f, bg_*) with the
338
+ options in `work`/opts.json, write `work`/out.npz (pts, tets)."""
339
+ import json
340
+
341
+ import pytetwild
342
+
343
+ with np.load(os.path.join(work, "in.npz")) as z:
344
+ arrays = {k: z[k] for k in z.files}
345
+ with open(os.path.join(work, "opts.json")) as fh:
346
+ opts = json.load(fh)
347
+ v, f = arrays.pop("v"), arrays.pop("f")
348
+ # fTetWild leaves files (__tracked_surface.stl, _skipped.*) in the
349
+ # working directory: run it in the scratch one
350
+ os.chdir(work)
351
+ pts, tets = pytetwild.tetrahedralize(v, f, num_threads=os.cpu_count() or 1, **opts, **arrays)
352
+ np.savez(os.path.join(work, "out.tmp.npz"), pts=np.asarray(pts, float), tets=np.asarray(tets, np.int64))
353
+ os.replace(os.path.join(work, "out.tmp.npz"), os.path.join(work, "out.npz"))
354
+
355
+
356
+ def _tetwild_guarded(v, f, opts, bg):
357
+ """fTetWild in a child process under a memory limit (_mesher_budget):
358
+ (pts, tets), or None when it went past the limit and was stopped. A
359
+ mesher that runs away in-process takes the whole study (and on a
360
+ shared box, everything else) down with it; in a child it costs one
361
+ retry. (A plain subprocess, not
362
+ multiprocessing: spawning re-runs the caller's script without a
363
+ __main__ guard.)"""
364
+ import json
365
+ import sys
366
+
367
+ budget = _mesher_budget()
368
+ pkg_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
369
+ env = dict(os.environ, PYTHONPATH=os.pathsep.join([pkg_parent] + [x for x in [os.environ.get("PYTHONPATH")] if x]))
370
+ with tempfile.TemporaryDirectory() as work:
371
+ np.savez(os.path.join(work, "in.npz"), v=v, f=f, **bg)
372
+ with open(os.path.join(work, "opts.json"), "w") as fh:
373
+ json.dump(opts, fh)
374
+ code = ("import sys; from loadpath.crosscheck import _tetwild_main; "
375
+ "_tetwild_main(sys.argv[1])")
376
+ with open(os.path.join(work, "log.txt"), "wb") as log:
377
+ proc = subprocess.Popen([sys.executable, "-c", code, work], env=env, stdout=log, stderr=subprocess.STDOUT)
378
+ try:
379
+ while True:
380
+ try:
381
+ proc.wait(0.5)
382
+ break
383
+ except subprocess.TimeoutExpired:
384
+ pass
385
+ r = _rss(proc.pid)
386
+ if r is not None and r > budget:
387
+ return None
388
+ finally:
389
+ if proc.poll() is None:
390
+ proc.kill()
391
+ proc.wait()
392
+ out = os.path.join(work, "out.npz")
393
+ if proc.returncode != 0 or not os.path.exists(out):
394
+ with open(os.path.join(work, "log.txt"), "rb") as fh:
395
+ err = fh.read().decode(errors="replace").strip().splitlines()[-3:]
396
+ raise RuntimeError(f"fTetWild exited with code {proc.returncode}: {' '.join(err)}")
397
+ with np.load(out) as z:
398
+ return z["pts"], z["tets"]
399
+
400
+
401
+ def _tet_mesh_once(mesh, size, order, algo, size_at=None):
402
+ import gmsh
403
+
404
+ with tempfile.TemporaryDirectory() as tmp:
405
+ stl = os.path.join(tmp, "part.stl")
406
+ mesh.export(stl)
407
+ gmsh.initialize(["-noenv"])
408
+ try:
409
+ gmsh.option.setNumber("General.Terminal", 0)
410
+ gmsh.merge(stl)
411
+ gmsh.model.mesh.removeDuplicateNodes() # STL repeats every vertex per triangle
412
+ # keep the triangles as given: they already are the exact surface,
413
+ # and the volume mesh grows inward from them
414
+ surfs = gmsh.model.getEntities(2)
415
+ loop = gmsh.model.geo.addSurfaceLoop([s[1] for s in surfs])
416
+ gmsh.model.geo.addVolume([loop])
417
+ gmsh.model.geo.synchronize()
418
+ gmsh.option.setNumber("Mesh.MeshSizeMax", size)
419
+ gmsh.option.setNumber("Mesh.MeshSizeMin", size * 0.25)
420
+ if size_at is not None:
421
+ # element size as a function of position (adaptive refinement)
422
+ gmsh.model.mesh.setSizeCallback(lambda dim, tag, x, y, z, lc: min(lc, size_at(x, y, z)))
423
+ gmsh.option.setNumber("Mesh.Algorithm3D", algo) # 1 Delaunay, 10 HXT
424
+ gmsh.option.setNumber("Mesh.ElementOrder", order)
425
+ gmsh.option.setNumber("Mesh.SecondOrderLinear", 1) # mid-nodes on the straight edge: the surface is facets
426
+ gmsh.model.mesh.generate(3)
427
+ tags, coords, _ = gmsh.model.mesh.getNodes()
428
+ pts = coords.reshape(-1, 3)
429
+ index = {int(t): i for i, t in enumerate(tags)}
430
+ types, _, nodes = gmsh.model.mesh.getElements(3)
431
+ n_per, code = (4, 4) if order == 1 else (10, 11)
432
+ k = list(types).index(code)
433
+ tets = np.array([index[int(t)] for t in nodes[k]]).reshape(-1, n_per)
434
+ used = np.unique(tets)
435
+ remap = -np.ones(len(pts), int)
436
+ remap[used] = np.arange(len(used))
437
+ pts, tets = pts[used], remap[tets]
438
+ finally:
439
+ gmsh.finalize()
440
+ return pts, tets
441
+
442
+
443
+ def _boundary_nodes(tets):
444
+ """Vertices on the mesh's boundary: of the faces only one tet owns.
445
+ (A distance to the input surface took 7 s on the motor-mount and misses
446
+ nodes fTetWild leaves inside its envelope.)"""
447
+ f = np.sort(tets[:, [[1, 2, 3], [0, 2, 3], [0, 1, 3], [0, 1, 2]]].reshape(-1, 3), axis=1)
448
+ uniq, count = np.unique(f, axis=0, return_counts=True)
449
+ return np.unique(uniq[count == 1])
450
+
451
+
452
+ def _region_nodes(study, shape, pts, tol):
453
+ return np.nonzero(shape.sdf(pts) <= tol)[0]
454
+
455
+
456
+ def _load_vectors(study, pts, surf, tol):
457
+ """Per load case: nodal force array (n, 3), and the load node indices."""
458
+ cases = []
459
+ for case in study.load_cases:
460
+ f = np.zeros((len(pts), 3))
461
+ nodes_all = []
462
+ for ld in case.loads:
463
+ if ld.pressure:
464
+ raise ValueError("the tetrahedral cross-check does not apply pressure loads yet")
465
+ shape = ld.shape if ld.shape is not None else study.region_shape(ld.region)[0]
466
+ nodes = np.intersect1d(_region_nodes(study, shape, pts, tol), surf)
467
+ if len(nodes) == 0:
468
+ raise ValueError(f"load in case {case.name!r} touches no surface node of the part")
469
+ nodes_all.append(nodes)
470
+ force = np.asarray(ld.force, float)
471
+ if ld.distribution == "bearing":
472
+ # as domain.py: the shaft pushes on the half of the bore facing
473
+ # the force, as a cosine over it
474
+ from .domain import _cylinder_of
475
+
476
+ p0, ax = _cylinder_of(study, ld.region, ld.shape, f"load in case {case.name!r}")
477
+ axial = (force @ ax) * ax
478
+ radial = force - axial
479
+ if np.any(radial):
480
+ d = pts[nodes] - p0
481
+ rad = d - (d @ ax)[:, None] * ax
482
+ rad /= np.maximum(np.linalg.norm(rad, axis=1, keepdims=True), 1e-12)
483
+ w = np.maximum(rad @ (radial / np.linalg.norm(radial)), 0.0)
484
+ if w.sum() > 0:
485
+ f[nodes] += (w / w.sum())[:, None] * radial[None]
486
+ force = axial
487
+ if np.any(force):
488
+ f[nodes] += force / len(nodes)
489
+ if ld.moment is not None and np.any(ld.moment):
490
+ # a couple as a rigid rotation's forces about the nodes' centroid
491
+ r = pts[nodes] - pts[nodes].mean(axis=0)
492
+ m = np.asarray(ld.moment, float)
493
+ j = np.einsum("ij,ij->", r, r) * np.eye(3) - r.T @ r
494
+ w = np.linalg.solve(j + 1e-9 * np.trace(j) * np.eye(3), m)
495
+ f[nodes] += np.cross(w, r)
496
+ cases.append((case.name, f, np.unique(np.concatenate(nodes_all))))
497
+ return cases
498
+
499
+
500
+ # 4-point Gauss rule on a tetrahedron (exact for degree 2: a P2 stiffness
501
+ # integrand is degree 2), in barycentric coordinates.
502
+ _QA, _QB = 0.5854101966249685, 0.1381966011250105
503
+ _QUAD = np.array([[_QA, _QB, _QB, _QB], [_QB, _QA, _QB, _QB], [_QB, _QB, _QA, _QB], [_QB, _QB, _QB, _QA]])
504
+ _EDGES = np.array([[0, 1], [1, 2], [0, 2], [0, 3], [1, 3], [2, 3]])
505
+
506
+
507
+ def _p2_mesh(pts, tets):
508
+ """Quadratic tets on the linear mesh: edge mid-nodes appended after the
509
+ vertices. Returns (nodes, conn10) with conn10 = 4 vertices + 6 edges in
510
+ _EDGES order."""
511
+ e = np.sort(tets[:, _EDGES].reshape(-1, 2), axis=1)
512
+ uniq, inv = np.unique(e, axis=0, return_inverse=True)
513
+ mids = 0.5 * (pts[uniq[:, 0]] + pts[uniq[:, 1]])
514
+ conn = np.hstack([tets, len(pts) + inv.reshape(-1, 6)])
515
+ return np.vstack([pts, mids]), conn, uniq
516
+
517
+
518
+ def _grads(nodes, conn):
519
+ """Per element: volume and the constant gradients of the 4 barycentrics."""
520
+ x = nodes[conn[:, :4]] # (ne, 4, 3)
521
+ j = (x[:, 1:] - x[:, :1]).transpose(0, 2, 1) # columns = edge vectors
522
+ jinv = np.linalg.inv(j) # rows: grad L1..L3
523
+ g = np.empty((len(conn), 4, 3))
524
+ g[:, 1:] = jinv
525
+ g[:, 0] = -jinv.sum(axis=1)
526
+ vol = np.abs(np.linalg.det(j)) / 6.0
527
+ return vol, g
528
+
529
+
530
+ def _shape_grads(g, lam):
531
+ """Gradients of the 10 P2 shape functions at barycentric point `lam`."""
532
+ ne = len(g)
533
+ d = np.empty((ne, 10, 3))
534
+ for i in range(4):
535
+ d[:, i] = (4.0 * lam[i] - 1.0) * g[:, i]
536
+ for k, (a, b) in enumerate(_EDGES):
537
+ d[:, 4 + k] = 4.0 * (lam[b] * g[:, a] + lam[a] * g[:, b])
538
+ return d
539
+
540
+
541
+ def _b_matrices(d):
542
+ """Strain-displacement matrices (ne, 6, 30) from shape gradients (ne, 10, 3);
543
+ dofs are node-interleaved (u0x, u0y, u0z, u1x, ...)."""
544
+ ne = len(d)
545
+ b = np.zeros((ne, 6, 30))
546
+ for n in range(10):
547
+ dx, dy, dz = d[:, n, 0], d[:, n, 1], d[:, n, 2]
548
+ c = 3 * n
549
+ b[:, 0, c], b[:, 1, c + 1], b[:, 2, c + 2] = dx, dy, dz
550
+ b[:, 3, c], b[:, 3, c + 1] = dy, dx
551
+ b[:, 4, c + 1], b[:, 4, c + 2] = dz, dy
552
+ b[:, 5, c], b[:, 5, c + 2] = dz, dx
553
+ return b
554
+
555
+
556
+ def _elastic(E, nu):
557
+ """6x6 elasticity (engineering shear, the order of fem._VOIGT, which
558
+ _b_matrices shares): isotropic for a Poisson's ratio, transversely
559
+ isotropic about the build direction for a fem.Layered, exactly the
560
+ matrix the voxel verify solves with."""
561
+ from .fem import elastic_c
562
+
563
+ return elastic_c(E, nu)
564
+
565
+
566
+ def _node_pattern(conn, n, chunk=200_000):
567
+ """Which nodes share an element: (indptr, indices) of the node graph,
568
+ each row sorted: the pattern of E^T E for the element-node incidence E,
569
+ summed over chunks of elements (a sort of every element's 100 node
570
+ pairs took 73 s on the upper arm; this, a few)."""
571
+ import scipy.sparse as sp
572
+
573
+ g = None
574
+ m = conn.shape[1]
575
+ for s0 in range(0, len(conn), chunk):
576
+ cc = conn[s0:s0 + chunk]
577
+ e = sp.csr_matrix((np.ones(cc.size, np.float32), cc.ravel(), np.arange(0, cc.size + 1, m)),
578
+ shape=(len(cc), n))
579
+ part = (e.T.tocsr() @ e).tocsr()
580
+ part.data[:] = 1.0
581
+ g = part if g is None else ((g + part).tocsr())
582
+ if g is not part:
583
+ g.data[:] = 1.0
584
+ g.sort_indices()
585
+ return g.indptr.astype(np.int64), g.indices.astype(np.int32)
586
+
587
+
588
+ def _scatter(cc, ke, nptr, nind, data):
589
+ """Add element matrices `ke` (ne, 3m, 3m) on nodes `cc` (ne, m) into
590
+ `data` of the dof CSR expanded from the node graph (nptr, nind): row
591
+ 3i+c starts at 9 nptr[i] + 3 deg(i) c, and holds node i's deg(i)
592
+ neighbours x 3 components."""
593
+ ne, m = cc.shape
594
+ for e in range(ne):
595
+ for a in range(m):
596
+ i = cc[e, a]
597
+ lo, hi = nptr[i], nptr[i + 1]
598
+ deg = hi - lo
599
+ for b in range(m):
600
+ j = cc[e, b]
601
+ l, h = lo, hi
602
+ while l < h: # nind[lo:hi] is sorted
603
+ mid = (l + h) >> 1
604
+ if nind[mid] < j:
605
+ l = mid + 1
606
+ else:
607
+ h = mid
608
+ off = 3 * (l - lo)
609
+ for ci in range(3):
610
+ base = 9 * lo + 3 * deg * ci + off
611
+ for cj in range(3):
612
+ data[base + cj] += ke[e, 3 * a + ci, 3 * b + cj]
613
+
614
+
615
+ def _eliminate(indptr, indices, data, fixed):
616
+ """Zero the rows and columns of held dofs in a CSR, unit diagonal on
617
+ theirs (in place, without a row index per entry)."""
618
+ for i in range(len(indptr) - 1):
619
+ fi = fixed[i]
620
+ for jj in range(indptr[i], indptr[i + 1]):
621
+ j = indices[jj]
622
+ if fi or fixed[j]:
623
+ data[jj] = 1.0 if (fi and j == i) else 0.0
624
+
625
+
626
+ try:
627
+ import numba as _nb
628
+
629
+ _scatter = _nb.njit(cache=True, nogil=True)(_scatter)
630
+ _eliminate = _nb.njit(cache=True, nogil=True)(_eliminate)
631
+ except ImportError: # pragma: no cover - numba is a dependency
632
+ pass
633
+
634
+
635
+ def _assemble(nodes, conn, E, nu, chunk=20_000, extra=()):
636
+ """Global stiffness, vectorized over elements in chunks (skfem's
637
+ assembly took 117 of 644 s on the wrist yoke), added straight into the
638
+ final CSR: its pattern comes first from the node graph, and each
639
+ chunk's element matrices are scattered into it. (Summing per-chunk COO
640
+ matrices held three to four copies of the matrix at once: 2.07 GB to
641
+ assemble a 0.48 GB matrix on the wrist link, past 15 GB on the upper
642
+ arm.) `extra`: (nodes (k,), blocks (k, 3, 3)) added on the diagonal
643
+ node blocks (pins)."""
644
+ import scipy.sparse as sp
645
+
646
+ c = _elastic(E, nu)
647
+ n = len(nodes)
648
+ nptr, nind = _node_pattern(conn, n)
649
+ deg = np.diff(nptr)
650
+ # dof rows: node i's three rows, each 3 deg(i) long
651
+ row_len = np.repeat(3 * deg, 3)
652
+ indptr = np.zeros(3 * n + 1, np.int64)
653
+ np.cumsum(row_len, out=indptr[1:])
654
+ cols3 = (3 * nind[:, None].astype(np.int64) + np.arange(3)).ravel() # per node row, 3 deg long
655
+ indices = np.empty(indptr[-1], np.int64 if indptr[-1] > np.iinfo(np.int32).max else np.int32)
656
+ for ci in range(3):
657
+ # row 3i+ci = node i's expanded columns; written per node in one go
658
+ pos = (9 * nptr[:-1] + 3 * deg * ci)
659
+ src = 3 * nptr[:-1]
660
+ idx = np.repeat(pos - src, 3 * deg) + np.arange(len(cols3))
661
+ indices[idx] = cols3
662
+ del cols3
663
+ data = np.zeros(len(indices))
664
+ for s0 in range(0, len(conn), chunk):
665
+ cc = conn[s0:s0 + chunk]
666
+ vol, g = _grads(nodes, cc)
667
+ ke = np.zeros((len(cc), 30, 30))
668
+ for q in _QUAD:
669
+ b = _b_matrices(_shape_grads(g, q))
670
+ ke += np.einsum("eki,kl,elj->eij", b, c, b, optimize=True) * (vol / 4.0)[:, None, None]
671
+ _scatter(np.ascontiguousarray(cc, np.int64), ke, nptr, nind, data)
672
+ for idx, blocks in extra:
673
+ idx = np.asarray(idx, np.int64)
674
+ _scatter(idx[:, None], np.ascontiguousarray(blocks, np.float64), nptr, nind, data)
675
+ if indices.dtype == np.int32 and indptr[-1] <= np.iinfo(np.int32).max:
676
+ indptr = indptr.astype(np.int32)
677
+ k = sp.csr_matrix((data, indices, indptr), shape=(3 * n, 3 * n))
678
+ k.has_sorted_indices = True
679
+ return k
680
+
681
+
682
+ def _solve(nodes, conn, E, nu, fixed_dof, springs, loads, n_vertices, edges):
683
+ """P2 elasticity by CG with a p-multigrid preconditioner: symmetric
684
+ Gauss-Seidel on the quadratic system, and a coarse correction on the
685
+ linear (vertex-only) problem, Galerkin-projected and solved by node-
686
+ blocked smoothed-aggregation AMG with the six rigid-body modes.
687
+ Aggregation AMG coarsens the quadratic system's edge nodes badly: on the
688
+ wrist yoke it took 166 iterations directly and 82 this way (22 s vs 38 s
689
+ per case). `fixed_dof` marks held dofs; `springs` (or None; _pin_springs)
690
+ is added to the stiffness (pins)."""
691
+ import scipy.sparse as sp
692
+ import scipy.sparse.linalg as spla
693
+ from pyamg.relaxation.relaxation import gauss_seidel
694
+
695
+ from .fem import amg_solver
696
+
697
+ k = _assemble(nodes, conn, E, nu, extra=springs or ())
698
+ n = len(nodes)
699
+ # held dofs eliminated in place, not by slicing out the free block (a
700
+ # second copy of the matrix): their rows and columns zeroed and a unit
701
+ # diagonal. With zero load there, CG and the smoother keep them at zero,
702
+ # and every other dof sees exactly the free block.
703
+ kff = k
704
+ del k
705
+ _eliminate(kff.indptr, kff.indices, kff.data, fixed_dof)
706
+ free_mask = ~fixed_dof
707
+ # P1 -> P2 prolongation: vertices copy, edge nodes average their ends
708
+ nv = n_vertices
709
+ rows, cols, vals = [], [], []
710
+ for c in range(3):
711
+ rows.append(3 * np.arange(nv) + c)
712
+ cols.append(3 * np.arange(nv) + c)
713
+ vals.append(np.ones(nv))
714
+ for e in range(2):
715
+ rows.append(3 * (nv + np.arange(len(edges))) + c)
716
+ cols.append(3 * edges[:, e] + c)
717
+ vals.append(np.full(len(edges), 0.5))
718
+ p = sp.csr_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(3 * n, 3 * nv))
719
+ p = (sp.diags(free_mask.astype(float)) @ p).tocsr()
720
+ p.eliminate_zeros()
721
+ used = np.asarray(p.sum(axis=0)).ravel() > 0
722
+ p = p[:, used].tocsr()
723
+ # the Galerkin product by blocks of rows: P^T K whole is about as big as
724
+ # K itself (2.5 GB more on the upper arm, the solve's peak)
725
+ a1 = None
726
+ step = max(1, -(-kff.shape[0] // 16))
727
+ for r0 in range(0, kff.shape[0], step):
728
+ part = (p[r0:r0 + step].T @ (kff[r0:r0 + step] @ p)).tocsr()
729
+ a1 = part if a1 is None else a1 + part
730
+ a1 = a1.tocsr()
731
+ cn = np.nonzero(used)[0]
732
+ blocked = a1.shape[0] % 3 == 0 and np.all(np.bincount(cn // 3, minlength=nv)[np.unique(cn // 3)] == 3)
733
+ # seeded (fem.amg_solver): pyamg draws its setup from np.random
734
+ ml = amg_solver(a1.tobsr(blocksize=(3, 3)) if blocked else a1,
735
+ B=_rigid_modes(nodes[cn // 3], cn % 3), coarse_solver="splu", max_coarse=3000)
736
+
737
+ def precond(r):
738
+ r = np.asarray(r, dtype=np.float64).ravel()
739
+ x = np.zeros_like(r)
740
+ # one symmetric sweep each side: 106 iterations at 0.17 s beat two
741
+ # sweeps' 82 at 0.28 s (wrist yoke)
742
+ gauss_seidel(kff, x, r, iterations=1, sweep="symmetric")
743
+ x += p @ ml.solve(p.T @ (r - kff @ x), maxiter=1, cycle="V", tol=1e-12)
744
+ gauss_seidel(kff, x, r, iterations=1, sweep="symmetric")
745
+ return x
746
+ m = spla.LinearOperator(kff.shape, matvec=precond)
747
+
748
+ def one(f):
749
+ fn = np.zeros((n, 3))
750
+ fn[: len(f)] = f
751
+ # 1e-7 is six digits of stress; 1e-9 cost a third more iterations
752
+ u, _info = spla.cg(kff, np.where(free_mask, fn.ravel(), 0.0), M=m, rtol=1e-7, maxiter=2000)
753
+ u[fixed_dof] = 0.0
754
+ return u.reshape(n, 3)
755
+ fs = [f for _n, f, _ in loads]
756
+ from . import amgfast
757
+
758
+ if len(fs) < 2 or not amgfast.installed():
759
+ # pyamg's own Gauss-Seidel holds the GIL: threads would only contend
760
+ return [one(f) for f in fs]
761
+ # cases on threads (amgfast's smoother releases the GIL), each its own
762
+ # CG, so the same numbers; one cycle first factors the coarsest level
763
+ # once (pyamg does it lazily, and each thread would make its own)
764
+ precond(np.zeros(kff.shape[0]))
765
+ from concurrent.futures import ThreadPoolExecutor
766
+
767
+ with ThreadPoolExecutor(min(len(fs), 4)) as ex:
768
+ return list(ex.map(one, fs))
769
+
770
+
771
+ # a pin's stiffness per node, per unit modulus times element size, radially
772
+ # and along the axis: stiff against the elements' own (about E h), not so
773
+ # stiff that CG notices
774
+ _PIN_STIFFNESS = 1e3
775
+
776
+
777
+ def _pin_springs(blocks, e_h, n):
778
+ """Pin stiffness as (nodes, 3x3 blocks) for _assemble's `extra`, or None."""
779
+ if not blocks:
780
+ return None
781
+ return [(idx, b * (_PIN_STIFFNESS * e_h)) for idx, b in blocks]
782
+
783
+
784
+ def _rigid_modes(xyz, comp):
785
+ """The six rigid-body modes of the free dofs, the near-null space AMG
786
+ needs to coarsen elasticity well."""
787
+ n = len(comp)
788
+ b = np.zeros((n, 6))
789
+ for c in range(3):
790
+ b[comp == c, c] = 1.0
791
+ x, y, z = xyz.T
792
+ # rotations about x, y, z: (0,-z,y), (z,0,-x), (-y,x,0)
793
+ b[comp == 1, 3], b[comp == 2, 3] = -z[comp == 1], y[comp == 2]
794
+ b[comp == 0, 4], b[comp == 2, 4] = z[comp == 0], -x[comp == 2]
795
+ b[comp == 0, 5], b[comp == 1, 5] = -y[comp == 0], x[comp == 1]
796
+ return b
797
+
798
+
799
+ def _nodal_von_mises(nodes, conn, u, E, nu, n_vertices, normal=None, chunk=50_000):
800
+ """Von Mises at each vertex: the P2 field's stress at each element's own
801
+ vertices, averaged over the elements sharing it. With `normal` (the
802
+ build direction, unit), also the normal stress across the layers there,
803
+ (vm, sn)."""
804
+ c = _elastic(E, nu)
805
+ acc = np.zeros(n_vertices)
806
+ acc_n = np.zeros(n_vertices)
807
+ cnt = np.zeros(n_vertices)
808
+ # in chunks: the strain matrices of every element at once are 1.4 kB
809
+ # each (0.7 GB on the upper arm)
810
+ for s0 in range(0, len(conn), chunk):
811
+ cc = conn[s0:s0 + chunk]
812
+ _vol, g = _grads(nodes, cc)
813
+ ue = u.reshape(-1, 3)[cc].reshape(len(cc), 30)
814
+ for i in range(4):
815
+ lam = np.zeros(4)
816
+ lam[i] = 1.0
817
+ b = _b_matrices(_shape_grads(g, lam))
818
+ sig = np.einsum("kl,eli,ei->ek", c, b, ue, optimize=True)
819
+ sx, sy, sz, txy, tyz, tzx = sig.T
820
+ vm = np.sqrt(0.5 * ((sx - sy) ** 2 + (sy - sz) ** 2 + (sz - sx) ** 2) + 3 * (txy ** 2 + tyz ** 2 + tzx ** 2))
821
+ np.add.at(acc, cc[:, i], vm)
822
+ if normal is not None:
823
+ n = normal
824
+ # n . sigma . n, as verify.py reads it (shear terms counted twice)
825
+ sn = (sx * n[0] ** 2 + sy * n[1] ** 2 + sz * n[2] ** 2
826
+ + 2.0 * (txy * n[0] * n[1] + tyz * n[1] * n[2] + tzx * n[2] * n[0]))
827
+ np.add.at(acc_n, cc[:, i], sn)
828
+ np.add.at(cnt, cc[:, i], 1)
829
+ vm = acc / np.maximum(cnt, 1)
830
+ if normal is None:
831
+ return vm
832
+ return vm, acc_n / np.maximum(cnt, 1)
833
+
834
+
835
+ def crosscheck(study: Study, mesh, size: float | None = None, contact_band: float | None = None,
836
+ backend: str = "native", adaptive: bool = False, mesher: str | None = None) -> dict:
837
+ """Independent analysis of `mesh` (trimesh) under the study's cases.
838
+ Returns {safetyFactor, cases: [{name, peakStress, peakAt, deflection}],
839
+ nodes, elements, seconds, timings, backend, mesher}.
840
+
841
+ backend: "native" (P2 here), "ccx" (CalculiX), or "both" on the same
842
+ mesh: then the native result carries `calculix` (CalculiX's) and
843
+ `agreement` (CalculiX / native per case). Supports hold the axes the
844
+ study gives them (`fix`), and pins hold radially and axially.
845
+ mesher: "tetwild" (default when pytetwild is installed) or "gmsh"."""
846
+ t0 = time.time()
847
+ mat = study.material
848
+ # the voxel verify's elasticity and bond check (verify.py), so both read
849
+ # a layered part the same way
850
+ el = study.elastic(mat)
851
+ proc = study.process
852
+ normal, interlayer = None, 1.0
853
+ if proc is not None and proc.build not in (None, "auto") and proc.interlayer < 1.0:
854
+ from .am import build_vector
855
+
856
+ normal = build_vector(proc.build)
857
+ normal = normal / np.linalg.norm(normal)
858
+ interlayer = float(proc.interlayer)
859
+ asked = size or element_size(study)
860
+ # an explicit size is the caller's; the default is coarsened to fit memory
861
+ size = asked if size else fit_size(float(mesh.volume), asked)
862
+ band = contact_band if contact_band is not None else 2.0 * size
863
+ if adaptive:
864
+ # (Off by default: the volume mesh grows from the STL's own surface
865
+ # triangles, so on the wrist yoke this cut elements by only 12% and
866
+ # the extra pass cost more than it saved.)
867
+ # Two passes: the whole part at twice the element size finds where
868
+ # stress concentrates; the answer comes from a mesh that is fine
869
+ # (`size`) within a few elements of those places and coarse
870
+ # elsewhere. Peaks sit at a handful of fillets, so a uniformly fine
871
+ # mesh spends most of its elements where nothing happens.
872
+ coarse = crosscheck(study, mesh, size=2.0 * size, contact_band=band, backend=backend, adaptive=False)
873
+ hot = np.array([h for c in coarse["cases"] for h in c["hotspots"]])
874
+ radius = 6.0 * size
875
+ from scipy.spatial import cKDTree as _T
876
+ tree = _T(hot)
877
+
878
+ def size_at(x, y, z):
879
+ d = tree.query([x, y, z])[0]
880
+ return size if d < radius else size + (2.5 * size - size) * min(1.0, (d - radius) / radius)
881
+ pts, tets = _tet_mesh(mesh, 2.5 * size, size_at=size_at)
882
+ else:
883
+ pts, tets = _tet_mesh(mesh, size, mesher=mesher)
884
+ timings = {"mesh": time.time() - t0}
885
+ from scipy.spatial import cKDTree
886
+ surf = _boundary_nodes(tets)
887
+ tol = 0.6 * size
888
+ loads = _load_vectors(study, pts, surf, tol)
889
+
890
+ t1 = time.time()
891
+ nodes, conn, edges = _p2_mesh(pts, tets)
892
+ # supports on the quadratic mesh: an edge node is held when both its
893
+ # ends are; per axis as the study says (a sliding support holds one)
894
+ fixed_dof = np.zeros(3 * len(nodes), bool)
895
+ supports, pin_blocks, fixed_nodes = [], [], []
896
+ for i, sup in enumerate(study.supports):
897
+ sh = sup.shape if sup.shape is not None else study.region_shape(sup.region)[0]
898
+ vn = np.intersect1d(_region_nodes(study, sh, pts, tol), surf)
899
+ if len(vn) == 0:
900
+ raise ValueError(f"supports[{i}] touches no surface node of the part")
901
+ on = np.zeros(len(pts), bool)
902
+ on[vn] = True
903
+ held = np.concatenate([vn, len(pts) + np.nonzero(on[edges[:, 0]] & on[edges[:, 1]])[0]])
904
+ fixed_nodes.append(vn)
905
+ for a in range(3):
906
+ if sup.fix[a]:
907
+ fixed_dof[3 * held + a] = True
908
+ pin = None
909
+ if sup.pin:
910
+ # held radially and along the axis, free to turn about it
911
+ pin = (np.asarray(sh.p0, float), np.asarray(sh.axis, float) / np.linalg.norm(sh.axis))
912
+ d = nodes[held] - pin[0]
913
+ rad = d - (d @ pin[1])[:, None] * pin[1]
914
+ rad /= np.maximum(np.linalg.norm(rad, axis=1, keepdims=True), 1e-12)
915
+ pin_blocks.append((held, rad[:, :, None] * rad[:, None, :] + np.outer(pin[1], pin[1])[None]))
916
+ supports.append((held, sup.fix, pin))
917
+ fixed_nodes = np.unique(np.concatenate(fixed_nodes))
918
+ contact = np.unique(np.concatenate([fixed_nodes] + [l[2] for l in loads]))
919
+ far = cKDTree(pts[contact]).query(pts, distance_upper_bound=band)[0] == np.inf
920
+
921
+ def summarize(results):
922
+ cases = []
923
+ sfs, sfs_raw = [], []
924
+ for (name, f, lnodes), res in zip(loads, results):
925
+ u, vm = res[0], res[1]
926
+ sn = res[2] if len(res) > 2 else None
927
+ i = int(np.argmax(np.where(far, vm, -1)))
928
+ # where the stress is within half of the peak: the next pass refines there
929
+ order = np.argsort(-np.where(far, vm, -1))[:200]
930
+ hot = pts[order[vm[order] > 0.5 * vm[i]]]
931
+ peak = float(vm[i])
932
+ raw = mat.strength / peak if peak > 0 else float("inf")
933
+ row = {}
934
+ if sn is not None:
935
+ # layers part along their bond: tension across them against
936
+ # interlayer x strength, beside von Mises against strength
937
+ # (verify.py's check, the same allowance)
938
+ tens = np.where(far, np.maximum(sn, 0.0), 0.0)
939
+ j = int(np.argmax(tens))
940
+ inter = float(tens[j])
941
+ raw_i = interlayer * mat.strength / inter if inter > 0 else float("inf")
942
+ row = {"interlayerStress": inter, "interlayerAt": pts[j].tolist(),
943
+ "interlayerSafetyFactor": raw_i / STRESS_ALLOWANCE}
944
+ raw = min(raw, raw_i)
945
+ sfs_raw.append(raw)
946
+ sfs.append(raw / STRESS_ALLOWANCE)
947
+ cases.append({"name": name, "peakStress": peak, "peakAt": pts[i].tolist(),
948
+ "hotspots": hot.tolist(),
949
+ "deflection": float(np.linalg.norm(u[lnodes].mean(axis=0))),
950
+ "maxDisplacement": float(np.linalg.norm(u, axis=1).max()),
951
+ "safetyFactor": raw / STRESS_ALLOWANCE, **row})
952
+ return {"safetyFactor": min(sfs), "safetyFactorNoAllowance": min(sfs_raw), "cases": cases}
953
+
954
+ out = {}
955
+ if backend in ("native", "both"):
956
+ us = _solve(nodes, conn, mat.E, el, fixed_dof, _pin_springs(pin_blocks, mat.E * size, len(nodes)),
957
+ loads, len(pts), edges)
958
+ if normal is None:
959
+ out["native"] = summarize([(u, _nodal_von_mises(nodes, conn, u.ravel(), mat.E, el, len(pts)))
960
+ for u in us])
961
+ else:
962
+ out["native"] = summarize([(u, *_nodal_von_mises(nodes, conn, u.ravel(), mat.E, el, len(pts), normal))
963
+ for u in us])
964
+ timings["native"] = time.time() - t1
965
+ if backend in ("ccx", "both"):
966
+ # the same quadratic mesh, held nodes and nodal forces: only the
967
+ # solver and its stress recovery differ (a layered material as
968
+ # engineering constants in the frame of its layers)
969
+ t2 = time.time()
970
+ out["ccx"] = summarize(_solve_ccx(mat, nodes, conn, supports, loads, len(pts), el, normal))
971
+ timings["ccx"] = time.time() - t2
972
+ if not out:
973
+ raise ValueError(f"backend must be 'native', 'ccx' or 'both', not {backend!r}")
974
+ result = dict(out.get("native") or out["ccx"])
975
+ if len(out) == 2:
976
+ # both solvers on one mesh: the native answer, CalculiX's beside it,
977
+ # and how far apart they are (ratio CalculiX / native per case)
978
+ result["calculix"] = out["ccx"]
979
+ result["agreement"] = [{"name": a["name"], "peakStress": b["peakStress"] / a["peakStress"],
980
+ "deflection": b["deflection"] / a["deflection"]}
981
+ for a, b in zip(out["native"]["cases"], out["ccx"]["cases"])]
982
+ return result | {"timings": timings, "backend": backend, "nodes": int(len(pts)), "elements": int(len(tets)),
983
+ "elementSize": size, "contactBand": band, "adaptive": bool(adaptive),
984
+ **({"coarsenedFrom": asked} if size > asked else {}),
985
+ "mesher": mesher or ("tetwild" if _has_tetwild() and not adaptive else "gmsh"),
986
+ "seconds": time.time() - t0}
987
+
988
+
989
+ def _ccx_material(mat, el) -> str:
990
+ """The *MATERIAL block: isotropic, or for a fem.Layered the same
991
+ transversely isotropic constants as engineering constants in the frame
992
+ of the layers (1, 2 in the layer, 3 along the build direction), with an
993
+ *ORIENTATION putting axis 3 on the layer normal; the section names it.
994
+ fem.Layered: E3 = ez E, nu12 = nu13 = nu23 = nu (so nu31 = nu ez),
995
+ G12 = E / 2(1 + nu), G13 = G23 = gz G12."""
996
+ from .fem import Layered
997
+
998
+ if not isinstance(el, Layered):
999
+ return (f"*MATERIAL,NAME=M\n*ELASTIC\n{mat.E:.9g},{float(el):.9g}\n"
1000
+ "*SOLID SECTION,ELSET=PART,MATERIAL=M\n")
1001
+ e, nu = mat.E, el.nu
1002
+ g = e / (2.0 * (1.0 + nu))
1003
+ z = np.asarray(el.normal, float)
1004
+ # the local frame fem.Layered.c rotates from
1005
+ a = np.array([1.0, 0.0, 0.0]) if abs(z[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
1006
+ x = np.cross(a, z)
1007
+ x /= np.linalg.norm(x)
1008
+ y = np.cross(z, x)
1009
+ consts = (e, e, el.ez * e, nu, nu, nu, g, el.gz * g, el.gz * g)
1010
+ return ("*MATERIAL,NAME=M\n*ELASTIC,TYPE=ENGINEERING CONSTANTS\n"
1011
+ + ",".join(f"{v:.9g}" for v in consts[:8]) + f"\n{consts[8]:.9g}\n"
1012
+ "*ORIENTATION,NAME=LAYERS,SYSTEM=RECTANGULAR\n" + ",".join(f"{v:.9g}" for v in (*x, *y)) + "\n"
1013
+ "*SOLID SECTION,ELSET=PART,MATERIAL=M,ORIENTATION=LAYERS\n")
1014
+
1015
+
1016
+ def _solve_ccx(mat, nodes, conn, supports, loads, n_vertices, el=None, normal=None):
1017
+ """The quadratic system solved by CalculiX (ccx on PATH): one Abaqus-
1018
+ style deck, one static step per load case. Returns [(u, von Mises at
1019
+ the vertices)] per case, as the native solve does, and with `normal`
1020
+ (a layered study's build direction) the normal stress across the
1021
+ layers too, (u, vm, sn). CalculiX recovers stress at its integration
1022
+ points and extrapolates to the nodes, a different route from
1023
+ _nodal_von_mises; its nodal stresses are in the global frame."""
1024
+ exe = shutil.which("ccx")
1025
+ if exe is None:
1026
+ raise RuntimeError("CalculiX (ccx) is not on PATH; install it (Debian: apt install calculix-ccx) "
1027
+ "or use the default backend")
1028
+ solver = _CCX_SOLVER or ("SPOOLES" if 3 * len(nodes) <= _CCX_DIRECT_MAX_DOF else "ITERATIVE CHOLESKY")
1029
+ with tempfile.TemporaryDirectory() as tmp:
1030
+ deck = os.path.join(tmp, "job")
1031
+ with open(deck + ".inp", "w") as fh:
1032
+ fh.write("*NODE,NSET=NALL\n")
1033
+ np.savetxt(fh, np.column_stack([np.arange(1, len(nodes) + 1), nodes]), fmt="%d,%.9g,%.9g,%.9g")
1034
+ # _EDGES is CalculiX's C3D10 mid-node order (12, 23, 31, 14, 24, 34)
1035
+ fh.write("*ELEMENT,TYPE=C3D10,ELSET=PART\n")
1036
+ np.savetxt(fh, np.column_stack([np.arange(1, len(conn) + 1), conn + 1]), fmt="%d", delimiter=",")
1037
+ bc = []
1038
+ for k, (held, fix, pin) in enumerate(supports):
1039
+ fh.write(f"*NSET,NSET=S{k}\n")
1040
+ np.savetxt(fh, held + 1, fmt="%d")
1041
+ if pin is not None:
1042
+ # the pin exactly: cylindrical axes about it (radial,
1043
+ # tangential, axial), radial and axial held
1044
+ a, b = pin[0], pin[0] + pin[1]
1045
+ fh.write(f"*TRANSFORM,NSET=S{k},TYPE=C\n" + ",".join(f"{v:.9g}" for v in (*a, *b)) + "\n")
1046
+ bc += [f"S{k},1,1", f"S{k},3,3"]
1047
+ bc += [f"S{k},{a + 1},{a + 1}" for a in range(3) if fix[a]]
1048
+ fh.write(_ccx_material(mat, mat.nu if el is None else el) + "*BOUNDARY\n" + "\n".join(bc) + "\n")
1049
+ for _name, f, _l in loads:
1050
+ fh.write(f"*STEP\n*STATIC,SOLVER={solver}\n*CLOAD,OP=NEW\n")
1051
+ i, c = np.nonzero(f)
1052
+ np.savetxt(fh, np.column_stack([i + 1, c + 1, f[i, c]]), fmt="%d,%d,%.12g")
1053
+ fh.write("*NODE FILE\nU\n*NODE FILE\nS\n*END STEP\n")
1054
+ env = dict(os.environ)
1055
+ env.setdefault("OMP_NUM_THREADS", str(os.cpu_count() or 1))
1056
+ run = subprocess.run([exe, "job"], cwd=tmp, capture_output=True, text=True, timeout=7200, env=env)
1057
+ if run.returncode != 0 or not os.path.exists(deck + ".frd"):
1058
+ raise RuntimeError(f"CalculiX failed: {(run.stdout + run.stderr)[-2000:]}")
1059
+ steps = _read_frd(deck + ".frd", len(nodes))
1060
+ if len(steps) != len(loads):
1061
+ raise RuntimeError(f"CalculiX returned {len(steps)} steps for {len(loads)} load cases")
1062
+ if normal is None:
1063
+ return [(u, vm[:n_vertices]) for u, vm, _s in steps]
1064
+ n = np.asarray(normal, float)
1065
+ out = []
1066
+ for u, vm, sig in steps:
1067
+ sx, sy, sz, sxy, syz, szx = sig[:n_vertices].T
1068
+ sn = (sx * n[0] ** 2 + sy * n[1] ** 2 + sz * n[2] ** 2
1069
+ + 2.0 * (sxy * n[0] * n[1] + syz * n[1] * n[2] + szx * n[2] * n[0]))
1070
+ out.append((u, vm[:n_vertices], sn))
1071
+ return out
1072
+
1073
+
1074
+ # CalculiX's solver: its direct SPOOLES factorization up to this many
1075
+ # unknowns (110 s on the motor-mount's 350k, where its preconditioned CG
1076
+ # took 410 s), the CG above (memory stays linear in the mesh)
1077
+ _CCX_DIRECT_MAX_DOF = 1_000_000
1078
+ _CCX_SOLVER = None
1079
+
1080
+
1081
+ def _read_frd(path, n):
1082
+ """Per step: displacements (n, 3), nodal von Mises (n,) and the nodal
1083
+ stress (n, 6: xx, yy, zz, xy, yz, zx) from a CalculiX .frd result file
1084
+ (fixed-width records)."""
1085
+ steps, u, s, block = [], None, None, None
1086
+ with open(path) as fh:
1087
+ for line in fh:
1088
+ if line.startswith(" -4"):
1089
+ block = line.split()[1]
1090
+ if block == "DISP":
1091
+ u, s = np.zeros((n, 3)), np.zeros((n, 6))
1092
+ steps.append((u, s))
1093
+ elif line.startswith(" -1") and block in ("DISP", "STRESS"):
1094
+ i = int(line[3:13]) - 1
1095
+ vals = [float(line[13 + 12 * k:25 + 12 * k]) for k in range((len(line.rstrip()) - 13) // 12)]
1096
+ if block == "DISP":
1097
+ u[i] = vals[:3]
1098
+ else:
1099
+ s[i] = vals[:6]
1100
+ elif line.startswith(" -3"):
1101
+ block = None
1102
+ out = []
1103
+ for u, s in steps:
1104
+ sx, sy, sz, sxy, syz, szx = s.T
1105
+ out.append((u, np.sqrt(0.5 * ((sx - sy) ** 2 + (sy - sz) ** 2 + (sz - sx) ** 2)
1106
+ + 3 * (sxy ** 2 + syz ** 2 + szx ** 2)), s))
1107
+ return out