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.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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
|